activeitem 0.0.16 → 0.0.18

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: 6460db8a74ca7bcb5d28c0bfdb7c4a6c1eba5c5d68318245612dad78d1d1d282
4
- data.tar.gz: 9fe2d9707efa0a41b74fa1c07c5c24453605baa5d24e3f1880c248c92c5a03b0
3
+ metadata.gz: 89a17fbb7cce2459ac2ab8c4edf9e981e12193571b273c149b3d2d74511dba9e
4
+ data.tar.gz: 1cb5a9301704de43df7fd601c841183bbfdb5b140bac9e2f7ff2b4e729b77f70
5
5
  SHA512:
6
- metadata.gz: 61ad96dc3dd1a28bda5a9af288bd17e1eef4f11b450f08efae26c1ea8f8060819727dde8d855d562e0fbbf2e6012096e159952d44c0d22255abc2384d85d80a9
7
- data.tar.gz: 741d0e3a257199f62373c16960bea2d90fe26e37e9f4dcb8516cfb049170caac8a237c90ef1ba17dde88b356218b12f7e5e7d5f4a9d96abee7f64225639762f8
6
+ metadata.gz: 4e7ac3bb2d79938a0aec2d860fc1cd106472e8d24b27166dc0afccaba2fac740faed88d2753e664f96f3149937e6b0209c35d316bfa84b7c020e45e601fa8ab2
7
+ data.tar.gz: 9c9c2bac36a241539eab5ebc44dd428d3b91b2780b18dd1660e5b3a997fc151669ea46df521b29e5636e26e6f444063a4bf49d059940bf0d03f3bc5bfeb5928c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.18
4
+
5
+ ### Added
6
+
7
+ - **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:
8
+
9
+ ```ruby
10
+ Model.transaction do
11
+ record1.save!
12
+ record2.save!
13
+ record3.destroy!
14
+ end
15
+ ```
16
+
17
+ All operations are committed atomically at block end. The explicit API (`txn.put(record)`) continues to work for backwards compatibility.
18
+
19
+ - **RecordInvalid exception** — `save!` now raises `ActiveItem::RecordInvalid` with the record attached, instead of a generic `StandardError`. This matches ActiveRecord behavior.
20
+
21
+ - **Transaction.active?** — class method to check if code is executing inside a transaction block.
22
+
3
23
  ## 0.0.13
4
24
 
5
25
  ### Changed
@@ -143,9 +143,13 @@ module ActiveItem
143
143
  "#{association_name.to_s.camelize}Index"
144
144
  end
145
145
 
146
+ # Store partition key in DynamoDB format (camelCase) for consistency
147
+ # with RECENT_INDEX and explicit index definitions
148
+ dynamo_key = to_dynamo_key(foreign_key)
149
+
146
150
  @_belongs_to_indexes ||= {}
147
151
  @_belongs_to_indexes = @_belongs_to_indexes.merge(
148
- index_name => { partition_key: foreign_key }
152
+ index_name => { partition_key: dynamo_key }
149
153
  )
150
154
  end
151
155
  end
@@ -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
@@ -547,8 +547,7 @@ module ActiveItem
547
547
  if effective_index && normalized_conditions.any? && normalized_conditions.values.first.is_a?(Array)
548
548
  index_config = resolved_model.indexes[effective_index] || {}
549
549
  ruby_partition_key = normalized_conditions.keys.first.to_s
550
- raw_pk = index_config[:partition_key]&.to_s || ruby_partition_key
551
- dynamo_partition_key = resolved_model.to_dynamo_key(raw_pk)
550
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
552
551
  return fanout_paginated_query(effective_index, normalized_conditions, index_config, dynamo_partition_key,
553
552
  normalized_conditions.values.first, cursor, per_page)
554
553
  end
@@ -613,8 +612,7 @@ module ActiveItem
613
612
  partition_value = normalized_conditions.values.first
614
613
 
615
614
  index_config = resolved_model.indexes[idx_name] || {}
616
- raw_pk = index_config[:partition_key]&.to_s || ruby_partition_key
617
- dynamo_partition_key = resolved_model.to_dynamo_key(raw_pk)
615
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
618
616
 
619
617
  params = {
620
618
  table_name: resolved_model.table_name,
@@ -863,8 +861,7 @@ module ActiveItem
863
861
  partition_value = normalized_conditions.values.first
864
862
 
865
863
  index_config = resolved_model.indexes[idx_name] || {}
866
- raw_partition_key = index_config[:partition_key]&.to_s || ruby_partition_key
867
- dynamo_partition_key = resolved_model.to_dynamo_key(raw_partition_key)
864
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
868
865
 
869
866
  params = {
870
867
  table_name: resolved_model.table_name,
@@ -980,8 +977,7 @@ module ActiveItem
980
977
  partition_value = normalized_conditions.values.first
981
978
 
982
979
  index_config = resolved_model.indexes[idx_name] || {}
983
- raw_pk = index_config[:partition_key]&.to_s || ruby_partition_key
984
- dynamo_partition_key = resolved_model.to_dynamo_key(raw_pk)
980
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
985
981
 
986
982
  params = {
987
983
  table_name: resolved_model.table_name,
@@ -1139,9 +1135,8 @@ module ActiveItem
1139
1135
  # Get the actual DynamoDB partition key name from the index definition
1140
1136
  # The index definition stores the DynamoDB key name (which may be camelCase)
1141
1137
  index_config = resolved_model.indexes[idx_name] || {}
1142
- raw_partition_key = index_config[:partition_key]&.to_s || ruby_partition_key
1143
1138
  # Always convert to DynamoDB key format (camelCase) for the expression
1144
- dynamo_partition_key = resolved_model.to_dynamo_key(raw_partition_key)
1139
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
1145
1140
 
1146
1141
  return fanout_query(idx_name, normalized_conditions, index_config, dynamo_partition_key, partition_value) if partition_value.is_a?(Array)
1147
1142
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.16'
4
+ VERSION = '0.0.18'
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.16
4
+ version: 0.0.18
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