activeitem 0.0.23 → 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: 3391ecc77f10aaffc7953d95d84691bdf4791ea11163fe45ba7b3b5be3b8418f
4
- data.tar.gz: 02ad5cbf59628300f250fa972729ad503a53e197824350f7990e7d8cefd4281f
3
+ metadata.gz: 296b80f6ec0f16823c99df6d3a041fe16d55eab4ce59118d95081566f13e01d4
4
+ data.tar.gz: 8ec0e73b20698346c67f96075c0ccc3070f2f6232c1e1b3607a61424c1a19f8b
5
5
  SHA512:
6
- metadata.gz: b60d3ec30ab2f3f5a593c320d6af5764e793574b03e4389661325942d9e31d8c7b40b25532816ac0e1a5b8cfd4c5b49650ea413673d2686dd05a3841aeb0ae56
7
- data.tar.gz: 37398daa0b3af333ed40fb9dc2ad4571d9044df57ebaefe7f20fd21a5c714676cce31925e5b8d5a9a6a847768b15e0e53eca8138ed7b89b38fbf17d8cc71b665
6
+ metadata.gz: 8b5d8087d0cffede8296535868978d6dd24cddc70d1f6a1ba0741db31d5fc46caaea9635791f8984166aa8de92bfb50db000ecc935df4fe6498ecc2456fc1029
7
+ data.tar.gz: ef5ca8b2bb01a83a5673d0181d13627d5eb0b8bb01612f6e6ef146c5e4eb669cfd2ef0936c1aa09b15e6a5eed2367d7b1adb3c844c7e8aa4c72e7dc600201624
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
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
+
3
18
  ## 0.0.23
4
19
 
5
20
  ### Added
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
@@ -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.23'
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.23
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-15 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