Module: ActiveItem::QueryHelpers

Included in:
Base
Defined in:
lib/active_item/query_helpers.rb

Overview

Class-level query interface providing find, where, batch operations, counting, existence checks, and automatic GSI index detection.

Constant Summary collapse

RECENT_INDEX =

Define GSI indexes for the model This enables automatic index detection in where() queries

Examples:

class Pickup < ActiveItem::Base
  indexes(
    'CustomerIndex' => { partition_key: 'customer_id' },
    'StatusIndex' => { partition_key: 'status', sort_key: 'pickup_date' },
    'EmployeeIndex' => { partition_key: 'assigned_employee_id', sort_key: 'pickup_date' }
  )
end
{ 'RecentIndex' => { partition_key: '_recent_pk', sort_key: 'createdAt' } }.freeze

Instance Method Summary collapse

Instance Method Details

#all(limit: nil) ⇒ Object

Returns a Relation for all records (enables chaining from .all)



203
204
205
206
207
208
209
# File 'lib/active_item/query_helpers.rb', line 203

def all(limit: nil)
  if limit
    Relation.new(self, limit_value: limit)
  else
    Relation.new(self)
  end
end

#all_records(limit: nil) ⇒ Object

Convenience method that immediately returns array (for backwards compat)



237
238
239
240
# File 'lib/active_item/query_helpers.rb', line 237

def all_records(limit: nil)
  items = scan(limit: limit)
  items.map { |item| instantiate(item) }
end

#batch_find(ids) ⇒ Array

Batch find multiple records by primary key using DynamoDB's BatchGetItem Much more efficient than multiple .find() calls

Examples:

Customer.batch_find(['cust-1', 'cust-2', 'cust-3'])
# => [#<Customer id="cust-1">, #<Customer id="cust-2">, #<Customer id="cust-3">]

Parameters:

  • ids (Array)

    Array of primary key values

Returns:

  • (Array)

    Array of model instances (silently skips IDs not found)



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/active_item/query_helpers.rb', line 25

def batch_find(ids)
  return [] if ids.empty?

  results = []
  ids.each_slice(100) do |id_chunk|
    keys = id_chunk.map { |id| { primary_key.to_s => id } }
    request = { table_name => { keys: keys } }

    # Retry loop for unprocessed keys (DynamoDB may throttle under load)
    max_retries = 5
    retries = 0

    while request&.any?
      response = dynamodb.batch_get_item(request_items: request)

      items = response.responses[table_name] || []
      results.concat(items.map { |item| instantiate(item) })

      # Check for unprocessed keys and retry with exponential backoff + jitter
      unprocessed = response.unprocessed_keys
      break unless unprocessed&.any?

      retries += 1
      break if retries > max_retries

      sleep(0.05 * (2**retries) * (0.5 + (rand * 0.5)))
      request = unprocessed

    end
  end

  results
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
  raise ActiveItem::AccessDeniedError.new(model_name: name, table: table_name,
                                          operation: 'BatchGetItem', original_error: e)
end

#batch_write(records) ⇒ Array<ActiveItem::Base>

Batch write multiple records using DynamoDB's BatchWriteItem Much more efficient than individual PutItem calls (25 items per request vs 1)

WARNING: This bypasses callbacks (before_create, after_create, etc.), validations, and conditional writes (attribute_not_exists). Records are written as raw PutItem operations. Use this only when you need raw throughput and are confident the data is already valid.

Examples:

items = 30.times.map { |i| InventoryItem.new(name: "Item #{i}", ...) }
InventoryItem.batch_write(items)

Parameters:

Returns:



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/active_item/query_helpers.rb', line 76

