pg_sql_triggers 1.4.0 → 1.5.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 +4 -4
- data/.erb_lint.yml +0 -0
- data/.rspec +0 -0
- data/.rubocop.yml +6 -16
- data/AGENTS.md +8 -0
- data/CHANGELOG.md +104 -2
- data/COVERAGE.md +39 -41
- data/LICENSE +0 -0
- data/README.md +24 -3
- data/RELEASE.md +0 -0
- data/Rakefile +5 -0
- data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
- data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
- data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
- data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
- data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
- data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
- data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
- data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
- data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
- data/app/models/pg_sql_triggers/application_record.rb +0 -0
- data/app/models/pg_sql_triggers/audit_log.rb +29 -47
- data/app/models/pg_sql_triggers/trigger_registry.rb +105 -78
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -0
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -24
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +26 -14
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
- data/config/initializers/pg_sql_triggers.rb +0 -0
- data/config/routes.rb +0 -0
- data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
- data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
- data/db/migrate/{20260228000001_add_for_each_to_pg_sql_triggers_registry.rb → 20260228162233_add_for_each_to_pg_sql_triggers_registry.rb} +0 -0
- data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
- data/docs/README.md +3 -0
- data/docs/api-reference.md +133 -0
- data/docs/audit-trail.md +1 -1
- data/docs/configuration.md +172 -0
- data/docs/getting-started.md +14 -0
- data/docs/kill-switch.md +0 -0
- data/docs/permissions.md +6 -9
- data/docs/troubleshooting.md +0 -0
- data/docs/ui-guide.md +0 -0
- data/docs/usage-guide.md +74 -0
- data/docs/web-ui.md +0 -0
- data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/README +0 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +0 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +0 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +0 -0
- data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
- data/lib/pg_sql_triggers/alerting.rb +77 -0
- data/lib/pg_sql_triggers/database_introspection.rb +0 -0
- data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +14 -5
- data/lib/pg_sql_triggers/drift/detector.rb +9 -1
- data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
- data/lib/pg_sql_triggers/drift.rb +5 -0
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +56 -2
- data/lib/pg_sql_triggers/dsl.rb +0 -0
- data/lib/pg_sql_triggers/engine.rb +35 -0
- data/lib/pg_sql_triggers/errors.rb +0 -0
- data/lib/pg_sql_triggers/events_checksum.rb +114 -0
- data/lib/pg_sql_triggers/migration.rb +5 -6
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +77 -73
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +3 -1
- data/lib/pg_sql_triggers/migrator.rb +90 -94
- data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
- data/lib/pg_sql_triggers/permissions.rb +1 -0
- data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
- data/lib/pg_sql_triggers/registry/manager.rb +27 -13
- data/lib/pg_sql_triggers/registry/validator.rb +226 -2
- data/lib/pg_sql_triggers/registry.rb +0 -0
- data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
- data/lib/pg_sql_triggers/sql/kill_switch.rb +2 -1
- data/lib/pg_sql_triggers/sql.rb +0 -0
- data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
- data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
- data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
- data/lib/pg_sql_triggers/testing.rb +0 -0
- data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +17 -0
- data/lib/tasks/trigger_migrations.rake +235 -152
- data/rakelib/pg_sql_triggers_environment.rake +9 -0
- data/scripts/generate_coverage_report.rb +4 -1
- data/sig/pg_sql_triggers.rbs +0 -0
- metadata +65 -13
- data/GEM_ANALYSIS.md +0 -368
- data/Goal.md +0 -742
- data/pg_sql_triggers.gemspec +0 -53
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
# Drift alerting: configurable callback when drift detection finds drifted, dropped, or unknown
|
|
5
|
+
# triggers. Use with +trigger:check_drift+ or +PgSqlTriggers::Alerting.check_and_notify+.
|
|
6
|
+
module Alerting
|
|
7
|
+
ALERTABLE_STATES = [
|
|
8
|
+
PgSqlTriggers::DRIFT_STATE_DRIFTED,
|
|
9
|
+
PgSqlTriggers::DRIFT_STATE_DROPPED,
|
|
10
|
+
PgSqlTriggers::DRIFT_STATE_UNKNOWN
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# @param result [Hash] a single drift result from {Drift::Detector}
|
|
15
|
+
# @return [Boolean]
|
|
16
|
+
def alertable?(result)
|
|
17
|
+
ALERTABLE_STATES.include?(result[:state])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param results [Array<Hash>] drift results from {Drift::Detector.detect_all}
|
|
21
|
+
# @return [Array<Hash>]
|
|
22
|
+
def filter_alertable(results)
|
|
23
|
+
results.select { |r| alertable?(r) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs drift detection for all triggers, invokes +PgSqlTriggers.drift_notifier+ when configured
|
|
27
|
+
# and there is at least one alertable result, and emits +ActiveSupport::Notifications+ when
|
|
28
|
+
# available.
|
|
29
|
+
#
|
|
30
|
+
# The notifier receives one argument: an Array of alertable result hashes (same shape as
|
|
31
|
+
# {Drift::Detector}). For advanced use, a second keyword argument +all_results:+ is passed
|
|
32
|
+
# with the full result set.
|
|
33
|
+
#
|
|
34
|
+
# @return [Hash] +:results+ (all), +:alertable+ (subset), +:notified+ (Boolean)
|
|
35
|
+
def check_and_notify
|
|
36
|
+
results = PgSqlTriggers::Drift::Detector.detect_all
|
|
37
|
+
alertable = filter_alertable(results)
|
|
38
|
+
notified = false
|
|
39
|
+
|
|
40
|
+
payload = {
|
|
41
|
+
results: results,
|
|
42
|
+
alertable: alertable,
|
|
43
|
+
alertable_count: alertable.size,
|
|
44
|
+
total_count: results.size,
|
|
45
|
+
notified: false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
instrument("pg_sql_triggers.drift_check", payload) do
|
|
49
|
+
if alertable.any? && PgSqlTriggers.drift_notifier
|
|
50
|
+
begin
|
|
51
|
+
PgSqlTriggers.drift_notifier.call(alertable, all_results: results)
|
|
52
|
+
notified = true
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
payload[:notifier_error] = e.message
|
|
55
|
+
if defined?(Rails.logger) && Rails.logger
|
|
56
|
+
Rails.logger.error("PgSqlTriggers drift_notifier failed: #{e.class}: #{e.message}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
payload[:notified] = notified
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
{ results: results, alertable: alertable, notified: notified }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def instrument(name, payload, &block)
|
|
69
|
+
if defined?(ActiveSupport::Notifications)
|
|
70
|
+
ActiveSupport::Notifications.instrument(name, payload, &block)
|
|
71
|
+
else
|
|
72
|
+
block.call
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
# Normalizes deferrable / initially metadata for checksum calculation so
|
|
5
|
+
# {Registry::Manager}, {TriggerRegistry#calculate_checksum}, and {Drift::Detector}
|
|
6
|
+
# stay aligned with PostgreSQL's pg_trigger flags.
|
|
7
|
+
module DeferralChecksum
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @return [Array<String>] always three elements: constraint flag, deferrable mode, initially mode
|
|
11
|
+
def parts(constraint_trigger:, deferrable:, initially:)
|
|
12
|
+
constraint = ActiveModel::Type::Boolean.new.cast(constraint_trigger)
|
|
13
|
+
return ["0", "", ""] unless constraint
|
|
14
|
+
|
|
15
|
+
deferrable_sym =
|
|
16
|
+
if deferrable.nil? || deferrable.to_s.strip.empty?
|
|
17
|
+
nil
|
|
18
|
+
else
|
|
19
|
+
deferrable.to_sym
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
deferrable_key = deferrable_sym == :deferrable ? "deferrable" : "not_deferrable"
|
|
23
|
+
|
|
24
|
+
initially_key = if deferrable_key == "deferrable"
|
|
25
|
+
case initially&.to_sym
|
|
26
|
+
when :deferred then "deferred"
|
|
27
|
+
else "immediate"
|
|
28
|
+
end
|
|
29
|
+
else
|
|
30
|
+
""
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
["1", deferrable_key, initially_key]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param db_trigger [Hash] row from {Drift::DbQueries} including tgconstraint, tgdeferrable, tginitdeferred
|
|
37
|
+
# @return [Array<String>] three elements matching {.parts}
|
|
38
|
+
def parts_from_db(db_trigger)
|
|
39
|
+
constraint = db_trigger["tgconstraint"].to_i.nonzero?
|
|
40
|
+
return ["0", "", ""] unless constraint
|
|
41
|
+
|
|
42
|
+
deferrable = ActiveModel::Type::Boolean.new.cast(db_trigger["tgdeferrable"])
|
|
43
|
+
deferrable_key = deferrable ? "deferrable" : "not_deferrable"
|
|
44
|
+
|
|
45
|
+
initially_key = if deferrable
|
|
46
|
+
ActiveModel::Type::Boolean.new.cast(db_trigger["tginitdeferred"]) ? "deferred" : "immediate"
|
|
47
|
+
else
|
|
48
|
+
""
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
["1", deferrable_key, initially_key]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module PgSqlTriggers
|
|
4
4
|
module Drift
|
|
5
|
-
module DbQueries
|
|
5
|
+
module DbQueries # rubocop:disable Metrics/ModuleLength -- trigger introspection SQL in one place
|
|
6
6
|
class << self
|
|
7
7
|
# Fetch all triggers from database
|
|
8
8
|
def all_triggers
|
|
@@ -16,7 +16,10 @@ module PgSqlTriggers
|
|
|
16
16
|
pg_get_triggerdef(t.oid) AS trigger_definition,
|
|
17
17
|
pg_get_functiondef(p.oid) AS function_definition,
|
|
18
18
|
t.tgenabled AS enabled,
|
|
19
|
-
t.tgisinternal AS is_internal
|
|
19
|
+
t.tgisinternal AS is_internal,
|
|
20
|
+
t.tgconstraint AS tgconstraint,
|
|
21
|
+
t.tgdeferrable AS tgdeferrable,
|
|
22
|
+
t.tginitdeferred AS tginitdeferred
|
|
20
23
|
FROM pg_trigger t
|
|
21
24
|
JOIN pg_class c ON t.tgrelid = c.oid
|
|
22
25
|
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
@@ -42,7 +45,10 @@ module PgSqlTriggers
|
|
|
42
45
|
pg_get_triggerdef(t.oid) AS trigger_definition,
|
|
43
46
|
pg_get_functiondef(p.oid) AS function_definition,
|
|
44
47
|
t.tgenabled AS enabled,
|
|
45
|
-
t.tgisinternal AS is_internal
|
|
48
|
+
t.tgisinternal AS is_internal,
|
|
49
|
+
t.tgconstraint AS tgconstraint,
|
|
50
|
+
t.tgdeferrable AS tgdeferrable,
|
|
51
|
+
t.tginitdeferred AS tginitdeferred
|
|
46
52
|
FROM pg_trigger t
|
|
47
53
|
JOIN pg_class c ON t.tgrelid = c.oid
|
|
48
54
|
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
@@ -68,7 +74,10 @@ module PgSqlTriggers
|
|
|
68
74
|
pg_get_triggerdef(t.oid) AS trigger_definition,
|
|
69
75
|
pg_get_functiondef(p.oid) AS function_definition,
|
|
70
76
|
t.tgenabled AS enabled,
|
|
71
|
-
t.tgisinternal AS is_internal
|
|
77
|
+
t.tgisinternal AS is_internal,
|
|
78
|
+
t.tgconstraint AS tgconstraint,
|
|
79
|
+
t.tgdeferrable AS tgdeferrable,
|
|
80
|
+
t.tginitdeferred AS tginitdeferred
|
|
72
81
|
FROM pg_trigger t
|
|
73
82
|
JOIN pg_class c ON t.tgrelid = c.oid
|
|
74
83
|
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
@@ -115,6 +124,6 @@ module PgSqlTriggers
|
|
|
115
124
|
end
|
|
116
125
|
end
|
|
117
126
|
end
|
|
118
|
-
end
|
|
127
|
+
end # rubocop:enable Metrics/ModuleLength
|
|
119
128
|
end
|
|
120
129
|
end
|
|
@@ -92,6 +92,12 @@ module PgSqlTriggers
|
|
|
92
92
|
condition = extract_trigger_condition(db_trigger)
|
|
93
93
|
db_for_each = extract_trigger_for_each(db_trigger)
|
|
94
94
|
|
|
95
|
+
deferral = PgSqlTriggers::DeferralChecksum.parts_from_db(db_trigger)
|
|
96
|
+
|
|
97
|
+
events_segment = PgSqlTriggers::EventsChecksum.canonical_from_pg_triggerdef(
|
|
98
|
+
db_trigger["trigger_definition"]
|
|
99
|
+
)
|
|
100
|
+
|
|
95
101
|
# Use same algorithm as TriggerRegistry#calculate_checksum
|
|
96
102
|
Digest::SHA256.hexdigest([
|
|
97
103
|
registry_entry.trigger_name,
|
|
@@ -100,7 +106,9 @@ module PgSqlTriggers
|
|
|
100
106
|
function_body,
|
|
101
107
|
condition || "",
|
|
102
108
|
registry_entry.timing || "before",
|
|
103
|
-
db_for_each || "row"
|
|
109
|
+
db_for_each || "row",
|
|
110
|
+
events_segment,
|
|
111
|
+
*deferral
|
|
104
112
|
].join)
|
|
105
113
|
end
|
|
106
114
|
|
|
File without changes
|
|
@@ -23,5 +23,10 @@ module PgSqlTriggers
|
|
|
23
23
|
def self.report(trigger_name)
|
|
24
24
|
Reporter.report(trigger_name)
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
# Runs full drift detection and optional external notification; see {PgSqlTriggers::Alerting}.
|
|
28
|
+
def self.check_and_notify
|
|
29
|
+
Alerting.check_and_notify
|
|
30
|
+
end
|
|
26
31
|
end
|
|
27
32
|
end
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module PgSqlTriggers
|
|
4
4
|
module DSL
|
|
5
5
|
class TriggerDefinition
|
|
6
|
-
attr_accessor :name, :table_name, :events, :function_name, :environments, :condition, :version, :enabled
|
|
6
|
+
attr_accessor :name, :table_name, :events, :function_name, :environments, :condition, :version, :enabled,
|
|
7
|
+
:columns, :deferrable, :initially
|
|
7
8
|
attr_reader :timing, :for_each
|
|
8
9
|
|
|
9
10
|
def initialize(name)
|
|
@@ -15,6 +16,21 @@ module PgSqlTriggers
|
|
|
15
16
|
@condition = nil
|
|
16
17
|
@timing = "before"
|
|
17
18
|
@for_each = "row"
|
|
19
|
+
@columns = nil
|
|
20
|
+
@constraint_trigger = false
|
|
21
|
+
@deferrable = nil
|
|
22
|
+
@initially = nil
|
|
23
|
+
@depends_on = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Intentionally not named `constraint_trigger?` — matches registry column and JSON key.
|
|
27
|
+
# The setter always casts to a boolean, and the initial value is `false`, so this returns
|
|
28
|
+
# the stored value directly without coercion.
|
|
29
|
+
attr_reader :constraint_trigger
|
|
30
|
+
|
|
31
|
+
def constraint_trigger=(value)
|
|
32
|
+
@constraint_trigger = ActiveModel::Type::Boolean.new.cast(value)
|
|
33
|
+
clear_deferral unless @constraint_trigger
|
|
18
34
|
end
|
|
19
35
|
|
|
20
36
|
def table(table_name)
|
|
@@ -23,6 +39,12 @@ module PgSqlTriggers
|
|
|
23
39
|
|
|
24
40
|
def on(*events)
|
|
25
41
|
@events = events.map(&:to_s)
|
|
42
|
+
@columns = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def on_update_of(*cols)
|
|
46
|
+
@events = ["update"]
|
|
47
|
+
@columns = cols.map(&:to_s)
|
|
26
48
|
end
|
|
27
49
|
|
|
28
50
|
def function(function_name)
|
|
@@ -33,6 +55,10 @@ module PgSqlTriggers
|
|
|
33
55
|
@timing = val.to_s
|
|
34
56
|
end
|
|
35
57
|
|
|
58
|
+
def constraint_trigger!
|
|
59
|
+
self.constraint_trigger = true
|
|
60
|
+
end
|
|
61
|
+
|
|
36
62
|
def for_each_row
|
|
37
63
|
@for_each = "row"
|
|
38
64
|
end
|
|
@@ -52,6 +78,22 @@ module PgSqlTriggers
|
|
|
52
78
|
@condition = condition_sql
|
|
53
79
|
end
|
|
54
80
|
|
|
81
|
+
# Declares that this trigger must run after the named trigger(s) on the same table.
|
|
82
|
+
# PostgreSQL executes same-kind triggers in alphabetical order by name; use naming or
|
|
83
|
+
# validate with Registry.validate! / rake trigger:validate_order.
|
|
84
|
+
def depends_on(*names)
|
|
85
|
+
names.flatten.compact.each do |entry|
|
|
86
|
+
label = entry.to_s.strip
|
|
87
|
+
next if label.empty?
|
|
88
|
+
|
|
89
|
+
@depends_on << label unless @depends_on.include?(label)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def depends_on_names
|
|
94
|
+
@depends_on.dup
|
|
95
|
+
end
|
|
96
|
+
|
|
55
97
|
def function_body
|
|
56
98
|
nil # DSL definitions don't include function_body directly
|
|
57
99
|
end
|
|
@@ -67,9 +109,21 @@ module PgSqlTriggers
|
|
|
67
109
|
environments: @environments,
|
|
68
110
|
condition: @condition,
|
|
69
111
|
timing: @timing,
|
|
70
|
-
for_each: @for_each
|
|
112
|
+
for_each: @for_each,
|
|
113
|
+
columns: @columns,
|
|
114
|
+
constraint_trigger: @constraint_trigger == true,
|
|
115
|
+
deferrable: @deferrable&.to_s,
|
|
116
|
+
initially: @initially&.to_s,
|
|
117
|
+
depends_on: @depends_on.dup
|
|
71
118
|
}
|
|
72
119
|
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def clear_deferral
|
|
124
|
+
@deferrable = nil
|
|
125
|
+
@initially = nil
|
|
126
|
+
end
|
|
73
127
|
end
|
|
74
128
|
end
|
|
75
129
|
end
|
data/lib/pg_sql_triggers/dsl.rb
CHANGED
|
File without changes
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "schema_dumper_extension"
|
|
4
|
+
require_relative "trigger_structure_dumper"
|
|
5
|
+
|
|
3
6
|
module PgSqlTriggers
|
|
4
7
|
class Engine < ::Rails::Engine
|
|
5
8
|
isolate_namespace PgSqlTriggers
|
|
@@ -26,10 +29,20 @@ module PgSqlTriggers
|
|
|
26
29
|
load root.join("lib/tasks/trigger_migrations.rake")
|
|
27
30
|
end
|
|
28
31
|
|
|
32
|
+
initializer "pg_sql_triggers.schema_integration", before: :load_config_initializers do
|
|
33
|
+
ActiveSupport.on_load(:active_record) do
|
|
34
|
+
unless ActiveRecord::SchemaDumper.ancestors.include?(PgSqlTriggers::SchemaDumperExtension)
|
|
35
|
+
ActiveRecord::SchemaDumper.prepend(PgSqlTriggers::SchemaDumperExtension)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
29
40
|
# Warn at startup if no permission_checker is set in a protected environment.
|
|
30
41
|
# The default is to allow all actions (including admin-level ones), which is
|
|
31
42
|
# unsafe in production without an explicit checker configured.
|
|
32
43
|
config.after_initialize do
|
|
44
|
+
install_schema_load_trigger_hook
|
|
45
|
+
|
|
33
46
|
if PgSqlTriggers.permission_checker.nil? && defined?(Rails) && Rails.env.production?
|
|
34
47
|
Rails.logger.warn(
|
|
35
48
|
"[PgSqlTriggers] SECURITY WARNING: No permission_checker is configured. " \
|
|
@@ -39,5 +52,27 @@ module PgSqlTriggers
|
|
|
39
52
|
)
|
|
40
53
|
end
|
|
41
54
|
end
|
|
55
|
+
|
|
56
|
+
def self.install_schema_load_trigger_hook
|
|
57
|
+
return if @schema_load_trigger_hook_installed
|
|
58
|
+
return unless PgSqlTriggers.migrate_triggers_after_schema_load
|
|
59
|
+
return if ENV["SKIP_TRIGGER_MIGRATE_AFTER_SCHEMA_LOAD"].present?
|
|
60
|
+
return unless defined?(Rake::Task)
|
|
61
|
+
|
|
62
|
+
if defined?(Rails.application) && Rails.application.respond_to?(:load_tasks)
|
|
63
|
+
Rails.application.load_tasks
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
return unless Rake::Task.task_defined?("db:schema:load")
|
|
67
|
+
|
|
68
|
+
@schema_load_trigger_hook_installed = true
|
|
69
|
+
|
|
70
|
+
Rake::Task["db:schema:load"].enhance do
|
|
71
|
+
next unless PgSqlTriggers.migrate_triggers_after_schema_load
|
|
72
|
+
next if ENV["SKIP_TRIGGER_MIGRATE_AFTER_SCHEMA_LOAD"].present?
|
|
73
|
+
|
|
74
|
+
Rake::Task["trigger:migrate"].invoke
|
|
75
|
+
end
|
|
76
|
+
end
|
|
42
77
|
end
|
|
43
78
|
end
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
# Normalizes trigger event lists (including PostgreSQL +UPDATE OF col1, col2+) for checksums so
|
|
5
|
+
# {TriggerRegistry#calculate_checksum}, {Registry::Manager#calculate_checksum}, and
|
|
6
|
+
# {Drift::Detector#calculate_db_checksum} stay aligned.
|
|
7
|
+
module EventsChecksum
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# @param defn [Hash] parsed registry +definition+ JSON (string or symbol keys)
|
|
11
|
+
# @return [String] canonical token string, or empty when +events+ is missing/empty
|
|
12
|
+
def canonical_from_definition(defn)
|
|
13
|
+
hash = stringify(defn)
|
|
14
|
+
events = Array(hash["events"]).map(&:to_s).map(&:downcase)
|
|
15
|
+
return "" if events.empty?
|
|
16
|
+
|
|
17
|
+
columns = normalized_columns(hash["columns"])
|
|
18
|
+
build_tokens(events, columns).sort.join("|")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param definition_json [String, nil]
|
|
22
|
+
def segment_from_definition_json(definition_json)
|
|
23
|
+
return "" if definition_json.blank?
|
|
24
|
+
|
|
25
|
+
canonical_from_definition(JSON.parse(definition_json))
|
|
26
|
+
rescue JSON::ParserError
|
|
27
|
+
""
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @param trigger_def [String] +pg_get_triggerdef+ output
|
|
31
|
+
def canonical_from_pg_triggerdef(trigger_def)
|
|
32
|
+
return "" if trigger_def.blank?
|
|
33
|
+
|
|
34
|
+
clause = extract_events_clause(trigger_def)
|
|
35
|
+
return "" if clause.blank?
|
|
36
|
+
|
|
37
|
+
parse_events_clause(clause).sort.join("|")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# SQL fragment for the event list (e.g. +INSERT OR UPDATE OF "email"+), preserving DSL event order.
|
|
41
|
+
def events_sql_fragment(defn, quote_column:)
|
|
42
|
+
hash = stringify(defn)
|
|
43
|
+
events = Array(hash["events"]).map(&:to_s).map(&:downcase)
|
|
44
|
+
columns = ordered_columns(hash["columns"])
|
|
45
|
+
return "INSERT" if events.empty?
|
|
46
|
+
|
|
47
|
+
events.map do |ev|
|
|
48
|
+
if ev == "update" && columns.any?
|
|
49
|
+
quoted = columns.map { |c| quote_column.call(c) }.join(", ")
|
|
50
|
+
"UPDATE OF #{quoted}"
|
|
51
|
+
else
|
|
52
|
+
ev.upcase
|
|
53
|
+
end
|
|
54
|
+
end.join(" OR ")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def normalized_columns(raw)
|
|
58
|
+
ordered_columns(raw).map(&:downcase).sort
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ordered_columns(raw)
|
|
62
|
+
Array(raw).flatten.compact.map { |c| c.to_s.strip }.reject(&:empty?)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_tokens(events, sorted_lowercase_columns)
|
|
66
|
+
cols_join = sorted_lowercase_columns.join(",")
|
|
67
|
+
events.map do |ev|
|
|
68
|
+
if ev == "update" && cols_join.present?
|
|
69
|
+
"update:#{cols_join}"
|
|
70
|
+
else
|
|
71
|
+
ev
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def extract_events_clause(trigger_def)
|
|
77
|
+
s = trigger_def.to_s.squish
|
|
78
|
+
m = s.match(/(?:BEFORE|AFTER|INSTEAD\s+OF)\s+(.+?)\s+ON\s+/i)
|
|
79
|
+
return "" unless m
|
|
80
|
+
|
|
81
|
+
m.captures.first.to_s.strip
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_events_clause(clause)
|
|
85
|
+
clause.split(/\s+OR\s+/i).filter_map do |part|
|
|
86
|
+
part = part.strip
|
|
87
|
+
next if part.empty?
|
|
88
|
+
|
|
89
|
+
um = part.match(/\AUPDATE\s+OF\s+(.+)\z/i)
|
|
90
|
+
if um
|
|
91
|
+
cols = um[1].split(",").map { |c| normalize_pg_identifier(c) }.sort
|
|
92
|
+
"update:#{cols.join(',')}"
|
|
93
|
+
else
|
|
94
|
+
part.downcase
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_pg_identifier(fragment)
|
|
100
|
+
f = fragment.to_s.strip
|
|
101
|
+
if f.start_with?('"') && f.end_with?('"') && f.length >= 2
|
|
102
|
+
f[1...-1].gsub('""', '"').downcase
|
|
103
|
+
else
|
|
104
|
+
f.downcase
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def stringify(defn)
|
|
109
|
+
return {} if defn.nil?
|
|
110
|
+
|
|
111
|
+
defn.transform_keys(&:to_s)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -4,12 +4,11 @@ module PgSqlTriggers
|
|
|
4
4
|
class Migration < ActiveRecord::Migration[6.1]
|
|
5
5
|
# Base class for trigger migrations
|
|
6
6
|
# Similar to ActiveRecord::Migration but for trigger-specific migrations
|
|
7
|
-
|
|
8
|
-
#
|
|
9
|
-
# delegate
|
|
10
|
-
def execute(
|
|
11
|
-
connection.execute(
|
|
7
|
+
#
|
|
8
|
+
# Cannot use `delegate` here: ActiveRecord::Migration defines a class-method
|
|
9
|
+
# `delegate` (schema DSL) that shadows Module's `delegate` on Rails 8+.
|
|
10
|
+
def execute(...)
|
|
11
|
+
connection.execute(...)
|
|
12
12
|
end
|
|
13
|
-
# rubocop:enable Rails/Delegate
|
|
14
13
|
end
|
|
15
14
|
end
|