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,405 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module EcsRails
6
+ # The catalogue: standard components that ship in the gem (ADR-0018).
7
+ #
8
+ # Each catalogue component is a module in this namespace — `Catalogue::Money`,
9
+ # `Catalogue::Address` — carrying its behaviour, its validations and a
10
+ # **schema declaration** the generators read. A host application owns the
11
+ # constant and includes the module into a one-line class:
12
+ #
13
+ # class Money < ApplicationComponent
14
+ # include EcsRails::Catalogue::Money
15
+ # end
16
+ #
17
+ # `rails g ecs_rails:install` writes those classes and ONE migration creating
18
+ # every catalogue table, so that after install and one `db:migrate` an
19
+ # application is built from the catalogue without another migration. The
20
+ # tables exist; a slot names the role (`component Text, prefix: :title`).
21
+ # `rails g ecs_rails:upgrade` diffs these declarations against the database
22
+ # when the gem is upgraded.
23
+ #
24
+ # Components are grouped into **sets** so a prototype need not carry every
25
+ # table: `core` (the default), `commerce`, and `social` / `saas` (named,
26
+ # empty in 0.3.0). See ADR-0018 §5 for the shelf.
27
+ #
28
+ # The one rule on names: never shadow a Ruby core constant (`Date`, `Time`,
29
+ # `Set`, `Range`) — hence `CalendarDate`. Where a name collides with a gem the
30
+ # application uses (`Money`), the one-line class is the remedy:
31
+ # `class Price < ApplicationComponent; include EcsRails::Catalogue::Money; end`.
32
+ module Catalogue
33
+ # A recorded schema declaration: the columns a catalogue table has beyond
34
+ # the ones every component table has (`id`, `entity_id`, `slot`, timestamps),
35
+ # plus any extra indexes and foreign keys.
36
+ #
37
+ # One declaration, two outputs. {#apply} creates the table on a live
38
+ # connection — the gem's own test schema is built this way — and {#to_ruby}
39
+ # renders the equivalent migration source for the install and upgrade
40
+ # generators. Because both read the same recording, the migration a user
41
+ # runs and the table the gem was tested against cannot drift (ADR-0018:
42
+ # "the schema declaration is the single source of truth for the table").
43
+ class Schema
44
+ # A declared column: `type` (Symbol, an ActiveRecord column type), `name`
45
+ # (Symbol) and `options` (Hash — `default:`, `null:`, `limit:`, `array:`, ...).
46
+ Column = Struct.new(:type, :name, :options)
47
+
48
+ # A declared index beyond the standard `(entity_id, slot)` one: `columns`
49
+ # (Array<Symbol>) and `options` (Hash — `unique:`, `using:`, `where:`, `name:`).
50
+ Index = Struct.new(:columns, :options)
51
+
52
+ # A declared foreign key to `entities` beyond the cascading `entity_id`
53
+ # one: `column` (Symbol) and `on_delete` (`:cascade` or `:nullify`).
54
+ ForeignKey = Struct.new(:column, :on_delete)
55
+
56
+ # The column types a declaration may use. Kept to what every supported
57
+ # PostgreSQL has and ActiveRecord maps natively, so a catalogue table
58
+ # never needs an extension the install migration did not enable.
59
+ COLUMN_TYPES = %i[
60
+ string text integer bigint float decimal boolean date datetime uuid jsonb tsvector
61
+ ].freeze
62
+
63
+ # The recorder the `schema` block yields as `t`. It answers the column
64
+ # types like a migration's table definition does, and records rather than
65
+ # executes.
66
+ class Recorder
67
+ def initialize(schema)
68
+ @schema = schema
69
+ end
70
+
71
+ COLUMN_TYPES.each do |type|
72
+ define_method(type) do |name, **options|
73
+ @schema.columns << Column.new(type, name.to_sym, options)
74
+ end
75
+ end
76
+
77
+ # An index beyond the `(entity_id, slot)` one every table gets.
78
+ #
79
+ # @param columns [Array<Symbol>, Symbol]
80
+ # @param options [Hash] `unique:`, `using:`, `where:`, `name:`
81
+ # @return [void]
82
+ def index(columns, **options)
83
+ @schema.indexes << Index.new(Array(columns).map(&:to_sym), options)
84
+ end
85
+
86
+ # A foreign key to `entities` beyond the cascading `entity_id` one.
87
+ #
88
+ # @param column [Symbol] the referencing column
89
+ # @param on_delete [Symbol] `:cascade` or `:nullify`
90
+ # @return [void]
91
+ def foreign_key(column, on_delete:)
92
+ @schema.foreign_keys << ForeignKey.new(column.to_sym, on_delete)
93
+ end
94
+ end
95
+
96
+ # @return [Array<Column>]
97
+ attr_reader :columns
98
+ # @return [Array<Index>]
99
+ attr_reader :indexes
100
+ # @return [Array<ForeignKey>]
101
+ attr_reader :foreign_keys
102
+
103
+ # @yieldparam t [Recorder] the recorder to declare columns and indexes on
104
+ def initialize
105
+ @columns = []
106
+ @indexes = []
107
+ @foreign_keys = []
108
+ yield Recorder.new(self) if block_given?
109
+ end
110
+
111
+ # Creates the table on `target` — anything answering `create_table`,
112
+ # `add_index` and `add_foreign_key`: an `ActiveRecord::Schema.define`
113
+ # block's receiver, a migration, or a connection.
114
+ #
115
+ # Every catalogue table gets the invariants of architecture.md §2 — UUID
116
+ # primary key, non-null `entity_id`, `slot` defaulting to `""`, a unique
117
+ # index on `(entity_id, slot)`, a cascading foreign key to `entities` —
118
+ # and then whatever the declaration added.
119
+ #
120
+ # @param target [#create_table] where to create it
121
+ # @param table_name [String, Symbol] the table to create
122
+ # @param force [Boolean, Symbol] `create_table`'s `force:` — `:cascade`
123
+ # drops an existing table first; a test schema wants that, a migration
124
+ # never does
125
+ # @return [void]
126
+ def apply(target, table_name:, force: false)
127
+ table_name = table_name.to_sym
128
+ target.create_table(table_name, id: :uuid, default: -> { "gen_random_uuid()" }, force: force) do |t|
129
+ t.uuid :entity_id, null: false
130
+ t.string :slot, null: false, default: ""
131
+ columns.each { |column| t.public_send(column.type, column.name, **column.options) }
132
+ t.timestamps
133
+ end
134
+ target.add_index(table_name, %i[entity_id slot], unique: true)
135
+ indexes.each { |index| target.add_index(table_name, index.columns, **index.options) }
136
+ target.add_foreign_key(table_name, :entities, column: :entity_id, on_delete: :cascade)
137
+ foreign_keys.each do |fk|
138
+ target.add_foreign_key(table_name, :entities, column: fk.column, on_delete: fk.on_delete)
139
+ end
140
+ end
141
+
142
+ # The migration source that does what {#apply} does, for the generators'
143
+ # templates. Indented to sit inside a migration's `def change`.
144
+ #
145
+ # @param table_name [String, Symbol]
146
+ # @param indent [Integer] leading spaces
147
+ # @return [String]
148
+ def to_ruby(table_name:, indent: 4)
149
+ pad = " " * indent
150
+ lines = []
151
+ lines << "#{pad}create_table :#{table_name}, id: :uuid, default: -> { \"gen_random_uuid()\" } do |t|"
152
+ lines << "#{pad} t.uuid :entity_id, null: false"
153
+ lines << "#{pad} t.string :slot, null: false, default: \"\""
154
+ columns.each do |column|
155
+ lines << "#{pad} t.#{column.type} :#{column.name}#{render_options(column.options)}"
156
+ end
157
+ lines << "#{pad} t.timestamps"
158
+ lines << "#{pad}end"
159
+ lines << "#{pad}add_index :#{table_name}, [:entity_id, :slot], unique: true"
160
+ indexes.each do |index|
161
+ cols = index.columns.size == 1 ? ":#{index.columns.first}" : "[#{index.columns.map { |c| ":#{c}" }.join(', ')}]"
162
+ lines << "#{pad}add_index :#{table_name}, #{cols}#{render_options(index.options)}"
163
+ end
164
+ lines << "#{pad}add_foreign_key :#{table_name}, :entities, column: :entity_id, on_delete: :cascade"
165
+ foreign_keys.each do |fk|
166
+ lines << "#{pad}add_foreign_key :#{table_name}, :entities, column: :#{fk.column}, on_delete: :#{fk.on_delete}"
167
+ end
168
+ lines.join("\n")
169
+ end
170
+
171
+ # Additive migration source after verifying the live catalogue structure.
172
+ # Existing incompatible definitions raise before any migration is written.
173
+ #
174
+ # @param table_name [String, Symbol]
175
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
176
+ # @param slot_upgrade [Boolean] an earlier migration will add the slot
177
+ # @param indent [Integer]
178
+ # @return [String] empty when every required structure is current
179
+ # @raise [SchemaMismatch] an explicit reviewed repair is required
180
+ def to_ruby_diff(table_name:, connection:, slot_upgrade: false, indent: 4)
181
+ SchemaDiff.new(self, table_name: table_name, connection: connection,
182
+ slot_upgrade: slot_upgrade).to_ruby(indent: indent)
183
+ end
184
+
185
+ private
186
+
187
+ # `, default: "", null: false` — options as they would be typed. Symbols,
188
+ # strings, numbers, booleans, nil and arrays render with `inspect`, which
189
+ # is exactly their Ruby literal.
190
+ def render_options(options)
191
+ return "" if options.empty?
192
+
193
+ ", " + options.map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
194
+ end
195
+ end
196
+
197
+ # Extended into each catalogue component module. Gives the module the
198
+ # declaration DSL — `table`, `set`, `schema`, `included` — registers it in
199
+ # the catalogue, and makes `include EcsRails::Catalogue::Money` into a class
200
+ # set the table name and run the module's `included` block, the way an
201
+ # ActiveSupport::Concern would.
202
+ #
203
+ # module EcsRails::Catalogue::Money
204
+ # extend EcsRails::Catalogue::Definition
205
+ #
206
+ # table "monies"
207
+ # set :commerce
208
+ # schema do |t|
209
+ # t.integer :amount_cents, null: false, default: 0
210
+ # t.string :currency, null: false, default: "USD", limit: 3
211
+ # end
212
+ #
213
+ # included do
214
+ # validates :currency, format: /\A[A-Z]{3}\z/
215
+ # end
216
+ #
217
+ # def +(other) = ...
218
+ # end
219
+ #
220
+ # Not an ActiveSupport::Concern, deliberately: a concern's `included` block
221
+ # has no handle on *which* concern is being included, and the table name is
222
+ # per catalogue component. `Module#included(base)` does.
223
+ module Definition
224
+ # Registers the module with the catalogue as it is defined.
225
+ #
226
+ # @api private
227
+ def self.extended(mod)
228
+ Catalogue.register(mod)
229
+ end
230
+
231
+ # The module's short name, as a Symbol: `:money`, `:calendar_date`. Also
232
+ # the default class name's underscore form and the file name install
233
+ # writes.
234
+ #
235
+ # @return [Symbol]
236
+ def catalogue_name
237
+ name.demodulize.underscore.to_sym
238
+ end
239
+
240
+ # The default app class name: `"Money"`, `"CalendarDate"`.
241
+ #
242
+ # @return [String]
243
+ def class_name
244
+ name.demodulize
245
+ end
246
+
247
+ # Declares or reads the table name. Set once in the module body; the
248
+ # including class may still override it (`self.table_name = ...`) after
249
+ # the include, which is how an app whose `emails` table is already taken
250
+ # keeps the catalogue component — the app owns the table name too.
251
+ #
252
+ # @param name [String, nil]
253
+ # @return [String]
254
+ def table(name = nil)
255
+ @table = name.to_s if name
256
+ @table || class_name.underscore.pluralize
257
+ end
258
+
259
+ # Declares or reads the set. `:core` when not declared.
260
+ #
261
+ # @param name [Symbol, nil]
262
+ # @return [Symbol]
263
+ def set(name = nil)
264
+ @set = name.to_sym if name
265
+ @set || :core
266
+ end
267
+
268
+ # Declares (or reads) the component's primary attribute — the one a
269
+ # labelled slot stands for, so `component Text, prefix: :title` also
270
+ # delegates `post.title` (see EcsRails::Slots::Component::ClassMethods#primary).
271
+ # Applied to the including class at include time.
272
+ #
273
+ # @param name [Symbol, nil]
274
+ # @return [Symbol, nil]
275
+ def primary_attribute(name = nil)
276
+ @primary_attribute = name.to_sym if name
277
+ @primary_attribute
278
+ end
279
+
280
+ # Declares (with a block) or reads the schema.
281
+ #
282
+ # @yieldparam t [Schema::Recorder]
283
+ # @return [Schema]
284
+ def schema(&block)
285
+ @schema = Schema.new(&block) if block
286
+ @schema ||= Schema.new
287
+ end
288
+
289
+ # With a block: records what to run in the including class — validations,
290
+ # `slot_option`s, associations — exactly as a concern's `included` does.
291
+ # Without one, it is Ruby's own include hook: sets the table name and runs
292
+ # that block.
293
+ #
294
+ # @param base [Class, nil]
295
+ # @return [void]
296
+ def included(base = nil, &block)
297
+ if block
298
+ @included_block = block
299
+ return
300
+ end
301
+
302
+ base.table_name = table
303
+ base.primary(primary_attribute) if primary_attribute
304
+ base.class_eval(&@included_block) if @included_block
305
+ end
306
+ end
307
+
308
+ class << self
309
+ # Every catalogue component, in definition (file) order.
310
+ #
311
+ # @return [Array<Module>]
312
+ def components
313
+ @components ||= []
314
+ end
315
+
316
+ # Records a catalogue module as it is defined ({Definition.extended}).
317
+ #
318
+ # @param mod [Module]
319
+ # @return [void]
320
+ # @api private
321
+ def register(mod)
322
+ components << mod unless components.include?(mod)
323
+ end
324
+
325
+ # The catalogue component named `name`, by catalogue name, class name or
326
+ # table.
327
+ #
328
+ # @param name [Symbol, String]
329
+ # @return [Module, nil]
330
+ def [](name)
331
+ key = name.to_s
332
+ components.find do |mod|
333
+ mod.catalogue_name.to_s == key || mod.class_name == key || mod.table == key
334
+ end
335
+ end
336
+
337
+ # The set names, in the order they first appear.
338
+ #
339
+ # @return [Array<Symbol>]
340
+ def sets
341
+ (components.map(&:set) + %i[core commerce social saas]).uniq
342
+ end
343
+
344
+ # The components in the given sets, in catalogue order.
345
+ #
346
+ # @param names [Array<Symbol, String>] set names; `:all` for everything
347
+ # @return [Array<Module>]
348
+ # @raise [ArgumentError] for an unknown set
349
+ def in_sets(*names)
350
+ names = names.flatten.map(&:to_sym)
351
+ return components.dup if names.include?(:all)
352
+
353
+ unknown = names - sets
354
+ raise ArgumentError, "unknown catalogue set#{'s' if unknown.size > 1}: #{unknown.join(', ')}; " \
355
+ "known: #{sets.join(', ')}" unless unknown.empty?
356
+
357
+ components.select { |mod| names.include?(mod.set) }
358
+ end
359
+
360
+ # Creates every catalogue table (or those of the given sets) on `target`.
361
+ # The gem's test schema is built this way.
362
+ #
363
+ # @param target [#create_table]
364
+ # @param sets [Array<Symbol>, Symbol] default every set
365
+ # @param except [Array<Module, Symbol>] components to skip
366
+ # @param force [Boolean, Symbol] passed to {Schema#apply}
367
+ # @return [void]
368
+ def create_tables(target, sets: :all, except: [], force: false)
369
+ skipped = except.map { |c| c.is_a?(Module) ? c : self[c] }
370
+ in_sets(sets).each do |mod|
371
+ next if skipped.include?(mod)
372
+
373
+ mod.schema.apply(target, table_name: mod.table, force: force)
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end
379
+
380
+ require "ecs_rails/catalogue/schema_diff"
381
+ require "ecs_rails/catalogue/relationship"
382
+ require "ecs_rails/catalogue/marker"
383
+ require "ecs_rails/catalogue/name"
384
+ require "ecs_rails/catalogue/email"
385
+ require "ecs_rails/catalogue/password"
386
+ require "ecs_rails/catalogue/phone"
387
+ require "ecs_rails/catalogue/address"
388
+ require "ecs_rails/catalogue/geolocation"
389
+ require "ecs_rails/catalogue/link"
390
+ require "ecs_rails/catalogue/text"
391
+ require "ecs_rails/catalogue/identifier"
392
+ require "ecs_rails/catalogue/counter"
393
+ require "ecs_rails/catalogue/rating"
394
+ require "ecs_rails/catalogue/timestamp"
395
+ require "ecs_rails/catalogue/calendar_date"
396
+ require "ecs_rails/catalogue/period"
397
+ require "ecs_rails/catalogue/position"
398
+ require "ecs_rails/catalogue/state"
399
+ require "ecs_rails/catalogue/tags"
400
+ require "ecs_rails/catalogue/search_vector"
401
+ require "ecs_rails/catalogue/discard"
402
+ require "ecs_rails/catalogue/image"
403
+ require "ecs_rails/catalogue/role"
404
+ require "ecs_rails/catalogue/token"
405
+ require "ecs_rails/catalogue/money"
@@ -23,8 +23,9 @@ module EcsRails
23
23
  # Scopes, validations, callbacks and associations all work as normal: this is
