activeitem 0.0.22 → 0.0.24

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: 296b80f6ec0f16823c99df6d3a041fe16d55eab4ce59118d95081566f13e01d4
4
+ data.tar.gz: 8ec0e73b20698346c67f96075c0ccc3070f2f6232c1e1b3607a61424c1a19f8b
5
5
  SHA512:
6
- metadata.gz: db67b991fe6c8c9ed9ce18a362dc15dfd9f1b46c63ed20a49e8b1f82bf0bb2dfe3738b1e2f7d7b29a31396e61d40ee3e6d528393b01a93a92bf81c2e9d9f234b
7
- data.tar.gz: dc5a3c76df184f02bf6fa4f0cd040e9a98e42729326f35243b198fc2d7c8ae4e4a86429bdc457be7f7e9198892ba93aaddf5a4d5650806afcba6cb528d6d54e7
6
+ metadata.gz: 8b5d8087d0cffede8296535868978d6dd24cddc70d1f6a1ba0741db31d5fc46caaea9635791f8984166aa8de92bfb50db000ecc935df4fe6498ecc2456fc1029
7
+ data.tar.gz: ef5ca8b2bb01a83a5673d0181d13627d5eb0b8bb01612f6e6ef146c5e4eb669cfd2ef0936c1aa09b15e6a5eed2367d7b1adb3c844c7e8aa4c72e7dc600201624
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.24
4
+
5
+ ### Added
6
+
7
+ - **`ActiveItem.begins_with` for prefix queries** — Pass it as a `where` value. On an index's sort key it goes into the key condition (`begins_with(#sk, :sk_val)`), so DynamoDB reads only the matching items instead of scanning. On any other attribute it becomes a `begins_with` filter, and `where.not` gives `NOT begins_with(...)`. Works with `limit`, `order`, `count`, `explain`, and array (fan-out) partition keys.
8
+
9
+ ```ruby
10
+ # GSI PrefixIndex: partition = first 2 letters, sort = whole name (both lowercased)
11
+ Username.where(search_prefix: 'ab', search_name: ActiveItem.begins_with('abe')).limit(10)
12
+ ```
13
+
14
+ ### Changed
15
+
16
+ - The equality / Range / prefix sort-key condition is now built in one place (`build_sort_key_condition`) instead of five copies in `Relation`.
17
+
18
+ ## 0.0.23
19
+
20
+ ### Added
21
+
22
+ - **`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.
23
+
24
+ ### Fixed
25
+
26
+ - **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.
27
+
28
+ ```ruby
29
+ class Article < ActiveItem::Base
30
+ attr_accessor :tags, :payload
31
+
32
+ # Coerce to an Array, Rails-style: override and call super.
33
+ def tags=(value)
34
+ super(Array(value))
35
+ end
36
+
37
+ # Or be explicit with write_attribute.
38
+ def payload=(value)
39
+ write_attribute(:payload, value.is_a?(String) ? value : JSON.generate(value))
40
+ end
41
+ end
42
+ ```
43
+
3
44
  ## 0.0.22
4
45
 
5
46
  ### Changed
data/README.md CHANGED
@@ -75,6 +75,18 @@ User.count
75
75
  User.exists?('user-123')
76
76
  ```
77
77
 
78
+ Sort-key conditions on an index go into the key condition, not a filter:
79
+
80
+ ```ruby
81
+ Event.where(customer_id: id, created_at: 1.week.ago..) # >=, <=, BETWEEN via Range
82
+ Username.where(search_prefix: 'ab', search_name: ActiveItem.begins_with('abe')) # prefix
83
+ ```
84
+
85
+ For prefix search (autocomplete), add a GSI whose partition key is the first few
86
+ lowercased characters and whose sort key is the whole lowercased value, then
87
+ `begins_with` on the sort key. `begins_with` on a non-key attribute works too, but
88
+ it's a filter, so DynamoDB still reads every item in the query or scan.
89
+
78
90
  ### Associations
79
91
 
