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
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
# The class-level DSL for markers: `marker`.
|
|
5
|
+
#
|
|
6
|
+
# Implements ADR-0018 §4 (RFC-0016), which amends ADR-0009's rejection of a
|
|
7
|
+
# `marker` keyword. Extended into EcsRails::Entity after {DSL}, {Relationships}
|
|
8
|
+
# and {Querying}, all of which it is built on.
|
|
9
|
+
#
|
|
10
|
+
# class User < ApplicationEntity
|
|
11
|
+
# marker :moderator
|
|
12
|
+
# marker :administrator
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# user.add(:moderator) # a row in markers, slot "moderator"
|
|
16
|
+
# user.moderator? # => true — the row exists
|
|
17
|
+
# user.moderator = false # remove; `= true` adds
|
|
18
|
+
# user.remove(:moderator)
|
|
19
|
+
# User.with_marker(:moderator) # entities that have the row
|
|
20
|
+
# User.without_marker(:moderator)
|
|
21
|
+
#
|
|
22
|
+
# ## How a marker is stored (ADR-0018 §4)
|
|
23
|
+
#
|
|
24
|
+
# `marker :moderator` is
|
|
25
|
+
#
|
|
26
|
+
# component Marker, prefix: :moderator, delegate: false
|
|
27
|
+
#
|
|
28
|
+
# — a labelled component (RFC-0014) with no attributes, whose slot is the
|
|
29
|
+
# marker name, on the one `markers` table created at install. The reader is
|
|
30
|
+
# `moderator_marker`; presence semantics are exactly ADR-0009's: a user *is* a
|
|
31
|
+
# moderator when the `(entity_id, "moderator")` row exists, `add` writes it
|
|
32
|
+
# now (the save cascade never would — a marker is never dirty), `remove`
|
|
33
|
+
# destroys it.
|
|
34
|
+
#
|
|
35
|
+
# What the declaration adds over a bare `component` is the ergonomics
|
|
36
|
+
# prefixing takes away: the predicate is `moderator?`, not
|
|
37
|
+
# `moderator_marker?`; `add`/`has?`/`remove` take the Symbol; and
|
|
38
|
+
# `moderator=` accepts a Boolean so a form checkbox routes through flat mass
|
|
39
|
+
# assignment. The `Marker` class is the host app's one-line catalogue
|
|
40
|
+
# component (EcsRails::Catalogue::Marker), found by name through
|
|
41
|
+
# {EcsRails::Config#marker_class_name}.
|
|
42
|
+
module Markers
|
|
43
|
+
# Declares a marker named `name`.
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# class User < ApplicationEntity
|
|
47
|
+
# marker :moderator
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# user.add(:moderator)
|
|
51
|
+
# user.moderator? # => true
|
|
52
|
+
# user.moderator = false # removes the row
|
|
53
|
+
#
|
|
54
|
+
# @param name [Symbol, String] the marker name; becomes the slot, the
|
|
55
|
+
# `name?` predicate, the `name=` writer, and the `name_marker` reader
|
|
56
|
+
# @return [EcsRails::Registry::Declaration] the Marker declaration
|
|
57
|
+
# @raise [EcsRails::DelegationConflict] if `name?` or `name=` is already a
|
|
58
|
+
# method the entity answers
|
|
59
|
+
# @raise [ArgumentError] if `name` is not a valid slot label
|
|
60
|
+
# @raise [NameError] if the app has no `Marker` component (run
|
|
61
|
+
# `rails g ecs_rails:install`, or set {EcsRails::Config#marker_class_name})
|
|
62
|
+
# @see EcsRails::Presence::Entity#add
|
|
63
|
+
# @see #with_marker
|
|
64
|
+
def marker(name)
|
|
65
|
+
name = name.to_sym
|
|
66
|
+
detect_marker_collision!(name)
|
|
67
|
+
|
|
68
|
+
declaration = component(ecs_marker_class, prefix: name, delegate: false)
|
|
69
|
+
define_marker_accessors(name)
|
|
70
|
+
ecs_own_markers << name unless ecs_own_markers.include?(name)
|
|
71
|
+
|
|
72
|
+
declaration
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The declared marker names for this entity, ancestry included.
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# User.marker_names # => [:moderator, :administrator]
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Symbol>]
|
|
81
|
+
def marker_names
|
|
82
|
+
entity_ancestry.flat_map { |klass| klass.instance_variable_get(:@ecs_markers) || [] }.uniq
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Is `name` a marker this entity declares?
|
|
86
|
+
#
|
|
87
|
+
# @param name [Symbol, String]
|
|
88
|
+
# @return [Boolean]
|
|
89
|
+
def marker?(name)
|
|
90
|
+
marker_names.include?(name.to_sym)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Entities that HAVE the marker (RFC-0010 sugar, ADR-0018 §4).
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# User.with_marker(:moderator)
|
|
97
|
+
#
|
|
98
|
+
# @param name [Symbol, String] a declared marker
|
|
99
|
+
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
100
|
+
# @raise [EcsRails::InvalidComponent] if `name` is not a declared marker
|
|
101
|
+
# @see #without_marker
|
|
102
|
+
def with_marker(name)
|
|
103
|
+
with_component(ecs_marker_class, prefix: ecs_resolve_marker!(name))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Entities that do NOT have the marker.
|
|
107
|
+
#
|
|
108
|
+
# @example
|
|
109
|
+
# User.without_marker(:administrator)
|
|
110
|
+
#
|
|
111
|
+
# @param name [Symbol, String] a declared marker
|
|
112
|
+
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
113
|
+
# @raise [EcsRails::InvalidComponent] if `name` is not a declared marker
|
|
114
|
+
# @see #with_marker
|
|
115
|
+
def without_marker(name)
|
|
116
|
+
without_component(ecs_marker_class, prefix: ecs_resolve_marker!(name))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# The host app's Marker component (EcsRails::Catalogue::Marker), resolved
|
|
122
|
+
# by name on every call so a reloaded constant is picked up.
|
|
123
|
+
def ecs_marker_class
|
|
124
|
+
EcsRails.config.marker_class_name.constantize
|
|
125
|
+
rescue NameError => e
|
|
126
|
+
raise NameError,
|
|
127
|
+
"EcsRails: `marker` needs the #{EcsRails.config.marker_class_name} component " \
|
|
128
|
+
"(#{e.message}). Run `rails g ecs_rails:install`, which writes it, or set " \
|
|
129
|
+
"EcsRails.config.marker_class_name to your class that includes " \
|
|
130
|
+
"EcsRails::Catalogue::Marker.", e.backtrace
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The bare predicate and the Boolean writer, into the same module the
|
|
134
|
+
# readers live in (ADR-0004: an entity-defined method still wins). Both are
|
|
135
|
+
# sugar over Presence: `moderator?` is `has?(:moderator)`, `moderator = x`
|
|
136
|
+
# is `add`/`remove`. The writer is what lets a checkbox param route through
|
|
137
|
+
# flat mass assignment (`User.update(moderator: "1")`); it takes anything
|
|
138
|
+
# ActiveModel would cast as a Boolean.
|
|
139
|
+
def define_marker_accessors(name)
|
|
140
|
+
mod = generated_component_methods
|
|
141
|
+
|
|
142
|
+
mod.define_method(:"#{name}?") { has?(name) }
|
|
143
|
+
mod.define_method(:"#{name}=") do |value|
|
|
144
|
+
ActiveModel::Type::Boolean.new.cast(value) ? add(name) : remove(name)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The names a marker reserves on the entity, for the DSL's conflict checks
|
|
149
|
+
# (EcsRails::DSL#ecs_reserved_names). Extends whatever Relationships (and
|
|
150
|
+
# the DSL) reserved.
|
|
151
|
+
def ecs_reserved_names
|
|
152
|
+
marker_names.each_with_object(super) do |name, reserved|
|
|
153
|
+
[:"#{name}?", :"#{name}="].each { |method| reserved[method] ||= "marker :#{name}" }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def ecs_own_markers
|
|
158
|
+
@ecs_markers ||= []
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# A marker name resolves to its slot, or raises the fail-loud
|
|
162
|
+
# InvalidComponent naming the declared markers — the same stance as
|
|
163
|
+
# relationships (RFC-0013).
|
|
164
|
+
def ecs_resolve_marker!(name)
|
|
165
|
+
return name.to_sym if marker?(name)
|
|
166
|
+
|
|
167
|
+
declared = marker_names
|
|
168
|
+
known = declared.empty? ? "none" : declared.map { |n| ":#{n}" }.join(", ")
|
|
169
|
+
raise InvalidComponent,
|
|
170
|
+
"#{self.name} has no marker named :#{name}. #{self.name} marks: #{known}."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ADR-0004: the predicate and writer a marker generates must not be names
|
|
174
|
+
# the entity already answers — a component reader's `email?` predicate, a
|
|
175
|
+
# delegated `verified=`, a relationship's writer, another marker. The reader
|
|
176
|
+
# (`moderator_marker`) is checked by `component` itself a moment later.
|
|
177
|
+
def detect_marker_collision!(name)
|
|
178
|
+
# The same marker twice is a DuplicateComponent, which `component` raises
|
|
179
|
+
# a moment later with the slot named; reporting it here as a collision
|
|
180
|
+
# with its own predicate would be the less useful message.
|
|
181
|
+
return if marker_names.include?(name)
|
|
182
|
+
|
|
183
|
+
taken = component_declarations.flat_map do |declaration|
|
|
184
|
+
[declaration.reader_name, :"#{declaration.reader_name}?"] + delegation_map_for(declaration).keys
|
|
185
|
+
end
|
|
186
|
+
taken += ecs_reserved_names.keys
|
|
187
|
+
|
|
188
|
+
clash = [:"#{name}?", :"#{name}="].find { |method| taken.include?(method) }
|
|
189
|
+
return unless clash
|
|
190
|
+
|
|
191
|
+
raise DelegationConflict,
|
|
192
|
+
"marker :#{name} on #{self.name} collides with an existing ##{clash} method — " \
|
|
193
|
+
"a component, a relationship or another marker already owns that name. " \
|
|
194
|
+
"Choose a different marker name."
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
data/lib/ecs_rails/preloading.rb
CHANGED
|
@@ -53,6 +53,10 @@ module EcsRails
|
|
|
53
53
|
# @example A named subset, chained onto a component filter
|
|
54
54
|
# Post.with_component(PublishState).includes_components(Title, Body)
|
|
55
55
|
#
|
|
56
|
+
# A component declared under several slots (RFC-0014) preloads every slot's
|
|
57
|
+
# has_one: `includes_components(PostalAddress)` batches `postal_address` and
|
|
58
|
+
# `business_address` alike, one query each.
|
|
59
|
+
#
|
|
56
60
|
# @param component_classes [Array<Class<EcsRails::Component>>] components to
|
|
57
61
|
# preload; empty preloads every component the entity declares
|
|
58
62
|
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
@@ -61,19 +65,26 @@ module EcsRails
|
|
|
61
65
|
# which accepts any concrete component
|
|
62
66
|
# @see EcsRails::Relationships#includes_related for relationships
|
|
63
67
|
def includes_components(*component_classes)
|
|
64
|
-
|
|
65
|
-
targets =
|
|
66
|
-
|
|
68
|
+
declarations = component_declarations
|
|
69
|
+
targets =
|
|
70
|
+
if component_classes.empty?
|
|
71
|
+
declarations
|
|
72
|
+
else
|
|
73
|
+
component_classes.flat_map { |component_class| ecs_declarations_of!(component_class, declarations) }
|
|
74
|
+
end
|
|
67
75
|
|
|
68
|
-
all.preload(*targets.map
|
|
76
|
+
all.preload(*targets.map(&:reader_name))
|
|
69
77
|
end
|
|
70
78
|
|
|
71
79
|
private
|
|
72
80
|
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
# Every declaration of `component_class` on this entity — one per slot
|
|
82
|
+
# (RFC-0014). Each reader name is the has_one the DSL defined, so preloading
|
|
83
|
+
# by reader name is the same derivation the DSL used
|
|
84
|
+
# (EcsRails::DSL#define_component_association).
|
|
85
|
+
def ecs_declarations_of!(component_class, declarations)
|
|
86
|
+
ecs_validate_declared_component!(component_class, declarations.map(&:component_class).uniq)
|
|
87
|
+
declarations.select { |declaration| declaration.component_class_name == component_class.name }
|
|
77
88
|
end
|
|
78
89
|
|
|
79
90
|
# A preloadable component is one this entity *declares* — unlike the query DSL
|
data/lib/ecs_rails/presence.rb
CHANGED
|
@@ -27,6 +27,15 @@ module EcsRails
|
|
|
27
27
|
# This module is the entity side. The `<reader>?` predicate is generated per
|
|
28
28
|
# component by the DSL, into generated_component_methods (see
|
|
29
29
|
# EcsRails::DSL#define_component_predicate); it is just `has?(ThatComponent)`.
|
|
30
|
+
#
|
|
31
|
+
# Labelled slots (RFC-0014) add an optional `prefix:` to all three verbs —
|
|
32
|
+
# `add(PostalAddress, prefix: :business)` — and the per-slot predicate
|
|
33
|
+
# `user.business_address?`. Omitted, `prefix:` means the default slot, so
|
|
34
|
+
# every singular call reads exactly as before.
|
|
35
|
+
#
|
|
36
|
+
# Markers (ADR-0018 §4) add a Symbol form: `add(:moderator)` / `has?` /
|
|
37
|
+
# `remove` name a marker declared with `marker :moderator`, which is the
|
|
38
|
+
# `Marker` component in slot "moderator". `user.moderator?` is that `has?`.
|
|
30
39
|
module Presence
|
|
31
40
|
# The entity side of presence: `add` / `has?` / `remove`.
|
|
32
41
|
#
|
|
@@ -59,16 +68,27 @@ module EcsRails
|
|
|
59
68
|
# @example Validation still applies
|
|
60
69
|
# user.add(Email) # raises: Email#address is required
|
|
61
70
|
#
|
|
62
|
-
# @
|
|
63
|
-
#
|
|
71
|
+
# @example A labelled slot (RFC-0014)
|
|
72
|
+
# user.add(PostalAddress, prefix: :business)
|
|
73
|
+
# user.business_address? # => true
|
|
74
|
+
#
|
|
75
|
+
# @example A marker, by name (ADR-0018 §4)
|
|
76
|
+
# user.add(:moderator)
|
|
77
|
+
# user.moderator? # => true
|
|
78
|
+
#
|
|
79
|
+
# @param component_class [Class<EcsRails::Component>, Symbol] a component
|
|
80
|
+
# this entity declares, or the name of a marker it declares
|
|
81
|
+
# @param prefix [Symbol, String, nil] the slot label; nil for the default
|
|
82
|
+
# slot. Not accepted with a marker name.
|
|
64
83
|
# @return [EcsRails::Component] the persisted component instance
|
|
65
|
-
# @raise [EcsRails::InvalidComponent] if the entity does not declare it
|
|
84
|
+
# @raise [EcsRails::InvalidComponent] if the entity does not declare it in
|
|
85
|
+
# that slot
|
|
66
86
|
# @raise [ActiveRecord::RecordInvalid] if the component is invalid — you
|
|
67
87
|
# cannot add an empty required component (ADR-0009)
|
|
68
88
|
# @see #has?
|
|
69
89
|
# @see #remove
|
|
70
|
-
def add(component_class)
|
|
71
|
-
name =
|
|
90
|
+
def add(component_class, prefix: nil)
|
|
91
|
+
name = ecs_presence_declaration(component_class, prefix).reader_name
|
|
72
92
|
|
|
73
93
|
# The reader goes through RFC-0006's memo. When no row exists on this
|
|
74
94
|
# instance it loads one (a SELECT) or builds a virtual; when a row does
|
|
@@ -102,27 +122,40 @@ module EcsRails
|
|
|
102
122
|
# user.has?(Moderator) # => true
|
|
103
123
|
# user.moderator? # => the same question, generated per component
|
|
104
124
|
#
|
|
105
|
-
# @param component_class [Class<EcsRails::Component
|
|
106
|
-
# entity declares
|
|
107
|
-
# @
|
|
108
|
-
# @
|
|
125
|
+
# @param component_class [Class<EcsRails::Component>, Symbol] a component
|
|
126
|
+
# this entity declares, or the name of a marker it declares
|
|
127
|
+
# @param prefix [Symbol, String, nil] the slot label; nil for the default slot
|
|
128
|
+
# @return [Boolean] whether a persisted row exists in that slot
|
|
129
|
+
# @raise [EcsRails::InvalidComponent] if the entity does not declare it in
|
|
130
|
+
# that slot
|
|
109
131
|
# @see #add
|
|
110
132
|
# @see #remove
|
|
111
|
-
def has?(component_class)
|
|
112
|
-
|
|
133
|
+
def has?(component_class, prefix: nil)
|
|
134
|
+
declaration = ecs_presence_declaration(component_class, prefix)
|
|
135
|
+
name = declaration.reader_name
|
|
113
136
|
|
|
114
137
|
# Memo first: if this instance already loaded (and persisted) the
|
|
115
|
-
# component, the answer is known without touching the database.
|
|
116
|
-
# virtual memo entry is *not* proof of absence — a row could have been
|
|
117
|
-
# written elsewhere — so it falls through to the existence check rather
|
|
118
|
-
# than answering false from memory.
|
|
138
|
+
# component, the answer is known without touching the database.
|
|
119
139
|
cached = @ecs_components&.[](name)
|
|
120
140
|
return true if cached&.persisted?
|
|
121
141
|
|
|
142
|
+
# Then the has_one itself. Once ActiveRecord has loaded it — by a
|
|
143
|
+
# preload (`includes_components(Marker)` on a list view) or by an earlier
|
|
144
|
+
# read through the reader — its target is authoritative for this
|
|
145
|
+
# instance, present or absent, exactly as any association cache is; a
|
|
146
|
+
# row written elsewhere since is stale until `reload`, as it would be
|
|
147
|
+
# for `user.email`. This is what makes a page of marker badges cost no
|
|
148
|
+
# queries after one preload (RFC-0016). A virtual memo entry alone does
|
|
149
|
+
# not decide it: `remove` on an absent marker leaves one behind without
|
|
150
|
+
# loading anything, and the association says so.
|
|
151
|
+
association = association(name)
|
|
152
|
+
return association.target&.persisted? || false if association.loaded?
|
|
153
|
+
|
|
122
154
|
# Bare existence check: no row is instantiated, so nothing is loaded and
|
|
123
155
|
# nothing is dirtied. One query per component, as everywhere else in the
|
|
124
|
-
# gem (architecture.md §7 non-goal: query optimisation).
|
|
125
|
-
|
|
156
|
+
# gem (architecture.md §7 non-goal: query optimisation). Slot-scoped, so
|
|
157
|
+
# a `business_address` row does not answer for `postal_address`.
|
|
158
|
+
declaration.component_class.where(entity_id: id, slot: declaration.slot).exists?
|
|
126
159
|
end
|
|
127
160
|
|
|
128
161
|
# Destroys the row for `component_class` if present, and resets the reader
|
|
@@ -133,14 +166,16 @@ module EcsRails
|
|
|
133
166
|
# user.remove(Moderator) # DELETEs the row; no-op if there was none
|
|
134
167
|
# user.moderator # => #<Moderator> — virtual again, not nil
|
|
135
168
|
#
|
|
136
|
-
# @param component_class [Class<EcsRails::Component
|
|
137
|
-
# entity declares
|
|
169
|
+
# @param component_class [Class<EcsRails::Component>, Symbol] a component
|
|
170
|
+
# this entity declares, or the name of a marker it declares
|
|
171
|
+
# @param prefix [Symbol, String, nil] the slot label; nil for the default slot
|
|
138
172
|
# @return [self] the entity, for chaining
|
|
139
|
-
# @raise [EcsRails::InvalidComponent] if the entity does not declare it
|
|
173
|
+
# @raise [EcsRails::InvalidComponent] if the entity does not declare it in
|
|
174
|
+
# that slot
|
|
140
175
|
# @see #add
|
|
141
176
|
# @see #has?
|
|
142
|
-
def remove(component_class)
|
|
143
|
-
name =
|
|
177
|
+
def remove(component_class, prefix: nil)
|
|
178
|
+
name = ecs_presence_declaration(component_class, prefix).reader_name
|
|
144
179
|
|
|
145
180
|
# Reach the component through the reader so a present row is loaded with
|
|
146
181
|
# its callbacks intact. `component.destroy` fires Lazy::Component's
|
|
@@ -155,21 +190,39 @@ module EcsRails
|
|
|
155
190
|
|
|
156
191
|
private
|
|
157
192
|
|
|
158
|
-
# Resolves a declared component
|
|
193
|
+
# Resolves a declared (component, slot) — or a marker name — to its
|
|
194
|
+
# declaration, or raises.
|
|
159
195
|
#
|
|
160
196
|
# `add`/`has?`/`remove` accept only a component the entity actually
|
|
161
|
-
# declares (RFC-0009)
|
|
197
|
+
# declares (RFC-0009), in a slot it declares it under (RFC-0014), or the
|
|
198
|
+
# Symbol name of a marker it declares (ADR-0018 §4); anything else — an
|
|
199
|
+
# undeclared component, an undeclared slot, an unknown marker, or a class
|
|
162
200
|
# that is not a component at all — is InvalidComponent, checked before any
|
|
163
|
-
# database work. The
|
|
164
|
-
# (DSL#
|
|
201
|
+
# database work. The lookup already accounts for inheritance
|
|
202
|
+
# (DSL#declaration_for walks the ancestry), so a subclass sees its parents'
|
|
165
203
|
# components too.
|
|
166
|
-
def
|
|
167
|
-
|
|
204
|
+
def ecs_presence_declaration(component_class, prefix)
|
|
205
|
+
if component_class.is_a?(Symbol) || component_class.is_a?(String)
|
|
206
|
+
raise ArgumentError, "a marker name takes no prefix: (got #{prefix.inspect})" unless prefix.nil?
|
|
207
|
+
|
|
208
|
+
declaration = self.class.marker?(component_class) &&
|
|
209
|
+
self.class.declaration_for(EcsRails.config.marker_class_name.constantize,
|
|
210
|
+
prefix: component_class)
|
|
211
|
+
return declaration if declaration
|
|
212
|
+
|
|
213
|
+
known = self.class.marker_names
|
|
168
214
|
raise InvalidComponent,
|
|
169
|
-
"
|
|
215
|
+
":#{component_class} is not a marker of #{self.class.name}. #{self.class.name} marks: " \
|
|
216
|
+
"#{known.empty? ? 'none' : known.map { |n| ":#{n}" }.join(', ')}."
|
|
170
217
|
end
|
|
171
218
|
|
|
172
|
-
|
|
219
|
+
declaration =
|
|
220
|
+
component_class.is_a?(Class) && self.class.declaration_for(component_class, prefix: prefix)
|
|
221
|
+
return declaration if declaration
|
|
222
|
+
|
|
223
|
+
where = prefix.nil? ? "" : " in slot #{prefix.to_s.inspect} (prefix: :#{prefix})"
|
|
224
|
+
raise InvalidComponent,
|
|
225
|
+
"#{component_class.inspect} is not a component of #{self.class.name}#{where}"
|
|
173
226
|
end
|
|
174
227
|
end
|
|
175
228
|
end
|
data/lib/ecs_rails/querying.rb
CHANGED
|
@@ -13,6 +13,10 @@ module EcsRails
|
|
|
13
13
|
# User.without_component(Avatar) # users with NO avatar
|
|
14
14
|
# Post.with_component(Name).with_component(Avatar) # AND — chainable
|
|
15
15
|
#
|
|
16
|
+
# Product.with_component(Money, prefix: :price) { where("amount_cents < ?", 5000) }
|
|
17
|
+
# Product.with_component(Rating, "stars >= ?", 4) # beyond equality (RFC-0018)
|
|
18
|
+
# Product.order_by_component(Money, :amount_cents, prefix: :price) # cheapest first
|
|
19
|
+
#
|
|
16
20
|
# Extended into EcsRails::Entity, so these are class methods on every entity
|
|
17
21
|
# class. ActiveRecord delegates class methods to relations (via `scoping`), so
|
|
18
22
|
# `Post.where(...).with_component(...)` chains too: the method runs with `all`
|
|
@@ -34,6 +38,15 @@ module EcsRails
|
|
|
34
38
|
# is Post's own default-scoped relation (`model = 'posts'`, ADR-0002). The DSL
|
|
35
39
|
# only appends the `EXISTS` clause. Building from `unscoped` or from
|
|
36
40
|
# `ApplicationEntity` would drop that scope and leak — so we build from `all`.
|
|
41
|
+
#
|
|
42
|
+
# ## Ordering is a different mechanism (RFC-0018)
|
|
43
|
+
#
|
|
44
|
+
# `EXISTS` is a boolean: it can filter on a component's value but cannot sort
|
|
45
|
+
# by it. `order_by_component` adds a correlated **scalar** subquery to
|
|
46
|
+
# `ORDER BY` — `(SELECT amount_cents FROM monies WHERE entity_id = entities.id
|
|
47
|
+
# AND slot = 'price' LIMIT 1)` — which needs no join, no table alias, cannot
|
|
48
|
+
# duplicate rows, and composes with every filter above. Entities without the
|
|
49
|
+
# row sort last.
|
|
37
50
|
module Querying
|
|
38
51
|
# Entities that HAVE a row for `component_class` (RFC-0010). With
|
|
39
52
|
# `conditions`, the row must also match them (hash equality, like `where`).
|
|
@@ -43,7 +56,7 @@ module EcsRails
|
|
|
43
56
|
# ActiveRecord and treated as data, never SQL (ADR-0011).
|
|
44
57
|
#
|
|
45
58
|
# The component need *not* be declared on this entity: querying a component
|
|
46
|
-
# the entity never declares is
|
|
59
|
+
# the entity never declares is valid and matches any stored rows (RFC-0010).
|
|
47
60
|
#
|
|
48
61
|
# @example Filtering by a component's attributes
|
|
49
62
|
# Post.with_component(PublishState, state: "published")
|
|
@@ -51,16 +64,95 @@ module EcsRails
|
|
|
51
64
|
# @example Chaining — each call ANDs
|
|
52
65
|
# Post.with_component(Title).with_component(Body).order(created_at: :desc)
|
|
53
66
|
#
|
|
67
|
+
# @example Filtering by slot (RFC-0014) — `prefix:` is sugar for `slot:`
|
|
68
|
+
# User.with_component(Address, prefix: :business, region: "WA")
|
|
69
|
+
# User.with_component(Address) # any slot
|
|
70
|
+
#
|
|
71
|
+
# @example Beyond equality (RFC-0018): a block, run as the component's scope
|
|
72
|
+
# Product.with_component(Money, prefix: :price) { where("amount_cents < ?", 5000) }
|
|
73
|
+
# Post.with_component(Tags, prefix: :topics) { tagged("ruby") } # a component scope
|
|
74
|
+
# Post.with_component(SearchVector) { matching("composable") }
|
|
75
|
+
#
|
|
76
|
+
# @example Beyond equality: `where`-style positional conditions
|
|
77
|
+
# Product.with_component(Rating, "stars >= ?", 4)
|
|
78
|
+
# Product.with_component(Rating, ["stars >= ?", 4])
|
|
79
|
+
#
|
|
54
80
|
# @param component_class [Class<EcsRails::Component>] a concrete component
|
|
81
|
+
# @param where_args [Array] optional `where`-style conditions — a SQL
|
|
82
|
+
# fragment with binds, or an array of them — applied to the component row
|
|
83
|
+
# @param prefix [Symbol, String, nil] a slot label, as the DSL spells it;
|
|
84
|
+
# equivalent to `slot: "label"`. Omitted: any slot.
|
|
55
85
|
# @param conditions [Hash] optional attribute equality the row must match
|
|
86
|
+
# @yield the component's relation, as `self` — call `where`, `where.not`,
|
|
87
|
+
# any of the component's own scopes; must return a relation
|
|
56
88
|
# @return [ActiveRecord::Relation] chainable, and still scoped to this
|
|
57
89
|
# entity's own `model` discriminator (ADR-0002)
|
|
58
90
|
# @raise [EcsRails::InvalidComponent] if `component_class` is not a concrete
|
|
59
91
|
# component, or is abstract and so owns no table
|
|
92
|
+
# @raise [ArgumentError] if the block returns something other than a
|
|
93
|
+
# relation of the component
|
|
60
94
|
# @see #without_component
|
|
95
|
+
# @see #order_by_component
|
|
61
96
|
# @see EcsRails::Relationships#with_related the relationship-name equivalent
|
|
62
|
-
def with_component(component_class, **conditions)
|
|
63
|
-
|
|
97
|
+
def with_component(component_class, *where_args, prefix: nil, **conditions, &block)
|
|
98
|
+
conditions = ecs_slot_conditions(prefix).merge(conditions)
|
|
99
|
+
all.where(ecs_component_exists_sql(component_class, conditions, negate: false,
|
|
100
|
+
where_args: where_args, refine: block))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Orders entities by a component's column, via a correlated scalar subquery
|
|
104
|
+
# in `ORDER BY` (RFC-0018). Entities without the row sort last, whatever the
|
|
105
|
+
# direction, unless `nulls: :first`.
|
|
106
|
+
#
|
|
107
|
+
# Composes with `with_component` and the rest of ActiveRecord; several calls
|
|
108
|
+
# order by several components in turn; `reorder` clears earlier orders as
|
|
109
|
+
# usual. The entity-model scope is untouched: the subquery correlates on
|
|
110
|
+
# this relation's rows and nothing else.
|
|
111
|
+
#
|
|
112
|
+
# @example Cheapest first, then top-rated
|
|
113
|
+
# Product.listed.order_by_component(Money, :amount_cents, prefix: :price)
|
|
114
|
+
# Product.listed.order_by_component(Rating, :stars, :desc)
|
|
115
|
+
#
|
|
116
|
+
# @example By a primary attribute — the column may be omitted
|
|
117
|
+
# Post.published.order_by_component(Counter, prefix: :likes, direction: :desc)
|
|
118
|
+
# Post.order_by_component(Text, prefix: :title) # alphabetical by title
|
|
119
|
+
#
|
|
120
|
+
# @param component_class [Class<EcsRails::Component>] a concrete component
|
|
121
|
+
# @param column [Symbol, String, nil] one of the component's columns; omit
|
|
122
|
+
# for the component's primary attribute
|
|
123
|
+
# @param positional_direction [Symbol] `:asc` or `:desc`; also accepted as
|
|
124
|
+
# `direction:`, which wins when both are given
|
|
125
|
+
# @param direction [Symbol, nil] the keyword spelling of the direction
|
|
126
|
+
# @param prefix [Symbol, String, nil] a slot label; omitted means the
|
|
127
|
+
# default slot `""` — ordering needs ONE row per entity, so "any slot" is
|
|
128
|
+
# not an option here
|
|
129
|
+
# @param nulls [Symbol] `:last` (default) or `:first` — where entities with
|
|
130
|
+
# no row go
|
|
131
|
+
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
132
|
+
# @raise [EcsRails::InvalidComponent] if `component_class` is not a concrete
|
|
133
|
+
# component, or is abstract and so owns no table
|
|
134
|
+
# @raise [ArgumentError] for an unknown column, a missing column on a
|
|
135
|
+
# component with no primary attribute, or a bad direction / nulls
|
|
136
|
+
# @see #with_component
|
|
137
|
+
def order_by_component(component_class, column = nil, positional_direction = :asc,
|
|
138
|
+
direction: nil, prefix: nil, nulls: :last)
|
|
139
|
+
ecs_validate_queryable_component!(component_class)
|
|
140
|
+
direction = (direction || positional_direction).to_sym
|
|
141
|
+
column = ecs_order_column!(component_class, column)
|
|
142
|
+
unless %i[asc desc].include?(direction)
|
|
143
|
+
raise ArgumentError, "order_by_component: direction must be :asc or :desc, got #{direction.inspect}"
|
|
144
|
+
end
|
|
145
|
+
unless %i[first last].include?(nulls)
|
|
146
|
+
raise ArgumentError, "order_by_component: nulls: must be :first or :last, got #{nulls.inspect}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
subquery = component_class
|
|
150
|
+
.where(slot: prefix.to_s)
|
|
151
|
+
.where(component_class.arel_table[:entity_id].eq(arel_table[primary_key]))
|
|
152
|
+
.select(component_class.arel_table[column])
|
|
153
|
+
.limit(1)
|
|
154
|
+
|
|
155
|
+
all.order(Arel.sql("(#{subquery.to_sql}) #{direction.to_s.upcase} NULLS #{nulls.to_s.upcase}"))
|
|
64
156
|
end
|
|
65
157
|
|
|
66
158
|
# Entities that have NO row for `component_class` (RFC-0010).
|
|
@@ -75,17 +167,29 @@ module EcsRails
|
|
|
75
167
|
# @example
|
|
76
168
|
# User.without_component(Avatar)
|
|
77
169
|
#
|
|
170
|
+
# @example No row in one slot (RFC-0014)
|
|
171
|
+
# User.without_component(PostalAddress, prefix: :business)
|
|
172
|
+
#
|
|
78
173
|
# @param component_class [Class<EcsRails::Component>] a concrete component
|
|
174
|
+
# @param prefix [Symbol, String, nil] a slot label; omitted means no row in
|
|
175
|
+
# any slot
|
|
79
176
|
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
80
177
|
# @raise [EcsRails::InvalidComponent] if `component_class` is not a concrete
|
|
81
178
|
# component, or is abstract and so owns no table
|
|
82
179
|
# @see #with_component
|
|
83
|
-
def without_component(component_class)
|
|
84
|
-
all.where(ecs_component_exists_sql(component_class,
|
|
180
|
+
def without_component(component_class, prefix: nil)
|
|
181
|
+
all.where(ecs_component_exists_sql(component_class, ecs_slot_conditions(prefix), negate: true))
|
|
85
182
|
end
|
|
86
183
|
|
|
87
184
|
private
|
|
88
185
|
|
|
186
|
+
# RFC-0014: `prefix: :business` reads as the DSL does and means
|
|
187
|
+
# `slot: "business"`. The slot is an ordinary column, so this is the only
|
|
188
|
+
# query surface slots need — pure spelling.
|
|
189
|
+
def ecs_slot_conditions(prefix)
|
|
190
|
+
prefix.nil? ? {} : { slot: prefix.to_s }
|
|
191
|
+
end
|
|
192
|
+
|
|
89
193
|
# Builds the `EXISTS (...)` / `NOT EXISTS (...)` fragment for one component.
|
|
90
194
|
#
|
|
91
195
|
# The subquery is built from `component_class.where(conditions)` so that
|
|
@@ -95,14 +199,23 @@ module EcsRails
|
|
|
95
199
|
# as properly quoted identifiers against the OUTER entities table — never a
|
|
96
200
|
# hand-built string.
|
|
97
201
|
#
|
|
202
|
+
# `where_args` (RFC-0018) go through the component relation's own `where`,
|
|
203
|
+
# so binds are sanitised exactly as in any `where("x > ?", n)`. `refine` is
|
|
204
|
+
# the block form: instance_exec'd on the component relation so `where`,
|
|
205
|
+
# `where.not` and the component's scopes read naturally, and checked to
|
|
206
|
+
# return a relation of the component — anything else is a mistake that
|
|
207
|
+
# would otherwise silently drop the refinement.
|
|
208
|
+
#
|
|
98
209
|
# `arel_table` here is the entity class's own table (`entities`); the outer
|
|
99
210
|
# query the fragment is appended to selects from that same table, so `id`
|
|
100
211
|
# correlates to each candidate entity row.
|
|
101
|
-
def ecs_component_exists_sql(component_class, conditions, negate:)
|
|
212
|
+
def ecs_component_exists_sql(component_class, conditions, negate:, where_args: [], refine: nil)
|
|
102
213
|
ecs_validate_queryable_component!(component_class)
|
|
103
214
|
|
|
104
|
-
subquery = component_class
|
|
105
|
-
|
|
215
|
+
subquery = component_class.where(conditions)
|
|
216
|
+
subquery = subquery.where(*where_args) unless where_args.empty?
|
|
217
|
+
subquery = ecs_refine_component_scope(component_class, subquery, refine) if refine
|
|
218
|
+
subquery = subquery
|
|
106
219
|
.where(component_class.arel_table[:entity_id].eq(arel_table[primary_key]))
|
|
107
220
|
.select("1")
|
|
108
221
|
|
|
@@ -110,12 +223,45 @@ module EcsRails
|
|
|
110
223
|
"#{keyword} (#{subquery.to_sql})"
|
|
111
224
|
end
|
|
112
225
|
|
|
226
|
+
# The block form of `with_component`: run as the component's relation, and
|
|
227
|
+
# its result checked, because a block that returns e.g. an Integer or some
|
|
228
|
+
# other class's relation would otherwise be dropped on the floor.
|
|
229
|
+
def ecs_refine_component_scope(component_class, scope, block)
|
|
230
|
+
refined = scope.instance_exec(&block)
|
|
231
|
+
unless refined.is_a?(ActiveRecord::Relation) && refined.klass == component_class
|
|
232
|
+
raise ArgumentError,
|
|
233
|
+
"with_component(#{component_class.name}) { ... } must return a relation of " \
|
|
234
|
+
"#{component_class.name} (got #{refined.class}); call where/scopes on the block's self"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
refined
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The column to order by: the one given, validated against the component's
|
|
241
|
+
# real columns (it is interpolated into SQL, so only a known column may pass),
|
|
242
|
+
# or the component's primary attribute when none is given.
|
|
243
|
+
def ecs_order_column!(component_class, column)
|
|
244
|
+
column = column.nil? ? component_class.primary : column.to_sym
|
|
245
|
+
if column.nil?
|
|
246
|
+
raise ArgumentError,
|
|
247
|
+
"order_by_component(#{component_class.name}): name a column — #{component_class.name} " \
|
|
248
|
+
"declares no primary attribute"
|
|
249
|
+
end
|
|
250
|
+
unless component_class.column_names.include?(column.to_s)
|
|
251
|
+
raise ArgumentError,
|
|
252
|
+
"order_by_component: #{component_class.name} has no column #{column.inspect} " \
|
|
253
|
+
"(columns: #{component_class.column_names.join(', ')})"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
column
|
|
257
|
+
end
|
|
258
|
+
|
|
113
259
|
# A queryable component is any concrete EcsRails::Component. Unlike the
|
|
114
260
|
# presence API (RFC-0009), it need NOT be declared on this entity: querying
|
|
115
|
-
# `Post.with_component(Avatar)`
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
261
|
+
# `Post.with_component(Avatar)` is valid even without that declaration. It
|
|
262
|
+
# matches stored rows, if any (RFC-0010), without consulting the registry.
|
|
263
|
+
# Anything that is not a concrete component raises InvalidComponent before
|
|
264
|
+
# any database work.
|
|
119
265
|
def ecs_validate_queryable_component!(component_class)
|
|
120
266
|
unless component_class.is_a?(Class) && component_class < EcsRails::Component
|
|
121
267
|
raise InvalidComponent,
|