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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a08a2b03026d0380bc6232ad035c49ca911bb5ee96c8e7d55de3dd23ebc3dcba
4
- data.tar.gz: 1778c6afe64631e5f40ef13b69a761109475befffdea301cb4144a5ea0abc7bb
3
+ metadata.gz: 11aa0780a8b4e5d41d3010603fff7f4e86f2dd6226e443d1c969d461f115d9a5
4
+ data.tar.gz: fa289cbbd0677836e4933cb57392ca7027821e12df1f3eb1cacd8fb87b130ddc
5
5
  SHA512:
6
- metadata.gz: c03b044f31d8a843fd37f066a6f5ae25a406165db25541a717e547bee044f20f09f45399dc4beaa133fb696611d86ee0938d09d9e67d3cdb50616e9493005c8b
7
- data.tar.gz: 024f823d6e9d76a4e7f9b9a77ac1e9212d213a549e9fc735051e7fdc2c5ac507f28c894aa9d2d209b589a772f5db534063c3e33d7f8db64cc8eefeea02b8955d
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
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Andy Davis
3
+ Copyright (c) 2026 Stowzilla Inc.
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -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.new(name)
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
- if foreign_key.to_s != default_foreign_key
102
- define_method(default_foreign_key) { send(foreign_key) }
103
- define_method("#{default_foreign_key}=") { |value| send("#{foreign_key}=", value) }
104
- end
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
- class_name: config[:class_name], owner: self)
122
+ class_name: config[:class_name], owner: self)
124
123
 
125
124
  if config[:scope]
126
- if config[:scope].arity == 0
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)
@@ -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
- attr_accessor :id, :created_at, :updated_at, :dbrecord
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, :set_created_timestamp
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
- if attributes.is_a?(Hash)
50
- attributes.each do |key, value|
51
- setter = "#{key}="
52
- send(setter, value) if respond_to?(setter)
53
- end
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 ||= begin
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
- if item.key?(key)
79
- value = item[key]
80
- found = true
81
- break
82
- end
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
- if old_value != value && instance_variable_defined?(:@pending_changes)
109
- @pending_changes ||= {}
110
- @pending_changes[attr_name] ||= [old_value, nil]
111
- @pending_changes[attr_name][1] = value
112
- end
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}=".to_sym
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}=".to_sym, :id=
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
- def dynamodb=(client)
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[self.primary_key])
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, &block)
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, &block)
217
- when :update then set_callback(:update, :before, *args, &block)
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, &block)
223
+ set_callback(:save, :before, *args, &)
222
224
  end
223
225
  end
224
226
 
225
- def after_save(*args, &block)
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, &block)
230
- when :update then set_callback(:update, :after, *args, &block)
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, &block)
236
+ set_callback(:save, :after, *args, &)
235
237
  end
236
238
  end
237
239
 
238
- def before_create(*args, &block) = set_callback(:create, :before, *args, &block)
239
- def after_create(*args, &block) = set_callback(:create, :after, *args, &block)
240
- def before_update(*args, &block) = set_callback(:update, :before, *args, &block)
241
- def after_update(*args, &block) = set_callback(:update, :after, *args, &block)
242
- def before_validation(*args, &block) = set_callback(:validation, :before, *args, &block)
243
- def after_validation(*args, &block) = set_callback(:validation, :after, *args, &block)
244
- def before_destroy(*args, &block) = set_callback(:destroy, :before, *args, &block)
245
- def after_destroy(*args, &block) = set_callback(:destroy, :after, *args, &block)
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, "scope body must be callable (Proc/Lambda)" unless body.respond_to?(:call)
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 "Cannot generate table name for anonymous class" unless name
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}=".to_sym, :id=
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 "Cannot reload a new record" if new_record?
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 = send(pk_name) rescue instance_variable_get("@#{pk_name}")
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
- pk_value = send(self.class.primary_key) rescue id
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
- if respond_to?(setter)
430
- old_value = send(key)
431
- @pending_changes[key.to_s] = [old_value, value] if old_value != value
432
- send(setter, value)
433
- end
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(context) if defined?(@running_validations) && @running_validations
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 set_created_timestamp
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, "already exists")
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
- operation: 'PutItem', original_error: e)
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 << "updatedAt = :updatedAt"
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
- operation: 'UpdateItem', original_error: e)
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
- operation: 'DeleteItem', original_error: e)
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 = Object.const_get(config[:class_name])
13
-
14
- if item[dynamo_key].is_a?(Hash)
15
- vo = if vo_class.respond_to?(:from_dynamo_map)
16
- vo_class.from_dynamo_map(item[dynamo_key])
17
- else
18
- kwargs = {}
19
- config[:mapping].each do |_model_attr, vo_attr|
20
- key = self.class.to_dynamo_key(vo_attr.to_s)
21
- kwargs[vo_attr] = item[dynamo_key][key] || item[dynamo_key][vo_attr.to_s]
22
- end
23
- vo_class.new(**kwargs)
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
- instance_variable_set("@_composed_#{part_id}", vo)
27
- config[:mapping].each do |model_attr, vo_attr|
28
- instance_variable_set("@#{model_attr}", vo.send(vo_attr))
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].each do |_model_attr, vo_attr|
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.each do |part_id, _config|
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
- @_compositions ||= {}
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 = Object.const_get(class_name)
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?(Object.const_get(class_name))
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) : Object.const_get(class_name).send(converter, 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 = Object.const_get(class_name).new(**value.transform_keys(&:to_sym))
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
- operation: operation, original_error: original_error)
81
+ operation: operation, original_error: original_error)
82
82
  end
83
83
  end
84
84
  end