def batch_write(records)
  return [] if records.empty?

  now = Time.now.utc.iso8601

  # Prepare each record: assign ID and timestamps
  records.each do |record|
    record.instance_variable_set(:@id, SecureRandom.uuid) unless record.id
    pk = primary_key
    record.instance_variable_set(:"@#{pk}", record.id) if pk != 'id'
    record.instance_variable_set(:@created_at, now) unless record.created_at
    record.instance_variable_set(:@updated_at, now)
  end

  failed_count = 0

  # DynamoDB BatchWriteItem limit is 25 items per request
  records.each_slice(25) do |chunk|
    write_requests = chunk.map do |record|
      { put_request: { item: record.send(:build_dynamodb_item).merge('createdAt' => record.created_at, 'updatedAt' => record.updated_at, '_recent_pk' => 'ALL') } }
    end

    request = { table_name => write_requests }
    max_retries = 8
    retries = 0

    while request&.any?
      response = dynamodb.batch_write_item(request_items: request)

      unprocessed = response.unprocessed_items
      break unless unprocessed&.any?

      retries += 1
      if retries > max_retries
        # Count items that could not be written after all retries
        unprocessed.each_value { |reqs| failed_count += reqs.size }
        break
      end

      sleep(0.05 * (2**retries) * (0.5 + (rand * 0.5)))
      request = unprocessed

    end
  end

  if failed_count.positive?
    raise ActiveItem::BatchWriteError.new(
      model_name: name,
      table: table_name,
      failed_count: failed_count,
      total_count: records.size
    )
  end

  records.each { |r| r.instance_variable_set(:@new_record, false) }
  records
rescue Aws::DynamoDB::Errors::AccessDeniedException => e
  raise ActiveItem::AccessDeniedError.new(model_name: name, table: table_name,
                                          operation: 'BatchWriteItem', original_error: e)
end

#count(**conditions) ⇒ Object

Count records with optional conditions or block

Examples:

Count all records

Customer.count  # => 42

Count with conditions

Customer.count(status: 'active')  # => 30

Count with block (Rails-like, loads all records)

Customer.count { |c| c.email.include?('@gmail.com') }  # => 15


257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/active_item/query_helpers.rb', line 257

def count(**conditions, &)
  if block_given?
    # Block provided - load all records and count with Ruby
    all.count(&)
  elsif conditions.empty?
    # No conditions, no block - use efficient DynamoDB COUNT
    response = dynamodb.scan(
      table_name: table_name,
      select: 'COUNT'
    )
    response.count
  else
    # Conditions provided - delegate to where().count
    where(**conditions).count
  end
end

#delete_allObject



308
309
310
# File 'lib/active_item/query_helpers.rb', line 308

def delete_all
  all.delete_all
end

#destroy_allObject



304
305
306
# File 'lib/active_item/query_helpers.rb', line 304

def destroy_all
  all.destroy_all
end

#exists?(id_or_conditions = nil, **conditions) ⇒ Boolean

Rails-like exists? method that accepts attribute conditions or a single ID

Examples:

Check by primary key (single ID)

Customer.exists?('cust-123')
EmailSuppression.exists?('test@example.com')

Check by primary key (hash)

Customer.exists?(id: 'cust-123')
EmailSuppression.exists?(email: 'test@example.com')

Check by any attributes

Customer.exists?(email: 'test@example.com', status: 'active')
Pickup.exists?(customer_id: 'cust-123', status: 'pending')

Parameters:

  • id_or_conditions (String, Hash) (defaults to: nil)

    Primary key value or attribute conditions

Returns:

  • (Boolean)

    true if a record exists matching the conditions



290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/active_item/query_helpers.rb', line 290

def exists?(id_or_conditions = nil, **conditions)
  # Handle single ID parameter: Customer.exists?('cust-123')
  return !!get({ primary_key.to_s => id_or_conditions }) if id_or_conditions.is_a?(String) && conditions.empty?

  # Merge positional hash with keyword arguments if both provided
  conditions = id_or_conditions.merge(conditions) if id_or_conditions.is_a?(Hash)

  # If checking by primary key only, use the efficient get operation
  return !!get({ primary_key.to_s => conditions[primary_key.to_sym] }) if conditions.keys.size == 1 && conditions.key?(primary_key.to_sym)

  # For other conditions, use where with limit 1 and count
  where(**conditions).limit(1).any?
end

#find(id) ⇒ Object



9
10
11
12
13
14
# File 'lib/active_item/query_helpers.rb', line 9

def find(id)
  record = get({ primary_key.to_s => id })
  raise ActiveItem::RecordNotFound, "Couldn't find #{name} with '#{primary_key}'=#{id}" unless record

  instantiate(record)
end

#find_by(**conditions) ⇒ Object



137
138
139
# File 'lib/active_item/query_helpers.rb', line 137

