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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +32 -0
- data/lib/active_item/base.rb +85 -16
- data/lib/active_item/relation.rb +20 -5
- 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: 3391ecc77f10aaffc7953d95d84691bdf4791ea11163fe45ba7b3b5be3b8418f
|
|
4
|
+
data.tar.gz: 02ad5cbf59628300f250fa972729ad503a53e197824350f7990e7d8cefd4281f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/active_item/base.rb
CHANGED
|
@@ -100,15 +100,33 @@ module ActiveItem
|
|
|
100
100
|
|
|
101
101
|
define_attribute_methods attr_name
|
|
102
102
|
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
data/lib/active_item/relation.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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
|
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.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-
|
|
12
|
+
date: 2026-09-15 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: activemodel
|