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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7992e08eadef33c715ba67e3869454bca7cf174cae56e419982b81de608075c6
4
- data.tar.gz: dc1e6e803c91bb774f7e0c2edf43f6ca744ac10221bba26b8a5a4a4d7466f425
3
+ metadata.gz: 652850a906bc798042da7b5c3ea1396aeef9775057ad1199030354cff2b24b73
4
+ data.tar.gz: 55001585d52f922c371cd6ca6cc61beef3f6f5ea98e4ca268d0bfe1e6ebf8d5a
5
5
  SHA512:
6
- metadata.gz: ee3f192c4bc8d0ea583f85462cb28841092e0389ed7682b6cfec753f446b687174385f74b81fa58ddc411c34b7dea1fd348aeea243a1109b0ae1ae0915565036
7
- data.tar.gz: f21580968c7db755fbc2c75e4cdb2cf8c9b3ab7f400a5a83954fb297215a78ec9adf4968f2ed3e526d771747c80460d50a8b7c23f70c6e2489a5d8e84f03b4a2
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 = {})
@@ -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
- return if changes.empty?
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.each_with_index do |(field, (_old_val, new_val)), idx|
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)
@@ -67,7 +67,7 @@ module ActiveItem
67
67
  end
68
68
 
69
69
  def perform_update
70
- return if changes.empty?
70
+ return if changes.empty? && !embedded_associations_changed?
71
71
 
72
72
  compositions = self.class.compositions
73
73
  return super if compositions.empty?
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.19'
4
+ VERSION = '0.0.20'
5
5
  end
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.19
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