activeitem 0.0.1

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.
@@ -0,0 +1,637 @@
1
+ # encoding: utf-8
2
+
3
+ require_relative 'relation'
4
+
5
+ module ActiveItem
6
+ module QueryHelpers
7
+
8
+ def find(id)
9
+ record = get({ primary_key.to_s => id })
10
+ raise ActiveItem::RecordNotFound, "Couldn't find #{name} with '#{primary_key}'=#{id}" unless record
11
+ instantiate(record)
12
+ end
13
+
14
+ # Batch find multiple records by primary key using DynamoDB's BatchGetItem
15
+ # Much more efficient than multiple .find() calls
16
+ #
17
+ # @example
18
+ # Customer.batch_find(['cust-1', 'cust-2', 'cust-3'])
19
+ # # => [#<Customer id="cust-1">, #<Customer id="cust-2">, #<Customer id="cust-3">]
20
+ #
21
+ # @param ids [Array] Array of primary key values
22
+ # @return [Array] Array of model instances (silently skips IDs not found)
23
+ def batch_find(ids)
24
+ return [] if ids.empty?
25
+
26
+ results = []
27
+ ids.each_slice(100) do |id_chunk|
28
+ keys = id_chunk.map { |id| { primary_key.to_s => id } }
29
+ request = { table_name => { keys: keys } }
30
+
31
+ # Retry loop for unprocessed keys (DynamoDB may throttle under load)
32
+ max_retries = 5
33
+ retries = 0
34
+
35
+ while request&.any?
36
+ response = dynamodb.batch_get_item(request_items: request)
37
+
38
+ items = response.responses[table_name] || []
39
+ results.concat(items.map { |item| instantiate(item) })
40
+
41
+ # Check for unprocessed keys and retry with exponential backoff
42
+ unprocessed = response.unprocessed_keys
43
+ if unprocessed&.any?
44
+ retries += 1
45
+ break if retries > max_retries
46
+
47
+ sleep(0.05 * (2**retries)) # Exponential backoff: 100ms, 200ms, 400ms...
48
+ request = unprocessed
49
+ else
50
+ break
51
+ end
52
+ end
53
+ end
54
+
55
+ results
56
+ rescue Aws::DynamoDB::Errors::AccessDeniedException => e
57
+ raise ActiveItem::AccessDeniedError.new(model_name: name, table: table_name,
58
+ operation: 'BatchGetItem', original_error: e)
59
+ end
60
+
61
+ # Batch write multiple records using DynamoDB's BatchWriteItem
62
+ # Much more efficient than individual PutItem calls (25 items per request vs 1)
63
+ #
64
+ # WARNING: This bypasses callbacks (before_create, after_create, etc.),
65
+ # validations, and conditional writes (attribute_not_exists). Records are
66
+ # written as raw PutItem operations. Use this only when you need raw
67
+ # throughput and are confident the data is already valid.
68
+ #
69
+ # @example
70
+ # items = 30.times.map { |i| InventoryItem.new(name: "Item #{i}", ...) }
71
+ # InventoryItem.batch_write(items)
72
+ #
73
+ # @param records [Array<ActiveItem::Base>] Records to write
74
+ # @return [Array<ActiveItem::Base>] The records with IDs and timestamps assigned
75
+ def batch_write(records)
76
+ return [] if records.empty?
77
+
78
+ now = Time.now.utc.iso8601
79
+
80
+ # Prepare each record: assign ID and timestamps
81
+ records.each do |record|
82
+ record.instance_variable_set(:@id, SecureRandom.uuid) unless record.id
83
+ pk = primary_key
84
+ record.instance_variable_set(:"@#{pk}", record.id) if pk != 'id'
85
+ record.instance_variable_set(:@created_at, now) unless record.created_at
86
+ record.instance_variable_set(:@updated_at, now)
87
+ end
88
+
89
+ # DynamoDB BatchWriteItem limit is 25 items per request
90
+ records.each_slice(25) do |chunk|
91
+ write_requests = chunk.map do |record|
92
+ { put_request: { item: record.send(:build_dynamodb_item).merge('createdAt' => record.created_at, 'updatedAt' => record.updated_at) } }
93
+ end
94
+
95
+ request = { table_name => write_requests }
96
+ max_retries = 5
97
+ retries = 0
98
+
99
+ while request&.any?
100
+ response = dynamodb.batch_write_item(request_items: request)
101
+
102
+ unprocessed = response.unprocessed_items
103
+ if unprocessed&.any?
104
+ retries += 1
105
+ break if retries > max_retries
106
+
107
+ sleep(0.05 * (2**retries))
108
+ request = unprocessed
109
+ else
110
+ break
111
+ end
112
+ end
113
+ end
114
+
115
+ records.each { |r| r.instance_variable_set(:@new_record, false) }
116
+ records
117
+ rescue Aws::DynamoDB::Errors::AccessDeniedException => e
118
+ raise ActiveItem::AccessDeniedError.new(model_name: name, table: table_name,
119
+ operation: 'BatchWriteItem', original_error: e)
120
+ end
121
+
122
+ def find_by(**conditions)
123
+ where(**conditions).first
124
+ end
125
+
126
+ # Chainable where method with GSI support - returns a Relation for lazy evaluation
127
+ #
128
+ # @example Simple query
129
+ # Pickup.where(status: 'pending')
130
+ #
131
+ # @example Chained queries (Rails-like!)
132
+ # slots = AvailabilitySlot.where(employee_id: '123')
133
+ # slots = slots.where(zip_code: '32780') if zip_code?
134
+ # slots.each { |s| puts s.id }
135
+ #
136
+ # @example With explicit index
137
+ # Pickup.where(customer_id: '123', index: 'CustomerIndex')
138
+ #
139
+ # @example Auto-detected index (if model defines indexes)
140
+ # Pickup.where(customer_id: '123') # Uses CustomerIndex if defined
141
+ #
142
+ # @example Multiple conditions (first is partition key, rest are filters)
143
+ # Pickup.where(status: 'pending', pickup_date: '2024-01-15')
144
+ #
145
+ # @example Negation with where.not (Rails-like!)
146
+ # Container.where.not(parent_container_id: nil) # Has a parent
147
+ # Container.where(status: 'active').not(archived: true)
148
+ #
149
+ # @example Case-insensitive search (ilike option)
150
+ # Container.where(name: 'box', ilike: true) # Substring match
151
+ # Container.where(name: 'box', ilike: true, exact: true) # Exact case-insensitive
152
+ #
153
+ # @example Batch find by primary key (automatically uses BatchGetItem)
154
+ # Customer.where(customer_id: ['cust-1', 'cust-2', 'cust-3'])
155
+ # # Equivalent to: Customer.batch_find(['cust-1', 'cust-2', 'cust-3'])
156
+ #
157
+ def where(**conditions)
158
+ # If no conditions, return a Relation that supports .not() chaining
159
+ return Relation.new(self) if conditions.empty?
160
+
161
+ # Extract special options
162
+ index_name = conditions.delete(:index) || conditions.delete(:index_name)
163
+ ilike = conditions.delete(:ilike) || false
164
+ exact = conditions.delete(:exact) || false
165
+
166
+ # Optimization: Detect primary key array queries and use batch_find
167
+ # This makes Customer.where(customer_id: [ids]) automatically efficient
168
+ # Also supports .where(id: [ids]) since 'id' is aliased to the primary key
169
+ if conditions.size == 1 && !index_name
170
+ key = conditions.keys.first.to_s
171
+ value = conditions.values.first
172
+
173
+ # Check if querying primary key with an array
174
+ # Accept both the actual primary key name (e.g., 'customer_id') and 'id' (the alias)
175
+ is_primary_key_query = key == primary_key.to_s || key == 'id'
176
+
177
+ if is_primary_key_query && value.is_a?(Array)
178
+ # Return a Relation wrapping the batch_find results
179
+ # This allows further chaining like .where(...).map(&:to_h)
180
+ return Relation.new(self, preloaded_records: batch_find(value))
181
+ end
182
+ end
183
+
184
+ Relation.new(self, conditions: conditions, index_name: index_name, ilike: ilike, ilike_exact: exact)
185
+ end
186
+
187
+ # Returns a Relation for all records (enables chaining from .all)
188
+ def all(limit: nil)
189
+ if limit
190
+ Relation.new(self, limit_value: limit)
191
+ else
192
+ Relation.new(self)
193
+ end
194
+ end
195
+
196
+ # Returns a Relation with associations to preload (enables chaining from .includes)
197
+ def includes(*associations)
198
+ Relation.new(self, includes_associations: associations.flatten)
199
+ end
200
+
201
+ # Recent records, newest first. Single query against RecentIndex GSI.
202
+ #
203
+ # Requires a RecentIndex GSI on the DynamoDB table with a fixed partition key
204
+ # (default: _recent_pk = "ALL") and createdAt as the sort key.
205
+ #
206
+ # @param limit [Integer] max records to return (default 50)
207
+ # @return [Relation] chainable relation sorted newest-first
208
+ def recent(limit: 50)
209
+ where(_recent_pk: 'ALL', index: 'RecentIndex').order(:desc).limit(limit)
210
+ end
211
+
212
+ # Most recent record. Convenience wrapper around .recent.
213
+ def last
214
+ recent(limit: 1).first
215
+ end
216
+
217
+ def none
218
+ Relation.new(self).none
219
+ end
220
+
221
+ # Convenience method that immediately returns array (for backwards compat)
222
+ def all_records(limit: nil)
223
+ items = scan(limit: limit)
224
+ items.map { |item| instantiate(item) }
225
+ end
226
+
227
+ def first
228
+ all.first
229
+ end
230
+
231
+ def last
232
+ all.last
233
+ end
234
+
235
+ # Count records with optional conditions or block
236
+ #
237
+ # @example Count all records
238
+ # Customer.count # => 42
239
+ #
240
+ # @example Count with conditions
241
+ # Customer.count(status: 'active') # => 30
242
+ #
243
+ # @example Count with block (Rails-like, loads all records)
244
+ # Customer.count { |c| c.email.include?('@gmail.com') } # => 15
245
+ #
246
+ def count(**conditions, &block)
247
+ if block_given?
248
+ # Block provided - load all records and count with Ruby
249
+ all.count(&block)
250
+ elsif conditions.empty?
251
+ # No conditions, no block - use efficient DynamoDB COUNT
252
+ response = dynamodb.scan(
253
+ table_name: table_name,
254
+ select: 'COUNT'
255
+ )
256
+ response.count
257
+ else
258
+ # Conditions provided - delegate to where().count
259
+ where(**conditions).count
260
+ end
261
+ end
262
+
263
+ # Rails-like exists? method that accepts attribute conditions or a single ID
264
+ #
265
+ # @example Check by primary key (single ID)
266
+ # Customer.exists?('cust-123')
267
+ # EmailSuppression.exists?('test@example.com')
268
+ #
269
+ # @example Check by primary key (hash)
270
+ # Customer.exists?(id: 'cust-123')
271
+ # EmailSuppression.exists?(email: 'test@example.com')
272
+ #
273
+ # @example Check by any attributes
274
+ # Customer.exists?(email: 'test@example.com', status: 'active')
275
+ # Pickup.exists?(customer_id: 'cust-123', status: 'pending')
276
+ #
277
+ # @param id_or_conditions [String, Hash] Primary key value or attribute conditions
278
+ # @return [Boolean] true if a record exists matching the conditions
279
+ def exists?(id_or_conditions = nil, **conditions)
280
+ # Handle single ID parameter: Customer.exists?('cust-123')
281
+ if id_or_conditions.is_a?(String) && conditions.empty?
282
+ return !!get({ primary_key.to_s => id_or_conditions })
283
+ end
284
+
285
+ # Merge positional hash with keyword arguments if both provided
286
+ if id_or_conditions.is_a?(Hash)
287
+ conditions = id_or_conditions.merge(conditions)
288
+ end
289
+
290
+ # If checking by primary key only, use the efficient get operation
291
+ if conditions.keys.size == 1 && conditions.key?(primary_key.to_sym)
292
+ return !!get({ primary_key.to_s => conditions[primary_key.to_sym] })
293
+ end
294
+
295
+ # For other conditions, use where with limit 1 and count
296
+ where(**conditions).limit(1).count > 0
297
+ end
298
+
299
+ def delete_all
300
+ all.destroy_all
301
+ end
302
+
303
+ # Define GSI indexes for the model
304
+ # This enables automatic index detection in where() queries
305
+ #
306
+ # @example
307
+ # class Pickup < ActiveItem::Base
308
+ # indexes(
309
+ # 'CustomerIndex' => { partition_key: 'customer_id' },
310
+ # 'StatusIndex' => { partition_key: 'status', sort_key: 'pickup_date' },
311
+ # 'EmployeeIndex' => { partition_key: 'assigned_employee_id', sort_key: 'pickup_date' }
312
+ # )
313
+ # end
314
+ #
315
+ RECENT_INDEX = { 'RecentIndex' => { partition_key: '_recent_pk', sort_key: 'createdAt' } }.freeze
316
+
317
+ def indexes(index_definitions = nil)
318
+ if index_definitions
319
+ @index_definitions = index_definitions
320
+ else
321
+ RECENT_INDEX.merge(@index_definitions || {})
322
+ end
323
+ end
324
+
325
+ private
326
+
327
+ # Detect which index to use based on query conditions
328
+ # Used by Relation class
329
+ def detect_index_for_conditions(conditions)
330
+ return nil if indexes.empty?
331
+
332
+ ruby_partition_key = conditions.keys.first.to_s
333
+ partition_value = conditions.values.first
334
+
335
+ # Can't use GSI query with nil value - must scan with attribute_not_exists
336
+ return nil if partition_value.nil?
337
+
338
+ # Convert Ruby attribute name to DynamoDB key name for comparison
339
+ dynamo_partition_key = to_dynamo_key(ruby_partition_key)
340
+
341
+ # First, try to find an index with the converted partition key
342
+ indexes.each do |index_name, config|
343
+ if config[:partition_key].to_s == dynamo_partition_key
344
+ return index_name
345
+ end
346
+ end
347
+
348
+ # Also try the original Ruby key (for legacy indexes defined with snake_case)
349
+ indexes.each do |index_name, config|
350
+ if config[:partition_key].to_s == ruby_partition_key
351
+ return index_name
352
+ end
353
+ end
354
+
355
+ # If not found, check if this is an association-based attribute
356
+ # e.g., customer_id might map to user_id via belongs_to :customer, foreign_key: :user_id
357
+ resolved_key = resolve_association_foreign_key_for_query(ruby_partition_key)
358
+
359
+ if resolved_key
360
+ dynamo_resolved_key = to_dynamo_key(resolved_key)
361
+ indexes.each do |index_name, config|
362
+ pk = config[:partition_key].to_s
363
+ if pk == dynamo_resolved_key || pk == resolved_key
364
+ return index_name
365
+ end
366
+ end
367
+ end
368
+
369
+ nil
370
+ end
371
+
372
+ # Resolve association-based attribute names to their actual foreign keys for queries
373
+ # e.g., customer_id -> user_id if belongs_to :customer, foreign_key: :user_id
374
+ def resolve_association_foreign_key_for_query(attr_name)
375
+ return nil unless attr_name.end_with?('_id')
376
+
377
+ # Extract the association name (remove _id suffix)
378
+ association_name = attr_name.chomp('_id').to_sym
379
+
380
+ # Check if this association exists
381
+ associations = _associations || {}
382
+ association_config = associations[association_name]
383
+
384
+ return nil unless association_config
385
+ return nil unless association_config[:type] == :belongs_to
386
+
387
+ # Get the foreign key from the association
388
+ foreign_key = association_config[:foreign_key]
389
+
390
+ # If the foreign key is different from the attribute name, return it
391
+ foreign_key.to_s != attr_name ? foreign_key.to_s : nil
392
+ end
393
+
394
+ # Build a condition expression for a single attribute
395
+ # Supports:
396
+ # - Simple attributes: { status: 'active' }
397
+ # - Dot notation for nested: { 'address.zip_code' => '12345' }
398
+ # - Nested hash syntax: { address: { zip_code: '12345' } }
399
+ # - Array values (IN): { status: ['active', 'pending'] }
400
+ # - Range values (BETWEEN): { date: Date.today..Date.today + 7 }
401
+ # - Beginless range (<=): { date: ..Date.today }
402
+ # - Endless range (>=): { date: Date.today.. }
403
+ # - Model objects: { parent_container: container } -> uses container.id
404
+ #
405
+ # Note: Attribute names are converted from Ruby snake_case to DynamoDB camelCase
406
+ #
407
+ # @param attr [String, Symbol] Attribute name (can include dots for nested)
408
+ # @param val [Object] Value to match (can be Hash for nested, Array for IN, Range for BETWEEN, nil for NOT EXISTS, or a ActiveItem model)
409
+ # @param idx [Integer] Index for unique placeholder names
410
+ # @param ilike [Boolean] If true, use case-insensitive contains() matching
411
+ # @return [Array<String, Hash, Hash>] [expression, attribute_names, attribute_values]
412
+ def build_condition_expression(attr, val, idx, ilike: false)
413
+ attr_str = attr.to_s
414
+
415
+ # Convert Ruby snake_case to DynamoDB camelCase for the attribute name
416
+ dynamo_attr = to_dynamo_key(attr_str)
417
+
418
+ # Handle nil - use attribute_not_exists (DynamoDB doesn't support = NULL)
419
+ if val.nil?
420
+ return ["attribute_not_exists(#attr#{idx})", { "#attr#{idx}" => dynamo_attr }, {}]
421
+ end
422
+
423
+ # Handle case-insensitive search with ilike option
424
+ # Uses DynamoDB's `contains` function with downcased value
425
+ if ilike && val.is_a?(String)
426
+ return build_ilike_condition(dynamo_attr, val, idx)
427
+ end
428
+
429
+ # Handle ActiveItem model objects - extract primary key value
430
+ # This allows queries like: Container.where(parent_container: some_container)
431
+ # Also converts association name to foreign key (parent_container -> parent_container_id)
432
+ if val.is_a?(ActiveItem::Base)
433
+ val = val.send(val.class.primary_key)
434
+ # Convert association name to foreign key if it doesn't already end with _id
435
+ attr_str = "#{attr_str}_id" unless attr_str.end_with?('_id')
436
+ dynamo_attr = to_dynamo_key(attr_str)
437
+ end
438
+
439
+ # Handle nested hash syntax: { address: { zip_code: '12345' } }
440
+ if val.is_a?(Hash)
441
+ return build_nested_hash_conditions(dynamo_attr, val, idx)
442
+ end
443
+
444
+ # Handle dot notation: 'address.zip_code'
445
+ if attr_str.include?('.')
446
+ return build_dot_notation_condition(attr_str, val, idx)
447
+ end
448
+
449
+ # Handle Range values (BETWEEN, >=, <=)
450
+ if val.is_a?(Range)
451
+ return build_range_condition(dynamo_attr, val, idx)
452
+ end
453
+
454
+ # Handle array values (IN clause)
455
+ if val.is_a?(Array)
456
+ return build_in_condition(dynamo_attr, val, idx)
457
+ end
458
+
459
+ # Simple equality condition (use placeholder for reserved words)
460
+ build_simple_condition(dynamo_attr, val, idx)
461
+ end
462
+
463
+ # Build case-insensitive condition using DynamoDB's contains function
464
+ # Note: This is a substring match. For exact case-insensitive matching,
465
+ # use exact: true which adds Ruby-side filtering.
466
+ #
467
+ # @param attr [String] Attribute name
468
+ # @param val [String] Search value (will be downcased)
469
+ # @param idx [Integer] Index for unique placeholder names
470
+ # @return [Array<String, Hash, Hash>] [expression, attribute_names, attribute_values]
471
+ def build_ilike_condition(attr, val, idx)
472
+ # For case-insensitive search, we can't rely on DynamoDB's case-sensitive contains()
473
+ # Instead, we'll return a condition that matches more broadly and filter in Ruby
474
+ # We use attribute_exists to ensure the field exists, then filter everything in Ruby
475
+ [
476
+ "attribute_exists(#attr#{idx})",
477
+ { "#attr#{idx}" => attr },
478
+ {}
479
+ ]
480
+ end
481
+
482
+ # Build condition for nested hash: { address: { zip_code: '12345' } }
483
+ # Converts nested keys to camelCase for DynamoDB compatibility
484
+ def build_nested_hash_conditions(parent_attr, nested_hash, base_idx)
485
+ expressions = []
486
+ all_names = {}
487
+ all_values = {}
488
+
489
+ nested_hash.each_with_index do |(nested_key, nested_val), nested_idx|
490
+ dynamo_nested_key = to_dynamo_key(nested_key.to_s)
491
+ full_path = "#{parent_attr}.#{dynamo_nested_key}"
492
+ idx = "#{base_idx}_#{nested_idx}"
493
+
494
+ if nested_val.is_a?(Hash)
495
+ # Recursively handle deeper nesting
496
+ expr, names, values = build_nested_hash_conditions(full_path, nested_val, idx)
497
+ expressions << expr
498
+ all_names.merge!(names)
499
+ all_values.merge!(values)
500
+ else
501
+ expr, names, values = build_dot_notation_condition(full_path, nested_val, idx)
502
+ expressions << expr
503
+ all_names.merge!(names)
504
+ all_values.merge!(values)
505
+ end
506
+ end
507
+
508
+ [expressions.join(' AND '), all_names, all_values]
509
+ end
510
+
511
+ # Build condition for dot notation: 'address.zip_code' => '12345'
512
+ def build_dot_notation_condition(attr_path, val, idx)
513
+ parts = attr_path.split('.')
514
+
515
+ # Build path expression with attribute name placeholders
516
+ path_placeholders = parts.map.with_index { |_, i| "#attr#{idx}_#{i}" }
517
+ path_expr = path_placeholders.join('.')
518
+
519
+ # Build attribute names map
520
+ names = {}
521
+ parts.each_with_index do |part, i|
522
+ names["#attr#{idx}_#{i}"] = part
523
+ end
524
+
525
+ if val.is_a?(Array)
526
+ # IN clause for nested attribute
527
+ placeholders = val.map.with_index { |_, i| ":val#{idx}_#{i}" }
528
+ values = {}
529
+ val.each_with_index { |v, i| values[":val#{idx}_#{i}"] = v }
530
+ ["#{path_expr} IN (#{placeholders.join(', ')})", names, values]
531
+ else
532
+ ["#{path_expr} = :val#{idx}", names, { ":val#{idx}" => val }]
533
+ end
534
+ end
535
+
536
+ # Build IN condition for array values
537
+ def build_in_condition(attr, values_array, idx)
538
+ placeholders = values_array.map.with_index { |_, i| ":val#{idx}_#{i}" }
539
+ values = {}
540
+ values_array.each_with_index { |v, i| values[":val#{idx}_#{i}"] = v }
541
+
542
+ # Use placeholder for attribute name (handles reserved words)
543
+ ["#attr#{idx} IN (#{placeholders.join(', ')})", { "#attr#{idx}" => attr }, values]
544
+ end
545
+
546
+ # Build simple equality condition
547
+ def build_simple_condition(attr, val, idx)
548
+ # Use placeholder for attribute name (handles reserved words like 'status')
549
+ ["#attr#{idx} = :val#{idx}", { "#attr#{idx}" => attr }, { ":val#{idx}" => val }]
550
+ end
551
+
552
+ # Build Range condition (BETWEEN, >=, <=)
553
+ # Supports:
554
+ # - Full range: date: Date.today..Date.today + 7 -> BETWEEN
555
+ # - Beginless range: date: ..Date.today -> <=
556
+ # - Endless range: date: Date.today.. -> >=
557
+ #
558
+ # @param attr [String] Attribute name
559
+ # @param range [Range] Range value
560
+ # @param idx [Integer] Index for unique placeholder names
561
+ # @return [Array<String, Hash, Hash>] [expression, attribute_names, attribute_values]
562
+ def build_range_condition(attr, range, idx)
563
+ names = { "#attr#{idx}" => attr }
564
+
565
+ # Normalize range values to strings (for dates, times, etc.)
566
+ range_begin = normalize_range_value(range.begin)
567
+ range_end = normalize_range_value(range.end)
568
+
569
+ if range_begin.nil?
570
+ # Beginless range: ..end_value (<=)
571
+ values = { ":val#{idx}" => range_end }
572
+ ["#attr#{idx} <= :val#{idx}", names, values]
573
+ elsif range_end.nil?
574
+ # Endless range: start_value.. (>=)
575
+ values = { ":val#{idx}" => range_begin }
576
+ ["#attr#{idx} >= :val#{idx}", names, values]
577
+ else
578
+ # Full range: start..end (BETWEEN)
579
+ values = {
580
+ ":val#{idx}_start" => range_begin,
581
+ ":val#{idx}_end" => range_end
582
+ }
583
+ ["#attr#{idx} BETWEEN :val#{idx}_start AND :val#{idx}_end", names, values]
584
+ end
585
+ end
586
+
587
+ # Normalize range values to DynamoDB-compatible strings
588
+ # Handles Date, Time, DateTime, ActiveSupport::TimeWithZone
589
+ def normalize_range_value(value)
590
+ return nil if value.nil?
591
+
592
+ case value
593
+ when Date
594
+ value.to_s # YYYY-MM-DD format
595
+ when Time, DateTime
596
+ value.utc.iso8601
597
+ else
598
+ # ActiveSupport::TimeWithZone or already a string
599
+ value.respond_to?(:to_date) ? value.to_date.to_s : value.to_s
600
+ end
601
+ end
602
+
603
+ # Build sort key condition for Range values in GSI queries
604
+ # Returns the key condition expression part and values for sort key
605
+ def build_sort_key_range_condition(sort_key, range, placeholder_prefix = 'sk')
606
+ range_begin = normalize_range_value(range.begin)
607
+ range_end = normalize_range_value(range.end)
608
+
609
+ if range_begin.nil?
610
+ # Beginless range: ..end_value (<=)
611
+ {
612
+ expression: "##{placeholder_prefix} <= :#{placeholder_prefix}_val",
613
+ names: { "##{placeholder_prefix}" => sort_key },
614
+ values: { ":#{placeholder_prefix}_val" => range_end }
615
+ }
616
+ elsif range_end.nil?
617
+ # Endless range: start_value.. (>=)
618
+ {
619
+ expression: "##{placeholder_prefix} >= :#{placeholder_prefix}_val",
620
+ names: { "##{placeholder_prefix}" => sort_key },
621
+ values: { ":#{placeholder_prefix}_val" => range_begin }
622
+ }
623
+ else
624
+ # Full range: BETWEEN
625
+ {
626
+ expression: "##{placeholder_prefix} BETWEEN :#{placeholder_prefix}_start AND :#{placeholder_prefix}_end",
627
+ names: { "##{placeholder_prefix}" => sort_key },
628
+ values: {
629
+ ":#{placeholder_prefix}_start" => range_begin,
630
+ ":#{placeholder_prefix}_end" => range_end
631
+ }
632
+ }
633
+ end
634
+ end
635
+
636
+ end
637
+ end