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,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A WGS 84 coordinate, and when it was geocoded. Derived data: it exists to
6
+ # be filled by an entity-blind geocoding system, paired to an `Address` by
7
+ # sharing its slot (`component Address, prefix: :registered` +
8
+ # `component Geolocation, prefix: :registered`).
9
+ module Geolocation
10
+ extend Definition
11
+
12
+ table "geolocations"
13
+ schema do |t|
14
+ t.decimal :lat, default: nil, precision: 10, scale: 7
15
+ t.decimal :lng, default: nil, precision: 10, scale: 7
16
+ t.datetime :geocoded_at, default: nil
17
+ end
18
+
19
+ included do
20
+ validates :lat, numericality: { greater_than_or_equal_to: -90, less_than_or_equal_to: 90 }, allow_nil: true
21
+ validates :lng, numericality: { greater_than_or_equal_to: -180, less_than_or_equal_to: 180 }, allow_nil: true
22
+ end
23
+
24
+ # @return [Boolean] whether a coordinate has been set
25
+ def geocoded?
26
+ lat.present? && lng.present?
27
+ end
28
+
29
+ # @return [Array<BigDecimal>, nil] `[lat, lng]`, or nil when not geocoded
30
+ def coordinates
31
+ [lat, lng] if geocoded?
32
+ end
33
+
34
+ # Records a lookup's answer: sets the coordinate and stamps `geocoded_at`.
35
+ # A lookup that found nothing is recorded as `locate(nil, nil)` — the
36
+ # stamp still says the address was tried, so a system need not retry it
37
+ # until the address changes, and stale coordinates never outlive it.
38
+ #
39
+ # `at:` defaults to now. A geocoding system that decides staleness by
40
+ # comparing `geocoded_at` with the address's `updated_at` should pass
41
+ # the `updated_at` it read instead: an edit landing while the lookup runs
42
+ # is then still newer than the stamp, and the next run recomputes it.
43
+ #
44
+ # @param lat [Numeric, nil]
45
+ # @param lng [Numeric, nil]
46
+ # @param at [Time] the moment (or address version) the answer describes
47
+ # @return [void]
48
+ def locate(lat, lng, at: Time.current)
49
+ assign_attributes(lat: lat, lng: lng, geocoded_at: at)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # An identifier: sku, order_number, invoice_number, slug, an external id
6
+ # (`stripe_customer`) — one slot each, **unique per slot** at the database.
7
+ module Identifier
8
+ extend Definition
9
+
10
+ table "identifiers"
11
+ primary_attribute :value
12
+ schema do |t|
13
+ t.string :value, default: nil
14
+ t.index %i[slot value], unique: true
15
+ end
16
+
17
+ # @return [String] the value, or ""
18
+ def to_s
19
+ value.to_s
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # An image by URL with alt text: avatar, logo — one slot each. When Active
6
+ # Storage is loaded in the application the class also gains
7
+ # `has_one_attached :file`, so an upload can back the URL.
8
+ module Image
9
+ extend Definition
10
+
11
+ table "images"
12
+ schema do |t|
13
+ t.string :url, default: nil
14
+ t.string :alt, default: nil
15
+ end
16
+
17
+ included do
18
+ validates :url, format: { with: %r{\Ahttps?://\S+\z}, allow_blank: true }
19
+ has_one_attached :file if respond_to?(:has_one_attached)
20
+ end
21
+
22
+ # @return [Boolean]
23
+ def present_url?
24
+ url.present?
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A URL with a label: website, github, webhook — one slot each.
6
+ module Link
7
+ extend Definition
8
+
9
+ table "links"
10
+ schema do |t|
11
+ t.string :url, default: nil
12
+ t.string :label, default: nil
13
+ end
14
+
15
+ included do
16
+ validates :url, format: { with: %r{\Ahttps?://\S+\z}, allow_blank: true }
17
+ end
18
+
19
+ # @return [String, nil] the host part of the URL
20
+ def host
21
+ URI.parse(url).host if url.present?
22
+ rescue URI::InvalidURIError
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # The one component every marker is a row of (ADR-0018 §4).
6
+ #
7
+ # A marker carries no state: an entity *is* a moderator exactly when the row
8
+ # exists (ADR-0009). Under ADR-0018 all markers in an application share one
9
+ # `markers` table — `(id, entity_id, slot, timestamps)`, `UNIQUE (entity_id,
10
+ # slot)` — and the slot is the marker name. Declaring a marker is pure Ruby:
11
+ #
12
+ # class User < ApplicationEntity
13
+ # marker :moderator # slot "moderator" on the markers table
14
+ # end
15
+ #
16
+ # user.add(:moderator) # a row
17
+ # user.moderator? # => true
18
+ # user.remove(:moderator)
19
+ #
20
+ # The host application owns the constant and includes this concern, exactly
21
+ # as with {Relationship}:
22
+ #
23
+ # class Marker < ApplicationComponent
24
+ # include EcsRails::Catalogue::Marker
25
+ # end
26
+ #
27
+ # There is nothing else to it. Presence is {EcsRails::Presence::Entity}'s;
28
+ # the `marker` declaration ({EcsRails::Markers#marker}) is sugar over
29
+ # `component Marker, prefix: :moderator, delegate: false` that restores the
30
+ # bare `moderator?` predicate prefixing would otherwise take away.
31
+ module Marker
32
+ extend Definition
33
+
34
+ table "markers"
35
+ schema { |_t| } # no attributes: presence is the whole meaning
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # An amount of money: integer minor units and an ISO 4217 currency code —
6
+ # never a float. In the `commerce` set.
7
+ #
8
+ # product.price_money.amount_cents = 1999
9
+ # product.price_money.to_s # => "USD 19.99"
10
+ # total = line.price_money + other.price_money # same currency, or CurrencyMismatch
11
+ module Money
12
+ extend Definition
13
+
14
+ # Raised by arithmetic across currencies.
15
+ class CurrencyMismatch < EcsRails::Error; end
16
+
17
+ table "monies"
18
+ set :commerce
19
+ schema do |t|
20
+ t.integer :amount_cents, default: 0, null: false
21
+ t.string :currency, default: "USD", null: false, limit: 3
22
+ end
23
+
24
+ included do
25
+ validates :currency, format: { with: /\A[A-Z]{3}\z/ }
26
+ end
27
+
28
+ # @return [BigDecimal] the amount in major units
29
+ def amount
30
+ BigDecimal(amount_cents) / 100
31
+ end
32
+
33
+ # @param value [Numeric] major units
34
+ # @return [Integer] the stored cents
35
+ def amount=(value)
36
+ self.amount_cents = (BigDecimal(value.to_s) * 100).round.to_i
37
+ end
38
+
39
+ # @return [Boolean]
40
+ def zero?
41
+ amount_cents.zero?
42
+ end
43
+
44
+ # A new, unsaved instance holding the sum; same currency required.
45
+ #
46
+ # @param other [Money]
47
+ # @return [ActiveRecord::Base]
48
+ # @raise [CurrencyMismatch]
49
+ def +(other)
50
+ ecs_same_currency!(other)
51
+ self.class.new(amount_cents: amount_cents + other.amount_cents, currency: currency)
52
+ end
53
+
54
+ # A new, unsaved instance holding the difference; same currency required.
55
+ #
56
+ # @param other [Money]
57
+ # @return [ActiveRecord::Base]
58
+ # @raise [CurrencyMismatch]
59
+ def -(other)
60
+ ecs_same_currency!(other)
61
+ self.class.new(amount_cents: amount_cents - other.amount_cents, currency: currency)
62
+ end
63
+
64
+ # A new, unsaved instance scaled by `factor`, rounded to a cent.
65
+ #
66
+ # @param factor [Numeric]
67
+ # @return [ActiveRecord::Base]
68
+ def *(factor)
69
+ self.class.new(amount_cents: (amount_cents * factor).round, currency: currency)
70
+ end
71
+
72
+ # @return [String] e.g. "USD 19.99"
73
+ def to_s
74
+ format("%s %.2f", currency, amount)
75
+ end
76
+
77
+ private
78
+
79
+ def ecs_same_currency!(other)
80
+ return if other.currency == currency
81
+
82
+ raise CurrencyMismatch, "cannot combine #{currency} with #{other.currency}"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A person's name (schema.org `givenName` / `familyName` / `name`).
6
+ #
7
+ # `given` and `family` for names that split; `full` for the ones that do
8
+ # not — mononyms, or an order that is not given-then-family. An
9
+ # organisation's name is `Text` under slot `name`, not this.
10
+ #
11
+ # user.name.given = "Ada"; user.name.family = "Lovelace"
12
+ # user.name.to_s # => "Ada Lovelace"
13
+ # user.name.initials # => "AL"
14
+ module Name
15
+ extend Definition
16
+
17
+ table "names"
18
+ schema do |t|
19
+ t.string :given, default: nil
20
+ t.string :family, default: nil
21
+ t.string :full, default: nil
22
+ end
23
+
24
+ # The display form: `full` when set, else given and family joined.
25
+ #
26
+ # @return [String]
27
+ def to_s
28
+ full.presence || [given, family].compact_blank.join(" ")
29
+ end
30
+
31
+ # @return [String] the first letters of given and family, e.g. "AL"
32
+ def initials
33
+ [given, family].compact_blank.map { |part| part[0] }.join.upcase
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A password digest, via `has_secure_password` (Rails 8's generated
6
+ # authentication, from the catalogue). Requires the `bcrypt` gem in the
7
+ # application, as `has_secure_password` always does.
8
+ #
9
+ # user.password.password = "s3cret"; user.save! # stores a digest
10
+ # user.password.authenticate("s3cret") # => the component, or false
11
+ #
12
+ # A `Session` entity is `Token` + `Timestamp` + `relates_to :user`.
13
+ module Password
14
+ extend Definition
15
+
16
+ table "passwords"
17
+ schema do |t|
18
+ t.string :password_digest, default: nil
19
+ end
20
+
21
+ # The one-line class install writes is loaded by every application that
22
+ # installs `core`, bcrypt or not — and under eager_load a raise here would
23
+ # stop the app booting for a component it may never use. So the
24
+ # dependency is checked when the class is *used*: with bcrypt present,
25
+ # `has_secure_password`; without it, the same methods raise a LoadError
26
+ # that says what to add.
27
+ included do
28
+ if EcsRails::Catalogue::Password.bcrypt_available?
29
+ has_secure_password
30
+ else
31
+ %i[password= password_confirmation= authenticate authenticate_password].each do |method|
32
+ define_method(method) do |*|
33
+ raise LoadError, "EcsRails::Catalogue::Password needs the bcrypt gem: add `gem \"bcrypt\"` " \
34
+ "to your Gemfile and bundle"
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ # @return [Boolean] whether bcrypt can be required
41
+ # @api private
42
+ def self.bcrypt_available?
43
+ require "bcrypt"
44
+ true
45
+ rescue LoadError
46
+ false
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A span of time: an event, a booking, an availability window. `ends_at`
6
+ # may be nil for an open-ended period.
7
+ module Period
8
+ extend Definition
9
+
10
+ table "periods"
11
+ schema do |t|
12
+ t.datetime :starts_at, default: nil
13
+ t.datetime :ends_at, default: nil
14
+ t.string :time_zone, default: nil
15
+ t.boolean :all_day, default: false, null: false
16
+ end
17
+
18
+ included do
19
+ validate :ecs_ends_after_starts
20
+ end
21
+
22
+ # @return [Boolean] whether now falls inside the period
23
+ def current?(at = Time.current)
24
+ return false if starts_at.nil?
25
+
26
+ at >= starts_at && (ends_at.nil? || at < ends_at)
27
+ end
28
+
29
+ # @param other [Period, Range<Time>]
30
+ # @return [Boolean] whether the two periods share any instant
31
+ def overlaps?(other)
32
+ other_start, other_end = if other.respond_to?(:starts_at)
33
+ [other.starts_at, other.ends_at]
34
+ else
35
+ [other.begin, other.end]
36
+ end
37
+ return false if starts_at.nil? || other_start.nil?
38
+
39
+ (ends_at.nil? || other_start < ends_at) && (other_end.nil? || starts_at < other_end)
40
+ end
41
+
42
+ # @return [ActiveSupport::Duration, nil] the length to the second, or nil
43
+ # when open-ended
44
+ def duration
45
+ (ends_at - starts_at).round.seconds if starts_at && ends_at
46
+ end
47
+
48
+ private
49
+
50
+ def ecs_ends_after_starts
51
+ return if starts_at.nil? || ends_at.nil? || ends_at >= starts_at
52
+
53
+ errors.add(:ends_at, "must not be before starts_at")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A phone number in E.164 (`+61412345678`), with an optional extension.
6
+ # Multi-role by nature: `component Phone, prefix: :mobile`, `prefix: :work`.
7
+ #
8
+ # Format is the E.164 shape (a plus, a non-zero digit, up to 14 more) — no
9
+ # country database, no dependency.
10
+ module Phone
11
+ extend Definition
12
+
13
+ table "phones"
14
+ schema do |t|
15
+ t.string :e164, default: nil, limit: 16
16
+ t.string :extension, default: nil, limit: 8
17
+ end
18
+
19
+ included do
20
+ validates :e164, format: { with: /\A\+[1-9]\d{1,14}\z/, allow_blank: true }
21
+ validates :extension, format: { with: /\A\d+\z/, allow_blank: true }
22
+ end
23
+
24
+ # @return [String] the number with the extension, e.g. "+61812345678 x204"
25
+ def to_s
26
+ [e164, extension.presence && "x#{extension}"].compact.join(" ")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # An ordering within a list — kanban columns, checklist items. One slot per
6
+ # list an entity can be ordered in.
7
+ module Position
8
+ extend Definition
9
+
10
+ table "positions"
11
+ primary_attribute :position
12
+ schema do |t|
13
+ t.integer :position, default: 0, null: false
14
+ t.index %i[slot position]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A rating of one to five stars.
6
+ module Rating
7
+ extend Definition
8
+
9
+ table "ratings"
10
+ primary_attribute :stars
11
+ schema do |t|
12
+ t.integer :stars, default: nil
13
+ end
14
+
15
+ included do
16
+ validates :stars, inclusion: { in: 1..5 }, allow_nil: true
17
+ end
18
+
19
+ # @return [Boolean]
20
+ def rated?
21
+ stars.present?
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ # The catalogue: standard components that ship in the gem as concerns
5
+ # (ADR-0018). A host application owns the constant and includes the concern:
6
+ #
7
+ # class Relationship < ApplicationComponent
8
+ # include EcsRails::Catalogue::Relationship
9
+ # end
10
+ #
11
+ # `rails g ecs_rails:install` writes these one-line classes and the one
12
+ # migration that creates their tables. The rest of the catalogue arrives with
13
+ # Linear ECS-9; {Relationship} is the first entry, because ADR-0017 needs it.
14
+ module Catalogue
15
+ # The one component every `relates_to` is a row of (ADR-0017).
16
+ #
17
+ # A relationship is a labelled component (RFC-0014) whose single attribute is
18
+ # a target: the slot is the relationship name, `target_id` points at the
19
+ # other entity. All relationships in an application live in one
20
+ # `relationships` table, created at install, so declaring one is pure Ruby:
21
+ #
22
+ # class Post < ApplicationEntity
23
+ # relates_to :author, User # a row with slot "author"
24
+ # end
25
+ #
26
+ # post.author = user # post.author_relationship.target = user
27
+ # post.author # => the User (ADR-0008 resolves the subclass)
28
+ #
29
+ # The table (architecture.md §2):
30
+ #
31
+ # relationships (id, entity_id, slot, target_id, owner_model, exclusive, timestamps)
32
+ # UNIQUE (entity_id, slot) -- one target per name
33
+ # INDEX (target_id, slot) -- inverse lookups (RFC-0015)
34
+ # UNIQUE (target_id, slot, owner_model) WHERE exclusive -- has_one, DB-enforced
35
+ # FK entity_id -> entities ON DELETE CASCADE
36
+ # FK target_id -> entities ON DELETE NULLIFY
37
+ #
38
+ # `owner_model` is a denormalised copy of the owner's `entities.model`, so
39
+ # the exclusivity index is per owner type (an Invoice and an OrderItem both
40
+ # pointing at an Order under slot `order` must not collide) and the inverse
41
+ # `has_many` of RFC-0015 has a cheap scope. `exclusive` is what
42
+ # `relates_to :order, Order, unique: true` writes; the partial unique index
43
+ # then rejects a second owner of the same model pointing at the same target
44
+ # under the same slot — at most one Invoice per Order — with no index of its
45
+ # own. Both are stamped before save from the owning entity and the slot's
46
+ # declaration, never set by hand.
47
+ #
48
+ # The declared target class travels with the declaration as the slot option
49
+ # `target_class_name` (RFC-0014), and {#target=} checks assignments against
50
+ # it: `post.author = company` raises {EcsRails::InvalidRelationship}. The
51
+ # database never enforced target *type* — the foreign key points at
52
+ # `entities`, not at a per-type table — so this Ruby check is the type
53
+ # system, and it is mandatory (ADR-0017). Validation checks ID assignments
54
+ # too, including saves made directly through this component (RFC-0012).
55
+ module Relationship
56
+ extend Definition
57
+
58
+ table "relationships"
59
+ schema do |t|
60
+ t.uuid :target_id, default: nil
61
+ t.string :owner_model, null: false
62
+ t.boolean :exclusive, default: false, null: false
63
+ t.index %i[target_id slot]
64
+ t.index %i[target_id slot owner_model], unique: true, where: "exclusive", name: "index_relationships_exclusive"
65
+ t.foreign_key :target_id, on_delete: :nullify
66
+ end
67
+
68
+ included do
69
+ # The pointed-at entity. The association targets the abstract entity
70
+ # base, exactly as Component's `belongs_to :entity` does, and the loaded
71
+ # row's `model` decides the subclass (ADR-0008) — so `post.author` is a
72
+ # User. Optional: an unset relationship is a valid row-less virtual, and
73
+ # a nullified one (target destroyed) is a valid row (ADR-0003 / ADR-0013).
74
+ # Explicitly assigned new targets participate in the same transaction
75
+ # as the link. Autosave also validates them before the owner can commit
76
+ # (RFC-0012, ECS-25 amendment), preserving the save/save! contract.
77
+ belongs_to :target, class_name: "ApplicationEntity", optional: true, autosave: true
78
+
79
+ # RFC-0014 slot options, set by `relates_to` on the declaration: the
80
+ # target type this slot points at, and whether the link is exclusive.
81
+ slot_option :target_class_name
82
+ slot_option :unique, default: false
83
+
84
+ validate :ecs_validate_target
85
+ before_save :ecs_stamp_owner
86
+ end
87
+
88
+ # The entity class this relationship's slot was declared to point at.
89
+ #
90
+ # @return [Class<EcsRails::Entity>, nil] nil for a row whose owner no longer
91
+ # declares the slot
92
+ def target_class
93
+ target_class_name&.constantize
94
+ end
95
+
96
+ # Assigns the target, checking it against the declared target class first.
97
+ #
98
+ # @param value [EcsRails::Entity, nil]
99
+ # @return [EcsRails::Entity, nil]
100
+ # @raise [EcsRails::InvalidRelationship] if `value` is not an instance of
101
+ # the declared target class (or a subclass of it)
102
+ def target=(value)
103
+ expected = target_class
104
+ if value && expected && !value.is_a?(expected)
105
+ raise InvalidRelationship,
106
+ "relates_to :#{slot} on #{entity&.class&.name || owner_model} points at " \
107
+ "#{expected.name}; got #{value.class.name}"
108
+ end
109
+
110
+ super
111
+ end
112
+
113
+ # Whether the relationship has state waiting to be persisted.
114
+ # A new target has no UUID yet, but assigning it is a write. Without this
115
+ # check the lazy cascade skips the link before Rails can autosave its
116
+ # target. Only inspect an already loaded association: merely checking for
117
+ # pending state must not query an untouched target (RFC-0006 / RFC-0012).
118
+ #
119
+ # @return [Boolean]
120
+ def ecs_dirty?
121
+ return true if super
122
+ return false unless association(:target).loaded?
123
+
124
+ target&.new_record? || false
125
+ end
126
+
127
+ private
128
+
129
+ # ID writers bypass the object writer's type check. Validate through the
130
+ # association so preloaded/assigned targets are reused and a changed ID
131
+ # invalidates the old target using Rails' normal association behavior.
132
+ # The component error merges onto the owner through RFC-0007, preserving
133
+ # save/save! behavior even when the owner has not been inserted yet.
134
+ def ecs_validate_target
135
+ return if target_id.nil?
136
+
137
+ expected = target_class
138
+ value = target
139
+ if value.nil?
140
+ errors.add(:target, "must exist")
141
+ elsif expected && !value.is_a?(expected)
142
+ errors.add(:target, "must be a #{expected.name}")
143
+ end
144
+ end
145
+
146
+ # The owner's discriminator and the slot's exclusivity, copied onto the
147
+ # row so the database can enforce `unique: true` per owner type
148
+ # (ADR-0017). Stamped on every save rather than preset on the virtual: a
149
+ # preset would differ from the column default and make an untouched
150
+ # virtual look dirty (RFC-0006), and a save is the only time the values
151
+ # can matter.
152
+ def ecs_stamp_owner
153
+ owner = association(:entity).target || entity
154
+ self.owner_model = owner.model if owner
155
+ self.exclusive = unique ? true : false
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A role name: `member`, `owner`, `admin`. Data that belongs to a link, so
6
+ # it usually sits on a join entity (`Membership`, `Employment`).
7
+ module Role
8
+ extend Definition
9
+
10
+ table "roles"
11
+ primary_attribute :name
12
+ schema do |t|
13
+ t.string :name, default: nil
14
+ end
15
+
16
+ # @param value [String, Symbol]
17
+ # @return [Boolean]
18
+ def is?(value)
19
+ name == value.to_s
20
+ end
21
+ end
22
+ end
23
+ end