80
92
  ```ruby
@@ -114,6 +126,38 @@ class Customer < ActiveItem::Base
114
126
  end
115
127
  ```
116
128
 
129
+ ### Custom Attribute Writers
130
+
131
+ Like ActiveRecord, you can override a generated writer to coerce or normalize a
132
+ value on assignment. Just route the assignment back through the generated
133
+ writer — call `super`, or use `write_attribute`. Both record the change for
134
+ dirty tracking, so `save` (and `update`) persist it. Assigning `@ivar` directly
135
+ bypasses dirty tracking and the change is silently dropped on update.
136
+
137
+ ```ruby
138
+ class Article < ActiveItem::Base
139
+ attr_accessor :tags, :payload
140
+
141
+ # Coerce to an Array, Rails-style: override and call super.
142
+ def tags=(value)
143
+ super(Array(value))
144
+ end
145
+
146
+ # Or be explicit with write_attribute.
147
+ def payload=(value)
148
+ write_attribute(:payload, value.is_a?(String) ? value : JSON.generate(value))
149
+ end
150
+ end
151
+ ```
152
+
153
+ `read_attribute(:name)` is available too, for custom readers:
154
+
155
+ ```ruby
156
+ def display_name
157
+ read_attribute(:name).to_s.strip
158
+ end
159
+ ```
160
+
117
161
  ## License
118
162
 
119
163
  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
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveItem
4
+ # A prefix match for `where`, built with ActiveItem.begins_with.
5
+ #
6
+ # On an index's sort key it becomes part of the key condition, so DynamoDB
7
+ # reads only the matching items. On any other attribute it is a filter.
8
+ #
9
+ # @example Prefix search on a GSI (partition = first letters, sort = whole name)
10
+ # Username.where(search_prefix: 'ab', search_name: ActiveItem.begins_with('abc')).limit(10)
11
+ BeginsWith = Data.define(:prefix) do
12
+ def initialize(prefix:)
13
+ raise ArgumentError, 'begins_with needs a non-empty prefix' if prefix.to_s.empty?
14
+
15
+ super(prefix: prefix.to_s)
16
+ end
17
+ end
18
+ end
@@ -411,6 +411,7 @@ module ActiveItem
411
411
  # - Range values (BETWEEN): { date: Date.today..Date.today + 7 }
412
412
  # - Beginless range (<=): { date: ..Date.today }
413
413
  # - Endless range (>=): { date: Date.today.. }
414
+ # - Prefix match: { name: ActiveItem.begins_with('ab') }
414
415
  # - Model objects: { parent_container: container } -> uses container.id
415
416
  #
416
417
  # Note: Attribute names are converted from Ruby snake_case to DynamoDB camelCase
@@ -452,6 +453,9 @@ module ActiveItem
452
453
  # Handle Range values (BETWEEN, >=, <=)
453
454
  return build_range_condition(dynamo_attr, val, idx) if val.is_a?(Range)
454
455
 
456
+ # Handle prefix match (begins_with)
457
+ return ["begins_with(#attr#{idx}, :val#{idx})", { "#attr#{idx}" => dynamo_attr }, { ":val#{idx}" => val.prefix }] if val.is_a?(ActiveItem::BeginsWith)
458
+
455
459
  # Handle array values (IN clause)
456
460
  return build_in_condition(dynamo_attr, val, idx) if val.is_a?(Array)
457
461
 
@@ -596,6 +600,24 @@ module ActiveItem
596
600
  end
597
601
  end
598
602
 
603
+ # Build the sort key part of a GSI key condition: equality, a Range
604
+ # (BETWEEN, >=, <=), or ActiveItem.begins_with.
605
+ def build_sort_key_condition(sort_key, value, placeholder_prefix = 'sk')
606
+ return build_sort_key_range_condition(sort_key, value, placeholder_prefix) if value.is_a?(Range)
607
+
608
+ expression = if value.is_a?(ActiveItem::BeginsWith)
609
+ value = value.prefix
610
+ "begins_with(##{placeholder_prefix}, :#{placeholder_prefix}_val)"
611
+ else
612
+ "##{placeholder_prefix} = :#{placeholder_prefix}_val"
613
+ end
614
+ {
615
+ expression: expression,
616
+ names: { "##{placeholder_prefix}" => sort_key },
617
+ values: { ":#{placeholder_prefix}_val" => value }
618
+ }
619
+ end
620
+
599
621
  # Build sort key condition for Range values in GSI queries
