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