ecs_on_rails 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +304 -0
  3. data/README.md +365 -50
  4. data/lib/ecs_rails/catalogue/address.rb +39 -0
  5. data/lib/ecs_rails/catalogue/calendar_date.rb +27 -0
  6. data/lib/ecs_rails/catalogue/counter.rb +46 -0
  7. data/lib/ecs_rails/catalogue/discard.rb +36 -0
  8. data/lib/ecs_rails/catalogue/email.rb +35 -0
  9. data/lib/ecs_rails/catalogue/geolocation.rb +53 -0
  10. data/lib/ecs_rails/catalogue/identifier.rb +23 -0
  11. data/lib/ecs_rails/catalogue/image.rb +28 -0
  12. data/lib/ecs_rails/catalogue/link.rb +27 -0
  13. data/lib/ecs_rails/catalogue/marker.rb +38 -0
  14. data/lib/ecs_rails/catalogue/money.rb +86 -0
  15. data/lib/ecs_rails/catalogue/name.rb +37 -0
  16. data/lib/ecs_rails/catalogue/password.rb +50 -0
  17. data/lib/ecs_rails/catalogue/period.rb +57 -0
  18. data/lib/ecs_rails/catalogue/phone.rb +30 -0
  19. data/lib/ecs_rails/catalogue/position.rb +18 -0
  20. data/lib/ecs_rails/catalogue/rating.rb +25 -0
  21. data/lib/ecs_rails/catalogue/relationship.rb +159 -0
  22. data/lib/ecs_rails/catalogue/role.rb +23 -0
  23. data/lib/ecs_rails/catalogue/schema_diff.rb +198 -0
  24. data/lib/ecs_rails/catalogue/search_vector.rb +38 -0
  25. data/lib/ecs_rails/catalogue/state.rb +71 -0
  26. data/lib/ecs_rails/catalogue/tags.rb +64 -0
  27. data/lib/ecs_rails/catalogue/text.rb +29 -0
  28. data/lib/ecs_rails/catalogue/timestamp.rb +34 -0
  29. data/lib/ecs_rails/catalogue/token.rb +77 -0
  30. data/lib/ecs_rails/catalogue.rb +405 -0
  31. data/lib/ecs_rails/component.rb +13 -7
  32. data/lib/ecs_rails/config.rb +33 -11
  33. data/lib/ecs_rails/dsl.rb +463 -97
  34. data/lib/ecs_rails/entity.rb +21 -5
  35. data/lib/ecs_rails/errors.rb +3 -0
  36. data/lib/ecs_rails/inverses.rb +398 -0
  37. data/lib/ecs_rails/lazy.rb +86 -23
  38. data/lib/ecs_rails/markers.rb +197 -0
  39. data/lib/ecs_rails/preloading.rb +19 -8
  40. data/lib/ecs_rails/presence.rb +83 -30
  41. data/lib/ecs_rails/querying.rb +158 -12
  42. data/lib/ecs_rails/railtie.rb +10 -0
  43. data/lib/ecs_rails/registry.rb +66 -21
  44. data/lib/ecs_rails/relationships.rb +168 -160
  45. data/lib/ecs_rails/slots.rb +183 -0
  46. data/lib/ecs_rails/validations.rb +3 -5
  47. data/lib/ecs_rails/version.rb +2 -2
  48. data/lib/ecs_rails.rb +7 -3
  49. data/lib/generators/ecs_rails/catalogue_options.rb +66 -0
  50. data/lib/generators/ecs_rails/component/templates/migration.rb.tt +9 -5
  51. data/lib/generators/ecs_rails/component/templates/model.rb.tt +2 -1
  52. data/lib/generators/ecs_rails/entity/entity_generator.rb +93 -0
  53. data/lib/generators/ecs_rails/entity/templates/entity.rb.tt +9 -0
  54. data/lib/generators/ecs_rails/install/install_generator.rb +29 -12
  55. data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +3 -2
  56. data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +2 -2
  57. data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +2 -2
  58. data/lib/generators/ecs_rails/install/templates/install_migration.rb.tt +38 -0
  59. data/lib/generators/ecs_rails/templates/catalogue_class.rb.tt +10 -0
  60. data/lib/generators/ecs_rails/upgrade/templates/catalogue_migration.rb.tt +18 -0
  61. data/lib/generators/ecs_rails/upgrade/templates/markers_migration.rb.tt +31 -0
  62. data/lib/generators/ecs_rails/upgrade/templates/relationships_migration.rb.tt +33 -0
  63. data/lib/generators/ecs_rails/upgrade/templates/slots_migration.rb.tt +23 -0
  64. data/lib/generators/ecs_rails/upgrade/upgrade_generator.rb +281 -0
  65. metadata +61 -4
  66. data/lib/generators/ecs_rails/install/templates/migration.rb.tt +0 -20
  67. data/lib/generators/ecs_rails/relationship/relationship_generator.rb +0 -99
  68. data/lib/generators/ecs_rails/relationship/templates/migration.rb.tt +0 -32