def find_by(**conditions)
  where(**conditions).first
end

#firstObject



242
243
244
# File 'lib/active_item/query_helpers.rb', line 242

def first
  all.first
end

#includes(*associations) ⇒ Object

Returns a Relation with associations to preload (enables chaining from .includes)



212
213
214
# File 'lib/active_item/query_helpers.rb', line 212

def includes(*associations)
  Relation.new(self, includes_associations: associations.flatten)
end

#indexes(index_definitions = nil) ⇒ Object



326
327
328
329
330
331
332
# File 'lib/active_item/query_helpers.rb', line 326

def indexes(index_definitions = nil)
  if index_definitions
    @index_definitions = index_definitions
  else
    RECENT_INDEX.merge(@index_definitions || {})
  end
end

#lastObject

Most recent record. Convenience wrapper around .recent.



228
229
230
# File 'lib/active_item/query_helpers.rb', line 228

def last
  recent(limit: 1).first
end

#noneObject



232
233
234
# File 'lib/active_item/query_helpers.rb', line 232

def none
  Relation.new(self).none
end

#recent(limit: 50) ⇒ Relation

Recent records, newest first. Single query against RecentIndex GSI.

Requires a RecentIndex GSI on the DynamoDB table with a fixed partition key (default: _recent_pk = "ALL") and createdAt as the sort key.

Parameters:

  • limit (Integer) (defaults to: 50)

    max records to return (default 50)

Returns:

  • (Relation)

    chainable relation sorted newest-first



223
224
225
# File 'lib/active_item/query_helpers.rb', line 223

def recent(limit: 50)
  where(_recent_pk: 'ALL', index: 'RecentIndex').order(:desc).limit(limit)
end

#where(**conditions) ⇒ Object

Chainable where method with GSI support - returns a Relation for lazy evaluation

Examples:

Simple query

Pickup.where(status: 'pending')

Chained queries (Rails-like!)

slots = AvailabilitySlot.where(employee_id: '123')
slots = slots.where(zip_code: '32780') if zip_code?
slots.each { |s| puts s.id }

With explicit index

Pickup.where(customer_id: '123', index: 'CustomerIndex')

Auto-detected index (if model defines indexes)

Pickup.where(customer_id: '123')  # Uses CustomerIndex if defined

Multiple conditions (first is partition key, rest are filters)

Pickup.where(status: 'pending', pickup_date: '2024-01-15')

Negation with where.not (Rails-like!)

Container.where.not(parent_container_id: nil)  # Has a parent
Container.where(status: 'active').not(archived: true)

Case-insensitive search (ilike option)

Container.where(name: 'box', ilike: true)              # Substring match
Container.where(name: 'box', ilike: true, exact: true) # Exact case-insensitive

Batch find by primary key (automatically uses BatchGetItem)

Customer.where(customer_id: ['cust-1', 'cust-2', 'cust-3'])
# Equivalent to: Customer.batch_find(['cust-1', 'cust-2', 'cust-3'])


172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
# File 'lib/active_item/query_helpers.rb', line 172

def where(**conditions)
  # If no conditions, return a Relation that supports .not() chaining
  return Relation.new(self) if conditions.empty?

  # Extract special options
  index_name = conditions.delete(:index) || conditions.delete(:index_name)
  ilike = conditions.delete(:ilike) || false
  exact = conditions.delete(:exact) || false

  # Optimization: Detect primary key array queries and use batch_find
  # This makes Customer.where(customer_id: [ids]) automatically efficient
  # Also supports .where(id: [ids]) since 'id' is aliased to the primary key
  if conditions.size == 1 && !index_name
    key = conditions.keys.first.to_s
    value = conditions.values.first

    # Check if querying primary key with an array
    # Accept both the actual primary key name (e.g., 'customer_id') and 'id' (the alias)
    is_primary_key_query = key == primary_key.to_s || key == 'id'

    if is_primary_key_query && value.is_a?(Array)
      # Return a Relation wrapping the batch_find results
      # This allows further chaining like .where(...).map(&:to_h)
      return Relation.new(self, preloaded_records: batch_find(value))
    end
  end

  Relation.new(self, conditions: conditions, index_name: index_name, ilike: ilike, ilike_exact: exact)
end