activeitem 0.0.17 → 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: 3a4b1c7cc7af49e67776f895886bdf472d1e801ab3280f3ea28101bd20b8a701
4
- data.tar.gz: 7415931df8a26b187e4a9ab52586b67c7cea08f0fa3a476ab09b863c02250835
3
+ metadata.gz: 7992e08eadef33c715ba67e3869454bca7cf174cae56e419982b81de608075c6
4
+ data.tar.gz: dc1e6e803c91bb774f7e0c2edf43f6ca744ac10221bba26b8a5a4a4d7466f425
5
5
  SHA512:
6
- metadata.gz: b8c97c1ffadb921d440702b5299cf4bb219ba97ad0351cef7bebfea1125e74bf027ddc0546e1a254da8d6da8952598bf170368404c6ab65074ecc8379bfaec3b
7
- data.tar.gz: b9936169d15857e1423de8f622ddfe85b7be05458739f569ce1983dd6565f234ae2789f12978ac530106cc98203906a99b9666805d1e2e43c7e0a275e7c41d06
6
+ metadata.gz: ee3f192c4bc8d0ea583f85462cb28841092e0389ed7682b6cfec753f446b687174385f74b81fa58ddc411c34b7dea1fd348aeea243a1109b0ae1ae0915565036
7
+ data.tar.gz: f21580968c7db755fbc2c75e4cdb2cf8c9b3ab7f400a5a83954fb297215a78ec9adf4968f2ed3e526d771747c80460d50a8b7c23f70c6e2489a5d8e84f03b4a2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
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
+
27
+ ## 0.0.18
28
+
29
+ ### Added
30
+
31
+ - **Transactional saves** — `save`, `save!`, `destroy`, and `destroy!` now automatically enroll in the current transaction when called inside a `Model.transaction` block. This enables an ActiveRecord-style implicit API:
32
+
33
+ ```ruby
34
+ Model.transaction do
35
+ record1.save!
36
+ record2.save!
37
+ record3.destroy!
38
+ end
39
+ ```
40
+
41
+ All operations are committed atomically at block end. The explicit API (`txn.put(record)`) continues to work for backwards compatibility.
42
+
43
+ - **RecordInvalid exception** — `save!` now raises `ActiveItem::RecordInvalid` with the record attached, instead of a generic `StandardError`. This matches ActiveRecord behavior.
44
+
45
+ - **Transaction.active?** — class method to check if code is executing inside a transaction block.
46
+
3
47
  ## 0.0.13
4
48
 
5
49
  ### Changed
@@ -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]
@@ -349,6 +349,9 @@ module ActiveItem
349
349
  def save(validate: true)
350
350
  return false if validate && !run_validations
351
351
 
352
+ # If inside a transaction block, enroll this save in the transaction
353
+ return enroll_in_transaction if Transaction.active?
354
+
352
355
  result = run_callbacks :save do
353
356
  if new_record?
354
357
  run_callbacks(:create) { perform_create }
@@ -368,7 +371,9 @@ module ActiveItem
368
371
  end
369
372
 
370
373
  def save!
371
- raise StandardError, "Validation failed: #{errors.full_messages.join(', ')}" unless save
374
+ raise RecordInvalid.new(self), "Validation failed: #{errors.full_messages.join(', ')}" unless save
375
+
376
+ true
372
377
  end
373
378
 
374
379
  def self.create(attributes = {})
@@ -383,10 +388,39 @@ module ActiveItem
383
388
  obj
384
389
  end
385
390
 
391
+ # Execute a block within a transaction context.
392
+ #
393
+ # Supports two usage patterns:
394
+ #
395
+ # 1. Explicit API (block receives transaction):
396
+ # Model.transaction do |txn|
397
+ # txn.put(record1)
398
+ # txn.update(record2)
399
+ # end
400
+ #
401
+ # 2. Implicit API (transactional saves):
402
+ # Model.transaction do
403
+ # record1.save!
404
+ # record2.save!
405
+ # record3.destroy!
406
+ # end
407
+ #
408
+ # In the implicit API, save/destroy calls are automatically enrolled.
409
+ # The transaction is committed when the block completes successfully.
410
+ # If an exception is raised, no changes are committed (all-or-nothing).
411
+ #
412
+ # @yield [Transaction] the transaction object (optional)
413
+ # @raise [TransactionError] if the transaction fails
386
414
  def self.transaction
387
415
  txn = Transaction.new
388
- yield txn
389
- txn.execute!
416
+ Transaction.current = txn
417
+ begin
418
+ # Support both explicit (yield txn) and implicit (no block param) APIs
419
+ yield txn if block_given?
420
+ txn.execute!
421
+ ensure
422
+ Transaction.current = nil
423
+ end
390
424
  end
