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

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.

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:



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)
  options = normalized_delegation_options(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, options)
  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: 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_declarationsArray<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”.

Returns:

See Also:



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

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

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:



135
136
137
# File 'lib/ecs_rails/dsl.rb', line 135

def components
  component_declarations.map(&:component_class)
end

#generated_component_methodsModule

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



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