pg_sql_triggers 1.3.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 +354 -0
- data/COVERAGE.md +39 -41
- data/LICENSE +0 -0
- data/README.md +44 -26
- 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 +137 -74
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -27
- 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 +27 -18
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
- 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 -14
- 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/20260228162233_add_for_each_to_pg_sql_triggers_registry.rb +8 -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 +176 -152
- data/docs/audit-trail.md +1 -1
- data/docs/configuration.md +196 -3
- data/docs/getting-started.md +31 -16
- 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 +112 -67
- data/docs/web-ui.md +3 -103
- 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 +11 -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 +29 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -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 +26 -13
- data/lib/pg_sql_triggers/drift/detector.rb +59 -38
- 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 +68 -20
- data/lib/pg_sql_triggers/dsl.rb +0 -0
- data/lib/pg_sql_triggers/engine.rb +49 -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 +85 -82
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +34 -12
- data/lib/pg_sql_triggers/migrator.rb +137 -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 +60 -21
- data/lib/pg_sql_triggers/registry/validator.rb +287 -6
- 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 +154 -275
- data/lib/pg_sql_triggers/sql.rb +0 -6
- 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 +21 -1
- 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 +68 -22
- data/Goal.md +0 -742
- data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
- data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
- data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
- data/lib/generators/trigger/migration_generator.rb +0 -60
- data/lib/pg_sql_triggers/generator/form.rb +0 -80
- data/lib/pg_sql_triggers/generator/service.rb +0 -339
- data/lib/pg_sql_triggers/generator.rb +0 -8
- data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
- data/lib/pg_sql_triggers/sql/executor.rb +0 -200
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/migration"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
6
|
+
|
|
7
|
+
module PgSqlTriggers
|
|
8
|
+
module Generators
|
|
9
|
+
class TriggerGenerator < Rails::Generators::Base
|
|
10
|
+
include Rails::Generators::Migration
|
|
11
|
+
|
|
12
|
+
source_root File.expand_path("templates", __dir__)
|
|
13
|
+
|
|
14
|
+
desc "Generates a pg_sql_triggers DSL file and migration for a new trigger."
|
|
15
|
+
|
|
16
|
+
argument :trigger_name, type: :string,
|
|
17
|
+
desc: "Name of the trigger (e.g. notify_on_insert_users)"
|
|
18
|
+
argument :table_name, type: :string,
|
|
19
|
+
desc: "Database table the trigger attaches to (e.g. users)"
|
|
20
|
+
argument :events, type: :array, default: ["insert"], banner: "EVENT ...",
|
|
21
|
+
desc: "Trigger events: insert, update, delete (default: insert)"
|
|
22
|
+
|
|
23
|
+
class_option :timing, type: :string, default: "before",
|
|
24
|
+
desc: "Trigger timing: before or after (default: before)"
|
|
25
|
+
class_option :function, type: :string,
|
|
26
|
+
desc: "Function name (default: TRIGGER_NAME_function)"
|
|
27
|
+
|
|
28
|
+
def self.next_migration_number(_dirname)
|
|
29
|
+
existing = if Rails.root.join("db/triggers").exist?
|
|
30
|
+
Rails.root.glob("db/triggers/*.rb")
|
|
31
|
+
.map { |f| File.basename(f, ".rb").split("_").first.to_i }
|
|
32
|
+
.reject(&:zero?)
|
|
33
|
+
.max || 0
|
|
34
|
+
else
|
|
35
|
+
0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
now = Time.now.utc
|
|
39
|
+
base = now.strftime("%Y%m%d%H%M%S").to_i
|
|
40
|
+
base = existing + 1 if existing.positive? && base <= existing
|
|
41
|
+
base
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def create_dsl_file
|
|
45
|
+
template "trigger_dsl.rb.tt", "app/triggers/#{trigger_name}.rb"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_migration_file
|
|
49
|
+
template "trigger_migration_full.rb.tt", "db/triggers/#{migration_file_name}.rb"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def function_name
|
|
55
|
+
options[:function].presence || "#{trigger_name}_function"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def timing
|
|
59
|
+
options[:timing]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def events_list
|
|
63
|
+
events.map { |e| ":#{e}" }.join(", ")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def events_sql
|
|
67
|
+
events.map(&:upcase).join(" OR ")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def trigger_class_name
|
|
71
|
+
"Add#{trigger_name.camelize}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def migration_file_name
|
|
75
|
+
"#{migration_number}_#{trigger_name}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def migration_number
|
|
79
|
+
self.class.next_migration_number(nil)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
File without changes
|
|
@@ -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,18 +16,21 @@ 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
|
|
23
26
|
JOIN pg_proc p ON t.tgfoid = p.oid
|
|
24
27
|
WHERE NOT t.tgisinternal
|
|
25
|
-
AND n.nspname =
|
|
28
|
+
AND n.nspname = $1
|
|
26
29
|
AND t.tgname NOT LIKE 'RI_%'
|
|
27
30
|
ORDER BY c.relname, t.tgname;
|
|
28
31
|
SQL
|
|
29
32
|
|
|
30
|
-
execute_query(sql)
|
|
33
|
+
execute_query(sql, [schema_name])
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
# Fetch single trigger
|
|
@@ -42,17 +45,20 @@ 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
|
|
49
55
|
JOIN pg_proc p ON t.tgfoid = p.oid
|
|
50
56
|
WHERE t.tgname = $1
|
|
51
57
|
AND NOT t.tgisinternal
|
|
52
|
-
AND n.nspname =
|
|
58
|
+
AND n.nspname = $2;
|
|
53
59
|
SQL
|
|
54
60
|
|
|
55
|
-
result = execute_query(sql, [trigger_name])
|
|
61
|
+
result = execute_query(sql, [trigger_name, schema_name])
|
|
56
62
|
result.first
|
|
57
63
|
end
|
|
58
64
|
|
|
@@ -68,19 +74,22 @@ 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
|
|
75
84
|
JOIN pg_proc p ON t.tgfoid = p.oid
|
|
76
85
|
WHERE c.relname = $1
|
|
77
86
|
AND NOT t.tgisinternal
|
|
78
|
-
AND n.nspname =
|
|
87
|
+
AND n.nspname = $2
|
|
79
88
|
AND t.tgname NOT LIKE 'RI_%'
|
|
80
89
|
ORDER BY t.tgname;
|
|
81
90
|
SQL
|
|
82
91
|
|
|
83
|
-
execute_query(sql, [table_name])
|
|
92
|
+
execute_query(sql, [table_name, schema_name])
|
|
84
93
|
end
|
|
85
94
|
|
|
86
95
|
# Fetch function body by function name
|
|
@@ -92,15 +101,19 @@ module PgSqlTriggers
|
|
|
92
101
|
FROM pg_proc p
|
|
93
102
|
JOIN pg_namespace n ON p.pronamespace = n.oid
|
|
94
103
|
WHERE p.proname = $1
|
|
95
|
-
AND n.nspname =
|
|
104
|
+
AND n.nspname = $2;
|
|
96
105
|
SQL
|
|
97
106
|
|
|
98
|
-
result = execute_query(sql, [function_name])
|
|
107
|
+
result = execute_query(sql, [function_name, schema_name])
|
|
99
108
|
result.first
|
|
100
109
|
end
|
|
101
110
|
|
|
102
111
|
private
|
|
103
112
|
|
|
113
|
+
def schema_name
|
|
114
|
+
PgSqlTriggers.db_schema.to_s
|
|
115
|
+
end
|
|
116
|
+
|
|
104
117
|
def execute_query(sql, params = [])
|
|
105
118
|
if params.any?
|
|
106
119
|
# Use ActiveRecord's connection to execute parameterized queries
|
|
@@ -111,6 +124,6 @@ module PgSqlTriggers
|
|
|
111
124
|
end
|
|
112
125
|
end
|
|
113
126
|
end
|
|
114
|
-
end
|
|
127
|
+
end # rubocop:enable Metrics/ModuleLength
|
|
115
128
|
end
|
|
116
129
|
end
|
|
@@ -11,27 +11,7 @@ module PgSqlTriggers
|
|
|
11
11
|
def detect(trigger_name)
|
|
12
12
|
registry_entry = TriggerRegistry.find_by(trigger_name: trigger_name)
|
|
13
13
|
db_trigger = DbQueries.find_trigger(trigger_name)
|
|
14
|
-
|
|
15
|
-
# State 1: DISABLED - Registry entry disabled
|
|
16
|
-
return disabled_state(registry_entry, db_trigger) if registry_entry&.enabled == false
|
|
17
|
-
|
|
18
|
-
# State 2: MANUAL_OVERRIDE - Marked as manual SQL
|
|
19
|
-
return manual_override_state(registry_entry, db_trigger) if registry_entry&.source == "manual_sql"
|
|
20
|
-
|
|
21
|
-
# State 3: DROPPED - Registry entry exists, DB trigger missing
|
|
22
|
-
return dropped_state(registry_entry) if registry_entry && !db_trigger
|
|
23
|
-
|
|
24
|
-
# State 4: UNKNOWN - DB trigger exists, no registry entry
|
|
25
|
-
return unknown_state(db_trigger) if !registry_entry && db_trigger
|
|
26
|
-
|
|
27
|
-
# State 5: DRIFTED - Checksum mismatch
|
|
28
|
-
if registry_entry && db_trigger
|
|
29
|
-
checksum_match = checksums_match?(registry_entry, db_trigger)
|
|
30
|
-
return drifted_state(registry_entry, db_trigger) unless checksum_match
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# State 6: IN_SYNC - Everything matches
|
|
34
|
-
in_sync_state(registry_entry, db_trigger)
|
|
14
|
+
detect_with_preloaded(registry_entry, db_trigger)
|
|
35
15
|
end
|
|
36
16
|
|
|
37
17
|
# Detect drift for all triggers
|
|
@@ -39,13 +19,14 @@ module PgSqlTriggers
|
|
|
39
19
|
registry_entries = TriggerRegistry.all.to_a
|
|
40
20
|
db_triggers = DbQueries.all_triggers
|
|
41
21
|
|
|
42
|
-
|
|
22
|
+
db_trigger_map = db_triggers.index_by { |t| t["trigger_name"] }
|
|
23
|
+
|
|
43
24
|
results = registry_entries.map do |entry|
|
|
44
|
-
|
|
25
|
+
detect_with_preloaded(entry, db_trigger_map[entry.trigger_name])
|
|
45
26
|
end
|
|
46
27
|
|
|
47
28
|
# Find unknown (external) triggers not in registry
|
|
48
|
-
registry_trigger_names = registry_entries.
|
|
29
|
+
registry_trigger_names = registry_entries.to_set(&:trigger_name)
|
|
49
30
|
db_triggers.each do |db_trigger|
|
|
50
31
|
next if registry_trigger_names.include?(db_trigger["trigger_name"])
|
|
51
32
|
|
|
@@ -60,13 +41,14 @@ module PgSqlTriggers
|
|
|
60
41
|
registry_entries = TriggerRegistry.for_table(table_name).to_a
|
|
61
42
|
db_triggers = DbQueries.find_triggers_for_table(table_name)
|
|
62
43
|
|
|
63
|
-
|
|
44
|
+
db_trigger_map = db_triggers.index_by { |t| t["trigger_name"] }
|
|
45
|
+
|
|
64
46
|
results = registry_entries.map do |entry|
|
|
65
|
-
|
|
47
|
+
detect_with_preloaded(entry, db_trigger_map[entry.trigger_name])
|
|
66
48
|
end
|
|
67
49
|
|
|
68
50
|
# Find unknown triggers on this table
|
|
69
|
-
registry_trigger_names = registry_entries.
|
|
51
|
+
registry_trigger_names = registry_entries.to_set(&:trigger_name)
|
|
70
52
|
db_triggers.each do |db_trigger|
|
|
71
53
|
next if registry_trigger_names.include?(db_trigger["trigger_name"])
|
|
72
54
|
|
|
@@ -78,6 +60,20 @@ module PgSqlTriggers
|
|
|
78
60
|
|
|
79
61
|
private
|
|
80
62
|
|
|
63
|
+
# Core state computation using pre-loaded data — no additional DB queries.
|
|
64
|
+
def detect_with_preloaded(registry_entry, db_trigger)
|
|
65
|
+
return disabled_state(registry_entry, db_trigger) if registry_entry&.enabled == false
|
|
66
|
+
return manual_override_state(registry_entry, db_trigger) if registry_entry&.source == "manual_sql"
|
|
67
|
+
return dropped_state(registry_entry) if registry_entry && !db_trigger
|
|
68
|
+
return unknown_state(db_trigger) if !registry_entry && db_trigger
|
|
69
|
+
|
|
70
|
+
if registry_entry && db_trigger && !checksums_match?(registry_entry, db_trigger)
|
|
71
|
+
return drifted_state(registry_entry, db_trigger)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
in_sync_state(registry_entry, db_trigger)
|
|
75
|
+
end
|
|
76
|
+
|
|
81
77
|
# Compare registry checksum with calculated DB checksum
|
|
82
78
|
def checksums_match?(registry_entry, db_trigger)
|
|
83
79
|
db_checksum = calculate_db_checksum(registry_entry, db_trigger)
|
|
@@ -86,32 +82,47 @@ module PgSqlTriggers
|
|
|
86
82
|
|
|
87
83
|
# Calculate checksum from DB trigger (must match registry algorithm)
|
|
88
84
|
def calculate_db_checksum(registry_entry, db_trigger)
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
function_body = if registry_entry.source == "dsl"
|
|
86
|
+
db_trigger["function_definition"] || ""
|
|
87
|
+
else
|
|
88
|
+
extract_function_body(db_trigger) || ""
|
|
89
|
+
end
|
|
91
90
|
|
|
92
|
-
# Extract condition from trigger definition
|
|
91
|
+
# Extract condition and for_each granularity from trigger definition
|
|
93
92
|
condition = extract_trigger_condition(db_trigger)
|
|
93
|
+
db_for_each = extract_trigger_for_each(db_trigger)
|
|
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
|
+
)
|
|
94
100
|
|
|
95
101
|
# Use same algorithm as TriggerRegistry#calculate_checksum
|
|
96
102
|
Digest::SHA256.hexdigest([
|
|
97
103
|
registry_entry.trigger_name,
|
|
98
104
|
registry_entry.table_name,
|
|
99
105
|
registry_entry.version,
|
|
100
|
-
function_body
|
|
101
|
-
condition || ""
|
|
106
|
+
function_body,
|
|
107
|
+
condition || "",
|
|
108
|
+
registry_entry.timing || "before",
|
|
109
|
+
db_for_each || "row",
|
|
110
|
+
events_segment,
|
|
111
|
+
*deferral
|
|
102
112
|
].join)
|
|
103
113
|
end
|
|
104
114
|
|
|
105
|
-
# Extract
|
|
115
|
+
# Extract just the PL/pgSQL body from pg_get_functiondef output.
|
|
116
|
+
# pg_get_functiondef() returns the full CREATE OR REPLACE FUNCTION statement;
|
|
117
|
+
# we extract only the content between the dollar-quote delimiters so the
|
|
118
|
+
# comparison is format-agnostic (handles $$ and $function$ styles).
|
|
106
119
|
def extract_function_body(db_trigger)
|
|
107
120
|
function_def = db_trigger["function_definition"]
|
|
108
121
|
return nil unless function_def
|
|
109
122
|
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# TODO: Parse and extract just the body if needed
|
|
114
|
-
function_def
|
|
123
|
+
# Match any dollar-quoted string: $tag$body$tag$ (tag may be empty)
|
|
124
|
+
match = function_def.match(/\$([^$]*)\$(.*?)\$\1\$/m)
|
|
125
|
+
match ? match[2].strip : function_def.strip
|
|
115
126
|
end
|
|
116
127
|
|
|
117
128
|
# Extract WHEN condition from trigger definition
|
|
@@ -125,6 +136,16 @@ module PgSqlTriggers
|
|
|
125
136
|
match ? match[1].strip : nil
|
|
126
137
|
end
|
|
127
138
|
|
|
139
|
+
# Extract FOR EACH ROW / FOR EACH STATEMENT from trigger definition.
|
|
140
|
+
# Returns "row" or "statement" (lowercase). Defaults to "row".
|
|
141
|
+
def extract_trigger_for_each(db_trigger)
|
|
142
|
+
trigger_def = db_trigger["trigger_definition"]
|
|
143
|
+
return "row" unless trigger_def
|
|
144
|
+
|
|
145
|
+
match = trigger_def.match(/FOR\s+EACH\s+(ROW|STATEMENT)/i)
|
|
146
|
+
match ? match[1].downcase : "row"
|
|
147
|
+
end
|
|
148
|
+
|
|
128
149
|
# State helper methods
|
|
129
150
|
def disabled_state(registry_entry, db_trigger)
|
|
130
151
|
{
|
|
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,16 +3,34 @@
|
|
|
3
3
|
module PgSqlTriggers
|
|
4
4
|
module DSL
|
|
5
5
|
class TriggerDefinition
|
|
6
|
-
attr_accessor :name, :table_name, :events, :function_name, :environments, :condition
|
|
6
|
+
attr_accessor :name, :table_name, :events, :function_name, :environments, :condition, :version, :enabled,
|
|
7
|
+
:columns, :deferrable, :initially
|
|
8
|
+
attr_reader :timing, :for_each
|
|
7
9
|
|
|
8
10
|
def initialize(name)
|
|
9
11
|
@name = name
|
|
10
12
|
@events = []
|
|
11
13
|
@version = 1
|
|
12
|
-
@enabled =
|
|
14
|
+
@enabled = true
|
|
13
15
|
@environments = []
|
|
14
16
|
@condition = nil
|
|
15
17
|
@timing = "before"
|
|
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
|
|
16
34
|
end
|
|
17
35
|
|
|
18
36
|
def table(table_name)
|
|
@@ -21,29 +39,38 @@ module PgSqlTriggers
|
|
|
21
39
|
|
|
22
40
|
def on(*events)
|
|
23
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)
|
|
24
48
|
end
|
|
25
49
|
|
|
26
50
|
def function(function_name)
|
|
27
51
|
@function_name = function_name
|
|
28
52
|
end
|
|
29
53
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
@version
|
|
33
|
-
else
|
|
34
|
-
@version = version
|
|
35
|
-
end
|
|
54
|
+
def timing=(val)
|
|
55
|
+
@timing = val.to_s
|
|
36
56
|
end
|
|
37
57
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
58
|
+
def constraint_trigger!
|
|
59
|
+
self.constraint_trigger = true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def for_each_row
|
|
63
|
+
@for_each = "row"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def for_each_statement
|
|
67
|
+
@for_each = "statement"
|
|
44
68
|
end
|
|
45
69
|
|
|
46
70
|
def when_env(*environments)
|
|
71
|
+
warn "[DEPRECATION] `when_env` is deprecated and will be removed in a future version. " \
|
|
72
|
+
"Environment-specific trigger behavior causes schema drift between environments. " \
|
|
73
|
+
"Use application-level configuration instead."
|
|
47
74
|
@environments = environments.map(&:to_s)
|
|
48
75
|
end
|
|
49
76
|
|
|
@@ -51,14 +78,22 @@ module PgSqlTriggers
|
|
|
51
78
|
@condition = condition_sql
|
|
52
79
|
end
|
|
53
80
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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)
|
|
59
90
|
end
|
|
60
91
|
end
|
|
61
92
|
|
|
93
|
+
def depends_on_names
|
|
94
|
+
@depends_on.dup
|
|
95
|
+
end
|
|
96
|
+
|
|
62
97
|
def function_body
|
|
63
98
|
nil # DSL definitions don't include function_body directly
|
|
64
99
|
end
|
|
@@ -73,9 +108,22 @@ module PgSqlTriggers
|
|
|
73
108
|
enabled: @enabled,
|
|
74
109
|
environments: @environments,
|
|
75
110
|
condition: @condition,
|
|
76
|
-
timing: @timing
|
|
111
|
+
timing: @timing,
|
|
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
|
|
77
118
|
}
|
|
78
119
|
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def clear_deferral
|
|
124
|
+
@deferrable = nil
|
|
125
|
+
@initially = nil
|
|
126
|
+
end
|
|
79
127
|
end
|
|
80
128
|
end
|
|
81
129
|
end
|
data/lib/pg_sql_triggers/dsl.rb
CHANGED
|
File without changes
|