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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +112 -0
- data/lib/active_item/associations.rb +176 -0
- data/lib/active_item/base.rb +591 -0
- data/lib/active_item/composed_of.rb +195 -0
- data/lib/active_item/configuration.rb +24 -0
- data/lib/active_item/database_helpers.rb +84 -0
- data/lib/active_item/errors.rb +28 -0
- data/lib/active_item/logging.rb +19 -0
- data/lib/active_item/model_loader.rb +23 -0
- data/lib/active_item/pagination.rb +51 -0
- data/lib/active_item/query_helpers.rb +637 -0
- data/lib/active_item/relation.rb +1509 -0
- data/lib/active_item/transaction.rb +97 -0
- data/lib/active_item/validations.rb +95 -0
- data/lib/active_item/version.rb +5 -0
- data/lib/activeitem.rb +31 -0
- metadata +134 -0
|
@@ -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
|