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,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
|