dynamoid 3.3.0 → 3.7.0

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -1
  3. data/README.md +146 -52
  4. data/lib/dynamoid.rb +1 -0
  5. data/lib/dynamoid/adapter.rb +20 -7
  6. data/lib/dynamoid/adapter_plugin/aws_sdk_v3.rb +70 -37
  7. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/batch_get_item.rb +3 -0
  8. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/create_table.rb +20 -12
  9. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/item_updater.rb +5 -4
  10. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/backoff.rb +2 -2
  11. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/limit.rb +2 -3
  12. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/middleware/start_key.rb +2 -2
  13. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/query.rb +4 -2
  14. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/scan.rb +4 -2
  15. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/table.rb +1 -0
  16. data/lib/dynamoid/adapter_plugin/aws_sdk_v3/until_past_table_status.rb +2 -1
  17. data/lib/dynamoid/application_time_zone.rb +1 -0
  18. data/lib/dynamoid/associations.rb +182 -19
  19. data/lib/dynamoid/associations/association.rb +10 -2
  20. data/lib/dynamoid/associations/belongs_to.rb +2 -1
  21. data/lib/dynamoid/associations/has_and_belongs_to_many.rb +2 -1
  22. data/lib/dynamoid/associations/has_many.rb +2 -1
  23. data/lib/dynamoid/associations/has_one.rb +2 -1
  24. data/lib/dynamoid/associations/many_association.rb +68 -23
  25. data/lib/dynamoid/associations/single_association.rb +31 -4
  26. data/lib/dynamoid/components.rb +2 -0
  27. data/lib/dynamoid/config.rb +15 -3
  28. data/lib/dynamoid/config/backoff_strategies/constant_backoff.rb +1 -0
  29. data/lib/dynamoid/config/backoff_strategies/exponential_backoff.rb +1 -0
  30. data/lib/dynamoid/config/options.rb +1 -0
  31. data/lib/dynamoid/criteria.rb +9 -1
  32. data/lib/dynamoid/criteria/chain.rb +421 -46
  33. data/lib/dynamoid/criteria/ignored_conditions_detector.rb +3 -3
  34. data/lib/dynamoid/criteria/key_fields_detector.rb +31 -10
  35. data/lib/dynamoid/criteria/nonexistent_fields_detector.rb +3 -2
  36. data/lib/dynamoid/criteria/overwritten_conditions_detector.rb +1 -1
  37. data/lib/dynamoid/dirty.rb +119 -64
  38. data/lib/dynamoid/document.rb +133 -46
  39. data/lib/dynamoid/dumping.rb +9 -0
  40. data/lib/dynamoid/dynamodb_time_zone.rb +1 -0
  41. data/lib/dynamoid/errors.rb +2 -0
  42. data/lib/dynamoid/fields.rb +251 -39
  43. data/lib/dynamoid/fields/declare.rb +86 -0
  44. data/lib/dynamoid/finders.rb +69 -32
  45. data/lib/dynamoid/identity_map.rb +6 -0
  46. data/lib/dynamoid/indexes.rb +86 -17
  47. data/lib/dynamoid/loadable.rb +2 -2
  48. data/lib/dynamoid/log/formatter.rb +26 -0
  49. data/lib/dynamoid/middleware/identity_map.rb +1 -0
  50. data/lib/dynamoid/persistence.rb +502 -104
  51. data/lib/dynamoid/persistence/import.rb +2 -1
  52. data/lib/dynamoid/persistence/save.rb +1 -0
  53. data/lib/dynamoid/persistence/update_fields.rb +5 -2
  54. data/lib/dynamoid/persistence/update_validations.rb +18 -0
  55. data/lib/dynamoid/persistence/upsert.rb +5 -3
  56. data/lib/dynamoid/primary_key_type_mapping.rb +1 -0
  57. data/lib/dynamoid/railtie.rb +1 -0
  58. data/lib/dynamoid/tasks.rb +3 -1
  59. data/lib/dynamoid/tasks/database.rb +1 -0
  60. data/lib/dynamoid/type_casting.rb +12 -2
  61. data/lib/dynamoid/undumping.rb +8 -0
  62. data/lib/dynamoid/validations.rb +6 -1
  63. data/lib/dynamoid/version.rb +1 -1
  64. metadata +48 -75
  65. data/.coveralls.yml +0 -1
  66. data/.document +0 -5
  67. data/.gitignore +0 -74
  68. data/.rspec +0 -2
  69. data/.rubocop.yml +0 -71
  70. data/.rubocop_todo.yml +0 -55
  71. data/.travis.yml +0 -44
  72. data/Appraisals +0 -22
  73. data/Gemfile +0 -8
  74. data/Rakefile +0 -46
  75. data/Vagrantfile +0 -29
  76. data/docker-compose.yml +0 -7
  77. data/dynamoid.gemspec +0 -57
  78. data/gemfiles/rails_4_2.gemfile +0 -9
  79. data/gemfiles/rails_5_0.gemfile +0 -8
  80. data/gemfiles/rails_5_1.gemfile +0 -8
  81. data/gemfiles/rails_5_2.gemfile +0 -8
  82. data/gemfiles/rails_6_0.gemfile +0 -8
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ module Dynamoid
4
4
  # This is the base module for all domain objects that need to be persisted to