24
24
  # a plain AR model that happens to hang off an entity.
25
25
  #
26
- # The one sanctioned exception to "knows nothing about entity subclasses" is a
27
- # relationship component's `class_name:` — see ADR-0006.
26
+ # Relationship target types are supplied by the owner's declaration as slot
27
+ # options; the shared Relationship component does not name domain classes
28
+ # (ADR-0017).
28
29
  class Component < ActiveRecord::Base
29
30
  self.abstract_class = true
30
31
 
@@ -34,13 +35,18 @@ module EcsRails
34
35
  # virtual instance (architecture.md §3).
35
36
  include Lazy::Component
36
37
 
38
+ # Labelled components (RFC-0014 / ADR-0015), from the component's side: the
39
+ # slot-aware reader name, and the `slot_option` DSL through which a
40
+ # declaration site configures this component per slot.
41
+ include Slots::Component
42
+
37
43
  # Every component belongs to exactly one entity (architecture.md §1).
38
44
  #
39
45
  # The association targets the abstract ApplicationEntity, which has no table
40
46
  # of its own — but EcsRails::Entity sets table_name to "entities", so the
41
47
  # generated query is an ordinary select against that table. The loaded row's
42
48
  # `model` column then decides which subclass to instantiate, via
43
- # Entity.discriminate_class_for_record. That is what makes `email.entity`
49
+ # Entity.instantiate_instance_of (ADR-0008's amendment). This makes `email.entity`
44
50
  # return a User rather than an ApplicationEntity (RFC-0003, ADR-0008).
