activeitem 0.0.18 → 0.0.19

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: 89a17fbb7cce2459ac2ab8c4edf9e981e12193571b273c149b3d2d74511dba9e
4
- data.tar.gz: 1cb5a9301704de43df7fd601c841183bbfdb5b140bac9e2f7ff2b4e729b77f70
3
+ metadata.gz: 7992e08eadef33c715ba67e3869454bca7cf174cae56e419982b81de608075c6
4
+ data.tar.gz: dc1e6e803c91bb774f7e0c2edf43f6ca744ac10221bba26b8a5a4a4d7466f425
5
5
  SHA512:
6
- metadata.gz: 4e7ac3bb2d79938a0aec2d860fc1cd106472e8d24b27166dc0afccaba2fac740faed88d2753e664f96f3149937e6b0209c35d316bfa84b7c020e45e601fa8ab2
7
- data.tar.gz: 9c9c2bac36a241539eab5ebc44dd428d3b91b2780b18dd1660e5b3a997fc151669ea46df521b29e5636e26e6f444063a4bf49d059940bf0d03f3bc5bfeb5928c
6
+ metadata.gz: ee3f192c4bc8d0ea583f85462cb28841092e0389ed7682b6cfec753f446b687174385f74b81fa58ddc411c34b7dea1fd348aeea243a1109b0ae1ae0915565036
7
+ data.tar.gz: f21580968c7db755fbc2c75e4cdb2cf8c9b3ab7f400a5a83954fb297215a78ec9adf4968f2ed3e526d771747c80460d50a8b7c23f70c6e2489a5d8e84f03b4a2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.19
4
+
5
+ ### Added
6
+
7
+ - **`validates_associated`** — Validates that associated records (loaded via `has_many`) are valid before saving the parent. Only checks in-memory/loaded records — does not trigger DB queries for unloaded associations.
8
+
9
+ - **`accepts_nested_attributes_for`** — Define nested attribute writers for has_many associations. Supports create, update, and destroy of child records through the parent's save. Nested saves are now wrapped in a DynamoDB transaction for atomicity — if any child operation fails, none are committed.
10
+
11
+ ```ruby
12
+ class Conversation < ActiveItem::Base
13
+ has_many :messages
14
+ accepts_nested_attributes_for :messages, allow_destroy: true
15
+ end
16
+
17
+ conversation.messages_attributes = [
18
+ { role: 'user', body: 'Hello' },
19
+ { id: 'msg-1', body: 'Updated text' },
20
+ { id: 'msg-2', _destroy: true }
21
+ ]
22
+ conversation.save # atomic: all children created/updated/destroyed together
23
+ ```
24
+
25
+ - **`Relation#loaded?`** — Check whether a has_many relation's records have been loaded into memory.
26
+
3
27
  ## 0.0.18
4
28
 
5
29
  ### Added
@@ -152,9 +152,92 @@ module ActiveItem
152
152
  index_name => { partition_key: dynamo_key }
153
153
  )
154
154
  end
155
- end
156
155
 
