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

Instance Method Details

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:



259
260
261
262
263
264
265
266
# File 'lib/ecs_rails/relationships.rb', line 259

def includes_related(*names)
  preloads = names.map do |name|
    meta = ecs_resolve_relationship!(name)
    { :"#{meta.name}_relationship" => meta.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.

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:



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

Parameters:

  • name (Symbol, String)

    the relationship name

Returns:

See Also:



185
186
187
# File 'lib/ecs_rails/relationships.rb', line 185

def relationship_meta(name)
  relationship_declarations[name.to_sym]
end

#relationship_namesArray<Symbol>

The declared relationship names for this entity, ancestry included.

Examples:

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

Returns:

  • (Array<Symbol>)

    declared relationship names

See Also:



196
197
198
# File 'lib/ecs_rails/relationships.rb', line 196

def relationship_names
  relationship_declarations.keys
end

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:



220
221
222
223
224
225
226
227
# File 'lib/ecs_rails/relationships.rb', line 220

def with_related(name, target = ANY_TARGET)
  meta = ecs_resolve_relationship!(name)

  return with_component(meta.backing_class) if ANY_TARGET.equal?(target)

  id = target.respond_to?(:id) ? target.id : target
  with_component(meta.backing_class, meta.foreign_key => id)
end

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:



241
242
243
# File 'lib/ecs_rails/relationships.rb', line 241

def without_related(name)
  without_component(ecs_resolve_relationship!(name).backing_class)
end