dynomite 1.2.7 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +17 -2
  3. data/CHANGELOG.md +21 -0
  4. data/Gemfile +1 -5
  5. data/LICENSE.txt +22 -0
  6. data/README.md +6 -190
  7. data/Rakefile +13 -1
  8. data/dynomite.gemspec +9 -2
  9. data/exe/dynomite +14 -0
  10. data/lib/dynomite/associations/association.rb +126 -0
  11. data/lib/dynomite/associations/belongs_to.rb +35 -0
  12. data/lib/dynomite/associations/has_and_belongs_to_many.rb +19 -0
  13. data/lib/dynomite/associations/has_many.rb +19 -0
  14. data/lib/dynomite/associations/has_one.rb +19 -0
  15. data/lib/dynomite/associations/many_association.rb +257 -0
  16. data/lib/dynomite/associations/single_association.rb +157 -0
  17. data/lib/dynomite/associations.rb +248 -0
  18. data/lib/dynomite/autoloader.rb +25 -0
  19. data/lib/dynomite/cli.rb +48 -0
  20. data/lib/dynomite/client.rb +118 -0
  21. data/lib/dynomite/command.rb +89 -0
  22. data/lib/dynomite/completer/script.rb +6 -0
  23. data/lib/dynomite/completer/script.sh +10 -0
  24. data/lib/dynomite/completer.rb +159 -0
  25. data/lib/dynomite/config.rb +39 -0
  26. data/lib/dynomite/core.rb +18 -19
  27. data/lib/dynomite/engine.rb +15 -0
  28. data/lib/dynomite/erb.rb +5 -3
  29. data/lib/dynomite/error.rb +12 -0
  30. data/lib/dynomite/help/completion.md +20 -0
  31. data/lib/dynomite/help/completion_script.md +3 -0
  32. data/lib/dynomite/help/migrate.md +3 -0
  33. data/lib/dynomite/help.rb +9 -0
  34. data/lib/dynomite/install.rb +4 -0
  35. data/lib/dynomite/item/abstract.rb +15 -0
  36. data/lib/dynomite/item/components.rb +33 -0
  37. data/lib/dynomite/item/dsl.rb +101 -0
  38. data/lib/dynomite/item/id.rb +41 -0
  39. data/lib/dynomite/item/indexes/finder.rb +58 -0
  40. data/lib/dynomite/item/indexes/index.rb +21 -0
  41. data/lib/dynomite/item/indexes/primary_index.rb +18 -0
  42. data/lib/dynomite/item/indexes.rb +25 -0
  43. data/lib/dynomite/item/locking.rb +53 -0
  44. data/lib/dynomite/item/magic_fields.rb +66 -0
  45. data/lib/dynomite/item/primary_key.rb +85 -0
  46. data/lib/dynomite/item/query/delegates.rb +28 -0
  47. data/lib/dynomite/item/query/params/base.rb +42 -0
  48. data/lib/dynomite/item/query/params/expression_attribute.rb +79 -0
  49. data/lib/dynomite/item/query/params/filter.rb +41 -0
  50. data/lib/dynomite/item/query/params/function/attribute_exists.rb +21 -0
  51. data/lib/dynomite/item/query/params/function/attribute_type.rb +30 -0
  52. data/lib/dynomite/item/query/params/function/base.rb +33 -0
  53. data/lib/dynomite/item/query/params/function/begins_with.rb +32 -0
  54. data/lib/dynomite/item/query/params/function/contains.rb +7 -0
  55. data/lib/dynomite/item/query/params/function/size_fn.rb +37 -0
  56. data/lib/dynomite/item/query/params/helpers.rb +94 -0
  57. data/lib/dynomite/item/query/params/key_condition.rb +34 -0
  58. data/lib/dynomite/item/query/params.rb +115 -0
  59. data/lib/dynomite/item/query/partiql/executer.rb +72 -0
  60. data/lib/dynomite/item/query/partiql.rb +67 -0
  61. data/lib/dynomite/item/query/relation/chain.rb +125 -0
  62. data/lib/dynomite/item/query/relation/comparision_expression.rb +21 -0
  63. data/lib/dynomite/item/query/relation/comparision_map.rb +19 -0
  64. data/lib/dynomite/item/query/relation/delete.rb +38 -0
  65. data/lib/dynomite/item/query/relation/ids.rb +21 -0
  66. data/lib/dynomite/item/query/relation/math.rb +19 -0
  67. data/lib/dynomite/item/query/relation/where_field.rb +32 -0
  68. data/lib/dynomite/item/query/relation/where_group.rb +78 -0
  69. data/lib/dynomite/item/query/relation.rb +127 -0
  70. data/lib/dynomite/item/query.rb +7 -0
  71. data/lib/dynomite/item/read/find.rb +196 -0
  72. data/lib/dynomite/item/read/find_with_event.rb +42 -0
  73. data/lib/dynomite/item/read.rb +90 -0
  74. data/lib/dynomite/item/sti.rb +43 -0
  75. data/lib/dynomite/item/table_namespace.rb +43 -0
  76. data/lib/dynomite/item/typecaster.rb +106 -0
  77. data/lib/dynomite/item/waiter_methods.rb +18 -0
  78. data/lib/dynomite/item/write/base.rb +15 -0
  79. data/lib/dynomite/item/write/delete_item.rb +14 -0
  80. data/lib/dynomite/item/write/put_item.rb +99 -0
  81. data/lib/dynomite/item/write/update_item.rb +73 -0
  82. data/lib/dynomite/item/write.rb +204 -0
  83. data/lib/dynomite/item.rb +113 -286
  84. data/lib/dynomite/migration/dsl/accessor.rb +19 -0
  85. data/lib/dynomite/migration/dsl/index/base.rb +42 -0
  86. data/lib/dynomite/migration/dsl/index/gsi.rb +59 -0
  87. data/lib/dynomite/migration/dsl/index/lsi.rb +27 -0
  88. data/lib/dynomite/migration/dsl/index.rb +72 -0
  89. data/lib/dynomite/migration/dsl/primary_key.rb +62 -0
  90. data/lib/dynomite/migration/dsl/provisioned_throughput.rb +38 -0
  91. data/lib/dynomite/migration/dsl.rb +89 -142
  92. data/lib/dynomite/migration/file_info.rb +28 -0
  93. data/lib/dynomite/migration/generator.rb +30 -16
  94. data/lib/dynomite/migration/helpers.rb +7 -0
  95. data/lib/dynomite/migration/internal/migrate/create_schema_migrations.rb +17 -0
  96. data/lib/dynomite/migration/internal/models/schema_migration.rb +6 -0
  97. data/lib/dynomite/migration/runner.rb +179 -0
  98. data/lib/dynomite/migration/templates/create_table.rb +7 -23
  99. data/lib/dynomite/migration/templates/delete_table.rb +7 -0
  100. data/lib/dynomite/migration/templates/update_table.rb +3 -18
  101. data/lib/dynomite/migration.rb +53 -10
  102. data/lib/dynomite/reserved_words.rb +13 -3
  103. data/lib/dynomite/seed.rb +13 -0
  104. data/lib/dynomite/types.rb +22 -0
  105. data/lib/dynomite/version.rb +1 -1
  106. data/lib/dynomite/waiter.rb +40 -0
  107. data/lib/dynomite.rb +11 -17
  108. data/lib/generators/application_item/application_item_generator.rb +30 -0
  109. data/lib/generators/application_item/templates/application_item.rb.tt +4 -0
  110. data/lib/jets/commands/dynamodb_command.rb +29 -0
  111. data/lib/jets/commands/help/generate.md +33 -0
  112. data/lib/jets/commands/help/migrate.md +3 -0
  113. metadata +201 -17
  114. data/docs/migrations/long-example.rb +0 -127
  115. data/docs/migrations/short-example.rb +0 -40
  116. data/lib/dynomite/db_config.rb +0 -121
  117. data/lib/dynomite/errors.rb +0 -15
  118. data/lib/dynomite/log.rb +0 -15
  119. data/lib/dynomite/migration/common.rb +0 -86
  120. data/lib/dynomite/migration/dsl/base_secondary_index.rb +0 -73
  121. data/lib/dynomite/migration/dsl/global_secondary_index.rb +0 -4
  122. data/lib/dynomite/migration/dsl/local_secondary_index.rb +0 -8
  123. data/lib/dynomite/migration/executor.rb +0 -38