45
51
  #
46
52
  # `optional: false` is explicit rather than inherited from
@@ -60,9 +66,9 @@ module EcsRails
60
66
  # destructive; the DB layer already has the right one.
61
67
  belongs_to :entity, class_name: "ApplicationEntity", optional: false
62
68
 
63
- # The unique index on entity_id (ADR-0005) is the real enforcement, and a
64
- # uniqueness validation here would cost a SELECT on every save while still
65
- # racing. RFC-0003 asserts RecordNotUnique from the database, so the guard
66
- # is left where it is enforced.
69
+ # The unique index on (entity_id, slot) (ADR-0005, generalised by ADR-0015)
70
+ # is the real enforcement, and a uniqueness validation here would cost a
71
+ # SELECT on every save while still racing. RFC-0003 asserts RecordNotUnique
72
+ # from the database, so the guard is left where it is enforced.
67
73
  end
68
74
  end
@@ -1,31 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EcsRails
4
- # Generator configuration for the directory layout (ADR-0010).
4
+ # Configuration: the generator directory layout (ADR-0010) and the name of
5
+ # the host app's `Relationship` component (ADR-0017).
5
6
  #
6
- # The gem RUNTIME never reads this: entities and components are keyed by class
7
- # name (RFC-0002) and by the `model` discriminator (ADR-0002), so the registry
8
- # does not care where a class file lives. This object exists purely so the
9
- # generators (RFC-0008) know where to write, and so the initializer they emit
10
- # can echo the chosen path.
7
+ # The layout half is generator-only: entities and components are keyed by
8
+ # class name (RFC-0002) and by the `model` discriminator (ADR-0002), so the
9
+ # registry does not care where a class file lives; the generators (RFC-0008)
10
+ # read it to know where to write, and the initializer they emit echoes it.
11
11
  #
