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.
-
#build(attributes = {}) ⇒ ActiveItem::Base
Build a new record with the foreign key pre-set (does not save).
-
#count ⇒ 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.
-
#create(attributes = {}) ⇒ ActiveItem::Base
Build and save a new record with the foreign key pre-set.
-
#create!(attributes = {}) ⇒ ActiveItem::Base
Build and save a new record with the foreign key pre-set.
-
#delete_all ⇒ Object
Delete all matching records (no callbacks).
-
#destroy_all ⇒ Object
Destroy all matching records.
-
#each ⇒ 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) ⇒ 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.
-
#loaded? ⇒ Boolean
Whether the relation's records have been loaded from DynamoDB.
-
#method_missing(method_name, *args) ⇒ 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) ⇒ 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.
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
# File 'lib/active_item/relation.rb', line 16 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) ⇒ Object
Forward named scope calls to the model's scope registry
464 465 466 467 468 469 470 |
# File 'lib/active_item/relation.rb', line 464 def method_missing(method_name, *args, &) 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
365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 |
# File 'lib/active_item/relation.rb', line 365 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)
184 185 186 |
# File 'lib/active_item/relation.rb', line 184 def all self end |
#any? ⇒ Boolean
Check if any records exist
233 234 235 |
# File 'lib/active_item/relation.rb', line 233 def any? !empty? end |
#build(attributes = {}) ⇒ ActiveItem::Base
Build a new record with the foreign key pre-set (does not save). Usage: conversation.messages.build(role: "user", body: "hello")
386 387 388 389 |
# File 'lib/active_item/relation.rb', line 386 def build(attributes = {}) foreign_key, foreign_value = conditions.first resolved_model.new(attributes.merge(foreign_key => foreign_value)) end |
#count ⇒ 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
214 215 216 217 218 219 220 221 222 223 |
# File 'lib/active_item/relation.rb', line 214 def count(&) if block_given? || ilike || ilike_exact load_records return block_given? ? @records.count(&) : @records.length end return @records.length if @loaded execute_count_query end |
#create(attributes = {}) ⇒ ActiveItem::Base
Build and save a new record with the foreign key pre-set. Returns the record (may be invalid — check .persisted? or .errors). Usage: conversation.messages.create(role: "user", body: "hello")
396 397 398 399 400 401 |
# File 'lib/active_item/relation.rb', line 396 def create(attributes = {}) record = build(attributes) record.save reload record end |
#create!(attributes = {}) ⇒ ActiveItem::Base
Build and save a new record with the foreign key pre-set. Raises ActiveItem::RecordInvalid if validations fail. Usage: conversation.messages.create!(role: "user", body: "hello")
408 409 410 411 412 413 |
# File 'lib/active_item/relation.rb', line 408 def create!(attributes = {}) record = build(attributes) record.save! reload record end |
#delete_all ⇒ Object
Delete all matching records (no callbacks)
349 350 351 |
# File 'lib/active_item/relation.rb', line 349 def delete_all to_a.each(&:delete) end |
#destroy_all ⇒ Object
Destroy all matching records
344 345 346 |
# File 'lib/active_item/relation.rb', line 344 def destroy_all to_a.each(&:destroy) end |
#each ⇒ Object
Execute query and iterate over results
189 190 191 192 |
# File 'lib/active_item/relation.rb', line 189 def each(&) load_records @records.each(&) end |
#empty? ⇒ Boolean
Check if no records exist
238 239 240 |
# File 'lib/active_item/relation.rb', line 238 def empty? count.zero? end |
#exists?(**additional_conditions) ⇒ Boolean
Check if records exist matching optional conditions
243 244 245 246 247 248 249 |
# File 'lib/active_item/relation.rb', line 243 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.
428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 |
# File 'lib/active_item/relation.rb', line 428 def explain return { operation: :none, reason: 'empty relation' } if conditions[:_empty] normalized_conditions = normalize_conditions(conditions) effective_index = (index_name || resolved_model.send(:detect_index_for_conditions, normalized_conditions) if normalized_conditions.any?) 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)
313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 |
# File 'lib/active_item/relation.rb', line 313 def find(id = nil, &) if block_given? # Delegate to Enumerable#find when block is given (Rails behavior) to_a.find(&) 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
332 333 334 335 336 337 338 339 340 341 |
# File 'lib/active_item/relation.rb', line 332 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
195 196 197 |
# File 'lib/active_item/relation.rb', line 195 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
52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/active_item/relation.rb', line 52 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) else new_includes << assoc end end spawn(includes_associations: new_includes) end |
#inspect ⇒ Object
For debugging
450 451 452 453 454 455 456 457 458 459 460 461 |
# File 'lib/active_item/relation.rb', line 450 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)
200 201 202 |
# File 'lib/active_item/relation.rb', line 200 def last to_a.last end |
#length ⇒ Object Also known as: size
Length/size always return the total count (no block support)
226 227 228 229 |
# File 'lib/active_item/relation.rb', line 226 def length load_records @records.length end |
#limit(value) ⇒ Object
Chainable limit
101 102 103 |
# File 'lib/active_item/relation.rb', line 101 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.
269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/active_item/relation.rb', line 269 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 |
#loaded? ⇒ Boolean
Whether the relation's records have been loaded from DynamoDB.
35 36 37 |
# File 'lib/active_item/relation.rb', line 35 def loaded? @loaded end |
#none ⇒ Object
178 179 180 |
# File 'lib/active_item/relation.rb', line 178 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')
96 97 98 |
# File 'lib/active_item/relation.rb', line 96 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.
117 118 119 120 121 122 |
# File 'lib/active_item/relation.rb', line 117 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
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/active_item/relation.rb', line 162 def page(cursor = nil, per_page: Pagination::DEFAULT_PER_PAGE) per_page = per_page.to_i.clamp(1, Pagination::MAX_PER_PAGE) 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
285 286 287 288 289 290 291 292 293 |
# File 'lib/active_item/relation.rb', line 285 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)
354 355 356 357 358 |
# File 'lib/active_item/relation.rb', line 354 def reload @loaded = false @records = nil self end |
#respond_to_missing?(method_name, include_private = false) ⇒ Boolean
472 473 474 |
# File 'lib/active_item/relation.rb', line 472 def respond_to_missing?(method_name, include_private = false) (resolved_model.respond_to?(:_scopes) && resolved_model._scopes.key?(method_name)) || super end |
#select(*attrs) ⇒ 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.
139 140 141 142 143 144 145 |
# File 'lib/active_item/relation.rb', line 139 def select(*attrs, &) if block_given? super(&) else spawn(select_attributes: attrs.map(&:to_sym)) end end |
#to_a ⇒ Object Also known as: to_ary
Convert to array (triggers query execution)
252 253 254 255 |
# File 'lib/active_item/relation.rb', line 252 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
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/active_item/relation.rb', line 69 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 |