athar 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 +29 -0
- data/LICENSE.txt +21 -0
- data/README.md +417 -0
- data/lib/athar/actor_lookup.rb +31 -0
- data/lib/athar/configuration.rb +35 -0
- data/lib/athar/context.rb +121 -0
- data/lib/athar/deletion.rb +61 -0
- data/lib/athar/engine.rb +11 -0
- data/lib/athar/metadata_stack.rb +37 -0
- data/lib/athar/retention.rb +131 -0
- data/lib/athar/retention_job.rb +11 -0
- data/lib/athar/sql.rb +67 -0
- data/lib/athar/table_event.rb +27 -0
- data/lib/athar/version.rb +5 -0
- data/lib/athar.rb +61 -0
- data/lib/generators/athar/fx_helper.rb +60 -0
- data/lib/generators/athar/install/functions/athar_capture_delete.sql +111 -0
- data/lib/generators/athar/install/functions/athar_capture_truncate.sql.erb +51 -0
- data/lib/generators/athar/install/functions/athar_filter_keys.sql +29 -0
- data/lib/generators/athar/install/install_generator.rb +116 -0
- data/lib/generators/athar/install/templates/install_migration.rb.erb +80 -0
- data/lib/generators/athar/install/templates/install_migration_fx.rb.erb +73 -0
- data/lib/generators/athar/model/model_generator.rb +344 -0
- data/lib/generators/athar/model/templates/migration.rb.erb +47 -0
- data/lib/generators/athar/model/templates/migration_fx.rb.erb +29 -0
- data/lib/generators/athar/model/triggers/athar_delete.sql.erb +14 -0
- data/lib/generators/athar/model/triggers/athar_truncate.sql.erb +8 -0
- metadata +144 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "actor_lookup"
|
|
4
|
+
|
|
5
|
+
module Athar
|
|
6
|
+
class Deletion < ActiveRecord::Base
|
|
7
|
+
include ActorLookup
|
|
8
|
+
|
|
9
|
+
self.table_name = Athar::DELETIONS_TABLE_NAME
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def recent
|
|
13
|
+
order(deleted_at: :desc, id: :desc)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def for_record(record_or_class, id = nil)
|
|
17
|
+
# Filter by (schema_name, table_name, record_id):
|
|
18
|
+
# - `record_type` is unreliable because STI subclass rows are stored
|
|
19
|
+
# under the concrete class name (`"Admin"`) while
|
|
20
|
+
# `Admin.base_class.name` is `"User"`.
|
|
21
|
+
# - Active Record returns `"reporting.reporting_buckets"` for models
|
|
22
|
+
# in non-public schemas, but Athar stores schema and table in
|
|
23
|
+
# separate columns. Splitting on `.` mirrors what the generator
|
|
24
|
+
# does at trigger-install time.
|
|
25
|
+
if id.nil? && record_or_class.is_a?(ActiveRecord::Base)
|
|
26
|
+
schema, table = split_schema_qualified(record_or_class.class.table_name)
|
|
27
|
+
where(schema_name: schema, table_name: table, record_id: record_or_class.id)
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, "id is required when passing a record class or type" if id.nil?
|
|
30
|
+
|
|
31
|
+
klass = record_or_class.is_a?(Class) ? record_or_class : record_or_class.to_s.constantize
|
|
32
|
+
schema, table = split_schema_qualified(klass.table_name)
|
|
33
|
+
where(schema_name: schema, table_name: table, record_id: id)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def for_record_type(type)
|
|
38
|
+
where(record_type: type.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def for_table(table_name)
|
|
42
|
+
where(table_name: table_name.to_s)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def before(time)
|
|
46
|
+
where(arel_table[:deleted_at].lt(time))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def after(time)
|
|
50
|
+
where(arel_table[:deleted_at].gt(time))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def split_schema_qualified(qualified)
|
|
56
|
+
full = qualified.to_s
|
|
57
|
+
full.include?(".") ? full.split(".", 2) : ["public", full]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/athar/engine.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/isolated_execution_state"
|
|
4
|
+
|
|
5
|
+
module Athar
|
|
6
|
+
# Fiber-aware metadata stack used by Athar::Context to merge nested
|
|
7
|
+
# `with_metadata` blocks. Storage is delegated to ActiveSupport's
|
|
8
|
+
# IsolatedExecutionState so the stack tracks the request/job execution
|
|
9
|
+
# boundary correctly under fiber-based runtimes (Solid Queue, Falcon, etc.).
|
|
10
|
+
class MetadataStack
|
|
11
|
+
STATE_KEY = :athar_metadata_stack
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def push(meta)
|
|
15
|
+
stack << meta
|
|
16
|
+
current
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pop
|
|
20
|
+
stack.pop
|
|
21
|
+
current
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def current
|
|
25
|
+
stack.reduce({}) { |acc, meta| acc.merge(meta) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear!
|
|
29
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stack
|
|
33
|
+
ActiveSupport::IsolatedExecutionState[STATE_KEY] ||= []
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Athar
|
|
4
|
+
module Retention
|
|
5
|
+
Result = Struct.new(:deleted_by_age, :deleted_by_count, :table_events_deleted, :batches, keyword_init: true) do
|
|
6
|
+
def total_deleted
|
|
7
|
+
deleted_by_age + deleted_by_count + table_events_deleted
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def prune!(max_age: nil, max_count: nil, batch_size: nil, max_batches: nil, prune_table_events: nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
|
|
13
|
+
config = Athar.configuration.retention
|
|
14
|
+
max_age ||= config.max_age
|
|
15
|
+
max_count ||= config.max_count
|
|
16
|
+
batch_size ||= config.batch_size
|
|
17
|
+
max_batches ||= config.max_batches_per_run
|
|
18
|
+
prune_table_events = config.prune_table_events if prune_table_events.nil?
|
|
19
|
+
|
|
20
|
+
result = Result.new(deleted_by_age: 0, deleted_by_count: 0, table_events_deleted: 0, batches: 0)
|
|
21
|
+
|
|
22
|
+
if max_age
|
|
23
|
+
cutoff = Time.current - max_age
|
|
24
|
+
result.deleted_by_age, age_batches = prune_by_age(
|
|
25
|
+
:athar_deletions,
|
|
26
|
+
"deleted_at",
|
|
27
|
+
cutoff,
|
|
28
|
+
batch_size,
|
|
29
|
+
max_batches
|
|
30
|
+
)
|
|
31
|
+
result.batches += age_batches
|
|
32
|
+
|
|
33
|
+
if prune_table_events
|
|
34
|
+
result.table_events_deleted, table_event_batches = prune_by_age(
|
|
35
|
+
:athar_table_events,
|
|
36
|
+
"occurred_at",
|
|
37
|
+
cutoff,
|
|
38
|
+
batch_size,
|
|
39
|
+
[max_batches - result.batches, 0].max
|
|
40
|
+
)
|
|
41
|
+
result.batches += table_event_batches
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if max_count && result.batches < max_batches
|
|
46
|
+
result.deleted_by_count, count_batches = prune_by_count(
|
|
47
|
+
:athar_deletions,
|
|
48
|
+
max_count,
|
|
49
|
+
batch_size,
|
|
50
|
+
max_batches - result.batches
|
|
51
|
+
)
|
|
52
|
+
result.batches += count_batches
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def prune_by_age(table, time_column, cutoff, batch_size, max_batches) # rubocop:disable Metrics/MethodLength
|
|
61
|
+
return [0, 0] if max_batches <= 0
|
|
62
|
+
|
|
63
|
+
connection = ActiveRecord::Base.connection
|
|
64
|
+
total = 0
|
|
65
|
+
batches = 0
|
|
66
|
+
loop do
|
|
67
|
+
break if batches >= max_batches
|
|
68
|
+
|
|
69
|
+
deleted = connection.delete(
|
|
70
|
+
<<~SQL
|
|
71
|
+
DELETE FROM #{table}
|
|
72
|
+
WHERE id IN (
|
|
73
|
+
SELECT id FROM #{table}
|
|
74
|
+
WHERE #{time_column} < #{connection.quote(cutoff)}
|
|
75
|
+
ORDER BY #{time_column} ASC
|
|
76
|
+
LIMIT #{batch_size.to_i}
|
|
77
|
+
)
|
|
78
|
+
SQL
|
|
79
|
+
)
|
|
80
|
+
batches += 1
|
|
81
|
+
total += deleted
|
|
82
|
+
break if deleted < batch_size.to_i
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
[total, batches]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def prune_by_count(table, max_count, batch_size, max_batches) # rubocop:disable Metrics/MethodLength
|
|
89
|
+
return [0, 0] if max_batches <= 0
|
|
90
|
+
|
|
91
|
+
connection = ActiveRecord::Base.connection
|
|
92
|
+
boundary = connection.select_one(
|
|
93
|
+
<<~SQL
|
|
94
|
+
SELECT deleted_at, id
|
|
95
|
+
FROM #{table}
|
|
96
|
+
ORDER BY deleted_at DESC, id DESC
|
|
97
|
+
OFFSET #{max_count.to_i}
|
|
98
|
+
LIMIT 1
|
|
99
|
+
SQL
|
|
100
|
+
)
|
|
101
|
+
return [0, 0] unless boundary
|
|
102
|
+
|
|
103
|
+
boundary_deleted_at = connection.quote(boundary.fetch("deleted_at"))
|
|
104
|
+
boundary_id = connection.quote(boundary.fetch("id"))
|
|
105
|
+
|
|
106
|
+
total = 0
|
|
107
|
+
batches = 0
|
|
108
|
+
loop do
|
|
109
|
+
break if batches >= max_batches
|
|
110
|
+
|
|
111
|
+
deleted = connection.delete(
|
|
112
|
+
<<~SQL
|
|
113
|
+
DELETE FROM #{table}
|
|
114
|
+
WHERE id IN (
|
|
115
|
+
SELECT id FROM #{table}
|
|
116
|
+
WHERE (deleted_at, id) <= (#{boundary_deleted_at}, #{boundary_id})
|
|
117
|
+
ORDER BY deleted_at ASC, id ASC
|
|
118
|
+
LIMIT #{batch_size.to_i}
|
|
119
|
+
)
|
|
120
|
+
SQL
|
|
121
|
+
)
|
|
122
|
+
batches += 1
|
|
123
|
+
total += deleted
|
|
124
|
+
break if deleted < batch_size.to_i
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
[total, batches]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Athar
|
|
4
|
+
class RetentionJob < ActiveJob::Base
|
|
5
|
+
queue_as { Athar.configuration.retention.queue_name || :default }
|
|
6
|
+
|
|
7
|
+
def perform(max_age: nil, max_count: nil, batch_size: nil, max_batches: nil)
|
|
8
|
+
Athar::Retention.prune!(max_age:, max_count:, batch_size:, max_batches:)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/athar/sql.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Athar
|
|
6
|
+
module SQL
|
|
7
|
+
GENERATORS_ROOT = File.expand_path("../generators/athar", __dir__)
|
|
8
|
+
|
|
9
|
+
INSTALL_FUNCTIONS_DIR = File.join(GENERATORS_ROOT, "install", "functions")
|
|
10
|
+
MODEL_TRIGGERS_DIR = File.join(GENERATORS_ROOT, "model", "triggers")
|
|
11
|
+
|
|
12
|
+
STATIC_FUNCTIONS = %w[
|
|
13
|
+
athar_filter_keys
|
|
14
|
+
athar_capture_delete
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
TEMPLATE_FUNCTIONS = %w[
|
|
18
|
+
athar_capture_truncate
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
INSTALLED_FUNCTIONS = (STATIC_FUNCTIONS + TEMPLATE_FUNCTIONS).freeze
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def read_function(name, locals = {})
|
|
25
|
+
if STATIC_FUNCTIONS.include?(name)
|
|
26
|
+
File.read(File.join(INSTALL_FUNCTIONS_DIR, "#{name}.sql"))
|
|
27
|
+
elsif TEMPLATE_FUNCTIONS.include?(name)
|
|
28
|
+
path = File.join(INSTALL_FUNCTIONS_DIR, "#{name}.sql.erb")
|
|
29
|
+
render(File.read(path), locals)
|
|
30
|
+
else
|
|
31
|
+
raise ArgumentError, "unknown SQL function: #{name.inspect}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def all_functions(locals = {})
|
|
36
|
+
INSTALLED_FUNCTIONS.to_h { |name| [name, read_function(name, locals)] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def function_signature(name)
|
|
40
|
+
case name
|
|
41
|
+
when "athar_filter_keys" then "jsonb, text[]"
|
|
42
|
+
when "athar_capture_delete", "athar_capture_truncate" then ""
|
|
43
|
+
else
|
|
44
|
+
raise ArgumentError, "unknown SQL function: #{name.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render(template, locals)
|
|
49
|
+
Render.new(locals).result(template)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class Render
|
|
54
|
+
def initialize(locals)
|
|
55
|
+
@locals = locals
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def result(template)
|
|
59
|
+
binding_context = binding
|
|
60
|
+
@locals.each do |key, value|
|
|
61
|
+
binding_context.local_variable_set(key, value)
|
|
62
|
+
end
|
|
63
|
+
ERB.new(template, trim_mode: "-").result(binding_context)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "actor_lookup"
|
|
4
|
+
|
|
5
|
+
module Athar
|
|
6
|
+
class TableEvent < ActiveRecord::Base
|
|
7
|
+
include ActorLookup
|
|
8
|
+
|
|
9
|
+
EVENT_TYPE_TRUNCATE = "truncate"
|
|
10
|
+
|
|
11
|
+
self.table_name = Athar::TABLE_EVENTS_TABLE_NAME
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def recent
|
|
15
|
+
order(occurred_at: :desc, id: :desc)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def for_table(table_name)
|
|
19
|
+
where(table_name: table_name.to_s)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def truncate
|
|
23
|
+
where(event_type: EVENT_TYPE_TRUNCATE)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/athar.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support"
|
|
4
|
+
require "active_support/core_ext/numeric/time"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
6
|
+
require "active_support/json"
|
|
7
|
+
require "active_record"
|
|
8
|
+
require "active_job"
|
|
9
|
+
require "fx"
|
|
10
|
+
|
|
11
|
+
require_relative "athar/version"
|
|
12
|
+
require_relative "athar/configuration"
|
|
13
|
+
require_relative "athar/sql"
|
|
14
|
+
require_relative "athar/metadata_stack"
|
|
15
|
+
require_relative "athar/context"
|
|
16
|
+
require_relative "athar/deletion"
|
|
17
|
+
require_relative "athar/table_event"
|
|
18
|
+
require_relative "athar/retention"
|
|
19
|
+
|
|
20
|
+
module Athar
|
|
21
|
+
class Error < StandardError; end
|
|
22
|
+
|
|
23
|
+
class ConfigurationError < Error; end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def configuration
|
|
27
|
+
@configuration ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configure
|
|
31
|
+
yield(configuration)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def reset_configuration!
|
|
35
|
+
@configuration = Configuration.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def logger
|
|
39
|
+
configuration.logger || (Rails.logger if defined?(Rails)) || Logger.new($stdout)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def with_actor(...)
|
|
43
|
+
Context.with_actor(...)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def with_metadata(...)
|
|
47
|
+
Context.with_metadata(...)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def with_context(...)
|
|
51
|
+
Context.with_context(...)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def without_capture(...)
|
|
55
|
+
Context.without_capture(...)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
require_relative "athar/retention_job" if defined?(ActiveJob)
|
|
61
|
+
require_relative "athar/engine" if defined?(Rails::Engine)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fx"
|
|
4
|
+
|
|
5
|
+
module Athar
|
|
6
|
+
module Generators
|
|
7
|
+
module FxHelper
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.class_option :fx,
|
|
10
|
+
type: :boolean,
|
|
11
|
+
optional: true,
|
|
12
|
+
desc: "Use the fx gem to manage SQL functions and triggers (default when fx is loaded)."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fx?
|
|
16
|
+
return true if options[:fx] == true
|
|
17
|
+
return false if options[:fx] == false
|
|
18
|
+
|
|
19
|
+
defined?(::Fx::SchemaDumper) ? true : false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def schema_format
|
|
23
|
+
return :ruby unless Rails.application
|
|
24
|
+
|
|
25
|
+
Rails.application.config.active_record.schema_format || :ruby
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ensure_raw_sql_supported!
|
|
29
|
+
return if schema_format == :sql
|
|
30
|
+
|
|
31
|
+
raise ::Thor::Error,
|
|
32
|
+
"Athar requires the fx gem (default) or `config.active_record.schema_format = :sql` " \
|
|
33
|
+
"when using --no-fx. Either install fx or switch the host app to SQL schema dumps."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Indent each non-blank line of `text` by `spaces` spaces.
|
|
37
|
+
def indent_sql(text, spaces)
|
|
38
|
+
prefix = " " * spaces
|
|
39
|
+
text.each_line.map { |line| line.strip.empty? ? line : prefix + line }.join
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resolve the app-wide Rails generator primary/foreign-key types.
|
|
43
|
+
def athar_primary_key_type
|
|
44
|
+
primary_key_setting || :primary_key
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def athar_foreign_key_type
|
|
48
|
+
primary_key_setting || :bigint
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def primary_key_setting
|
|
52
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:configuration)
|
|
53
|
+
|
|
54
|
+
generators_config = ::Rails.configuration.generators
|
|
55
|
+
orm = generators_config.orm
|
|
56
|
+
generators_config.options[orm][:primary_key_type]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
-- Athar capture delete trigger function (v1).
|
|
2
|
+
-- Args:
|
|
3
|
+
-- TG_ARGV[0] record_type (e.g. 'User') or 'null'
|
|
4
|
+
-- TG_ARGV[1] schema_name (e.g. 'public')
|
|
5
|
+
-- TG_ARGV[2] table_name (e.g. 'users')
|
|
6
|
+
-- TG_ARGV[3] primary_key (e.g. 'id')
|
|
7
|
+
-- TG_ARGV[4] id_type ('bigint' | 'integer' | 'uuid')
|
|
8
|
+
-- TG_ARGV[5] record_type_column ('type' | 'null')
|
|
9
|
+
-- TG_ARGV[6] capture_mode ('identity' | 'only' | 'snapshot')
|
|
10
|
+
-- TG_ARGV[7] columns ('{email,name}' or 'null')
|
|
11
|
+
|
|
12
|
+
CREATE OR REPLACE FUNCTION athar_capture_delete()
|
|
13
|
+
RETURNS trigger AS $$
|
|
14
|
+
DECLARE
|
|
15
|
+
arg_record_type text;
|
|
16
|
+
arg_schema_name text;
|
|
17
|
+
arg_table_name text;
|
|
18
|
+
arg_primary_key text;
|
|
19
|
+
arg_id_type text;
|
|
20
|
+
arg_record_type_column text;
|
|
21
|
+
arg_capture_mode text;
|
|
22
|
+
arg_columns text[];
|
|
23
|
+
|
|
24
|
+
full_row jsonb;
|
|
25
|
+
filtered_data jsonb;
|
|
26
|
+
meta jsonb;
|
|
27
|
+
meta_text text;
|
|
28
|
+
computed_record_type text;
|
|
29
|
+
computed_record_id text;
|
|
30
|
+
computed_actor_type text;
|
|
31
|
+
computed_actor_id text;
|
|
32
|
+
BEGIN
|
|
33
|
+
arg_record_type := NULLIF(TG_ARGV[0], 'null');
|
|
34
|
+
arg_schema_name := NULLIF(TG_ARGV[1], 'null');
|
|
35
|
+
arg_table_name := NULLIF(TG_ARGV[2], 'null');
|
|
36
|
+
arg_primary_key := NULLIF(TG_ARGV[3], 'null');
|
|
37
|
+
arg_id_type := NULLIF(TG_ARGV[4], 'null');
|
|
38
|
+
arg_record_type_column := NULLIF(TG_ARGV[5], 'null');
|
|
39
|
+
arg_capture_mode := NULLIF(TG_ARGV[6], 'null');
|
|
40
|
+
arg_columns := NULLIF(TG_ARGV[7], 'null')::text[];
|
|
41
|
+
|
|
42
|
+
full_row := to_jsonb(OLD);
|
|
43
|
+
|
|
44
|
+
computed_record_type := arg_record_type;
|
|
45
|
+
IF arg_record_type_column IS NOT NULL AND full_row ? arg_record_type_column THEN
|
|
46
|
+
IF (full_row ->> arg_record_type_column) IS NOT NULL THEN
|
|
47
|
+
computed_record_type := full_row ->> arg_record_type_column;
|
|
48
|
+
END IF;
|
|
49
|
+
END IF;
|
|
50
|
+
|
|
51
|
+
IF arg_capture_mode = 'identity' THEN
|
|
52
|
+
filtered_data := '{}'::jsonb;
|
|
53
|
+
ELSIF arg_capture_mode = 'snapshot' THEN
|
|
54
|
+
filtered_data := full_row;
|
|
55
|
+
ELSIF arg_capture_mode = 'only' THEN
|
|
56
|
+
filtered_data := athar_filter_keys(full_row, arg_columns);
|
|
57
|
+
ELSE
|
|
58
|
+
RAISE EXCEPTION 'Unsupported Athar capture mode: %', arg_capture_mode;
|
|
59
|
+
END IF;
|
|
60
|
+
|
|
61
|
+
meta := '{}'::jsonb;
|
|
62
|
+
meta_text := current_setting('athar.meta', true);
|
|
63
|
+
IF coalesce(meta_text, '') <> '' THEN
|
|
64
|
+
meta := meta_text::jsonb;
|
|
65
|
+
END IF;
|
|
66
|
+
|
|
67
|
+
computed_record_id := full_row ->> arg_primary_key;
|
|
68
|
+
computed_actor_type := meta ->> 'actor_type';
|
|
69
|
+
computed_actor_id := meta ->> 'actor_id';
|
|
70
|
+
|
|
71
|
+
EXECUTE format(
|
|
72
|
+
'INSERT INTO athar_deletions (
|
|
73
|
+
record_type,
|
|
74
|
+
record_id,
|
|
75
|
+
actor_type,
|
|
76
|
+
actor_id,
|
|
77
|
+
schema_name,
|
|
78
|
+
table_name,
|
|
79
|
+
deleted_at,
|
|
80
|
+
record_data,
|
|
81
|
+
metadata,
|
|
82
|
+
created_at
|
|
83
|
+
)
|
|
84
|
+
VALUES (
|
|
85
|
+
$1,
|
|
86
|
+
($2)::%s,
|
|
87
|
+
$3,
|
|
88
|
+
CASE WHEN $4 IS NULL THEN NULL ELSE ($4)::%s END,
|
|
89
|
+
$5,
|
|
90
|
+
$6,
|
|
91
|
+
statement_timestamp(),
|
|
92
|
+
$7,
|
|
93
|
+
$8,
|
|
94
|
+
statement_timestamp()
|
|
95
|
+
)',
|
|
96
|
+
arg_id_type,
|
|
97
|
+
arg_id_type
|
|
98
|
+
)
|
|
99
|
+
USING
|
|
100
|
+
computed_record_type,
|
|
101
|
+
computed_record_id,
|
|
102
|
+
computed_actor_type,
|
|
103
|
+
computed_actor_id,
|
|
104
|
+
arg_schema_name,
|
|
105
|
+
arg_table_name,
|
|
106
|
+
filtered_data,
|
|
107
|
+
meta - 'actor_type' - 'actor_id';
|
|
108
|
+
|
|
109
|
+
RETURN OLD;
|
|
110
|
+
END;
|
|
111
|
+
$$ LANGUAGE plpgsql;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
-- Athar capture truncate trigger function (v1).
|
|
2
|
+
-- Statement-level AFTER TRUNCATE trigger.
|
|
3
|
+
-- Args:
|
|
4
|
+
-- TG_ARGV[0] schema_name (e.g. 'public')
|
|
5
|
+
-- TG_ARGV[1] table_name (e.g. 'users')
|
|
6
|
+
|
|
7
|
+
CREATE OR REPLACE FUNCTION athar_capture_truncate()
|
|
8
|
+
RETURNS trigger AS $$
|
|
9
|
+
DECLARE
|
|
10
|
+
arg_schema_name text;
|
|
11
|
+
arg_table_name text;
|
|
12
|
+
|
|
13
|
+
meta jsonb;
|
|
14
|
+
meta_text text;
|
|
15
|
+
actor_id_text text;
|
|
16
|
+
BEGIN
|
|
17
|
+
arg_schema_name := NULLIF(TG_ARGV[0], 'null');
|
|
18
|
+
arg_table_name := NULLIF(TG_ARGV[1], 'null');
|
|
19
|
+
|
|
20
|
+
meta := '{}'::jsonb;
|
|
21
|
+
meta_text := current_setting('athar.meta', true);
|
|
22
|
+
IF coalesce(meta_text, '') <> '' THEN
|
|
23
|
+
meta := meta_text::jsonb;
|
|
24
|
+
END IF;
|
|
25
|
+
|
|
26
|
+
actor_id_text := meta ->> 'actor_id';
|
|
27
|
+
|
|
28
|
+
INSERT INTO athar_table_events (
|
|
29
|
+
event_type,
|
|
30
|
+
schema_name,
|
|
31
|
+
table_name,
|
|
32
|
+
actor_type,
|
|
33
|
+
actor_id,
|
|
34
|
+
metadata,
|
|
35
|
+
occurred_at,
|
|
36
|
+
created_at
|
|
37
|
+
)
|
|
38
|
+
VALUES (
|
|
39
|
+
'truncate',
|
|
40
|
+
arg_schema_name,
|
|
41
|
+
arg_table_name,
|
|
42
|
+
meta ->> 'actor_type',
|
|
43
|
+
CASE WHEN actor_id_text IS NULL THEN NULL ELSE actor_id_text::<%= foreign_key_type %> END,
|
|
44
|
+
meta - 'actor_type' - 'actor_id',
|
|
45
|
+
statement_timestamp(),
|
|
46
|
+
statement_timestamp()
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
RETURN NULL;
|
|
50
|
+
END;
|
|
51
|
+
$$ LANGUAGE plpgsql;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- Athar filter keys function (v1).
|
|
2
|
+
-- Keeps only the listed keys from a JSONB object.
|
|
3
|
+
-- Missing keys are ignored. Empty column list returns {}.
|
|
4
|
+
|
|
5
|
+
CREATE OR REPLACE FUNCTION athar_filter_keys(
|
|
6
|
+
data jsonb,
|
|
7
|
+
columns text[]
|
|
8
|
+
) RETURNS jsonb AS $$
|
|
9
|
+
DECLARE
|
|
10
|
+
result jsonb := '{}'::jsonb;
|
|
11
|
+
column_name text;
|
|
12
|
+
BEGIN
|
|
13
|
+
IF data IS NULL THEN
|
|
14
|
+
RETURN '{}'::jsonb;
|
|
15
|
+
END IF;
|
|
16
|
+
|
|
17
|
+
IF columns IS NULL OR array_length(columns, 1) IS NULL THEN
|
|
18
|
+
RETURN '{}'::jsonb;
|
|
19
|
+
END IF;
|
|
20
|
+
|
|
21
|
+
FOREACH column_name IN ARRAY columns LOOP
|
|
22
|
+
IF data ? column_name THEN
|
|
23
|
+
result := result || jsonb_build_object(column_name, data -> column_name);
|
|
24
|
+
END IF;
|
|
25
|
+
END LOOP;
|
|
26
|
+
|
|
27
|
+
RETURN result;
|
|
28
|
+
END;
|
|
29
|
+
$$ LANGUAGE plpgsql IMMUTABLE;
|