@@ -1,105 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # #constantize is load-bearing for reload safety: relationship metadata stores
4
- # the backing and target class *names* (strings) and resolves them on read, so a
5
- # reloaded constant is picked up — the same discipline as the registry
6
- # (RFC-0002). Required explicitly rather than relying on ActiveRecord.
4
+ # the target class *name* (a string) and resolves it on read, so a reloaded
5
+ # constant is picked up — the same discipline as the registry (RFC-0002).
7
6
  require "active_support/core_ext/string/inflections"
8
7
 
9
8
  module EcsRails
10
9
  # The class-level DSL for cross-entity links: `relates_to`.
11
10
  #
12
- # Implements RFC-0012, decided by ADR-0013. Extended into EcsRails::Entity,
13
- # alongside the `component` DSL it is built on. Surfaced by the demo, where
14
- # `Authorship`, `MemberUser` and `MemberGroup` were each *nothing but* a
15
- # `belongs_to` in a component, plus a `component` declaration and a migration.
11
+ # Implements RFC-0012 / RFC-0013, decided by ADR-0013 and re-based on the
12
+ # shared `relationships` table by ADR-0017. Extended into EcsRails::Entity,
13
+ # alongside the `component` DSL it is built on.
16
14
  #
17
15
  # class Post < ApplicationEntity
18
16
  # relates_to :author, User
19
17
  # end
20
18
  #
21
- # class Membership < ApplicationEntity
22
- # relates_to :user, User
23
- # relates_to :team, Team
24
- # component Role
19
+ # class Invoice < ApplicationEntity
20
+ # relates_to :order, Order, unique: true # at most one Invoice per Order
25
21
  # end
26
22
  #
27
- # post.author = user # writer (delegated from the backing belongs_to)
28
- # post.author # => the User (delegated)
29
- # post.author_relationship # => the backing component row (the reader)
23
+ # post.author = user # writer (checked against User)
24
+ # post.author # => the User
25
+ # post.author_id # => the User's id
26
+ # post.author_relationship # => the backing Relationship row (the reader)
30
27
  #
31
- # `relates_to` writes no relationship component file. It defines the backing
32
- # component dynamically and then declares it with `component`, so the whole
33
- # RFC-0004/0005/0006/0009/0010/0011 stack applies for free: registry, lazy
34
- # reader, delegation, `with_component`, presence, `includes_components`.
28
+ # ## How a relationship is stored (ADR-0017)
35
29
  #
36
- # ## How the backing component is built (ADR-0013)
30
+ # Every relationship in the application is a row in ONE `relationships` table,
31
+ # created at install. `relates_to :author, User` is
37
32
  #
38
- # `relates_to :author, User` on `Post` dynamically defines
39
- # `Post::AuthorRelationship`, a concrete EcsRails::Component subclass, with:
40
- # - `table_name = "post_authors"` — `#{entity.singular}_#{relation.plural}`,
41
- # owner-scoped so it is collision-free by construction (ADR-0013),
42
- # - `belongs_to :author, class_name: "User", foreign_key: :author_id,
43
- # optional: true` — the target link, optional so an unset relationship is
44
- # valid (ADR-0003).
33
+ # component Relationship, prefix: :author, delegate: false,
34
+ # target_class_name: "User", unique: false
45
35
  #
46
- # Then it runs `component Post::AuthorRelationship`. Delegation surfaces the
47
- # `belongs_to` as `post.author` / `post.author=`; the backing component's own
48
- # reader is `post.author_relationship`. There is no reader collision, because
49
- # the component is named for the *relationship* and the association for the
50
- # *target* — the rule the ADR-0006 amendment arrived at the hard way.
36
+ # — a labelled component (RFC-0014) whose slot is the relationship name. The
37
+ # reader is `author_relationship`, the slot-scoped has_one every labelled
38
+ # component gets, and the lazy reader presets `slot = "author"` on a virtual.
39
+ # Nothing is defined dynamically: no backing class, no per-relationship table,
40
+ # no migration. The `Relationship` class is the host app's one-line catalogue
41
+ # component (EcsRails::Catalogue::Relationship), found by name through
42
+ # {EcsRails::Config#relationship_class_name}.
51
43
  #