5
5
  # the database as documents.
6
6
  module Document
@@ -17,28 +17,19 @@ module Dynamoid #:nodoc:
17
17
  end
18
18
 
19
19
  module ClassMethods
20
- # Set up table options, including naming it whatever you want, setting the id key, and manually overriding read and
21
- # write capacity.
22
- #
23
- # @param [Hash] options options to pass for this table
24
- # @option options [Symbol] :name the name for the table; this still gets namespaced
25
- # @option options [Symbol] :id id column for the table
26
- # @option options [Integer] :read_capacity set the read capacity for the table; does not work on existing tables
27
- # @option options [Integer] :write_capacity set the write capacity for the table; does not work on existing tables
28
- #
29
- # @since 0.4.0
20
+ # @private
30
21
  def table(options = {})
31
22
  self.options = options
32
23
  super if defined? super
33
24
  end
34
25
 
35
26
  def attr_readonly(*read_only_attributes)
36
- ActiveSupport::Deprecation.warn('[Dynamoid] .attr_readonly is deprecated! Call .find instead of')
37
27
  self.read_only_attributes.concat read_only_attributes.map(&:to_s)
38
28
  end
39
29
 
40
- # Returns the read_capacity for this table.
30
+ # Returns the read capacity for this table.
41
31
  #
32
+ # @return [Integer] read capacity units
42
33
  # @since 0.4.0
43
34
  def read_capacity
44
35
  options[:read_capacity] || Dynamoid::Config.read_capacity
@@ -46,25 +37,53 @@ module Dynamoid #:nodoc:
46
37
 
47
38
  # Returns the write_capacity for this table.
48
39
  #
40
+ # @return [Integer] write capacity units
49
41
  # @since 0.4.0
50
42
  def write_capacity
51
43
  options[:write_capacity] || Dynamoid::Config.write_capacity
52
44
  end
53
45
 
46
+ # Returns the billing (capacity) mode for this table.
47
+ #
48
+ # Could be either +provisioned+ or +on_demand+.
49
+ #
50
+ # @return [Symbol]
51
+ def capacity_mode
52
+ options[:capacity_mode] || Dynamoid::Config.capacity_mode
53
+ end
54
+
54
55
  # Returns the field name used to support STI for this table.
56
+ #
57
+ # Default field name is +type+ but it can be overrided in the +table+
58
+ # method call.
59
+ #
60
+ # User.inheritance_field # => :type
55
61
  def inheritance_field
56
62
  options[:inheritance_field] || :type
57
63
  end
58
64
 
59
- # Returns the id field for this class.
65
+ # Returns the hash key field name for this class.
60
66
  #
67
+ # By default +id+ field is used. But it can be overriden in the +table+
68
+ # method call.
69
+ #
70
+ # User.hash_key # => :id
71
+ #
72
+ # @return [Symbol] a hash key name
61
73
  # @since 0.4.0
62
74
  def hash_key
63
75
  options[:key] || :id
64
76
  end
65
77
 
66
- # Returns the number of items for this class.
78
+ # Return the count of items for this class.
79
+ #
80
+ # It returns aproximate value based on DynamoDB statistic. DynamoDB
81
+ # updates it periodicaly so the value can be no accurate.
82
+ #
83
+ # It's a reletivly cheap operation and doesn't read all the items in a
84
+ # table. It makes just one HTTP request to DynamoDB.
67
85
  #
