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 +4 -4
- data/CHANGELOG.md +44 -0
- data/lib/active_item/associations.rb +85 -2
- data/lib/active_item/base.rb +74 -4
- data/lib/active_item/errors.rb +11 -0
- data/lib/active_item/relation.rb +5 -0
- data/lib/active_item/transaction.rb +34 -0
- data/lib/active_item/validations.rb +42 -0
- data/lib/active_item/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7992e08eadef33c715ba67e3869454bca7cf174cae56e419982b81de608075c6
|
|
4
|
+
data.tar.gz: dc1e6e803c91bb774f7e0c2edf43f6ca744ac10221bba26b8a5a4a4d7466f425
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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]
|
data/lib/active_item/base.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
data/lib/active_item/errors.rb
CHANGED
|
@@ -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
|
data/lib/active_item/relation.rb
CHANGED
|
@@ -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
|
data/lib/active_item/version.rb
CHANGED
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.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-
|
|
12
|
+
date: 2026-08-05 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activemodel
|