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
data/lib/ecs_rails/entity.rb
CHANGED
|
@@ -89,16 +89,18 @@ module EcsRails
|
|
|
89
89
|
# Lazy::Entity because it walks Lazy's memo (@ecs_components).
|
|
90
90
|
include Validations::Entity
|
|
91
91
|
|
|
92
|
+
# The wildcard query `referrers` (RFC-0015): every relationship row pointing
|
|
93
|
+
# at this entity, whatever its name.
|
|
94
|
+
include Inverses::Entity
|
|
95
|
+
|
|
92
96
|
# The `component` DSL (RFC-0004). Extended rather than defined here: RFC-0001
|
|
93
97
|
# is about identity, and composition is a separate concern with its own file.
|
|
94
98
|
# Singleton methods are inherited, so every entity subclass answers it.
|
|
95
99
|
extend DSL
|
|
96
100
|
|
|
97
|
-
# The `relates_to` DSL (RFC-0012):
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
# private reader/delegation resolution for its collision check. See
|
|
101
|
-
# EcsRails::Relationships.
|
|
101
|
+
# The `relates_to` DSL (RFC-0012, ADR-0017): each link is a labelled slot
|
|
102
|
+
# of the shared Relationship component. Extended after DSL because it uses
|
|
103
|
+
# `component` and its reader/delegation collision checks.
|
|
102
104
|
extend Relationships
|
|
103
105
|
|
|
104
106
|
# The component query DSL (RFC-0010): with_component / without_component.
|
|
@@ -117,6 +119,20 @@ module EcsRails
|
|
|
117
119
|
# yields virtual instances — see docs/adr/0012-component-preloading.md.
|
|
118
120
|
extend Preloading
|
|
119
121
|
|
|
122
|
+
# The `marker` DSL (ADR-0018 §4 / RFC-0016): markers as slots of one Marker
|
|
123
|
+
# component. Extended last: it is built on `component` (DSL), reserves names
|
|
124
|
+
# through the hook Relationships also extends, and its query sugar rides
|
|
125
|
+
# Querying. See EcsRails::Markers.
|
|
126
|
+
extend Markers
|
|
127
|
+
|
|
128
|
+
# The parent side of a relationship (RFC-0015): `has_many :comments, Comment,
|
|
129
|
+
# via: :post` and `has_one`, expanding to native has_many :through over the
|
|
130
|
+
# shared relationships table. Extended last so its `has_many`/`has_one`
|
|
131
|
+
# overrides sit in front of ActiveRecord's — which they fall through to
|
|
132
|
+
# without `via:`, as the DSL's own slot-scoped has_one does — and so its
|
|
133
|
+
# reserved names extend Relationships' and Markers'. See EcsRails::Inverses.
|
|
134
|
+
extend Inverses
|
|
135
|
+
|
|
120
136
|
# Immutable identity (architecture.md §1). Beyond the guard above, this is
|
|
121
137
|
# what excludes id and model from any UPDATE statement.
|
|
122
138
|
attr_readonly :id, :model
|
data/lib/ecs_rails/errors.rb
CHANGED
|
@@ -4,6 +4,9 @@ module EcsRails
|
|
|
4
4
|
# Base class for every error the gem raises.
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
+
# Raised when catalogue upgrade finds structure needing an explicit repair.
|
|
8
|
+
class SchemaMismatch < Error; end
|
|
9
|
+
|
|
7
10
|
# Raised when `component` is given something that is not a EcsRails::Component.
|
|
8
11
|
# See RFC-0004.
|
|
9
12
|
class InvalidComponent < Error; end
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
# The parent side of a relationship: `has_many` / `has_one` over the shared
|
|
5
|
+
# `relationships` table (RFC-0015, on ADR-0017's storage).
|
|
6
|
+
#
|
|
7
|
+
# class Comment < ApplicationEntity
|
|
8
|
+
# relates_to :post, Post # the child owns the link
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# class Post < ApplicationEntity
|
|
12
|
+
# has_many :comments, via: :post # the parent reads it back; child inferred from the name
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# post.comments # a real ActiveRecord CollectionProxy of Comments
|
|
16
|
+
# post.comments << comment # writes the relationships row
|
|
17
|
+
# post.comments.create!(...) # a Comment whose :post is this post
|
|
18
|
+
# Post.includes(:comments) # preloads, no N+1
|
|
19
|
+
#
|
|
20
|
+
# class Invoice < ApplicationEntity
|
|
21
|
+
# relates_to :order, Order, unique: true
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# class Order < ApplicationEntity
|
|
25
|
+
# has_one :invoice, via: :order # requires unique: true on the child
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# ## The expansion
|
|
29
|
+
#
|
|
30
|
+
# Every `relates_to` is a row in `relationships` — `entity_id` the child,
|
|
31
|
+
# `slot` the relationship name, `target_id` the parent, `owner_model` the
|
|
32
|
+
# child's discriminator (ADR-0017). The parent's primary key *is* the
|
|
33
|
+
# `entities.id` that `target_id` references, so the parent registers native
|
|
34
|
+
# ActiveRecord associations over it. `has_many :comments, via: :post` expands to
|
|
35
|
+
#
|
|
36
|
+
# has_many :comments_post_links,
|
|
37
|
+
# -> { where(slot: "post", owner_model: "comments") },
|
|
38
|
+
# class_name: "Relationship", foreign_key: :target_id, inverse_of: :target
|
|
39
|
+
# has_many :comments, through: :comments_post_links, source: :entity, class_name: "Comment"
|
|
40
|
+
#
|
|
41
|
+
# One shape for every inverse, and everything after it is Rails: the reader is
|
|
42
|
+
# a real `CollectionProxy`, preloading is `includes(:comments)`, and
|
|
43
|
+
# ADR-0008's read-time resolution returns `Comment`s (spiked before this
|
|
44
|
+
# module was written). `owner_model` on the link scope is load-bearing —
|
|
45
|
+
# without it a `Review` relating `:post` would be collected too — and
|
|
46
|
+
# `class_name` on the through adds the entity-model scope as a second guard.
|
|
47
|
+
# `has_one` is the same with a has_one link (`invoice_order_link`), because
|
|
48
|
+
# ActiveRecord's has_one :through cannot pass through a collection.
|
|
49
|
+
#
|
|
50
|
+
# ## The child is named, not referenced — and validated on first use
|
|
51
|
+
#
|
|
52
|
+
# The child may be given as a class (`Comment`), a string (`"Comment"`), or
|
|
53
|
+
# left to be inferred from the reader (`:comments` → `"Comment"`), exactly as
|
|
54
|
+
# ActiveRecord's `class_name:` works. It is held as a NAME and resolved when
|
|
55
|
+
# the association is used, for the reason Rails does the same: the parent
|
|
56
|
+
# names the child and the child's `relates_to` names the parent, and under
|
|
57
|
+
# autoloading a constant reference from either side loads the other *in the
|
|
58
|
+
# middle of its own class body*. Referencing `Membership` from `Group`'s body
|
|
59
|
+
# autoloads `membership.rb`, whose `relates_to :group, Group` re-enters a
|
|
60
|
+
# half-defined `Group` that then inspects a half-defined `Membership`. The
|
|
61
|
+
# forum hit exactly this. Names break the cycle.
|
|
62
|
+
#
|
|
63
|
+
# So the checks — the child is a concrete entity, it declares `via` towards
|
|
64
|
+
# this entity, and for `has_one` that relationship is `unique: true` — run
|
|
65
|
+
# **on the first read** of the association (once per class), and **at boot
|
|
66
|
+
# when `config.eager_load` is on** (the Railtie), so production fails to
|
|
67
|
+
# start rather than at the first request. Call {#validate_inverses!} to run
|
|
68
|
+
# them whenever you like. This is RFC-0015's declaration-time raise, moved to
|
|
69
|
+
# the earliest moment that is safe under autoloading.
|
|
70
|
+
#
|
|
71
|
+
# ## What you get, and what you don't (honestly)
|
|
72
|
+
#
|
|
73
|
+
# - `<<` and `create!` write the link row immediately, with `slot`,
|
|
74
|
+
# `owner_model` and `exclusive` preset by the link scope and the
|
|
75
|
+
# Relationship's own before_save. **`build` writes it when the parent
|
|
76
|
+
# saves**, as with any `has_many :through`; a built child that saves itself
|
|
77
|
+
# first is not linked. For `has_one`, assignment and `create_x!` link
|
|
78
|
+
# immediately (the latter flushes the join record Rails would otherwise
|
|
79
|
+
# leave for the owner's save); `build_x` links on the owner's save.
|
|
80
|
+
# - `dependent:` applies to the **link rows** only (`:destroy` or
|
|
81
|
+
# `:delete_all`): destroying the parent then removes its pointers rather
|
|
82
|
+
# than leaving them nullified by the foreign key. Destroying the child
|
|
83
|
+
# *entities* stays an explicit domain choice.
|
|
84
|
+
# - No counter cache (`counter_cache` is belongs_to-only). A cached count is
|
|
85
|
+
# a `Counter` slot plus callbacks.
|
|
86
|
+
#
|
|
87
|
+
# Both macros keep their ActiveRecord meaning when called without `via:` — the
|
|
88
|
+
# DSL's own slot-scoped `has_one` for component readers goes through here
|
|
89
|
+
# untouched.
|
|
90
|
+
module Inverses
|
|
91
|
+
# One recorded inverse, by class NAME (reload-safe, like the registry).
|
|
92
|
+
#
|
|
93
|
+
# @!attribute [rw] name
|
|
94
|
+
# @return [Symbol] the reader, e.g. `:comments`
|
|
95
|
+
# @!attribute [rw] macro
|
|
96
|
+
# @return [Symbol] `:has_many` or `:has_one`
|
|
97
|
+
# @!attribute [rw] child_class_name
|
|
98
|
+
# @return [String] the entity that owns the link, e.g. `"Comment"`
|
|
99
|
+
# @!attribute [rw] via
|
|
100
|
+
# @return [Symbol] the child's relationship name, e.g. `:post`
|
|
101
|
+
InverseMeta = Struct.new(:name, :macro, :child_class_name, :via) do
|
|
102
|
+
# @return [Class<EcsRails::Entity>]
|
|
103
|
+
# @raise [NameError] if no such constant
|
|
104
|
+
def child_class
|
|
105
|
+
child_class_name.constantize
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Symbol] the link association's name — `:comments_post_links`
|
|
109
|
+
# for a has_many, `:invoice_order_link` for a has_one
|
|
110
|
+
def link_name
|
|
111
|
+
macro == :has_many ? :"#{name}_#{via}_links" : :"#{name}_#{via}_link"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Instance-side helpers, included into EcsRails::Entity.
|
|
116
|
+
module Entity
|
|
117
|
+
# Every relationship row pointing at this entity, under any name — the
|
|
118
|
+
# Flecs `(*, target)` wildcard the shared table makes expressible
|
|
119
|
+
# (RFC-0015). A query, not an association: what a safe-destroy check or an
|
|
120
|
+
# orphan audit wants.
|
|
121
|
+
#
|
|
122
|
+
# @example
|
|
123
|
+
# user.referrers.pluck(:owner_model, :slot) # => [["posts", "author"], ...]
|
|
124
|
+
# user.referrers(slot: :author).count
|
|
125
|
+
#
|
|
126
|
+
# @param slot [Symbol, String, nil] restrict to one relationship name
|
|
127
|
+
# @return [ActiveRecord::Relation] Relationship rows
|
|
128
|
+
def referrers(slot: nil)
|
|
129
|
+
scope = EcsRails.config.relationship_class_name.constantize.where(target_id: id)
|
|
130
|
+
slot.nil? ? scope : scope.where(slot: slot.to_s)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Declares a collection of the entities whose `via` relationship points at
|
|
135
|
+
# this one — or, without `via:`, ActiveRecord's own `has_many`.
|
|
136
|
+
#
|
|
137
|
+
# @example
|
|
138
|
+
# has_many :comments, via: :post # child inferred: Comment
|
|
139
|
+
# has_many :replies, "Comment", via: :parent # child named
|
|
140
|
+
# has_many :comments, via: :post, dependent: :destroy # destroy the link rows too
|
|
141
|
+
#
|
|
142
|
+
# @param name [Symbol] the reader
|
|
143
|
+
# @param child [Class<EcsRails::Entity>, String, Proc, nil] the child entity,
|
|
144
|
+
# as a class or class name; inferred from `name` when omitted. Without
|
|
145
|
+
# `via:`, ActiveRecord's optional scope.
|
|
146
|
+
# @param via [Symbol, nil] the child's `relates_to` name
|
|
147
|
+
# @param dependent [Symbol, nil] `:destroy` or `:delete_all` — for the link
|
|
148
|
+
# rows, never the children
|
|
149
|
+
# @param options [Hash] ActiveRecord's options, when `via:` is absent
|
|
150
|
+
# @return [void]
|
|
151
|
+
# @raise [EcsRails::DelegationConflict] if `name` is already taken
|
|
152
|
+
# @raise [ArgumentError] for a bad `child` or `dependent:`
|
|
153
|
+
# @see #has_one
|
|
154
|
+
# @see #validate_inverses!
|
|
155
|
+
def has_many(name, child = nil, via: nil, dependent: nil, **options, &block)
|
|
156
|
+
return super(name, child, **options, &block) if via.nil? && !child.is_a?(Class)
|
|
157
|
+
|
|
158
|
+
define_inverse(:has_many, name, child, via, dependent)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Declares the single entity whose `via` relationship points at this one —
|
|
162
|
+
# or, without `via:`, ActiveRecord's own `has_one`.
|
|
163
|
+
#
|
|
164
|
+
# The child's relationship must be `unique: true`: that is what makes "at
|
|
165
|
+
# most one" a database guarantee (ADR-0017's partial unique index), and a
|
|
166
|
+
# `has_one` that could silently return one of several rows is refused —
|
|
167
|
+
# on first use, or at boot under `eager_load` (ADR-0004's ethos, at the
|
|
168
|
+
# earliest safe moment).
|
|
169
|
+
#
|
|
170
|
+
# @example
|
|
171
|
+
# has_one :invoice, via: :order
|
|
172
|
+
#
|
|
173
|
+
# @param name [Symbol] the reader
|
|
174
|
+
# @param child [Class<EcsRails::Entity>, String, Proc, nil] as for {#has_many}
|
|
175
|
+
# @param via [Symbol, nil] the child's `relates_to` name
|
|
176
|
+
# @param dependent [Symbol, nil] `:destroy` or `:delete_all` — for the link row
|
|
177
|
+
# @param options [Hash] ActiveRecord's options, when `via:` is absent
|
|
178
|
+
# @return [void]
|
|
179
|
+
# @raise [EcsRails::DelegationConflict] if `name` is already taken
|
|
180
|
+
# @raise [ArgumentError] for a bad `child` or `dependent:`
|
|
181
|
+
# @see #has_many
|
|
182
|
+
def has_one(name, child = nil, via: nil, dependent: nil, **options, &block)
|
|
183
|
+
return super(name, child, **options, &block) if via.nil? && !child.is_a?(Class)
|
|
184
|
+
|
|
185
|
+
define_inverse(:has_one, name, child, via, dependent)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The inverses declared on this entity, ancestry included.
|
|
189
|
+
#
|
|
190
|
+
# @return [Array<InverseMeta>]
|
|
191
|
+
def inverses
|
|
192
|
+
entity_ancestry.flat_map { |klass| (klass.instance_variable_get(:@ecs_inverses) || {}).values }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @param name [Symbol, String]
|
|
196
|
+
# @return [InverseMeta, nil]
|
|
197
|
+
def inverse_meta(name)
|
|
198
|
+
inverses.find { |meta| meta.name == name.to_sym }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Checks every inverse declared on this entity against its child: the child
|
|
202
|
+
# is a concrete entity, it declares `via` towards this entity, and for a
|
|
203
|
+
# `has_one` that relationship is `unique: true`. Runs automatically on the
|
|
204
|
+
# first read of each association and at boot under `eager_load`; call it
|
|
205
|
+
# yourself to fail earlier.
|
|
206
|
+
#
|
|
207
|
+
# @return [void]
|
|
208
|
+
# @raise [EcsRails::InvalidRelationship] naming the exact `relates_to` line to write
|
|
209
|
+
def validate_inverses!
|
|
210
|
+
inverses.each { |meta| validate_inverse!(meta.name) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Checks one inverse, once per class.
|
|
214
|
+
#
|
|
215
|
+
# @param name [Symbol]
|
|
216
|
+
# @return [void]
|
|
217
|
+
# @raise [EcsRails::InvalidRelationship]
|
|
218
|
+
# @api private
|
|
219
|
+
def validate_inverse!(name)
|
|
220
|
+
validated = (@ecs_validated_inverses ||= {})
|
|
221
|
+
return if validated[name]
|
|
222
|
+
|
|
223
|
+
meta = inverse_meta(name) or return
|
|
224
|
+
validate_inverse_pair!(meta)
|
|
225
|
+
validated[name] = true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
# ActiveRecord's own macros, reached by binding rather than `super` because
|
|
231
|
+
# the has_one expansion needs has_many for its link.
|
|
232
|
+
AR_HAS_MANY = ActiveRecord::Associations::ClassMethods.instance_method(:has_many)
|
|
233
|
+
AR_HAS_ONE = ActiveRecord::Associations::ClassMethods.instance_method(:has_one)
|
|
234
|
+
private_constant :AR_HAS_MANY, :AR_HAS_ONE
|
|
235
|
+
|
|
236
|
+
def define_inverse(macro, name, child, via, dependent)
|
|
237
|
+
name = name.to_sym
|
|
238
|
+
raise ArgumentError, "#{macro} :#{name} on #{self.name}: an entity child needs `via:` — the child's " \
|
|
239
|
+
"relates_to name, e.g. `#{macro} :#{name}, via: :owner`" if via.nil?
|
|
240
|
+
|
|
241
|
+
via = via.to_sym
|
|
242
|
+
child_name = inverse_child_name(macro, name, child)
|
|
243
|
+
validate_dependent!(name, dependent)
|
|
244
|
+
detect_inverse_collision!(name)
|
|
245
|
+
|
|
246
|
+
meta = InverseMeta.new(name, macro, child_name, via)
|
|
247
|
+
slot = via.to_s
|
|
248
|
+
link_macro = macro == :has_many ? AR_HAS_MANY : AR_HAS_ONE
|
|
249
|
+
link_options = { class_name: EcsRails.config.relationship_class_name, foreign_key: :target_id,
|
|
250
|
+
inverse_of: :target }
|
|
251
|
+
link_options[:dependent] = dependent if dependent
|
|
252
|
+
|
|
253
|
+
# owner_model is read inside the lambda, at query time, so the child is
|
|
254
|
+
# resolved then and never at class-load time (see the module comment).
|
|
255
|
+
link_macro.bind_call(self, meta.link_name,
|
|
256
|
+
-> { where(slot: slot, owner_model: meta.child_class.model_name.collection) },
|
|
257
|
+
**link_options)
|
|
258
|
+
link_macro.bind_call(self, name, through: meta.link_name, source: :entity, class_name: child_name)
|
|
259
|
+
|
|
260
|
+
(@ecs_inverses ||= {})[name] = meta
|
|
261
|
+
define_inverse_guards(meta)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# The child as a class name: a Class gives its name (and is checked now to be
|
|
265
|
+
# an entity — a Component here is a mistake we can see immediately), a
|
|
266
|
+
# String or Symbol is taken as given, nil infers from the reader
|
|
267
|
+
# (`:comments` → "Comment").
|
|
268
|
+
def inverse_child_name(macro, name, child)
|
|
269
|
+
case child
|
|
270
|
+
when nil then name.to_s.singularize.camelize
|
|
271
|
+
when String, Symbol then child.to_s
|
|
272
|
+
when Class
|
|
273
|
+
unless child < EcsRails::Entity && !child.abstract_class?
|
|
274
|
+
raise InvalidRelationship,
|
|
275
|
+
"#{macro} :#{name} on #{self.name} expected an entity as the child, got #{child.name}. " \
|
|
276
|
+
"Write `#{macro} :#{name}, via: :#{name.to_s.singularize}` and let the child be inferred, " \
|
|
277
|
+
"or name an entity class."
|
|
278
|
+
end
|
|
279
|
+
child.name
|
|
280
|
+
else
|
|
281
|
+
raise ArgumentError, "#{macro} :#{name}: the child must be an entity class or a class name, got #{child.inspect}"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The first read of the association (and, for has_one, its build/create
|
|
286
|
+
# forms) validates the pair once. Defined into generated_component_methods,
|
|
287
|
+
# which sits in front of ActiveRecord's generated association methods, so
|
|
288
|
+
# `super` reaches the real association.
|
|
289
|
+
#
|
|
290
|
+
# `create_invoice` / `create_invoice!` also save the link row. ActiveRecord's
|
|
291
|
+
# has_one :through builds the join record on create and leaves it for the
|
|
292
|
+
# owner's next save — `club.create_invoice!` would otherwise return a saved
|
|
293
|
+
# Invoice whose `team` is nil until `club.save!`. Flushing the freshly built
|
|
294
|
+
# link here makes the method mean what it says; assignment
|
|
295
|
+
# (`club.invoice = invoice`) already links immediately.
|
|
296
|
+
def define_inverse_guards(meta)
|
|
297
|
+
mod = generated_component_methods
|
|
298
|
+
name = meta.name
|
|
299
|
+
link_name = meta.link_name
|
|
300
|
+
|
|
301
|
+
mod.define_method(name) do |*args, **kwargs, &block|
|
|
302
|
+
self.class.validate_inverse!(name)
|
|
303
|
+
super(*args, **kwargs, &block)
|
|
304
|
+
end
|
|
305
|
+
return unless meta.macro == :has_one
|
|
306
|
+
|
|
307
|
+
mod.define_method(:"build_#{name}") do |*args, **kwargs, &block|
|
|
308
|
+
self.class.validate_inverse!(name)
|
|
309
|
+
super(*args, **kwargs, &block)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
[:"create_#{name}", :"create_#{name}!"].each do |method|
|
|
313
|
+
mod.define_method(method) do |*args, **kwargs, &block|
|
|
314
|
+
self.class.validate_inverse!(name)
|
|
315
|
+
record = super(*args, **kwargs, &block)
|
|
316
|
+
link = association(link_name).target
|
|
317
|
+
link.save! if record.persisted? && link&.new_record?
|
|
318
|
+
record
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The child must be a concrete entity that relates `via` to THIS entity (or
|
|
324
|
+
# an ancestor of it — a subclass reads its parent's inverses), and for
|
|
325
|
+
# has_one that relationship must be unique.
|
|
326
|
+
def validate_inverse_pair!(meta)
|
|
327
|
+
macro, name, via = meta.macro, meta.name, meta.via
|
|
328
|
+
child = begin
|
|
329
|
+
meta.child_class
|
|
330
|
+
rescue NameError
|
|
331
|
+
raise InvalidRelationship,
|
|
332
|
+
"#{macro} :#{name} on #{self.name}: no entity class named #{meta.child_class_name}. " \
|
|
333
|
+
"Name the child — `#{macro} :#{name}, \"TheClass\", via: :#{via}` — or define it."
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
unless child.is_a?(Class) && child < EcsRails::Entity && !child.abstract_class?
|
|
337
|
+
raise InvalidRelationship,
|
|
338
|
+
"#{macro} :#{name} on #{self.name}: #{meta.child_class_name} is not a concrete entity."
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
rel = child.relationship_meta(via)
|
|
342
|
+
unless rel
|
|
343
|
+
known = child.relationship_names
|
|
344
|
+
raise InvalidRelationship,
|
|
345
|
+
"#{macro} :#{name} on #{self.name}: #{child.name} has no relationship :#{via} " \
|
|
346
|
+
"(#{child.name} relates to: #{known.empty? ? 'none' : known.map { |n| ":#{n}" }.join(', ')}). " \
|
|
347
|
+
"Declare `relates_to :#{via}, #{self.name}` on #{child.name} first."
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
unless self <= rel.target_class
|
|
351
|
+
raise InvalidRelationship,
|
|
352
|
+
"#{macro} :#{name} on #{self.name}: #{child.name} relates :#{via} to " \
|
|
353
|
+
"#{rel.target_class_name}, not to #{self.name}."
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
return unless macro == :has_one && !rel.unique
|
|
357
|
+
|
|
358
|
+
raise InvalidRelationship,
|
|
359
|
+
"has_one :#{name} on #{self.name} needs #{child.name}'s relationship :#{via} to be " \
|
|
360
|
+
"declared `unique: true` — only the database's partial unique index makes \"at most one\" " \
|
|
361
|
+
"true. Write `relates_to :#{via}, #{self.name}, unique: true` on #{child.name}, or use has_many."
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def validate_dependent!(name, dependent)
|
|
365
|
+
return if dependent.nil? || %i[destroy delete_all].include?(dependent)
|
|
366
|
+
|
|
367
|
+
raise ArgumentError,
|
|
368
|
+
"has_many/has_one :#{name}: dependent: applies to the link rows and accepts :destroy or " \
|
|
369
|
+
":delete_all; got #{dependent.inspect}. Destroying child entities is a domain choice."
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# The names an inverse reserves on the entity, for the DSL's conflict
|
|
373
|
+
# checks (EcsRails::DSL#ecs_reserved_names): a later component may not
|
|
374
|
+
# delegate `comments`, and a marker may not be named `comments`.
|
|
375
|
+
def ecs_reserved_names
|
|
376
|
+
inverses.each_with_object(super) do |meta, reserved|
|
|
377
|
+
[meta.name, :"#{meta.name}=", :"#{meta.name.to_s.singularize}_ids",
|
|
378
|
+
:"#{meta.name.to_s.singularize}_ids=", meta.link_name].each do |method|
|
|
379
|
+
reserved[method] ||= "#{meta.macro} :#{meta.name}"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# ADR-0004: the reader must not be a name the entity already answers.
|
|
385
|
+
def detect_inverse_collision!(name)
|
|
386
|
+
taken = component_declarations.flat_map do |declaration|
|
|
387
|
+
[declaration.reader_name, :"#{declaration.reader_name}?"] + delegation_map_for(declaration).keys
|
|
388
|
+
end
|
|
389
|
+
taken += ecs_reserved_names.keys
|
|
390
|
+
|
|
391
|
+
return unless taken.include?(name)
|
|
392
|
+
|
|
393
|
+
raise DelegationConflict,
|
|
394
|
+
"has_many/has_one :#{name} on #{self.name} collides with an existing ##{name} — a component, " \
|
|
395
|
+
"a relationship, a marker or another inverse already owns that name. Choose a different name."
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
data/lib/ecs_rails/lazy.rb
CHANGED
|
@@ -66,12 +66,17 @@ module EcsRails
|
|
|
66
66
|
# it means the cascade can simply walk the memo, rather than interrogating
|
|
67
67
|
# ActiveRecord about which associations happen to be loaded.
|
|
68
68
|
#
|
|
69
|
+
# `slot` is the declaration's slot (RFC-0014): `""` for a singular
|
|
70
|
+
# component, `"business"` for `component PostalAddress, prefix:
|
|
71
|
+
# :business`. It only matters when no row exists, to preset the virtual —
|
|
72
|
+
# see #ecs_build_component.
|
|
73
|
+
#
|
|
69
74
|
# @api private
|
|
70
|
-
def ecs_component(name)
|
|
75
|
+
def ecs_component(name, slot = "")
|
|
71
76
|
cache = (@ecs_components ||= {})
|
|
72
77
|
return cache[name] if cache.key?(name)
|
|
73
78
|
|
|
74
|
-
cache[name] = yield || ecs_build_component(name)
|
|
79
|
+
cache[name] = yield || ecs_build_component(name, slot)
|
|
75
80
|
end
|
|
76
81
|
|
|
77
82
|
# Forgets a component, so the next read rebuilds it as virtual.
|
|
@@ -99,6 +104,60 @@ module EcsRails
|
|
|
99
104
|
association(name).target = nil
|
|
100
105
|
end
|
|
101
106
|
|
|
107
|
+
# Replaces one declared component without nullifying its required owner
|
|
108
|
+
# FK or inserting alongside the existing singleton (RFC-0006, ECS-26).
|
|
109
|
+
# Public for the generated writer; application code uses that writer.
|
|
110
|
+
#
|
|
111
|
+
# @param name [Symbol] component reader
|
|
112
|
+
# @param slot [String] declaration's slot
|
|
113
|
+
# @param value [EcsRails::Component, nil] replacement, or nil to remove
|
|
114
|
+
# @return [EcsRails::Component, nil]
|
|
115
|
+
# @raise [EcsRails::InvalidComponent] for a wrong type or owner/slot
|
|
116
|
+
# @raise [ActiveRecord::RecordInvalid] when a replacement fails validation
|
|
117
|
+
# @api private
|
|
118
|
+
def ecs_replace_component(name, slot, value)
|
|
119
|
+
component_association = association(name)
|
|
120
|
+
expected = component_association.klass
|
|
121
|
+
unless value.nil? || value.is_a?(expected)
|
|
122
|
+
raise InvalidComponent, "#{name}= expects #{expected.name} or nil"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
if value
|
|
126
|
+
other_owner = (value.entity_id && value.entity_id != id) ||
|
|
127
|
+
(value.entity && value.entity != self)
|
|
128
|
+
other_slot = value.persisted? && value.slot != slot
|
|
129
|
+
if other_owner || other_slot
|
|
130
|
+
raise InvalidComponent, "#{name}= cannot move a component from another owner or slot"
|
|
131
|
+
end
|
|
132
|
+
value.entity = self
|
|
133
|
+
value.slot = slot
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
previous = public_send(name)
|
|
137
|
+
begin
|
|
138
|
+
if persisted?
|
|
139
|
+
# A savepoint keeps a failed replacement atomic even when the
|
|
140
|
+
# caller rescues it inside a larger transaction and continues.
|
|
141
|
+
self.class.transaction(requires_new: true) do
|
|
142
|
+
previous.destroy! if previous.persisted? && previous != value
|
|
143
|
+
value.save! if value && (value.persisted? || value.ecs_dirty?)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
rescue StandardError
|
|
147
|
+
component_association.target = previous
|
|
148
|
+
(@ecs_components ||= {})[name] = previous
|
|
149
|
+
raise
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
component_association.target = value
|
|
153
|
+
if value
|
|
154
|
+
(@ecs_components ||= {})[name] = value
|
|
155
|
+
else
|
|
156
|
+
@ecs_components&.delete(name)
|
|
157
|
+
end
|
|
158
|
+
value
|
|
159
|
+
end
|
|
160
|
+
|
|
102
161
|
private
|
|
103
162
|
|
|
104
163
|
# An in-memory component with every attribute at its default and entity_id
|
|
@@ -106,8 +165,15 @@ module EcsRails
|
|
|
106
165
|
# column definitions — so a virtual `user.email.address` and a bare
|
|
107
166
|
# `Email.new.address` agree by construction rather than by us copying a
|
|
108
167
|
# list of defaults about.
|
|
109
|
-
|
|
168
|
+
#
|
|
169
|
+
# The one attribute NOT left at its default is `slot` (RFC-0014): a
|
|
170
|
+
# virtual `business_address` is built with `slot = "business"` preset, so
|
|
171
|
+
# its first write lands in the right slot. Like entity_id, slot is
|
|
172
|
+
# identity rather than state, and Component#ecs_dirty? skips it — a
|
|
173
|
+
# preset slot must not make an untouched virtual look dirty.
|
|
174
|
+
def ecs_build_component(name, slot = "")
|
|
110
175
|
component = self.class.reflect_on_association(name).klass.new
|
|
176
|
+
component.slot = slot.to_s
|
|
111
177
|
# Not `component.entity_id = id` — the belongs_to writer also sets the
|
|
112
178
|
# association target, which is what makes `user.email.entity` return
|
|
113
179
|
# this very entity without a query (architecture.md §1: a system reaches
|
|
@@ -144,23 +210,13 @@ module EcsRails
|
|
|
144
210
|
# for exactly this reason.
|
|
145
211
|
component.entity_id = id
|
|
146
212
|
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
# so `entity.save!` would return nil and raise *nothing at all* — a
|
|
155
|
-
# silent failure to write, which is the one outcome a bang method must
|
|
156
|
-
# never have. (`throw :abort` is not an option either: after_ callbacks
|
|
157
|
-
# cannot halt a chain, and it escapes as an UncaughtThrowError.)
|
|
158
|
-
#
|
|
159
|
-
# RFC-0007 is what fixes this properly: once a dirty component's errors
|
|
160
|
-
# merge onto the entity, `entity.valid?` is false and non-bang `save`
|
|
161
|
-
# returns false before ever reaching this callback — leaving the bang
|
|
162
|
-
# here as belt-and-braces, which is exactly the role the equivalent
|
|
163
|
-
# line plays in ActiveRecord's autosave.
|
|
213
|
+
# RFC-0007 merges component errors before this callback: ordinary
|
|
214
|
+
# invalid input makes entity.save return false and save! raise. Keep
|
|
215
|
+
# the bang here as a final guard if validation fails during the
|
|
216
|
+
# cascade, so the transaction rolls back and the failure is visible.
|
|
217
|
+
# Raising ActiveRecord::Rollback here would be swallowed by the save
|
|
218
|
+
# transaction and could make save! fail silently. The original
|
|
219
|
+
# callback trade-off is recorded in RFC-0006 and RFC-0007.
|
|
164
220
|
component.save!
|
|
165
221
|
end
|
|
166
222
|
end
|
|
@@ -220,9 +276,12 @@ module EcsRails
|
|
|
220
276
|
private
|
|
221
277
|
|
|
222
278
|
# Attributes that say something about the component, as opposed to which
|
|
223
|
-
# component it is. Only these can make it dirty.
|
|
279
|
+
# component it is. Only these can make it dirty. `slot` (RFC-0014) is the
|
|
280
|
+
# second half of the row's identity — which of an entity's PostalAddresses
|
|
281
|
+
# this is — so a labelled virtual with `slot = "business"` preset is no
|
|
282
|
+
# more dirty than a singular one with entity_id set.
|
|
224
283
|
def state_attribute?(attribute)
|
|
225
|
-
attribute != self.class.primary_key && attribute != "entity_id"
|
|
284
|
+
attribute != self.class.primary_key && attribute != "entity_id" && attribute != "slot"
|
|
226
285
|
end
|
|
227
286
|
|
|
228
287
|
# Tells the owning entity to forget this component, so its reader reverts
|
|
@@ -233,11 +292,15 @@ module EcsRails
|
|
|
233
292
|
# query and simply has nobody to notify. Nothing is reset on *this*
|
|
234
293
|
# object: ActiveRecord freezes a destroyed record, and the entity is going
|
|
235
294
|
# to hand out a fresh instance anyway.
|
|
295
|
+
#
|
|
296
|
+
# The reader to forget is slot-aware (RFC-0014): destroying the
|
|
297
|
+
# `business_address` row resets `user.business_address`, not
|
|
298
|
+
# `user.postal_address`.
|
|
236
299
|
def reset_entity_component
|
|
237
300
|
owner = association(:entity).target
|
|
238
301
|
return unless owner.is_a?(EcsRails::Entity)
|
|
239
302
|
|
|
240
|
-
owner.ecs_forget_component(
|
|
303
|
+
owner.ecs_forget_component(ecs_reader_name)
|
|
241
304
|
end
|
|
242
305
|
end
|
|
243
306
|
end
|