86
+ # @return [Integer] items count in a table
68
87
  # @since 0.6.1
69
88
  def count
70
89
  Dynamoid.adapter.count(table_name)
@@ -72,35 +91,67 @@ module Dynamoid #:nodoc:
72
91
 
73
92
  # Initialize a new object.
74
93
  #
75
- # @param [Hash] attrs Attributes with which to create the object.
94
+ # User.build(name: 'A')
76
95
  #
77
- # @return [Dynamoid::Document] the new document
96
+ # Initialize an object and pass it into a block to set other attributes.
97
+ #
98
+ # User.build(name: 'A') do |u|
99
+ # u.age = 21
100
+ # end
101
+ #
102
+ # The only difference between +build+ and +new+ methods is that +build+
103
+ # supports STI (Single table inheritance) and looks at the inheritance
104
+ # field. So it can build a model of actual class. For instance:
105
+ #
106
+ # class Employee
107
+ # include Dynamoid::Document
78
108
  #
109
+ # field :type
110
+ # field :name
111
+ # end
112
+ #
113
+ # class Manager < Employee
114
+ # end
115
+ #
116
+ # Employee.build(name: 'Alice', type: 'Manager') # => #<Manager:0x00007f945756e3f0 ...>
117
+ #
118
+ # @param attrs [Hash] Attributes with which to create the document
119
+ # @param block [Proc] Block to process a document after initialization
120
+ # @return [Dynamoid::Document] the new document
79
121
  # @since 0.2.0
80
- def build(attrs = {})
81
- choose_right_class(attrs).new(attrs)
122
+ def build(attrs = {}, &block)
123
+ choose_right_class(attrs).new(attrs, &block)
82
124
  end
83
125
 
84
- # Does this object exist?
126
+ # Does this model exist in a table?
127
+ #
128
+ # User.exists?('713') # => true
85
129
  #
86
- # Supports primary key in format that `find` call understands.
87
- # Multiple keys and single compound primary key should be passed only as Array explicitily.
130
+ # If a range key is declared it should be specified in the following way:
88
131
  #
89
- # Supports conditions in format that `where` call understands.
132
+ # User.exists?([['713', 'range-key-value']]) # => true
90
133
  #
91
- # @param [Mixed] id_or_conditions the id of the object or a hash with the options to filter from.
134
+ # It's possible to check existence of several models at once:
92
135
  #
93
- # @return [Boolean] true/false
136
+ # User.exists?(['713', '714', '715'])
94
137
  #
95
- # @example With id
138
+ # Or in case when a range key is declared:
96
139
  #
97
- # Post.exist?(713)
98
- # Post.exist?([713, 210])
140
+ # User.exists?(
141
+ # [
142
+ # ['713', 'range-key-value-1'],
143
+ # ['714', 'range-key-value-2'],
144
+ # ['715', 'range-key-value-3']
145
+ # ]
146
+ # )
99
147
  #
100
- # @example With attributes conditions
148
+ # It's also possible to specify models not with primary key but with
149
+ # conditions on the attributes (in the +where+ method style):
101
150
  #
102
- # Post.exist?(version: 1, 'created_at.gt': Time.now - 1.day)
151
+ # User.exists?(age: 20, 'created_at.gt': Time.now - 1.day)
103
152
  #
153
+ # @param id_or_conditions [String|Array[String]|Array[Array]|Hash] the primary id of the model, a list of primary ids or a hash with the options to filter from.
154
+ # @return [true|false]
104
155
  # @since 0.2.0
105
156
  def exists?(id_or_conditions = {})
106
157
  case id_or_conditions
@@ -115,10 +166,12 @@ module Dynamoid #:nodoc:
115
166
  end
116
167
  end
117
168
 
169
+ # @private
118
170
  def deep_subclasses
119
171
  subclasses + subclasses.map(&:deep_subclasses).flatten
120
172
  end
121
173
 
174
+ # @private
122
175
  def choose_right_class(attrs)
123
176
  attrs[inheritance_field] ? attrs[inheritance_field].constantize : self
124
177
  end
@@ -126,36 +179,50 @@ module Dynamoid #:nodoc:
126
179
 
127
180
  # Initialize a new object.
128
181
  #
129
- # @param [Hash] attrs Attributes with which to create the object.
182
+ # User.new(name: 'A')
130
183
  #
