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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/README.md +326 -0
- data/lib/generators/lethe/install/install_generator.rb +24 -0
- data/lib/generators/lethe/install/templates/create_lethe_erasures.rb.tt +14 -0
- data/lib/generators/lethe/install/templates/lethe.rb.tt +7 -0
- data/lib/lethe/checker.rb +205 -0
- data/lib/lethe/configuration.rb +75 -0
- data/lib/lethe/definition.rb +118 -0
- data/lib/lethe/discovery.rb +117 -0
- data/lib/lethe/enums.rb +46 -0
- data/lib/lethe/eraser.rb +126 -0
- data/lib/lethe/exporter.rb +163 -0
- data/lib/lethe/formatters.rb +178 -0
- data/lib/lethe/ledger.rb +29 -0
- data/lib/lethe/leftovers.rb +98 -0
- data/lib/lethe/locales/en.yml +77 -0
- data/lib/lethe/locales/ja.yml +77 -0
- data/lib/lethe/locator.rb +59 -0
- data/lib/lethe/plan.rb +110 -0
- data/lib/lethe/policy.rb +31 -0
- data/lib/lethe/railtie.rb +10 -0
- data/lib/lethe/reference.rb +43 -0
- data/lib/lethe/report.rb +36 -0
- data/lib/lethe/result.rb +22 -0
- data/lib/lethe/rows.rb +37 -0
- data/lib/lethe/rules.rb +136 -0
- data/lib/lethe/runner.rb +126 -0
- data/lib/lethe/sweeper.rb +50 -0
- data/lib/lethe/target.rb +42 -0
- data/lib/lethe/tasks/lethe.rake +26 -0
- data/lib/lethe/version.rb +5 -0
- data/lib/lethe.rb +193 -0
- metadata +119 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Lethe
|
|
7
|
+
# Turn a Report into text for people, GitHub Actions annotations, or JSON.
|
|
8
|
+
module Formatters
|
|
9
|
+
MARKS = {covered: "✓", ignored: "✓", missing: "✗", guess: "?", invalid: "!"}.freeze
|
|
10
|
+
|
|
11
|
+
def self.for(name)
|
|
12
|
+
case name.to_s
|
|
13
|
+
when "", "text" then Text
|
|
14
|
+
when "github" then GitHub
|
|
15
|
+
when "json" then JSON
|
|
16
|
+
else raise ArgumentError, "lethe: unknown format #{name.inspect}; use text, github or json"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class Base
|
|
21
|
+
# root: paths in locations are shown relative to it.
|
|
22
|
+
# definition_path: where problems without a location point (the definitions file).
|
|
23
|
+
def initialize(report, root: Dir.pwd, locale: I18n.default_locale, definition_path: nil)
|
|
24
|
+
@report = report
|
|
25
|
+
@root = Pathname(root)
|
|
26
|
+
@locale = locale
|
|
27
|
+
@definition_path = definition_path
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def t(key, **values)
|
|
33
|
+
I18n.t(key, **values, locale: @locale)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The sentence for a result that needs one.
|
|
37
|
+
def message(result)
|
|
38
|
+
case result.status
|
|
39
|
+
when :missing
|
|
40
|
+
if result.key == "subject"
|
|
41
|
+
t("lethe.check.subject_missing", subject: result.subject)
|
|
42
|
+
else
|
|
43
|
+
t("lethe.check.missing", vias: vias(result.reference))
|
|
44
|
+
end
|
|
45
|
+
when :guess
|
|
46
|
+
t("lethe.check.guess", vias: vias(result.reference), subject: result.subject)
|
|
47
|
+
when :invalid
|
|
48
|
+
key, values = result.message
|
|
49
|
+
t("lethe.messages.#{key}", subject: result.subject, **values)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def vias(reference)
|
|
54
|
+
reference.vias.map { |via| t("lethe.vias.#{via}") }.join(t("lethe.export.list_separator"))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# [path, line] relative to root, or nil.
|
|
58
|
+
def location(result)
|
|
59
|
+
return nil unless result.location
|
|
60
|
+
|
|
61
|
+
path, line = result.location.match(/\A(.*):(\d+)\z/)&.captures
|
|
62
|
+
return nil unless path
|
|
63
|
+
|
|
64
|
+
[relative(path), line.to_i]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def relative(path)
|
|
68
|
+
Pathname(path).relative_path_from(@root).to_s
|
|
69
|
+
rescue ArgumentError
|
|
70
|
+
path
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class Text < Base
|
|
75
|
+
def render
|
|
76
|
+
lines = []
|
|
77
|
+
@report.subjects.each do |subject|
|
|
78
|
+
results = @report.results.select { |result| result.subject == subject }
|
|
79
|
+
lines.concat(subject_lines(subject, results))
|
|
80
|
+
lines << ""
|
|
81
|
+
end
|
|
82
|
+
lines << totals
|
|
83
|
+
lines.join("\n") + "\n"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def subject_lines(subject, results)
|
|
89
|
+
references = results.filter_map(&:reference)
|
|
90
|
+
counts = references.flat_map(&:vias).tally.sort_by { |via, _| via.to_s }
|
|
91
|
+
vias = counts.map { |via, count| "#{t("lethe.vias.#{via}")} #{count}" }.join(t("lethe.export.list_separator"))
|
|
92
|
+
lines = [t("lethe.check.summary", subject: subject, count: references.size, vias: vias), ""]
|
|
93
|
+
|
|
94
|
+
covered = results.count { |result| result.reference && (result.status == :covered || result.status == :ignored) }
|
|
95
|
+
lines << "#{MARKS[:covered]} #{t("lethe.check.covered", count: covered)}"
|
|
96
|
+
|
|
97
|
+
listed = results.reject { |result| result.status == :covered || result.status == :ignored }
|
|
98
|
+
width = listed.map { |result| label(result).length }.max || 0
|
|
99
|
+
listed.each do |result|
|
|
100
|
+
lines << "#{MARKS[result.status]} #{label(result).ljust(width)} #{message(result)}"
|
|
101
|
+
end
|
|
102
|
+
lines
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def label(result)
|
|
106
|
+
where = location(result)
|
|
107
|
+
prefix = where ? "#{where.join(":")} " : ""
|
|
108
|
+
"#{prefix}#{result.key}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def totals
|
|
112
|
+
problems = @report.problems.size
|
|
113
|
+
guesses = @report.strict? ? 0 : @report.count(:guess)
|
|
114
|
+
return t("lethe.check.clean") if problems.zero? && guesses.zero?
|
|
115
|
+
|
|
116
|
+
t("lethe.check.totals", problems: problems, guesses: guesses)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# GitHub Actions workflow commands, one per problem or guess.
|
|
121
|
+
class GitHub < Base
|
|
122
|
+
def render
|
|
123
|
+
@report.results.filter_map { |result| annotation(result) }.join("\n").then { |text| text.empty? ? "" : "#{text}\n" }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def annotation(result)
|
|
129
|
+
level =
|
|
130
|
+
if result.problem?(strict: @report.strict?) then "error"
|
|
131
|
+
elsif result.status == :guess then "warning"
|
|
132
|
+
end
|
|
133
|
+
return nil unless level
|
|
134
|
+
|
|
135
|
+
path, line = location(result) || [@definition_path && relative(@definition_path), nil]
|
|
136
|
+
properties = {"file" => path, "line" => line, "title" => "lethe: #{result.subject} #{result.key}"}
|
|
137
|
+
.compact.map { |name, value| "#{name}=#{escape_property(value.to_s)}" }.join(",")
|
|
138
|
+
"::#{level} #{properties}::#{escape_data(message(result))}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# https://docs.github.com/actions/reference/workflow-commands-for-github-actions
|
|
142
|
+
def escape_data(value)
|
|
143
|
+
value.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def escape_property(value)
|
|
147
|
+
escape_data(value).gsub(":", "%3A").gsub(",", "%2C")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class JSON < Base
|
|
152
|
+
def render
|
|
153
|
+
::JSON.pretty_generate(
|
|
154
|
+
"version" => 1,
|
|
155
|
+
"clean" => @report.clean?,
|
|
156
|
+
"strict" => @report.strict?,
|
|
157
|
+
"results" => @report.results.map { |result| entry(result) }
|
|
158
|
+
) + "\n"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def entry(result)
|
|
164
|
+
where = location(result)
|
|
165
|
+
{
|
|
166
|
+
"status" => result.status.to_s,
|
|
167
|
+
"subject" => result.subject,
|
|
168
|
+
"key" => result.key,
|
|
169
|
+
"vias" => result.reference&.vias&.map(&:to_s),
|
|
170
|
+
"actions" => result.policies.map { |policy| policy.action.to_s },
|
|
171
|
+
"file" => where&.first,
|
|
172
|
+
"line" => where&.last,
|
|
173
|
+
"message" => message(result)
|
|
174
|
+
}.compact
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
data/lib/lethe/ledger.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lethe
|
|
6
|
+
# The lethe_erasures table: one row each time a subject is erased or its
|
|
7
|
+
# kept rows are swept, with the subject's class, id and the number of rows
|
|
8
|
+
# per table. Never anything personal. bin/rails generate lethe:install
|
|
9
|
+
# creates it.
|
|
10
|
+
module Ledger
|
|
11
|
+
TABLE = "lethe_erasures"
|
|
12
|
+
|
|
13
|
+
def self.available?(connection)
|
|
14
|
+
connection.data_source_exists?(TABLE)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.record(subject_type, subject_id, kind, counts)
|
|
18
|
+
Rows.for(TABLE).create!(
|
|
19
|
+
subject_type: subject_type, subject_id: subject_id.to_s, kind: kind,
|
|
20
|
+
counts: JSON.generate(counts), created_at: Time.current
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# { subject_id => when it was first erased }
|
|
25
|
+
def self.erased(subject_type)
|
|
26
|
+
Rows.for(TABLE).where(subject_type: subject_type, kind: "erase").group(:subject_id).minimum(:created_at)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# After an erasure, finds what still points at the subject in a way the
|
|
5
|
+
# definition does not allow: rows that should be gone, columns that should
|
|
6
|
+
# have been overwritten, attachments, and the subject's own row.
|
|
7
|
+
#
|
|
8
|
+
# Kept rows are allowed. Values computed by a Proc cannot be compared, so
|
|
9
|
+
# those columns are not checked.
|
|
10
|
+
class Leftovers
|
|
11
|
+
Leftover = Data.define(:key, :problem, :count) do
|
|
12
|
+
def to_s
|
|
13
|
+
"#{key}: #{count} #{problem.to_s.tr("_", " ")}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(subject, id, definition, results, models:)
|
|
18
|
+
@subject = subject
|
|
19
|
+
@id = id
|
|
20
|
+
@definition = definition
|
|
21
|
+
@results = results
|
|
22
|
+
@locator = Locator.new(subject, models: models)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
references + attachments + subject_row
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def covered
|
|
32
|
+
@results.select { |result| result.status == :covered && result.reference }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def references
|
|
36
|
+
covered.reject { |result| result.reference.attachment }.flat_map do |result|
|
|
37
|
+
policies = result.policies.reject(&:ignore?)
|
|
38
|
+
next [] if policies.empty?
|
|
39
|
+
|
|
40
|
+
location = @locator.locate(policies.first)
|
|
41
|
+
base = @locator.rows(location).where(@locator.conditions(location, nil, @id)).where(declared_type(location, policies))
|
|
42
|
+
found = []
|
|
43
|
+
|
|
44
|
+
allowed = policies.select { |policy| %i[keep scrub].include?(policy.action) }
|
|
45
|
+
unless allowed.any? { |policy| policy.where.nil? }
|
|
46
|
+
staying = allowed.map { |policy| base.where(Enums.cast(location.model, policy.where)) }.reduce { |all, scope| all.or(scope) }
|
|
47
|
+
count = base.count - (staying ? staying.count : 0)
|
|
48
|
+
found << Leftover.new(key: result.key, problem: :not_deleted, count: count) if count.positive?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
allowed.select { |policy| policy.action == :scrub }.each do |policy|
|
|
52
|
+
fixed = policy.set.reject { |_, value| value.is_a?(Proc) }
|
|
53
|
+
next if fixed.empty?
|
|
54
|
+
|
|
55
|
+
count = base.where(Enums.cast(location.model, policy.where || {})).where.not(Enums.cast(location.model, fixed)).count
|
|
56
|
+
found << Leftover.new(key: result.key, problem: :not_scrubbed, count: count) if count.positive?
|
|
57
|
+
end
|
|
58
|
+
found
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A "table.column" target names its polymorphic type in where:, which
|
|
63
|
+
# the checker requires; the rows of other types are not this subject's.
|
|
64
|
+
def declared_type(location, policies)
|
|
65
|
+
return {} if location.type_column
|
|
66
|
+
|
|
67
|
+
type_column = "#{location.column.delete_suffix("_id")}_type"
|
|
68
|
+
return {} unless @locator.rows(location).column_names.include?(type_column)
|
|
69
|
+
|
|
70
|
+
Enums.cast(location.model, policies.first.where).transform_keys(&:to_s).slice(type_column)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attachments
|
|
74
|
+
return [] unless defined?(ActiveStorage::Attachment) && ActiveStorage::Attachment.table_exists?
|
|
75
|
+
|
|
76
|
+
covered.select { |result| result.reference.attachment }.filter_map do |result|
|
|
77
|
+
count = ActiveStorage::Attachment
|
|
78
|
+
.where(record_type: @subject.polymorphic_name, record_id: @id, name: result.reference.attachment).count
|
|
79
|
+
Leftover.new(key: result.key, problem: :attachment_remains, count: count) if count.positive?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def subject_row
|
|
84
|
+
policy = @definition.subject_policy
|
|
85
|
+
row = Rows.for(@subject.table_name).where(@subject.primary_key => @id)
|
|
86
|
+
return [] unless policy && row.exists?
|
|
87
|
+
return [Leftover.new(key: "subject", problem: :not_deleted, count: 1)] if policy.destroy
|
|
88
|
+
|
|
89
|
+
found = []
|
|
90
|
+
found << Leftover.new(key: "subject", problem: :not_marked_deleted, count: 1) if row.where(policy.soft_delete => nil).exists?
|
|
91
|
+
fixed = policy.scrub.reject { |_, value| value.is_a?(Proc) }
|
|
92
|
+
if fixed.any? && row.where.not(Enums.cast(@subject, fixed)).exists?
|
|
93
|
+
found << Leftover.new(key: "subject", problem: :not_scrubbed, count: 1)
|
|
94
|
+
end
|
|
95
|
+
found
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
en:
|
|
2
|
+
lethe:
|
|
3
|
+
errors:
|
|
4
|
+
no_definitions_file: "lethe: %{path} not found. Declare your policies there, or set Lethe.configuration.definition_path."
|
|
5
|
+
load_failed: "lethe: could not load %{path}: %{error}"
|
|
6
|
+
ledger_required: "lethe: %{table} is missing; keep ..., for: needs it to know when a subject was erased. Run bin/rails generate lethe:install and migrate."
|
|
7
|
+
no_definition: "lethe: there is no Lethe.define for %{subject}"
|
|
8
|
+
foreign_key_cycle: "lethe: foreign keys between %{tables} form a cycle, so there is no order to delete them in; use cascade: true or remove one of the keys"
|
|
9
|
+
vias:
|
|
10
|
+
foreign_key: foreign key
|
|
11
|
+
association: association
|
|
12
|
+
join_table: join table
|
|
13
|
+
polymorphic: polymorphic association
|
|
14
|
+
polymorphic_guess: polymorphic belongs_to
|
|
15
|
+
attachment: attachment
|
|
16
|
+
column_name: column name
|
|
17
|
+
declared: declaration
|
|
18
|
+
check:
|
|
19
|
+
summary: "%{subject}: %{count} references (%{vias})"
|
|
20
|
+
covered: "%{count} have a policy"
|
|
21
|
+
missing: "no policy (found by %{vias})"
|
|
22
|
+
guess: "guessed from the %{vias}; declare a policy, or `ignore` it with a reason if it does not point at %{subject}"
|
|
23
|
+
subject_missing: "no `subject` declaration; say how the %{subject} row itself goes"
|
|
24
|
+
totals: "%{problems} problems, %{guesses} to confirm."
|
|
25
|
+
clean: "No problems."
|
|
26
|
+
messages:
|
|
27
|
+
subject_not_a_model: "%{subject} is not an Active Record model with a table"
|
|
28
|
+
no_association: "%{subject} has no association :%{name}"
|
|
29
|
+
through_association: ":%{name} goes through another association; declare the table it goes through instead"
|
|
30
|
+
association_class_missing: "the class of association :%{name} cannot be loaded"
|
|
31
|
+
belongs_to_association: ":%{name} is a belongs_to; it points away from %{subject}, not at it"
|
|
32
|
+
no_attachment: "%{subject} has no attachment :%{name}"
|
|
33
|
+
no_table: "there is no table %{table}"
|
|
34
|
+
no_column: "%{table} has no column %{column}"
|
|
35
|
+
reason_required: "%{action} needs a reason:"
|
|
36
|
+
reason_not_string: "reason: must be a String"
|
|
37
|
+
set_required: "scrub needs set: with the columns to overwrite"
|
|
38
|
+
where_not_hash: "where: must be a non-empty Hash of columns"
|
|
39
|
+
unknown_column: "%{option}: %{table} has no column %{column}"
|
|
40
|
+
unknown_enum_value: "%{option}: %{value} is not a value of the %{column} enum on %{table}"
|
|
41
|
+
retention_not_allowed: "for: belongs to keep and scrub, not %{action}"
|
|
42
|
+
cascade_without_retention: "cascade: on %{action} only applies when lethe:sweep deletes the rows; add for:, or leave cascade out"
|
|
43
|
+
retention_not_duration: "for: must be a duration such as 2.years"
|
|
44
|
+
contradiction: "%{key} already has a different policy at %{other}; limit one of them with where:"
|
|
45
|
+
polymorphic_type_required: "%{key} is polymorphic; add where: { %{type_column}: \"%{subject_type}\" } so other types' rows stay"
|
|
46
|
+
cascade_needs_model: "cascade: true needs a model for %{table}; without one, leave cascade out"
|
|
47
|
+
computed_set_needs_primary_key: "a Proc in set: needs %{table} to have a primary key, to update one row at a time"
|
|
48
|
+
kept_rows_block_destroy: "%{key} has a foreign key to %{subject}, so its rows cannot stay when subject destroy: true deletes the row; soft delete it instead"
|
|
49
|
+
subject_both: "subject cannot both destroy: and soft_delete:"
|
|
50
|
+
subject_neither: "subject needs destroy: true or soft_delete: :column"
|
|
51
|
+
subject_scrub_not_hash: "subject scrub: must be a Hash of columns"
|
|
52
|
+
export:
|
|
53
|
+
title: "What happens when a %{subject} leaves"
|
|
54
|
+
generated: "Generated by lethe from %{path}. Do not edit by hand; run bin/rails lethe:export."
|
|
55
|
+
data: Data
|
|
56
|
+
action: When they leave
|
|
57
|
+
retention: Kept for
|
|
58
|
+
reason: Reason
|
|
59
|
+
subject_row: "The %{subject} itself"
|
|
60
|
+
attachment: "%{name} (attachment)"
|
|
61
|
+
column_suffix: "%{label} (%{column})"
|
|
62
|
+
destroy: Deleted
|
|
63
|
+
destroy_attachment: Deleted
|
|
64
|
+
scrub: "Kept, with %{columns} overwritten"
|
|
65
|
+
keep: Kept
|
|
66
|
+
soft_delete: "Kept as deleted (%{column})"
|
|
67
|
+
soft_delete_scrub: "Kept as deleted (%{column}), with %{columns} overwritten"
|
|
68
|
+
where: "%{action}, where %{conditions}"
|
|
69
|
+
list_separator: ", "
|
|
70
|
+
parts:
|
|
71
|
+
years: "%{count} years"
|
|
72
|
+
months: "%{count} months"
|
|
73
|
+
weeks: "%{count} weeks"
|
|
74
|
+
days: "%{count} days"
|
|
75
|
+
hours: "%{count} hours"
|
|
76
|
+
minutes: "%{count} minutes"
|
|
77
|
+
seconds: "%{count} seconds"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
ja:
|
|
2
|
+
lethe:
|
|
3
|
+
errors:
|
|
4
|
+
no_definitions_file: "lethe: %{path} がありません。方針をそこに書くか、Lethe.configuration.definition_path を設定してください。"
|
|
5
|
+
load_failed: "lethe: %{path} を読み込めませんでした: %{error}"
|
|
6
|
+
ledger_required: "lethe: %{table} がありません。keep ..., for: には、いつ退会したかの記録が必要です。bin/rails generate lethe:install を実行して migrate してください。"
|
|
7
|
+
no_definition: "lethe: %{subject} の Lethe.define がありません"
|
|
8
|
+
foreign_key_cycle: "lethe: %{tables} の外部キーが循環していて、消す順番を決められません。cascade: true を使うか、外部キーを 1 つ外してください"
|
|
9
|
+
vias:
|
|
10
|
+
foreign_key: 外部キー
|
|
11
|
+
association: 関連
|
|
12
|
+
join_table: 中間テーブル
|
|
13
|
+
polymorphic: ポリモーフィック関連
|
|
14
|
+
polymorphic_guess: ポリモーフィックの belongs_to
|
|
15
|
+
attachment: 添付
|
|
16
|
+
column_name: 列名
|
|
17
|
+
declared: 宣言
|
|
18
|
+
check:
|
|
19
|
+
summary: "%{subject}: 参照 %{count} 件(%{vias})"
|
|
20
|
+
covered: "%{count} 件に方針があります"
|
|
21
|
+
missing: "方針がありません(%{vias}で見つけました)"
|
|
22
|
+
guess: "%{vias}から推測しました。%{subject} を指すなら方針を、指さないなら理由をつけて ignore を書いてください"
|
|
23
|
+
subject_missing: "subject の宣言がありません。%{subject} の行そのものをどうするかを書いてください"
|
|
24
|
+
totals: "問題 %{problems} 件、要確認 %{guesses} 件。"
|
|
25
|
+
clean: "問題はありません。"
|
|
26
|
+
messages:
|
|
27
|
+
subject_not_a_model: "%{subject} はテーブルのある Active Record のモデルではありません"
|
|
28
|
+
no_association: "%{subject} に :%{name} という関連はありません"
|
|
29
|
+
through_association: ":%{name} は別の関連を通っています。通っている先のテーブルを宣言してください"
|
|
30
|
+
association_class_missing: ":%{name} のクラスを読み込めません"
|
|
31
|
+
belongs_to_association: ":%{name} は belongs_to です。%{subject} を指すのではなく、%{subject} から外を指しています"
|
|
32
|
+
no_attachment: "%{subject} に :%{name} という添付はありません"
|
|
33
|
+
no_table: "%{table} というテーブルはありません"
|
|
34
|
+
no_column: "%{table} に %{column} という列はありません"
|
|
35
|
+
reason_required: "%{action} には reason: が必要です"
|
|
36
|
+
reason_not_string: "reason: は文字列にしてください"
|
|
37
|
+
set_required: "scrub には、書き換える列を set: で書いてください"
|
|
38
|
+
where_not_hash: "where: は列と値の Hash にしてください"
|
|
39
|
+
unknown_column: "%{option}: %{table} に %{column} という列はありません"
|
|
40
|
+
unknown_enum_value: "%{option}: %{value} は %{table} の %{column} の enum にない値です"
|
|
41
|
+
retention_not_allowed: "for: は keep と scrub に書くものです(%{action} には書けません)"
|
|
42
|
+
cascade_without_retention: "%{action} の cascade: は lethe:sweep で消すときだけ効きます。for: を足すか、cascade を外してください"
|
|
43
|
+
retention_not_duration: "for: は 2.years のような期間にしてください"
|
|
44
|
+
contradiction: "%{key} には %{other} で別の方針があります。どちらかを where: で絞ってください"
|
|
45
|
+
polymorphic_type_required: "%{key} はポリモーフィックです。他の型の行を消さないよう where: { %{type_column}: \"%{subject_type}\" } を足してください"
|
|
46
|
+
cascade_needs_model: "cascade: true には %{table} のモデルが必要です。モデルがなければ cascade を外してください"
|
|
47
|
+
computed_set_needs_primary_key: "set: に Proc を書くには、1 行ずつ書き換えるため %{table} に主キーが必要です"
|
|
48
|
+
kept_rows_block_destroy: "%{key} は %{subject} への外部キーを持つため、subject destroy: true で行を消すと残せません。論理削除にしてください"
|
|
49
|
+
subject_both: "subject に destroy: と soft_delete: の両方は書けません"
|
|
50
|
+
subject_neither: "subject には destroy: true か soft_delete: :列名 が必要です"
|
|
51
|
+
subject_scrub_not_hash: "subject の scrub: は列と値の Hash にしてください"
|
|
52
|
+
export:
|
|
53
|
+
title: "%{subject} が退会するとどうなるか"
|
|
54
|
+
generated: "lethe が %{path} から書き出しました。手で直さず、bin/rails lethe:export を実行してください。"
|
|
55
|
+
data: データ
|
|
56
|
+
action: 退会すると
|
|
57
|
+
retention: 保存期間
|
|
58
|
+
reason: 理由
|
|
59
|
+
subject_row: "%{subject} 本体"
|
|
60
|
+
attachment: "%{name}(添付)"
|
|
61
|
+
column_suffix: "%{label}(%{column})"
|
|
62
|
+
destroy: 消す
|
|
63
|
+
destroy_attachment: 消す
|
|
64
|
+
scrub: "残す。%{columns} は書き換える"
|
|
65
|
+
keep: 残す
|
|
66
|
+
soft_delete: "削除済みとして残す(%{column})"
|
|
67
|
+
soft_delete_scrub: "削除済みとして残す(%{column})。%{columns} は書き換える"
|
|
68
|
+
where: "%{conditions} のとき: %{action}"
|
|
69
|
+
list_separator: "、"
|
|
70
|
+
parts:
|
|
71
|
+
years: "%{count} 年"
|
|
72
|
+
months: "%{count} か月"
|
|
73
|
+
weeks: "%{count} 週間"
|
|
74
|
+
days: "%{count} 日"
|
|
75
|
+
hours: "%{count} 時間"
|
|
76
|
+
minutes: "%{count} 分"
|
|
77
|
+
seconds: "%{count} 秒"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# Finds the rows a policy applies to, for one subject: the table, the
|
|
5
|
+
# column holding the subject's id, the polymorphic type column when there
|
|
6
|
+
# is one, and the application's model when cascade needs it.
|
|
7
|
+
class Locator
|
|
8
|
+
Location = Data.define(:table, :column, :type_column, :model)
|
|
9
|
+
|
|
10
|
+
def initialize(subject, models:)
|
|
11
|
+
@subject = subject
|
|
12
|
+
@models = models
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def locate(policy)
|
|
16
|
+
target = policy.target
|
|
17
|
+
case target.kind
|
|
18
|
+
when :association then locate_association(target.name)
|
|
19
|
+
when :column then Location.new(table: target.table, column: target.column, type_column: nil, model: model_for(target.table))
|
|
20
|
+
else raise ArgumentError, "lethe: attachments have no table to locate"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Conditions for the rows of one subject id (or several), with the
|
|
25
|
+
# polymorphic type and the policy's own where:.
|
|
26
|
+
def conditions(location, policy, id)
|
|
27
|
+
conditions = {location.column => id}
|
|
28
|
+
conditions[location.type_column] = @subject.polymorphic_name if location.type_column
|
|
29
|
+
conditions.merge(Enums.cast(location.model, policy&.where || {}).transform_keys(&:to_s))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def rows(location)
|
|
33
|
+
Rows.for(location.table)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def locate_association(name)
|
|
39
|
+
reflection = @subject.reflect_on_association(name)
|
|
40
|
+
if reflection.options[:primary_key]
|
|
41
|
+
raise Error, "lethe: :#{name} uses primary_key:, which lethe does not support yet"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if reflection.macro == :has_and_belongs_to_many
|
|
45
|
+
Location.new(table: reflection.join_table.to_s, column: reflection.foreign_key.to_s, type_column: nil, model: nil)
|
|
46
|
+
else
|
|
47
|
+
type_column = reflection.options[:as] ? reflection.type.to_s : nil
|
|
48
|
+
Location.new(
|
|
49
|
+
table: reflection.klass.table_name, column: reflection.foreign_key.to_s,
|
|
50
|
+
type_column: type_column, model: reflection.klass
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def model_for(table)
|
|
56
|
+
@models.find { |model| model.table_name == table && model.base_class == model }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/lethe/plan.rb
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# What erasing one subject record will do, step by step, with the number
|
|
5
|
+
# of rows each step touches. Building a plan changes nothing.
|
|
6
|
+
#
|
|
7
|
+
# Steps run in this order: attachments, deletions (rows that point at
|
|
8
|
+
# other deleted tables first), overwrites, kept rows (nothing happens to
|
|
9
|
+
# them), and the subject's own row last.
|
|
10
|
+
class Plan
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
Step = Data.define(:kind, :key, :policy, :location, :conditions, :count) do
|
|
14
|
+
def to_s
|
|
15
|
+
what =
|
|
16
|
+
case kind
|
|
17
|
+
when :destroy then policy.cascade ? "destroy (cascade)" : "destroy"
|
|
18
|
+
when :scrub then "scrub #{policy.set.keys.join(", ")}"
|
|
19
|
+
when :keep then policy.retention ? "keep for #{policy.retention.inspect}" : "keep"
|
|
20
|
+
when :attachment then "purge"
|
|
21
|
+
else kind.to_s
|
|
22
|
+
end
|
|
23
|
+
"#{what.ljust(20)} #{key.ljust(36)} #{count} #{(count == 1) ? "row" : "rows"}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :record, :definition, :steps
|
|
28
|
+
|
|
29
|
+
def initialize(record, definition, results, models:, connection:)
|
|
30
|
+
@record = record
|
|
31
|
+
@definition = definition
|
|
32
|
+
@connection = connection
|
|
33
|
+
@locator = Locator.new(record.class, models: models)
|
|
34
|
+
@steps = build(results)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def each(&)
|
|
38
|
+
steps.each(&)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_s
|
|
42
|
+
steps.join("\n")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
attr_reader :locator
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build(results)
|
|
50
|
+
covered = results.select { |result| result.status == :covered && result.subject == @definition.subject_name }
|
|
51
|
+
steps = covered.flat_map do |result|
|
|
52
|
+
result.policies.reject(&:ignore?).map { |policy| step_for(result.key, policy) }
|
|
53
|
+
end
|
|
54
|
+
attachments, rest = steps.partition { |step| step.kind == :attachment }
|
|
55
|
+
destroys = rest.select { |step| step.kind == :destroy }
|
|
56
|
+
others = rest - destroys
|
|
57
|
+
attachments + order_destroys(destroys) + others.sort_by { |step| [(step.kind == :scrub) ? 0 : 1, step.key] } + [subject_step]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def step_for(key, policy)
|
|
61
|
+
if policy.action == :destroy_attachment
|
|
62
|
+
return Step.new(kind: :attachment, key: key, policy: policy, location: nil, conditions: nil, count: attachment_count(policy.target.name))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
location = @locator.locate(policy)
|
|
66
|
+
conditions = @locator.conditions(location, policy, @record.id)
|
|
67
|
+
Step.new(
|
|
68
|
+
kind: policy.action, key: key, policy: policy, location: location,
|
|
69
|
+
conditions: conditions, count: @locator.rows(location).where(conditions).count
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attachment_count(name)
|
|
74
|
+
attached = @record.public_send(name)
|
|
75
|
+
if attached.respond_to?(:attachments)
|
|
76
|
+
attached.attachments.size
|
|
77
|
+
else
|
|
78
|
+
(attached.attached? ? 1 : 0)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def subject_step
|
|
83
|
+
table = @record.class.table_name
|
|
84
|
+
location = Locator::Location.new(table: table, column: @record.class.primary_key, type_column: nil, model: nil)
|
|
85
|
+
conditions = {location.column => @record.id}
|
|
86
|
+
count = Rows.for(table).where(conditions).count
|
|
87
|
+
Step.new(kind: :subject, key: "subject", policy: @definition.subject_policy, location: location, conditions: conditions, count: count)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Rows in a table with a foreign key to another table being deleted go first.
|
|
91
|
+
def order_destroys(destroys)
|
|
92
|
+
tables = destroys.map { |step| step.location.table }.uniq
|
|
93
|
+
before = tables.to_h do |table|
|
|
94
|
+
referenced = @connection.foreign_keys(table).map(&:to_table) & tables
|
|
95
|
+
[table, referenced - [table]]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
ordered = []
|
|
99
|
+
remaining = tables.sort
|
|
100
|
+
until remaining.empty?
|
|
101
|
+
ready = remaining.select { |table| remaining.none? { |other| before[other].include?(table) } }
|
|
102
|
+
raise Error, I18n.t("lethe.errors.foreign_key_cycle", tables: remaining.join(", ")) if ready.empty?
|
|
103
|
+
|
|
104
|
+
ordered.concat(ready)
|
|
105
|
+
remaining -= ready
|
|
106
|
+
end
|
|
107
|
+
destroys.sort_by { |step| [ordered.index(step.location.table), step.key] }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/lethe/policy.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# One declared policy for one target.
|
|
5
|
+
#
|
|
6
|
+
# action :destroy, :scrub, :keep, :ignore or :destroy_attachment
|
|
7
|
+
# reason why, in words for people (required for keep and ignore)
|
|
8
|
+
# retention how long a kept or scrubbed row stays (an ActiveSupport::Duration), or nil
|
|
9
|
+
# where a hash of column conditions the policy is limited to, or nil
|
|
10
|
+
# set for scrub: the columns to overwrite and their new values;
|
|
11
|
+
# a Proc is called for each row (with the row, if it takes one)
|
|
12
|
+
# cascade delete through Active Record, so that dependent: associations,
|
|
13
|
+
# callbacks and attachments go too: when erasing, for destroy;
|
|
14
|
+
# when sweeping, for keep and scrub with a retention
|
|
15
|
+
# location "path:line" of the declaration
|
|
16
|
+
Policy = Data.define(:action, :target, :reason, :retention, :where, :set, :cascade, :location) do
|
|
17
|
+
def ignore?
|
|
18
|
+
action == :ignore
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def computed_set?
|
|
22
|
+
set.is_a?(Hash) && set.values.any?(Proc)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# How the subject's own row goes away.
|
|
27
|
+
SubjectPolicy = Data.define(:destroy, :soft_delete, :scrub, :location)
|
|
28
|
+
|
|
29
|
+
# A block to run inside the erasure's transaction: :before or :after.
|
|
30
|
+
Hook = Data.define(:moment, :block, :location)
|
|
31
|
+
end
|