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,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# Something that points at a subject: a table's column, or an attachment.
|
|
5
|
+
#
|
|
6
|
+
# vias says how it was found. Foreign keys, associations and attachments
|
|
7
|
+
# are certain; a column name alone, or a polymorphic belongs_to the subject
|
|
8
|
+
# does not answer with an `as:` association, is only a guess.
|
|
9
|
+
Reference = Data.define(:table, :column, :attachment, :vias) do
|
|
10
|
+
def self.column(table, column, via)
|
|
11
|
+
new(table: table.to_s, column: column.to_s, attachment: nil, vias: [via])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.attachment(name)
|
|
15
|
+
new(table: nil, column: nil, attachment: name.to_s, vias: [:attachment])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def key
|
|
19
|
+
attachment ? "attachment:#{attachment}" : "#{table}.#{column}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def certain?
|
|
23
|
+
vias.any? { |via| self.class::CERTAIN_VIAS.include?(via) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def merge(other)
|
|
27
|
+
with(vias: self.class.order(vias + other.vias))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Clues in a fixed order, the most certain first, so output does not
|
|
31
|
+
# depend on which clue found a reference first.
|
|
32
|
+
def self.order(vias)
|
|
33
|
+
vias.uniq.sort_by { |via| self::VIA_ORDER.index(via) || self::VIA_ORDER.size }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_s
|
|
37
|
+
attachment ? "attachment :#{attachment}" : key
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Reference::CERTAIN_VIAS = %i[foreign_key association join_table polymorphic attachment declared].freeze
|
|
42
|
+
Reference::VIA_ORDER = (Reference::CERTAIN_VIAS + %i[polymorphic_guess column_name]).freeze
|
|
43
|
+
end
|
data/lib/lethe/report.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
class Report
|
|
5
|
+
attr_reader :results
|
|
6
|
+
|
|
7
|
+
def initialize(results, strict: false)
|
|
8
|
+
@results = results
|
|
9
|
+
@strict = strict
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def strict?
|
|
13
|
+
@strict
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def problems
|
|
17
|
+
results.select { |result| result.problem?(strict: @strict) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def clean?
|
|
21
|
+
problems.empty?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def exit_status
|
|
25
|
+
clean? ? 0 : 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def count(status)
|
|
29
|
+
results.count { |result| result.status == status }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def subjects
|
|
33
|
+
results.map(&:subject).uniq
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/lethe/result.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# What the check concluded about one reference or one declaration.
|
|
5
|
+
#
|
|
6
|
+
# status :covered a policy applies
|
|
7
|
+
# :ignored declared not to point at the subject
|
|
8
|
+
# :missing certainly points at the subject, and nothing is declared
|
|
9
|
+
# :guess might point at the subject, and nothing is declared
|
|
10
|
+
# :invalid a declaration is wrong or names something that is gone
|
|
11
|
+
# subject the subject class name
|
|
12
|
+
# key "table.column", "attachment:name" or "subject" for the subject's own row
|
|
13
|
+
# reference the Reference, when there is one
|
|
14
|
+
# policies the policies that apply
|
|
15
|
+
# message an I18n key and its values, for :invalid
|
|
16
|
+
# location "path:line" of the declaration, when there is one
|
|
17
|
+
Result = Data.define(:status, :subject, :key, :reference, :policies, :message, :location) do
|
|
18
|
+
def problem?(strict: false)
|
|
19
|
+
status == :missing || status == :invalid || (strict && status == :guess)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/lethe/rows.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# Plain Active Record classes for reaching a table's rows without the
|
|
5
|
+
# application's model: no default scopes, callbacks or single table
|
|
6
|
+
# inheritance get in the way of deleting or overwriting exactly the rows
|
|
7
|
+
# a policy names. One class per table, made once.
|
|
8
|
+
module Rows
|
|
9
|
+
@classes = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
|
|
12
|
+
# Forgets the classes, for when the schema changes in the same process.
|
|
13
|
+
def self.reset!
|
|
14
|
+
@mutex.synchronize { @classes.clear }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.for(table)
|
|
18
|
+
@mutex.synchronize do
|
|
19
|
+
@classes[table] ||= Class.new(ActiveRecord::Base) do
|
|
20
|
+
self.table_name = table
|
|
21
|
+
self.inheritance_column = nil
|
|
22
|
+
|
|
23
|
+
def self.name
|
|
24
|
+
"Lethe::Rows"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The application's models, leaving out the classes lethe makes.
|
|
32
|
+
def self.application_models
|
|
33
|
+
ActiveRecord::Base.descendants.reject do |model|
|
|
34
|
+
model.abstract_class? || model.name.nil? || model.name.start_with?("Lethe::")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/lethe/rules.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# The rules a declared policy must follow once its target is known to
|
|
5
|
+
# exist. Each rule returns the problems it finds, as :invalid Results.
|
|
6
|
+
# They exist so that a definition that would erase the wrong rows, or
|
|
7
|
+
# fail halfway, never passes the check.
|
|
8
|
+
class Rules
|
|
9
|
+
RULES = %i[
|
|
10
|
+
reason_given reason_is_text
|
|
11
|
+
scrub_sets_columns where_names_columns
|
|
12
|
+
retention_only_where_rows_stay cascade_on_kept_rows_needs_retention retention_is_duration
|
|
13
|
+
enum_values_exist polymorphic_type_given cascade_has_model computed_values_have_primary_key
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# schema answers column_names(table), model_for_table(table) and
|
|
17
|
+
# primary_key(table); invalid builds the :invalid Result for a policy.
|
|
18
|
+
def initialize(subject, schema, &invalid)
|
|
19
|
+
@subject = subject
|
|
20
|
+
@schema = schema
|
|
21
|
+
@invalid = invalid
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(policy, key)
|
|
25
|
+
table = key.start_with?("attachment:") ? nil : key.split(".", 2).first
|
|
26
|
+
RULES.flat_map { |rule| Array(send(rule, policy, key, table)) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# keep and ignore are decisions to leave data in place; the export and
|
|
32
|
+
# anyone auditing it need to know why.
|
|
33
|
+
def reason_given(policy, _key, _table)
|
|
34
|
+
return unless %i[keep ignore].include?(policy.action) && blank?(policy.reason)
|
|
35
|
+
|
|
36
|
+
problem(policy, "reason_required", action: policy.action)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reason_is_text(policy, _key, _table)
|
|
40
|
+
problem(policy, "reason_not_string") unless policy.reason.nil? || policy.reason.is_a?(String)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scrub_sets_columns(policy, _key, table)
|
|
44
|
+
return unless policy.action == :scrub
|
|
45
|
+
return problem(policy, "set_required") if !policy.set.is_a?(Hash) || policy.set.empty?
|
|
46
|
+
|
|
47
|
+
unknown_columns(policy, table, policy.set.keys, "set")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def where_names_columns(policy, _key, table)
|
|
51
|
+
return if policy.where.nil?
|
|
52
|
+
return problem(policy, "where_not_hash") if !policy.where.is_a?(Hash) || policy.where.empty?
|
|
53
|
+
|
|
54
|
+
unknown_columns(policy, table, policy.where.keys, "where")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Only rows that stay can stay for a while.
|
|
58
|
+
def retention_only_where_rows_stay(policy, _key, _table)
|
|
59
|
+
return unless policy.retention && !staying?(policy)
|
|
60
|
+
|
|
61
|
+
problem(policy, "retention_not_allowed", action: policy.action)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def retention_is_duration(policy, _key, _table)
|
|
65
|
+
return if policy.retention.nil? || policy.retention.is_a?(ActiveSupport::Duration)
|
|
66
|
+
|
|
67
|
+
problem(policy, "retention_not_duration")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# On keep and scrub, cascade only changes how lethe:sweep deletes.
|
|
71
|
+
def cascade_on_kept_rows_needs_retention(policy, _key, _table)
|
|
72
|
+
return unless policy.cascade && staying?(policy) && policy.retention.nil?
|
|
73
|
+
|
|
74
|
+
problem(policy, "cascade_without_retention", action: policy.action)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Rows are reached without the application's model, so an enum name the
|
|
78
|
+
# enum does not have would silently match nothing.
|
|
79
|
+
def enum_values_exist(policy, _key, table)
|
|
80
|
+
return unless table
|
|
81
|
+
|
|
82
|
+
model = @schema.model_for_table(table)
|
|
83
|
+
{"where" => policy.where, "set" => (policy.action == :scrub) ? policy.set : nil}.flat_map do |option, hash|
|
|
84
|
+
Enums.unknown(model, hash).map do |column, value|
|
|
85
|
+
problem(policy, "unknown_enum_value", option: option, table: table, column: column, value: value.inspect)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# A "table.column" for a polymorphic *_id must say which type it means,
|
|
91
|
+
# or erasing User 5 would also erase what belongs to Post 5.
|
|
92
|
+
def polymorphic_type_given(policy, key, table)
|
|
93
|
+
return unless table && policy.target.kind == :column
|
|
94
|
+
|
|
95
|
+
column = key.split(".", 2).last
|
|
96
|
+
return unless column.end_with?("_id")
|
|
97
|
+
|
|
98
|
+
type_column = "#{column.delete_suffix("_id")}_type"
|
|
99
|
+
return unless @schema.column_names(table).include?(type_column)
|
|
100
|
+
return if policy.where.is_a?(Hash) && policy.where.keys.map(&:to_s).include?(type_column)
|
|
101
|
+
|
|
102
|
+
problem(policy, "polymorphic_type_required", key: key, type_column: type_column, subject_type: @subject.polymorphic_name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def cascade_has_model(policy, _key, table)
|
|
106
|
+
problem(policy, "cascade_needs_model", table: table) if table && policy.cascade && !@schema.model_for_table(table)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# A Proc in set: is evaluated row by row, which needs a key to update by.
|
|
110
|
+
def computed_values_have_primary_key(policy, _key, table)
|
|
111
|
+
return unless table && policy.action == :scrub && policy.computed_set?
|
|
112
|
+
|
|
113
|
+
problem(policy, "computed_set_needs_primary_key", table: table) unless @schema.primary_key(table)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def unknown_columns(policy, table, names, option)
|
|
117
|
+
return [] unless table
|
|
118
|
+
|
|
119
|
+
(names.map(&:to_s) - @schema.column_names(table)).map do |column|
|
|
120
|
+
problem(policy, "unknown_column", option: option, table: table, column: column)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def staying?(policy)
|
|
125
|
+
%i[keep scrub].include?(policy.action)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def problem(policy, message_key, **values)
|
|
129
|
+
@invalid.call(policy, message_key, **values)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def blank?(value)
|
|
133
|
+
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/lethe/runner.rb
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Lethe
|
|
6
|
+
# Runs lethe:check and lethe:export: reads the options from the environment,
|
|
7
|
+
# prints, and returns the exit status (0 clean, 1 problems, 2 cannot check).
|
|
8
|
+
class Runner
|
|
9
|
+
def initialize(root:, env: ENV, out: $stdout, err: $stderr, models: nil)
|
|
10
|
+
@root = File.expand_path(root)
|
|
11
|
+
@env = env
|
|
12
|
+
@out = out
|
|
13
|
+
@err = err
|
|
14
|
+
@models = models
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check
|
|
18
|
+
report = run_check or return 2
|
|
19
|
+
formatter = Formatters.for(@env["FORMAT"]).new(report, root: @root, locale: locale, definition_path: definition_path)
|
|
20
|
+
@out.print formatter.render
|
|
21
|
+
report.exit_status
|
|
22
|
+
rescue ArgumentError => error
|
|
23
|
+
@err.puts error.message
|
|
24
|
+
2
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def export
|
|
28
|
+
report = run_check or return 2
|
|
29
|
+
unless report.clean?
|
|
30
|
+
@err.print Formatters::Text.new(report, root: @root, locale: locale, definition_path: definition_path).render
|
|
31
|
+
return 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
exporter = Exporter.new(
|
|
35
|
+
report, definitions: Lethe.definitions.values, models: models, root: @root,
|
|
36
|
+
definition_path: definition_path, locale: locale
|
|
37
|
+
)
|
|
38
|
+
files = {"#{export_path}.md" => exporter.markdown, "#{export_path}.json" => exporter.json}
|
|
39
|
+
truthy?(@env["CHECK"]) ? compare(files) : write(files)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def sweep
|
|
43
|
+
return 2 unless valid_locale?
|
|
44
|
+
|
|
45
|
+
Lethe.load_definitions(definition_path)
|
|
46
|
+
swept = Lethe.sweep!(models: models)
|
|
47
|
+
lines = swept.flat_map { |subject, counts| counts.map { |key, count| "lethe: swept #{count} from #{subject} #{key}" } }
|
|
48
|
+
@out.puts(lines.empty? ? "lethe: nothing to sweep" : lines)
|
|
49
|
+
0
|
|
50
|
+
rescue Error => error
|
|
51
|
+
@err.puts error.message
|
|
52
|
+
2
|
|
53
|
+
rescue ScriptError, StandardError => error
|
|
54
|
+
@err.puts I18n.t("lethe.errors.load_failed", path: relative(definition_path), error: "#{error.class}: #{error.message}", locale: locale)
|
|
55
|
+
2
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def run_check
|
|
61
|
+
return nil unless valid_locale?
|
|
62
|
+
|
|
63
|
+
Lethe.load_definitions(definition_path)
|
|
64
|
+
Lethe.check(models: models, strict: truthy?(@env["STRICT"]))
|
|
65
|
+
rescue DefinitionError => error
|
|
66
|
+
@err.puts error.message
|
|
67
|
+
nil
|
|
68
|
+
rescue ScriptError, StandardError => error
|
|
69
|
+
@err.puts I18n.t("lethe.errors.load_failed", path: relative(definition_path), error: "#{error.class}: #{error.message}", locale: locale)
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def compare(files)
|
|
74
|
+
stale = files.reject { |path, content| File.file?(path) && File.read(path) == content }
|
|
75
|
+
if stale.empty?
|
|
76
|
+
@out.puts "lethe: #{files.keys.map { |path| relative(path) }.join(", ")} up to date"
|
|
77
|
+
0
|
|
78
|
+
else
|
|
79
|
+
@err.puts "lethe: #{stale.keys.map { |path| relative(path) }.join(", ")} out of date; run bin/rails lethe:export"
|
|
80
|
+
1
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def write(files)
|
|
85
|
+
files.each do |path, content|
|
|
86
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
87
|
+
File.write(path, content)
|
|
88
|
+
@out.puts "lethe: wrote #{relative(path)}"
|
|
89
|
+
end
|
|
90
|
+
0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def models
|
|
94
|
+
@models || Lethe.application_models
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def definition_path
|
|
98
|
+
File.expand_path(Lethe.configuration.definition_path, @root)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def export_path
|
|
102
|
+
File.expand_path(Lethe.configuration.export_path, @root)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def locale
|
|
106
|
+
(@env["LOCALE"].nil? || @env["LOCALE"].empty?) ? I18n.default_locale : @env["LOCALE"].to_sym
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def valid_locale?
|
|
110
|
+
return true if I18n.available_locales.include?(locale)
|
|
111
|
+
|
|
112
|
+
@err.puts "lethe: LOCALE=#{locale} is not one of #{I18n.available_locales.join(", ")}"
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def truthy?(value)
|
|
117
|
+
%w[1 true yes].include?(value.to_s.downcase)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def relative(path)
|
|
121
|
+
Pathname(path).relative_path_from(Pathname(@root)).to_s
|
|
122
|
+
rescue ArgumentError
|
|
123
|
+
path.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# Deletes kept rows whose time is up: `keep ..., for: 2.years` (or
|
|
5
|
+
# `scrub ..., for:`) keeps a subject's rows for two years after it was
|
|
6
|
+
# first erased, as recorded in lethe_erasures, and this deletes them afterwards.
|
|
7
|
+
class Sweeper
|
|
8
|
+
def initialize(definitions, models:, now: Time.current)
|
|
9
|
+
@definitions = definitions
|
|
10
|
+
@models = models
|
|
11
|
+
@now = now
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Returns { "User" => { "inquiries.user_id" => 3 } } for what it deleted.
|
|
15
|
+
def call
|
|
16
|
+
@definitions.each_with_object({}) do |definition, swept|
|
|
17
|
+
policies = definition.policies.select { |policy| %i[keep scrub].include?(policy.action) && policy.retention }
|
|
18
|
+
next if policies.empty?
|
|
19
|
+
|
|
20
|
+
subject = definition.subject_name.constantize
|
|
21
|
+
erased = Ledger.erased(definition.subject_name)
|
|
22
|
+
locator = Locator.new(subject, models: @models)
|
|
23
|
+
policies.each do |policy|
|
|
24
|
+
ids = erased.select { |_, at| at <= @now - policy.retention }.keys
|
|
25
|
+
count = sweep(locator, policy, ids, subject)
|
|
26
|
+
(swept[subject.name] ||= {})[policy.target.to_s] = count if count.positive?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def sweep(locator, policy, ids, subject)
|
|
34
|
+
location = locator.locate(policy)
|
|
35
|
+
rows = locator.rows(location)
|
|
36
|
+
ids.each_slice(500).sum do |slice|
|
|
37
|
+
present = rows.where(locator.conditions(location, policy, slice)).distinct.pluck(location.column)
|
|
38
|
+
present.sum do |id|
|
|
39
|
+
ActiveRecord::Base.transaction do
|
|
40
|
+
relation = policy.cascade ? location.model.unscoped : rows
|
|
41
|
+
relation = relation.where(locator.conditions(location, policy, id))
|
|
42
|
+
count = policy.cascade ? relation.destroy_all.size : relation.delete_all
|
|
43
|
+
Ledger.record(subject.name, id, "sweep", {policy.target.to_s => {"sweep" => count}})
|
|
44
|
+
count
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/lethe/target.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lethe
|
|
4
|
+
# What a policy points at, as written in the definitions:
|
|
5
|
+
# an association of the subject (:ratings), a "table.column" string,
|
|
6
|
+
# or an attachment of the subject.
|
|
7
|
+
Target = Data.define(:kind, :name, :table, :column) do
|
|
8
|
+
def self.parse(value)
|
|
9
|
+
case value
|
|
10
|
+
when Symbol
|
|
11
|
+
new(kind: :association, name: value, table: nil, column: nil)
|
|
12
|
+
when String
|
|
13
|
+
table, column, rest = value.split(".")
|
|
14
|
+
unless rest.nil? && present?(table) && present?(column)
|
|
15
|
+
raise ArgumentError, "lethe: #{value.inspect} is not \"table.column\"; use a symbol for an association"
|
|
16
|
+
end
|
|
17
|
+
new(kind: :column, name: value, table: table, column: column)
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "lethe: #{value.inspect} must be an association name (a Symbol) or \"table.column\""
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.attachment(value)
|
|
24
|
+
raise ArgumentError, "lethe: attachment #{value.inspect} must be a Symbol" unless value.is_a?(Symbol)
|
|
25
|
+
|
|
26
|
+
new(kind: :attachment, name: value, table: nil, column: nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.present?(part)
|
|
30
|
+
!part.nil? && !part.empty?
|
|
31
|
+
end
|
|
32
|
+
private_class_method :present?
|
|
33
|
+
|
|
34
|
+
def to_s
|
|
35
|
+
case kind
|
|
36
|
+
when :association then ":#{name}"
|
|
37
|
+
when :attachment then "attachment :#{name}"
|
|
38
|
+
else name
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Exit only on failure, so that the tasks can be chained:
|
|
4
|
+
# bin/rails lethe:check lethe:export
|
|
5
|
+
namespace :lethe do
|
|
6
|
+
desc "Check that everything pointing at a subject has a policy (FORMAT=text|github|json STRICT=1 LOCALE=en|ja)"
|
|
7
|
+
task check: :environment do
|
|
8
|
+
Rails.application.eager_load!
|
|
9
|
+
status = Lethe::Runner.new(root: Rails.root).check
|
|
10
|
+
exit status unless status.zero?
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "Write what happens to each kind of data when a subject leaves (CHECK=1 to compare instead, LOCALE=en|ja)"
|
|
14
|
+
task export: :environment do
|
|
15
|
+
Rails.application.eager_load!
|
|
16
|
+
status = Lethe::Runner.new(root: Rails.root).export
|
|
17
|
+
exit status unless status.zero?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "Delete kept rows whose `for:` has passed since their subject was erased"
|
|
21
|
+
task sweep: :environment do
|
|
22
|
+
Rails.application.eager_load!
|
|
23
|
+
status = Lethe::Runner.new(root: Rails.root).sweep
|
|
24
|
+
exit status unless status.zero?
|
|
25
|
+
end
|
|
26
|
+
end
|