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,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
module Catalogue
|
|
5
|
+
# Compares a declaration with live PostgreSQL metadata (ECS-29). It only
|
|
6
|
+
# renders additions; a mismatch describes the explicit repair needed.
|
|
7
|
+
# Application data and existing definitions are never changed here.
|
|
8
|
+
class SchemaDiff
|
|
9
|
+
# @param schema [Schema] the expected catalogue declaration
|
|
10
|
+
# @param table_name [String, Symbol] the existing table
|
|
11
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter]
|
|
12
|
+
# @param slot_upgrade [Boolean] the preceding migration adds slot/index
|
|
13
|
+
def initialize(schema, table_name:, connection:, slot_upgrade: false)
|
|
14
|
+
@schema = schema
|
|
15
|
+
@table = table_name.to_s
|
|
16
|
+
@connection = connection
|
|
17
|
+
@slot_upgrade = slot_upgrade
|
|
18
|
+
@columns = connection.columns(@table).index_by(&:name)
|
|
19
|
+
@indexes = connection.indexes(@table)
|
|
20
|
+
@foreign_keys = connection.foreign_keys(@table)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param indent [Integer] spaces inside the generated change method
|
|
24
|
+
# @return [String] additive source, or empty for a compatible table
|
|
25
|
+
# @raise [SchemaMismatch] existing structure requires a reviewed repair
|
|
26
|
+
def to_ruby(indent: 4)
|
|
27
|
+
@additions = []
|
|
28
|
+
@problems = []
|
|
29
|
+
inspect_columns
|
|
30
|
+
inspect_indexes
|
|
31
|
+
inspect_foreign_keys
|
|
32
|
+
unless @problems.empty?
|
|
33
|
+
raise SchemaMismatch, "Catalogue schema mismatch:\n- #{@problems.join("\n- ")}\n" \
|
|
34
|
+
"Write and review an explicit repair/backfill migration, then rerun ecs_rails:upgrade. " \
|
|
35
|
+
"No column conversion or constraint replacement was generated."
|
|
36
|
+
end
|
|
37
|
+
@additions.map { |line| "#{' ' * indent}#{line}" }.join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def required_columns
|
|
43
|
+
[Schema::Column.new(:uuid, :id, null: false),
|
|
44
|
+
Schema::Column.new(:uuid, :entity_id, null: false),
|
|
45
|
+
Schema::Column.new(:string, :slot, null: false, default: ""),
|
|
46
|
+
*@schema.columns,
|
|
47
|
+
Schema::Column.new(:datetime, :created_at, null: false, precision: 6),
|
|
48
|
+
Schema::Column.new(:datetime, :updated_at, null: false, precision: 6)]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inspect_columns
|
|
52
|
+
primary_key = @connection.primary_key(@table)
|
|
53
|
+
mismatch("primary key", primary_key, "id") unless primary_key == "id"
|
|
54
|
+
required_columns.each do |column|
|
|
55
|
+
next if column.name == :slot && @slot_upgrade
|
|
56
|
+
|
|
57
|
+
actual = @columns[column.name.to_s]
|
|
58
|
+
if actual.nil?
|
|
59
|
+
if %i[id entity_id].include?(column.name) || (!column.options.fetch(:null, true) && column.options[:default].nil?)
|
|
60
|
+
@problems << "#{@table}.#{column.name} is missing; needs #{column.type} #{column.options.inspect} and an explicit backfill"
|
|
61
|
+
else
|
|
62
|
+
@additions << "add_column :#{@table}, :#{column.name}, :#{column.type}#{options(column.options)}"
|
|
63
|
+
end
|
|
64
|
+
next
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
expected = column_properties(column)
|
|
68
|
+
observed = actual_properties(actual)
|
|
69
|
+
expected.each do |property, value|
|
|
70
|
+
mismatch("#{column.name} #{property}", observed[property], value) unless observed[property] == value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def column_properties(column)
|
|
76
|
+
type = column.type == :bigint ? :integer : column.type
|
|
77
|
+
properties = { type: type, null: column.options.fetch(:null, true),
|
|
78
|
+
array: column.options.fetch(:array, false), default: column.options[:default] }
|
|
79
|
+
if column.name == :id
|
|
80
|
+
properties[:default] = "gen_random_uuid()"
|
|
81
|
+
end
|
|
82
|
+
if %i[string integer].include?(type)
|
|
83
|
+
properties[:limit] = column.options.fetch(:limit, type == :integer ? (column.type == :bigint ? 8 : 4) : nil)
|
|
84
|
+
end
|
|
85
|
+
if %i[decimal datetime].include?(type)
|
|
86
|
+
properties[:precision] = column.options.fetch(:precision, type == :datetime ? 6 : nil)
|
|
87
|
+
end
|
|
88
|
+
properties[:scale] = column.options[:scale] if type == :decimal
|
|
89
|
+
properties
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def actual_properties(actual)
|
|
93
|
+
default = if actual.default_function
|
|
94
|
+
actual.default_function.sub(/\A(?:public|pg_catalog)\./, "")
|
|
95
|
+
else
|
|
96
|
+
deserialize_default(actual)
|
|
97
|
+
end
|
|
98
|
+
{ type: actual.type, null: actual.null, array: actual.array, default: default,
|
|
99
|
+
limit: actual.limit, precision: actual.type == :datetime ? (actual.precision || 6) : actual.precision,
|
|
100
|
+
scale: actual.scale }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Use registered attribute types without loading an application model or
|
|
104
|
+
# populating its table/schema cache. PostgreSQL reports arrays and JSON
|
|
105
|
+
# defaults as strings; comparing those strings to Ruby [] is incorrect.
|
|
106
|
+
def deserialize_default(column)
|
|
107
|
+
return nil if column.default.nil?
|
|
108
|
+
return column.default if column.type == :tsvector
|
|
109
|
+
|
|
110
|
+
modifiers = column.array ? { array: true } : {}
|
|
111
|
+
ActiveRecord::Type.lookup(column.type, adapter: :postgresql, **modifiers)
|
|
112
|
+
.deserialize(column.default)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def inspect_indexes
|
|
116
|
+
singleton = Schema::Index.new(%i[entity_id slot], unique: true)
|
|
117
|
+
if @slot_upgrade
|
|
118
|
+
legacy = Schema::Index.new([:entity_id], unique: true)
|
|
119
|
+
unless @indexes.any? { |index| matching_index?(index, legacy) }
|
|
120
|
+
@problems << "#{@table} pre-slot upgrade needs an unconditional unique index on entity_id; inspect legacy duplicates before repair"
|
|
121
|
+
end
|
|
122
|
+
else
|
|
123
|
+
inspect_index(singleton)
|
|
124
|
+
end
|
|
125
|
+
@schema.indexes.each { |index| inspect_index(index) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def inspect_index(expected)
|
|
129
|
+
return if @indexes.any? { |actual| matching_index?(actual, expected) }
|
|
130
|
+
|
|
131
|
+
name = expected.options[:name] || @connection.index_name(@table, column: expected.columns)
|
|
132
|
+
conflicts = @indexes.select { |index| index.columns == expected.columns.map(&:to_s) || index.name == name }
|
|
133
|
+
if conflicts.any?
|
|
134
|
+
actual = conflicts.map { |index| "#{index.name} #{index_properties(index).inspect}" }.join(", ")
|
|
135
|
+
mismatch("index #{expected.columns.inspect}", actual, expected_index_properties(expected))
|
|
136
|
+
else
|
|
137
|
+
@additions << "add_index :#{@table}, #{expected.columns.inspect}#{options(expected.options)}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def matching_index?(actual, expected)
|
|
142
|
+
actual.columns == expected.columns.map(&:to_s) && index_properties(actual) == expected_index_properties(expected)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def index_properties(index)
|
|
146
|
+
{ unique: index.unique, using: index.using.to_s, where: predicate(index.where),
|
|
147
|
+
order: index.orders.presence, opclass: index.opclasses.presence,
|
|
148
|
+
nulls_not_distinct: !!index.nulls_not_distinct, valid: index.valid? }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def expected_index_properties(index)
|
|
152
|
+
{ unique: index.options.fetch(:unique, false), using: index.options.fetch(:using, :btree).to_s,
|
|
153
|
+
where: predicate(index.options[:where]), order: index.options[:order].presence,
|
|
154
|
+
opclass: index.options[:opclass].presence,
|
|
155
|
+
nulls_not_distinct: index.options.fetch(:nulls_not_distinct, false), valid: true }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The catalogue has one simple Boolean predicate. Recognize its normal
|
|
159
|
+
# PostgreSQL spellings; do not guess equivalence for arbitrary SQL.
|
|
160
|
+
def predicate(value)
|
|
161
|
+
text = value.to_s.strip
|
|
162
|
+
return nil if text.empty?
|
|
163
|
+
return "exclusive" if text.match?(/\A\(*"?exclusive"?\s*(?:=\s*true|IS\s+TRUE)?\)*\z/i)
|
|
164
|
+
|
|
165
|
+
text
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def inspect_foreign_keys
|
|
169
|
+
required = [Schema::ForeignKey.new(:entity_id, :cascade), *@schema.foreign_keys]
|
|
170
|
+
required.each do |expected|
|
|
171
|
+
candidates = @foreign_keys.select { |key| key.column == expected.column.to_s }
|
|
172
|
+
properties = { to_table: "entities", primary_key: "id", on_delete: expected.on_delete,
|
|
173
|
+
on_update: nil, validate: true, deferrable: false }
|
|
174
|
+
next if candidates.any? { |key| foreign_key_properties(key) == properties }
|
|
175
|
+
|
|
176
|
+
if candidates.empty?
|
|
177
|
+
@additions << "add_foreign_key :#{@table}, :entities, column: :#{expected.column}, on_delete: :#{expected.on_delete}"
|
|
178
|
+
else
|
|
179
|
+
mismatch("foreign key #{expected.column}", candidates.map { |key| foreign_key_properties(key) }, properties)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def foreign_key_properties(key)
|
|
185
|
+
{ to_table: key.to_table, primary_key: key.primary_key, on_delete: key.on_delete,
|
|
186
|
+
on_update: key.on_update, validate: key.validate?, deferrable: !!key.deferrable }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def mismatch(subject, actual, expected)
|
|
190
|
+
@problems << "#{@table}.#{subject}: found #{actual.inspect}; expected #{expected.inspect}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def options(values)
|
|
194
|
+
values.empty? ? "" : ", " + values.map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
module Catalogue
|
|
5
|
+
# PostgreSQL full-text search with no dependency: a `tsvector` and a GIN
|
|
6
|
+
# index. Rebuilt from an entity's texts by an entity-blind Indexer system.
|
|
7
|
+
#
|
|
8
|
+
# post.search_vector.reindex!(post.title_text, post.body_text)
|
|
9
|
+
# SearchVector.matching("composable").pluck(:entity_id)
|
|
10
|
+
module SearchVector
|
|
11
|
+
extend Definition
|
|
12
|
+
|
|
13
|
+
table "search_vectors"
|
|
14
|
+
schema do |t|
|
|
15
|
+
t.tsvector :document, default: nil
|
|
16
|
+
t.index :document, using: :gin
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
# Rows whose document matches `query` (plainto_tsquery, `simple` config).
|
|
21
|
+
#
|
|
22
|
+
# @param query [String]
|
|
23
|
+
# @return [ActiveRecord::Relation]
|
|
24
|
+
scope :matching, ->(query) { where("document @@ plainto_tsquery('simple', ?)", query) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Rebuilds the document from `texts` and saves.
|
|
28
|
+
#
|
|
29
|
+
# @param texts [Array<String, nil>]
|
|
30
|
+
# @return [void]
|
|
31
|
+
def reindex!(*texts)
|
|
32
|
+
save! unless persisted?
|
|
33
|
+
self.class.where(id: id).update_all(["document = to_tsvector('simple', ?)", texts.flatten.compact.join(" ")])
|
|
34
|
+
reload
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
module Catalogue
|
|
5
|
+
# A state machine: a `status` and an optional `transitions` log. The
|
|
6
|
+
# vocabulary belongs to the declaring entity, not the component:
|
|
7
|
+
#
|
|
8
|
+
# class Order < ApplicationEntity
|
|
9
|
+
# component State, prefix: :fulfilment, states: %w[pending paid shipped], history: true
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# order.fulfilment_state.transition!("paid", event: "checkout")
|
|
13
|
+
# order.fulfilment_state.status # => "paid"
|
|
14
|
+
# order.fulfilment_state.transitions # => [{ "at" => ..., "from" => "pending", "to" => "paid", "event" => "checkout" }]
|
|
15
|
+
#
|
|
16
|
+
# `states:` — the allowed values; empty means any. `history:` — whether
|
|
17
|
+
# `transition!` appends to the log (default true); `history: false` makes a
|
|
18
|
+
# slot a plain enum (visibility, priority, severity) on the same table.
|
|
19
|
+
module State
|
|
20
|
+
extend Definition
|
|
21
|
+
|
|
22
|
+
table "states"
|
|
23
|
+
schema do |t|
|
|
24
|
+
t.string :status, default: nil
|
|
25
|
+
t.jsonb :transitions, default: [], null: false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
included do
|
|
29
|
+
slot_option :states, default: []
|
|
30
|
+
slot_option :history, default: true
|
|
31
|
+
validate :ecs_status_in_vocabulary
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Moves to `to`, logging the transition when `history` is on, and saves.
|
|
35
|
+
#
|
|
36
|
+
# @param to [String, Symbol] the new status
|
|
37
|
+
# @param event [String, Symbol, nil] a label for the log
|
|
38
|
+
# @return [String] the new status
|
|
39
|
+
# @raise [ArgumentError] if `to` is not in the slot's `states:`
|
|
40
|
+
def transition!(to, event: nil)
|
|
41
|
+
to = to.to_s
|
|
42
|
+
unless states.empty? || states.map(&:to_s).include?(to)
|
|
43
|
+
raise ArgumentError, "#{to.inspect} is not one of #{states.inspect} (slot #{slot.inspect})"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if history
|
|
47
|
+
entry = { "at" => Time.current.iso8601, "from" => status, "to" => to }
|
|
48
|
+
entry["event"] = event.to_s if event
|
|
49
|
+
self.transitions = transitions + [entry]
|
|
50
|
+
end
|
|
51
|
+
self.status = to
|
|
52
|
+
save!
|
|
53
|
+
status
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param value [String, Symbol]
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
def in?(value)
|
|
59
|
+
status == value.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def ecs_status_in_vocabulary
|
|
65
|
+
return if status.nil? || states.empty? || states.map(&:to_s).include?(status)
|
|
66
|
+
|
|
67
|
+
errors.add(:status, "must be one of #{states.join(', ')}")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
module Catalogue
|
|
5
|
+
# Free tagging: an array of strings. An unbounded set of *strings* is a
|
|
6
|
+
# value, not a collection of entities. `allow:` on the declaration
|
|
7
|
+
# restricts the vocabulary.
|
|
8
|
+
#
|
|
9
|
+
# component Tags, prefix: :topics, allow: %w[ruby rails ecs]
|
|
10
|
+
# post.topics_tags.add("ruby")
|
|
11
|
+
# Post.with_component(Tags, prefix: :topics) { tagged("ruby") }
|
|
12
|
+
module Tags
|
|
13
|
+
extend Definition
|
|
14
|
+
|
|
15
|
+
table "tags"
|
|
16
|
+
schema do |t|
|
|
17
|
+
t.string :names, array: true, default: [], null: false
|
|
18
|
+
t.index :names, using: :gin
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
included do
|
|
22
|
+
slot_option :allow, default: nil
|
|
23
|
+
validate :ecs_names_allowed
|
|
24
|
+
|
|
25
|
+
# Rows tagged with `name`.
|
|
26
|
+
#
|
|
27
|
+
# @param name [String]
|
|
28
|
+
# @return [ActiveRecord::Relation]
|
|
29
|
+
scope :tagged, ->(name) { where("? = ANY (names)", name.to_s) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Adds names, de-duplicated, and returns the array.
|
|
33
|
+
#
|
|
34
|
+
# @param new_names [Array<String>]
|
|
35
|
+
# @return [Array<String>]
|
|
36
|
+
def add(*new_names)
|
|
37
|
+
self.names = (names + new_names.flatten.map(&:to_s)).uniq
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Removes names and returns the array.
|
|
41
|
+
#
|
|
42
|
+
# @param gone [Array<String>]
|
|
43
|
+
# @return [Array<String>]
|
|
44
|
+
def remove(*gone)
|
|
45
|
+
self.names = names - gone.flatten.map(&:to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param name [String]
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def tagged?(name)
|
|
51
|
+
names.include?(name.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def ecs_names_allowed
|
|
57
|
+
return if allow.nil?
|
|
58
|
+
|
|
59
|
+
disallowed = names - allow.map(&:to_s)
|
|
60
|
+
errors.add(:names, "#{disallowed.join(', ')} not allowed (allowed: #{allow.join(', ')})") if disallowed.any?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
module Catalogue
|
|
5
|
+
# A piece of text: title, body, bio, description — one slot each. The most
|
|
6
|
+
# generic shape in the catalogue. `value` is the primary attribute, so
|
|
7
|
+
# `component Text, prefix: :title` reads and writes as `post.title` — the
|
|
8
|
+
# slot name is the field name — while `post.title_text` is the component.
|
|
9
|
+
module Text
|
|
10
|
+
extend Definition
|
|
11
|
+
|
|
12
|
+
table "texts"
|
|
13
|
+
primary_attribute :value
|
|
14
|
+
schema do |t|
|
|
15
|
+
t.text :value, default: nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [String] the value, or ""
|
|
19
|
+
def to_s
|
|
20
|
+
value.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Integer] word count
|
|
24
|
+
def words
|
|
25
|
+
value.to_s.split.size
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
module Catalogue
|
|
5
|
+
# A point in time: issued_at, published_at, last_seen_at — one slot each.
|
|
6
|
+
module Timestamp
|
|
7
|
+
extend Definition
|
|
8
|
+
|
|
9
|
+
table "timestamps"
|
|
10
|
+
primary_attribute :at
|
|
11
|
+
schema do |t|
|
|
12
|
+
t.datetime :at, default: nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Sets `at` to now and saves.
|
|
16
|
+
#
|
|
17
|
+
# @return [Time]
|
|
18
|
+
def stamp!
|
|
19
|
+
update!(at: Time.current)
|
|
20
|
+
at
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Boolean] whether `at` is set and in the past
|
|
24
|
+
def past?
|
|
25
|
+
at.present? && at <= Time.current
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] whether `at` is set and in the future
|
|
29
|
+
def future?
|
|
30
|
+
at.present? && at > Time.current
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "active_support/core_ext/securerandom"
|
|
6
|
+
require "active_support/security_utils"
|
|
7
|
+
|
|
8
|
+
module EcsRails
|
|
9
|
+
module Catalogue
|
|
10
|
+
# A revocable secret: password reset, invitation, API key — one slot each.
|
|
11
|
+
# Stores a **digest**, never the value (the `generates_token_for` shape):
|
|
12
|
+
# generate once, show once, verify against the digest.
|
|
13
|
+
#
|
|
14
|
+
# raw = user.reset_token.generate!(expires_in: 1.hour) # give this to the user
|
|
15
|
+
# user.reset_token.verify(raw) # => true while valid
|
|
16
|
+
# user.reset_token.revoke! # the caller consumes/revokes it
|
|
17
|
+
#
|
|
18
|
+
# Verification does not consume the token. A single-use workflow must
|
|
19
|
+
# coordinate verification, its action and revocation in the application.
|
|
20
|
+
module Token
|
|
21
|
+
extend Definition
|
|
22
|
+
|
|
23
|
+
table "tokens"
|
|
24
|
+
schema do |t|
|
|
25
|
+
t.string :digest, default: nil
|
|
26
|
+
t.datetime :expires_at, default: nil
|
|
27
|
+
t.index :digest
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Generates a fresh value, stores its digest and saves. Returns the value
|
|
31
|
+
# — the only time it is available.
|
|
32
|
+
#
|
|
33
|
+
# @param expires_in [ActiveSupport::Duration, nil] nil for no expiry
|
|
34
|
+
# @return [String] the raw token
|
|
35
|
+
def generate!(expires_in: nil)
|
|
36
|
+
raw = SecureRandom.base58(32)
|
|
37
|
+
update!(digest: self.class.ecs_digest(raw), expires_at: expires_in && Time.current + expires_in)
|
|
38
|
+
raw
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param raw [String]
|
|
42
|
+
# @return [Boolean] whether `raw` matches and the token has not expired
|
|
43
|
+
def verify(raw)
|
|
44
|
+
return false if digest.nil? || raw.nil? || expired?
|
|
45
|
+
|
|
46
|
+
ActiveSupport::SecurityUtils.secure_compare(digest, self.class.ecs_digest(raw))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def expired?
|
|
51
|
+
expires_at.present? && expires_at <= Time.current
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Forgets the token.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def revoke!
|
|
58
|
+
update!(digest: nil, expires_at: nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
included do
|
|
62
|
+
# @api private
|
|
63
|
+
def self.ecs_digest(raw)
|
|
64
|
+
Digest::SHA256.hexdigest(raw)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The row for a raw token, if any (then `verify` it).
|
|
68
|
+
#
|
|
69
|
+
# @param raw [String]
|
|
70
|
+
# @return [ActiveRecord::Base, nil]
|
|
71
|
+
def self.find_by_token(raw)
|
|
72
|
+
find_by(digest: ecs_digest(raw))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|