Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #increment! and #decrement! methods to behave similarly to the Rails' ActiveRecord methods #624

Merged
merged 8 commits into from
Jan 9, 2023
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ jobs:
- rails_6_1
- rails_7_0
rubygems:
- latest
- default
bundler:
- latest
- default
ruby:
- "2.3"
- "2.4"
Expand Down
74 changes: 45 additions & 29 deletions lib/dynamoid/persistence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
require 'dynamoid/persistence/update_fields'
require 'dynamoid/persistence/upsert'
require 'dynamoid/persistence/save'
require 'dynamoid/persistence/inc'
require 'dynamoid/persistence/update_validations'

# encoding: utf-8
Expand Down Expand Up @@ -378,28 +379,21 @@ def upsert(hash_key_value, range_key_value = nil, attrs = {}, conditions = {})
# Doesn't run validations and callbacks. Doesn't update +created_at+ and
# +updated_at+ as well.
#
# When `:touch` option is passed the timestamp columns are updating. If
# attribute names are passed, they are updated along with updated_at
# attribute:
#
# User.inc('1', age: 2, touch: true)
# User.inc('1', age: 2, touch: :viewed_at)
# User.inc('1', age: 2, touch: [:viewed_at, :accessed_at])
#
# @param hash_key_value [Scalar value] hash key
# @param range_key_value [Scalar value] range key (optional)
# @param counters [Hash] value to increase by
# @option counters [true | Symbol | Array[Symbol]] :touch to update update_at attribute and optionally the specified ones
# @return [Model class] self
def inc(hash_key_value, range_key_value = nil, counters)
options = if range_key
value_casted = TypeCasting.cast_field(range_key_value, attributes[range_key])
value_dumped = Dumping.dump_field(value_casted, attributes[range_key])
{ range_key: value_dumped }
else
{}
end

Dynamoid.adapter.update_item(table_name, hash_key_value, options) do |t|
counters.each do |k, v|
value_casted = TypeCasting.cast_field(v, attributes[k])
value_dumped = Dumping.dump_field(value_casted, attributes[k])

t.add(k => value_dumped)
end
end

Inc.call(self, hash_key_value, range_key_value, counters)
self
end
end
Expand Down Expand Up @@ -736,14 +730,28 @@ def increment(attribute, by = 1)
# user.increment!(:followers_count)
# user.increment!(:followers_count, 2)
#
# Returns +true+ if a model was saved and +false+ otherwise.
# Only `attribute` is saved. The model itself is not saved. So any other
# modified attributes will still be dirty. Validations and callbacks are
# skipped.
#
# When `:touch` option is passed the timestamp columns are updating. If
# attribute names are passed, they are updated along with updated_at
# attribute:
#
# user.increment!(:followers_count, touch: true)
# user.increment!(:followers_count, touch: :viewed_at)
# user.increment!(:followers_count, touch: [:viewed_at, :accessed_at])
#
# @param attribute [Symbol] attribute name
# @param by [Numeric] value to add (optional)
# @return [true|false] whether saved model successfully
def increment!(attribute, by = 1)
# @param touch [true | Symbol | Array[Symbol]] to update update_at attribute and optionally the specified ones
# @return [Dynamoid::Document] self
def increment!(attribute, by = 1, touch: nil)
increment(attribute, by)
save
change = read_attribute(attribute) - (attribute_was(attribute) || 0)
self.class.inc(hash_key, range_value, attribute => change, touch: touch)
clear_attribute_changes(attribute)
self
end

# Change numeric attribute value.
Expand All @@ -758,9 +766,7 @@ def increment!(attribute, by = 1)
# @param by [Numeric] value to subtract (optional)
# @return [Dynamoid::Document] self
def decrement(attribute, by = 1)
self[attribute] ||= 0
self[attribute] -= by
self
increment(attribute, -by)
end

# Change numeric attribute value and save a model.
Expand All @@ -771,14 +777,24 @@ def decrement(attribute, by = 1)
# user.decrement!(:followers_count)
# user.decrement!(:followers_count, 2)
#
# Returns +true+ if a model was saved and +false+ otherwise.
# Only `attribute` is saved. The model itself is not saved. So any other
# modified attributes will still be dirty. Validations and callbacks are
# skipped.
#
# When `:touch` option is passed the timestamp columns are updating. If
# attribute names are passed, they are updated along with updated_at
# attribute:
#
# user.decrement!(:followers_count, touch: true)
# user.decrement!(:followers_count, touch: :viewed_at)
# user.decrement!(:followers_count, touch: [:viewed_at, :accessed_at])
#
# @param attribute [Symbol] attribute name
# @param by [Numeric] value to subtract (optional)
# @return [true|false] whether saved model successfully
def decrement!(attribute, by = 1)
decrement(attribute, by)
save
# @param touch [true | Symbol | Array[Symbol]] to update update_at attribute and optionally the specified ones
# @return [Dynamoid::Document] self
def decrement!(attribute, by = 1, touch: nil)
increment!(attribute, -by, touch: touch)
end

# Delete a model.
Expand Down
66 changes: 66 additions & 0 deletions lib/dynamoid/persistence/inc.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# frozen_string_literal: true

module Dynamoid
module Persistence
# @private
class Inc
def self.call(model_class, hash_key, range_key = nil, counters)
new(model_class, hash_key, range_key, counters).call
end

# rubocop:disable Style/OptionalArguments
def initialize(model_class, hash_key, range_key = nil, counters)
@model_class = model_class
@hash_key = hash_key
@range_key = range_key
@counters = counters
end
# rubocop:enable Style/OptionalArguments

def call
touch = @counters.delete(:touch)

Dynamoid.adapter.update_item(@model_class.table_name, @hash_key, update_item_options) do |t|
@counters.each do |name, value|
t.add(name => cast_and_dump_attribute_value(name, value))
end

if touch
value = DateTime.now.in_time_zone(Time.zone)

timestamp_attributes_to_touch(touch).each do |name|
t.set(name => cast_and_dump_attribute_value(name, value))
end
end
end
end

private

def update_item_options
if @model_class.range_key
range_key_options = @model_class.attributes[@model_class.range_key]
value_casted = TypeCasting.cast_field(@range_key, range_key_options)
value_dumped = Dumping.dump_field(value_casted, range_key_options)
{ range_key: value_dumped }
else
{}
end
end

def cast_and_dump_attribute_value(name, value)
value_casted = TypeCasting.cast_field(value, @model_class.attributes[name])
Dumping.dump_field(value_casted, @model_class.attributes[name])
end

def timestamp_attributes_to_touch(touch)
return [] unless touch

names = []
names << :updated_at if @model_class.timestamps_enabled?
names += Array.wrap(touch) if touch != true
names
end
end
end
end