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