Module: EcsRails::Relationships
- Included in:
- Entity
- Defined in:
- lib/ecs_rails/relationships.rb
Overview
The class-level DSL for cross-entity links: relates_to.
Implements RFC-0012, decided by ADR-0013. Extended into EcsRails::Entity,
alongside the component DSL it is built on. Surfaced by the demo, where
Authorship, MemberUser and MemberGroup were each nothing but a
belongs_to in a component, plus a component declaration and a migration.
class Post < ApplicationEntity relates_to :author, User end
class Membership < ApplicationEntity relates_to :user, User relates_to :team, Team component Role end
post.author = user # writer (delegated from the backing belongs_to) post.author # => the User (delegated) post.author_relationship # => the backing component row (the reader)
relates_to writes no relationship component file. It defines the backing
component dynamically and then declares it with component, so the whole
RFC-0004/0005/0006/0009/0010/0011 stack applies for free: registry, lazy
reader, delegation, with_component, presence, includes_components.
How the backing component is built (ADR-0013)
relates_to :author, User on Post dynamically defines
Post::AuthorRelationship, a concrete EcsRails::Component subclass, with:
- table_name = "post_authors" — #{entity.singular}_#{relation.plural},
owner-scoped so it is collision-free by construction (ADR-0013),
- belongs_to :author, class_name: "User", foreign_key: :author_id,
optional: true — the target link, optional so an unset relationship is
valid (ADR-0003).
Then it runs component Post::AuthorRelationship. Delegation surfaces the
belongs_to as post.author / post.author=; the backing component’s own
reader is post.author_relationship. There is no reader collision, because
the component is named for the relationship and the association for the
target — the rule the ADR-0006 amendment arrived at the hard way.
The reader name (RFC-0012 Open, resolved)
The reader is author_relationship, not post_author_relationship. That is
a real trap: the DSL derives the reader (and has_one name, and preload key)
from component_class.model_name.singular, and for the nested constant
Post::AuthorRelationship that is “post_author_relationship” — the namespace
leaks in and the entity name is doubled up. ADR-0013 specifies
author_relationship. So the backing class pins its own model_name to the
demodulized element (“AuthorRelationship” => “author_relationship”), which is
the single source every DSL derivation reads. Nothing else in the gem uses
the backing component’s model_name, so this is safe and total: reader,
has_one, and includes_components key all agree on author_relationship.
Querying and preloading by name (RFC-0013 / ADR-0014)
with_related / without_related / includes_related are the
relationship-name equivalents of with_component / without_component /
includes_components. They are thin sugar: each resolves the relationship
name to its backing class and FK via metadata relates_to records, then
delegates to the component verb — so Post.with_related(:author, ada)
compiles to exactly Post.with_component(Post::AuthorRelationship, author_id:
ada.id) and inherits its entity-model scoping and EXISTS correctness
(ADR-0011). The backing *Relationship class never appears in app code.
Defined Under Namespace
Classes: RelationshipMeta
Instance Method Summary collapse
-
#includes_related(*names) ⇒ ActiveRecord::Relation
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
Declares a cross-entity link named
nameattarget_class. -
#relationship_meta(name) ⇒ RelationshipMeta?
The recorded metadata for relationship
nameon this entity (RFC-0013). -
#relationship_names ⇒ Array<Symbol>
The declared relationship names for this entity, ancestry included.
-
#with_related(name, target = ANY_TARGET) ⇒ ActiveRecord::Relation
Entities whose
namerelationship points attarget(RFC-0013 / ADR-0014). -
#without_related(name) ⇒ ActiveRecord::Relation
Entities with NO backing row for
name(RFC-0013).
Instance Method Details
#includes_related(*names) ⇒ ActiveRecord::Relation
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.
259 260 261 262 263 264 265 266 |
# File 'lib/ecs_rails/relationships.rb', line 259 def (*names) preloads = names.map do |name| = ecs_resolve_relationship!(name) { :"#{.name}_relationship" => .name } end all.preload(*preloads) end |
#relates_to(name, target_class) ⇒ EcsRails::Registry::Declaration
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.
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/ecs_rails/relationships.rb', line 148 def relates_to(name, target_class) name = name.to_sym # Target first: this must fire before any name/table derivation, so that # `Class.new(ApplicationEntity).relates_to(:x, String)` raises on the bad # target rather than on the anonymous entity's blank model_name. validate_relationship_target!(name, target_class) # Then the name, with a relationship-shaped message. Left to `component`, # a re-declared relationship trips the registry's DuplicateComponent, whose # message names the CamelCase backing class ("Post::AuthorRelationship") # rather than the relationship the developer wrote (`:author`) — and it # would also warn on the doubled const_set. Catching it here keeps the # message about the thing the developer typed. detect_relationship_collision!(name) backing = build_relationship_component(name, target_class) declaration = component(backing) # RFC-0013 / ADR-0014: record the relationship metadata the `*_related` # query verbs resolve against. Recorded here, at declaration, so there is # one source of truth for the backing class + FK rather than a second # place that re-derives the naming convention (ADR-0014). On reload the # entity class body reruns on a fresh class, repopulating this from empty. (name, backing, target_class) declaration end |
#relationship_meta(name) ⇒ RelationshipMeta?
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.
185 186 187 |
# File 'lib/ecs_rails/relationships.rb', line 185 def (name) relationship_declarations[name.to_sym] end |
#relationship_names ⇒ Array<Symbol>
The declared relationship names for this entity, ancestry included.
196 197 198 |
# File 'lib/ecs_rails/relationships.rb', line 196 def relationship_names relationship_declarations.keys end |
#with_related(name, target = ANY_TARGET) ⇒ ActiveRecord::Relation
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.
220 221 222 223 224 225 226 227 |
# File 'lib/ecs_rails/relationships.rb', line 220 def (name, target = ANY_TARGET) = ecs_resolve_relationship!(name) return with_component(.backing_class) if ANY_TARGET.equal?(target) id = target.respond_to?(:id) ? target.id : target with_component(.backing_class, .foreign_key => id) end |
#without_related(name) ⇒ ActiveRecord::Relation
Entities with NO backing row for name (RFC-0013).
Sugar over Querying#without_component; inherits its NULL-safe
NOT EXISTS (ADR-0011).
241 242 243 |
# File 'lib/ecs_rails/relationships.rb', line 241 def (name) without_component(ecs_resolve_relationship!(name).backing_class) end |