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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +304 -0
- data/README.md +365 -50
- data/lib/ecs_rails/catalogue/address.rb +39 -0
- data/lib/ecs_rails/catalogue/calendar_date.rb +27 -0
- data/lib/ecs_rails/catalogue/counter.rb +46 -0
- data/lib/ecs_rails/catalogue/discard.rb +36 -0
- data/lib/ecs_rails/catalogue/email.rb +35 -0
- data/lib/ecs_rails/catalogue/geolocation.rb +53 -0
- data/lib/ecs_rails/catalogue/identifier.rb +23 -0
- data/lib/ecs_rails/catalogue/image.rb +28 -0
- data/lib/ecs_rails/catalogue/link.rb +27 -0
- data/lib/ecs_rails/catalogue/marker.rb +38 -0
- data/lib/ecs_rails/catalogue/money.rb +86 -0
- data/lib/ecs_rails/catalogue/name.rb +37 -0
- data/lib/ecs_rails/catalogue/password.rb +50 -0
- data/lib/ecs_rails/catalogue/period.rb +57 -0
- data/lib/ecs_rails/catalogue/phone.rb +30 -0
- data/lib/ecs_rails/catalogue/position.rb +18 -0
- data/lib/ecs_rails/catalogue/rating.rb +25 -0
- data/lib/ecs_rails/catalogue/relationship.rb +159 -0
- data/lib/ecs_rails/catalogue/role.rb +23 -0
- data/lib/ecs_rails/catalogue/schema_diff.rb +198 -0
- data/lib/ecs_rails/catalogue/search_vector.rb +38 -0
- data/lib/ecs_rails/catalogue/state.rb +71 -0
- data/lib/ecs_rails/catalogue/tags.rb +64 -0
- data/lib/ecs_rails/catalogue/text.rb +29 -0
- data/lib/ecs_rails/catalogue/timestamp.rb +34 -0
- data/lib/ecs_rails/catalogue/token.rb +77 -0
- data/lib/ecs_rails/catalogue.rb +405 -0
- data/lib/ecs_rails/component.rb +13 -7
- data/lib/ecs_rails/config.rb +33 -11
- data/lib/ecs_rails/dsl.rb +463 -97
- data/lib/ecs_rails/entity.rb +21 -5
- data/lib/ecs_rails/errors.rb +3 -0
- data/lib/ecs_rails/inverses.rb +398 -0
- data/lib/ecs_rails/lazy.rb +86 -23
- data/lib/ecs_rails/markers.rb +197 -0
- data/lib/ecs_rails/preloading.rb +19 -8
- data/lib/ecs_rails/presence.rb +83 -30
- data/lib/ecs_rails/querying.rb +158 -12
- data/lib/ecs_rails/railtie.rb +10 -0
- data/lib/ecs_rails/registry.rb +66 -21
- data/lib/ecs_rails/relationships.rb +168 -160
- data/lib/ecs_rails/slots.rb +183 -0
- data/lib/ecs_rails/validations.rb +3 -5
- data/lib/ecs_rails/version.rb +2 -2
- data/lib/ecs_rails.rb +7 -3
- data/lib/generators/ecs_rails/catalogue_options.rb +66 -0
- data/lib/generators/ecs_rails/component/templates/migration.rb.tt +9 -5
- data/lib/generators/ecs_rails/component/templates/model.rb.tt +2 -1
- data/lib/generators/ecs_rails/entity/entity_generator.rb +93 -0
- data/lib/generators/ecs_rails/entity/templates/entity.rb.tt +9 -0
- data/lib/generators/ecs_rails/install/install_generator.rb +29 -12
- data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +3 -2
- data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +2 -2
- data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +2 -2
- data/lib/generators/ecs_rails/install/templates/install_migration.rb.tt +38 -0
- data/lib/generators/ecs_rails/templates/catalogue_class.rb.tt +10 -0
- data/lib/generators/ecs_rails/upgrade/templates/catalogue_migration.rb.tt +18 -0
- data/lib/generators/ecs_rails/upgrade/templates/markers_migration.rb.tt +31 -0
- data/lib/generators/ecs_rails/upgrade/templates/relationships_migration.rb.tt +33 -0
- data/lib/generators/ecs_rails/upgrade/templates/slots_migration.rb.tt +23 -0
- data/lib/generators/ecs_rails/upgrade/upgrade_generator.rb +281 -0
- metadata +61 -4
- data/lib/generators/ecs_rails/install/templates/migration.rb.tt +0 -20
- data/lib/generators/ecs_rails/relationship/relationship_generator.rb +0 -99
- 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
|
|
5
|
-
#
|
|
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
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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
|
|
22
|
-
# relates_to :
|
|
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
|
|
28
|
-
# post.author
|
|
29
|
-
# post.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
39
|
-
#
|
|
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
|
-
#
|
|
47
|
-
#
|
|
48
|
-
# reader
|
|
49
|
-
#
|
|
50
|
-
#
|
|
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
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
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`.
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
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
|
-
# {#
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
#
|
|
115
|
-
#
|
|
116
|
-
# stack — registry, lazy reader,
|
|
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 #
|
|
113
|
+
# post.author = user # checked: must be a User
|
|
127
114
|
# post.author # => #<User>
|
|
128
|
-
# post.author_relationship # => the backing
|
|
115
|
+
# post.author_relationship # => the backing Relationship row
|
|
129
116
|
#
|
|
130
|
-
# @example
|
|
131
|
-
# class
|
|
132
|
-
# relates_to :
|
|
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
|
|
138
|
-
#
|
|
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
|
-
# @
|
|
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
|
|
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
|
|
159
|
-
#
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
170
|
-
#
|
|
171
|
-
#
|
|
172
|
-
record_relationship_meta(name,
|
|
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}
|
|
203
|
-
#
|
|
204
|
-
#
|
|
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.
|
|
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(
|
|
225
|
+
with_component(backing, prefix: meta.name, target_id: id)
|
|
227
226
|
end
|
|
228
227
|
|
|
229
|
-
# Entities with NO
|
|
228
|
+
# Entities with NO row for `name` (RFC-0013).
|
|
230
229
|
#
|
|
231
|
-
# Sugar over {EcsRails::Querying#without_component}; inherits
|
|
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
|
-
|
|
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
|
|
246
|
-
#
|
|
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: :
|
|
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
|
-
|
|
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
|
-
#
|
|
272
|
-
def record_relationship_meta(name,
|
|
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
|
|
370
|
-
#
|
|
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 (#
|
|
374
|
-
# #
|
|
375
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
"##{
|
|
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
|