52
- # ## The reader name (RFC-0012 Open, resolved)
53
- #
54
- # The reader is `author_relationship`, not `post_author_relationship`. That is
55
- # a real trap: the DSL derives the reader (and has_one name, and preload key)
56
- # from `component_class.model_name.singular`, and for the *nested* constant
57
- # `Post::AuthorRelationship` that is "post_author_relationship" — the namespace
58
- # leaks in and the entity name is doubled up. ADR-0013 specifies
59
- # `author_relationship`. So the backing class pins its own `model_name` to the
60
- # demodulized element ("AuthorRelationship" => "author_relationship"), which is
61
- # the single source every DSL derivation reads. Nothing else in the gem uses
62
- # the backing component's model_name, so this is safe and total: reader,
63
- # has_one, and `includes_components` key all agree on `author_relationship`.
44
+ # `delegate: false` because the component's own accessor is `target`; the
45
+ # DSL here defines `author` / `author=` / `author_id` / `author_id=` by hand,
46
+ # forwarding to the reader's `target`. The target type is checked on
47
+ # assignment by the component (`post.author = company` raises
48
+ # InvalidRelationship), against the `target_class_name` slot option.
64
49
  #
65
50
  # ## Querying and preloading by name (RFC-0013 / ADR-0014)
66
51
  #
67
52
  # `with_related` / `without_related` / `includes_related` are the
68
53
  # relationship-name equivalents of `with_component` / `without_component` /
69
- # `includes_components`. They are thin sugar: each resolves the relationship
70
- # name to its backing class and FK via metadata `relates_to` records, then
71
- # delegates to the component verb — so `Post.with_related(:author, ada)`
72
- # compiles to exactly `Post.with_component(Post::AuthorRelationship, author_id:
73
- # ada.id)` and inherits its entity-model scoping and EXISTS correctness
74
- # (ADR-0011). The backing `*Relationship` class never appears in app code.
54
+ # `includes_components`. Each resolves the relationship name to its slot via
55
+ # the metadata `relates_to` records, then delegates to the component verb —
56
+ # so `Post.with_related(:author, ada)` compiles to exactly
57
+ # `Post.with_component(Relationship, prefix: :author, target_id: ada.id)` and
58
+ # inherits its entity-model scoping and EXISTS correctness (ADR-0011). Under
59
+ # the shared table that scope is load-bearing, not belt-and-braces: a Comment
60
+ # and a Post both relating `:author` share the table and the slot, and only
61
+ # the owner's `model` tells them apart (ADR-0014, amended).
75
62
  module Relationships
76
63
  # One recorded relationship, held by NAME so it survives a Rails reload the
77
64
  # same way the registry's {EcsRails::Registry::Declaration} does (RFC-0002):
78
- # {#backing_class} and {#target_class} resolve via `constantize` on read, so a
79
- # metadata entry taken before a reload still resolves to the post-reload
80
- # constants.
65
+ # {#target_class} resolves via `constantize` on read.
81
66
  #
82
67
  # @!attribute [rw] name
83
68
  # @return [Symbol] the relationship name, e.g. `:author`
84
- # @!attribute [rw] backing_class_name
85
- # @return [String] the dynamically defined backing component,
86
- # e.g. `"Post::AuthorRelationship"`
87
- # @!attribute [rw] foreign_key
88
- # @return [Symbol] the FK on the backing component, e.g. `:author_id`
89
69
  # @!attribute [rw] target_class_name
90
70
  # @return [String] the entity pointed at, e.g. `"User"`
91
- RelationshipMeta = Struct.new(:name, :backing_class_name, :foreign_key, :target_class_name) do
92
- # @return [Class<EcsRails::Component>] the backing component class
93
- # @raise [NameError] if the constant no longer exists
94
- def backing_class
95
- backing_class_name.constantize
96
- end
97
-
71
+ # @!attribute [rw] unique
72
+ # @return [Boolean] whether rows are written `exclusive` (ADR-0017)
73
+ RelationshipMeta = Struct.new(:name, :target_class_name, :unique) do
98
74
  # @return [Class<EcsRails::Entity>] the entity this relationship points at
