lethe 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1b7cb2467505c4efcd2901a83a887f829641721fc6efbd812810df4836f8e38c
4
+ data.tar.gz: 3d93eb10b84a500315d85a687528321e098a09eada43d264db7865cba14d44e7
5
+ SHA512:
6
+ metadata.gz: c7a9e090623334708e50d611ab37d68d8c57bfe2a5372bd6bdca222f7b34b3cf3db050e6c381f731e650f2c06851cb43f9093a91cc161792de50795ce0ea419d
7
+ data.tar.gz: b5351ebc5369d48142aa7e0e8f343a70530e90ad4ba13481a3688881c61b9e99246bf6b083975145c96d264e4987acd666107b9784c5ddfe532dbc54e692a0d1
data/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file. The format
4
+ is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
5
+ project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## 0.1.0 - 2026-10-27
8
+
9
+ The first release.
10
+
11
+ ### Added
12
+
13
+ - `Lethe.define` to declare, per subject such as `User`, what happens to the
14
+ data that points at it: `destroy`, `scrub`, `keep`, `ignore`,
15
+ `destroy_attachments`, and `subject` for its own row.
16
+ - Finding what points at a subject from foreign keys, associations, join
17
+ tables, polymorphic associations, Active Storage attachments and, as a
18
+ guess, column names.
19
+ - `bin/rails lethe:check`, with text, GitHub Actions and JSON output, and
20
+ `STRICT=1` to fail on guesses.
21
+ - `bin/rails lethe:export`, which writes the policies as Markdown and JSON,
22
+ and `CHECK=1` to fail when they are out of date.
23
+ - `Lethe.check!` for tests.
24
+ - `Lethe.erase!` to carry the policies out in one transaction, refusing
25
+ while the check has problems; `destroy ..., cascade: true`, Procs in
26
+ `set:`, and `before_erase` / `after_erase` hooks.
27
+ - `Lethe.plan` to see the steps without changing anything, and
28
+ `Lethe.leftovers` to prove in tests that nothing else was left behind.
29
+ - `bin/rails lethe:sweep` to delete kept rows once `keep ..., for:` or
30
+ `scrub ..., for:` has passed, through the model with `cascade: true`, and
31
+ `bin/rails generate lethe:install` for the table it needs.
32
+ - The check also fails on a polymorphic "table.column" without its type,
33
+ `cascade: true` without a model, and rows kept with a foreign key to a
34
+ subject row that is deleted.
35
+ - Enum names in `where:` and `set:`, read through the application's model;
36
+ a name the enum does not have fails the check.
37
+ - A definition for an STI subclass is used for its records, and its kept
38
+ rows are swept by its own retention.
39
+ - Inside a Rails app, `config/lethe.rb` and every model are loaded on first
40
+ use, so `Lethe.erase!` works from a controller and sees what CI sees.
41
+ - Messages and exports in English and Japanese.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KAIBYAKU K.K
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,326 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="docs/logo/lethe-logo-vertical-dark.svg">
4
+ <img src="docs/logo/lethe-logo-vertical.svg" alt="lethe: a time to keep, and a time to cast away." width="300">
5
+ </picture>
6
+ </p>
7
+
8
+ # lethe [![CI](https://github.com/kaibyaku-jp/lethe/actions/workflows/ci.yml/badge.svg)](https://github.com/kaibyaku-jp/lethe/actions/workflows/ci.yml)
9
+
10
+ A time to keep, and a time to cast away.
11
+
12
+ When someone deletes their account, every table that points at them needs a
13
+ decision: delete the rows, overwrite what is personal, or keep them for a
14
+ reason. lethe finds those tables in your Rails app, checks that each one has
15
+ a declared policy, fails CI when a new table has none, and carries the
16
+ policies out when the account goes.
17
+
18
+ > **Status:** early, before its first release. Until 1.0, minor versions may
19
+ > change how things work; [CHANGELOG.md](CHANGELOG.md) says what changed.
20
+
21
+ Lethe is the river of forgetting in Greek myth: in Plato's *Republic*, souls
22
+ drink from it before they are born again.
23
+
24
+
25
+ ## Features
26
+
27
+ ### Finds What Points at a User
28
+
29
+ Foreign keys, `has_many` and `has_one`, `belongs_to` in other models,
30
+ join tables, polymorphic associations, Active Storage attachments and,
31
+ as a guess, columns named like `user_id` or `*_user_id`.
32
+
33
+ ### Fails on What Nobody Decided
34
+
35
+ A table added last week with a `user_id` and no policy fails the check. So
36
+ does a policy for a table or column that no longer exists.
37
+
38
+ ### Writes It Down for People
39
+
40
+ `lethe:export` writes what happens to each kind of data as a Markdown table,
41
+ in English or Japanese, for your privacy policy, app store review or support
42
+ team. CI can check that it is up to date, so every change shows up in review.
43
+
44
+ ### Erases as Declared
45
+
46
+ `Lethe.erase!(user)` deletes, overwrites and keeps exactly what the
47
+ policies say, in one transaction, and refuses to run while the check has
48
+ problems. `Lethe.leftovers(user)` proves in a test that nothing else was
49
+ left behind.
50
+
51
+ ### Reads the Schema to Check
52
+
53
+ `lethe:check` and `lethe:export` read the schema and your models'
54
+ associations only, never a row. Only `erase!`, `plan`, `leftovers` and
55
+ `lethe:sweep` touch data, and only when your code calls them.
56
+
57
+
58
+ ## Supported Versions
59
+
60
+ * Ruby 3.4, 4.0
61
+
62
+ * Rails 7.2, 8.0, 8.1
63
+
64
+ * SQLite, MySQL and PostgreSQL
65
+
66
+
67
+ ## Installation
68
+
69
+ Put this in your Gemfile:
70
+
71
+ ```ruby
72
+ group :development, :test do
73
+ gem "lethe"
74
+ end
75
+ ```
76
+
77
+ To erase accounts with it in production, put it outside the group instead.
78
+ It adds the rake tasks and the methods below, and nothing else: no
79
+ callbacks, middleware or patches to Rails.
80
+
81
+ To keep rows for a while (`keep ..., for:`), lethe records each erasure in a
82
+ table of its own. Create it, with a `config/lethe.rb` to start from:
83
+
84
+ ```sh
85
+ % bin/rails generate lethe:install
86
+ % bin/rails db:migrate
87
+ ```
88
+
89
+
90
+ ## Declaring Policies
91
+
92
+ Write the policies in `config/lethe.rb`:
93
+
94
+ ```ruby
95
+ Lethe.define User do
96
+ # The users row itself: destroy: true, or soft_delete: with a column.
97
+ subject soft_delete: :deleted_at, scrub: {nickname: "Deleted user", email: nil}
98
+
99
+ # Associations of User, by name.
100
+ destroy :ratings, :favorites, :notifications
101
+ scrub :posts, set: {body: ""}, reason: "Quoted posts keep their row"
102
+ scrub :questions, where: {status: "answered"}, set: {body: ""}
103
+ destroy :questions, where: {status: %w[pending declined]}
104
+ scrub :orders, set: {phone: nil, address: nil}, for: 7.years, reason: "Bookkeeping"
105
+
106
+ # Anything without an association, as "table.column".
107
+ destroy "follows.follower_id", "follows.following_id"
108
+ keep "muted_users.muted_user_id", reason: "Other people's settings"
109
+
110
+ destroy_attachments :avatar
111
+
112
+ # A guess that does not point at User.
113
+ ignore "audit_logs.admin_user_id", reason: "Points at AdminUser"
114
+ end
115
+ ```
116
+
117
+ | policy | means |
118
+ | --- | --- |
119
+ | `destroy` | The rows are deleted. |
120
+ | `scrub` | The rows stay, with the columns in `set:` overwritten; `for:` says how long. |
121
+ | `keep` | The rows stay as they are. Needs a `reason:`; `for:` says how long. |
122
+ | `ignore` | The column does not point at the subject. Needs a `reason:`. |
123
+ | `destroy_attachments` | The subject's Active Storage attachments are deleted. |
124
+ | `subject` | How the subject's own row goes. |
125
+
126
+ `where:` limits a policy to some rows, so the same table can have one policy
127
+ per condition. lethe does not check that the conditions cover every row.
128
+ Enum names work in `where:` and `set:` as they do with your model, and a
129
+ name the enum does not have fails the check.
130
+
131
+ An STI subclass, such as `Admin < User`, can have a definition of its own.
132
+ It is used instead of the base class's for records of that class, so it
133
+ must declare everything.
134
+
135
+ Declare more subjects, such as `Organization`, the same way. Declarations
136
+ for the same subject add up.
137
+
138
+
139
+ ## Checking
140
+
141
+ ```sh
142
+ % bin/rails lethe:check
143
+ User: 41 references (association 36, attachment 3, column name 4, foreign key 31)
144
+
145
+ ✓ 37 have a policy
146
+ ✗ chat_messages.user_id no policy (found by foreign key, association, column name)
147
+ ? question_blocks.blocked_user_id guessed from the column name; declare a policy, or `ignore` it with a reason if it does not point at User
148
+ ! config/lethe.rb:14 :badges User has no association :badges
149
+
150
+ 2 problems, 1 to confirm.
151
+ ```
152
+
153
+ | mark | means | fails the check |
154
+ | --- | --- | --- |
155
+ | `✓` | Has a policy. | no |
156
+ | `✗` | Certainly points at the subject, with no policy. | yes |
157
+ | `!` | A declaration is wrong or names something that is gone. | yes |
158
+ | `?` | Might point at the subject, from its name alone, with no policy. | with `STRICT=1` |
159
+
160
+ It exits with 0 when there are no problems, 1 when there are, and 2 when
161
+ `config/lethe.rb` cannot be loaded.
162
+
163
+ | option | meaning |
164
+ | --- | --- |
165
+ | `FORMAT=github` | GitHub Actions annotations; `FORMAT=json` for tools |
166
+ | `STRICT=1` | Fail on `?` too |
167
+ | `LOCALE=ja` | Messages in Japanese |
168
+
169
+ In a test instead:
170
+
171
+ ```ruby
172
+ it "has a policy for every table that points at a user" do
173
+ Lethe.check!
174
+ end
175
+ ```
176
+
177
+ Inside a Rails app, lethe loads `config/lethe.rb` the first time it is
178
+ needed, and loads every model once where `eager_load` is off, so that
179
+ development and test see the same associations as CI.
180
+
181
+
182
+ ## Exporting
183
+
184
+ ```sh
185
+ % bin/rails lethe:export LOCALE=ja
186
+ lethe: wrote docs/lethe.md
187
+ lethe: wrote docs/lethe.json
188
+ ```
189
+
190
+ ```markdown
191
+ | Data | When they leave | Kept for | Reason |
192
+ | --- | --- | --- | --- |
193
+ | The User itself | Kept as deleted (deleted_at), with nickname, email overwritten | | |
194
+ | Avatar (attachment) | Deleted | | |
195
+ | Order | Kept, with phone, address overwritten | 7 years | Bookkeeping |
196
+ ```
197
+
198
+ Names come from your app's translations, such as `activerecord.models.order`.
199
+ Nothing is written while the check has problems. `CHECK=1` compares instead
200
+ of writing and fails when the files are out of date; run it in CI:
201
+
202
+ ```sh
203
+ bin/rails lethe:check lethe:export CHECK=1 FORMAT=github
204
+ ```
205
+
206
+
207
+ ## Erasing
208
+
209
+ ```ruby
210
+ class AccountsController < ApplicationController
211
+ def destroy
212
+ Lethe.erase!(current_user)
213
+ reset_session
214
+ head :no_content
215
+ end
216
+ end
217
+ ```
218
+
219
+ `erase!` checks the subject's definition first and raises `Lethe::Unclean`
220
+ without touching anything when it has problems. Then, in one transaction,
221
+ it runs the `before_erase` hooks, deletes rows (those with foreign keys to
222
+ other deleted tables first), overwrites columns, marks or deletes the
223
+ subject's own row, runs the `after_erase` hooks and records the erasure. If
224
+ anything fails, all of it is rolled back. Attachments are detached in the
225
+ transaction and their files purged after it commits.
226
+
227
+ | erasing | how |
228
+ | --- | --- |
229
+ | `destroy` | `delete_all` on the table, with no callbacks; `cascade: true` uses the model's `destroy_all`, so `dependent:` associations and attachments go too |
230
+ | `scrub` | `update_all`; a Proc in `set:` is called for each row, given the row if it takes an argument |
231
+ | `keep` | Nothing now; `lethe:sweep` deletes the rows once `for:` has passed (as does `scrub ..., for:`) |
232
+ | polymorphic | The type always becomes part of the condition, so erasing User 5 never touches Post 5's rows |
233
+
234
+ ```ruby
235
+ Lethe.define User do
236
+ subject soft_delete: :deleted_at,
237
+ scrub: {screen_name: ->(user) { "deleted_#{user.id}_#{SecureRandom.hex(8)}" }}
238
+ destroy :episodes, cascade: true
239
+ destroy "ratings.target_id", where: {target_type: "User"}
240
+
241
+ before_erase { |user| Follow.decrement_counters_for(user) }
242
+ after_erase { |user| DeleteFirebaseAccountJob.perform_later(user.firebase_uid) }
243
+ end
244
+ ```
245
+
246
+ Hooks run inside the transaction. Anything that cannot be rolled back,
247
+ such as a call to another service, belongs in a job enqueued from
248
+ `after_erase`.
249
+
250
+ `Lethe.plan(user)` lists the steps and how many rows each touches, without
251
+ changing anything. `erase!` returns the rows per table and policy, and
252
+ sends an `erase.lethe` notification with the subject's class, id and those
253
+ counts, never anything personal. Files that could not be purged after the
254
+ commit are logged and listed as `purge_failures` (blob ids) rather than
255
+ raised, since the erasure itself has already committed.
256
+
257
+ Running `erase!` again is safe: what is gone stays gone, and a soft
258
+ delete keeps the time of the first erasure.
259
+
260
+ ### Proving It in Tests
261
+
262
+ ```ruby
263
+ it "leaves nothing of a user behind but what we keep" do
264
+ user = create(:user, :with_everything)
265
+ Lethe.erase!(user)
266
+
267
+ expect(Lethe.leftovers(user)).to be_empty
268
+ end
269
+ ```
270
+
271
+ `leftovers` lists rows that should be gone, columns that should have been
272
+ overwritten (values from a Proc are not compared), attachments, and the
273
+ subject's row.
274
+
275
+ ### Sweeping
276
+
277
+ Run this daily, from cron or a recurring job:
278
+
279
+ ```sh
280
+ % bin/rails lethe:sweep
281
+ lethe: swept 3 from User :inquiries
282
+ ```
283
+
284
+ It deletes the rows of each `keep ..., for:` and `scrub ..., for:` whose
285
+ time has passed since their subject was first erased, one subject at a time
286
+ in a transaction. If other rows still point at them through a foreign key,
287
+ it stops there; add `cascade: true` to delete through the model and its
288
+ `dependent:` associations instead.
289
+
290
+
291
+ ## Configuring lethe
292
+
293
+ In `config/initializers/lethe.rb`, or anywhere loaded before the tasks run:
294
+
295
+ ```ruby
296
+ Lethe.configure do |config|
297
+ config.definition_path = "config/lethe.rb" # the default
298
+ config.export_path = "docs/lethe" # .md and .json are added
299
+ config.skip_tables = %w[audit_logs] # besides Rails' own
300
+ config.guess_columns[User] = %w[follower_id following_id questioner_id]
301
+ config.purge = :later # or :now, for erased attachments' files
302
+ end
303
+ ```
304
+
305
+
306
+ ## Questions, Feedback
307
+
308
+ Open an issue on GitHub. Report security problems privately as described in
309
+ [SECURITY.md](SECURITY.md).
310
+
311
+
312
+ ## Contributing to lethe
313
+
314
+ Fork, fix, then send a pull request. [CONTRIBUTING.md](CONTRIBUTING.md) has the
315
+ details.
316
+
317
+ ```sh
318
+ % nix develop # or use Ruby 3.4+ without Nix
319
+ % bundle install
320
+ % bundle exec rake
321
+ ```
322
+
323
+
324
+ ## Copyright
325
+
326
+ Copyright (c) 2026- KAIBYAKU K.K. See [LICENSE](LICENSE) for further details.
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Lethe
7
+ module Generators
8
+ # bin/rails generate lethe:install: the lethe_erasures table and a
9
+ # config/lethe.rb to start from.
10
+ class InstallGenerator < Rails::Generators::Base
11
+ include ActiveRecord::Generators::Migration
12
+
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ def create_migration_file
16
+ migration_template "create_lethe_erasures.rb.tt", File.join(db_migrate_path, "create_lethe_erasures.rb")
17
+ end
18
+
19
+ def create_definitions
20
+ template "lethe.rb.tt", "config/lethe.rb", skip: true
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,14 @@
1
+ # One row each time lethe erases a subject or sweeps its kept rows: the
2
+ # subject's class and id, and the number of rows per table. Nothing personal.
3
+ class CreateLetheErasures < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :lethe_erasures do |t|
6
+ t.string :subject_type, null: false
7
+ t.string :subject_id, null: false
8
+ t.string :kind, null: false
9
+ t.text :counts, null: false
10
+ t.datetime :created_at, null: false
11
+ end
12
+ add_index :lethe_erasures, [:subject_type, :subject_id]
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What happens to the data that points at a user when they leave.
4
+ # bin/rails lethe:check lists what still needs a policy.
5
+ Lethe.define "User" do
6
+ subject destroy: true
7
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lethe
4
+ # Compares one definition with what Discovery finds, and returns a Result
5
+ # for every reference and for every declaration that is wrong.
6
+ class Checker
7
+ def initialize(definition, models:, connection:, configuration:)
8
+ @definition = definition
9
+ @models = models
10
+ @connection = connection
11
+ @configuration = configuration
12
+ end
13
+
14
+ def call
15
+ @subject = resolve_subject
16
+ return [invalid(nil, "subject_not_a_model", location: first_location, subject: subject_name)] unless @subject
17
+
18
+ discovery = Discovery.new(@subject, models: models, connection: @connection, configuration: @configuration)
19
+ @tables = discovery.tables
20
+ by_key, problems = declared_keys
21
+
22
+ results = problems + contradictions(by_key)
23
+ results.concat(discovery.call.map { |reference| result_for(reference, by_key.delete(reference.key) || []) })
24
+ # Declared and valid, but not something Discovery found: trust the declaration.
25
+ results.concat(by_key.map { |key, policies| result_for(Reference.column(*key.split(".", 2), :declared), policies) })
26
+ results << subject_result
27
+ results.concat(blocked_by_foreign_keys(results))
28
+ end
29
+
30
+ # The schema questions Rules asks.
31
+ def column_names(table)
32
+ @column_names ||= {}
33
+ @column_names[table] ||= @connection.columns(table).map(&:name)
34
+ end
35
+
36
+ def model_for_table(table)
37
+ models.find do |model|
38
+ !model.abstract_class? && model.table_name == table && model.base_class == model
39
+ rescue ActiveRecord::ActiveRecordError
40
+ false
41
+ end
42
+ end
43
+
44
+ def primary_key(table)
45
+ @connection.primary_key(table)
46
+ end
47
+
48
+ private
49
+
50
+ def subject_name
51
+ @definition.subject_name
52
+ end
53
+
54
+ def models
55
+ @models || Lethe.application_models
56
+ end
57
+
58
+ def resolve_subject
59
+ model = subject_name.safe_constantize
60
+ (model.is_a?(Class) && model < ActiveRecord::Base && !model.abstract_class?) ? model : nil
61
+ end
62
+
63
+ def first_location
64
+ @definition.policies.first&.location || @definition.subject_policy&.location
65
+ end
66
+
67
+ # Returns [key, nil], or [nil, an :invalid Result].
68
+ def resolve(policy)
69
+ target = policy.target
70
+ case target.kind
71
+ when :association then resolve_association(policy)
72
+ when :attachment then resolve_attachment(policy)
73
+ else resolve_column(policy, target.table, target.column)
74
+ end
75
+ end
76
+
77
+ def resolve_association(policy)
78
+ name = policy.target.name
79
+ reflection = @subject.reflect_on_association(name)
80
+ return [nil, invalid(policy, "no_association", name: name)] unless reflection
81
+ return [nil, invalid(policy, "through_association", name: name)] if reflection.through_reflection?
82
+
83
+ case reflection.macro
84
+ when :has_many, :has_one
85
+ table = begin
86
+ reflection.klass.table_name
87
+ rescue NameError, ArgumentError
88
+ return [nil, invalid(policy, "association_class_missing", name: name)]
89
+ end
90
+ resolve_column(policy, table, reflection.foreign_key.to_s)
91
+ when :has_and_belongs_to_many
92
+ resolve_column(policy, reflection.join_table.to_s, reflection.foreign_key.to_s)
93
+ else
94
+ [nil, invalid(policy, "belongs_to_association", name: name)]
95
+ end
96
+ end
97
+
98
+ def resolve_attachment(policy)
99
+ name = policy.target.name
100
+ found = @subject.respond_to?(:reflect_on_attachment) && @subject.reflect_on_attachment(name)
101
+ found ? ["attachment:#{name}", nil] : [nil, invalid(policy, "no_attachment", name: name)]
102
+ end
103
+
104
+ def resolve_column(policy, table, column)
105
+ return [nil, invalid(policy, "no_table", table: table)] unless @tables.include?(table)
106
+ return [nil, invalid(policy, "no_column", table: table, column: column)] unless column_names(table).include?(column)
107
+
108
+ ["#{table}.#{column}", nil]
109
+ end
110
+
111
+ # { "table.column" => [valid policies] }, and the problems with the rest.
112
+ def declared_keys
113
+ rules = Rules.new(@subject, self) { |policy, message_key, **values| invalid(policy, message_key, **values) }
114
+ problems = []
115
+ by_key = Hash.new { |hash, key| hash[key] = [] }
116
+ @definition.policies.each do |policy|
117
+ key, error = resolve(policy)
118
+ next problems << error if error
119
+
120
+ found = rules.call(policy, key)
121
+ found.empty? ? by_key[key] << policy : problems.concat(found)
122
+ end
123
+ [by_key, problems]
124
+ end
125
+
126
+ # When the subject's row is deleted, rows that stay and point at it
127
+ # through a foreign key would make the delete fail.
128
+ def blocked_by_foreign_keys(results)
129
+ return [] unless @definition.subject_policy&.destroy
130
+
131
+ results.filter_map do |result|
132
+ next unless result.status == :covered && result.reference&.vias&.include?(:foreign_key)
133
+
134
+ staying = result.policies.find { |policy| %i[keep scrub].include?(policy.action) }
135
+ invalid(staying, "kept_rows_block_destroy", key: result.key) if staying
136
+ end
137
+ end
138
+
139
+ # Two policies for the same rows that say different things. The rows stay
140
+ # covered; the second declaration is reported.
141
+ def contradictions(by_key)
142
+ by_key.flat_map do |key, policies|
143
+ unconditional = policies.select { |policy| policy.where.nil? }
144
+ next [] if unconditional.map(&:action).uniq.size < 2
145
+
146
+ unconditional.drop(1).map do |policy|
147
+ invalid(policy, "contradiction", key: key, other: unconditional.first.location)
148
+ end
149
+ end
150
+ end
151
+
152
+ def result_for(reference, policies)
153
+ status =
154
+ if policies.empty?
155
+ reference.certain? ? :missing : :guess
156
+ elsif policies.all?(&:ignore?)
157
+ :ignored
158
+ else
159
+ :covered
160
+ end
161
+ Result.new(
162
+ status: status, subject: subject_name, key: reference.key, reference: reference,
163
+ policies: policies, message: nil, location: policies.first&.location
164
+ )
165
+ end
166
+
167
+ def subject_result
168
+ policy = @definition.subject_policy
169
+ message = policy ? subject_problem(policy) : nil
170
+ Result.new(
171
+ status: if policy.nil?
172
+ :missing
173
+ else
174
+ (message ? :invalid : :covered)
175
+ end, subject: subject_name, key: "subject",
176
+ reference: nil, policies: [], message: message, location: policy&.location
177
+ )
178
+ end
179
+
180
+ # What is wrong with the subject declaration, as [message key, values], or nil.
181
+ def subject_problem(policy)
182
+ table = @subject.table_name
183
+ columns = column_names(table)
184
+ return ["subject_both", {}] if policy.destroy && policy.soft_delete
185
+ return ["subject_neither", {}] if !policy.destroy && policy.soft_delete.nil?
186
+ if policy.soft_delete && !columns.include?(policy.soft_delete.to_s)
187
+ return ["unknown_column", {option: "soft_delete", table: table, column: policy.soft_delete.to_s}]
188
+ end
189
+ return ["subject_scrub_not_hash", {}] unless policy.scrub.is_a?(Hash)
190
+
191
+ unknown = policy.scrub.keys.map(&:to_s) - columns
192
+ return ["unknown_column", {option: "scrub", table: table, column: unknown.first}] if unknown.any?
193
+
194
+ column, value = Enums.unknown(@subject, policy.scrub).first
195
+ ["unknown_enum_value", {option: "scrub", table: table, column: column, value: value.inspect}] if column
196
+ end
197
+
198
+ def invalid(policy, message_key, location: policy&.location, subject: subject_name, **values)
199
+ Result.new(
200
+ status: :invalid, subject: subject, key: policy ? policy.target.to_s : subject, reference: nil,
201
+ policies: policy ? [policy] : [], message: [message_key, values], location: location
202
+ )
203
+ end
204
+ end
205
+ end