Class: ActiveItem::Relation
- Inherits:
-
Object
- Object
- ActiveItem::Relation
- Includes:
- ModelLoader, Enumerable
- Defined in:
- lib/active_item/relation.rb
Overview
Chainable query builder that accumulates conditions and executes lazily Mimics ActiveRecord::Relation behavior
Instance Attribute Summary collapse
-
#class_name ⇒ Object
readonly
Returns the value of attribute class_name.
-
#conditions ⇒ Object
readonly
Returns the value of attribute conditions.
-
#ilike ⇒ Object
readonly
Returns the value of attribute ilike.
-
#ilike_exact ⇒ Object
readonly
Returns the value of attribute ilike_exact.
-
#includes_associations ⇒ Object
readonly
Returns the value of attribute includes_associations.
-
#index_name ⇒ Object
readonly
Returns the value of attribute index_name.
-
#limit_value ⇒ Object
readonly
Returns the value of attribute limit_value.
-
#model ⇒ Object
readonly
Returns the value of attribute model.
-
#not_conditions ⇒ Object
readonly
Returns the value of attribute not_conditions.
-
#order_direction ⇒ Object
readonly
Returns the value of attribute order_direction.
-
#owner ⇒ Object
readonly
Returns the value of attribute owner.
-
#select_attributes ⇒ Object
readonly
Returns the value of attribute select_attributes.
Instance Method Summary collapse
-
#<<(record) ⇒ ActiveItem::Base
Add a record to this association Sets the foreign key and saves the record Usage: container.items << item.
-
#all ⇒ Object
Returns self, mirroring ActiveRecord::Relation#all behavior.
-
#any? ⇒ Boolean
Check if any records exist.
-
#count(&block) ⇒ Object
Count records When called with a block, delegates to Enumerable#count (Ruby-side filtering) When called without a block, returns the count of loaded records.
-
#delete_all ⇒ Object
Delete all matching records (no callbacks).
-
#destroy_all ⇒ Object
Destroy all matching records.
-
#each(&block) ⇒ Object
Execute query and iterate over results.
-
#empty? ⇒ Boolean
Check if no records exist.
-
#exists?(**additional_conditions) ⇒ Boolean
Check if records exist matching optional conditions.
-
#explain ⇒ Hash
Returns the DynamoDB operation that would be executed, without running it.
-
#find(id = nil, &block) ⇒ Object
Find by id within the current scope, or find by block (like Enumerable#find).
-
#find_by(**additional_conditions) ⇒ Object
Find by conditions within current scope Always loads records first, then filters in memory This ensures we search through all records in the current scope.
-
#first ⇒ Object
Get first record.
-
#includes(*associations) ⇒ Object
Chainable includes - preload associations to avoid N+1 queries.
-
#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) ⇒ Relation
constructor
A new instance of Relation.
-
#inspect ⇒ Object
For debugging.
-
#last ⇒ Object
Get last record (loads all, not efficient for large sets).
-
#length ⇒ Object
(also: #size)
Length/size always return the total count (no block support).
-
#limit(value) ⇒ Object
Chainable limit.
-
#load ⇒ Array<ActiveItem::Base>
Re-fetch full records from the main table via batch_find, or return already-loaded records if the initial query returned full items.
-
#method_missing(method_name, *args, &block) ⇒ Object
Forward named scope calls to the model’s scope registry.
- #none ⇒ Object
-
#not(**negated_conditions) ⇒ Object
Chainable not - returns a WhereChain for negation or a new Relation with negated conditions Usage: Model.where.not(status: ‘deleted’) # Via WhereChain Model.where(active: true).not(archived: true) # Direct on Relation.
-
#order(direction = :asc) ⇒ Relation
Chainable order - sets DynamoDB ScanIndexForward for sort key ordering Only effective when querying a GSI with a sort key.
-
#page(cursor = nil, per_page: Pagination::DEFAULT_PER_PAGE) ⇒ Pagination::PaginatedResult
Cursor-based pagination for DynamoDB.
-
#pluck(*attrs) ⇒ Object
Pluck specific attributes.
-
#reload ⇒ Object
Reload the relation (clear cached records).
- #respond_to_missing?(method_name, include_private = false) ⇒ Boolean
-
#select(*attrs, &block) ⇒ Relation
Chainable select - adds DynamoDB projection expression to only return specified attributes.
-
#to_a ⇒ Object
(also: #to_ary)
Convert to array (triggers query execution).
-
#where(**new_conditions) ⇒ Object
Chainable where - returns a new Relation with merged conditions When called without arguments, returns a WhereChain for .not() syntax.
Methods included from ModelLoader
Constructor Details
#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) ⇒ Relation
Returns a new instance of Relation.
15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# File 'lib/active_item/relation.rb', line 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) @model = model @class_name = class_name # For lazy loading @owner = owner # The object that owns this association @conditions = conditions.dup @index_name = index_name @limit_value = limit_value @not_conditions = not_conditions.dup @ilike = ilike # Use case-insensitive contains() for string conditions @ilike_exact = ilike_exact # Require exact case-insensitive match (Ruby-side filter) @loaded = preloaded_records ? true : false @records = preloaded_records @includes_associations = includes_associations @order_direction = order_direction # :asc or :desc for DynamoDB ScanIndexForward @select_attributes = select_attributes # Projection expression attributes end |
Dynamic Method Handling
This class handles dynamic methods through the method_missing method
#method_missing(method_name, *args, &block) ⇒ Object
Forward named scope calls to the model’s scope registry
428 429 430 431 432 433 434 |
# File 'lib/active_item/relation.rb', line 428 def method_missing(method_name, *args, &block) if resolved_model.respond_to?(:_scopes) && resolved_model._scopes.key?(method_name) instance_exec(&resolved_model._scopes[method_name]) else super end end |
Instance Attribute Details
#class_name ⇒ Object (readonly)
Returns the value of attribute class_name.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def class_name @class_name end |
#conditions ⇒ Object (readonly)
Returns the value of attribute conditions.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def conditions @conditions end |
#ilike ⇒ Object (readonly)
Returns the value of attribute ilike.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def ilike @ilike end |
#ilike_exact ⇒ Object (readonly)
Returns the value of attribute ilike_exact.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def ilike_exact @ilike_exact end |
#includes_associations ⇒ Object (readonly)
Returns the value of attribute includes_associations.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def includes_associations @includes_associations end |
#index_name ⇒ Object (readonly)
Returns the value of attribute index_name.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def index_name @index_name end |
#limit_value ⇒ Object (readonly)
Returns the value of attribute limit_value.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def limit_value @limit_value end |
#model ⇒ Object (readonly)
Returns the value of attribute model.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def model @model end |
#not_conditions ⇒ Object (readonly)
Returns the value of attribute not_conditions.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def not_conditions @not_conditions end |
#order_direction ⇒ Object (readonly)
Returns the value of attribute order_direction.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def order_direction @order_direction end |
#owner ⇒ Object (readonly)
Returns the value of attribute owner.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def owner @owner end |
#select_attributes ⇒ Object (readonly)
Returns the value of attribute select_attributes.
13 14 15 |
# File 'lib/active_item/relation.rb', line 13 def select_attributes @select_attributes end |
Instance Method Details
#<<(record) ⇒ ActiveItem::Base
Add a record to this association Sets the foreign key and saves the record Usage: container.items << item
360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 |
# File 'lib/active_item/relation.rb', line 360 def <<(record) # Get the foreign key from conditions (first condition is the foreign key) foreign_key, foreign_value = conditions.first # Set the foreign key on the record record.send("#{foreign_key}=", foreign_value) # Save the record record.save! # Clear cached records so next access will reload reload # Return the record for chaining record end |
#all ⇒ Object
Returns self, mirroring ActiveRecord::Relation#all behavior. Allows chaining like: Model.includes(:assoc).all.limit(100)
179 180 181 |
# File 'lib/active_item/relation.rb', line 179 def all self end |
#any? ⇒ Boolean
Check if any records exist
228 229 230 |
# File 'lib/active_item/relation.rb', line 228 def any? !empty? end |
#count(&block) ⇒ Object
Count records When called with a block, delegates to Enumerable#count (Ruby-side filtering) When called without a block, returns the count of loaded records
209 210 211 212 213 214 215 216 217 218 |
# File 'lib/active_item/relation.rb', line 209 def count(&block) if block_given? || ilike || ilike_exact load_records return block_given? ? @records.count(&block) : @records.length end return @records.length if @loaded execute_count_query end |
#delete_all ⇒ Object
Delete all matching records (no callbacks)
344 345 346 |
# File 'lib/active_item/relation.rb', line 344 def delete_all to_a.each(&:delete) end |
#destroy_all ⇒ Object
Destroy all matching records
339 340 341 |
# File 'lib/active_item/relation.rb', line 339 def destroy_all to_a.each(&:destroy) end |
#each(&block) ⇒ Object
Execute query and iterate over results
184 185 186 187 |
# File 'lib/active_item/relation.rb', line 184 def each(&block) load_records @records.each(&block) end |
#empty? ⇒ Boolean
Check if no records exist
233 234 235 |
# File 'lib/active_item/relation.rb', line 233 def empty? count == 0 end |
#exists?(**additional_conditions) ⇒ Boolean
Check if records exist matching optional conditions
238 239 240 241 242 243 244 |
# File 'lib/active_item/relation.rb', line 238 def exists?(**additional_conditions) if additional_conditions.any? where(**additional_conditions).limit(1).any? else limit(1).any? end end |
#explain ⇒ Hash
Returns the DynamoDB operation that would be executed, without running it. Analogous to ActiveRecord’s .to_sql — shows the operation type, table, index, key conditions, filters, and limits in DynamoDB terms.
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 |
# File 'lib/active_item/relation.rb', line 390 def explain return { operation: :none, reason: 'empty relation' } if conditions[:_empty] normalized_conditions = normalize_conditions(conditions) effective_index = if normalized_conditions.any? index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions) end if normalized_conditions.empty? && not_conditions.empty? params = { table_name: resolved_model.table_name } params[:limit] = limit_value if limit_value return { operation: :scan, table: resolved_model.table_name, params: params } end if effective_index && normalized_conditions.any? params = build_explain_query_params(effective_index, normalized_conditions) { operation: :query, table: resolved_model.table_name, index: effective_index, params: params } else params = build_explain_scan_params(normalized_conditions) { operation: :scan, table: resolved_model.table_name, params: params } end end |
#find(id) ⇒ Object? #find {|record| ... } ⇒ Object?
Find by id within the current scope, or find by block (like Enumerable#find)
308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/active_item/relation.rb', line 308 def find(id = nil, &block) if block_given? # Delegate to Enumerable#find when block is given (Rails behavior) to_a.find(&block) elsif id # Use direct GetItem instead of scanning — O(1) vs O(n) record = resolved_model.find(id) preload_associations_for_records([record]) if includes_associations.any? record else raise ArgumentError, 'find requires either an ID or a block' end rescue ActiveItem::RecordNotFound nil end |
#find_by(**additional_conditions) ⇒ Object
Find by conditions within current scope Always loads records first, then filters in memory This ensures we search through all records in the current scope
327 328 329 330 331 332 333 334 335 336 |
# File 'lib/active_item/relation.rb', line 327 def find_by(**additional_conditions) load_records unless @loaded # Filter loaded records in memory @records.find do |record| additional_conditions.all? do |key, value| record.send(key) == value end end end |
#first ⇒ Object
Get first record
190 191 192 |
# File 'lib/active_item/relation.rb', line 190 def first limit(1).to_a.first end |
#includes(*associations) ⇒ Object
Chainable includes - preload associations to avoid N+1 queries
Supports three forms:
Symbol – full preload (belongs_to uses BatchGetItem, has_many loads records)
Hash :count – preload only the count (has_many only, uses SELECT COUNT on GSI)
Hash :records – same as symbol form, explicit full preload
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'lib/active_item/relation.rb', line 45 def includes(*associations) # Normalize into a flat list: symbols stay as-is, hashes get merged new_includes = includes_associations.dup associations.each do |assoc| case assoc when Symbol new_includes << assoc unless new_includes.include?(assoc) when Hash new_includes << assoc else new_includes << assoc end end spawn(includes_associations: new_includes) end |
#inspect ⇒ Object
For debugging
414 415 416 417 418 419 420 421 422 423 424 425 |
# File 'lib/active_item/relation.rb', line 414 def inspect if @loaded "#<#{self.class.name} [#{@records.map(&:inspect).join(', ')}]>" else parts = [] parts << "conditions=#{conditions.inspect}" if conditions.any? parts << "not_conditions=#{not_conditions.inspect}" if not_conditions.any? parts << "ilike=true" if ilike parts << "exact=true" if ilike_exact "#<#{self.class.name} (not loaded) #{parts.join(' ')}>" end end |
#last ⇒ Object
Get last record (loads all, not efficient for large sets)
195 196 197 |
# File 'lib/active_item/relation.rb', line 195 def last to_a.last end |
#length ⇒ Object Also known as: size
Length/size always return the total count (no block support)
221 222 223 224 |
# File 'lib/active_item/relation.rb', line 221 def length load_records @records.length end |
#limit(value) ⇒ Object
Chainable limit
96 97 98 |
# File 'lib/active_item/relation.rb', line 96 def limit(value) spawn(limit_value: value) end |
#load ⇒ Array<ActiveItem::Base>
Re-fetch full records from the main table via batch_find, or return already-loaded records if the initial query returned full items.
Detects whether the query results contain only key attributes (KEYS_ONLY GSI) or full items, and skips the re-fetch when unnecessary.
264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/active_item/relation.rb', line 264 def load records = to_a return [] if records.empty? # Check if records already have full attributes (not just keys) # A KEYS_ONLY GSI record will only have the primary key + sort key attributes sample = records.first attr_count = sample.class.attribute_names.count { |a| sample.instance_variable_get("@#{a}") != nil } # If the record has more than just the key attributes, it's already fully hydrated return records if attr_count > 2 resolved_model.batch_find(records.map(&:id)) end |
#none ⇒ Object
173 174 175 |
# File 'lib/active_item/relation.rb', line 173 def none Relation.new(resolved_model, conditions: { _empty: true }, includes_associations: includes_associations) end |
#not(**negated_conditions) ⇒ Object
Chainable not - returns a WhereChain for negation or a new Relation with negated conditions Usage:
Model.where.not(status: 'deleted') # Via WhereChain
Model.where(active: true).not(archived: true) # Direct on Relation
Supports:
- nil values: where.not(parent_id: nil) -> attribute_exists(parent_id)
- equality: where.not(status: 'deleted') -> status <> 'deleted'
- arrays: where.not(status: ['a', 'b']) -> status NOT IN ('a', 'b')
91 92 93 |
# File 'lib/active_item/relation.rb', line 91 def not(**negated_conditions) spawn(not_conditions: not_conditions.merge(negated_conditions)) end |
#order(direction = :asc) ⇒ Relation
Chainable order - sets DynamoDB ScanIndexForward for sort key ordering Only effective when querying a GSI with a sort key.
112 113 114 115 116 117 |
# File 'lib/active_item/relation.rb', line 112 def order(direction = :asc) dir = direction.to_sym raise ArgumentError, "order must be :asc or :desc, got #{direction.inspect}" unless %i[asc desc].include?(dir) spawn(order_direction: dir) end |
#page(cursor = nil, per_page: Pagination::DEFAULT_PER_PAGE) ⇒ Pagination::PaginatedResult
Cursor-based pagination for DynamoDB
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/active_item/relation.rb', line 157 def page(cursor = nil, per_page: Pagination::DEFAULT_PER_PAGE) per_page = [[per_page.to_i, 1].max, Pagination::MAX_PER_PAGE].min items, next_cursor = execute_paginated_query(cursor, per_page) if includes_associations.any? begin @_paginated = true preload_associations_for_records(items) ensure @_paginated = false end end Pagination::PaginatedResult.new(items: items, next_cursor: next_cursor, per_page: per_page) end |
#pluck(*attrs) ⇒ Object
Pluck specific attributes
280 281 282 283 284 285 286 287 288 |
# File 'lib/active_item/relation.rb', line 280 def pluck(*attrs) to_a.map do |record| if attrs.length == 1 record.send(attrs.first) else attrs.map { |attr| record.send(attr) } end end end |
#reload ⇒ Object
Reload the relation (clear cached records)
349 350 351 352 353 |
# File 'lib/active_item/relation.rb', line 349 def reload @loaded = false @records = nil self end |
#respond_to_missing?(method_name, include_private = false) ⇒ Boolean
436 437 438 |
# File 'lib/active_item/relation.rb', line 436 def respond_to_missing?(method_name, include_private = false) (resolved_model.respond_to?(:_scopes) && resolved_model._scopes.key?(method_name)) || super end |
#select(*attrs, &block) ⇒ Relation
Chainable select - adds DynamoDB projection expression to only return specified attributes. Reduces RCU consumption and data transfer for queries that only need a few fields.
When called with a block, delegates to Enumerable#select (Rails behavior). The primary key is always included automatically.
134 135 136 137 138 139 140 |
# File 'lib/active_item/relation.rb', line 134 def select(*attrs, &block) if block_given? super(&block) else spawn(select_attributes: attrs.map(&:to_sym)) end end |
#to_a ⇒ Object Also known as: to_ary
Convert to array (triggers query execution)
247 248 249 250 |
# File 'lib/active_item/relation.rb', line 247 def to_a load_records @records end |
#where(**new_conditions) ⇒ Object
Chainable where - returns a new Relation with merged conditions When called without arguments, returns a WhereChain for .not() syntax
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/active_item/relation.rb', line 64 def where(**new_conditions) # If no conditions, return WhereChain for .not() chaining return WhereChain.new(self) if new_conditions.empty? # Extract special options new_index = new_conditions.delete(:index) || new_conditions.delete(:index_name) new_ilike = new_conditions.delete(:ilike) new_exact = new_conditions.delete(:exact) spawn( conditions: conditions.merge(new_conditions), index_name: new_index || index_name, ilike: new_ilike.nil? ? ilike : new_ilike, ilike_exact: new_exact.nil? ? ilike_exact : new_exact ) end |