99
75
  # @raise [NameError] if the constant no longer exists
100
76
  def target_class
101
77
  target_class_name.constantize
102
78
  end
79
+
80
+ # @return [String] the slot the rows are stored under — the name
81
+ def slot
82
+ name.to_s
83
+ end
84
+
85
+ # @return [Symbol] the backing reader, `:author_relationship`
86
+ def reader_name
87
+ :"#{name}_relationship"
88
+ end
103
89
  end
104
90
 
105
91
  # Sentinel for with_related's optional target: distinguishes "no target
@@ -111,10 +97,11 @@ module EcsRails
111
97
 
112
98
  # Declares a cross-entity link named `name` at `target_class`.
113
99
  #
114
- # Writes no relationship component file: it defines the backing component
115
- # dynamically and declares it with {EcsRails::DSL#component}, so the whole
116
- # stack — registry, lazy reader, delegation, querying, presence, preloading —
117
- # applies for free.
100
+ # Pure Ruby: a row in the shared `relationships` table under slot `name`
101
+ # (ADR-0017). Declares the app's `Relationship` component into that slot with
102
+ # {EcsRails::DSL#component}, so the whole stack — registry, lazy reader,
103
+ # querying, presence, preloading — applies for free, and defines the
104
+ # `name` / `name=` / `name_id` / `name_id=` accessors on the entity.
118
105
  #
119
106
  # Subclasses inherit the declaration exactly as they inherit `component`.
120
107
  #
@@ -123,53 +110,63 @@ module EcsRails
123
110
  # relates_to :author, User
124
111
  # end
125
112
  #
126
- # post.author = user # writer, delegated from the backing belongs_to
113
+ # post.author = user # checked: must be a User
127
114
  # post.author # => #<User>
128
- # post.author_relationship # => the backing component row
115
+ # post.author_relationship # => the backing Relationship row
129
116
  #
130
- # @example Several relationships on one entity
131
- # class Membership < ApplicationEntity
132
- # relates_to :user, User
133
- # relates_to :team, Team
134
- # component Role
117
+ # @example An exclusive link — at most one Invoice per Order (ADR-0017)
118
+ # class Invoice < ApplicationEntity
119
+ # relates_to :order, Order, unique: true
135
120
  # end
136
121
  #
137
- # @param name [Symbol, String] the relationship name. Becomes the delegated
138
- # accessor (`#author`, `#author=`), the `<name>_id` foreign key, and the
122
+ # @param name [Symbol, String] the relationship name. Becomes the slot, the
123
+ # accessors (`#author`, `#author=`, `#author_id`, `#author_id=`), and the
139
124
  # `<name>_relationship` backing reader.
140
125
  # @param target_class [Class<EcsRails::Entity>] a concrete entity to point at
141
- # @return [EcsRails::Registry::Declaration] the backing component's declaration
126
+ # @param unique [Boolean] `true` writes rows as `exclusive`, so the database
127
+ # rejects a second owner of this entity type pointing at the same target
128
+ # under this name
129
+ # @return [EcsRails::Registry::Declaration] the Relationship declaration
142
130
  # @raise [EcsRails::InvalidRelationship] if `target_class` is not a concrete
143
131
  # entity — a component, an abstract entity, or a plain class
144
132
  # @raise [EcsRails::DelegationConflict] if `name` collides with an existing
145
133
  # component reader, delegated method, or another relationship
134
+ # @raise [ArgumentError] if `unique:` is not a Boolean
135
+ # @raise [NameError] if the app has no `Relationship` component (run
136
+ # `rails g ecs_rails:install`, or set {EcsRails::Config#relationship_class_name})
146
137
  # @see #with_related
147
138
  # @see #includes_related
148
- def relates_to(name, target_class)
139
+ def relates_to(name, target_class, unique: false)
149
140
  name = name.to_sym
150
141
 
151
- # Target first: this must fire before any name/table derivation, so that
142
+ # Target first: this must fire before any name derivation, so that
152
143
  # `Class.new(ApplicationEntity).relates_to(:x, String)` raises on the bad
153
144
  # target rather than on the anonymous entity's blank model_name.
154
145
  validate_relationship_target!(name, target_class)
146
+ validate_unique_flag!(name, unique)
155
147
 
156
148
  # Then the name, with a relationship-shaped message. Left to `component`,
