activeitem 0.0.21 → 0.0.23

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: 976cb7f41429d4ca2489b0a67a87f39ca3720bc223b02ad9c1d4e2e244f6a700
4
- data.tar.gz: e5c0fd6be8a2ea1ccfc00793942d91973e53b9d434fec9b67e92b8da9660049d
3
+ metadata.gz: 3391ecc77f10aaffc7953d95d84691bdf4791ea11163fe45ba7b3b5be3b8418f
4
+ data.tar.gz: 02ad5cbf59628300f250fa972729ad503a53e197824350f7990e7d8cefd4281f
5
5
  SHA512:
6
- metadata.gz: de5d8d0d4f7b6a986369e0604eec719dd686537009967b00302543abc507db28298b65222cb85e5fb22f9ee7e86568ec4934dc2f761956b6f880d89a74ad5456
7
- data.tar.gz: 160ca85fad18fcd70b5650f274077c2a8fc841f6943b6d601a1effc13408c74ce037593c25dc6a58b4aa0a04f465b1616159719416d710e77fd10e1a2a134737
6
+ metadata.gz: b60d3ec30ab2f3f5a593c320d6af5764e793574b03e4389661325942d9e31d8c7b40b25532816ac0e1a5b8cfd4c5b49650ea413673d2686dd05a3841aeb0ae56
7
+ data.tar.gz: 37398daa0b3af333ed40fb9dc2ad4571d9044df57ebaefe7f20fd21a5c714676cce31925e5b8d5a9a6a847768b15e0e53eca8138ed7b89b38fbf17d8cc71b665
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.23
4
+
5
+ ### Added
6
+
7
+ - **`read_attribute` / `write_attribute`** — ActiveItem now exposes the same attribute primitives as ActiveRecord. `write_attribute(:name, value)` records the change for dirty tracking; `read_attribute(:name)` returns the current value. Custom readers/writers can route through these instead of touching the ivar directly.
8
+
9
+ ### Fixed
10
+
11
+ - **Custom attribute writers now persist on update, Rails-style** — Overriding a generated writer to coerce or normalize a value no longer silently drops the change. Generated readers/writers are now defined in an included module (like Rails' `GeneratedAttributeMethods`), so a hand-written `def foo=` can call `super` and get dirty tracking for free. Previously, a custom writer that assigned `@foo` directly left the attribute out of the changeset — `save` returned `true` with no error, but the update wrote nothing. Both `super` and `write_attribute` are now supported.
12
+
13
+ ```ruby
14
+ class Article < ActiveItem::Base
15
+ attr_accessor :tags, :payload
16
+
17
+ # Coerce to an Array, Rails-style: override and call super.
18
+ def tags=(value)
19
+ super(Array(value))
20
+ end
21
+
22
+ # Or be explicit with write_attribute.
23
+ def payload=(value)
24
+ write_attribute(:payload, value.is_a?(String) ? value : JSON.generate(value))
25
+ end
26
+ end
27
+ ```
28
+
29
+ ## 0.0.22
30
+
31
+ ### Changed
32
+
33
+ - **Scoped `find` on associations** — `Relation#find(id)` now enforces association scope, matching Rails behavior. When called on an association (e.g., `@project.epics.find(id)`), it validates that the found record belongs to the association before returning it. If the record exists but belongs to a different parent, `ActiveItem::RecordNotFound` is raised. This prevents accidentally accessing records outside the association scope.
34
+
35
+ ```ruby
36
+ # Before: would return the epic even if it belonged to a different project
37
+ @project.epics.find(epic_id)
38
+
39
+ # After: raises RecordNotFound if epic.project_id != @project.id
40
+ @project.epics.find(epic_id)
41
+ ```
42
+
3
43
  ## 0.0.21
4
44
 
5
45
  ### Added
data/README.md CHANGED
@@ -114,6 +114,38 @@ class Customer < ActiveItem::Base
114
114
  end
115
115
  ```
116
116
 
117
+ ### Custom Attribute Writers
118
+
119
+ Like ActiveRecord, you can override a generated writer to coerce or normalize a
120
+ value on assignment. Just route the assignment back through the generated
121
+ writer — call `super`, or use `write_attribute`. Both record the change for
122
+ dirty tracking, so `save` (and `update`) persist it. Assigning `@ivar` directly
123
+ bypasses dirty tracking and the change is silently dropped on update.
124
+
125
+ ```ruby
126
+ class Article < ActiveItem::Base
127
+ attr_accessor :tags, :payload
128
+
129
+ # Coerce to an Array, Rails-style: override and call super.
130
+ def tags=(value)
131
+ super(Array(value))
132
+ end
133
+
134
+ # Or be explicit with write_attribute.
135
+ def payload=(value)
136
+ write_attribute(:payload, value.is_a?(String) ? value : JSON.generate(value))
137
+ end
138
+ end
139
+ ```
140
+
141
+ `read_attribute(:name)` is available too, for custom readers:
142
+
143
+ ```ruby
144
+ def display_name
145
+ read_attribute(:name).to_s.strip
146
+ end
147
+ ```
148
+
117
149
  ## License
118
150
 
119
151
  MIT
@@ -100,15 +100,33 @@ module ActiveItem
100
100
 
101
101
  define_attribute_methods attr_name
102
102
 
103
- define_method(attr_name) do
104
- instance_variable_get("@#{attr_name}")
103
+ # Define the generated reader/writer in a dedicated module that is
104
+ # included into the model, rather than directly on the class. This
105
+ # mirrors Rails' GeneratedAttributeMethods: a hand-written
106
+ # `def foo=` on the model sits *above* the generated writer in the
107
+ # ancestor chain, so a custom writer can call `super` (or
108
+ # `write_attribute`) to get dirty tracking for free.
109
+ generated_attribute_methods.module_eval do
110
+ define_method(attr_name) do
111
+ read_attribute(attr_name)
112
+ end
113
+
114
+ define_method("#{attr_name}=") do |value|
115
+ write_attribute(attr_name, value)
116
+ end
105
117
  end
118
+ end
119
+ end
106
120
 
107
- define_method("#{attr_name}=") do |value|
108
- old_value = instance_variable_get("@#{attr_name}")
109
- send("#{attr_name}_will_change!") if (old_value != value) && !changed_attributes.key?(attr_name)
110
- instance_variable_set("@#{attr_name}", value)
111
- end
121
+ # Per-class module that holds the generated attribute reader/writer
122
+ # methods. Included once, ahead of ActiveModel in the ancestor chain but
123
+ # behind the model class itself, so user-defined setters can `super`.
124
+ def generated_attribute_methods
125
+ @generated_attribute_methods ||= begin
126
+ mod = Module.new
127
+ include mod
128
+
129
+ mod
112
130
  end
113
131
  end
114
132
 
@@ -490,6 +508,50 @@ module ActiveItem
490
508
  end
491
509
  end
492
510
 
511
+ # Read the current value of an attribute by name.
512
+ #
513
+ # This is the Rails-like primitive that generated readers delegate to.
514
+ # Custom readers can call it (or `super`) instead of poking at the ivar
515
+ # directly.
516
+ #
517
+ # def display_name
518
+ # read_attribute(:name).to_s.strip
519
+ # end
520
+ #
521
+ # @param attr_name [String, Symbol] the attribute name
522
+ # @return the attribute's current value
523
+ def read_attribute(attr_name)
524
+ instance_variable_get("@#{attr_name}")
525
+ end
526
+
527
+ # Write an attribute value, recording it for dirty tracking so `save`
528
+ # actually persists it.
529
+ #
530
+ # This is the single place dirty tracking happens for attributes, mirroring
531
+ # Rails' `write_attribute`. Generated writers delegate here, which means a
532
+ # custom writer gets dirty tracking for free by going through either
533
+ # `super` or `write_attribute` directly:
534
+ #
535
+ # # coerce/normalize on assignment, Rails-style
536
+ # def tags=(value)
537
+ # super(Array(value)) # -> generated writer -> write_attribute
538
+ # end
539
+ #
540
+ # # or be explicit
541
+ # def payload=(value)
542
+ # write_attribute(:payload, value.is_a?(String) ? value : JSON.generate(value))
543
+ # end
544
+ #
545
+ # @param attr_name [String, Symbol] the attribute name
546
+ # @param value the value to assign
547
+ # @return the assigned value
548
+ def write_attribute(attr_name, value)
549
+ attr_name = attr_name.to_s
550
+ old_value = instance_variable_get("@#{attr_name}")
551
+ mark_attribute_will_change(attr_name, old_value, value)
552
+ instance_variable_set("@#{attr_name}", value)
553
+ end
554
+
493
555
  def attribute_changed?(attr_name)
494
556
  super(attr_name.to_s)
495
557
  end
@@ -511,6 +573,18 @@ module ActiveItem
511
573
 
512
574
  private
513
575
 
576
+ # Record a pending change for dirty tracking. Guarded with `respond_to?`
577
+ # so it degrades gracefully for attributes that weren't declared via
578
+ # `attr_accessor` (and under test harnesses that stub the ORM), and skips
579
+ # re-recording an original value that's already been captured.
580
+ def mark_attribute_will_change(attr_name, old_value, new_value)
581
+ return if old_value == new_value
582
+ return if changed_attributes.key?(attr_name)
583
+ return unless respond_to?("#{attr_name}_will_change!", true)
584
+
585
+ send("#{attr_name}_will_change!")
586
+ end
587
+
514
588
  # Enroll this record's save operation in the current transaction.
515
589
  # Called when save is invoked inside a transaction block.
516
590
  def enroll_in_transaction
@@ -912,15 +986,11 @@ module ActiveItem
912
986
  indexes.each do |index_name, config|
913
987
  # Check partition key if it's being changed
914
988
  partition_key = config[:partition_key]&.to_s
915
- if partition_key && changed_dynamo_keys.key?(partition_key)
916
- validate_gsi_key_value!(partition_key, changed_dynamo_keys[partition_key], index_name)
917
- end
989
+ validate_gsi_key_value!(partition_key, changed_dynamo_keys[partition_key], index_name) if partition_key && changed_dynamo_keys.key?(partition_key)
918
990
 
919
991
  # Check sort key if it's being changed
920
992
  sort_key = config[:sort_key]&.to_s
921
- if sort_key && changed_dynamo_keys.key?(sort_key)
922
- validate_gsi_key_value!(sort_key, changed_dynamo_keys[sort_key], index_name)
923
- end
993
+ validate_gsi_key_value!(sort_key, changed_dynamo_keys[sort_key], index_name) if sort_key && changed_dynamo_keys.key?(sort_key)
924
994
  end
925
995
  end
926
996
 
@@ -953,9 +1023,8 @@ module ActiveItem
953
1023
  case value
954
1024
  when String
955
1025
  !value.empty? # Empty strings are not allowed for GSI keys
956
- when Integer, Float, BigDecimal
957
- true
958
- when StringIO
1026
+ when Integer, Float, BigDecimal, StringIO
1027
+ # Numeric (Integer, Float, BigDecimal) and Binary (StringIO) are valid GSI key types
959
1028
  true
960
1029
  else
961
1030
  false
@@ -294,18 +294,26 @@ module ActiveItem
294
294
 
295
295
  # Find by id within the current scope, or find by block (like Enumerable#find)
296
296
  #
297
+ # When called on an association (e.g., @project.epics.find(id)), validates that
298
+ # the found record belongs to the association's scope. This matches Rails behavior
299
+ # where association.find(id) only returns records belonging to that association.
300
+ #
297
301
  # @overload find(id)
298
302
  # Find a record by ID within the current scope
299
303
  # @param id [String] The ID to find
300
- # @return [Object, nil] The found record or nil
304
+ # @return [Object] The found record
305
+ # @raise [ActiveItem::RecordNotFound] If record not found or not in scope
301
306
  #
302
307
  # @overload find(&block)
303
308
  # Find the first record matching the block condition (like Enumerable#find/detect)
304
309
  # @yield [record] Evaluates the block for each record
305
310
  # @return [Object, nil] The first record where block returns true, or nil
306
311
  #
307
- # @example Find by ID
308
- # User.where(status: 'active').find('user-123')
312
+ # @example Find by ID (scoped to association)
313
+ # @project.epics.find('epic-123') # Only finds if epic belongs to @project
314
+ #
315
+ # @example Find by ID (unscoped)
316
+ # Epic.where(status: 'active').find('epic-123') # Finds any active epic
309
317
  #
310
318
  # @example Find by block
311
319
  # User.where(status: 'active').find { |u| u.email.include?('@example.com') }
@@ -317,13 +325,20 @@ module ActiveItem
317
325
  elsif id
318
326
  # Use direct GetItem instead of scanning — O(1) vs O(n)
319
327
  record = resolved_model.find(id)
328
+
329
+ # When called on an association (has conditions), validate scope
330
+ # This ensures @project.epics.find(id) only returns epics belonging to @project
331
+ if conditions.any? && !conditions[:_empty]
332
+ foreign_key, expected_value = conditions.first
333
+ actual_value = record.send(foreign_key)
334
+ raise ActiveItem::RecordNotFound, "Couldn't find #{resolved_model.name} with id=#{id}" unless actual_value == expected_value
335
+ end
336
+
320
337
  preload_associations_for_records([record]) if includes_associations.any?
321
338
  record
322
339
  else
323
340
  raise ArgumentError, 'find requires either an ID or a block'
324
341
  end
325
- rescue ActiveItem::RecordNotFound
326
- nil
327
342
  end
328
343
 
329
344
  # Find by conditions within current scope
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.21'
4
+ VERSION = '0.0.23'
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.21
4
+ version: 0.0.23
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-13 00:00:00.000000000 Z
12
+ date: 2026-09-15 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activemodel