Class: ActiveItem::Relation

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

Methods included from ModelLoader

#safe_constantize_model

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_nameObject (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

#conditionsObject (readonly)

Returns the value of attribute conditions.



13
14
15
# File 'lib/active_item/relation.rb', line 13

def conditions
  @conditions
end

#ilikeObject (readonly)

Returns the value of attribute ilike.



13
14
15
# File 'lib/active_item/relation.rb', line 13

def ilike
  @ilike
end

#ilike_exactObject (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_associationsObject (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_nameObject (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_valueObject (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

#modelObject (readonly)

Returns the value of attribute model.



13
14
15
# File 'lib/active_item/relation.rb', line 13

def model
  @model
end

#not_conditionsObject (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_directionObject (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

#ownerObject (readonly)

Returns the value of attribute owner.



13
14
15
# File 'lib/active_item/relation.rb', line 13

def owner
  @owner
end

#select_attributesObject (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

Parameters:

Returns:



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

#allObject

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

Returns:

  • (Boolean)


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

Examples:

Without block

Pickup.where(status: 'pending').count  # => 5

With block (Rails-like)

Pickup.all.count { |p| p.time_slot == "10-12" }  # => 3


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_allObject

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_allObject

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

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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

#explainHash

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.

Examples:

ActionLog.where(actor_id: 'user-1').explain
# => { operation: :query, table: "myapp-dev-action-logs", index: "ActorIndex", params: { ... } }

ActionLog.where(status: 'active').not(archived: true).limit(10).explain
# => { operation: :scan, table: "myapp-dev-action-logs", params: { ... } }

Returns:

  • (Hash)

    Operation details (:operation, :table, :params)



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)

Examples:

Find by ID

User.where(status: 'active').find('user-123')

Find by block

User.where(status: 'active').find { |u| u.email.include?('@example.com') }

Overloads:

  • #find(id) ⇒ Object?

    Find a record by ID within the current scope

    Parameters:

    • id (String)

      The ID to find

    Returns:

    • (Object, nil)

      The found record or nil

  • #find {|record| ... } ⇒ Object?

    Find the first record matching the block condition (like Enumerable#find/detect)

    Yields:

    • (record)

      Evaluates the block for each record

    Returns:

    • (Object, nil)

      The first record where block returns true, or nil



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

#firstObject

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

Examples:

Preload belongs_to and has_many counts

Container.includes(:customer, child_containers: :count, items: :count).all

Preload belongs_to only

InventoryItem.includes(:customer, :container).where(...)


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

#inspectObject

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

#lastObject

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

#lengthObject 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

#loadArray<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.

Examples:

container.items.load          # full InventoryItem records
container.items.load.count    # works like a normal array

Returns:



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

#noneObject



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.

Examples:

Newest first

BillingEvent.where(customer_id: id).order(:desc)

Oldest first (default DynamoDB behavior)

ItemChange.where(item_id: id).order(:asc)

Parameters:

  • direction (Symbol) (defaults to: :asc)

    :asc (default, oldest first) or :desc (newest first)

Returns:

Raises:

  • (ArgumentError)


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

Examples:

First page

result = Model.where(status: 'active').page(nil, per_page: 25)
result.items          # => [Model, Model, ...]
result.has_more?      # => true
result.next_cursor    # => "eyJpZCI6IjEyMyJ9"

Next page

result = Model.where(status: 'active').page(params[:cursor], per_page: 25)

Parameters:

  • cursor (String, nil) (defaults to: nil)

    Base64-encoded LastEvaluatedKey from previous page, or nil for first page

  • per_page (Integer) (defaults to: Pagination::DEFAULT_PER_PAGE)

    Number of items per page (default: 25, max: 100)

Returns:



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

#reloadObject

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

Returns:

  • (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.

Examples:

Column selection (DynamoDB projection)

InventoryItem.where(customer_id: id).select(:id, :name)

Enumerable filtering (block)

InventoryItem.where(customer_id: id).select { |i| i.active? }

Parameters:

  • attrs (Array<Symbol>)

    Attribute names to project

Returns:



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_aObject 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