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.



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_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:



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

#allObject

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

Returns:

  • (Boolean)


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")

Parameters:

  • attributes (Hash) (defaults to: {})

    Attributes for the new record

Returns:



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

#countObject

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


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")

Parameters:

  • attributes (Hash) (defaults to: {})

    Attributes for the new record

Returns:



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")

Parameters:

  • attributes (Hash) (defaults to: {})

    Attributes for the new record

Returns:



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_allObject

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_allObject

Destroy all matching records



344
345
346
# File 'lib/active_item/relation.rb', line 344

def destroy_all
  to_a.each(&:destroy)
end

#eachObject

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

Returns:

  • (Boolean)


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

Returns:

  • (Boolean)


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

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



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)

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



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

#firstObject

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

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


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

#inspectObject

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

#lastObject

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

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

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



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.

Returns:

  • (Boolean)


35
36
37
# File 'lib/active_item/relation.rb', line 35

def loaded?
  @loaded
end

#noneObject



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.

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)


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

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:



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

#reloadObject

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

Returns:

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

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:



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