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.
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lethe
4
+ # Adds the rake tasks, and nothing else: lethe does not run inside the application.
5
+ class Railtie < Rails::Railtie
6
+ rake_tasks do
7
+ load File.expand_path("tasks/lethe.rake", __dir__)
8
+ end
9
+ end
10
+ end