activeitem 0.0.19 → 0.0.20
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/lib/active_item/associations.rb +59 -0
- data/lib/active_item/base.rb +205 -4
- data/lib/active_item/composed_of.rb +1 -1
- data/lib/active_item/embeddable.rb +83 -0
- data/lib/active_item/embedded_collection.rb +136 -0
- data/lib/active_item/errors.rb +20 -0
- data/lib/active_item/query_helpers.rb +16 -0
- data/lib/active_item/version.rb +1 -1
- data/lib/activeitem.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 652850a906bc798042da7b5c3ea1396aeef9775057ad1199030354cff2b24b73
|
|
4
|
+
data.tar.gz: 55001585d52f922c371cd6ca6cc61beef3f6f5ea98e4ca268d0bfe1e6ebf8d5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 64fb13d70a43f1f6a5668c3e93b6e1f24ceec853ea6f7d9e974a7fd5b53c7a2f8b66e9d582d6f12d10498a892a10895712092b1bbcac4929c6cad86fc164d770
|
|
7
|
+
data.tar.gz: e6495e6752c6b8ffc0b8ef98451141ad8816fe069e4e33a40c7b65f45b4bf02981390bfab503149fce243e354d9842e1ce02bfb9f75f270af40dc5593d51ab5a
|
|
@@ -14,6 +14,7 @@ module ActiveItem
|
|
|
14
14
|
|
|
15
15
|
included do
|
|
16
16
|
class_attribute :_associations, default: {}
|
|
17
|
+
class_attribute :_embedded_associations, default: {}
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def check_dependent_associations
|
|
@@ -58,6 +59,11 @@ module ActiveItem
|
|
|
58
59
|
return
|
|
59
60
|
end
|
|
60
61
|
|
|
62
|
+
if options[:embedded]
|
|
63
|
+
define_has_many_embedded(association_name, options)
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
|
|
61
67
|
class_name = options[:class_name] || name.to_s.singularize.camelize
|
|
62
68
|
foreign_key = options[:foreign_key] || "#{self.name.underscore}_id"
|
|
63
69
|
index_name = options[:index]
|
|
@@ -99,6 +105,59 @@ module ActiveItem
|
|
|
99
105
|
end
|
|
100
106
|
end
|
|
101
107
|
|
|
108
|
+
def define_has_many_embedded(association_name, options)
|
|
109
|
+
class_name = options[:class_name] || association_name.to_s.singularize.camelize
|
|
110
|
+
|
|
111
|
+
self._associations = _associations.merge(
|
|
112
|
+
association_name => {
|
|
113
|
+
type: :has_many_embedded, class_name: class_name
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Register the embedded association so serialization knows about it
|
|
118
|
+
self._embedded_associations = _embedded_associations.merge(
|
|
119
|
+
association_name => { class_name: class_name }
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Define dirty-tracking attribute for the embedded collection
|
|
123
|
+
define_attribute_methods association_name.to_s
|
|
124
|
+
|
|
125
|
+
# Accessor that returns an EmbeddedCollection
|
|
126
|
+
define_method(association_name) do
|
|
127
|
+
ivar = :"@_embedded_#{association_name}"
|
|
128
|
+
return instance_variable_get(ivar) if instance_variable_defined?(ivar)
|
|
129
|
+
|
|
130
|
+
target_class = safe_constantize_model(class_name)
|
|
131
|
+
collection = EmbeddedCollection.new(
|
|
132
|
+
owner: self,
|
|
133
|
+
association_name: association_name,
|
|
134
|
+
target_class: target_class,
|
|
135
|
+
records: []
|
|
136
|
+
)
|
|
137
|
+
instance_variable_set(ivar, collection)
|
|
138
|
+
collection
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Setter for replacing the embedded collection
|
|
142
|
+
define_method("#{association_name}=") do |records|
|
|
143
|
+
ivar = :"@_embedded_#{association_name}"
|
|
144
|
+
target_class = safe_constantize_model(class_name)
|
|
145
|
+
collection = EmbeddedCollection.new(
|
|
146
|
+
owner: self,
|
|
147
|
+
association_name: association_name,
|
|
148
|
+
target_class: target_class,
|
|
149
|
+
records: Array(records)
|
|
150
|
+
)
|
|
151
|
+
send("#{association_name}_will_change!")
|
|
152
|
+
instance_variable_set(ivar, collection)
|
|
153
|
+
collection
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
define_method(:"#{association_name}_count") do
|
|
157
|
+
send(association_name).count
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
102
161
|
public
|
|
103
162
|
|
|
104
163
|
def belongs_to(name, options = {})
|
data/lib/active_item/base.rb
CHANGED
|
@@ -16,6 +16,7 @@ module ActiveItem
|
|
|
16
16
|
include ActiveModel::Dirty
|
|
17
17
|
extend ActiveModel::Callbacks
|
|
18
18
|
include Associations
|
|
19
|
+
include Embeddable
|
|
19
20
|
include Logging
|
|
20
21
|
|
|
21
22
|
define_model_callbacks :save, :create, :update, :destroy, :initialize, :validation
|
|
@@ -175,6 +176,7 @@ module ActiveItem
|
|
|
175
176
|
record = allocate
|
|
176
177
|
record.instance_variable_set(:@id, normalized_item[primary_key])
|
|
177
178
|
record.send(:populate_attributes_from_item, normalized_item)
|
|
179
|
+
record.send(:populate_embedded_associations, normalized_item)
|
|
178
180
|
record.instance_variable_set(:@new_record, false)
|
|
179
181
|
record.instance_variable_set(:@mutations_from_database, nil)
|
|
180
182
|
record.instance_variable_set(:@dbrecord, normalized_item)
|
|
@@ -289,7 +291,7 @@ module ActiveItem
|
|
|
289
291
|
end
|
|
290
292
|
|
|
291
293
|
def has_changes_to_save?
|
|
292
|
-
changed?
|
|
294
|
+
changed? || embedded_associations_changed?
|
|
293
295
|
end
|
|
294
296
|
|
|
295
297
|
def to_h
|
|
@@ -347,6 +349,8 @@ module ActiveItem
|
|
|
347
349
|
end
|
|
348
350
|
|
|
349
351
|
def save(validate: true)
|
|
352
|
+
return save_via_parent(validate: validate) if self.class.embedded? && !@_saving_via_parent
|
|
353
|
+
|
|
350
354
|
return false if validate && !run_validations
|
|
351
355
|
|
|
352
356
|
# If inside a transaction block, enroll this save in the transaction
|
|
@@ -377,12 +381,16 @@ module ActiveItem
|
|
|
377
381
|
end
|
|
378
382
|
|
|
379
383
|
def self.create(attributes = {})
|
|
384
|
+
raise_embedded_error!(:create) if respond_to?(:embedded?) && embedded?
|
|
385
|
+
|
|
380
386
|
obj = new(attributes)
|
|
381
387
|
obj.save
|
|
382
388
|
obj
|
|
383
389
|
end
|
|
384
390
|
|
|
385
391
|
def self.create!(attributes = {})
|
|
392
|
+
raise_embedded_error!(:create!) if respond_to?(:embedded?) && embedded?
|
|
393
|
+
|
|
386
394
|
obj = new(attributes)
|
|
387
395
|
obj.save!
|
|
388
396
|
obj
|
|
@@ -442,6 +450,8 @@ module ActiveItem
|
|
|
442
450
|
end
|
|
443
451
|
|
|
444
452
|
def destroy
|
|
453
|
+
return destroy_via_parent if self.class.embedded? && !@_destroying_via_parent
|
|
454
|
+
|
|
445
455
|
# If inside a transaction block, enroll this destroy in the transaction
|
|
446
456
|
return enroll_destroy_in_transaction if Transaction.active?
|
|
447
457
|
|
|
@@ -449,6 +459,8 @@ module ActiveItem
|
|
|
449
459
|
return false if result == false
|
|
450
460
|
|
|
451
461
|
true
|
|
462
|
+
rescue ActiveItem::EmbeddedModelError
|
|
463
|
+
raise
|
|
452
464
|
rescue DeleteRestrictionError
|
|
453
465
|
false
|
|
454
466
|
rescue StandardError => e
|
|
@@ -552,10 +564,149 @@ module ActiveItem
|
|
|
552
564
|
|
|
553
565
|
def run_validations
|
|
554
566
|
context = new_record? ? :create : :update
|
|
555
|
-
valid?(context)
|
|
567
|
+
return false unless valid?(context)
|
|
568
|
+
|
|
569
|
+
# Cascade validations into embedded collections
|
|
570
|
+
validate_embedded_associations
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Hydrate embedded associations from a DynamoDB item hash.
|
|
574
|
+
def populate_embedded_associations(item)
|
|
575
|
+
self.class._embedded_associations.each do |assoc_name, config|
|
|
576
|
+
dynamo_key = self.class.to_dynamo_key(assoc_name.to_s)
|
|
577
|
+
raw_array = item[dynamo_key]
|
|
578
|
+
next unless raw_array.is_a?(Array)
|
|
579
|
+
|
|
580
|
+
target_class = safe_constantize_model(config[:class_name])
|
|
581
|
+
records = raw_array.map do |hash|
|
|
582
|
+
record = Embeddable.from_embedded_hash(target_class, hash)
|
|
583
|
+
record.instance_variable_set(:@_embedded_parent, self)
|
|
584
|
+
record
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
collection = EmbeddedCollection.new(
|
|
588
|
+
owner: self,
|
|
589
|
+
association_name: assoc_name,
|
|
590
|
+
target_class: target_class,
|
|
591
|
+
records: records
|
|
592
|
+
)
|
|
593
|
+
instance_variable_set(:"@_embedded_#{assoc_name}", collection)
|
|
594
|
+
|
|
595
|
+
# Snapshot for dirty tracking
|
|
596
|
+
instance_variable_set(:"@_embedded_#{assoc_name}_snapshot", serialize_embedded_snapshot(collection))
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Validate all embedded associations. Returns true if all valid, false otherwise.
|
|
601
|
+
def validate_embedded_associations
|
|
602
|
+
all_valid = true
|
|
603
|
+
|
|
604
|
+
self.class._embedded_associations.each_key do |assoc_name|
|
|
605
|
+
collection = send(assoc_name)
|
|
606
|
+
collection.each_with_index do |record, idx|
|
|
607
|
+
next if record.valid?
|
|
608
|
+
|
|
609
|
+
all_valid = false
|
|
610
|
+
record.errors.each do |error|
|
|
611
|
+
errors.add(:"#{assoc_name}[#{idx}].#{error.attribute}", error.message)
|
|
612
|
+
end
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
all_valid
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# Run callbacks on embedded records during parent save.
|
|
620
|
+
def run_embedded_callbacks(callback_type)
|
|
621
|
+
self.class._embedded_associations.each_key do |assoc_name|
|
|
622
|
+
collection = send(assoc_name)
|
|
623
|
+
collection.each do |record|
|
|
624
|
+
record.instance_variable_set(:@_saving_via_parent, true)
|
|
625
|
+
record.run_callbacks(callback_type) { nil } if record.respond_to?(:run_callbacks, true)
|
|
626
|
+
record.instance_variable_set(:@_saving_via_parent, false)
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Delegate save from an embedded record to its parent.
|
|
632
|
+
# This allows `thing.save` and `thing.update(attrs)` to work
|
|
633
|
+
# by persisting through the parent record.
|
|
634
|
+
def save_via_parent(validate: true)
|
|
635
|
+
parent = instance_variable_get(:@_embedded_parent)
|
|
636
|
+
unless parent
|
|
637
|
+
raise ActiveItem::EmbeddedModelError,
|
|
638
|
+
"Cannot save #{self.class.name} — no parent record. " \
|
|
639
|
+
"Build embedded records through the parent's collection (e.g., parent.things.build)."
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
return false if validate && respond_to?(:valid?) && !valid?
|
|
643
|
+
|
|
644
|
+
parent.save(validate: validate)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Delegate destroy from an embedded record to its parent.
|
|
648
|
+
# Removes itself from the parent's collection and saves the parent.
|
|
649
|
+
def destroy_via_parent
|
|
650
|
+
parent = instance_variable_get(:@_embedded_parent)
|
|
651
|
+
unless parent
|
|
652
|
+
raise ActiveItem::EmbeddedModelError,
|
|
653
|
+
"Cannot destroy #{self.class.name} — no parent record."
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# Find which collection this record belongs to and remove it
|
|
657
|
+
self.class.superclass # ensure class is loaded
|
|
658
|
+
parent.class._embedded_associations.each do |assoc_name, config|
|
|
659
|
+
target_class = parent.send(:safe_constantize_model, config[:class_name])
|
|
660
|
+
next unless is_a?(target_class)
|
|
661
|
+
|
|
662
|
+
collection = parent.send(assoc_name)
|
|
663
|
+
collection.records.delete(self)
|
|
664
|
+
break
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
parent.save
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
# Snapshot the serialized state of an embedded collection for dirty comparison.
|
|
671
|
+
def serialize_embedded_snapshot(collection)
|
|
672
|
+
collection.map(&:to_embedded_hash)
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
# Check if any embedded collection has changed since load.
|
|
676
|
+
def embedded_associations_changed?
|
|
677
|
+
self.class._embedded_associations.any? do |assoc_name, _config|
|
|
678
|
+
current = send(assoc_name)
|
|
679
|
+
snapshot = instance_variable_get(:"@_embedded_#{assoc_name}_snapshot") || []
|
|
680
|
+
serialize_embedded_snapshot(current) != snapshot
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# Update snapshots after a successful write so subsequent saves
|
|
685
|
+
# don't re-persist unchanged embedded data.
|
|
686
|
+
def snapshot_embedded_associations
|
|
687
|
+
self.class._embedded_associations.each_key do |assoc_name|
|
|
688
|
+
collection = send(assoc_name)
|
|
689
|
+
instance_variable_set(:"@_embedded_#{assoc_name}_snapshot", serialize_embedded_snapshot(collection))
|
|
690
|
+
end
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Detect the DynamoDB 400KB item size limit error and re-raise
|
|
694
|
+
# as a friendlier ActiveItem::ItemTooLargeError.
|
|
695
|
+
def raise_if_item_too_large(error)
|
|
696
|
+
return unless error.message.include?('Item size') || error.message.include?('item size') ||
|
|
697
|
+
error.message.include?('400') || error.message.include?('exceeds')
|
|
698
|
+
|
|
699
|
+
raise ActiveItem::ItemTooLargeError.new(
|
|
700
|
+
model_name: self.class.name,
|
|
701
|
+
table: table_name,
|
|
702
|
+
original_error: error
|
|
703
|
+
)
|
|
556
704
|
end
|
|
557
705
|
|
|
558
706
|
def perform_create
|
|
707
|
+
run_embedded_callbacks(:create)
|
|
708
|
+
run_embedded_callbacks(:save)
|
|
709
|
+
|
|
559
710
|
item = build_dynamodb_item
|
|
560
711
|
item['createdAt'] = @created_at
|
|
561
712
|
item['updatedAt'] = Time.now.utc.iso8601
|
|
@@ -568,10 +719,14 @@ module ActiveItem
|
|
|
568
719
|
expression_attribute_names: { '#pk' => self.class.primary_key.to_s }
|
|
569
720
|
)
|
|
570
721
|
|
|
722
|
+
snapshot_embedded_associations
|
|
571
723
|
dynamo_logger.info("#{self.class.name} created (#{self.class.primary_key}: #{id})")
|
|
572
724
|
rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
|
|
573
725
|
errors.add(:id, 'already exists')
|
|
574
726
|
false
|
|
727
|
+
rescue Aws::DynamoDB::Errors::ValidationException => e
|
|
728
|
+
raise_if_item_too_large(e)
|
|
729
|
+
raise
|
|
575
730
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
576
731
|
raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
|
|
577
732
|
operation: 'PutItem', original_error: e)
|
|
@@ -588,6 +743,15 @@ module ActiveItem
|
|
|
588
743
|
item[dynamo_key] = value
|
|
589
744
|
end
|
|
590
745
|
|
|
746
|
+
# Serialize embedded collections
|
|
747
|
+
self.class._embedded_associations.each_key do |assoc_name|
|
|
748
|
+
collection = send(assoc_name)
|
|
749
|
+
next if collection.nil? || collection.empty?
|
|
750
|
+
|
|
751
|
+
dynamo_key = self.class.to_dynamo_key(assoc_name.to_s)
|
|
752
|
+
item[dynamo_key] = collection.map(&:to_embedded_hash)
|
|
753
|
+
end
|
|
754
|
+
|
|
591
755
|
item
|
|
592
756
|
end
|
|
593
757
|
|
|
@@ -599,19 +763,32 @@ module ActiveItem
|
|
|
599
763
|
attrs -= composed_attrs
|
|
600
764
|
end
|
|
601
765
|
|
|
766
|
+
# Embedded associations are serialized separately in build_dynamodb_item
|
|
767
|
+
if self.class._embedded_associations.any?
|
|
768
|
+
embedded_names = self.class._embedded_associations.keys.map(&:to_s)
|
|
769
|
+
attrs -= embedded_names
|
|
770
|
+
end
|
|
771
|
+
|
|
602
772
|
attrs
|
|
603
773
|
end
|
|
604
774
|
|
|
605
775
|
def perform_update
|
|
606
|
-
|
|
776
|
+
embedded_changed = embedded_associations_changed?
|
|
777
|
+
return if changes.empty? && !embedded_changed
|
|
778
|
+
|
|
779
|
+
run_embedded_callbacks(:update) if embedded_changed
|
|
780
|
+
run_embedded_callbacks(:save) if embedded_changed
|
|
607
781
|
|
|
608
782
|
update_parts = []
|
|
609
783
|
remove_parts = []
|
|
610
784
|
attr_values = {}
|
|
611
785
|
attr_names = {}
|
|
786
|
+
idx = 0
|
|
612
787
|
|
|
613
|
-
changes.
|
|
788
|
+
changes.each do |field, (_old_val, new_val)|
|
|
614
789
|
next if field == 'updated_at'
|
|
790
|
+
# Skip embedded association fields — handled separately below
|
|
791
|
+
next if self.class._embedded_associations.key?(field.to_sym)
|
|
615
792
|
|
|
616
793
|
dynamo_key = self.class.to_dynamo_key(field)
|
|
617
794
|
if new_val.nil?
|
|
@@ -622,6 +799,26 @@ module ActiveItem
|
|
|
622
799
|
attr_names["#field#{idx}"] = dynamo_key
|
|
623
800
|
attr_values[":val#{idx}"] = new_val
|
|
624
801
|
end
|
|
802
|
+
idx += 1
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
# Serialize changed embedded associations into the update expression
|
|
806
|
+
self.class._embedded_associations.each_key do |assoc_name|
|
|
807
|
+
collection = send(assoc_name)
|
|
808
|
+
snapshot = instance_variable_get(:"@_embedded_#{assoc_name}_snapshot") || []
|
|
809
|
+
current_serialized = serialize_embedded_snapshot(collection)
|
|
810
|
+
next if current_serialized == snapshot
|
|
811
|
+
|
|
812
|
+
dynamo_key = self.class.to_dynamo_key(assoc_name.to_s)
|
|
813
|
+
if collection.empty?
|
|
814
|
+
remove_parts << "#field#{idx}"
|
|
815
|
+
attr_names["#field#{idx}"] = dynamo_key
|
|
816
|
+
else
|
|
817
|
+
update_parts << "#field#{idx} = :val#{idx}"
|
|
818
|
+
attr_names["#field#{idx}"] = dynamo_key
|
|
819
|
+
attr_values[":val#{idx}"] = current_serialized
|
|
820
|
+
end
|
|
821
|
+
idx += 1
|
|
625
822
|
end
|
|
626
823
|
|
|
627
824
|
update_parts << '#updatedAt = :updatedAt'
|
|
@@ -640,6 +837,10 @@ module ActiveItem
|
|
|
640
837
|
params[:expression_attribute_names] = attr_names if attr_names.any?
|
|
641
838
|
|
|
642
839
|
dynamodb.update_item(params)
|
|
840
|
+
snapshot_embedded_associations
|
|
841
|
+
rescue Aws::DynamoDB::Errors::ValidationException => e
|
|
842
|
+
raise_if_item_too_large(e)
|
|
843
|
+
raise
|
|
643
844
|
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
|
|
644
845
|
raise ActiveItem::AccessDeniedError.new(model_name: self.class.name, table: table_name,
|
|
645
846
|
operation: 'UpdateItem', original_error: e)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
module ActiveItem
|
|
6
|
+
# Mixin for models that are stored embedded within a parent record
|
|
7
|
+
# rather than in their own DynamoDB table.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# class Thing < ActiveItem::Base
|
|
11
|
+
# self.embedded = true
|
|
12
|
+
# attr_accessor :name, :weight
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Embedded models:
|
|
16
|
+
# - Cannot be queried directly (no table)
|
|
17
|
+
# - Cannot call find, where, all, count, etc.
|
|
18
|
+
# - Are serialized/deserialized by their parent
|
|
19
|
+
# - Still support validations, callbacks, and dirty tracking
|
|
20
|
+
module Embeddable
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
included do
|
|
24
|
+
class_attribute :embedded, default: false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class_methods do
|
|
28
|
+
def embedded?
|
|
29
|
+
embedded == true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Instance methods — prevent direct save/destroy on embedded models
|
|
34
|
+
# are handled in Base#save and Base#destroy directly.
|
|
35
|
+
|
|
36
|
+
# Serialize this embedded record to a DynamoDB-compatible hash.
|
|
37
|
+
def to_embedded_hash
|
|
38
|
+
item = { 'id' => id || SecureRandom.uuid }
|
|
39
|
+
|
|
40
|
+
self.class.attribute_names.each do |attr_name|
|
|
41
|
+
next if %w[id dbrecord].include?(attr_name)
|
|
42
|
+
|
|
43
|
+
value = instance_variable_get("@#{attr_name}")
|
|
44
|
+
next if value.nil?
|
|
45
|
+
|
|
46
|
+
dynamo_key = self.class.to_dynamo_key(attr_name)
|
|
47
|
+
item[dynamo_key] = value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
item['createdAt'] = @created_at if @created_at
|
|
51
|
+
item['updatedAt'] = @updated_at if @updated_at
|
|
52
|
+
item
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Hydrate an embedded record from a DynamoDB hash.
|
|
56
|
+
def self.from_embedded_hash(klass, hash)
|
|
57
|
+
record = klass.allocate
|
|
58
|
+
record.instance_variable_set(:@id, hash['id'])
|
|
59
|
+
record.instance_variable_set(:@new_record, false)
|
|
60
|
+
record.instance_variable_set(:@created_at, hash['createdAt'])
|
|
61
|
+
record.instance_variable_set(:@updated_at, hash['updatedAt'])
|
|
62
|
+
|
|
63
|
+
klass.attribute_names.each do |attr_name|
|
|
64
|
+
next if %w[id dbrecord].include?(attr_name)
|
|
65
|
+
|
|
66
|
+
value = nil
|
|
67
|
+
found = false
|
|
68
|
+
klass.dynamo_key_variants(attr_name).each do |key|
|
|
69
|
+
next unless hash.key?(key)
|
|
70
|
+
|
|
71
|
+
value = hash[key]
|
|
72
|
+
found = true
|
|
73
|
+
break
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
record.instance_variable_set("@#{attr_name}", value) if found
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
record.send(:clear_changes_information) if record.respond_to?(:clear_changes_information, true)
|
|
80
|
+
record
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module ActiveItem
|
|
6
|
+
# In-memory collection for embedded model instances.
|
|
7
|
+
# Provides Enumerable access plus ActiveItem-flavored query methods
|
|
8
|
+
# (where, find, build, etc.) that operate on the already-loaded array.
|
|
9
|
+
class EmbeddedCollection
|
|
10
|
+
include Enumerable
|
|
11
|
+
|
|
12
|
+
attr_reader :records, :association_name, :owner, :target_class
|
|
13
|
+
|
|
14
|
+
def initialize(owner:, association_name:, target_class:, records: [])
|
|
15
|
+
@owner = owner
|
|
16
|
+
@association_name = association_name
|
|
17
|
+
@target_class = target_class
|
|
18
|
+
@records = records.dup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def each(&)
|
|
22
|
+
@records.each(&)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def length
|
|
26
|
+
@records.length
|
|
27
|
+
end
|
|
28
|
+
alias size length
|
|
29
|
+
alias count length
|
|
30
|
+
|
|
31
|
+
def empty?
|
|
32
|
+
@records.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def any?(&block)
|
|
36
|
+
block ? @records.any?(&block) : @records.any?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def first
|
|
40
|
+
@records.first
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def last
|
|
44
|
+
@records.last
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_a
|
|
48
|
+
@records.dup
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# In-memory find by ID.
|
|
52
|
+
def find(id)
|
|
53
|
+
record = @records.find { |r| r.id == id }
|
|
54
|
+
raise ActiveItem::RecordNotFound, "Couldn't find #{@target_class.name} with id '#{id}'" unless record
|
|
55
|
+
|
|
56
|
+
record
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# In-memory filtering by attribute conditions.
|
|
60
|
+
# Returns a new EmbeddedCollection with matching records.
|
|
61
|
+
def where(conditions = {})
|
|
62
|
+
filtered = @records.select do |record|
|
|
63
|
+
conditions.all? do |attr, value|
|
|
64
|
+
record.send(attr) == value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
self.class.new(
|
|
69
|
+
owner: @owner,
|
|
70
|
+
association_name: @association_name,
|
|
71
|
+
target_class: @target_class,
|
|
72
|
+
records: filtered
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build a new embedded record, add it to the collection, and mark
|
|
77
|
+
# the parent as dirty.
|
|
78
|
+
def build(attributes = {})
|
|
79
|
+
record = @target_class.new(attributes)
|
|
80
|
+
record.instance_variable_set(:@id, SecureRandom.uuid) unless record.id
|
|
81
|
+
record.instance_variable_set(:@_embedded_parent, @owner)
|
|
82
|
+
@records << record
|
|
83
|
+
mark_owner_dirty!
|
|
84
|
+
record
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Build, validate, and persist by saving the parent.
|
|
88
|
+
# Matches ActiveRecord semantics where create! actually persists.
|
|
89
|
+
def create!(attributes = {})
|
|
90
|
+
record = build(attributes)
|
|
91
|
+
unless record.valid?
|
|
92
|
+
@records.delete(record)
|
|
93
|
+
mark_owner_dirty!
|
|
94
|
+
raise ActiveItem::RecordInvalid, record
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
@owner.save!
|
|
98
|
+
record
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Remove a record from the collection by reference or ID.
|
|
102
|
+
def delete(record_or_id)
|
|
103
|
+
record = record_or_id.is_a?(String) ? find(record_or_id) : record_or_id
|
|
104
|
+
@records.delete(record)
|
|
105
|
+
mark_owner_dirty!
|
|
106
|
+
record
|
|
107
|
+
end
|
|
108
|
+
alias destroy delete
|
|
109
|
+
|
|
110
|
+
# Replace the entire collection.
|
|
111
|
+
def replace(new_records)
|
|
112
|
+
@records = new_records.dup
|
|
113
|
+
mark_owner_dirty!
|
|
114
|
+
self
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def <<(record)
|
|
118
|
+
record.instance_variable_set(:@_embedded_parent, @owner)
|
|
119
|
+
@records << record
|
|
120
|
+
mark_owner_dirty!
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
alias push <<
|
|
124
|
+
|
|
125
|
+
def inspect
|
|
126
|
+
"#<#{self.class.name} [#{@records.map(&:inspect).join(', ')}]>"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def mark_owner_dirty!
|
|
132
|
+
attr_name = @association_name.to_s
|
|
133
|
+
@owner.send("#{attr_name}_will_change!") if @owner.respond_to?("#{attr_name}_will_change!", true)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/active_item/errors.rb
CHANGED
|
@@ -62,4 +62,24 @@ module ActiveItem
|
|
|
62
62
|
super("Cannot delete record because dependent #{association_name} exist")
|
|
63
63
|
end
|
|
64
64
|
end
|
|
65
|
+
|
|
66
|
+
# Raised when attempting to perform table-level operations on an embedded
|
|
67
|
+
# model (one that has self.embedded = true and no DynamoDB table).
|
|
68
|
+
class EmbeddedModelError < StandardError; end
|
|
69
|
+
|
|
70
|
+
# Raised when a DynamoDB put/update fails because the item exceeds the
|
|
71
|
+
# 400KB size limit — typically due to large embedded collections.
|
|
72
|
+
class ItemTooLargeError < StandardError
|
|
73
|
+
attr_reader :model_name, :table
|
|
74
|
+
|
|
75
|
+
def initialize(model_name:, table:, original_error: nil)
|
|
76
|
+
@model_name = model_name
|
|
77
|
+
@table = table
|
|
78
|
+
msg = "Item exceeds DynamoDB's 400KB limit when saving #{model_name} to #{table}. " \
|
|
79
|
+
'The embedded collection(s) may have too many records. ' \
|
|
80
|
+
'Consider reducing the number of embedded items or moving them to a separate table.'
|
|
81
|
+
msg += " (Original: #{original_error.message})" if original_error
|
|
82
|
+
super(msg)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
65
85
|
end
|
|
@@ -7,6 +7,8 @@ module ActiveItem
|
|
|
7
7
|
# counting, existence checks, and automatic GSI index detection.
|
|
8
8
|
module QueryHelpers
|
|
9
9
|
def find(id)
|
|
10
|
+
raise_embedded_error!(:find) if respond_to?(:embedded?) && embedded?
|
|
11
|
+
|
|
10
12
|
record = get({ primary_key.to_s => id })
|
|
11
13
|
raise ActiveItem::RecordNotFound, "Couldn't find #{name} with '#{primary_key}'=#{id}" unless record
|
|
12
14
|
|
|
@@ -170,6 +172,8 @@ module ActiveItem
|
|
|
170
172
|
# # Equivalent to: Customer.batch_find(['cust-1', 'cust-2', 'cust-3'])
|
|
171
173
|
#
|
|
172
174
|
def where(**conditions)
|
|
175
|
+
raise_embedded_error!(:where) if respond_to?(:embedded?) && embedded?
|
|
176
|
+
|
|
173
177
|
# If no conditions, return a Relation that supports .not() chaining
|
|
174
178
|
return Relation.new(self) if conditions.empty?
|
|
175
179
|
|
|
@@ -201,6 +205,8 @@ module ActiveItem
|
|
|
201
205
|
|
|
202
206
|
# Returns a Relation for all records (enables chaining from .all)
|
|
203
207
|
def all(limit: nil)
|
|
208
|
+
raise_embedded_error!(:all) if respond_to?(:embedded?) && embedded?
|
|
209
|
+
|
|
204
210
|
if limit
|
|
205
211
|
Relation.new(self, limit_value: limit)
|
|
206
212
|
else
|
|
@@ -255,6 +261,8 @@ module ActiveItem
|
|
|
255
261
|
# Customer.count { |c| c.email.include?('@gmail.com') } # => 15
|
|
256
262
|
#
|
|
257
263
|
def count(**conditions, &)
|
|
264
|
+
raise_embedded_error!(:count) if respond_to?(:embedded?) && embedded?
|
|
265
|
+
|
|
258
266
|
if block_given?
|
|
259
267
|
# Block provided - load all records and count with Ruby
|
|
260
268
|
all.count(&)
|
|
@@ -288,6 +296,8 @@ module ActiveItem
|
|
|
288
296
|
# @param id_or_conditions [String, Hash] Primary key value or attribute conditions
|
|
289
297
|
# @return [Boolean] true if a record exists matching the conditions
|
|
290
298
|
def exists?(id_or_conditions = nil, **conditions)
|
|
299
|
+
raise_embedded_error!(:exists?) if respond_to?(:embedded?) && embedded?
|
|
300
|
+
|
|
291
301
|
# Handle single ID parameter: Customer.exists?('cust-123')
|
|
292
302
|
return !!get({ primary_key.to_s => id_or_conditions }) if id_or_conditions.is_a?(String) && conditions.empty?
|
|
293
303
|
|
|
@@ -618,5 +628,11 @@ module ActiveItem
|
|
|
618
628
|
}
|
|
619
629
|
end
|
|
620
630
|
end
|
|
631
|
+
|
|
632
|
+
def raise_embedded_error!(method_name)
|
|
633
|
+
raise ActiveItem::EmbeddedModelError,
|
|
634
|
+
"Cannot call .#{method_name} on #{name} because it is an embedded model " \
|
|
635
|
+
'(no DynamoDB table). Access embedded records through their parent association.'
|
|
636
|
+
end
|
|
621
637
|
end
|
|
622
638
|
end
|
data/lib/active_item/version.rb
CHANGED
data/lib/activeitem.rb
CHANGED
|
@@ -7,6 +7,8 @@ require_relative 'active_item/errors'
|
|
|
7
7
|
require_relative 'active_item/database_helpers'
|
|
8
8
|
require_relative 'active_item/query_helpers'
|
|
9
9
|
require_relative 'active_item/relation'
|
|
10
|
+
require_relative 'active_item/embedded_collection'
|
|
11
|
+
require_relative 'active_item/embeddable'
|
|
10
12
|
require_relative 'active_item/associations'
|
|
11
13
|
require_relative 'active_item/composed_of'
|
|
12
14
|
require_relative 'active_item/validations'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: activeitem
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.20
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andy Davis
|
|
@@ -160,6 +160,8 @@ files:
|
|
|
160
160
|
- lib/active_item/composed_of.rb
|
|
161
161
|
- lib/active_item/configuration.rb
|
|
162
162
|
- lib/active_item/database_helpers.rb
|
|
163
|
+
- lib/active_item/embeddable.rb
|
|
164
|
+
- lib/active_item/embedded_collection.rb
|
|
163
165
|
- lib/active_item/errors.rb
|
|
164
166
|
- lib/active_item/logging.rb
|
|
165
167
|
- lib/active_item/model_loader.rb
|