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
-
#with_component(component_class, **conditions) ⇒ ActiveRecord::Relation
Entities that HAVE a row for
component_class(RFC-0010). -
#without_component(component_class) ⇒ ActiveRecord::Relation
Entities that have NO row for
component_class(RFC-0010).
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.
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).
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 |