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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lethe
4
+ VERSION = "0.1.0"
5
+ end