activeitem 0.0.22 → 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: a7d5990fc9c0532dc9a65f803bd9886c1769dc2b4c3071fd4748c079fa7559b3
4
- data.tar.gz: 1c480246c6a898502eacedda1b456e003f54910a3f8562893cc5c070c06f168e
3
+ metadata.gz: 3391ecc77f10aaffc7953d95d84691bdf4791ea11163fe45ba7b3b5be3b8418f
4
+ data.tar.gz: 02ad5cbf59628300f250fa972729ad503a53e197824350f7990e7d8cefd4281f
5
5
  SHA512:
6
- metadata.gz: db67b991fe6c8c9ed9ce18a362dc15dfd9f1b46c63ed20a49e8b1f82bf0bb2dfe3738b1e2f7d7b29a31396e61d40ee3e6d528393b01a93a92bf81c2e9d9f234b
7
- data.tar.gz: dc5a3c76df184f02bf6fa4f0cd040e9a98e42729326f35243b198fc2d7c8ae4e4a86429bdc457be7f7e9198892ba93aaddf5a4d5650806afcba6cb528d6d54e7
6
+ metadata.gz: b60d3ec30ab2f3f5a593c320d6af5764e793574b03e4389661325942d9e31d8c7b40b25532816ac0e1a5b8cfd4c5b49650ea413673d2686dd05a3841aeb0ae56
7
+ data.tar.gz: 37398daa0b3af333ed40fb9dc2ad4571d9044df57ebaefe7f20fd21a5c714676cce31925e5b8d5a9a6a847768b15e0e53eca8138ed7b89b38fbf17d8cc71b665
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
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
+
3
29
  ## 0.0.22
4
30
 
5
31
  ### Changed
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveItem
4
- VERSION = '0.0.22'
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.22
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-09-07 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