184
+ # Initialize an object and pass it into a block to set other attributes.
185
+ #
186
+ # User.new(name: 'A') do |u|
187
+ # u.age = 21
188
+ # end
189
+ #
190
+ # @param attrs [Hash] Attributes with which to create the document
191
+ # @param block [Proc] Block to process a document after initialization
131
192
  # @return [Dynamoid::Document] the new document
132
193
  #
133
194
  # @since 0.2.0
134
- def initialize(attrs = {})
195
+ def initialize(attrs = {}, &block)
135
196
  run_callbacks :initialize do
136
197
  @new_record = true
137
198
  @attributes ||= {}
138
199
  @associations ||= {}
139
200
  @attributes_before_type_cast ||= {}
140
201
 
141
- attrs_with_defaults = self.class.attributes.reduce({}) do |res, (attribute, options)|
202
+ attrs_with_defaults = self.class.attributes.each_with_object({}) do |(attribute, options), res|
142
203
  if attrs.key?(attribute)
143
- res.merge(attribute => attrs[attribute])
204
+ res[attribute] = attrs[attribute]
144
205
  elsif options.key?(:default)
145
- res.merge(attribute => evaluate_default_value(options[:default]))
146
- else
147
- res
206
+ res[attribute] = evaluate_default_value(options[:default])
148
207
  end
149
208
  end
150
209
 
151
210
  attrs_virtual = attrs.slice(*(attrs.keys - self.class.attributes.keys))
152
211
 
153
212
  load(attrs_with_defaults.merge(attrs_virtual))
213
+
214
+ if block
215
+ block.call(self)
216
+ end
154
217
  end
155
218
  end
156
219
 
157
- # An object is equal to another object if their ids are equal.
220
+ # Check equality of two models.
158
221
  #
222
+ # A model is equal to another model only if their primary keys (hash key
223
+ # and optionaly range key) are equal.
224
+ #
225
+ # @return [true|false]
159
226
  # @since 0.2.0
160
227
  def ==(other)
161
228
  if self.class.identity_map_on?
@@ -167,36 +234,54 @@ module Dynamoid #:nodoc:
167
234
  end
168
235
  end
169
236
 
237
+ # Check equality of two models.
238
+ #
239
+ # Works exactly like +==+ does.
240
+ #
241
+ # @return [true|false]
170
242
  def eql?(other)
171
243
  self == other
172
244
  end
173
245
 
246
+ # Generate an Integer hash value for this model.
247
+ #
248
+ # Hash value is based on primary key. So models can be used safely as a
249
+ # +Hash+ keys.
250
+ #
251
+ # @return [Integer]
174
252
  def hash
175
253
  hash_key.hash ^ range_value.hash
176
254
  end
177
255
 
178
- # Return an object's hash key, regardless of what it might be called to the object.
256
+ # Return a model's hash key value.
179
257
  #
180
258
  # @since 0.4.0
181
259
  def hash_key
182
- send(self.class.hash_key)
260
+ self[self.class.hash_key.to_sym]
183
261
  end
184
262
 
185
- # Assign an object's hash key, regardless of what it might be called to the object.
263
+ # Assign a model's hash key value, regardless of what it might be called to
264
+ # the object.
186
265
  #
187
266
  # @since 0.4.0
188
267
  def hash_key=(value)
189
- send("#{self.class.hash_key}=", value)
268
+ self[self.class.hash_key.to_sym] = value
190
269
  end
191
270
 
271
+ # Return a model's range key value.
272
+ #
273
+ # Returns +nil+ if a range key isn't declared for a model.
192
274
  def range_value
193
- if range_key = self.class.range_key
194
- send(range_key)
275
+ if self.class.range_key
276
+ self[self.class.range_key.to_sym]
195
277
  end
196
278
  end
197
279
 
280
+ # Assign a model's range key value.
198
281
  def range_value=(value)
199
- send("#{self.class.range_key}=", value)
282
+ if self.class.range_key
283
+ self[self.class.range_key.to_sym] = value
284
+ end
200
285
  end
201
286
 
202
287
  private
@@ -208,7 +293,7 @@ module Dynamoid #:nodoc:
208
293
  # Evaluates the default value given, this is used by undump
209
294
  # when determining the value of the default given for a field options.
210
295
  #
211
- # @param [Object] :value the attribute's default value
296
+ # @param val [Object] the attribute's default value
212
297
  def evaluate_default_value(val)