157
- private
156
+ # Allows nested attributes for associated records, similar to Rails.
157
+ # Defines a writer method that builds or updates child records through the parent.
158
+ #
159
+ # @example
160
+ # class Conversation < ApplicationRecord
161
+ # has_many :messages
162
+ # accepts_nested_attributes_for :messages
163
+ # end
164
+ #
165
+ # conversation.messages_attributes = [
166
+ # { role: 'user', body: 'Hello' },
167
+ # { role: 'assistant', body: 'Hi there' }
168
+ # ]
169
+ # conversation.save # saves parent and creates children
170
+ #
171
+ # @param associations [Array<Symbol>] Association names to accept nested attributes for
172
+ # @param options [Hash] Options (allow_destroy: true to enable _destroy flag)
173
+ def accepts_nested_attributes_for(*associations, **options)
174
+ associations.each do |association_name|
175
+ define_nested_attributes_writer(association_name, options)
176
+ end
177
+ end
178
+
179
+ private
180
+
181
+ def define_nested_attributes_writer(association_name, options)
182
+ writer_method = :"#{association_name}_attributes="
183
+ allow_destroy = options.fetch(:allow_destroy, false)
184
+
185
+ # Store pending nested records for saving after parent
186
+ define_method(writer_method) do |attributes_collection|
187
+ attributes_collection = attributes_collection.values if attributes_collection.is_a?(Hash)
188
+ @_nested_attributes ||= {}
189
+ @_nested_attributes[association_name] = { records: attributes_collection, allow_destroy: allow_destroy }
190
+ end
191
+
192
+ # Hook into save to persist nested records
193
+ define_method(:save_with_nested) do
194
+ result = save_without_nested
195
+ return result unless result
196
+
197
+ save_nested_attributes
198
+ result
199
+ end
200
+
201
+ define_method(:save_nested_attributes) do
202
+ return unless instance_variable_defined?(:@_nested_attributes) && @_nested_attributes
203
+
204
+ self.class.transaction do
205
+ @_nested_attributes.each do |assoc_name, config|
206
+ assoc_config = self.class._associations[assoc_name]
207
+ next unless assoc_config
208
+
209
+ target_class = safe_constantize_model(assoc_config[:class_name])
210
+ foreign_key = assoc_config[:foreign_key]
211
+
212
+ config[:records].each do |attrs|
213
+ attrs = attrs.transform_keys(&:to_sym)
214
+
215
+ if attrs[:_destroy] && config[:allow_destroy]
216
+ if attrs[:id]
217
+ record = target_class.find(attrs[:id])
218
+ record.destroy!
219
+ end
220
+ elsif attrs[:id]
221
+ record = target_class.find(attrs[:id])
222
+ record.assign_attributes(attrs.except(:id, :_destroy))
223
+ record.save!
224
+ else
225
+ target_class.create!(attrs.merge(foreign_key.to_sym => id))
226
+ end
227
+ end
228
+ end
229
+ end
230
+
231
+ @_nested_attributes = nil
232
+ end
233
+
234
+ # Wrap save to include nested attributes
235
+ return if method_defined?(:save_without_nested)
236
+
237
+ alias_method :save_without_nested, :save
238
+ alias_method :save, :save_with_nested
239
+ end
240
+ end
158
241
 
159
242
  def load_has_many_association(name)
160
243
  config = self.class._associations[name]
@@ -31,6 +31,11 @@ module ActiveItem
31
31
  @select_attributes = select_attributes # Projection expression attributes
32
32
  end
33
33
 
34
+ # Whether the relation's records have been loaded from DynamoDB.
35
+ def loaded?
36
+ @loaded
37
+ end
38
+
34
39
  # Chainable includes - preload associations to avoid N+1 queries
35
40
  #
36
41
  # Supports three forms:
@@ -50,5 +50,47 @@ module ActiveItem
50
50
  def validates_uniqueness_of(*attributes, **options)
51
51
  validates(*attributes, uniqueness: options.empty? || options)
52
52
  end
53
+
54
+ # Validates that the associated records are valid.
55
+ # If any associated record is invalid, errors are added to the parent.
56
+ #
57
+ # @example
58
+ # class Conversation < ApplicationRecord
59
+ # has_many :messages
60
+ # validates_associated :messages
61
+ # end
62
+ #
63
+ # conversation.messages.build(role: "invalid")
64
+ # conversation.valid? # => false
65
+ # conversation.errors[:messages] # => ["is invalid"]
66
+ def validates_associated(*associations, **options)
67
+ validates(*associations, associated: options.empty? || options)
68
+ end
69
+ end
70
+
71
+ # ActiveModel validator that checks associated records are valid.
72
+ # Validates in-memory built records (via .build) and already-loaded records.
73
+ # Does not trigger a DB query — only checks what's already in memory.
74
+ class AssociatedValidator < ActiveModel::EachValidator
75
+ def validate_each(record, attribute, _value)
76
+ association_config = record.class._associations[attribute]
77
+ return unless association_config
78
+
79
+ associated = record.send(attribute)
80
+
81
+ # Only validate loaded/cached records — don't trigger a DB query
82
+ records = if associated.respond_to?(:loaded?) && associated.loaded?
83
+ associated.to_a
84
+ else
85
+ []
86
+ end
87
+
88
+ records.each do |child|
89
+ next if child.valid?
90
+
91
+ record.errors.add(attribute, :invalid, **options.except(:on))
92
+ break
93
+ end
94
+ end
53
95
  end
54
96
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.18'
4
+ VERSION = '0.0.19'
5
5
  end
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.18
4
+ version: 0.0.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis