Module: EcsRails::Querying

Included in:
Entity
Defined in:
lib/ecs_rails/querying.rb

Overview

The class/relation-level query DSL: filter entities by which components they have.

Implements RFC-0010, decided by ADR-0011. Surfaced by the demo, where every list view hand-rolled a cross-component subquery whose correctness silently rode the entity’s default_scope.

Post.with_component(PublishState, state: “published”) # posts that HAVE a # matching row User.without_component(Avatar) # users with NO avatar Post.with_component(Name).with_component(Avatar) # AND — chainable

Extended into EcsRails::Entity, so these are class methods on every entity class. ActiveRecord delegates class methods to relations (via scoping), so Post.where(...).with_component(...) chains too: the method runs with all returning the current relation, which already carries the entity-model scope.

Why EXISTS, not JOIN (ADR-0011)

Each call compiles to a correlated EXISTS / NOT EXISTS subquery rather than a join: EXISTS matches an entity once (no duplicate rows), N calls are N independent AND EXISTS clauses that compose without table aliasing, and NOT EXISTS is the natural, NULL-safe form of “without” (unlike NOT IN or a LEFT JOIN ... IS NULL).

Why the entity-model scope is correct for free (ADR-0011)

A component table is blind to entity type — Name has rows for Users and Posts. The scope that keeps Post.with_component(Name) from returning Users is NOT added by this DSL. It falls out of the method running on all, which is Post’s own default-scoped relation (model = 'posts', ADR-0002). The DSL only appends the EXISTS clause. Building from unscoped or from ApplicationEntity would drop that scope and leak — so we build from all.

Instance Method Summary collapse

Instance Method Details

#with_component(component_class, **conditions) ⇒ ActiveRecord::Relation

Entities that HAVE a row for component_class (RFC-0010). With conditions, the row must also match them (hash equality, like where).

Compiles to a correlated EXISTS subquery, so an entity matches once — never duplicated as a join would. Condition values are sanitised by ActiveRecord and treated as data, never SQL (ADR-0011).

The component need not be declared on this entity: querying a component the entity never declares is a valid, always-empty query, not an error.

Examples:

Filtering by a component’s attributes

Post.with_component(PublishState, state: "published")

Chaining — each call ANDs

Post.with_component(Title).with_component(Body).order(created_at: :desc)

Parameters:

  • component_class (Class<EcsRails::Component>)

    a concrete component

  • conditions (Hash)

    optional attribute equality the row must match

Returns:

  • (ActiveRecord::Relation)

    chainable, and still scoped to this entity’s own model discriminator (ADR-0002)

Raises:

See Also:



62
63
64
# File 'lib/ecs_rails/querying.rb', line 62

def with_component(component_class, **conditions)
  all.where(ecs_component_exists_sql(component_class, conditions, negate: false))
end

#without_component(component_class) ⇒ ActiveRecord::Relation

Entities that have NO row for component_class (RFC-0010).

Compiles to NOT EXISTS, which is the NULL-safe form of “without” — unlike NOT IN or a LEFT JOIN ... IS NULL.

There is deliberately no conditions form: “without a matching row” is ambiguous (see the RFC’s Non-goals). A virtual/lazy component has no row, so it counts as absent — the intuitive reading of “without” (ADR-0009).

Examples:

User.without_component(Avatar)

Parameters:

Returns:

  • (ActiveRecord::Relation)

    chainable, entity-model scoped

Raises:

See Also:



83
84
85
# File 'lib/ecs_rails/querying.rb', line 83

def without_component(component_class)
  all.where(ecs_component_exists_sql(component_class, {}, negate: true))
end