ecs_on_rails 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +304 -0
  3. data/README.md +365 -50
  4. data/lib/ecs_rails/catalogue/address.rb +39 -0
  5. data/lib/ecs_rails/catalogue/calendar_date.rb +27 -0
  6. data/lib/ecs_rails/catalogue/counter.rb +46 -0
  7. data/lib/ecs_rails/catalogue/discard.rb +36 -0
  8. data/lib/ecs_rails/catalogue/email.rb +35 -0
  9. data/lib/ecs_rails/catalogue/geolocation.rb +53 -0
  10. data/lib/ecs_rails/catalogue/identifier.rb +23 -0
  11. data/lib/ecs_rails/catalogue/image.rb +28 -0
  12. data/lib/ecs_rails/catalogue/link.rb +27 -0
  13. data/lib/ecs_rails/catalogue/marker.rb +38 -0
  14. data/lib/ecs_rails/catalogue/money.rb +86 -0
  15. data/lib/ecs_rails/catalogue/name.rb +37 -0
  16. data/lib/ecs_rails/catalogue/password.rb +50 -0
  17. data/lib/ecs_rails/catalogue/period.rb +57 -0
  18. data/lib/ecs_rails/catalogue/phone.rb +30 -0
  19. data/lib/ecs_rails/catalogue/position.rb +18 -0
  20. data/lib/ecs_rails/catalogue/rating.rb +25 -0
  21. data/lib/ecs_rails/catalogue/relationship.rb +159 -0
  22. data/lib/ecs_rails/catalogue/role.rb +23 -0
  23. data/lib/ecs_rails/catalogue/schema_diff.rb +198 -0
  24. data/lib/ecs_rails/catalogue/search_vector.rb +38 -0
  25. data/lib/ecs_rails/catalogue/state.rb +71 -0
  26. data/lib/ecs_rails/catalogue/tags.rb +64 -0
  27. data/lib/ecs_rails/catalogue/text.rb +29 -0
  28. data/lib/ecs_rails/catalogue/timestamp.rb +34 -0
  29. data/lib/ecs_rails/catalogue/token.rb +77 -0
  30. data/lib/ecs_rails/catalogue.rb +405 -0
  31. data/lib/ecs_rails/component.rb +13 -7
  32. data/lib/ecs_rails/config.rb +33 -11
  33. data/lib/ecs_rails/dsl.rb +463 -97
  34. data/lib/ecs_rails/entity.rb +21 -5
  35. data/lib/ecs_rails/errors.rb +3 -0
  36. data/lib/ecs_rails/inverses.rb +398 -0
  37. data/lib/ecs_rails/lazy.rb +86 -23
  38. data/lib/ecs_rails/markers.rb +197 -0
  39. data/lib/ecs_rails/preloading.rb +19 -8
  40. data/lib/ecs_rails/presence.rb +83 -30
  41. data/lib/ecs_rails/querying.rb +158 -12
  42. data/lib/ecs_rails/railtie.rb +10 -0
  43. data/lib/ecs_rails/registry.rb +66 -21
  44. data/lib/ecs_rails/relationships.rb +168 -160
  45. data/lib/ecs_rails/slots.rb +183 -0
  46. data/lib/ecs_rails/validations.rb +3 -5
  47. data/lib/ecs_rails/version.rb +2 -2
  48. data/lib/ecs_rails.rb +7 -3
  49. data/lib/generators/ecs_rails/catalogue_options.rb +66 -0
  50. data/lib/generators/ecs_rails/component/templates/migration.rb.tt +9 -5
  51. data/lib/generators/ecs_rails/component/templates/model.rb.tt +2 -1
  52. data/lib/generators/ecs_rails/entity/entity_generator.rb +93 -0
  53. data/lib/generators/ecs_rails/entity/templates/entity.rb.tt +9 -0
  54. data/lib/generators/ecs_rails/install/install_generator.rb +29 -12
  55. data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +3 -2
  56. data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +2 -2
  57. data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +2 -2
  58. data/lib/generators/ecs_rails/install/templates/install_migration.rb.tt +38 -0
  59. data/lib/generators/ecs_rails/templates/catalogue_class.rb.tt +10 -0
  60. data/lib/generators/ecs_rails/upgrade/templates/catalogue_migration.rb.tt +18 -0
  61. data/lib/generators/ecs_rails/upgrade/templates/markers_migration.rb.tt +31 -0
  62. data/lib/generators/ecs_rails/upgrade/templates/relationships_migration.rb.tt +33 -0
  63. data/lib/generators/ecs_rails/upgrade/templates/slots_migration.rb.tt +23 -0
  64. data/lib/generators/ecs_rails/upgrade/upgrade_generator.rb +281 -0
  65. metadata +61 -4
  66. data/lib/generators/ecs_rails/install/templates/migration.rb.tt +0 -20
  67. data/lib/generators/ecs_rails/relationship/relationship_generator.rb +0 -99
  68. data/lib/generators/ecs_rails/relationship/templates/migration.rb.tt +0 -32
@@ -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
@@ -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
- declared = components
65
- targets = component_classes.empty? ? declared : component_classes
66
- targets.each { |component_class| ecs_validate_declared_component!(component_class, declared) }
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 { |component_class| ecs_component_association_name(component_class) })
76
+ all.preload(*targets.map(&:reader_name))
69
77
  end
70
78
 
71
79
  private
72
80
 
73
- # The has_one name for a component — the same derivation the DSL uses when it
74
- # defines the association (EcsRails::DSL#define_component_association).
75
- def ecs_component_association_name(component_class)
76
- component_class.model_name.singular.to_sym
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
@@ -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
- # @param component_class [Class<EcsRails::Component>] a component this
63
- # entity declares
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 = ecs_presence_reader(component_class)
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>] a component this
106
- # entity declares
107
- # @return [Boolean] whether a persisted row exists
108
- # @raise [EcsRails::InvalidComponent] if the entity does not declare it
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
- name = ecs_presence_reader(component_class)
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. A merely
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
- component_class.where(entity_id: id).exists?
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>] a component this
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 = ecs_presence_reader(component_class)
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 class to its reader name, or raises.
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); anything else — an undeclared component, or a class
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 declared set already accounts for inheritance
164
- # (DSL#components walks the ancestry), so a subclass sees its parents'
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 ecs_presence_reader(component_class)
167
- unless self.class.components.include?(component_class)
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
- "#{component_class.inspect} is not a component of #{self.class.name}"
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
- component_class.model_name.singular.to_sym
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
@@ -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 a valid, always-empty query, not an error.
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
- all.where(ecs_component_exists_sql(component_class, conditions, negate: false))
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, {}, negate: true))
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
- .where(conditions)
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)` when Post has no Avatar is a valid,
116
- # always-empty query, not an error (RFC-0010) — and keeps the DSL from
117
- # needing the registry. Anything that is not a concrete component raises
118
- # InvalidComponent, before any database work.
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,