213
298
  if val.respond_to?(:call)
214
299
  val.call
@@ -220,3 +305,5 @@ module Dynamoid #:nodoc:
220
305
  end
221
306
  end
222
307
  end
308
+
309
+ ActiveSupport.run_load_hooks(:dynamoid, Dynamoid::Document)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dynamoid
4
+ # @private
4
5
  module Dumping
5
6
  def self.dump_attributes(attributes, attributes_options)
6
7
  {}.tap do |h|
@@ -35,6 +36,7 @@ module Dynamoid
35
36
  when :serialized then SerializedDumper
36
37
  when :raw then RawDumper
37
38
  when :boolean then BooleanDumper
39
+ when :binary then BinaryDumper
38
40
  when Class then CustomTypeDumper
39
41
  end
40
42
 
@@ -287,6 +289,13 @@ module Dynamoid
287
289
  end
288
290
  end
289
291
 
292
+ # string -> string
293
+ class BinaryDumper < Base
294
+ def process(value)
295
+ Base64.strict_encode64(value)
296
+ end
297
+ end
298
+
290
299
  # any object -> string
291
300
  class CustomTypeDumper < Base
292
301
  def process(value)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dynamoid
4
+ # @private
4
5
  module DynamodbTimeZone
5
6
  def self.in_time_zone(value)
6
7
  case Dynamoid::Config.dynamodb_timezone
@@ -75,5 +75,7 @@ module Dynamoid
75
75
  class InvalidQuery < Error; end
76
76
 
77
77
  class UnsupportedKeyType < Error; end
78
+
79
+ class UnknownAttribute < Error; end
78
80
  end
79
81
  end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Dynamoid #:nodoc:
3
+ require 'dynamoid/fields/declare'
4
+
5
+ module Dynamoid
4
6
  # All fields on a Dynamoid::Document must be explicitly defined -- if you have fields in the database that are not
5
7
  # specified with field, then they will be ignored.
6
8
  module Fields
7
9
  extend ActiveSupport::Concern
8
10
 
11
+ # @private
9
12
  # Types allowed in indexes:
10
13
  PERMITTED_KEY_TYPES = %i[
11
14
  number
@@ -21,8 +24,11 @@ module Dynamoid #:nodoc:
21
24
  class_attribute :range_key
22
25
 
23
26
  self.attributes = {}
24
- field :created_at, :datetime
25
- field :updated_at, :datetime
27
+
28
+ # Timestamp fields could be disabled later in `table` method call.
29
+ # So let's declare them here and remove them later if it will be necessary
30
+ field :created_at, :datetime if Dynamoid::Config.timestamps
31
+ field :updated_at, :datetime if Dynamoid::Config.timestamps
26
32
 
27
33
  field :id # Default primary key
28
34
  end
@@ -30,59 +36,214 @@ module Dynamoid #:nodoc:
30
36
  module ClassMethods
31
37
  # Specify a field for a document.
32
38
  #
33
- # Its type determines how it is coerced when read in and out of the datastore.
34
- # You can specify :integer, :number, :set, :array, :datetime, :date and :serialized,
39
+ # class User
40
+ # include Dynamoid::Document
41
+ #
42
+ # field :last_name
43
+ # field :age, :integer
44
+ # field :last_sign_in, :datetime
45
+ # end
46
+ #
47
+ # Its type determines how it is coerced when read in and out of the
48
+ # data store. You can specify +string+, +integer+, +number+, +set+, +array+,
49
+ # +map+, +datetime+, +date+, +serialized+, +raw+, +boolean+ and +binary+
35
50
  # or specify a class that defines a serialization strategy.
36
51
  #
52
+ # By default field type is +string+.
53
+ #
54
+ # Set can store elements of the same type only (it's a limitation of
55
+ # DynamoDB itself). If a set should store elements only of some particular
56
+ # type then +of+ option should be specified:
57
+ #
58
+ # field :hobbies, :set, of: :string
59
+ #
60
+ # Only +string+, +integer+, +number+, +date+, +datetime+ and +serialized+
61
+ # element types are supported.
62
+ #
63
+ # Element type can have own options - they should be specified in the
64
+ # form of +Hash+:
65
+ #
66
+ # field :hobbies, :set, of: { serialized: { serializer: JSON } }
67
+ #
68
+ # Array can contain element of different types but if supports the same
69
+ # +of+ option to convert all the provided elements to the declared type.
70
+ #
71
+ # field :rates, :array, of: :number
72
+ #
73
+ # By default +date+ and +datetime+ fields are stored as integer values.
74
+ # The format can be changed to string with option +store_as_string+:
75
+ #
76
+ # field :published_on, :datetime, store_as_string: true
77
+ #
78
+ # Boolean field by default is stored as a string +t+ or +f+. But DynamoDB
79
+ # supports boolean type natively. In order to switch to the native
80
+ # boolean type an option +store_as_native_boolean+ should be specified:
81
+ #
82
+ # field :active, :boolean, store_as_native_boolean: true
83
+ #
84
+ # If you specify the +serialized+ type a value will be serialized to
85
+ # string in Yaml format by default. Custom way to serialize value to
86
+ # string can be specified with +serializer+ option. Custom serializer
87
+ # should have +dump+ and +load+ methods.
88
+ #
37
89
  # If you specify a class for field type, Dynamoid will serialize using
38
- # `dynamoid_dump` or `dump` methods, and load using `dynamoid_load` or `load` methods.
90
+ # +dynamoid_dump+ method and load using +dynamoid_load+ method.
91
+ #
92
+ # Default field type is +string+.
93
+ #
94
+ # A field can have a default value. It's assigned at initializing a model
95
+ # if no value is specified:
39
96
  #
40
- # Default field type is :string.
97
+ # field :age, :integer, default: 1
41
98
  #
42
- # @param [Symbol] name the name of the field
43
- # @param [Symbol] type the type of the field (refer to method description for details)
44
- # @param [Hash] options any additional options for the field
99
+ # If a defautl value should be recalculated every time it can be
100
+ # specified as a callable object (it should implement a +call+ method
101
+ # e.g. +Proc+ object):
102
+ #
103
+ # field :date_of_birth, :date, default: -> { Date.today }
104
+ #
105
+ # For every field Dynamoid creates several methods:
106
+ #
107
+ # * getter
108
+ # * setter
109
+ # * predicate +<name>?+ to check whether a value set
110
+ # * +<name>_before_type_cast?+ to get an original field value before it was type casted
111
+ #
112
+ # It works in the following way:
113
+ #
114
+ # class User
115
+ # include Dynamoid::Document
116
+ #
117
+ # field :age, :integer
118
+ # end
119
+ #
120
+ # user = User.new
121
+ # user.age # => nil
122
+ # user.age? # => false
123
+ #
124
+ # user.age = 20
125
+ # user.age? # => true
126
+ #
127
+ # user.age = '21'
128
+ # user.age # => 21 - integer
129
+ # user.age_before_type_cast # => '21' - string
130
+ #
131
+ # There is also an option +alias+ which allows to use another name for a
132
+ # field:
133
+ #
134
+ # class User
135
+ # include Dynamoid::Document
136
+ #
137
+ # field :firstName, :string, alias: :first_name
138
+ # end
139
+ #
140
+ # user = User.new(firstName: 'Michael')
141
+ # user.firstName # Michael
142
+ # user.first_name # Michael
143
+ #
144
+ # @param name [Symbol] name of the field
145
+ # @param type [Symbol] type of the field (optional)
146
+ # @param options [Hash] any additional options for the field type (optional)
45
147
  #
46
148
  # @since 0.2.0
47
149
  def field(name, type = :string, options = {})
48
- named = name.to_s
49
150
  if type == :float
50
151
  Dynamoid.logger.warn("Field type :float, which you declared for '#{name}', is deprecated in favor of :number.")
51
152
  type = :number
52
153
  end
53
- self.attributes = attributes.merge(name => { type: type }.merge(options))
54
-
55
- define_attribute_method(name) # Dirty API
56
154
 
57
- generated_methods.module_eval do
58
- define_method(named) { read_attribute(named) }
59
- define_method("#{named}?") do
60
- value = read_attribute(named)
61
- case value
62
- when true then true
63
- when false, nil then false
64
- else
65
- !value.nil?
66
- end
67
- end
68
- define_method("#{named}=") { |value| write_attribute(named, value) }
69
- define_method("#{named}_before_type_cast") { read_attribute_before_type_cast(named) }
70
- end
155
+ Dynamoid::Fields::Declare.new(self, name, type, options).call
71
156
  end
72
157
 
158
+ # Declare a table range key.
159
+ #
160
+ # class User
161
+ # include Dynamoid::Document
162
+ #
163
+ # range :last_name
164
+ # end
165
+ #
166
+ # By default a range key is a string. In order to use any other type it
167
+ # should be specified as a second argument:
168
+ #
169
+ # range :age, :integer
170
+ #
171
+ # Type options can be specified as well:
172
+ #
173
+ # range :date_of_birth, :date, store_as_string: true
174
+ #
175
+ # @param name [Symbol] a range key attribute name
176
+ # @param type [Symbol] a range key type (optional)
177
+ # @param options [Symbol] type options (optional)
73
178
  def range(name, type = :string, options = {})
74
179
  field(name, type, options)
75
180
  self.range_key = name
76
181
  end
77
182
 
78
- def table(_options)
183
+ # Set table level properties.
184
+ #
185
+ # There are some sensible defaults:
186
+ #
187
+ # * table name is based on a model class e.g. +users+ for +User+ class
188
+ # * hash key name - +id+ by default
189
+ # * hash key type - +string+ by default
190
+ # * generating timestamp fields +created_at+ and +updated_at+
191
+ # * billing mode and read/write capacity units
192
+ #
193
+ # The +table+ method can be used to override the defaults:
194
+ #
195
+ # class User
196
+ # include Dynamoid::Document
197
+ #
198
+ # table name: :customers, key: :uuid
199
+ # end
200
+ #
201
+ # The hash key field is declared by default and a type is a string. If
202
+ # another type is needed the field should be declared explicitly:
203
+ #
204
+ # class User
205
+ # include Dynamoid::Document
206
+ #
207
+ # field :id, :integer
208
+ # end
209
+ #
210
+ # @param options [Hash] options to override default table settings
211
+ # @option options [Symbol] :name name of a table
212
+ # @option options [Symbol] :key name of a hash key attribute
213
+ # @option options [Symbol] :inheritance_field name of an attribute used for STI
214
+ # @option options [Symbol] :capacity_mode table billing mode - either +provisioned+ or +on_demand+
215
+ # @option options [Integer] :write_capacity table write capacity units
216
+ # @option options [Integer] :read_capacity table read capacity units
217
+ # @option options [true|false] :timestamps whether generate +created_at+ and +updated_at+ fields or not
218
+ # @option options [Hash] :expires set up a table TTL and should have following structure +{ field: <attriubute name>, after: <seconds> }+
219
+ #
220
+ # @since 0.4.0
221
+ def table(options)
79
222
  # a default 'id' column is created when Dynamoid::Document is included
80
223
  unless attributes.key? hash_key
81
224
  remove_field :id
82
225
  field(hash_key)
83
226
  end
227
+
228
+ if options[:timestamps] && !Dynamoid::Config.timestamps
229
+ # Timestamp fields weren't declared in `included` hook because they
230
+ # are disabled globaly
231
+ field :created_at, :datetime
232
+ field :updated_at, :datetime
233
+ elsif options[:timestamps] == false && Dynamoid::Config.timestamps
234
+ # Timestamp fields were declared in `included` hook but they are
235
+ # disabled for a table
236
+ remove_field :created_at
237
+ remove_field :updated_at
238
+ end
84
239
  end
85
240
 
241
+ # Remove a field declaration
242
+ #
243
+ # Removes a field from the list of fields and removes all te generated
244
+ # for a field methods.
245
+ #
246
+ # @param field [Symbol] a field name
86
247
  def remove_field(field)
87
248
  field = field.to_sym
88
249
  attributes.delete(field) || raise('No such field')
@@ -99,8 +260,12 @@ module Dynamoid #:nodoc:
99
260
  end
100
261
  end
101
262
 
102
- private
263
+ # @private
264
+ def timestamps_enabled?
265
+ options[:timestamps] || (options[:timestamps].nil? && Dynamoid::Config.timestamps)
266
+ end
103
267
 
268
+ # @private
104
269
  def generated_methods
105
270
  @generated_methods ||= begin
106
271
  Module.new.tap do |mod|
@@ -114,15 +279,25 @@ module Dynamoid #:nodoc:
114
279
  attr_accessor :attributes
115
280
  alias raw_attributes attributes
116
281
 
117
- # Write an attribute on the object. Also marks the previous value as dirty.
282
+ # Write an attribute on the object.
283
+ #
284
+ # user.age = 20
285
+ # user.write_attribute(:age, 21)
286
+ # user.age # => 21
118
287
  #
119
- # @param [Symbol] name the name of the field
120
- # @param [Object] value the value to assign to that field
288
+ # Also marks the previous value as dirty.
289
+ #
290
+ # @param name [Symbol] the name of the field
291
+ # @param value [Object] the value to assign to that field
121
292
  #
122
293
  # @since 0.2.0
123
294
  def write_attribute(name, value)
124
295
  name = name.to_sym
125
296
 
297
+ unless attribute_is_present_on_model?(name)
298
+ raise Dynamoid::Errors::UnknownAttribute.new("Attribute #{name} is not part of the model")
299
+ end
300
+
126
301
  if association = @associations[name]
127
302
  association.reset
128
303
  end
@@ -138,22 +313,40 @@ module Dynamoid #:nodoc:
138
313
 
139
314
  # Read an attribute from an object.
140
315
  #
141
- # @param [Symbol] name the name of the field
316
+ # user.age = 20
317
+ # user.read_attribute(:age) # => 20
142
318
  #
319
+ # @param name [Symbol] the name of the field
320
+ # @return attribute value
143
321
  # @since 0.2.0
144
322
  def read_attribute(name)
145
323
  attributes[name.to_sym]
146
324
  end
147
325
  alias [] read_attribute
148
326
 
149
- # Returns a hash of attributes before typecasting
327
+ # Return attributes values before type casting.
328
+ #
329
+ # user = User.new
330
+ # user.age = '21'
331
+ # user.age # => 21
332
+ #
333
+ # user.attributes_before_type_cast # => { age: '21' }
334
+ #
335
+ # @return [Hash] original attribute values
150
336
  def attributes_before_type_cast
151
337
  @attributes_before_type_cast
152
338
  end
153
339
 
154
- # Returns the value of the attribute identified by name before typecasting
340
+ # Return the value of the attribute identified by name before type casting.
341
+ #
342
+ # user = User.new
343
+ # user.age = '21'
344
+ # user.age # => 21
155
345
  #
156
- # @param [Symbol] attribute name
346
+ # user.read_attribute_before_type_cast(:age) # => '21'
347
+ #
348
+ # @param name [Symbol] attribute name
349
+ # @return original attribute value
157
350
  def read_attribute_before_type_cast(name)
158
351
  return nil unless name.respond_to?(:to_sym)
159
352
 
@@ -166,18 +359,32 @@ module Dynamoid #:nodoc:
166
359
  #
167
360
  # @since 0.2.0
168
361
  def set_created_at
169
- self.created_at ||= DateTime.now.in_time_zone(Time.zone) if Dynamoid::Config.timestamps
362
+ self.created_at ||= DateTime.now.in_time_zone(Time.zone) if self.class.timestamps_enabled?
170
363
  end
171
364
 
172
365
  # Automatically called during the save callback to set the updated_at time.
173
366
  #
174
367
  # @since 0.2.0
175
368
  def set_updated_at
176
- if Dynamoid::Config.timestamps && !updated_at_changed?
369
+ # @_touch_record=false means explicit disabling
370
+ if self.class.timestamps_enabled? && !updated_at_changed? && @_touch_record != false
177
371
  self.updated_at = DateTime.now.in_time_zone(Time.zone)
178
372
  end
179
373
  end
180
374
 
375
+ def set_expires_field
376
+ options = self.class.options[:expires]
377
+
378
+ if options.present?
379
+ name = options[:field]
380
+ seconds = options[:after]
381
+
382
+ if self[name].blank?
383
+ send("#{name}=", Time.now.to_i + seconds)
384
+ end
385
+ end
386
+ end
387
+
181
388
  def set_inheritance_field
182
389
  # actually it does only following logic:
183
390
  # self.type ||= self.class.name if self.class.attributes[:type]
@@ -187,5 +394,10 @@ module Dynamoid #:nodoc:
187
394
  send("#{type}=", self.class.name)
188
395
  end
189
396
  end
397
+
398
+ def attribute_is_present_on_model?(attribute_name)
399
+ setter = "#{attribute_name}=".to_sym
400
+ respond_to?(setter)
401
+ end
190
402
  end
191
403
  end