157
149
  # a re-declared relationship trips the registry's DuplicateComponent, whose
158
- # message names the CamelCase backing class ("Post::AuthorRelationship")
159
- # rather than the relationship the developer wrote (`:author`) — and it
160
- # would also warn on the doubled const_set. Catching it here keeps the
161
- # message about the thing the developer typed.
150
+ # message names the component ("Relationship in slot \"author\"") rather
151
+ # than the relationship the developer wrote (`:author`).
162
152
  detect_relationship_collision!(name)
163
153
 
164
- backing = build_relationship_component(name, target_class)
165
- declaration = component(backing)
154
+ declaration = component(
155
+ ecs_relationship_class,
156
+ prefix: name,
157
+ delegate: false,
158
+ target_class_name: target_class.name,
159
+ unique: unique
160
+ )
161
+
162
+ define_relationship_accessors(name)
166
163
 
167
164
  # RFC-0013 / ADR-0014: record the relationship metadata the `*_related`
168
165
  # query verbs resolve against. Recorded here, at declaration, so there is
169
- # one source of truth for the backing class + FK rather than a second
170
- # place that re-derives the naming convention (ADR-0014). On reload the
171
- # entity class body reruns on a fresh class, repopulating this from empty.
172
- record_relationship_meta(name, backing, target_class)
166
+ # one source of truth for the slot and target rather than a second place
167
+ # that re-derives them. On reload the entity class body reruns on a fresh
168
+ # class, repopulating this from empty.
169
+ record_relationship_meta(name, target_class, unique)
173
170
 
174
171
  declaration
175
172
  end
@@ -199,9 +196,10 @@ module EcsRails
199
196
 
200
197
  # Entities whose `name` relationship points at `target` (RFC-0013 / ADR-0014).
201
198
  #
202
- # Sugar over {EcsRails::Querying#with_component}, so it inherits that verb's
203
- # entity-model scoping and correlated EXISTS (ADR-0011) — no cross-entity
204
- # leak. The backing `*Relationship` class never appears in app code.
199
+ # Sugar over {EcsRails::Querying#with_component} on the shared Relationship
200
+ # component, slot-scoped, so it inherits that verb's entity-model scoping and
201
+ # correlated EXISTS (ADR-0011) — no cross-entity leak even though every
202
+ # entity's relationships share one table (ADR-0017).
205
203
  #
206
204
  # @example Posts by a given author
207
205
  # Post.with_related(:author, ada)
@@ -219,17 +217,18 @@ module EcsRails
219
217
  # @see #includes_related
220
218
  def with_related(name, target = ANY_TARGET)
221
219
  meta = ecs_resolve_relationship!(name)
220
+ backing = ecs_relationship_class
222
221
 
223
- return with_component(meta.backing_class) if ANY_TARGET.equal?(target)
222
+ return with_component(backing, prefix: meta.name) if ANY_TARGET.equal?(target)
224
223
 
225
224
  id = target.respond_to?(:id) ? target.id : target
226
- with_component(meta.backing_class, meta.foreign_key => id)
225
+ with_component(backing, prefix: meta.name, target_id: id)
227
226
  end
228
227
 
229
- # Entities with NO backing row for `name` (RFC-0013).
228
+ # Entities with NO row for `name` (RFC-0013).
230
229
  #
231
- # Sugar over {EcsRails::Querying#without_component}; inherits its NULL-safe
232
- # `NOT EXISTS` (ADR-0011).
230
+ # Sugar over {EcsRails::Querying#without_component}, slot-scoped; inherits
231
+ # its NULL-safe `NOT EXISTS` (ADR-0011).
233
232
  #
234
233
  # @example Orphaned posts
235
234
  # Post.without_related(:author)
@@ -239,15 +238,18 @@ module EcsRails
239
238
  # @raise [EcsRails::InvalidRelationship] if `name` is not declared
240
239
  # @see #with_related
241
240
  def without_related(name)
242
- without_component(ecs_resolve_relationship!(name).backing_class)
241
+ meta = ecs_resolve_relationship!(name)
242
+ without_component(ecs_relationship_class, prefix: meta.name)
243
243
  end
244
244
 
245
- # Preloads each named relationship's backing component AND its target entity
246
- # — one hop — so `entity.author` costs no extra query (RFC-0013 / ADR-0014).
245
+ # Preloads each named relationship's row AND its target entity — one hop —
246
+ # so `entity.author` costs no extra query (RFC-0013 / ADR-0014).
247
247
  #