600
622
  # Returns the key condition expression part and values for sort key
601
623
  def build_sort_key_range_condition(sort_key, range, placeholder_prefix = 'sk')
@@ -660,16 +660,10 @@ module ActiveItem
660
660
  resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
661
661
  end
662
662
 
663
- if sort_value.is_a?(Range)
664
- range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
665
- params[:key_condition_expression] += " AND #{range_condition[:expression]}"
666
- params[:expression_attribute_names].merge!(range_condition[:names])
667
- params[:expression_attribute_values].merge!(range_condition[:values])
668
- else
669
- params[:key_condition_expression] += ' AND #sk = :sk_val'
670
- params[:expression_attribute_names]['#sk'] = sort_key
671
- params[:expression_attribute_values][':sk_val'] = sort_value
672
- end
663
+ key_condition = resolved_model.send(:build_sort_key_condition, sort_key, sort_value)
664
+ params[:key_condition_expression] += " AND #{key_condition[:expression]}"
665
+ params[:expression_attribute_names].merge!(key_condition[:names])
666
+ params[:expression_attribute_values].merge!(key_condition[:values])
673
667
  end
674
668
  end
675
669
 
@@ -906,16 +900,10 @@ module ActiveItem
906
900
  resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
907
901
  end
908
902
 
909
- if sort_value.is_a?(Range)
910
- range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
911
- params[:key_condition_expression] += " AND #{range_condition[:expression]}"
912
- params[:expression_attribute_names].merge!(range_condition[:names])
913
- params[:expression_attribute_values].merge!(range_condition[:values])
914
- else
915
- params[:key_condition_expression] += ' AND #sk = :sk_val'
916
- params[:expression_attribute_names]['#sk'] = sort_key
917
- params[:expression_attribute_values][':sk_val'] = sort_value
918
- end
903
+ key_condition = resolved_model.send(:build_sort_key_condition, sort_key, sort_value)
904
+ params[:key_condition_expression] += " AND #{key_condition[:expression]}"
905
+ params[:expression_attribute_names].merge!(key_condition[:names])
906
+ params[:expression_attribute_values].merge!(key_condition[:values])
919
907
  end
920
908
  end
921
909
 
@@ -1020,16 +1008,10 @@ module ActiveItem
1020
1008
  resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
1021
1009
  end
1022
1010
 
1023
- if sort_value.is_a?(Range)
1024
- range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
1025
- params[:key_condition_expression] += " AND #{range_condition[:expression]}"
1026
- params[:expression_attribute_names].merge!(range_condition[:names])
1027
- params[:expression_attribute_values].merge!(range_condition[:values])
1028
- else
1029
- params[:key_condition_expression] += ' AND #sk = :sk_val'
1030
- params[:expression_attribute_names]['#sk'] = sort_key
1031
- params[:expression_attribute_values][':sk_val'] = sort_value
1032
- end
1011
+ key_condition = resolved_model.send(:build_sort_key_condition, sort_key, sort_value)
1012
+ params[:key_condition_expression] += " AND #{key_condition[:expression]}"
1013
+ params[:expression_attribute_names].merge!(key_condition[:names])
1014
+ params[:expression_attribute_values].merge!(key_condition[:values])
1033
1015
  end
1034
1016
  end
1035
1017
 
@@ -1186,16 +1168,10 @@ module ActiveItem
1186
1168
  resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
1187
1169
  end
1188
1170
 
