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
{ 'RecentIndex' => { partition_key: '_recent_pk', sort_key: 'createdAt' } }.freeze
Instance Method Summary collapse
-
#all(limit: nil) ⇒ Object
Returns a Relation for all records (enables chaining from .all).
-
#all_records(limit: nil) ⇒ Object
Convenience method that immediately returns array (for backwards compat).
-
#batch_find(ids) ⇒ Array
Batch find multiple records by primary key using DynamoDB's BatchGetItem Much more efficient than multiple .find() calls.
-
#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).
-
#count(**conditions) ⇒ Object
Count records with optional conditions or block.
- #delete_all ⇒ Object
- #destroy_all ⇒ Object
-
#exists?(id_or_conditions = nil, **conditions) ⇒ Boolean
Rails-like exists? method that accepts attribute conditions or a single ID.
- #find(id) ⇒ Object
- #find_by(**conditions) ⇒ Object
- #first ⇒ Object
-
#includes(*associations) ⇒ Object
Returns a Relation with associations to preload (enables chaining from .includes).
- #indexes(index_definitions = nil) ⇒ Object
-
#last ⇒ Object
Most recent record.
- #none ⇒ Object
-
#recent(limit: 50) ⇒ Relation
Recent records, newest first.
-
#where(**conditions) ⇒ Object
Chainable where method with GSI support - returns a Relation for lazy evaluation.
Instance Method Details
#all(limit: nil) ⇒ Object
Returns a Relation for all records (enables chaining from .all)
207 208 209 210 211 212 213 214 215 |
# File 'lib/active_item/query_helpers.rb', line 207 def all(limit: nil) (:all) if respond_to?(:embedded?) && 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)
243 244 245 246 |
# File 'lib/active_item/query_helpers.rb', line 243 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
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 61 62 |
# File 'lib/active_item/query_helpers.rb', line 27 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.
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 136 137 |
# File 'lib/active_item/query_helpers.rb', line 78 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
263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 |
# File 'lib/active_item/query_helpers.rb', line 263 def count(**conditions, &) (:count) if respond_to?(:embedded?) && 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_all ⇒ Object
318 319 320 |
# File 'lib/active_item/query_helpers.rb', line 318 def delete_all all.delete_all end |
#destroy_all ⇒ Object
314 315 316 |
# File 'lib/active_item/query_helpers.rb', line 314 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
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 |
# File 'lib/active_item/query_helpers.rb', line 298 def exists?(id_or_conditions = nil, **conditions) (:exists?) if respond_to?(:embedded?) && # 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 15 16 |
# File 'lib/active_item/query_helpers.rb', line 9 def find(id) (:find) if respond_to?(:embedded?) && 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
139 140 141 |
# File 'lib/active_item/query_helpers.rb', line 139 def find_by(**conditions) where(**conditions).first end |
#first ⇒ Object
248 249 250 |
# File 'lib/active_item/query_helpers.rb', line 248 def first all.first end |
#includes(*associations) ⇒ Object
Returns a Relation with associations to preload (enables chaining from .includes)
218 219 220 |
# File 'lib/active_item/query_helpers.rb', line 218 def includes(*associations) Relation.new(self, includes_associations: associations.flatten) end |
#indexes(index_definitions = nil) ⇒ Object
336 337 338 339 340 341 342 |
# File 'lib/active_item/query_helpers.rb', line 336 def indexes(index_definitions = nil) if index_definitions @index_definitions = index_definitions else RECENT_INDEX.merge(@_belongs_to_indexes || {}).merge(@index_definitions || {}) end end |
#last ⇒ Object
Most recent record. Convenience wrapper around .recent.
234 235 236 |
# File 'lib/active_item/query_helpers.rb', line 234 def last recent(limit: 1).first end |
#none ⇒ Object
238 239 240 |
# File 'lib/active_item/query_helpers.rb', line 238 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.
229 230 231 |
# File 'lib/active_item/query_helpers.rb', line 229 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
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 201 202 203 204 |
# File 'lib/active_item/query_helpers.rb', line 174 def where(**conditions) (:where) if respond_to?(:embedded?) && # 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 |