lib/github/kv.rb in github-ds-0.2.11 vs lib/github/kv.rb in github-ds-0.3.0

- old
+ new

@@ -46,10 +46,11 @@ MAX_VALUE_LENGTH = 65535 KeyLengthError = Class.new(StandardError) ValueLengthError = Class.new(StandardError) UnavailableError = Class.new(StandardError) + InvalidValueError = Class.new(StandardError) class MissingConnectionError < StandardError; end attr_accessor :use_local_time @@ -250,10 +251,106 @@ sql.affected_rows > 0 } end + # increment :: String, Integer, expires: Time? -> Integer + # + # Increment the key's value by an amount. + # + # key - The key to increment. + # amount - The amount to increment the key's value by. + # The user can increment by both positive and + # negative values + # expires - When the key should expire. + # touch_on_insert - Only when expires is specified. When true + # the expires value is only touched upon + # inserts. Otherwise the record is always + # touched. + # + # Returns the key's value after incrementing. + def increment(key, amount: 1, expires: nil, touch_on_insert: false) + validate_key(key) + validate_amount(amount) if amount + validate_expires(expires) if expires + validate_touch(touch_on_insert, expires) + + expires ||= GitHub::SQL::NULL + + # This query uses a few MySQL "hacks" to ensure that the incrementing + # is done atomically and the value is returned. The first trick is done + # using the `LAST_INSERT_ID` function. This allows us to manually set + # the LAST_INSERT_ID returned by the query. Here we are able to set it + # to the new value when an increment takes place, essentially allowing us + # to do: `UPDATE...;SELECT value from key_value where key=:key` in a + # single step. + # + # However the `LAST_INSERT_ID` trick is only used when the value is + # updated. Upon a fresh insert we know the amount is going to be set + # to the amount specified. + # + # Lastly we only do these tricks when the value at the key is an integer. + # If the value is not an integer the update ensures the values remain the + # same and we raise an error. + encapsulate_error { + sql = GitHub::SQL.run(<<-SQL, key: key, amount: amount, now: now, expires: expires, touch: !touch_on_insert, connection: connection) + INSERT INTO key_values (`key`, `value`, `created_at`, `updated_at`, `expires_at`) + VALUES(:key, :amount, :now, :now, :expires) + ON DUPLICATE KEY UPDATE + `value`=IF( + concat('',`value`*1) = `value`, + LAST_INSERT_ID(IF( + `expires_at` IS NULL OR `expires_at`>=:now, + `value`+:amount, + :amount + )), + `value` + ), + `updated_at`=IF( + concat('',`value`*1) = `value`, + :now, + `updated_at` + ), + `expires_at`=IF( + concat('',`value`*1) = `value`, + IF( + :touch, + :expires, + `expires_at` + ), + `expires_at` + ) + SQL + + # The ordering of these statements is extremely important if we are to + # support incrementing a negative amount. The checks occur in this order: + # 1. Check if an update with new values occurred? If so return the result + # This could potentially result in `sql.last_insert_id` with a value + # of 0, thus it must be before the second check. + # 2. Check if an update took place but nothing changed (I.E. no new value + # was set) + # 3. Check if an insert took place. + # + # See https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html for + # more information (NOTE: CLIENT_FOUND_ROWS is set) + if sql.affected_rows == 2 + # An update took place in which data changed. We use a hack to set + # the last insert ID to be the new value. + sql.last_insert_id + elsif sql.affected_rows == 0 || (sql.affected_rows == 1 && sql.last_insert_id == 0) + # No insert took place nor did any update occur. This means that + # the value was not an integer thus not incremented. + raise InvalidValueError + elsif sql.affected_rows == 1 + # If the number of affected_rows is 1 then a new value was inserted + # thus we can just return the amount given to us since that is the + # value at the key + amount + end + } + end + # del :: String -> nil # # Deletes the specified key. Returns nil. Raises on error. # # Example: @@ -309,10 +406,32 @@ WHERE `key` = :key AND (expires_at IS NULL OR expires_at > :now) SQL } end + # mttl :: [String] -> Result<[Time | nil]> + # + # Returns the expires_at time for the specified key or nil. + # + # Example: + # + # kv.mttl(["foo", "octocat"]) + # # => #<Result value: [2018-04-23 11:34:54 +0200, nil]> + # + def mttl(keys) + validate_key_array(keys) + + Result.new { + kvs = GitHub::SQL.results(<<-SQL, :keys => keys, :now => now, :connection => connection).to_h + SELECT `key`, expires_at FROM key_values + WHERE `key` in :keys AND (expires_at IS NULL OR expires_at > :now) + SQL + + keys.map { |key| kvs[key] } + } + end + private def now use_local_time ? Time.now : GitHub::SQL::NOW end @@ -364,9 +483,22 @@ end def validate_value_length(value) if value.bytesize > MAX_VALUE_LENGTH raise ValueLengthError, "value of length #{value.length} exceeds maximum value length of #{MAX_VALUE_LENGTH}" + end + end + + def validate_amount(amount) + raise ArgumentError.new("The amount specified must be an integer") unless amount.is_a? Integer + raise ArgumentError.new("The amount specified cannot be 0") if amount == 0 + end + + def validate_touch(touch, expires) + raise ArgumentError.new("touch_on_insert must be a boolean value") unless [true, false].include?(touch) + + if touch && expires.nil? + raise ArgumentError.new("Please specify an expires value if you wish to touch on insert") end end def validate_expires(expires) unless expires.respond_to?(:to_time)