12
- # `entities_path` is the single knob. Components always live in a `components`
13
- # subdirectory of it, which is why `components_path` is derived rather than
14
- # separately settable — ADR-0010 deliberately exposes one path, not two.
15
- # @example Restoring the pre-ADR-0010 single-directory layout
12
+ # `entities_path` is the single layout knob. Components always live in a
13
+ # `components` subdirectory of it, which is why `components_path` is derived
14
+ # rather than separately settable — ADR-0010 deliberately exposes one path,
15
+ # not two.
16
+ #
17
+ # `relationship_class_name` and `marker_class_name` ARE read at runtime — by
18
+ # `relates_to` and `marker`, on every call, so a reloaded constant is picked
19
+ # up. They exist for the one app whose domain already has a `Relationship` or
20
+ # a `Marker`; everyone else leaves the defaults.
21
+ # @example Placing entities under app/models (components remain in a subdirectory)
16
22
  # # config/initializers/ecs_rails.rb
17
23
  # EcsRails.configure { |config| config.entities_path = "app/models" }
18
24
  #
19
25
  # @see EcsRails.configure
20
26
  class Config
21
27
  # Entities land here; the default is the ADR-0010 layout. Set it to
22
- # `"app/models"` to restore the pre-ADR-0010 single-directory layout.
28
+ # `"app/models"` to place entities there and components in its subdirectory.
23
29
  #
24
30
  # @return [String] the directory entities are generated into
25
31
  attr_accessor :entities_path
26
32
 
33
+ # The host app's catalogue component that backs every `relates_to`
34
+ # (EcsRails::Catalogue::Relationship, ADR-0017). `rails g ecs_rails:install`
35
+ # writes it as `Relationship`; rename here only if that constant is taken.
36
+ #
37
+ # @return [String] the class name, resolved by `constantize` at each use
38
+ attr_accessor :relationship_class_name
39
+
40
+ # The host app's catalogue component that backs every `marker`
41
+ # (EcsRails::Catalogue::Marker, ADR-0018 §4). `rails g ecs_rails:install`
42
+ # writes it as `Marker`; rename here only if that constant is taken.
43
+ #
44
+ # @return [String] the class name, resolved by `constantize` at each use
45
+ attr_accessor :marker_class_name
46
+
27
47
  def initialize
28
48
  @entities_path = "app/entities"
49
+ @relationship_class_name = "Relationship"
50
+ @marker_class_name = "Marker"
29
51
  end
30
52
 
31
53
  # Components live in a `components` subdirectory of the entities path. The