248
- # For `:author` that is `preload(author_relationship: :author)`. Does **not**
248
+ # For `:author` that is `preload(author_relationship: :target)`. Does **not**
249
249
  # preload the target's own components (ADR-0014 non-goal) — chain
250
- # {EcsRails::Preloading#includes_components} on the target for that.
250
+ # {EcsRails::Preloading#includes_components} on the target for that, or
251
+ # write the nested preload by hand: `preload(author_relationship: { target:
252
+ # :name })`.
251
253
  #
252
254
  # @example
253
255
  # Post.published.includes_related(:author).each { |p| p.author.name }
@@ -258,8 +260,7 @@ module EcsRails
258
260
  # @see #with_related
259
261
  def includes_related(*names)
260
262
  preloads = names.map do |name|
261
- meta = ecs_resolve_relationship!(name)
262
- { :"#{meta.name}_relationship" => meta.name }
263
+ { ecs_resolve_relationship!(name).reader_name => :target }
263
264
  end
264
265
 
265
266
  all.preload(*preloads)
@@ -267,15 +268,50 @@ module EcsRails
267
268
 
268
269
  private
269
270
 
271
+ # The host app's Relationship component (EcsRails::Catalogue::Relationship),
272
+ # resolved by name on every call so a reloaded constant is picked up.
273
+ def ecs_relationship_class
274
+ EcsRails.config.relationship_class_name.constantize
275
+ rescue NameError => e
276
+ raise NameError,
277
+ "EcsRails: `relates_to` needs the #{EcsRails.config.relationship_class_name} " \
278
+ "component (#{e.message}). Run `rails g ecs_rails:install`, which writes it, " \
279
+ "or set EcsRails.config.relationship_class_name to your class that includes " \
280
+ "EcsRails::Catalogue::Relationship.", e.backtrace
281
+ end
282
+
283
+ # The four accessors a relationship gives the entity, into the same module
284
+ # the readers and delegated methods live in (ADR-0004: an entity-defined
285
+ # method still wins). Each goes through the slot-scoped reader, so it reaches
286
+ # RFC-0006's memoised instance — the one the save cascade persists — and
287
+ # `Post.create!(author: user)` routes through `author=` for free.
288
+ def define_relationship_accessors(name)
289
+ reader = :"#{name}_relationship"
290
+ mod = generated_component_methods
291
+
292
+ mod.define_method(name) { public_send(reader).target }
293
+ mod.define_method(:"#{name}=") { |value| public_send(reader).target = value }
294
+ mod.define_method(:"#{name}_id") { public_send(reader).target_id }
295
+ mod.define_method(:"#{name}_id=") { |value| public_send(reader).target_id = value }
296
+ end
297
+
298
+ # The names a relationship reserves on the entity, for the DSL's conflict
299
+ # checks (EcsRails::DSL#ecs_reserved_names): a later `component Foo, prefix:
300
+ # false` delegating `author` must clash with `relates_to :author`, and a
301
+ # `relates_to :email` must clash with `component Email`'s reader. Overrides
302
+ # the DSL's empty default.
303
+ def ecs_reserved_names
304
+ relationship_names.each_with_object(super) do |name, reserved|
305
+ [name, :"#{name}=", :"#{name}_id", :"#{name}_id="].each do |method|
306
+ reserved[method] ||= "relates_to :#{name}"
307
+ end
308
+ end
309
+ end
310
+
270
311
  # Records one relationship's metadata on THIS class, by name and by class
271
- # NAMES (strings), never Class objects (reload safety — see RelationshipMeta).
272
- def record_relationship_meta(name, backing, target_class)
273
- ecs_own_relationships[name] = RelationshipMeta.new(
274
- name, # :author
275
- backing.name, # "Post::AuthorRelationship"
276
- :"#{name}_id", # :author_id
277
- target_class.name # "User"
278
- )
312
+ # NAME (a string), never a Class object (reload safety — see RelationshipMeta).
313
+ def record_relationship_meta(name, target_class, unique)
314
+ ecs_own_relationships[name] = RelationshipMeta.new(name, target_class.name, unique)
279
315
  end
280
316
 
281
317
  # This class's OWN relationships (not inherited). A per-class hash, so a fresh
@@ -311,42 +347,6 @@ module EcsRails
311
347
  "#{self.name} relates to: #{known}."
