Class: EcsRails::Entity
- Inherits:
-
ActiveRecord::Base
- Object
- ActiveRecord::Base
- EcsRails::Entity
- Extended by:
- DSL, Preloading, Querying, Relationships
- Includes:
- ImmutableIdentity, Lazy::Entity, Presence::Entity, Validations::Entity
- Defined in:
- lib/ecs_rails/entity.rb
Overview
An immutable identity row. Carries no domain state.
Implements RFC-0001. See docs/architecture.md §1 for the invariants and
docs/adr/0002-single-entities-table.md for why the model column exists.
Host apps subclass this once, as ApplicationEntity, then subclass that per entity type:
class ApplicationEntity < EcsRails::Entity self.abstract_class = true end
class User < ApplicationEntity end
User.create!.model # => “users” User.all # => SELECT * FROM entities WHERE model = ‘users’ ApplicationEntity.all # => SELECT * FROM entities (no filter)
All entity classes share the one entities table. There is no updated_at
column: an entity is written once and never changes, so ActiveRecord’s
timestamp code — which only touches timestamp columns that actually exist —
stamps created_at and nothing else. No configuration is needed for that.
Defined Under Namespace
Modules: ImmutableIdentity
Class Method Summary collapse
-
.component(component_class, only: nil, except: nil) ⇒ EcsRails::Registry::Declaration
extended
from DSL
Declares that this entity is composed from
component_class. -
.component_declarations ⇒ Array<EcsRails::Registry::Declaration>
extended
from DSL
Every declaration this entity is composed from, parent’s before its own.
-
.components ⇒ Array<Class<EcsRails::Component>>
extended
from DSL
Every component this entity is composed from, nearest ancestor last.
-
.generated_component_methods ⇒ Module
extended
from DSL
private
The module the DSL generates methods into: RFC-0005’s delegated methods, and RFC-0006’s reader override.
-
.includes_components(*component_classes) ⇒ ActiveRecord::Relation
extended
from Preloading
Preloads the given components and returns a chainable relation.
-
.includes_related(*names) ⇒ ActiveRecord::Relation
extended
from Relationships
Preloads each named relationship’s backing component AND its target entity — one hop — so
entity.authorcosts no extra query (RFC-0013 / ADR-0014). -
.relates_to(name, target_class) ⇒ EcsRails::Registry::Declaration
extended
from Relationships
Declares a cross-entity link named
nameattarget_class. -
.relationship_meta(name) ⇒ RelationshipMeta?
extended
from Relationships
The recorded metadata for relationship
nameon this entity (RFC-0013). -
.relationship_names ⇒ Array<Symbol>
extended
from Relationships
The declared relationship names for this entity, ancestry included.
-
.with_component(component_class, **conditions) ⇒ ActiveRecord::Relation
extended
from Querying
Entities that HAVE a row for
component_class(RFC-0010). -
.with_related(name, target = ANY_TARGET) ⇒ ActiveRecord::Relation
extended
from Relationships
Entities whose
namerelationship points attarget(RFC-0013 / ADR-0014). -
.without_component(component_class) ⇒ ActiveRecord::Relation
extended
from Querying
Entities that have NO row for
component_class(RFC-0010). -
.without_related(name) ⇒ ActiveRecord::Relation
extended
from Relationships
Entities with NO backing row for
name(RFC-0013).
Instance Method Summary collapse
-
#_write_attribute(attr_name, value) ⇒ Object
included
from ImmutableIdentity
The internal writer, which every generated
attr=method funnels into. -
#add(component_class) ⇒ EcsRails::Component
included
from Presence::Entity
Ensures a row exists for
component_classon this entity and returns the now-persisted instance (RFC-0009). -
#ecs_component(name) ⇒ Object
included
from Lazy::Entity
private
The instance
entity.emailhands back. -
#ecs_forget_component(name) ⇒ Object
included
from Lazy::Entity
private
Forgets a component, so the next read rebuilds it as virtual.
-
#has?(component_class) ⇒ Boolean
included
from Presence::Entity
True iff a row exists for
component_classon this entity (RFC-0009). -
#reload ⇒ Object
included
from Lazy::Entity
Drops the memo, so the next read goes back to the database.
-
#remove(component_class) ⇒ self
included
from Presence::Entity
Destroys the row for
component_classif present, and resets the reader to a virtual default instance — exactly RFC-0006’scomponent.destroysemantics. -
#write_attribute(attr_name, value) ⇒ Object
included
from ImmutableIdentity
Rejects writes to readonly attributes once the row exists.
Class Method Details
.component(component_class, only: nil, except: nil) ⇒ EcsRails::Registry::Declaration Originally defined in module DSL
Declares that this entity is composed from component_class.
Defines a reader named for the component’s model_name.singular, so
component Email gives #email.
The reader always returns an instance, never nil (architecture.md §3). If the entity has no row for the component, a virtual one is built with every attribute at its default. That is RFC-0006’s doing, layered on top of this RFC through #generated_component_methods rather than woven into it — see #define_component_reader.
only: / except: restrict which of the component’s methods are delegated
onto the entity (RFC-0005). They never affect the reader: user.group
exists whatever except: says. RFC-0004 validates and records them;
RFC-0005 acts on them.
Deliberately no dependent: option on the has_one, contradicting RFC-0004
and matching architecture.md §3 and RFC-0003: cascade is owned by the
database. Every component table has an ON DELETE CASCADE FK to
entities(id), so entity.destroy already removes the rows, without loading a
single component. Declaring dependent: :destroy as well would put two
layers on one job — the ActiveRecord one masking the database one, so that
dropping the FK would break the invariant with every test still passing.
The price is that a component’s own destroy callbacks do not run on
entity.destroy; that is a real gap, and it wants an ADR rather than a
has_one option.
Raises InvalidComponent unless component_class is a concrete
EcsRails::Component; DuplicateComponent if this entity — or any entity it
inherits from — already declares it; ArgumentError for bad options or for
an anonymous class on either side.
Returns the Registry::Declaration.
.component_declarations ⇒ Array<EcsRails::Registry::Declaration> Originally defined in module DSL
Every declaration this entity is composed from, parent’s before its own.
RFC-0004 requires subclasses to inherit their parent’s declarations. The registry is not involved in that: it holds exactly what each class itself declared, keyed by that class’s own name (RFC-0002), and the walk happens here on read. Copying declarations down into each subclass instead — which is what RFC-0004’s example test implies — would triple-count component tables in #entities_for, miss anything the parent declares after the subclass is defined, and duplicate a name-keyed store whose entire purpose is to not hold stale copies.
So EcsRails.registry.components_for(Admin) is only Admin’s own, by design, and this is the method that answers “what is an Admin made of”.
.components ⇒ Array<Class<EcsRails::Component>> Originally defined in module DSL
Every component this entity is composed from, nearest ancestor last.
Inherited components are included: a subclass is composed from its parents’ components as well as its own.
.generated_component_methods ⇒ Module Originally defined in module DSL
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
The module the DSL generates methods into: RFC-0005’s delegated methods, and RFC-0006’s reader override. Empty in RFC-0004 — it exists here because the DSL owns method generation, and because the include ordering below is subtle enough to want pinning by tests now rather than in RFC-0006.
ADR-0004 requires generated methods to live in an included module rather than on the class, so that a method defined on the entity itself wins by Ruby’s ordinary lookup, with no special-casing.
Mirrors ActiveRecord’s own #generated_association_methods, and must land after it in the ancestor chain: the most recently included module sits closest to the class, so that ordering is what lets this module override the has_one reader (the seam RFC-0006 needs) rather than be shadowed by it.
It holds for free. ActiveRecord’s inherited hook calls
initialize_generated_modules, which creates and includes
GeneratedAssociationMethods at class-definition time — long before any
component line can run. So there is nothing to force here, and no order
of DSL calls that can invert it. That is an ActiveRecord internal, so the
ordering is pinned by tests (“the generated methods module” in
spec/dsl_spec.rb) and an upgrade that changed it would fail loudly.
.includes_components(*component_classes) ⇒ ActiveRecord::Relation Originally defined in module Preloading
Preloads the given components and returns a chainable relation.
With no arguments, preloads every declared component of the entity —
walking inherited declarations (RFC-0004) via #components — which is the one
affordance the raw preload(:a, :b, :c) cannot offer.
Takes component classes, not association symbols, consistent with
with_component / add / has?. Each must be a component declared on the
entity; otherwise EcsRails::InvalidComponent naming it, rather than leaking
ActiveRecord’s AssociationNotFoundError and the has_one abstraction the gem
otherwise hides (ADR-0012).
Uses preload (separate queries), never includes/eager_load: one extra
query per component, predictable, and no surprise JOIN that changes row
identity or interacts with with_component’s EXISTS. A developer who wants a
JOIN calls eager_load on the association names directly.
Built from all — like Querying — so it chains onto any prior scope and
keeps the entity-model default scope (ADR-0002/ADR-0011).
.includes_related(*names) ⇒ ActiveRecord::Relation Originally defined in module Relationships
Preloads each named relationship’s backing component AND its target entity
— one hop — so entity.author costs no extra query (RFC-0013 / ADR-0014).
For :author that is preload(author_relationship: :author). Does not
preload the target’s own components (ADR-0014 non-goal) — chain
Preloading#includes_components on the target for that.
.relates_to(name, target_class) ⇒ EcsRails::Registry::Declaration Originally defined in module Relationships
Declares a cross-entity link named name at target_class.
Writes no relationship component file: it defines the backing component dynamically and declares it with DSL#component, so the whole stack — registry, lazy reader, delegation, querying, presence, preloading — applies for free.
Subclasses inherit the declaration exactly as they inherit component.
.relationship_meta(name) ⇒ RelationshipMeta? Originally defined in module Relationships
The recorded metadata for relationship name on this entity (RFC-0013).
Walks the entity ancestry, so a subclass sees its parents’ relationships — the same way DSL#component_declarations does.
.relationship_names ⇒ Array<Symbol> Originally defined in module Relationships
The declared relationship names for this entity, ancestry included.
.with_component(component_class, **conditions) ⇒ ActiveRecord::Relation Originally defined in module Querying
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.
.with_related(name, target = ANY_TARGET) ⇒ ActiveRecord::Relation Originally defined in module Relationships
Entities whose name relationship points at target (RFC-0013 / ADR-0014).
Sugar over Querying#with_component, so it inherits that verb’s
entity-model scoping and correlated EXISTS (ADR-0011) — no cross-entity
leak. The backing *Relationship class never appears in app code.
.without_component(component_class) ⇒ ActiveRecord::Relation Originally defined in module Querying
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).
.without_related(name) ⇒ ActiveRecord::Relation Originally defined in module Relationships
Entities with NO backing row for name (RFC-0013).
Sugar over Querying#without_component; inherits its NULL-safe
NOT EXISTS (ADR-0011).
Instance Method Details
#_write_attribute(attr_name, value) ⇒ Object Originally defined in module ImmutableIdentity
The internal writer, which every generated attr= method funnels into.
#add(component_class) ⇒ EcsRails::Component Originally defined in module Presence::Entity
Ensures a row exists for component_class on this entity and returns the
now-persisted instance (RFC-0009).
Immediate, not deferred to the next save: the whole reason add exists
is that the save cascade never persists a marker. Idempotent: an existing
row is returned untouched, never a second INSERT — the unique entity_id
index (ADR-0005) would forbid one, and correctness should not depend on
catching that. Validated: it uses save!, so add(Email) raises
RecordInvalid (address is required) while add(Moderator) always
succeeds. You cannot add an empty required component (ADR-0009).
#ecs_component(name) ⇒ Object Originally defined in module Lazy::Entity
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
The instance entity.email hands back. Called by the readers the DSL
generates; the block reaches the has_one reader underneath.
Memoised per entity instance, which RFC-0006 requires and the money path
depends on: user.email.address = "x"; user.save! only works if the
instance the caller mutated is the instance the cascade later sees.
Reading twice must not throw the first read’s assignment away.
The memo caches the row case too, not just the virtual one. It costs nothing (the association has its own cache, holding the same object) and it means the cascade can simply walk the memo, rather than interrogating ActiveRecord about which associations happen to be loaded.
#ecs_forget_component(name) ⇒ Object Originally defined in module Lazy::Entity
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Forgets a component, so the next read rebuilds it as virtual.
Public only because a component calls it on its entity, from across the object boundary — unlike #ecs_component, which the generated readers call on themselves.
Called by a component’s after_destroy (see Lazy::Component). Clearing the memo is not enough on its own: if the row was read through the reader then ActiveRecord’s association is also holding it, and #super in the generated reader would hand the frozen, destroyed object straight back. Both caches have to go.
The association is left marked loaded-with-nil rather than reset, because that is not a guess: the row was just deleted, and a component appears at most once per entity (ADR-0005), so there is provably nothing left to find. Resetting would buy an identical answer for one SELECT.
#has?(component_class) ⇒ Boolean Originally defined in module Presence::Entity
True iff a row exists for component_class on this entity (RFC-0009).
Side-effect-free, like valid? (RFC-0007): it never materialises,
persists, or dirties anything. It consults RFC-0006’s memo first — a
component dirtied-and-saved on this instance is present without a fresh
query — and otherwise issues a bare existence check (SELECT … EXISTS),
a load of nothing.
The DSL also generates a per-component predicate, so user.moderator?
asks exactly this question.
#reload ⇒ Object Originally defined in module Lazy::Entity
Drops the memo, so the next read goes back to the database.
ActiveRecord’s #reload clears the association cache; the memo is a second cache over the same rows and has to be cleared with it or reload would be a lie — the caller would get their stale virtual back.
#remove(component_class) ⇒ self Originally defined in module Presence::Entity
Destroys the row for component_class if present, and resets the reader
to a virtual default instance — exactly RFC-0006’s component.destroy
semantics. Idempotent when absent. Returns the entity (RFC-0009).
#write_attribute(attr_name, value) ⇒ Object Originally defined in module ImmutableIdentity
Rejects writes to readonly attributes once the row exists. New records are untouched, so create — including ActiveRecord assigning the database-generated id — still works.
Both writers are guarded because the public #write_attribute does not call #_write_attribute; it writes to the attribute set directly.