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,1509 @@
1
+ # encoding: utf-8
2
+
3
+ require_relative 'model_loader'
4
+ require_relative 'pagination'
5
+
6
+ module ActiveItem
7
+ # Chainable query builder that accumulates conditions and executes lazily
8
+ # Mimics ActiveRecord::Relation behavior
9
+ class Relation
10
+ include Enumerable
11
+ include ModelLoader
12
+
13
+ attr_reader :model, :conditions, :index_name, :limit_value, :not_conditions, :ilike, :ilike_exact, :class_name, :owner, :includes_associations, :order_direction, :select_attributes
14
+
15
+ def initialize(model, conditions: {}, index_name: nil, limit_value: nil, not_conditions: {}, ilike: false, ilike_exact: false, class_name: nil, owner: nil, preloaded_records: nil, includes_associations: [], order_direction: nil, select_attributes: nil)
16
+ @model = model
17
+ @class_name = class_name # For lazy loading
18
+ @owner = owner # The object that owns this association
19
+ @conditions = conditions.dup
20
+ @index_name = index_name
21
+ @limit_value = limit_value
22
+ @not_conditions = not_conditions.dup
23
+ @ilike = ilike # Use case-insensitive contains() for string conditions
24
+ @ilike_exact = ilike_exact # Require exact case-insensitive match (Ruby-side filter)
25
+ @loaded = preloaded_records ? true : false
26
+ @records = preloaded_records
27
+ @includes_associations = includes_associations
28
+ @order_direction = order_direction # :asc or :desc for DynamoDB ScanIndexForward
29
+ @select_attributes = select_attributes # Projection expression attributes
30
+ end
31
+
32
+ # Chainable includes - preload associations to avoid N+1 queries
33
+ #
34
+ # Supports three forms:
35
+ # Symbol – full preload (belongs_to uses BatchGetItem, has_many loads records)
36
+ # Hash :count – preload only the count (has_many only, uses SELECT COUNT on GSI)
37
+ # Hash :records – same as symbol form, explicit full preload
38
+ #
39
+ # @example Preload belongs_to and has_many counts
40
+ # Container.includes(:customer, child_containers: :count, items: :count).all
41
+ #
42
+ # @example Preload belongs_to only
43
+ # InventoryItem.includes(:customer, :container).where(...)
44
+ #
45
+ def includes(*associations)
46
+ # Normalize into a flat list: symbols stay as-is, hashes get merged
47
+ new_includes = includes_associations.dup
48
+ associations.each do |assoc|
49
+ case assoc
50
+ when Symbol
51
+ new_includes << assoc unless new_includes.include?(assoc)
52
+ when Hash
53
+ new_includes << assoc
54
+ else
55
+ new_includes << assoc
56
+ end
57
+ end
58
+
59
+ spawn(includes_associations: new_includes)
60
+ end
61
+
62
+ # Chainable where - returns a new Relation with merged conditions
63
+ # When called without arguments, returns a WhereChain for .not() syntax
64
+ def where(**new_conditions)
65
+ # If no conditions, return WhereChain for .not() chaining
66
+ return WhereChain.new(self) if new_conditions.empty?
67
+
68
+ # Extract special options
69
+ new_index = new_conditions.delete(:index) || new_conditions.delete(:index_name)
70
+ new_ilike = new_conditions.delete(:ilike)
71
+ new_exact = new_conditions.delete(:exact)
72
+
73
+ spawn(
74
+ conditions: conditions.merge(new_conditions),
75
+ index_name: new_index || index_name,
76
+ ilike: new_ilike.nil? ? ilike : new_ilike,
77
+ ilike_exact: new_exact.nil? ? ilike_exact : new_exact
78
+ )
79
+ end
80
+
81
+ # Chainable not - returns a WhereChain for negation or a new Relation with negated conditions
82
+ # Usage:
83
+ # Model.where.not(status: 'deleted') # Via WhereChain
84
+ # Model.where(active: true).not(archived: true) # Direct on Relation
85
+ #
86
+ # Supports:
87
+ # - nil values: where.not(parent_id: nil) -> attribute_exists(parent_id)
88
+ # - equality: where.not(status: 'deleted') -> status <> 'deleted'
89
+ # - arrays: where.not(status: ['a', 'b']) -> status NOT IN ('a', 'b')
90
+ #
91
+ def not(**negated_conditions)
92
+ spawn(not_conditions: not_conditions.merge(negated_conditions))
93
+ end
94
+
95
+ # Chainable limit
96
+ def limit(value)
97
+ spawn(limit_value: value)
98
+ end
99
+
100
+ # Chainable order - sets DynamoDB ScanIndexForward for sort key ordering
101
+ # Only effective when querying a GSI with a sort key.
102
+ #
103
+ # @param direction [Symbol] :asc (default, oldest first) or :desc (newest first)
104
+ # @return [Relation]
105
+ #
106
+ # @example Newest first
107
+ # BillingEvent.where(customer_id: id).order(:desc)
108
+ #
109
+ # @example Oldest first (default DynamoDB behavior)
110
+ # ItemChange.where(item_id: id).order(:asc)
111
+ #
112
+ def order(direction = :asc)
113
+ dir = direction.to_sym
114
+ raise ArgumentError, "order must be :asc or :desc, got #{direction.inspect}" unless %i[asc desc].include?(dir)
115
+
116
+ spawn(order_direction: dir)
117
+ end
118
+
119
+ # Chainable select - adds DynamoDB projection expression to only return specified attributes.
120
+ # Reduces RCU consumption and data transfer for queries that only need a few fields.
121
+ #
122
+ # When called with a block, delegates to Enumerable#select (Rails behavior).
123
+ # The primary key is always included automatically.
124
+ #
125
+ # @param attrs [Array<Symbol>] Attribute names to project
126
+ # @return [Relation]
127
+ #
128
+ # @example Column selection (DynamoDB projection)
129
+ # InventoryItem.where(customer_id: id).select(:id, :name)
130
+ #
131
+ # @example Enumerable filtering (block)
132
+ # InventoryItem.where(customer_id: id).select { |i| i.active? }
133
+ #
134
+ def select(*attrs, &block)
135
+ if block_given?
136
+ super(&block)
137
+ else
138
+ spawn(select_attributes: attrs.map(&:to_sym))
139
+ end
140
+ end
141
+
142
+ # Cursor-based pagination for DynamoDB
143
+ #
144
+ # @param cursor [String, nil] Base64-encoded LastEvaluatedKey from previous page, or nil for first page
145
+ # @param per_page [Integer] Number of items per page (default: 25, max: 100)
146
+ # @return [Pagination::PaginatedResult] Result with items and pagination metadata
147
+ #
148
+ # @example First page
149
+ # result = Model.where(status: 'active').page(nil, per_page: 25)
150
+ # result.items # => [Model, Model, ...]
151
+ # result.has_more? # => true
152
+ # result.next_cursor # => "eyJpZCI6IjEyMyJ9"
153
+ #
154
+ # @example Next page
155
+ # result = Model.where(status: 'active').page(params[:cursor], per_page: 25)
156
+ #
157
+ def page(cursor = nil, per_page: Pagination::DEFAULT_PER_PAGE)
158
+ per_page = [[per_page.to_i, 1].max, Pagination::MAX_PER_PAGE].min
159
+
160
+ items, next_cursor = execute_paginated_query(cursor, per_page)
161
+ if includes_associations.any?
162
+ begin
163
+ @_paginated = true
164
+ preload_associations_for_records(items)
165
+ ensure
166
+ @_paginated = false
167
+ end
168
+ end
169
+
170
+ Pagination::PaginatedResult.new(items: items, next_cursor: next_cursor, per_page: per_page)
171
+ end
172
+
173
+ def none
174
+ Relation.new(resolved_model, conditions: { _empty: true }, includes_associations: includes_associations)
175
+ end
176
+
177
+ # Returns self, mirroring ActiveRecord::Relation#all behavior.
178
+ # Allows chaining like: Model.includes(:assoc).all.limit(100)
179
+ def all
180
+ self
181
+ end
182
+
183
+ # Execute query and iterate over results
184
+ def each(&block)
185
+ load_records
186
+ @records.each(&block)
187
+ end
188
+
189
+ # Get first record
190
+ def first
191
+ limit(1).to_a.first
192
+ end
193
+
194
+ # Get last record (loads all, not efficient for large sets)
195
+ def last
196
+ to_a.last
197
+ end
198
+
199
+ # Count records
200
+ # When called with a block, delegates to Enumerable#count (Ruby-side filtering)
201
+ # When called without a block, returns the count of loaded records
202
+ #
203
+ # @example Without block
204
+ # Pickup.where(status: 'pending').count # => 5
205
+ #
206
+ # @example With block (Rails-like)
207
+ # Pickup.all.count { |p| p.time_slot == "10-12" } # => 3
208
+ #
209
+ def count(&block)
210
+ if block_given? || ilike || ilike_exact
211
+ load_records
212
+ return block_given? ? @records.count(&block) : @records.length
213
+ end
214
+
215
+ return @records.length if @loaded
216
+
217
+ execute_count_query
218
+ end
219
+
220
+ # Length/size always return the total count (no block support)
221
+ def length
222
+ load_records
223
+ @records.length
224
+ end
225
+ alias_method :size, :length
226
+
227
+ # Check if any records exist
228
+ def any?
229
+ !empty?
230
+ end
231
+
232
+ # Check if no records exist
233
+ def empty?
234
+ count == 0
235
+ end
236
+
237
+ # Check if records exist matching optional conditions
238
+ def exists?(**additional_conditions)
239
+ if additional_conditions.any?
240
+ where(**additional_conditions).limit(1).any?
241
+ else
242
+ limit(1).any?
243
+ end
244
+ end
245
+
246
+ # Convert to array (triggers query execution)
247
+ def to_a
248
+ load_records
249
+ @records
250
+ end
251
+ alias_method :to_ary, :to_a
252
+
253
+ # Re-fetch full records from the main table via batch_find, or return
254
+ # already-loaded records if the initial query returned full items.
255
+ #
256
+ # Detects whether the query results contain only key attributes (KEYS_ONLY GSI)
257
+ # or full items, and skips the re-fetch when unnecessary.
258
+ #
259
+ # @example
260
+ # container.items.load # full InventoryItem records
261
+ # container.items.load.count # works like a normal array
262
+ #
263
+ # @return [Array<ActiveItem::Base>] Fully-hydrated model instances
264
+ def load
265
+ records = to_a
266
+ return [] if records.empty?
267
+
268
+ # Check if records already have full attributes (not just keys)
269
+ # A KEYS_ONLY GSI record will only have the primary key + sort key attributes
270
+ sample = records.first
271
+ attr_count = sample.class.attribute_names.count { |a| sample.instance_variable_get("@#{a}") != nil }
272
+
273
+ # If the record has more than just the key attributes, it's already fully hydrated
274
+ return records if attr_count > 2
275
+
276
+ resolved_model.batch_find(records.map(&:id))
277
+ end
278
+
279
+ # Pluck specific attributes
280
+ def pluck(*attrs)
281
+ to_a.map do |record|
282
+ if attrs.length == 1
283
+ record.send(attrs.first)
284
+ else
285
+ attrs.map { |attr| record.send(attr) }
286
+ end
287
+ end
288
+ end
289
+
290
+ # Find by id within the current scope, or find by block (like Enumerable#find)
291
+ #
292
+ # @overload find(id)
293
+ # Find a record by ID within the current scope
294
+ # @param id [String] The ID to find
295
+ # @return [Object, nil] The found record or nil
296
+ #
297
+ # @overload find(&block)
298
+ # Find the first record matching the block condition (like Enumerable#find/detect)
299
+ # @yield [record] Evaluates the block for each record
300
+ # @return [Object, nil] The first record where block returns true, or nil
301
+ #
302
+ # @example Find by ID
303
+ # User.where(status: 'active').find('user-123')
304
+ #
305
+ # @example Find by block
306
+ # User.where(status: 'active').find { |u| u.email.include?('@example.com') }
307
+ #
308
+ def find(id = nil, &block)
309
+ if block_given?
310
+ # Delegate to Enumerable#find when block is given (Rails behavior)
311
+ to_a.find(&block)
312
+ elsif id
313
+ # Use direct GetItem instead of scanning — O(1) vs O(n)
314
+ record = resolved_model.find(id)
315
+ preload_associations_for_records([record]) if includes_associations.any?
316
+ record
317
+ else
318
+ raise ArgumentError, 'find requires either an ID or a block'
319
+ end
320
+ rescue ActiveItem::RecordNotFound
321
+ nil
322
+ end
323
+
324
+ # Find by conditions within current scope
325
+ # Always loads records first, then filters in memory
326
+ # This ensures we search through all records in the current scope
327
+ def find_by(**additional_conditions)
328
+ load_records unless @loaded
329
+
330
+ # Filter loaded records in memory
331
+ @records.find do |record|
332
+ additional_conditions.all? do |key, value|
333
+ record.send(key) == value
334
+ end
335
+ end
336
+ end
337
+
338
+ # Destroy all matching records
339
+ def destroy_all
340
+ to_a.each(&:destroy)
341
+ end
342
+
343
+ # Delete all matching records (no callbacks)
344
+ def delete_all
345
+ to_a.each(&:delete)
346
+ end
347
+
348
+ # Reload the relation (clear cached records)
349
+ def reload
350
+ @loaded = false
351
+ @records = nil
352
+ self
353
+ end
354
+
355
+ # Add a record to this association
356
+ # Sets the foreign key and saves the record
357
+ # Usage: container.items << item
358
+ # @param record [ActiveItem::Base] Record to add to the association
359
+ # @return [ActiveItem::Base] The added record
360
+ def <<(record)
361
+ # Get the foreign key from conditions (first condition is the foreign key)
362
+ foreign_key, foreign_value = conditions.first
363
+
364
+ # Set the foreign key on the record
365
+ record.send("#{foreign_key}=", foreign_value)
366
+
367
+ # Save the record
368
+ record.save!
369
+
370
+ # Clear cached records so next access will reload
371
+ reload
372
+
373
+ # Return the record for chaining
374
+ record
375
+ end
376
+
377
+ # Returns the DynamoDB operation that would be executed, without running it.
378
+ # Analogous to ActiveRecord's .to_sql — shows the operation type, table, index,
379
+ # key conditions, filters, and limits in DynamoDB terms.
380
+ #
381
+ # @return [Hash] Operation details (:operation, :table, :params)
382
+ #
383
+ # @example
384
+ # ActionLog.where(actor_id: 'user-1').explain
385
+ # # => { operation: :query, table: "myapp-dev-action-logs", index: "ActorIndex", params: { ... } }
386
+ #
387
+ # ActionLog.where(status: 'active').not(archived: true).limit(10).explain
388
+ # # => { operation: :scan, table: "myapp-dev-action-logs", params: { ... } }
389
+ #
390
+ def explain
391
+ return { operation: :none, reason: 'empty relation' } if conditions[:_empty]
392
+
393
+ normalized_conditions = normalize_conditions(conditions)
394
+ effective_index = if normalized_conditions.any?
395
+ index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
396
+ end
397
+
398
+ if normalized_conditions.empty? && not_conditions.empty?
399
+ params = { table_name: resolved_model.table_name }
400
+ params[:limit] = limit_value if limit_value
401
+ return { operation: :scan, table: resolved_model.table_name, params: params }
402
+ end
403
+
404
+ if effective_index && normalized_conditions.any?
405
+ params = build_explain_query_params(effective_index, normalized_conditions)
406
+ { operation: :query, table: resolved_model.table_name, index: effective_index, params: params }
407
+ else
408
+ params = build_explain_scan_params(normalized_conditions)
409
+ { operation: :scan, table: resolved_model.table_name, params: params }
410
+ end
411
+ end
412
+
413
+ # For debugging
414
+ def inspect
415
+ if @loaded
416
+ "#<#{self.class.name} [#{@records.map(&:inspect).join(', ')}]>"
417
+ else
418
+ parts = []
419
+ parts << "conditions=#{conditions.inspect}" if conditions.any?
420
+ parts << "not_conditions=#{not_conditions.inspect}" if not_conditions.any?
421
+ parts << "ilike=true" if ilike
422
+ parts << "exact=true" if ilike_exact
423
+ "#<#{self.class.name} (not loaded) #{parts.join(' ')}>"
424
+ end
425
+ end
426
+
427
+ # Forward named scope calls to the model's scope registry
428
+ def method_missing(method_name, *args, &block)
429
+ if resolved_model.respond_to?(:_scopes) && resolved_model._scopes.key?(method_name)
430
+ instance_exec(&resolved_model._scopes[method_name])
431
+ else
432
+ super
433
+ end
434
+ end
435
+
436
+ def respond_to_missing?(method_name, include_private = false)
437
+ (resolved_model.respond_to?(:_scopes) && resolved_model._scopes.key?(method_name)) || super
438
+ end
439
+
440
+ private
441
+
442
+ # Spawn a new Relation with overridden attributes, preserving all others.
443
+ # Eliminates repetitive Relation.new(...) calls across chain methods.
444
+ def spawn(**overrides)
445
+ Relation.new(
446
+ overrides.fetch(:model, model),
447
+ conditions: overrides.fetch(:conditions, conditions),
448
+ index_name: overrides.fetch(:index_name, index_name),
449
+ limit_value: overrides.fetch(:limit_value, limit_value),
450
+ not_conditions: overrides.fetch(:not_conditions, not_conditions),
451
+ ilike: overrides.fetch(:ilike, ilike),
452
+ ilike_exact: overrides.fetch(:ilike_exact, ilike_exact),
453
+ class_name: overrides.fetch(:class_name, class_name),
454
+ owner: overrides.fetch(:owner, owner),
455
+ includes_associations: overrides.fetch(:includes_associations, includes_associations),
456
+ order_direction: overrides.fetch(:order_direction, order_direction),
457
+ select_attributes: overrides.fetch(:select_attributes, select_attributes)
458
+ )
459
+ end
460
+
461
+ # Resolve the model class lazily
462
+ # This defers loading until query execution to avoid circular dependencies
463
+ def resolved_model
464
+ return @model if @model
465
+ return Object unless @class_name
466
+
467
+ # Lazy load the class when first needed
468
+ @model ||= safe_constantize_model(@class_name)
469
+ end
470
+
471
+ # Apply Ruby-side filter for case-insensitive matching
472
+ # Called after DynamoDB query returns results when ilike is true
473
+ def apply_ilike_filter(records)
474
+ return records unless ilike
475
+
476
+ # Get the string conditions that need case-insensitive matching
477
+ string_conditions = conditions.select { |_, v| v.is_a?(String) }
478
+ return records if string_conditions.empty?
479
+
480
+ records.select do |record|
481
+ string_conditions.all? do |attr, expected_value|
482
+ actual_value = record.send(attr)&.to_s&.downcase
483
+ expected_downcase = expected_value.to_s.downcase
484
+
485
+ if ilike_exact
486
+ # Exact case-insensitive match
487
+ actual_value == expected_downcase
488
+ else
489
+ # Case-insensitive substring match
490
+ actual_value&.include?(expected_downcase)
491
+ end
492
+ end
493
+ end
494
+ end
495
+
496
+ def load_records
497
+ return if @loaded
498
+
499
+ @records = execute_query
500
+ preload_associations_for_records(@records) if includes_associations.any?
501
+ @loaded = true
502
+ end
503
+
504
+ # Execute a paginated query with cursor support
505
+ # @param cursor [String, nil] Base64-encoded LastEvaluatedKey
506
+ # @param per_page [Integer] Number of items to fetch
507
+ # @return [Array<Array<Model>, String>] [items, next_cursor]
508
+ def execute_paginated_query(cursor, per_page)
509
+ return [[], nil] if conditions[:_empty]
510
+
511
+ normalized_conditions = normalize_conditions(conditions)
512
+ exclusive_start_key = decode_cursor(cursor)
513
+
514
+ effective_index = index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
515
+
516
+ if effective_index && normalized_conditions.any?
517
+ paginated_query_with_index(effective_index, normalized_conditions, exclusive_start_key, per_page)
518
+ else
519
+ paginated_scan_with_conditions(normalized_conditions, exclusive_start_key, per_page)
520
+ end
521
+ rescue Aws::DynamoDB::Errors::AccessDeniedException => e
522
+ raise ActiveItem::AccessDeniedError.new(model_name: resolved_model.name, table: resolved_model.table_name,
523
+ operation: 'PaginatedQuery', original_error: e)
524
+ end
525
+
526
+ # Decode Base64 cursor to DynamoDB LastEvaluatedKey
527
+ def decode_cursor(cursor)
528
+ return nil if cursor.nil? || cursor.empty?
529
+
530
+ require 'base64'
531
+ require 'json'
532
+ JSON.parse(Base64.urlsafe_decode64(cursor))
533
+ rescue ArgumentError, JSON::ParserError => e
534
+ ActiveItem.logger.warn("Invalid pagination cursor: #{e.message}")
535
+ nil
536
+ end
537
+
538
+ # Encode DynamoDB LastEvaluatedKey to Base64 cursor
539
+ def encode_cursor(last_evaluated_key)
540
+ return nil if last_evaluated_key.nil?
541
+
542
+ require 'base64'
543
+ require 'json'
544
+ Base64.urlsafe_encode64(last_evaluated_key.to_json, padding: false)
545
+ end
546
+
547
+ # Execute paginated query using GSI
548
+ def paginated_query_with_index(idx_name, normalized_conditions, exclusive_start_key, per_page)
549
+ ruby_partition_key = normalized_conditions.keys.first.to_s
550
+ partition_value = normalized_conditions.values.first
551
+
552
+ index_config = resolved_model.indexes[idx_name] || {}
553
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
554
+
555
+ params = {
556
+ table_name: resolved_model.table_name,
557
+ index_name: idx_name,
558
+ key_condition_expression: "#pk = :pk_val",
559
+ expression_attribute_names: { '#pk' => dynamo_partition_key },
560
+ expression_attribute_values: { ':pk_val' => partition_value },
561
+ limit: per_page
562
+ }
563
+
564
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
565
+ params[:scan_index_forward] = (order_direction != :desc) unless order_direction.nil?
566
+
567
+ # Add sort key and filter conditions (same logic as non-paginated)
568
+ sort_key = index_config[:sort_key]&.to_s
569
+ remaining_conditions = conditions.to_a[1..]
570
+
571
+ if sort_key && remaining_conditions.any?
572
+ sort_condition = remaining_conditions.find { |k, _|
573
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
574
+ }
575
+ if sort_condition
576
+ _, sort_value = sort_condition
577
+ remaining_conditions = remaining_conditions.reject { |k, _|
578
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
579
+ }
580
+
581
+ if sort_value.is_a?(Range)
582
+ range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
583
+ params[:key_condition_expression] += " AND #{range_condition[:expression]}"
584
+ params[:expression_attribute_names].merge!(range_condition[:names])
585
+ params[:expression_attribute_values].merge!(range_condition[:values])
586
+ else
587
+ params[:key_condition_expression] += " AND #sk = :sk_val"
588
+ params[:expression_attribute_names]['#sk'] = sort_key
589
+ params[:expression_attribute_values][':sk_val'] = sort_value
590
+ end
591
+ end
592
+ end
593
+
594
+ # Add filter expression for remaining conditions
595
+ filter_parts = []
596
+
597
+ if remaining_conditions.any?
598
+ remaining_conditions.each_with_index do |(attr, val), idx|
599
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: ilike)
600
+ filter_parts << expr
601
+ params[:expression_attribute_names].merge!(names)
602
+ params[:expression_attribute_values].merge!(values) if values.any?
603
+ end
604
+ end
605
+
606
+ if not_conditions.any?
607
+ not_conditions.each_with_index do |(attr, val), idx|
608
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
609
+ filter_parts << expr
610
+ params[:expression_attribute_names].merge!(names)
611
+ params[:expression_attribute_values].merge!(values) if values.any?
612
+ end
613
+ end
614
+
615
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
616
+
617
+ # Fetch items until we have enough that pass the filter
618
+ collected_items = []
619
+ current_cursor = exclusive_start_key
620
+ last_evaluated_key = nil
621
+
622
+ loop do
623
+ params[:exclusive_start_key] = current_cursor if current_cursor
624
+ # Fetch more than needed to reduce round trips when filter removes items
625
+ params[:limit] = [per_page * 2, 100].min
626
+
627
+ response = resolved_model.dynamodb.query(params)
628
+ items = response.items.map { |item| resolved_model.instantiate(item) }
629
+ items = apply_ilike_filter(items)
630
+
631
+ collected_items.concat(items)
632
+ last_evaluated_key = response.last_evaluated_key
633
+ current_cursor = last_evaluated_key
634
+
635
+ # Stop if we have enough items or no more pages
636
+ break if collected_items.length >= per_page || last_evaluated_key.nil?
637
+ end
638
+
639
+ # Trim to requested page size and determine next cursor
640
+ if collected_items.length > per_page
641
+ [collected_items.take(per_page), encode_cursor(last_evaluated_key || current_cursor)]
642
+ else
643
+ [collected_items, encode_cursor(last_evaluated_key)]
644
+ end
645
+ end
646
+
647
+ # Execute paginated scan
648
+ # Keeps fetching until we have per_page items that pass the filter
649
+ def paginated_scan_with_conditions(normalized_conditions, exclusive_start_key, per_page)
650
+ filter_parts = []
651
+ filter_values = {}
652
+ filter_names = {}
653
+
654
+ normalized_conditions.each_with_index do |(attr, val), idx|
655
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: ilike)
656
+ filter_parts << expr
657
+ filter_names.merge!(names)
658
+ filter_values.merge!(values) if values.any?
659
+ end
660
+
661
+ not_conditions.each_with_index do |(attr, val), idx|
662
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
663
+ filter_parts << expr
664
+ filter_names.merge!(names)
665
+ filter_values.merge!(values) if values.any?
666
+ end
667
+
668
+ params = { table_name: resolved_model.table_name }
669
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
670
+ params[:expression_attribute_names] = filter_names if filter_names.any?
671
+ params[:expression_attribute_values] = filter_values if filter_values.any?
672
+
673
+ # Fetch items until we have enough that pass the filter
674
+ collected_items = []
675
+ current_cursor = exclusive_start_key
676
+ last_evaluated_key = nil
677
+
678
+ loop do
679
+ params[:exclusive_start_key] = current_cursor if current_cursor
680
+ # Fetch more than needed to reduce round trips when filter removes items
681
+ params[:limit] = [per_page * 2, 100].min
682
+
683
+ response = resolved_model.dynamodb.scan(params)
684
+ items = response.items.map { |item| resolved_model.instantiate(item) }
685
+ items = apply_ilike_filter(items)
686
+
687
+ collected_items.concat(items)
688
+ last_evaluated_key = response.last_evaluated_key
689
+ current_cursor = last_evaluated_key
690
+
691
+ # Stop if we have enough items or no more pages
692
+ break if collected_items.length >= per_page || last_evaluated_key.nil?
693
+ end
694
+
695
+ # Trim to requested page size and determine next cursor
696
+ if collected_items.length > per_page
697
+ # We have more items than needed — cursor must point to the last returned item's key
698
+ # so the next page starts right after it (not after the last scanned item)
699
+ last_returned = collected_items[per_page - 1]
700
+ next_key = { resolved_model.primary_key.to_s => last_returned.id }
701
+ [collected_items.take(per_page), encode_cursor(next_key)]
702
+ else
703
+ [collected_items, encode_cursor(last_evaluated_key)]
704
+ end
705
+ end
706
+
707
+ def execute_query
708
+ # Handle empty marker (from associations with nil key)
709
+ return [] if conditions[:_empty]
710
+
711
+ # Normalize conditions to resolve attribute aliases
712
+ normalized_conditions = normalize_conditions(conditions)
713
+
714
+ records = if normalized_conditions.empty? && not_conditions.empty?
715
+ # Direct scan - don't call model.all to avoid recursion
716
+ items = resolved_model.scan(limit: limit_value)
717
+ items.map { |item| resolved_model.instantiate(item) }
718
+ else
719
+ # Determine index to use (use normalized conditions)
720
+ effective_index = index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
721
+
722
+ if effective_index && normalized_conditions.any?
723
+ query_with_index_normalized(effective_index, normalized_conditions)
724
+ else
725
+ scan_with_conditions_normalized(normalized_conditions)
726
+ end
727
+ end
728
+
729
+ # Apply Ruby-side filter for case-insensitive matching
730
+ apply_ilike_filter(records)
731
+ rescue Aws::DynamoDB::Errors::AccessDeniedException => e
732
+ raise ActiveItem::AccessDeniedError.new(model_name: resolved_model.name, table: resolved_model.table_name,
733
+ operation: 'Query/Scan', original_error: e)
734
+ end
735
+
736
+ # Execute a count-only query using DynamoDB SELECT: 'COUNT'
737
+ # Avoids materializing records — returns just the count integer.
738
+ def execute_count_query
739
+ return 0 if conditions[:_empty]
740
+
741
+ normalized_conditions = normalize_conditions(conditions)
742
+
743
+ effective_index = if normalized_conditions.any?
744
+ index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions)
745
+ end
746
+
747
+ total = 0
748
+ exclusive_start_key = nil
749
+
750
+ loop do
751
+ params = if effective_index && normalized_conditions.any?
752
+ build_count_query_params(effective_index, normalized_conditions)
753
+ else
754
+ build_count_scan_params(normalized_conditions)
755
+ end
756
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
757
+
758
+ response = if effective_index && normalized_conditions.any?
759
+ resolved_model.dynamodb.query(params)
760
+ else
761
+ resolved_model.dynamodb.scan(params)
762
+ end
763
+
764
+ total += response.count
765
+ exclusive_start_key = response.last_evaluated_key
766
+ break unless exclusive_start_key
767
+ end
768
+
769
+ total
770
+ rescue Aws::DynamoDB::Errors::AccessDeniedException => e
771
+ raise ActiveItem::AccessDeniedError.new(model_name: resolved_model.name, table: resolved_model.table_name,
772
+ operation: 'Count', original_error: e)
773
+ end
774
+ def build_count_query_params(idx_name, normalized_conditions)
775
+ ruby_partition_key = normalized_conditions.keys.first.to_s
776
+ partition_value = normalized_conditions.values.first
777
+
778
+ index_config = resolved_model.indexes[idx_name] || {}
779
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
780
+
781
+ params = {
782
+ table_name: resolved_model.table_name,
783
+ index_name: idx_name,
784
+ select: 'COUNT',
785
+ key_condition_expression: '#pk = :pk_val',
786
+ expression_attribute_names: { '#pk' => dynamo_partition_key },
787
+ expression_attribute_values: { ':pk_val' => partition_value }
788
+ }
789
+
790
+ # Sort key condition
791
+ sort_key = index_config[:sort_key]&.to_s
792
+ remaining_conditions = conditions.to_a[1..]
793
+
794
+ if sort_key && remaining_conditions.any?
795
+ sort_condition = remaining_conditions.find { |k, _|
796
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
797
+ }
798
+ if sort_condition
799
+ _, sort_value = sort_condition
800
+ remaining_conditions = remaining_conditions.reject { |k, _|
801
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
802
+ }
803
+
804
+ if sort_value.is_a?(Range)
805
+ range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
806
+ params[:key_condition_expression] += " AND #{range_condition[:expression]}"
807
+ params[:expression_attribute_names].merge!(range_condition[:names])
808
+ params[:expression_attribute_values].merge!(range_condition[:values])
809
+ else
810
+ params[:key_condition_expression] += " AND #sk = :sk_val"
811
+ params[:expression_attribute_names]['#sk'] = sort_key
812
+ params[:expression_attribute_values][':sk_val'] = sort_value
813
+ end
814
+ end
815
+ end
816
+
817
+ # Filter expressions (remaining conditions + not_conditions)
818
+ filter_parts = []
819
+
820
+ if remaining_conditions.any?
821
+ remaining_conditions.each_with_index do |(attr, val), idx|
822
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: false)
823
+ filter_parts << expr
824
+ params[:expression_attribute_names].merge!(names)
825
+ params[:expression_attribute_values].merge!(values) if values.any?
826
+ end
827
+ end
828
+
829
+ if not_conditions.any?
830
+ not_conditions.each_with_index do |(attr, val), idx|
831
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
832
+ filter_parts << expr
833
+ params[:expression_attribute_names].merge!(names)
834
+ params[:expression_attribute_values].merge!(values) if values.any?
835
+ end
836
+ end
837
+
838
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
839
+ params
840
+ end
841
+
842
+ # Build scan params for a count-only scan
843
+ def build_count_scan_params(normalized_conditions)
844
+ filter_parts = []
845
+ filter_names = {}
846
+ filter_values = {}
847
+
848
+ normalized_conditions.each_with_index do |(attr, val), idx|
849
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: false)
850
+ filter_parts << expr
851
+ filter_names.merge!(names)
852
+ filter_values.merge!(values) if values.any?
853
+ end
854
+
855
+ not_conditions.each_with_index do |(attr, val), idx|
856
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
857
+ filter_parts << expr
858
+ filter_names.merge!(names)
859
+ filter_values.merge!(values) if values.any?
860
+ end
861
+
862
+ params = { table_name: resolved_model.table_name, select: 'COUNT' }
863
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
864
+ params[:expression_attribute_names] = filter_names if filter_names.any?
865
+ params[:expression_attribute_values] = filter_values if filter_values.any?
866
+ params
867
+ end
868
+
869
+ # Build query params for explain (same logic as query_with_index_normalized but no execution)
870
+ def build_explain_query_params(idx_name, normalized_conditions)
871
+ ruby_partition_key = normalized_conditions.keys.first.to_s
872
+ partition_value = normalized_conditions.values.first
873
+
874
+ index_config = resolved_model.indexes[idx_name] || {}
875
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
876
+
877
+ params = {
878
+ table_name: resolved_model.table_name,
879
+ index_name: idx_name,
880
+ key_condition_expression: "#pk = :pk_val",
881
+ expression_attribute_names: { '#pk' => dynamo_partition_key },
882
+ expression_attribute_values: { ':pk_val' => partition_value }
883
+ }
884
+
885
+ sort_key = index_config[:sort_key]&.to_s
886
+ remaining_conditions = conditions.to_a[1..]
887
+
888
+ if sort_key && remaining_conditions.any?
889
+ sort_condition = remaining_conditions.find { |k, _|
890
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
891
+ }
892
+ if sort_condition
893
+ _, sort_value = sort_condition
894
+ remaining_conditions = remaining_conditions.reject { |k, _|
895
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
896
+ }
897
+
898
+ if sort_value.is_a?(Range)
899
+ range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
900
+ params[:key_condition_expression] += " AND #{range_condition[:expression]}"
901
+ params[:expression_attribute_names].merge!(range_condition[:names])
902
+ params[:expression_attribute_values].merge!(range_condition[:values])
903
+ else
904
+ params[:key_condition_expression] += " AND #sk = :sk_val"
905
+ params[:expression_attribute_names]['#sk'] = sort_key
906
+ params[:expression_attribute_values][':sk_val'] = sort_value
907
+ end
908
+ end
909
+ end
910
+
911
+ filter_parts = []
912
+
913
+ if remaining_conditions.any?
914
+ remaining_conditions.each_with_index do |(attr, val), idx|
915
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: ilike)
916
+ filter_parts << expr
917
+ params[:expression_attribute_names].merge!(names)
918
+ params[:expression_attribute_values].merge!(values) if values.any?
919
+ end
920
+ end
921
+
922
+ if not_conditions.any?
923
+ not_conditions.each_with_index do |(attr, val), idx|
924
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
925
+ filter_parts << expr
926
+ params[:expression_attribute_names].merge!(names)
927
+ params[:expression_attribute_values].merge!(values) if values.any?
928
+ end
929
+ end
930
+
931
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
932
+ params[:limit] = limit_value if limit_value
933
+ params[:scan_index_forward] = (order_direction != :desc) unless order_direction.nil?
934
+ apply_projection_expression!(params)
935
+ params
936
+ end
937
+
938
+ # Build scan params for explain (same logic as scan_with_conditions_normalized but no execution)
939
+ def build_explain_scan_params(normalized_conditions)
940
+ filter_parts = []
941
+ filter_names = {}
942
+ filter_values = {}
943
+
944
+ normalized_conditions.each_with_index do |(attr, val), idx|
945
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: ilike)
946
+ filter_parts << expr
947
+ filter_names.merge!(names)
948
+ filter_values.merge!(values) if values.any?
949
+ end
950
+
951
+ not_conditions.each_with_index do |(attr, val), idx|
952
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
953
+ filter_parts << expr
954
+ filter_names.merge!(names)
955
+ filter_values.merge!(values) if values.any?
956
+ end
957
+
958
+ proj_expr, proj_names = build_projection_expression
959
+ filter_names.merge!(proj_names) if proj_names
960
+
961
+ params = { table_name: resolved_model.table_name }
962
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
963
+ params[:expression_attribute_names] = filter_names if filter_names.any?
964
+ params[:expression_attribute_values] = filter_values if filter_values.any?
965
+ params[:projection_expression] = proj_expr if proj_expr
966
+ params[:limit] = limit_value if limit_value
967
+ params
968
+ end
969
+
970
+ # Normalize conditions to resolve attribute aliases based on association metadata
971
+ # When a belongs_to association has a custom foreign_key, queries using the
972
+ # association name + _id should be converted to the actual foreign_key
973
+ # e.g., belongs_to :customer, foreign_key: :user_id
974
+ # where(customer_id: ...) -> where(user_id: ...)
975
+ def normalize_conditions(conds)
976
+ normalized = {}
977
+
978
+ conds.each do |key, value|
979
+ key_str = key.to_s
980
+
981
+ # Check if this key matches an association name pattern (association_name + _id)
982
+ # and if that association has a different foreign_key
983
+ resolved_key = resolve_association_foreign_key(key_str) || key_str
984
+
985
+ normalized[resolved_key.to_sym] = value
986
+ end
987
+
988
+ normalized
989
+ end
990
+
991
+ # Resolve association-based attribute names to their actual foreign keys
992
+ # e.g., customer_id -> user_id if belongs_to :customer, foreign_key: :user_id
993
+ def resolve_association_foreign_key(attr_name)
994
+ return nil unless attr_name.end_with?('_id')
995
+
996
+ # Extract the association name (remove _id suffix)
997
+ association_name = attr_name.chomp('_id').to_sym
998
+
999
+ # Check if this association exists
1000
+ associations = resolved_model._associations || {}
1001
+ association_config = associations[association_name]
1002
+
1003
+ return nil unless association_config
1004
+ return nil unless association_config[:type] == :belongs_to
1005
+
1006
+ # Get the foreign key from the association
1007
+ foreign_key = association_config[:foreign_key]
1008
+
1009
+ # If the foreign key is different from the attribute name, return it
1010
+ foreign_key.to_s != attr_name ? foreign_key.to_s : nil
1011
+ end
1012
+
1013
+ def query_with_index_normalized(idx_name, normalized_conditions)
1014
+ # Get the partition key from conditions (Ruby snake_case)
1015
+ ruby_partition_key = normalized_conditions.keys.first.to_s
1016
+ partition_value = normalized_conditions.values.first
1017
+
1018
+ # Get the actual DynamoDB partition key name from the index definition
1019
+ # The index definition stores the DynamoDB key name (which may be camelCase)
1020
+ index_config = resolved_model.indexes[idx_name] || {}
1021
+ dynamo_partition_key = index_config[:partition_key]&.to_s || resolved_model.to_dynamo_key(ruby_partition_key)
1022
+
1023
+ if partition_value.is_a?(Array)
1024
+ raise ArgumentError, "Array values not supported for partition key queries. Use scan instead."
1025
+ end
1026
+
1027
+ if partition_value.is_a?(Range)
1028
+ raise ArgumentError, "Range values not supported for partition key queries. Use scan instead."
1029
+ end
1030
+
1031
+ params = {
1032
+ table_name: resolved_model.table_name,
1033
+ index_name: idx_name,
1034
+ key_condition_expression: "#pk = :pk_val",
1035
+ expression_attribute_names: { '#pk' => dynamo_partition_key },
1036
+ expression_attribute_values: { ':pk_val' => partition_value }
1037
+ }
1038
+
1039
+ # Add sort key condition if present and index has a sort key
1040
+ sort_key = index_config[:sort_key]&.to_s
1041
+
1042
+ remaining_conditions = conditions.to_a[1..]
1043
+
1044
+ if sort_key && remaining_conditions.any?
1045
+ # Find the sort key condition by matching the DynamoDB key name
1046
+ sort_condition = remaining_conditions.find { |k, _|
1047
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
1048
+ }
1049
+ if sort_condition
1050
+ _, sort_value = sort_condition
1051
+ remaining_conditions = remaining_conditions.reject { |k, _|
1052
+ resolved_model.to_dynamo_key(k.to_s) == sort_key || k.to_s == sort_key
1053
+ }
1054
+
1055
+ if sort_value.is_a?(Range)
1056
+ range_condition = resolved_model.send(:build_sort_key_range_condition, sort_key, sort_value)
1057
+ params[:key_condition_expression] += " AND #{range_condition[:expression]}"
1058
+ params[:expression_attribute_names].merge!(range_condition[:names])
1059
+ params[:expression_attribute_values].merge!(range_condition[:values])
1060
+ else
1061
+ params[:key_condition_expression] += " AND #sk = :sk_val"
1062
+ params[:expression_attribute_names]['#sk'] = sort_key
1063
+ params[:expression_attribute_values][':sk_val'] = sort_value
1064
+ end
1065
+ end
1066
+ end
1067
+
1068
+ # Add remaining conditions as filter expression
1069
+ filter_parts = []
1070
+
1071
+ if remaining_conditions.any?
1072
+ remaining_conditions.each_with_index do |(attr, val), idx|
1073
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: ilike)
1074
+ filter_parts << expr
1075
+ params[:expression_attribute_names].merge!(names)
1076
+ params[:expression_attribute_values].merge!(values) if values.any?
1077
+ end
1078
+ end
1079
+
1080
+ # Add NOT conditions to filter expression
1081
+ if not_conditions.any?
1082
+ not_conditions.each_with_index do |(attr, val), idx|
1083
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
1084
+ filter_parts << expr
1085
+ params[:expression_attribute_names].merge!(names)
1086
+ params[:expression_attribute_values].merge!(values) if values.any?
1087
+ end
1088
+ end
1089
+
1090
+ params[:filter_expression] = filter_parts.join(' AND ') if filter_parts.any?
1091
+ params[:limit] = limit_value if limit_value
1092
+ params[:scan_index_forward] = (order_direction != :desc) unless order_direction.nil?
1093
+
1094
+ # Add projection expression if select() was used
1095
+ apply_projection_expression!(params)
1096
+
1097
+ # Auto-paginate through all DynamoDB pages (1MB limit per page)
1098
+ all_items = []
1099
+ exclusive_start_key = nil
1100
+
1101
+ loop do
1102
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1103
+ response = resolved_model.dynamodb.query(params)
1104
+ all_items.concat(response.items.map { |item| resolved_model.instantiate(item) })
1105
+ exclusive_start_key = response.last_evaluated_key
1106
+ break unless exclusive_start_key
1107
+ break if limit_value && all_items.length >= limit_value
1108
+ end
1109
+
1110
+ all_items
1111
+ end
1112
+
1113
+ def scan_with_conditions_normalized(normalized_conditions)
1114
+ filter_parts = []
1115
+ filter_values = {}
1116
+ filter_names = {}
1117
+
1118
+ normalized_conditions.each_with_index do |(attr, val), idx|
1119
+ expr, names, values = resolved_model.send(:build_condition_expression, attr, val, idx, ilike: ilike)
1120
+ filter_parts << expr
1121
+ filter_names.merge!(names)
1122
+ filter_values.merge!(values) if values.any?
1123
+ end
1124
+
1125
+ # Add NOT conditions
1126
+ not_conditions.each_with_index do |(attr, val), idx|
1127
+ expr, names, values = build_not_condition_expression(attr, val, "not#{idx}")
1128
+ filter_parts << expr
1129
+ filter_names.merge!(names)
1130
+ filter_values.merge!(values) if values.any?
1131
+ end
1132
+
1133
+ # Build projection expression if select() was used
1134
+ proj_expr, proj_names = build_projection_expression
1135
+ filter_names.merge!(proj_names) if proj_names
1136
+
1137
+ items = resolved_model.scan(
1138
+ filter_expression: filter_parts.any? ? filter_parts.join(' AND ') : nil,
1139
+ expression_attribute_names: filter_names.any? ? filter_names : nil,
1140
+ expression_attribute_values: filter_values.any? ? filter_values : nil,
1141
+ projection_expression: proj_expr,
1142
+ limit: limit_value
1143
+ )
1144
+
1145
+ items.map { |item| resolved_model.instantiate(item) }
1146
+ end
1147
+
1148
+ # Build a DynamoDB projection expression from select_attributes.
1149
+ # Always includes the primary key. Returns [expression_string, names_hash] or [nil, nil].
1150
+ def build_projection_expression
1151
+ return [nil, nil] unless select_attributes&.any?
1152
+
1153
+ pk = resolved_model.primary_key.to_s
1154
+ attrs = select_attributes.map(&:to_s)
1155
+ attrs << pk unless attrs.include?(pk) || attrs.include?('id')
1156
+
1157
+ names = {}
1158
+ placeholders = attrs.each_with_index.map do |attr, i|
1159
+ placeholder = "#proj#{i}"
1160
+ names[placeholder] = resolved_model.to_dynamo_key(attr)
1161
+ placeholder
1162
+ end
1163
+
1164
+ # Always include the raw primary key if it differs from 'id'
1165
+ unless names.values.include?(pk)
1166
+ placeholder = "#proj_pk"
1167
+ names[placeholder] = pk
1168
+ placeholders << placeholder
1169
+ end
1170
+
1171
+ [placeholders.join(', '), names]
1172
+ end
1173
+
1174
+ # Apply projection expression to a params hash (for query operations).
1175
+ # Merges projection attribute names into existing expression_attribute_names.
1176
+ def apply_projection_expression!(params)
1177
+ proj_expr, proj_names = build_projection_expression
1178
+ return unless proj_expr
1179
+
1180
+ params[:projection_expression] = proj_expr
1181
+ params[:expression_attribute_names] = (params[:expression_attribute_names] || {}).merge(proj_names)
1182
+ end
1183
+
1184
+ # Build a NOT condition expression for a single attribute
1185
+ # Supports:
1186
+ # - nil values: attribute_exists (NOT NULL)
1187
+ # - Simple values: <> (not equal)
1188
+ # - Array values: NOT IN
1189
+ #
1190
+ # @param attr [String, Symbol] Attribute name (Ruby snake_case)
1191
+ # @param val [Object] Value to negate
1192
+ # @param idx [String] Index for unique placeholder names
1193
+ # @return [Array<String, Hash, Hash>] [expression, attribute_names, attribute_values]
1194
+ def build_not_condition_expression(attr, val, idx)
1195
+ attr_str = attr.to_s
1196
+ # Convert to DynamoDB camelCase
1197
+ dynamo_attr = resolved_model.to_dynamo_key(attr_str)
1198
+
1199
+ # Handle nil - use attribute_exists (opposite of attribute_not_exists)
1200
+ if val.nil?
1201
+ return ["attribute_exists(#attr#{idx})", { "#attr#{idx}" => dynamo_attr }, {}]
1202
+ end
1203
+
1204
+ # Handle array values (NOT IN)
1205
+ if val.is_a?(Array)
1206
+ placeholders = val.map.with_index { |_, i| ":val#{idx}_#{i}" }
1207
+ values = {}
1208
+ val.each_with_index { |v, i| values[":val#{idx}_#{i}"] = v }
1209
+ return ["NOT (#attr#{idx} IN (#{placeholders.join(', ')}))", { "#attr#{idx}" => dynamo_attr }, values]
1210
+ end
1211
+
1212
+ # Simple not equal
1213
+ ["#attr#{idx} <> :val#{idx}", { "#attr#{idx}" => dynamo_attr }, { ":val#{idx}" => val }]
1214
+ end
1215
+
1216
+ # Preload associations for a collection of records
1217
+ # Mimics Rails eager loading to avoid N+1 queries
1218
+ #
1219
+ # Supports:
1220
+ # - Symbol associations: full preload (belongs_to via BatchGetItem)
1221
+ # - Hash with :count: preload only the count (has_many via SELECT COUNT on GSI)
1222
+ #
1223
+ # @example
1224
+ # includes(:customer) # belongs_to batch preload
1225
+ # includes(child_containers: :count) # has_many count preload
1226
+ # includes(:customer, items: :count) # mixed
1227
+ def preload_associations_for_records(records)
1228
+ return if records.empty? || includes_associations.empty?
1229
+
1230
+ includes_associations.each do |assoc_entry|
1231
+ case assoc_entry
1232
+ when Symbol
1233
+ preload_symbol_association(records, assoc_entry)
1234
+ when Hash
1235
+ assoc_entry.each do |assoc_name, mode|
1236
+ preload_hash_association(records, assoc_name, mode)
1237
+ end
1238
+ end
1239
+ end
1240
+ end
1241
+
1242
+ # Preload a symbol-form association (full preload)
1243
+ def preload_symbol_association(records, assoc_name)
1244
+ assoc_config = resolved_model._associations[assoc_name]
1245
+ raise ArgumentError, "Unknown association: #{assoc_name}" unless assoc_config
1246
+
1247
+ case assoc_config[:type]
1248
+ when :belongs_to
1249
+ preload_belongs_to(records, assoc_name, assoc_config)
1250
+ when :has_many
1251
+ preload_has_many_records(records, assoc_name, assoc_config)
1252
+ end
1253
+ end
1254
+
1255
+ # Preload a hash-form association (:count or :records)
1256
+ def preload_hash_association(records, assoc_name, mode)
1257
+ assoc_config = resolved_model._associations[assoc_name]
1258
+ raise ArgumentError, "Unknown association: #{assoc_name}" unless assoc_config
1259
+
1260
+ case mode
1261
+ when :count
1262
+ raise ArgumentError, "count preloading only supported for has_many (got #{assoc_config[:type]} for #{assoc_name})" unless assoc_config[:type] == :has_many
1263
+ preload_has_many_counts(records, assoc_name, assoc_config)
1264
+ when :records
1265
+ preload_symbol_association(records, assoc_name)
1266
+ else
1267
+ raise ArgumentError, "Unknown includes mode: #{mode} for #{assoc_name}. Use :count or :records"
1268
+ end
1269
+ end
1270
+
1271
+ # Preload belongs_to associations using batch_find
1272
+ def preload_belongs_to(records, assoc_name, config)
1273
+ foreign_key = config[:foreign_key]
1274
+ assoc_class = safe_constantize_model(config[:class_name])
1275
+
1276
+ # Collect all foreign key values
1277
+ foreign_ids = records.filter_map { |r| r.send(foreign_key) }.uniq
1278
+ return if foreign_ids.empty?
1279
+
1280
+ # Batch load associated records
1281
+ associated_records = assoc_class.batch_find(foreign_ids).index_by(&:id)
1282
+
1283
+ # Cache associations on each record
1284
+ records.each do |record|
1285
+ fk_value = record.send(foreign_key)
1286
+ record.instance_variable_set(:"@_association_cache_#{assoc_name}", associated_records[fk_value])
1287
+ end
1288
+ end
1289
+
1290
+ # Preload has_many counts efficiently.
1291
+ #
1292
+ # Strategy selection:
1293
+ # 1. Self-referential + full table loaded → count from loaded records (0 DB calls)
1294
+ # 2. GSI index available → parallel SELECT COUNT queries per parent (N fast indexed calls)
1295
+ # 3. Fallback → single projected scan of child table grouped in Ruby
1296
+ #
1297
+ # @param records [Array] Parent records
1298
+ # @param assoc_name [Symbol] Association name (e.g., :child_containers)
1299
+ # @param config [Hash] Association config from _associations
1300
+ def preload_has_many_counts(records, assoc_name, config)
1301
+ foreign_key = config[:foreign_key]
1302
+ assoc_class = safe_constantize_model(config[:class_name])
1303
+ local_key = config[:primary_key] || resolved_model.primary_key
1304
+ dynamo_fk = assoc_class.to_dynamo_key(foreign_key)
1305
+ index_name = config[:index]
1306
+
1307
+ parent_ids = Set.new
1308
+ records.each do |r|
1309
+ pk = r.send(local_key)
1310
+ parent_ids << pk if pk
1311
+ end
1312
+
1313
+ counts_by_parent = if assoc_class == resolved_model && conditions.empty? && not_conditions.empty? && !@_paginated
1314
+ # Self-referential with full table loaded — count in memory
1315
+ tally = Hash.new(0)
1316
+ records.each do |r|
1317
+ fk_val = r.send(foreign_key)
1318
+ tally[fk_val] += 1 if fk_val && parent_ids.include?(fk_val)
1319
+ end
1320
+ tally
1321
+ elsif index_name
1322
+ query_counts_via_index(assoc_class, index_name, dynamo_fk, parent_ids)
1323
+ else
1324
+ scan_and_count_foreign_keys(assoc_class, dynamo_fk, parent_ids)
1325
+ end
1326
+
1327
+ records.each do |record|
1328
+ pk_value = record.send(local_key)
1329
+ record._preloaded_counts[assoc_name] = counts_by_parent[pk_value] || 0
1330
+ end
1331
+ end
1332
+
1333
+ # Use GSI SELECT COUNT queries to get counts per parent ID.
1334
+ # Each query is a lightweight indexed count — no data transfer, just a number.
1335
+ # Uses thread pool for parallel execution when multiple parent IDs exist.
1336
+ #
1337
+ # @param assoc_class [Class] The associated model class
1338
+ # @param index_name [String] GSI index name
1339
+ # @param dynamo_fk [String] The DynamoDB attribute name for the foreign key
1340
+ # @param parent_ids [Set] Set of parent key values to count for
1341
+ # @return [Hash] { parent_id => count }
1342
+ def query_counts_via_index(assoc_class, index_name, dynamo_fk, parent_ids)
1343
+ return {} if parent_ids.empty?
1344
+
1345
+ client = assoc_class.dynamodb
1346
+ table = assoc_class.table_name
1347
+ counts = {}
1348
+ mutex = Mutex.new
1349
+
1350
+ # Bounded parallel GSI count queries — each is a tiny indexed operation
1351
+ # Upper bound on concurrent DynamoDB queries to avoid spawning one thread per parent_id
1352
+ max_concurrency = 10
1353
+
1354
+ parent_ids.each_slice(max_concurrency) do |batch|
1355
+ threads = batch.map do |pid|
1356
+ Thread.new do
1357
+ count = 0
1358
+ exclusive_start_key = nil
1359
+ loop do
1360
+ params = {
1361
+ table_name: table, index_name: index_name, select: 'COUNT',
1362
+ key_condition_expression: '#pk = :pk',
1363
+ expression_attribute_names: { '#pk' => dynamo_fk },
1364
+ expression_attribute_values: { ':pk' => pid }
1365
+ }
1366
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1367
+ response = client.query(params)
1368
+ count += response.count
1369
+ exclusive_start_key = response.last_evaluated_key
1370
+ break unless exclusive_start_key
1371
+ end
1372
+ mutex.synchronize { counts[pid] = count }
1373
+ end
1374
+ end
1375
+ threads.each(&:join)
1376
+ end
1377
+
1378
+ counts
1379
+ end
1380
+
1381
+ # Scan a table projecting only the foreign key attribute, then count occurrences
1382
+ # for each parent ID. Uses pagination to handle tables larger than 1MB.
1383
+ # Fallback when no GSI index is available.
1384
+ #
1385
+ # @param assoc_class [Class] The associated model class
1386
+ # @param dynamo_fk [String] The DynamoDB attribute name for the foreign key
1387
+ # @param parent_ids [Set] Set of parent key values to count for
1388
+ # @return [Hash] { parent_id => count }
1389
+ def scan_and_count_foreign_keys(assoc_class, dynamo_fk, parent_ids)
1390
+ client = assoc_class.dynamodb
1391
+ counts = Hash.new(0)
1392
+ exclusive_start_key = nil
1393
+
1394
+ loop do
1395
+ params = {
1396
+ table_name: assoc_class.table_name,
1397
+ projection_expression: '#fk',
1398
+ expression_attribute_names: { '#fk' => dynamo_fk }
1399
+ }
1400
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1401
+
1402
+ response = client.scan(params)
1403
+ response.items.each do |item|
1404
+ fk_val = item[dynamo_fk]
1405
+ counts[fk_val] += 1 if fk_val && parent_ids.include?(fk_val)
1406
+ end
1407
+
1408
+ exclusive_start_key = response.last_evaluated_key
1409
+ break unless exclusive_start_key
1410
+ end
1411
+
1412
+ counts
1413
+ end
1414
+
1415
+ # Preload has_many records (full preload, not just counts)
1416
+ # Loads all associated records in batch and caches them on each parent.
1417
+ #
1418
+ # @param records [Array] Parent records
1419
+ # @param assoc_name [Symbol] Association name
1420
+ # @param config [Hash] Association config from _associations
1421
+ def preload_has_many_records(records, assoc_name, config)
1422
+ foreign_key = config[:foreign_key]
1423
+ index_name = config[:index]
1424
+ assoc_class = safe_constantize_model(config[:class_name])
1425
+ local_key = config[:primary_key] || resolved_model.primary_key
1426
+
1427
+ dynamo_key = assoc_class.to_dynamo_key(foreign_key)
1428
+ client = assoc_class.dynamodb
1429
+
1430
+ # Load all associated records grouped by foreign key — parallel execution
1431
+ records_by_parent = Hash.new { |h, k| h[k] = [] }
1432
+ mutex = Mutex.new
1433
+ max_concurrency = 10
1434
+
1435
+ parent_ids = records.filter_map { |r| r.send(local_key) }.uniq
1436
+
1437
+ parent_ids.each_slice(max_concurrency) do |batch|
1438
+ threads = batch.map do |pk_value|
1439
+ Thread.new do
1440
+ items = if index_name
1441
+ all_items = []
1442
+ exclusive_start_key = nil
1443
+ loop do
1444
+ params = {
1445
+ table_name: assoc_class.table_name, index_name: index_name,
1446
+ key_condition_expression: '#pk = :pk',
1447
+ expression_attribute_names: { '#pk' => dynamo_key },
1448
+ expression_attribute_values: { ':pk' => pk_value }
1449
+ }
1450
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1451
+ response = client.query(params)
1452
+ all_items.concat(response.items)
1453
+ exclusive_start_key = response.last_evaluated_key
1454
+ break unless exclusive_start_key
1455
+ end
1456
+ all_items
1457
+ else
1458
+ all_items = []
1459
+ exclusive_start_key = nil
1460
+ loop do
1461
+ params = {
1462
+ table_name: assoc_class.table_name,
1463
+ filter_expression: '#fk = :fk',
1464
+ expression_attribute_names: { '#fk' => dynamo_key },
1465
+ expression_attribute_values: { ':fk' => pk_value }
1466
+ }
1467
+ params[:exclusive_start_key] = exclusive_start_key if exclusive_start_key
1468
+ response = client.scan(params)
1469
+ all_items.concat(response.items)
1470
+ exclusive_start_key = response.last_evaluated_key
1471
+ break unless exclusive_start_key
1472
+ end
1473
+ all_items
1474
+ end
1475
+
1476
+ instantiated = items.map { |item| assoc_class.instantiate(item) }
1477
+ mutex.synchronize { records_by_parent[pk_value] = instantiated }
1478
+ end
1479
+ end
1480
+ threads.each(&:join)
1481
+ end
1482
+
1483
+ # Cache on each record as a preloaded Relation
1484
+ records.each do |record|
1485
+ pk_value = record.send(local_key)
1486
+ associated = records_by_parent[pk_value] || []
1487
+ record._preloaded_associations[assoc_name] = associated
1488
+ end
1489
+ end
1490
+ end
1491
+
1492
+ # WhereChain enables the where.not(...) syntax
1493
+ # Returns a Relation with negated conditions
1494
+ #
1495
+ # @example
1496
+ # Container.where.not(parent_container_id: nil) # Has a parent
1497
+ # Container.where.not(status: 'deleted') # Not deleted
1498
+ # Container.where.not(status: ['a', 'b']) # Not in array
1499
+ #
1500
+ class WhereChain
1501
+ def initialize(relation)
1502
+ @relation = relation
1503
+ end
1504
+
1505
+ def not(**conditions)
1506
+ @relation.not(**conditions)
1507
+ end
1508
+ end
1509
+ end