312
348
  end
313
349
 
314
- # Dynamically defines the backing component class and returns it. Named
315
- # before anything reads its name: the registry keys by name and rejects
316
- # anonymous classes (RFC-0002), and `model_name` is derived from the const —
317
- # so const_set, which both installs the nested constant and gives the class
318
- # its name, must come first.
319
- def build_relationship_component(name, target_class)
320
- const_name = :"#{name.to_s.camelize}Relationship" # :AuthorRelationship
321
- table = "#{model_name.singular}_#{name.to_s.pluralize}" # "post_authors"
322
- foreign_key = :"#{name}_id" # :author_id
323
-
324
- backing = Class.new(EcsRails::Component)
325
- const_set(const_name, backing)
326
-
327
- # table_name is set explicitly (ADR-0013): the class name and the table
328
- # name are decoupled, so `Post::AuthorRelationship` reads `post_authors`.
329
- backing.table_name = table
330
-
331
- # Pin model_name to the demodulized element, so the DSL-derived reader is
332
- # `author_relationship` and not `post_author_relationship` — see the module
333
- # comment. Closed over `const_name`; ActiveModel::Name.new(self, nil,
334
- # "AuthorRelationship") gives singular "author_relationship".
335
- element = const_name.to_s
336
- backing.define_singleton_method(:model_name) do
337
- @ecs_relationship_model_name ||= ActiveModel::Name.new(self, nil, element)
338
- end
339
-
340
- # The one sanctioned place a component names an entity class (ADR-0006):
341
- # optional, so a post with no author — or a nullified one — is valid.
342
- backing.belongs_to name,
343
- class_name: target_class.name,
344
- foreign_key: foreign_key,
345
- optional: true
346
-
347
- backing
348
- end
349
-
350
350
  # RFC-0012: the target must be a concrete entity. A component, an abstract
351
351
  # entity, or a plain class is rejected with a relationship-shaped message.
352
352
  def validate_relationship_target!(name, target_class)
@@ -364,27 +364,35 @@ module EcsRails
364
364
  "no rows; relate to a concrete entity subclass."
365
365
  end
366
366
 
367
+ def validate_unique_flag!(name, unique)
368
+ return if unique == true || unique == false
369
+
370
+ raise ArgumentError,
371
+ "relates_to :#{name} expects `unique:` to be true or false; got #{unique.inspect}"
372
+ end
373
+
367
374
  # RFC-0012: `name` must not already be a reader or a delegated method on this
368
375
  # entity — two `relates_to :author`, or `:author` clashing with a component
369
- # that already exposes `author`. Checked here, before the backing const is
370
- # created, so the message names `:author` (the relationship) rather than the
371
- # backing class, and no doubled const_set warning is printed.
376
+ # that already exposes `author`. Checked here, before `component` runs, so the
377
+ # message names `:author` (the relationship) rather than the slot.
372
378
  #
373
- # Reuses the DSL's own reader/delegation resolution (#reader_name_for,
374
- # #delegated_method_names), so "what names does this entity already answer"
375
- # is computed the one way the gem computes it everywhere else.
379
+ # Reuses the DSL's own reader/delegation resolution (Declaration#reader_name,
380
+ # #delegation_map_for — the entity-level names, prefixed per ADR-0016) plus
381
+ # the names earlier relationships reserved, so "what names does this entity
382
+ # already answer" is computed the one way the gem computes it everywhere else.
376
383
  def detect_relationship_collision!(name)
377
384
  taken = component_declarations.flat_map do |declaration|
378
- component = declaration.component_class
379
- [reader_name_for(component)] + delegated_method_names(component, declaration.options)
385
+ [declaration.reader_name] + delegation_map_for(declaration).keys
380
386
  end
387
+ taken += ecs_reserved_names.keys
381
388
 
382
- return unless taken.include?(name)
389
+ clash = [name, :"#{name}=", :"#{name}_id"].find { |method| taken.include?(method) }
390
+ return unless clash
383
391
 
384
392
  raise DelegationConflict,
385
393
  "relates_to :#{name} on #{self.name} collides with an existing " \
386
- "##{name} method — a component reader or another relationship already " \
387
- "owns that name. Choose a different relationship name."
394
+ "##{clash} method — a component reader, a delegated method, or another " \
395
+ "relationship already owns that name. Choose a different relationship name."
388
396
  end
389
397
  end
390
398
  end