Module: EcsRails::DSL
- Included in:
- Entity
- Defined in:
- lib/ecs_rails/dsl.rb
Overview
The class-level DSL that composes an entity out of components.
Implements RFC-0004. Extended into Entity, so every entity class —
and every subclass of one — answers component.
class User < ApplicationEntity component Name component Email component Group, except: [:title] end
User.components # => [Name, Email, Group] User.create!.email # => the Email row, or a virtual one (RFC-0006)
Each declaration does three things: it records itself in the registry (RFC-0002), it sets up the has_one that reads the component row, and it generates the lazy reader (RFC-0006) into #generated_component_methods — the module RFC-0005’s delegated methods also land in.
Instance Method Summary collapse
-
#component(component_class, only: nil, except: nil) ⇒ EcsRails::Registry::Declaration
Declares that this entity is composed from
component_class. -
#component_declarations ⇒ Array<EcsRails::Registry::Declaration>
Every declaration this entity is composed from, parent’s before its own.
-
#components ⇒ Array<Class<EcsRails::Component>>
Every component this entity is composed from, nearest ancestor last.
-
#generated_component_methods ⇒ Module
private
The module the DSL generates methods into: RFC-0005’s delegated methods, and RFC-0006’s reader override.
Instance Method Details
#component(component_class, only: nil, except: nil) ⇒ EcsRails::Registry::Declaration
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.
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 |
# File 'lib/ecs_rails/dsl.rb', line 86 def component(component_class, only: nil, except: nil) validate_component_class!(component_class) = (only: only, except: except) validate_not_inherited!(component_class) # RFC-0005 is resolved *before* anything is registered or defined, so that # a bad `only:`/`except:` name or a DelegationConflict leaves the class in # exactly the state it was in. #delegated_method_names validates the option # names against the component's real method set (ArgumentError on a typo); # #detect_delegation_conflict! raises DelegationConflict (ADR-0004) if any # of those names is already delegated by a sibling component. delegated = delegated_method_names(component_class, ) detect_delegation_conflict!(component_class, delegated) detect_reader_collision!(component_class, delegated) # Registered first, so that the registry's own duplicate check (RFC-0002) # is what stops a doubled `component` line — before any method is defined. declaration = EcsRails.registry.register( entity_class: self, component_class: component_class, options: ) define_component_association(component_class) # Must follow the has_one: see #generated_component_methods. define_component_reader(component_class) # RFC-0005: the delegating methods, into the same module as the reader. define_component_delegation(component_class, delegated) # RFC-0009: the `<reader>?` presence predicate. Generated last so it can # see, and defer to, any delegated method that already owns its name. define_component_predicate(component_class) declaration end |
#component_declarations ⇒ Array<EcsRails::Registry::Declaration>
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”.
157 158 159 |
# File 'lib/ecs_rails/dsl.rb', line 157 def component_declarations entity_ancestry.flat_map { |klass| EcsRails.registry.components_for(klass) } end |
#components ⇒ Array<Class<EcsRails::Component>>
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.
135 136 137 |
# File 'lib/ecs_rails/dsl.rb', line 135 def components component_declarations.map(&:component_class) end |
#generated_component_methods ⇒ Module
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.
186 187 188 189 190 191 192 193 |
# File 'lib/ecs_rails/dsl.rb', line 186 def generated_component_methods @generated_component_methods ||= begin mod = const_set(:GeneratedComponentMethods, Module.new) private_constant :GeneratedComponentMethods include mod mod end end |