@@ -0,0 +1,43 @@
1
+ class Dynomite::Item
2
+ module Sti
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class_attribute :inheritance_field_name
7
+ end
8
+
9
+ class_methods do
10
+ def enable_sti(field_name='type')
11
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
12
+ def self.inherited(subclass)
13
+ field :#{field_name} # IE: field_name :type
14
+ subclass.table_name(sti_base_table_name) # IE: subclass: Car base_table: vehicles
15
+ subclass.inheritance_field_name = :#{field_name}
16
+
17
+ before_save :set_type
18
+ super
19
+ end
20
+ RUBY
21
+ end
22
+ alias inheritance_field enable_sti
23
+
24
+ def sti_base_table_name
25
+ klass = self
26
+ table_name = nil
27
+ until klass.abstract? # IE: ApplicationItem
28
+ table_name = klass.name.pluralize.gsub('::','_').underscore # vehicles
29
+ klass = klass.superclass
30
+ end
31
+ table_name
32
+ end
33
+
34
+ def sti_enabled?
35
+ inheritance_field_name.present?
36
+ end
37
+ end
38
+
39
+ def set_type
40
+ self[self.class.inheritance_field_name] = self.class.name
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,43 @@
1
+ class Dynomite::Item
2
+ module TableNamespace
3
+ def table_name(*args)
4
+ case args.size
5
+ when 0
6
+ get_table_name
7
+ when 1
8
+ set_table_name(args[0])
9
+ end
10
+ end
11
+
12
+ def set_table_name(value)
13
+ @table_name = value
14
+ end
15
+
16
+ def get_table_name
17
+ @table_name ||= self.name.pluralize.gsub('::','_').underscore
18
+ [namespace, @table_name].reject {|s| s.nil? || s.empty?}.join(namespace_separator)
19
+ end
20
+
21
+ def namespace(*args)
22
+ case args.size
23
+ when 0
24
+ get_namespace
25
+ when 1
26
+ set_namespace(args[0])
27
+ end
28
+ end
29
+
30
+ def get_namespace
31
+ return @namespace if defined?(@namespace)
32
+ @namespace = Dynomite.config.namespace || Dynomite.config.default_namespace
33
+ end
34
+
35
+ def set_namespace(value)
36
+ @namespace = value
37
+ end
38
+
39
+ def namespace_separator
40
+ Dynomite.config.namespace_separator || '_'
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,106 @@
1
+ require "time"
2
+
3
+ # aws-sdk-dynamodb handles typecast heavy-lifting. Adds typecasting support for DateTime objects.
4
+ class Dynomite::Item
5
+ class Typecaster
6
+ def initialize(model)
7
+ @model = model
8
+ end
9
+
10
+ def dump(data, depth=0)
11
+ case data
12
+ when Array
13
+ data.map! { |v| dump(v, depth+1) }
14
+ when Hash
15
+ data.each_with_object({}) do |(k,v), dumped|
16
+ if depth == 0
17
+ v = cast_to_attribute_type(k, v) # cast to attribute type if defined
18
+ end
19
+ dumped[k] = dump(v, depth+1)
20
+ dumped
21
+ end
22
+ else
23
+ data # pass through
24
+ end
25
+ end
26
+
27
+ # IE: field :price, type: :integer
28
+ # For most cases, we rely on aws-sdk-dynamodb to do the typecasting by inference.
29
+ #
30
+ # The method also helps keep track of where we cast_to_type
31
+ # It's only a few spots this provides an easy to search for it.
32
+ # See: https://rubyonjets.com/docs/database/dynamodb/model/typecasting/
33
+ FALSEY = [false, 'false', 'FALSE', 0, '0', 'f', 'F', 'off', 'OFF']
34
+ def cast_to_type(type, value, on: :read)
35
+ case type
36
+ when :integer
37
+ value.to_i
38
+ when :boolean
39
+ !FALSEY.include?(value)
40
+ when :time
41
+ cast_to_time(value, on: on)
42
+ when :string
43
+ value.to_s # force to string
44
+ else # :infer
45
+ value # passthrough and let aws-sdk-dynamodb handle it
46
+ end
47
+ end
48
+
49
+ # datetime to string
50
+ def cast_to_time(value, on: :read)
51
+ if on == :read
52
+ if value.is_a?(String)
53
+ Time.parse(value) # 2023-08-26T14:35:37Z
54
+ elsif value.respond_to?(:to_datetime) # time-like object already Time or DateTime
55
+ value
56
+ end
57
+ else # write or raw (for querying)
58
+ if value.respond_to?(:to_datetime) && !value.is_a?(String)
59
+ value.utc.strftime('%Y-%m-%dT%TZ') # Timestamp format iso8601 from AWS docs: http://amzn.to/2z98Bdc
60
+ else
61
+ value # passthrough string
62
+ end
63
+ end
64
+ end
65
+
66
+ # string to float if attribute_type is N
67
+ # number to string if attribute_type is S
68
+ def cast_to_attribute_type(attribute_name, attribute_value)
69
+ definition = @model.attribute_definitions.find { |d| d[:attribute_name] == attribute_name.to_s }
70
+ if definition
71
+ case definition[:attribute_type]
72
+ when "N" # Number
73
+ attribute_value.to_f
74
+ when "S" # String
75
+ attribute_value.to_s
76
+ when "BOOL" # Boolean
77
+ attribute_value == true
78
+ else
79
+ attribute_value # passthrough
80
+ end
81
+ else
82
+ attribute_value # passthrough
83
+ end
84
+ end
85
+
86
+ def load(data)
87
+ case data
88
+ when Array
89
+ data.map! { |v| load(v) }
90
+ when Hash
91
+ data.each_with_object({}) do |(k,v), loaded|
92
+ loaded[k] = load(v)
93
+ loaded
94
+ end
95
+ else
96
+ load_item(data)
97
+ end
98
+ end
99
+
100
+ REGEXP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/
101
+ def load_item(obj)
102
+ return obj unless obj.is_a?(String)
103
+ obj.match(REGEXP) ? Time.parse(obj) : obj
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,18 @@
1
+ class Dynomite::Item
2
+ module WaiterMethods
3
+ extend ActiveSupport::Concern
4
+
5
+ def waiter
6
+ self.class.waiter
7
+ end
8
+
9
+ class_methods do
10
+ extend Memoist
11
+
12
+ def waiter
13
+ Dynomite::Waiter.new
14
+ end
15
+ memoize :waiter
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ module Dynomite::Item::Write
2
+ class Base
3
+ include Dynomite::Client
4
+
5
+ # The attributes are in model.attrs and are held by reference
6
+ # The options are the client.delete_item or client.put_item options.
7
+ def self.call(model, options={})
8
+ new(model, options).call
9
+ end
10
+
11
+ def initialize(model, options={})
12
+ @model, @options = model, options
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ module Dynomite::Item::Write
2
+ class DeleteItem < Base
3
+ def call
4
+ key = @model.attrs.slice(@model.class.partition_key_field, @model.class.sort_key_field)
5
+ params = {
6
+ table_name: @model.class.table_name,
7
+ key: key
8
+ }
9
+ # In case you want to specify condition_expression or expression_attribute_values
10
+ params = params.merge(@options)
11
+ client.delete_item(params) # resp
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,99 @@
1
+ module Dynomite::Item::Write
2
+ class PutItem < Base
3
+ def call
4
+ # typecaster will convert the attrs to the correct types for saving to DynamoDB
5
+ item = Dynomite::Item::Typecaster.new(@model).dump(permitted_attrs)
6
+ @params = {
7
+ table_name: @model.class.table_name,
8
+ item: item
9
+ }
10
+ @params.merge!(check_unique_params)
11
+ @params.merge!(locking_params)
12
+
13
+ # put_item replaces the item fully. The resp does not contain the attrs.
14
+ log_debug(@params)
15
+ begin
16
+ client.put_item(@params)
17
+ rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
18
+ handle_conditional_check_failed_exception(e)
19
+ rescue Aws::DynamoDB::Errors::ValidationException => e
20
+ @model.reset_lock_version_was if @model.class.locking_enabled?
21
+ raise
22
+ end
23
+
24
+ @model.new_record = false
25
+ @model
26
+ end
27
+
28
+ def permitted_attrs
29
+ field_names = @model.class.field_names.map(&:to_sym)
30
+ assigned_fields = @model.attrs.keys.map(&:to_sym)
31
+ undeclared_fields = assigned_fields - field_names
32
+ declared_fields = field_names - assigned_fields
33
+
34
+ case Dynomite.config.undeclared_field_behavior.to_sym
35
+ when :allow
36
+ @model.attrs # allow
37
+ when :silent
38
+ @model.attrs.slice(*field_names)
39
+ when :error
40
+ unless undeclared_fields.empty?
41
+ raise Dynomite::Error::UndeclaredFields.new("ERROR: Saving undeclared fields not allowed: #{undeclared_fields} for #{@model.class}")
42
+ end
43
+ else # warn
44
+ unless undeclared_fields.empty?
45
+ logger.info "WARNING: Not saving undeclared fields: #{undeclared_fields}. Saving declared fields only: #{declared_fields} for #{@model.class}"
46
+ end
47
+ @model.attrs.slice(*field_names)
48
+ end
49
+ end
50
+
51
+ def handle_conditional_check_failed_exception(exception)
52
+ if @params[:condition_expression] == check_unique_condition
53
+ raise Dynomite::Error::RecordNotUnique.new(not_unique_message)
54
+ else # currently only other case is locking
55
+ raise Dynomite::Error::StaleObject.new(exception.message)
56
+ end
57
+ end
58
+
59
+ def not_unique_message
60
+ primary_key_attrs = permitted_attrs.stringify_keys.slice(@model.partition_key_field, @model.sort_key_field).symbolize_keys
61
+ primary_key_found = primary_key_attrs.keys.map(&:to_s)
62
+ "A #{@model.class.name} with the primary key #{primary_key_attrs} already exists"
63
+ end
64
+
65
+ def check_unique_params
66
+ if @model.new_record? && !@options[:put]
67
+ @params.merge!(condition_expression: check_unique_condition)
68
+ else
69
+ {}
70
+ end
71
+ end
72
+
73
+ # https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html#Expressions.ConditionExpressions.PreventingOverwrites
74
+ # Examples:
75
+ # attribute_not_exists(id)
76
+ # attribute_not_exists(category) AND attribute_not_exists(sku)
77
+ def check_unique_condition
78
+ condition_expression = @model.primary_key_fields.map do |field|
79
+ "attribute_not_exists(#{field})"
80
+ end.join(" AND ")
81
+ end
82
+
83
+ def locking_params
84
+ return {} if @params[:condition_expression] # already set from check_unique_params
85
+ return {} unless @model.class.locking_enabled?
86
+ return {} if @model._touching
87
+ field = @model.class.locking_field_name
88
+ current_version = @model.send(field) # must use send, since it was set by send. fixes .touch method
89
+ return {} if current_version == 1
90
+
91
+ previous_version = current_version - 1 # since before_save increments it
92
+ {
93
+ condition_expression: "#lock_version = :lock_version",
94
+ expression_attribute_names: {"#lock_version" => "lock_version"},
95
+ expression_attribute_values: {":lock_version" => previous_version}
96
+ }
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,73 @@
1
+ module Dynomite::Item::Write
2
+ class UpdateItem < Base
3
+ def initialize(model, options={})
4
+ super
5
+ @attrs = {}
6
+ @count_changes = {}
7
+ end
8
+
9
+ # Note: fields assigned directly with brackets are not tracked as changed
10
+ # IE: post[:title] = "test"
11
+ def call
12
+ changed_fields = @model.changed_attributes.keys
13
+ return if changed_fields.empty? # no changes to save
14
+ @attrs = @model.attrs.slice(*changed_fields)
15
+ log_debug(params)
16
+ client.update_item(params)
17
+ end
18
+
19
+ # Allows updates to specific attributes and counters
20
+ def save_changes(changes={})
21
+ @attrs = changes[:attrs] || {}
22
+ @count_changes = changes[:count_changes] || {}
23
+ log_debug(params)
24
+ client.update_item(params)
25
+ end
26
+
27
+ def params
28
+ {
29
+ expression_attribute_names: expression_attribute_names, # { "##{attribute}" => attribute },
30
+ expression_attribute_values: expression_attribute_values, # { ':attribute' => value } or { ':by' => by }
31
+ update_expression: update_expression, # "SET ##{attribute} = ##{attribute}" or "SET ##{attribute} = ##{attribute} + :by"
32
+ key: @model.primary_key,
33
+ table_name: @model.class.table_name
34
+ }
35
+ end
36
+ alias to_params params
37
+
38
+ def expression_attribute_names
39
+ attr_names = @attrs.inject({}) do |names, (name,_)|
40
+ names.merge!("##{name}" => name)
41
+ end
42
+ count_names = @count_changes.inject({}) do |names, (name,_)|
43
+ names.merge!("##{name}" => name)
44
+ end
45
+ attr_names.merge(count_names)
46
+ end
47
+
48
+ def expression_attribute_values
49
+ typecaster = Dynomite::Item::Typecaster.new(@model)
50
+ attr_values = @attrs.inject({}) do |values, (name,value)|
51
+ meta = @model.class.fields_meta[name.to_sym] # can be nil if field is not defined
52
+ type = meta ? meta[:type] : :infer
53
+ value = typecaster.cast_to_type(type, value, on: :write)
54
+ values.merge!(":#{name}" => value)
55
+ end
56
+ count_values = @count_changes.inject({}) do |values, (name,value)|
57
+ values.merge!(":#{name}" => value)
58
+ end
59
+ attr_values.merge(count_values)
60
+ end
61
+
62
+ def update_expression
63
+ expressions = []
64
+ @attrs.inject([]) do |exp, (name,_)|
65
+ expressions << "##{name} = :#{name}"
66
+ end
67
+ @count_changes.inject([]) do |exp, (name,_)|
68
+ expressions << "##{name} = ##{name} + :#{name}"
69
+ end
70
+ "SET " + expressions.join(', ')
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,204 @@
1
+ class Dynomite::Item
2
+ module Write
3
+ extend ActiveSupport::Concern
4
+
5
+ # Not using method_missing to allow usage of dot notation and assign
6
+ # @attrs because it might hide actual missing methods errors.
7
+ # DynamoDB attrs can go many levels deep so it makes less make sense to
8
+ # use to dot notation.
9
+
10
+ def save(options={})
11
+ options.reverse_merge!(validate: true)
12
+ return self if options[:validate] && !valid?
13
+
14
+ action = new_record? ? :create : :update
15
+ run_callbacks(:save) do
16
+ run_callbacks(action) do
17
+ if action == :create
18
+ PutItem.call(self, options)
19
+ else # :update
20
+ call_update_strategy(options)
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ # Similar to save, but raises an error on failed validation.
27
+ def save!(options={})
28
+ raise_error_if_invalid
29
+ save(options)
30
+ end
31
+
32
+ # post.update(title: "test", body: "body")
33
+ # post.update({title: "test", body: "body"}, {validate: false})
34
+ def update(attrs={}, options={})
35
+ self.attrs.merge!(attrs)
36
+ options.reverse_merge!(validate: true)
37
+ return false if options[:validate] && !valid?
38
+
39
+ run_callbacks(:save) do
40
+ run_callbacks(:update) do
41
+ call_update_strategy(options)
42
+ end
43
+ end
44
+ end
45
+
46
+ def call_update_strategy(options)
47
+ if Dynomite.config.update_strategy == :update_item
48
+ # Note: fields assigned directly with brackets are not tracked as changed
49
+ # IE: post[:title] = "test"
50
+ UpdateItem.call(self, options)
51
+ else # default
52
+ PutItem.call(self, options)
53
+ end
54
+ end
55
+
56
+ # Similar to update, but raises an error on failed validation.
57
+ def update!(attrs={}, options={})
58
+ raise_error_if_invalid
59
+ update(attrs, options)
60
+ end
61
+
62
+ # When you add an item, the primary key attributes are the only required attributes.
63
+ # https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/DynamoDB/Client.html#put_item-instance_method
64
+ def put(options={})
65
+ found_primary_keys = self.attrs.keys.map(&:to_s) & primary_key_fields
66
+ unless primary_key_fields.sort == found_primary_keys
67
+ raise Dynomite::Error::InvalidPut.new("Invalid put. The primary key fields #{primary_key_fields} must be present in the attrs #{attrs}")
68
+ end
69
+
70
+ options.reverse_merge!(validate: true)
71
+ return self if options[:validate] && !valid? # return self so can grab errors in invalid. save does the same thing
72
+
73
+ # Run callbacks for put so id is also set
74
+ run_callbacks(:save) do
75
+ run_callbacks(:update) do
76
+ PutItem.call(self, options.merge(put: true))
77
+ end
78
+ end
79
+ end
80
+ alias replace put
81
+
82
+ def put!(options={})
83
+ raise_error_if_invalid
84
+ put(options)
85
+ end
86
+ alias replace! put!
87
+
88
+ def destroy(options={})
89
+ run_callbacks(:destroy) do
90
+ DeleteItem.call(self, options)
91
+ end
92
+ end
93
+
94
+ def delete(options={})
95
+ DeleteItem.call(self, options)
96
+ end
97
+
98
+ attr_reader :_touching
99
+ def touch(*names, **options)
100
+ if new_record?
101
+ raise Dynomite::Error, 'cannot touch on a new item'
102
+ end
103
+
104
+ time_to_assign = options.delete(:time) || Time.now
105
+
106
+ self.updated_at = time_to_assign
107
+ names.each do |name|
108
+ attrs.send("#{name}=", time_to_assign)
109
+ end
110
+
111
+ @_touching = true
112
+ run_callbacks :touch do
113
+ UpdateItem.call(self, options)
114
+ end
115
+
116
+ self
117
+ end
118
+
119
+ # Examples:
120
+ # user.increment(:likes)
121
+ # user.increment(:likes, 2)
122
+ def increment(attribute, by = 1)
123
+ self[attribute] ||= 0
124
+ self[attribute] += by
125
+ self
126
+ end
127
+
128
+ # Increment counter. Validations and callbacks are skipped.
129
+ #
130
+ # Examples:
131
+ #
132
+ # user.increment!(:likes)
133
+ # user.increment!(:likes, 2)
134
+ # user.increment!(:likes, touch: true)
135
+ # user.increment!(:likes, touch: :created_at)
136
+ # user.increment!(:likes, touch: [:viewed_at, :created_at])
137
+ #
138
+ def increment!(attribute, by = 1, touch: nil)
139
+ increment(attribute, by)
140
+
141
+ now = Time.now
142
+ attrs = Array(touch).inject({}) do |attrs, field|
143
+ attrs.merge!(field => now)
144
+ end if touch
145
+
146
+ run_callbacks :touch do
147
+ UpdateItem.new(self).save_changes(attrs: attrs, count_changes: { attribute => by })
148
+ end
149
+
150
+ self
151
+ end
152
+
153
+ # Example:
154
+ #
155
+ # user = User.first
156
+ # user.banned? # => false
157
+ # user.toggle(:banned)
158
+ # user.banned? # => true
159
+ #
160
+ def toggle(attribute)
161
+ self[attribute] = !public_send("#{attribute}?")
162
+ self
163
+ end
164
+
165
+ def toggle!(attribute)
166
+ toggle(attribute).update_attribute(attribute, self[attribute])
167
+ end
168
+
169
+ def raise_error_if_invalid
170
+ raise Dynomite::Error::Validation, "Validation failed: #{errors.full_messages.join(', ')}" unless valid?
171
+ end
172
+
173
+ class_methods do
174
+ def put(attrs={}, &block)
175
+ new(attrs, &block).put
176
+ end
177
+
178
+ def put!(attrs={}, &block)
179
+ new(attrs, &block).put!
180
+ end
181
+
182
+ def create(attrs={}, &block)
183
+ new(attrs, &block).save
184
+ end
185
+ alias create_with create
186
+
187
+ def create!(attrs={}, &block)
188
+ new(attrs, &block).save!
189
+ end
190
+
191
+ def find_or_create_by(attrs={})
192
+ find_by(attrs) || create(attrs)
193
+ end
194
+
195
+ def find_or_create_by!(attrs={})
196
+ find_by(attrs) || create!(attrs)
197
+ end
198
+
199
+ def find_or_initialize_by(attrs)
200
+ find_by(attrs) || new(attrs)
201
+ end
202
+ end
203
+ end
204
+ end