activeitem 0.0.1 → 0.0.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/LICENSE.txt +1 -1
- data/lib/active_item/associations.rb +13 -13
- data/lib/active_item/base.rb +84 -64
- data/lib/active_item/composed_of.rb +34 -23
- data/lib/active_item/configuration.rb +2 -0
- data/lib/active_item/database_helpers.rb +3 -3
- data/lib/active_item/errors.rb +4 -1
- data/lib/active_item/logging.rb +2 -0
- data/lib/active_item/model_loader.rb +4 -12
- data/lib/active_item/pagination.rb +8 -5
- data/lib/active_item/query_helpers.rb +44 -78
- data/lib/active_item/relation.rb +119 -112
- data/lib/active_item/transaction.rb +3 -1
- data/lib/active_item/validations.rb +13 -21
- data/lib/active_item/version.rb +1 -1
- data/lib/activeitem.rb +3 -0
- metadata +63 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 11aa0780a8b4e5d41d3010603fff7f4e86f2dd6226e443d1c969d461f115d9a5
|
|
4
|
+
data.tar.gz: fa289cbbd0677836e4933cb57392ca7027821e12df1f3eb1cacd8fb87b130ddc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8f04eda18abbbf4bf4ab2aae97ff933406dad0692157acb3a3d22b8ca2b4caec99c750489c719f194ddda05e07ee42a310a3bc387ae173364a696809cea87546
|
|
7
|
+
data.tar.gz: 48b5015fc97d6137b33ca2b3b92c308f3e9f993d8a6c083bc0255fbac5af6da5c9bba9998801450da6192e2c8b2d4d35c1e67f3ead284d1c8608e745c2be2bc8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
|
|
7
|
+
- **[Critical]** Pagination cursor validation — decoded JSON is now validated to only contain flat key/value pairs with alphanumeric keys and string/numeric values. Prevents partition traversal via crafted cursors.
|
|
8
|
+
- **[Critical]** Remove arbitrary file require from `model_loader.rb` — `safe_constantize_model` now uses `safe_constantize` with class name format validation instead of requiring files from disk.
|
|
9
|
+
- **[Medium]** Add jitter to exponential backoff in batch operations to prevent thundering herd.
|
|
10
|
+
- **[Low]** Replace `Object.const_get` with `safe_constantize` in `composed_of` to prevent constant hierarchy traversal.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fix `set_created_timestamp` callback not setting `@created_at`, causing DynamoDB `Invalid attribute value type` errors on create
|
|
15
|
+
- Fix duplicate `id=` method definition (Lint/DuplicateMethods) by using `attr_reader :id` with a custom setter
|
|
16
|
+
- Fix duplicate `last` method definition in QueryHelpers
|
|
17
|
+
- Fix duplicate branch in Relation `includes` case statement (Lint/DuplicateBranch)
|
|
18
|
+
- Use `Comparable#clamp` in Pagination and Relation (Style/ComparableClamp)
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Documentation comments for all public modules and classes (Style/Documentation)
|
|
23
|
+
- `--workdir` option to CI DynamoDB service for `act` compatibility
|
|
24
|
+
|
|
3
25
|
## 0.0.1
|
|
4
26
|
|
|
5
27
|
- Initial release
|
data/LICENSE.txt
CHANGED
|
@@ -5,6 +5,9 @@ require 'active_support/inflector'
|
|
|
5
5
|
require_relative 'model_loader'
|
|
6
6
|
|
|
7
7
|
module ActiveItem
|
|
8
|
+
# Provides has_many and belongs_to association macros with lazy loading,
|
|
9
|
+
# preloading support, and dependent record handling (destroy, nullify,
|
|
10
|
+
# restrict).
|
|
8
11
|
module Associations
|
|
9
12
|
extend ActiveSupport::Concern
|
|
10
13
|
include ModelLoader
|
|
@@ -24,7 +27,7 @@ module ActiveItem
|
|
|
24
27
|
|
|
25
28
|
case config[:dependent]
|
|
26
29
|
when :restrict_with_exception
|
|
27
|
-
raise DeleteRestrictionError
|
|
30
|
+
raise DeleteRestrictionError, name
|
|
28
31
|
when :restrict_with_error
|
|
29
32
|
error_message = config[:message] || "Cannot delete #{self.class.name} because dependent #{name} exist"
|
|
30
33
|
errors.add(:base, error_message)
|
|
@@ -87,9 +90,7 @@ module ActiveItem
|
|
|
87
90
|
)
|
|
88
91
|
|
|
89
92
|
foreign_key_sym = foreign_key.to_sym
|
|
90
|
-
unless method_defined?(foreign_key_sym) || private_method_defined?(foreign_key_sym)
|
|
91
|
-
attr_accessor foreign_key_sym
|
|
92
|
-
end
|
|
93
|
+
attr_accessor foreign_key_sym unless method_defined?(foreign_key_sym) || private_method_defined?(foreign_key_sym)
|
|
93
94
|
|
|
94
95
|
validates foreign_key_sym, presence: true unless optional
|
|
95
96
|
|
|
@@ -98,10 +99,10 @@ module ActiveItem
|
|
|
98
99
|
define_method("#{association_name}=") { |record| set_belongs_to_association(association_name, record) }
|
|
99
100
|
|
|
100
101
|
default_foreign_key = "#{association_name}_id"
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
return unless foreign_key.to_s != default_foreign_key
|
|
103
|
+
|
|
104
|
+
define_method(default_foreign_key) { send(foreign_key) }
|
|
105
|
+
define_method("#{default_foreign_key}=") { |value| send("#{foreign_key}=", value) }
|
|
105
106
|
end
|
|
106
107
|
end
|
|
107
108
|
|
|
@@ -111,19 +112,17 @@ module ActiveItem
|
|
|
111
112
|
config = self.class._associations[name]
|
|
112
113
|
return Relation.new(Object, conditions: { _empty: true }) unless config
|
|
113
114
|
|
|
114
|
-
if _preloaded_associations.key?(name)
|
|
115
|
-
return Relation.new(nil, preloaded_records: _preloaded_associations[name], class_name: config[:class_name])
|
|
116
|
-
end
|
|
115
|
+
return Relation.new(nil, preloaded_records: _preloaded_associations[name], class_name: config[:class_name]) if _preloaded_associations.key?(name)
|
|
117
116
|
|
|
118
117
|
local_key_value = send(config[:primary_key])
|
|
119
118
|
return Relation.new(nil, conditions: { _empty: true }, class_name: config[:class_name]) if local_key_value.nil?
|
|
120
119
|
|
|
121
120
|
conditions = { config[:foreign_key].to_sym => local_key_value }
|
|
122
121
|
relation = Relation.new(nil, conditions: conditions, index_name: config[:index],
|
|
123
|
-
|
|
122
|
+
class_name: config[:class_name], owner: self)
|
|
124
123
|
|
|
125
124
|
if config[:scope]
|
|
126
|
-
if config[:scope].arity
|
|
125
|
+
if config[:scope].arity.zero?
|
|
127
126
|
relation.instance_exec(&config[:scope]) || relation
|
|
128
127
|
else
|
|
129
128
|
config[:scope].call(relation)
|
|
@@ -149,6 +148,7 @@ module ActiveItem
|
|
|
149
148
|
record = klass.find(foreign_key_value)
|
|
150
149
|
rescue ActiveItem::RecordNotFound
|
|
151
150
|
raise unless config[:optional]
|
|
151
|
+
|
|
152
152
|
record = nil
|
|
153
153
|
end
|
|
154
154
|
instance_variable_set(cache_var, record)
|
data/lib/active_item/base.rb
CHANGED
|
@@ -10,6 +10,9 @@ require 'active_model'
|
|
|
10
10
|
require 'securerandom'
|
|
11
11
|
|
|
12
12
|
module ActiveItem
|
|
13
|
+
# Base class for all ActiveItem models. Provides persistence, callbacks,
|
|
14
|
+
# validations, dirty tracking, and an ActiveRecord-like interface for
|
|
15
|
+
# DynamoDB tables.
|
|
13
16
|
class Base
|
|
14
17
|
include ActiveModel::Validations
|
|
15
18
|
include ActiveSupport::Callbacks
|
|
@@ -29,14 +32,15 @@ module ActiveItem
|
|
|
29
32
|
define_callbacks :save, :create, :update, :destroy, :validation
|
|
30
33
|
define_model_callbacks :initialize, only: :after
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
attr_reader :id
|
|
36
|
+
attr_accessor :created_at, :updated_at, :dbrecord
|
|
33
37
|
|
|
34
38
|
def id=(value)
|
|
35
39
|
@id = (value.to_s.strip.empty? ? nil : value)
|
|
36
40
|
end
|
|
37
41
|
|
|
38
42
|
set_callback :create, :before, :generate_primary_key
|
|
39
|
-
set_callback :create, :before, :
|
|
43
|
+
set_callback :create, :before, :assign_created_timestamp
|
|
40
44
|
set_callback :destroy, :before, :check_dependent_associations
|
|
41
45
|
|
|
42
46
|
def initialize(attributes = {})
|
|
@@ -46,11 +50,11 @@ module ActiveItem
|
|
|
46
50
|
@_preloaded_associations = {}
|
|
47
51
|
@new_record = true
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
return unless attributes.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
attributes.each do |key, value|
|
|
56
|
+
setter = "#{key}="
|
|
57
|
+
send(setter, value) if respond_to?(setter)
|
|
54
58
|
end
|
|
55
59
|
end
|
|
56
60
|
|
|
@@ -63,9 +67,7 @@ module ActiveItem
|
|
|
63
67
|
end
|
|
64
68
|
|
|
65
69
|
def self.attribute_names
|
|
66
|
-
@attribute_names ||=
|
|
67
|
-
instance_methods.grep(/\A[a-z_][a-z0-9_]*=\z/).map { |m| m.to_s.chomp('=') }.sort
|
|
68
|
-
end
|
|
70
|
+
@attribute_names ||= instance_methods.grep(/\A[a-z_][a-z0-9_]*=\z/).map { |m| m.to_s.chomp('=') }.sort
|
|
69
71
|
end
|
|
70
72
|
|
|
71
73
|
def populate_attributes_from_item(item)
|
|
@@ -75,11 +77,11 @@ module ActiveItem
|
|
|
75
77
|
value = nil
|
|
76
78
|
found = false
|
|
77
79
|
self.class.dynamo_key_variants(attr_name).each do |key|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
next unless item.key?(key)
|
|
81
|
+
|
|
82
|
+
value = item[key]
|
|
83
|
+
found = true
|
|
84
|
+
break
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
instance_variable_set("@#{attr_name}", value) if found
|
|
@@ -105,11 +107,11 @@ module ActiveItem
|
|
|
105
107
|
old_value = instance_variable_get("@#{attr_name}")
|
|
106
108
|
instance_variable_set("@#{attr_name}", value)
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
return unless old_value != value && instance_variable_defined?(:@pending_changes)
|
|
111
|
+
|
|
112
|
+
@pending_changes ||= {}
|
|
113
|
+
@pending_changes[attr_name] ||= [old_value, nil]
|
|
114
|
+
@pending_changes[attr_name][1] = value
|
|
113
115
|
end
|
|
114
116
|
end
|
|
115
117
|
end
|
|
@@ -120,12 +122,12 @@ module ActiveItem
|
|
|
120
122
|
|
|
121
123
|
def primary_key=(value)
|
|
122
124
|
remove_method primary_key.to_sym
|
|
123
|
-
remove_method "#{primary_key}="
|
|
125
|
+
remove_method :"#{primary_key}="
|
|
124
126
|
|
|
125
127
|
@primary_key = value.to_s
|
|
126
128
|
|
|
127
129
|
alias_method primary_key.to_sym, :id
|
|
128
|
-
alias_method "#{primary_key}="
|
|
130
|
+
alias_method :"#{primary_key}=", :id=
|
|
129
131
|
end
|
|
130
132
|
|
|
131
133
|
def table_name
|
|
@@ -140,9 +142,7 @@ module ActiveItem
|
|
|
140
142
|
@dynamodb ||= Aws::DynamoDB::Client.new(http_wire_trace: false)
|
|
141
143
|
end
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
@dynamodb = client
|
|
145
|
-
end
|
|
145
|
+
attr_writer :dynamodb
|
|
146
146
|
|
|
147
147
|
def dynamo_attribute_map(mappings = nil)
|
|
148
148
|
if mappings
|
|
@@ -155,6 +155,7 @@ module ActiveItem
|
|
|
155
155
|
def to_dynamo_key(attr_name)
|
|
156
156
|
attr_str = attr_name.to_s
|
|
157
157
|
return dynamo_attribute_map[attr_str] if dynamo_attribute_map.key?(attr_str)
|
|
158
|
+
|
|
158
159
|
attr_str.camelize(:lower)
|
|
159
160
|
end
|
|
160
161
|
|
|
@@ -162,6 +163,7 @@ module ActiveItem
|
|
|
162
163
|
key_str = dynamo_key.to_s
|
|
163
164
|
reverse_map = dynamo_attribute_map.invert
|
|
164
165
|
return reverse_map[key_str] if reverse_map.key?(key_str)
|
|
166
|
+
|
|
165
167
|
key_str.underscore
|
|
166
168
|
end
|
|
167
169
|
|
|
@@ -176,7 +178,7 @@ module ActiveItem
|
|
|
176
178
|
normalized_item = normalize_dynamodb_values(item)
|
|
177
179
|
|
|
178
180
|
record = allocate
|
|
179
|
-
record.instance_variable_set(:@id, normalized_item[
|
|
181
|
+
record.instance_variable_set(:@id, normalized_item[primary_key])
|
|
180
182
|
record.send(:populate_attributes_from_item, normalized_item)
|
|
181
183
|
record.instance_variable_set(:@new_record, false)
|
|
182
184
|
record.instance_variable_set(:@previously_changed, {})
|
|
@@ -209,43 +211,44 @@ module ActiveItem
|
|
|
209
211
|
end
|
|
210
212
|
|
|
211
213
|
# Callback DSL
|
|
212
|
-
def before_save(*args, &
|
|
214
|
+
def before_save(*args, &)
|
|
213
215
|
options = args.extract_options!
|
|
214
216
|
if options[:on]
|
|
215
217
|
case options[:on].to_sym
|
|
216
|
-
when :create then set_callback(:create, :before, *args, &
|
|
217
|
-
when :update then set_callback(:update, :before, *args, &
|
|
218
|
+
when :create then set_callback(:create, :before, *args, &)
|
|
219
|
+
when :update then set_callback(:update, :before, *args, &)
|
|
218
220
|
else raise ArgumentError, "Invalid on: option '#{options[:on]}'. Must be :create or :update"
|
|
219
221
|
end
|
|
220
222
|
else
|
|
221
|
-
set_callback(:save, :before, *args, &
|
|
223
|
+
set_callback(:save, :before, *args, &)
|
|
222
224
|
end
|
|
223
225
|
end
|
|
224
226
|
|
|
225
|
-
def after_save(*args, &
|
|
227
|
+
def after_save(*args, &)
|
|
226
228
|
options = args.extract_options!
|
|
227
229
|
if options[:on]
|
|
228
230
|
case options[:on].to_sym
|
|
229
|
-
when :create then set_callback(:create, :after, *args, &
|
|
230
|
-
when :update then set_callback(:update, :after, *args, &
|
|
231
|
+
when :create then set_callback(:create, :after, *args, &)
|
|
232
|
+
when :update then set_callback(:update, :after, *args, &)
|
|
231
233
|
else raise ArgumentError, "Invalid on: option '#{options[:on]}'. Must be :create or :update"
|
|
232
234
|
end
|
|
233
235
|
else
|
|
234
|
-
set_callback(:save, :after, *args, &
|
|
236
|
+
set_callback(:save, :after, *args, &)
|
|
235
237
|
end
|
|
236
238
|
end
|
|
237
239
|
|
|
238
|
-
def before_create(
|
|
239
|
-
def after_create(
|
|
240
|
-
def before_update(
|
|
241
|
-
def after_update(
|
|
242
|
-
def before_validation(
|
|
243
|
-
def after_validation(
|
|
244
|
-
def before_destroy(
|
|
245
|
-
def after_destroy(
|
|
240
|
+
def before_create(...) = set_callback(:create, :before, ...)
|
|
241
|
+
def after_create(...) = set_callback(:create, :after, ...)
|
|
242
|
+
def before_update(...) = set_callback(:update, :before, ...)
|
|
243
|
+
def after_update(...) = set_callback(:update, :after, ...)
|
|
244
|
+
def before_validation(...) = set_callback(:validation, :before, ...)
|
|
245
|
+
def after_validation(...) = set_callback(:validation, :after, ...)
|
|
246
|
+
def before_destroy(...) = set_callback(:destroy, :before, ...)
|
|
247
|
+
def after_destroy(...) = set_callback(:destroy, :after, ...)
|
|
246
248
|
|
|
247
249
|
def scope(name, body)
|
|
248
|
-
raise ArgumentError,
|
|
250
|
+
raise ArgumentError, 'scope body must be callable (Proc/Lambda)' unless body.respond_to?(:call)
|
|
251
|
+
|
|
249
252
|
_scopes[name.to_sym] = body
|
|
250
253
|
define_singleton_method(name) { all.instance_exec(&body) }
|
|
251
254
|
end
|
|
@@ -257,7 +260,8 @@ module ActiveItem
|
|
|
257
260
|
private
|
|
258
261
|
|
|
259
262
|
def default_table_name
|
|
260
|
-
raise
|
|
263
|
+
raise 'Cannot generate table name for anonymous class' unless name
|
|
264
|
+
|
|
261
265
|
ActiveItem.configuration.table_name_for(name)
|
|
262
266
|
end
|
|
263
267
|
|
|
@@ -265,7 +269,7 @@ module ActiveItem
|
|
|
265
269
|
super
|
|
266
270
|
subclass.class_eval do
|
|
267
271
|
alias_method primary_key.to_sym, :id
|
|
268
|
-
alias_method "#{primary_key}="
|
|
272
|
+
alias_method :"#{primary_key}=", :id=
|
|
269
273
|
end
|
|
270
274
|
end
|
|
271
275
|
end
|
|
@@ -279,12 +283,14 @@ module ActiveItem
|
|
|
279
283
|
end
|
|
280
284
|
|
|
281
285
|
def reload
|
|
282
|
-
raise
|
|
286
|
+
raise 'Cannot reload a new record' if new_record?
|
|
287
|
+
|
|
283
288
|
fresh_record = self.class.find(id)
|
|
284
289
|
raise "Record not found: #{self.class.name} with id #{id}" unless fresh_record
|
|
285
290
|
|
|
286
291
|
self.class.attribute_names.each do |attr_name|
|
|
287
292
|
next if attr_name == 'dbrecord'
|
|
293
|
+
|
|
288
294
|
value = fresh_record.instance_variable_get("@#{attr_name}")
|
|
289
295
|
instance_variable_set("@#{attr_name}", value)
|
|
290
296
|
end
|
|
@@ -308,12 +314,17 @@ module ActiveItem
|
|
|
308
314
|
def attributes
|
|
309
315
|
attrs = {}
|
|
310
316
|
pk_name = self.class.primary_key
|
|
311
|
-
pk_value =
|
|
317
|
+
pk_value = begin
|
|
318
|
+
send(pk_name)
|
|
319
|
+
rescue StandardError
|
|
320
|
+
instance_variable_get("@#{pk_name}")
|
|
321
|
+
end
|
|
312
322
|
attrs['id'] = pk_value
|
|
313
323
|
attrs[pk_name] = pk_value
|
|
314
324
|
|
|
315
325
|
self.class.attribute_names.each do |attr_name|
|
|
316
326
|
next if attr_name == 'dbrecord'
|
|
327
|
+
|
|
317
328
|
value = instance_variable_get("@#{attr_name}")
|
|
318
329
|
attrs[attr_name] = value unless value.nil?
|
|
319
330
|
end
|
|
@@ -324,11 +335,17 @@ module ActiveItem
|
|
|
324
335
|
end
|
|
325
336
|
|
|
326
337
|
def inspect
|
|
327
|
-
|
|
338
|
+
begin
|
|
339
|
+
send(self.class.primary_key)
|
|
340
|
+
rescue StandardError
|
|
341
|
+
id
|
|
342
|
+
end
|
|
328
343
|
attr_strs = self.class.attribute_names.filter_map do |attr|
|
|
329
344
|
next if attr == 'dbrecord'
|
|
345
|
+
|
|
330
346
|
value = instance_variable_get("@#{attr}")
|
|
331
347
|
next if value.nil?
|
|
348
|
+
|
|
332
349
|
"#{attr}: #{value.inspect}"
|
|
333
350
|
end
|
|
334
351
|
"#<#{self.class.name} #{attr_strs.join(', ')}>"
|
|
@@ -356,9 +373,10 @@ module ActiveItem
|
|
|
356
373
|
end
|
|
357
374
|
|
|
358
375
|
return false if result == false
|
|
376
|
+
|
|
359
377
|
changes_applied
|
|
360
378
|
true
|
|
361
|
-
rescue => e
|
|
379
|
+
rescue StandardError => e
|
|
362
380
|
dynamo_logger.error("Failed to save #{self.class.name}: #{e.message}")
|
|
363
381
|
raise e
|
|
364
382
|
end
|
|
@@ -406,10 +424,11 @@ module ActiveItem
|
|
|
406
424
|
def destroy
|
|
407
425
|
result = run_callbacks(:destroy) { perform_destroy }
|
|
408
426
|
return false if result == false
|
|
427
|
+
|
|
409
428
|
true
|
|
410
429
|
rescue DeleteRestrictionError
|
|
411
430
|
false
|
|
412
|
-
rescue => e
|
|
431
|
+
rescue StandardError => e
|
|
413
432
|
dynamo_logger.error("Failed to destroy #{self.class.name}: #{e.message}")
|
|
414
433
|
errors.add(:base, e.message)
|
|
415
434
|
false
|
|
@@ -418,7 +437,7 @@ module ActiveItem
|
|
|
418
437
|
def delete
|
|
419
438
|
perform_destroy
|
|
420
439
|
true
|
|
421
|
-
rescue => e
|
|
440
|
+
rescue StandardError => e
|
|
422
441
|
dynamo_logger.error("Failed to delete #{self.class.name}: #{e.message}")
|
|
423
442
|
false
|
|
424
443
|
end
|
|
@@ -426,11 +445,11 @@ module ActiveItem
|
|
|
426
445
|
def assign_attributes(attributes)
|
|
427
446
|
attributes.each do |key, value|
|
|
428
447
|
setter = "#{key}="
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
448
|
+
next unless respond_to?(setter)
|
|
449
|
+
|
|
450
|
+
old_value = send(key)
|
|
451
|
+
@pending_changes[key.to_s] = [old_value, value] if old_value != value
|
|
452
|
+
send(setter, value)
|
|
434
453
|
end
|
|
435
454
|
end
|
|
436
455
|
|
|
@@ -457,7 +476,7 @@ module ActiveItem
|
|
|
457
476
|
end
|
|
458
477
|
|
|
459
478
|
def valid?(context = nil)
|
|
460
|
-
return super
|
|
479
|
+
return super if defined?(@running_validations) && @running_validations
|
|
461
480
|
|
|
462
481
|
@running_validations = true
|
|
463
482
|
begin
|
|
@@ -477,8 +496,8 @@ module ActiveItem
|
|
|
477
496
|
instance_variable_set("@#{pk}", @id) if pk != 'id'
|
|
478
497
|
end
|
|
479
498
|
|
|
480
|
-
def
|
|
481
|
-
@created_at ||= Time.now.utc.iso8601
|
|
499
|
+
def assign_created_timestamp
|
|
500
|
+
@created_at ||= Time.now.utc.iso8601 # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
482
501
|
end
|
|
483
502
|
|
|
484
503
|
def dynamodb
|
|
@@ -508,11 +527,11 @@ module ActiveItem
|
|
|
508
527
|
|
|
509
528
|
dynamo_logger.info("#{self.class.name} created (#{self.class.primary_key}: #{id})")
|
|
510
529
|
rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
|
|
511
|
-
errors.add(:id,
|
|
530
|
+
errors.add(:id, 'already exists')
|
|
512
531
|
false
|
|
513
532
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
514
533
|
raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
|
|
515
|
-
|
|
534
|
+
operation: 'PutItem', original_error: e)
|
|
516
535
|
end
|
|
517
536
|
|
|
518
537
|
def build_dynamodb_item
|
|
@@ -521,6 +540,7 @@ module ActiveItem
|
|
|
521
540
|
dynamodb_attributes.each do |attr|
|
|
522
541
|
value = instance_variable_get("@#{attr}")
|
|
523
542
|
next if value.nil?
|
|
543
|
+
|
|
524
544
|
dynamo_key = self.class.to_dynamo_key(attr)
|
|
525
545
|
item[dynamo_key] = value
|
|
526
546
|
end
|
|
@@ -559,7 +579,7 @@ module ActiveItem
|
|
|
559
579
|
end
|
|
560
580
|
end
|
|
561
581
|
|
|
562
|
-
update_parts <<
|
|
582
|
+
update_parts << 'updatedAt = :updatedAt'
|
|
563
583
|
attr_values[':updatedAt'] = Time.now.utc.iso8601
|
|
564
584
|
|
|
565
585
|
update_expression = "SET #{update_parts.join(', ')}"
|
|
@@ -576,7 +596,7 @@ module ActiveItem
|
|
|
576
596
|
dynamodb.update_item(params)
|
|
577
597
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
578
598
|
raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
|
|
579
|
-
|
|
599
|
+
operation: 'UpdateItem', original_error: e)
|
|
580
600
|
end
|
|
581
601
|
|
|
582
602
|
def perform_destroy
|
|
@@ -585,7 +605,7 @@ module ActiveItem
|
|
|
585
605
|
dynamo_logger.info("#{self.class.name} deleted (#{key}: #{send(key)})")
|
|
586
606
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
587
607
|
raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
|
|
588
|
-
|
|
608
|
+
operation: 'DeleteItem', original_error: e)
|
|
589
609
|
end
|
|
590
610
|
end
|
|
591
611
|
end
|
|
@@ -3,30 +3,34 @@
|
|
|
3
3
|
require 'active_support/concern'
|
|
4
4
|
|
|
5
5
|
module ActiveItem
|
|
6
|
+
# Implements value object composition, allowing models to aggregate multiple
|
|
7
|
+
# attributes into a single nested DynamoDB map and expose them as a Ruby
|
|
8
|
+
# value object.
|
|
6
9
|
module ComposedOf
|
|
7
10
|
extend ActiveSupport::Concern
|
|
8
11
|
|
|
9
12
|
def populate_composed_attributes_from_item(item)
|
|
10
13
|
self.class.compositions.each do |part_id, config|
|
|
11
14
|
dynamo_key = self.class.to_dynamo_key(part_id.to_s)
|
|
12
|
-
vo_class =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
vo_class = config[:class_name].safe_constantize
|
|
16
|
+
next unless vo_class
|
|
17
|
+
|
|
18
|
+
next unless item[dynamo_key].is_a?(Hash)
|
|
19
|
+
|
|
20
|
+
vo = if vo_class.respond_to?(:from_dynamo_map)
|
|
21
|
+
vo_class.from_dynamo_map(item[dynamo_key])
|
|
22
|
+
else
|
|
23
|
+
kwargs = {}
|
|
24
|
+
config[:mapping].each_value do |vo_attr|
|
|
25
|
+
key = self.class.to_dynamo_key(vo_attr.to_s)
|
|
26
|
+
kwargs[vo_attr] = item[dynamo_key][key] || item[dynamo_key][vo_attr.to_s]
|
|
24
27
|
end
|
|
28
|
+
vo_class.new(**kwargs)
|
|
29
|
+
end
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
end
|
|
31
|
+
instance_variable_set("@_composed_#{part_id}", vo)
|
|
32
|
+
config[:mapping].each do |model_attr, vo_attr|
|
|
33
|
+
instance_variable_set("@#{model_attr}", vo.send(vo_attr))
|
|
30
34
|
end
|
|
31
35
|
end
|
|
32
36
|
end
|
|
@@ -46,7 +50,7 @@ module ActiveItem
|
|
|
46
50
|
item[dynamo_key] = vo.to_dynamo_map
|
|
47
51
|
else
|
|
48
52
|
map = {}
|
|
49
|
-
config[:mapping].
|
|
53
|
+
config[:mapping].each_value do |vo_attr|
|
|
50
54
|
key = self.class.to_dynamo_key(vo_attr.to_s)
|
|
51
55
|
map[key] = vo.send(vo_attr)
|
|
52
56
|
end
|
|
@@ -86,6 +90,7 @@ module ActiveItem
|
|
|
86
90
|
|
|
87
91
|
changes.each do |field, (_old_val, new_val)|
|
|
88
92
|
next if composed_flat_keys.include?(field)
|
|
93
|
+
|
|
89
94
|
dynamo_key = self.class.to_dynamo_key(field)
|
|
90
95
|
if new_val.nil?
|
|
91
96
|
remove_parts << "#field#{idx}"
|
|
@@ -98,7 +103,7 @@ module ActiveItem
|
|
|
98
103
|
idx += 1
|
|
99
104
|
end
|
|
100
105
|
|
|
101
|
-
changed_compositions.
|
|
106
|
+
changed_compositions.each_key do |part_id|
|
|
102
107
|
remove_instance_variable("@_composed_#{part_id}") if instance_variable_defined?("@_composed_#{part_id}")
|
|
103
108
|
vo = send(part_id)
|
|
104
109
|
dynamo_key = self.class.to_dynamo_key(part_id.to_s)
|
|
@@ -132,9 +137,10 @@ module ActiveItem
|
|
|
132
137
|
dynamodb.update_item(params)
|
|
133
138
|
end
|
|
134
139
|
|
|
140
|
+
# Class-level DSL for declaring composed_of value objects on a model.
|
|
135
141
|
module ClassMethods
|
|
136
142
|
def compositions
|
|
137
|
-
@
|
|
143
|
+
@compositions ||= {}
|
|
138
144
|
end
|
|
139
145
|
|
|
140
146
|
def composed_of(part_id, options = {})
|
|
@@ -151,7 +157,9 @@ module ActiveItem
|
|
|
151
157
|
ivar = "@_composed_#{part_id}"
|
|
152
158
|
return instance_variable_get(ivar) if instance_variable_defined?(ivar)
|
|
153
159
|
|
|
154
|
-
vo_class =
|
|
160
|
+
vo_class = class_name.safe_constantize
|
|
161
|
+
raise NameError, "Unknown value object class: #{class_name}" unless vo_class
|
|
162
|
+
|
|
155
163
|
values = mapping.map { |model_attr, _vo_attr| send(model_attr) }
|
|
156
164
|
|
|
157
165
|
if allow_nil && values.all? { |v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
|
|
@@ -173,17 +181,20 @@ module ActiveItem
|
|
|
173
181
|
define_method("#{part_id}=") do |value|
|
|
174
182
|
ivar = "@_composed_#{part_id}"
|
|
175
183
|
|
|
184
|
+
vo_class = class_name.safe_constantize
|
|
185
|
+
raise NameError, "Unknown value object class: #{class_name}" unless vo_class
|
|
186
|
+
|
|
176
187
|
if value.nil?
|
|
177
188
|
mapping.each_key { |model_attr| send("#{model_attr}=", nil) }
|
|
178
189
|
instance_variable_set(ivar, nil)
|
|
179
|
-
elsif value.is_a?(
|
|
190
|
+
elsif value.is_a?(vo_class)
|
|
180
191
|
mapping.each { |model_attr, vo_attr| send("#{model_attr}=", value.send(vo_attr)) }
|
|
181
192
|
instance_variable_set(ivar, value)
|
|
182
193
|
elsif converter
|
|
183
|
-
converted = converter.is_a?(Proc) ? converter.call(value) :
|
|
194
|
+
converted = converter.is_a?(Proc) ? converter.call(value) : vo_class.send(converter, value)
|
|
184
195
|
send("#{part_id}=", converted)
|
|
185
196
|
elsif value.is_a?(Hash)
|
|
186
|
-
vo =
|
|
197
|
+
vo = vo_class.new(**value.transform_keys(&:to_sym))
|
|
187
198
|
send("#{part_id}=", vo)
|
|
188
199
|
else
|
|
189
200
|
raise ArgumentError, "Cannot assign #{value.class} to #{part_id}. Expected #{class_name}, Hash, or nil."
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'logger'
|
|
4
4
|
|
|
5
5
|
module ActiveItem
|
|
6
|
+
# Holds global settings for ActiveItem including logger, table prefix, and
|
|
7
|
+
# environment. Used to derive DynamoDB table names from model class names.
|
|
6
8
|
class Configuration
|
|
7
9
|
attr_accessor :logger, :table_prefix, :environment
|
|
8
10
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'set'
|
|
4
|
-
|
|
5
3
|
module ActiveItem
|
|
4
|
+
# Low-level DynamoDB operations (get, put, delete, query, scan) with
|
|
5
|
+
# automatic pagination and access-denied error wrapping.
|
|
6
6
|
module DatabaseHelpers
|
|
7
7
|
def get(key)
|
|
8
8
|
response = dynamodb.get_item(table_name: table_name, key: key)
|
|
@@ -78,7 +78,7 @@ module ActiveItem
|
|
|
78
78
|
|
|
79
79
|
def raise_access_denied(operation, original_error)
|
|
80
80
|
raise ActiveItem::AccessDeniedError.new(model_name: name, table: table_name,
|
|
81
|
-
|
|
81
|
+
operation: operation, original_error: original_error)
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
end
|