1189
- if sort_value.is_a?(Range)
1190
- range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
1191
- params[:key_condition_expression] += " AND #{range_condition[:expression]}"
1192
- params[:expression_attribute_names].merge!(range_condition[:names])
1193
- params[:expression_attribute_values].merge!(range_condition[:values])
1194
- else
1195
- params[:key_condition_expression] += ' AND #sk = :sk_val'
1196
- params[:expression_attribute_names]['#sk'] = sort_key
1197
- params[:expression_attribute_values][':sk_val'] = sort_value
1198
- end
1171
+ key_condition = resolved_model.send(:build_sort_key_condition, sort_key, sort_value)
1172
+ params[:key_condition_expression] += " AND #{key_condition[:expression]}"
1173
+ params[:expression_attribute_names].merge!(key_condition[:names])
1174
+ params[:expression_attribute_values].merge!(key_condition[:values])
1199
1175
  end
1200
1176
  end
1201
1177
 
@@ -1416,16 +1392,10 @@ module ActiveItem
1416
1392
  return unless sort_condition
1417
1393
 
1418
1394
  _, sort_value = sort_condition
1419
- if sort_value.is_a?(Range)
1420
- range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
1421
- params[:key_condition_expression] += " AND #{range_condition[:expression]}"
1422
- params[:expression_attribute_names].merge!(range_condition[:names])
1423
- params[:expression_attribute_values].merge!(range_condition[:values])
1424
- else
1425
- params[:key_condition_expression] += ' AND #sk = :sk_val'
1426
- params[:expression_attribute_names]['#sk'] = sort_key
1427
- params[:expression_attribute_values][':sk_val'] = sort_value
1428
- end
1395
+ key_condition = resolved_model.send(:build_sort_key_condition, sort_key, sort_value)
1396
+ params[:key_condition_expression] += " AND #{key_condition[:expression]}"
1397
+ params[:expression_attribute_names].merge!(key_condition[:names])
1398
+ params[:expression_attribute_values].merge!(key_condition[:values])
1429
1399
  end
1430
1400
 
1431
1401
  def sort_fanout_results(items, sort_key)
@@ -1616,6 +1586,9 @@ module ActiveItem
1616
1586
  return ["NOT (#attr#{idx} IN (#{placeholders.join(', ')}))", { "#attr#{idx}" => dynamo_attr }, values]
1617
1587
  end
1618
1588
 
1589
+ # NOT begins_with
1590
+ return ["NOT begins_with(#attr#{idx}, :val#{idx})", { "#attr#{idx}" => dynamo_attr }, { ":val#{idx}" => val.prefix }] if val.is_a?(ActiveItem::BeginsWith)
1591
+
1619
1592
  # Simple not equal
1620
1593
  ["#attr#{idx} <> :val#{idx}", { "#attr#{idx}" => dynamo_attr }, { ":val#{idx}" => val }]
1621
1594
  end
@@ -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.24'
5
5
  end
data/lib/activeitem.rb CHANGED
@@ -5,6 +5,7 @@ require_relative 'active_item/configuration'
5
5
  require_relative 'active_item/logging'
6
6
  require_relative 'active_item/errors'
7
7
  require_relative 'active_item/database_helpers'
8
+ require_relative 'active_item/begins_with'
8
9
  require_relative 'active_item/query_helpers'
9
10
  require_relative 'active_item/relation'
10
11
  require_relative 'active_item/embedded_collection'
@@ -32,5 +33,10 @@ module ActiveItem
32
33
  def logger
33
34
  configuration.logger
34
35
  end
36
+
37
+ # Prefix match for `where`; see ActiveItem::BeginsWith.
38
+ def begins_with(prefix)
39
+ BeginsWith.new(prefix)
40
+ end
35
41
  end
36
42
  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.24
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-10-30 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activemodel
@@ -157,6 +157,7 @@ files:
157
157
  - README.md
158
158
  - lib/active_item/associations.rb
159
159
  - lib/active_item/base.rb
160
+ - lib/active_item/begins_with.rb
160
161
  - lib/active_item/composed_of.rb
161
162
  - lib/active_item/configuration.rb
162
163
  - lib/active_item/database_helpers.rb