Class: EcsRails::Entity

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

Instance Method Summary collapse

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.

Examples:

Composition, and the reader it generates

class User < ApplicationEntity
  component Email
end

User.new.email           # => #<Email> — never nil

Resolving a delegation conflict

component Group, except: [:title]

Parameters:

  • component_class (Class<EcsRails::Component>)

    a concrete component

  • only (Array<Symbol>, Symbol, nil) (defaults to: nil)

    delegate only these methods. Attribute aware: :title covers #title and #title=. Mutually exclusive with except:.

  • except (Array<Symbol>, Symbol, nil) (defaults to: nil)

    delegate everything but these. Attribute aware, as only: is. Mutually exclusive with only:.

Returns:

Raises:

  • (EcsRails::InvalidComponent)

    if component_class is not a concrete Component subclass, or is abstract and so owns no table

  • (EcsRails::DuplicateComponent)

    if this entity, or one it inherits from, already declares this component (ADR-0005)

  • (EcsRails::DelegationConflict)

    if a delegated name is already delegated by a sibling component, or collides with a component reader (ADR-0004)

  • (ArgumentError)

    if both only: and except: are given, if either names a method the component does not delegate, or if either class is anonymous

See Also:

.component_declarationsArray<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”.

Returns:

See Also:

.componentsArray<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.

Examples:

User.components  # => [Name, Email, Group]

Returns:

  • (Array<Class<EcsRails::Component>>)

    the component classes, resolved live from the registry (so a reloaded constant is picked up)

See Also:

.generated_component_methodsModule 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.

Returns:

  • (Module)

    the per-class module holding generated readers, delegated methods and presence predicates

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

Examples:

Every declared component

Post.published.includes_components

A named subset, chained onto a component filter

Post.with_component(PublishState).includes_components(Title, Body)

Parameters:

  • component_classes (Array<Class<EcsRails::Component>>)

    components to preload; empty preloads every component the entity declares

Returns:

  • (ActiveRecord::Relation)

    chainable, entity-model scoped

Raises:

See Also:

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.

Examples:

Post.published.includes_related(:author).each { |p| p.author.name }

Parameters:

  • names (Array<Symbol, String>)

    declared relationship names

Returns:

  • (ActiveRecord::Relation)

    chainable, entity-model scoped

Raises:

See Also:

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

Examples:

Declaring and using a relationship

class Post < ApplicationEntity
  relates_to :author, User
end

post.author = user        # writer, delegated from the backing belongs_to
post.author               # => #<User>
post.author_relationship  # => the backing component row

Several relationships on one entity

class Membership < ApplicationEntity
  relates_to :user, User
  relates_to :team, Team
  component Role
end

Parameters:

  • name (Symbol, String)

    the relationship name. Becomes the delegated accessor (#author, #author=), the <name>_id foreign key, and the <name>_relationship backing reader.

  • target_class (Class<EcsRails::Entity>)

    a concrete entity to point at

Returns:

Raises:

See Also:

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

Parameters:

  • name (Symbol, String)

    the relationship name

Returns:

See Also:

.relationship_namesArray<Symbol> Originally defined in module Relationships

The declared relationship names for this entity, ancestry included.

Examples:

Membership.relationship_names  # => [:user, :team]

Returns:

  • (Array<Symbol>)

    declared relationship names

See Also:

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

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:

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.

Examples:

Posts by a given author

Post.with_related(:author, ada)
Post.with_related(:author, ada.id)   # a bare id works too

Posts that have any author set

Post.with_related(:author)

Parameters:

  • name (Symbol, String)

    a declared relationship name

  • target (EcsRails::Entity, Integer, String) (defaults to: ANY_TARGET)

    the target entity or its id. Omit to match entities that merely have the relationship set.

Returns:

  • (ActiveRecord::Relation)

    chainable, entity-model scoped

Raises:

See Also:

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

Examples:

User.without_component(Avatar)

Parameters:

Returns:

  • (ActiveRecord::Relation)

    chainable, entity-model scoped

Raises:

See Also:

Entities with NO backing row for name (RFC-0013).

Sugar over Querying#without_component; inherits its NULL-safe NOT EXISTS (ADR-0011).

Examples:

Orphaned posts

Post.without_related(:author)

Parameters:

  • name (Symbol, String)

    a declared relationship name

Returns:

  • (ActiveRecord::Relation)

    chainable, entity-model scoped

Raises:

See Also:

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

Examples:

A marker, which the save cascade would never write

user.add(Moderator)     # INSERTs the row now
user.moderator?         # => true

Validation still applies

user.add(Email)         # raises: Email#address is required

Parameters:

Returns:

Raises:

  • (EcsRails::InvalidComponent)

    if the entity does not declare it

  • (ActiveRecord::RecordInvalid)

    if the component is invalid — you cannot add an empty required component (ADR-0009)

See Also:

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

Examples:

user.has?(Moderator)  # => true
user.moderator?       # => the same question, generated per component

Parameters:

Returns:

  • (Boolean)

    whether a persisted row exists

Raises:

See Also:

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

Examples:

user.remove(Moderator)  # DELETEs the row; no-op if there was none
user.moderator          # => #<Moderator> — virtual again, not nil

Parameters:

Returns:

  • (self)

    the entity, for chaining

Raises:

See Also:

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