391
425
 
392
426
  def self.transaction_find(items)
@@ -408,6 +442,9 @@ module ActiveItem
408
442
  end
409
443
 
410
444
  def destroy
445
+ # If inside a transaction block, enroll this destroy in the transaction
446
+ return enroll_destroy_in_transaction if Transaction.active?
447
+
411
448
  result = run_callbacks(:destroy) { perform_destroy }
412
449
  return false if result == false
413
450
 
@@ -421,7 +458,9 @@ module ActiveItem
421
458
  end
422
459
 
423
460
  def destroy!
424
- destroy || raise(RecordNotDestroyed.new(nil, self))
461
+ raise RecordNotDestroyed.new(nil, self) unless destroy
462
+
463
+ true
425
464
  end
426
465
 
427
466
  def delete
@@ -460,6 +499,37 @@ module ActiveItem
460
499
 
461
500
  private
462
501
 
502
+ # Enroll this record's save operation in the current transaction.
503
+ # Called when save is invoked inside a transaction block.
504
+ def enroll_in_transaction
505
+ txn = Transaction.current
506
+
507
+ result = run_callbacks :save do
508
+ if new_record?
509
+ run_callbacks(:create) { txn.put(self) }
510
+ else
511
+ run_callbacks(:update) { txn.update(self) }
512
+ end
513
+ end
514
+
515
+ return false if result == false
516
+
517
+ # Mark changes as applied (will be committed when transaction executes)
518
+ # Note: @new_record stays true until transaction.execute! completes
519
+ true
520
+ end
521
+
522
+ # Enroll this record's destroy operation in the current transaction.
523
+ # Called when destroy is invoked inside a transaction block.
524
+ def enroll_destroy_in_transaction
525
+ txn = Transaction.current
526
+
527
+ result = run_callbacks(:destroy) { txn.delete(self) }
528
+ return false if result == false
529
+
530
+ true
531
+ end
532
+
463
533
  def generate_primary_key
464
534
  @id = nil if @id.to_s.strip.empty?
465
535
  @id ||= SecureRandom.uuid
@@ -4,6 +4,17 @@ module ActiveItem
4
4
  class RecordNotFound < StandardError; end
5
5
  class TransactionError < StandardError; end
6
6
 
7
+ # Raised by save! when validations fail.
8
+ class RecordInvalid < StandardError
9
+ attr_reader :record
10
+
11
+ def initialize(record = nil)
12
+ @record = record
13
+ message = record ? "Validation failed: #{record.errors.full_messages.join(', ')}" : 'Validation failed'
14
+ super(message)
15
+ end
16
+ end
17
+
7
18
  # Raised when an IAM policy denies a DynamoDB operation on a table.
8
19
  class AccessDeniedError < StandardError
9
20
  attr_reader :model_name, :table, :operation, :original_error
@@ -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:
@@ -3,15 +3,49 @@
3
3
  module ActiveItem
4
4
  # Wraps DynamoDB TransactWriteItems, allowing multiple put, update, and
5
5
  # delete operations to be committed atomically (up to 100 items).
6
+ #
7
+ # Supports two usage patterns:
8
+ #
9
+ # 1. Explicit API (original):
10
+ # Model.transaction do |txn|
11
+ # txn.put(record1)
12
+ # txn.update(record2)
13
+ # end
14
+ #
15
+ # 2. Implicit API (transactional saves):
16
+ # Model.transaction do
17
+ # record1.save!
18
+ # record2.save!
19
+ # record3.destroy!
20
+ # end
21
+ #
22
+ # In the implicit API, save/destroy calls inside the block are automatically
23
+ # enrolled in the transaction and committed atomically at block end.
6
24
  class Transaction
7
25
  MAX_ITEMS = 100
8
26
 
9
27
  attr_reader :operations
10
28
 
29
+ class << self
30
+ # Thread-local storage for the current transaction context
31
+ def current
32
+ Thread.current[:activeitem_current_transaction]
33
+ end
34
+
35
+ def current=(txn)
36
+ Thread.current[:activeitem_current_transaction] = txn
37
+ end
38
+ end
39
+
11
40
  def initialize
12
41
  @operations = []
13
42
  end
14
43
 
44
+ # Check if we're inside a transaction block
45
+ def self.active?
46
+ !current.nil?
47
+ end
48
+
15
49
  def put(record, condition: nil)
16
50
  record.instance_variable_set(:@id, SecureRandom.uuid) unless record.id
17
51
  pk = record.class.primary_key
@@ -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.17'
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.17
4
+ version: 0.0.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2026-08-04 00:00:00.000000000 Z
12
+ date: 2026-08-05 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activemodel