pg_sql_triggers 1.3.0 → 1.4.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/CHANGELOG.md +253 -1
- data/GEM_ANALYSIS.md +368 -0
- data/README.md +20 -23
- data/app/models/pg_sql_triggers/trigger_registry.rb +42 -6
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +1 -4
- data/app/views/pg_sql_triggers/tables/index.html.erb +1 -4
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
- data/config/routes.rb +0 -14
- data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/api-reference.md +44 -153
- data/docs/configuration.md +24 -3
- data/docs/getting-started.md +17 -16
- data/docs/usage-guide.md +38 -67
- data/docs/web-ui.md +3 -103
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -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/pg_sql_triggers/drift/db_queries.rb +12 -8
- data/lib/pg_sql_triggers/drift/detector.rb +51 -38
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
- data/lib/pg_sql_triggers/engine.rb +14 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
- data/lib/pg_sql_triggers/migrator.rb +53 -6
- data/lib/pg_sql_triggers/registry/manager.rb +36 -11
- data/lib/pg_sql_triggers/registry/validator.rb +62 -5
- data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -275
- data/lib/pg_sql_triggers/sql.rb +0 -6
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +4 -1
- data/pg_sql_triggers.gemspec +53 -0
- metadata +7 -13
- 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
|
@@ -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,39 @@ 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
94
|
|
|
95
95
|
# Use same algorithm as TriggerRegistry#calculate_checksum
|
|
96
96
|
Digest::SHA256.hexdigest([
|
|
97
97
|
registry_entry.trigger_name,
|
|
98
98
|
registry_entry.table_name,
|
|
99
99
|
registry_entry.version,
|
|
100
|
-
function_body
|
|
101
|
-
condition || ""
|
|
100
|
+
function_body,
|
|
101
|
+
condition || "",
|
|
102
|
+
registry_entry.timing || "before",
|
|
103
|
+
db_for_each || "row"
|
|
102
104
|
].join)
|
|
103
105
|
end
|
|
104
106
|
|
|
105
|
-
# Extract
|
|
107
|
+
# Extract just the PL/pgSQL body from pg_get_functiondef output.
|
|
108
|
+
# pg_get_functiondef() returns the full CREATE OR REPLACE FUNCTION statement;
|
|
109
|
+
# we extract only the content between the dollar-quote delimiters so the
|
|
110
|
+
# comparison is format-agnostic (handles $$ and $function$ styles).
|
|
106
111
|
def extract_function_body(db_trigger)
|
|
107
112
|
function_def = db_trigger["function_definition"]
|
|
108
113
|
return nil unless function_def
|
|
109
114
|
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# TODO: Parse and extract just the body if needed
|
|
114
|
-
function_def
|
|
115
|
+
# Match any dollar-quoted string: $tag$body$tag$ (tag may be empty)
|
|
116
|
+
match = function_def.match(/\$([^$]*)\$(.*?)\$\1\$/m)
|
|
117
|
+
match ? match[2].strip : function_def.strip
|
|
115
118
|
end
|
|
116
119
|
|
|
117
120
|
# Extract WHEN condition from trigger definition
|
|
@@ -125,6 +128,16 @@ module PgSqlTriggers
|
|
|
125
128
|
match ? match[1].strip : nil
|
|
126
129
|
end
|
|
127
130
|
|
|
131
|
+
# Extract FOR EACH ROW / FOR EACH STATEMENT from trigger definition.
|
|
132
|
+
# Returns "row" or "statement" (lowercase). Defaults to "row".
|
|
133
|
+
def extract_trigger_for_each(db_trigger)
|
|
134
|
+
trigger_def = db_trigger["trigger_definition"]
|
|
135
|
+
return "row" unless trigger_def
|
|
136
|
+
|
|
137
|
+
match = trigger_def.match(/FOR\s+EACH\s+(ROW|STATEMENT)/i)
|
|
138
|
+
match ? match[1].downcase : "row"
|
|
139
|
+
end
|
|
140
|
+
|
|
128
141
|
# State helper methods
|
|
129
142
|
def disabled_state(registry_entry, db_trigger)
|
|
130
143
|
{
|
|
@@ -3,16 +3,18 @@
|
|
|
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
|
+
attr_reader :timing, :for_each
|
|
7
8
|
|
|
8
9
|
def initialize(name)
|
|
9
10
|
@name = name
|
|
10
11
|
@events = []
|
|
11
12
|
@version = 1
|
|
12
|
-
@enabled =
|
|
13
|
+
@enabled = true
|
|
13
14
|
@environments = []
|
|
14
15
|
@condition = nil
|
|
15
16
|
@timing = "before"
|
|
17
|
+
@for_each = "row"
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
def table(table_name)
|
|
@@ -27,23 +29,22 @@ module PgSqlTriggers
|
|
|
27
29
|
@function_name = function_name
|
|
28
30
|
end
|
|
29
31
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
@version
|
|
33
|
-
else
|
|
34
|
-
@version = version
|
|
35
|
-
end
|
|
32
|
+
def timing=(val)
|
|
33
|
+
@timing = val.to_s
|
|
36
34
|
end
|
|
37
35
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
def for_each_row
|
|
37
|
+
@for_each = "row"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def for_each_statement
|
|
41
|
+
@for_each = "statement"
|
|
44
42
|
end
|
|
45
43
|
|
|
46
44
|
def when_env(*environments)
|
|
45
|
+
warn "[DEPRECATION] `when_env` is deprecated and will be removed in a future version. " \
|
|
46
|
+
"Environment-specific trigger behavior causes schema drift between environments. " \
|
|
47
|
+
"Use application-level configuration instead."
|
|
47
48
|
@environments = environments.map(&:to_s)
|
|
48
49
|
end
|
|
49
50
|
|
|
@@ -51,14 +52,6 @@ module PgSqlTriggers
|
|
|
51
52
|
@condition = condition_sql
|
|
52
53
|
end
|
|
53
54
|
|
|
54
|
-
def timing(timing_value = nil)
|
|
55
|
-
if timing_value.nil?
|
|
56
|
-
@timing
|
|
57
|
-
else
|
|
58
|
-
@timing = timing_value.to_s
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
55
|
def function_body
|
|
63
56
|
nil # DSL definitions don't include function_body directly
|
|
64
57
|
end
|
|
@@ -73,7 +66,8 @@ module PgSqlTriggers
|
|
|
73
66
|
enabled: @enabled,
|
|
74
67
|
environments: @environments,
|
|
75
68
|
condition: @condition,
|
|
76
|
-
timing: @timing
|
|
69
|
+
timing: @timing,
|
|
70
|
+
for_each: @for_each
|
|
77
71
|
}
|
|
78
72
|
end
|
|
79
73
|
end
|
|
@@ -25,5 +25,19 @@ module PgSqlTriggers
|
|
|
25
25
|
rake_tasks do
|
|
26
26
|
load root.join("lib/tasks/trigger_migrations.rake")
|
|
27
27
|
end
|
|
28
|
+
|
|
29
|
+
# Warn at startup if no permission_checker is set in a protected environment.
|
|
30
|
+
# The default is to allow all actions (including admin-level ones), which is
|
|
31
|
+
# unsafe in production without an explicit checker configured.
|
|
32
|
+
config.after_initialize do
|
|
33
|
+
if PgSqlTriggers.permission_checker.nil? && defined?(Rails) && Rails.env.production?
|
|
34
|
+
Rails.logger.warn(
|
|
35
|
+
"[PgSqlTriggers] SECURITY WARNING: No permission_checker is configured. " \
|
|
36
|
+
"All actions are permitted by default, including admin-level operations " \
|
|
37
|
+
"(drop_trigger, execute_sql, override_drift). " \
|
|
38
|
+
"Set PgSqlTriggers.permission_checker in an initializer before deploying to production."
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
28
42
|
end
|
|
29
43
|
end
|
|
@@ -12,25 +12,24 @@ module PgSqlTriggers
|
|
|
12
12
|
# Compare expected state from migration with actual database state
|
|
13
13
|
# Returns a comparison result with diff information
|
|
14
14
|
def compare(migration_instance, direction: :up)
|
|
15
|
-
|
|
15
|
+
captured_sql = capture_sql(migration_instance, direction)
|
|
16
|
+
compare_sql(captured_sql)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Compare using pre-captured SQL (avoids re-instantiating the migration class).
|
|
20
|
+
# Returns a comparison result with diff information.
|
|
21
|
+
def compare_sql(captured_sql)
|
|
22
|
+
expected = parse_sql_to_state(captured_sql)
|
|
16
23
|
actual = extract_actual_state(expected)
|
|
17
24
|
generate_diff(expected, actual)
|
|
18
25
|
end
|
|
19
26
|
|
|
20
27
|
private
|
|
21
28
|
|
|
22
|
-
# Extract expected SQL and state from migration instance
|
|
23
|
-
def extract_expected_state(migration_instance, direction)
|
|
24
|
-
captured_sql = capture_sql(migration_instance, direction)
|
|
25
|
-
parse_sql_to_state(captured_sql)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
29
|
# Capture SQL that would be executed by the migration
|
|
29
30
|
def capture_sql(migration_instance, direction)
|
|
30
31
|
captured = []
|
|
31
32
|
|
|
32
|
-
# Override execute to capture SQL instead of executing
|
|
33
|
-
# Since we use a separate instance for comparison, we don't need to restore
|
|
34
33
|
migration_instance.define_singleton_method(:execute) do |sql|
|
|
35
34
|
captured << sql.to_s.strip
|
|
36
35
|
end
|
|
@@ -34,26 +34,41 @@ module PgSqlTriggers
|
|
|
34
34
|
raise UnsafeOperationError.new(error_message, violations)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Validate using pre-captured SQL (avoids re-instantiating the migration class).
|
|
38
|
+
# Raises UnsafeOperationError if unsafe patterns are detected.
|
|
39
|
+
def validate_sql!(captured_sql, direction: :up, allow_unsafe: false) # rubocop:disable Lint/UnusedMethodArgument
|
|
40
|
+
return if allow_unsafe
|
|
41
|
+
|
|
42
|
+
violations = detect_unsafe_patterns_from_sql(captured_sql)
|
|
43
|
+
return if violations.empty?
|
|
44
|
+
|
|
45
|
+
error_message = build_error_message(violations, "(migration)")
|
|
46
|
+
raise UnsafeOperationError.new(error_message, violations)
|
|
47
|
+
end
|
|
48
|
+
|
|
37
49
|
# Detect unsafe patterns in migration SQL
|
|
38
50
|
# Returns array of violation hashes
|
|
39
51
|
def detect_unsafe_patterns(migration_instance, direction)
|
|
40
|
-
violations = []
|
|
41
|
-
|
|
42
|
-
# Capture SQL that would be executed
|
|
43
52
|
captured_sql = capture_sql(migration_instance, direction)
|
|
53
|
+
detect_unsafe_patterns_from_sql(captured_sql)
|
|
54
|
+
end
|
|
44
55
|
|
|
45
|
-
|
|
46
|
-
sql_operations = parse_sql_operations(captured_sql)
|
|
56
|
+
private
|
|
47
57
|
|
|
48
|
-
|
|
58
|
+
# Core detection logic that works on pre-captured SQL.
|
|
59
|
+
def detect_unsafe_patterns_from_sql(captured_sql)
|
|
60
|
+
violations = []
|
|
61
|
+
sql_operations = parse_sql_operations(captured_sql)
|
|
49
62
|
violations.concat(detect_drop_create_patterns(sql_operations))
|
|
50
|
-
|
|
51
63
|
violations
|
|
52
64
|
end
|
|
53
65
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
#
|
|
66
|
+
# Capture SQL that would be executed by the migration.
|
|
67
|
+
# Runs the migration inside a transaction that is always rolled back so
|
|
68
|
+
# that any ActiveRecord migration helpers (add_column, create_table, …)
|
|
69
|
+
# that are called alongside explicit +execute+ calls produce no lasting
|
|
70
|
+
# side effects. The singleton override of +execute+ still intercepts
|
|
71
|
+
# raw SQL strings before they reach the database.
|
|
57
72
|
def capture_sql(migration_instance, direction)
|
|
58
73
|
captured = []
|
|
59
74
|
|
|
@@ -62,8 +77,13 @@ module PgSqlTriggers
|
|
|
62
77
|
captured << sql.to_s.strip
|
|
63
78
|
end
|
|
64
79
|
|
|
65
|
-
#
|
|
66
|
-
|
|
80
|
+
# Run inside a transaction and roll it back so that any AR helpers
|
|
81
|
+
# called by the migration (add_column, create_table, etc.) do not
|
|
82
|
+
# persist their side effects during the capture phase.
|
|
83
|
+
ActiveRecord::Base.transaction do
|
|
84
|
+
migration_instance.public_send(direction)
|
|
85
|
+
raise ActiveRecord::Rollback
|
|
86
|
+
end
|
|
67
87
|
|
|
68
88
|
captured
|
|
69
89
|
end
|
|
@@ -191,13 +191,17 @@ module PgSqlTriggers
|
|
|
191
191
|
end
|
|
192
192
|
end
|
|
193
193
|
|
|
194
|
+
# Capture SQL once from a single inspection instance so that both the
|
|
195
|
+
# safety validator and comparator work from the same snapshot without
|
|
196
|
+
# running the migration code a second time.
|
|
197
|
+
captured_sql = capture_migration_sql(migration_class.new, direction)
|
|
198
|
+
|
|
194
199
|
# Perform safety validation (prevent unsafe DROP + CREATE operations)
|
|
195
|
-
validation_instance = migration_class.new
|
|
196
200
|
begin
|
|
197
201
|
allow_unsafe = ENV["ALLOW_UNSAFE_MIGRATIONS"] == "true" ||
|
|
198
202
|
(defined?(PgSqlTriggers) && PgSqlTriggers.allow_unsafe_migrations == true)
|
|
199
203
|
|
|
200
|
-
SafetyValidator.
|
|
204
|
+
SafetyValidator.validate_sql!(captured_sql, direction: direction, allow_unsafe: allow_unsafe)
|
|
201
205
|
rescue SafetyValidator::UnsafeOperationError => e
|
|
202
206
|
# Safety validation failed - block the migration
|
|
203
207
|
error_msg = "\n#{e.message}\n\n"
|
|
@@ -212,11 +216,9 @@ module PgSqlTriggers
|
|
|
212
216
|
end
|
|
213
217
|
end
|
|
214
218
|
|
|
215
|
-
# Perform pre-apply comparison (diff expected vs actual)
|
|
216
|
-
# Create a separate instance for comparison to avoid side effects
|
|
217
|
-
comparison_instance = migration_class.new
|
|
219
|
+
# Perform pre-apply comparison (diff expected vs actual) using the same captured SQL
|
|
218
220
|
begin
|
|
219
|
-
diff_result = PreApplyComparator.
|
|
221
|
+
diff_result = PreApplyComparator.compare_sql(captured_sql)
|
|
220
222
|
|
|
221
223
|
# Log the comparison result
|
|
222
224
|
if diff_result[:has_differences]
|
|
@@ -261,6 +263,8 @@ module PgSqlTriggers
|
|
|
261
263
|
cleanup_orphaned_registry_entries
|
|
262
264
|
end
|
|
263
265
|
end
|
|
266
|
+
|
|
267
|
+
enforce_disabled_triggers if direction == :up
|
|
264
268
|
rescue LoadError => e
|
|
265
269
|
raise StandardError, "Error loading trigger migration #{migration.filename}: #{e.message}"
|
|
266
270
|
rescue StandardError => e
|
|
@@ -295,6 +299,49 @@ module PgSqlTriggers
|
|
|
295
299
|
current_version
|
|
296
300
|
end
|
|
297
301
|
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
# Capture the SQL statements a migration would execute for a given direction
|
|
305
|
+
# without committing any side effects. The migration's +execute+ method is
|
|
306
|
+
# overridden on the singleton so raw SQL strings are intercepted, and the
|
|
307
|
+
# whole run is wrapped in a transaction that is always rolled back so that
|
|
308
|
+
# any ActiveRecord migration helpers (add_column, create_table, …) don't
|
|
309
|
+
# persist their effects during the inspection phase.
|
|
310
|
+
def capture_migration_sql(migration_instance, direction)
|
|
311
|
+
captured = []
|
|
312
|
+
|
|
313
|
+
migration_instance.define_singleton_method(:execute) do |sql|
|
|
314
|
+
captured << sql.to_s.strip
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
ActiveRecord::Base.transaction do
|
|
318
|
+
migration_instance.public_send(direction)
|
|
319
|
+
raise ActiveRecord::Rollback
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
captured
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def enforce_disabled_triggers
|
|
326
|
+
return unless ActiveRecord::Base.connection.table_exists?("pg_sql_triggers_registry")
|
|
327
|
+
|
|
328
|
+
introspection = PgSqlTriggers::DatabaseIntrospection.new
|
|
329
|
+
PgSqlTriggers::TriggerRegistry.disabled.each do |registry|
|
|
330
|
+
next unless introspection.trigger_exists?(registry.trigger_name)
|
|
331
|
+
|
|
332
|
+
conn = ActiveRecord::Base.connection
|
|
333
|
+
quoted_table = conn.quote_table_name(registry.table_name.to_s)
|
|
334
|
+
quoted_trigger = conn.quote_table_name(registry.trigger_name.to_s)
|
|
335
|
+
conn.execute("ALTER TABLE #{quoted_table} DISABLE TRIGGER #{quoted_trigger};")
|
|
336
|
+
rescue StandardError => e
|
|
337
|
+
if defined?(Rails.logger)
|
|
338
|
+
Rails.logger.warn("[MIGRATOR] Could not disable trigger #{registry.trigger_name}: #{e.message}")
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
public
|
|
344
|
+
|
|
298
345
|
# Clean up registry entries for triggers that no longer exist in the database
|
|
299
346
|
# This is called after rolling back migrations to keep the registry in sync
|
|
300
347
|
def cleanup_orphaned_registry_entries
|
|
@@ -3,15 +3,19 @@
|
|
|
3
3
|
module PgSqlTriggers
|
|
4
4
|
module Registry
|
|
5
5
|
class Manager
|
|
6
|
+
REGISTRY_CACHE_MUTEX = Mutex.new
|
|
7
|
+
private_constant :REGISTRY_CACHE_MUTEX
|
|
8
|
+
|
|
6
9
|
class << self
|
|
7
|
-
# Request-level cache to avoid N+1 queries when loading multiple trigger files
|
|
8
|
-
#
|
|
10
|
+
# Request-level cache to avoid N+1 queries when loading multiple trigger files.
|
|
11
|
+
# Access to @_registry_cache is guarded by REGISTRY_CACHE_MUTEX so that
|
|
12
|
+
# concurrent threads cannot observe a partially-initialised hash.
|
|
9
13
|
def _registry_cache
|
|
10
|
-
@_registry_cache ||= {}
|
|
14
|
+
REGISTRY_CACHE_MUTEX.synchronize { @_registry_cache ||= {} }
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def _clear_registry_cache
|
|
14
|
-
@_registry_cache = {}
|
|
18
|
+
REGISTRY_CACHE_MUTEX.synchronize { @_registry_cache = {} }
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# Batch load existing triggers into cache to avoid N+1 queries
|
|
@@ -52,7 +56,8 @@ module PgSqlTriggers
|
|
|
52
56
|
source: "dsl",
|
|
53
57
|
environment: definition.environments.join(","),
|
|
54
58
|
definition: definition.to_h.to_json,
|
|
55
|
-
checksum: checksum
|
|
59
|
+
checksum: checksum,
|
|
60
|
+
for_each: definition.for_each || "row"
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
if existing
|
|
@@ -60,6 +65,7 @@ module PgSqlTriggers
|
|
|
60
65
|
attributes_changed = attributes.any? do |key, value|
|
|
61
66
|
existing.send(key) != value
|
|
62
67
|
end
|
|
68
|
+
enabled_changed = existing.enabled != definition.enabled
|
|
63
69
|
|
|
64
70
|
if attributes_changed
|
|
65
71
|
begin
|
|
@@ -67,6 +73,9 @@ module PgSqlTriggers
|
|
|
67
73
|
# Update cache with the modified record (reload to get fresh data)
|
|
68
74
|
reloaded = existing.reload
|
|
69
75
|
_registry_cache[trigger_name] = reloaded
|
|
76
|
+
if enabled_changed
|
|
77
|
+
sync_postgresql_enabled_state(existing.trigger_name, existing.table_name, definition.enabled)
|
|
78
|
+
end
|
|
70
79
|
reloaded
|
|
71
80
|
rescue ActiveRecord::RecordNotFound
|
|
72
81
|
# Cached record was deleted, create a new one
|
|
@@ -82,6 +91,9 @@ module PgSqlTriggers
|
|
|
82
91
|
new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
|
|
83
92
|
# Cache the newly created record
|
|
84
93
|
_registry_cache[trigger_name] = new_record
|
|
94
|
+
unless definition.enabled
|
|
95
|
+
sync_postgresql_enabled_state(new_record.trigger_name, new_record.table_name, definition.enabled)
|
|
96
|
+
end
|
|
85
97
|
new_record
|
|
86
98
|
end
|
|
87
99
|
end
|
|
@@ -127,22 +139,35 @@ module PgSqlTriggers
|
|
|
127
139
|
private
|
|
128
140
|
|
|
129
141
|
def calculate_checksum(definition)
|
|
130
|
-
# DSL definitions don't have function_body, so use placeholder
|
|
131
|
-
# Generator forms have function_body, so calculate real checksum
|
|
132
142
|
function_body_value = definition.respond_to?(:function_body) ? definition.function_body : nil
|
|
133
|
-
return "placeholder" if function_body_value.blank?
|
|
134
143
|
|
|
135
|
-
# Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
|
|
144
|
+
# Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum).
|
|
145
|
+
# DSL definitions have no function_body — use "" so the checksum is real and comparable
|
|
146
|
+
# with what Drift::Detector#calculate_db_checksum computes for DSL-source triggers.
|
|
136
147
|
require "digest"
|
|
137
148
|
Digest::SHA256.hexdigest([
|
|
138
149
|
definition.name,
|
|
139
150
|
definition.table_name,
|
|
140
151
|
definition.version,
|
|
141
|
-
function_body_value,
|
|
152
|
+
function_body_value || "",
|
|
142
153
|
definition.condition || "",
|
|
143
|
-
definition.timing || "before"
|
|
154
|
+
definition.timing || "before",
|
|
155
|
+
definition.for_each || "row"
|
|
144
156
|
].join)
|
|
145
157
|
end
|
|
158
|
+
|
|
159
|
+
def sync_postgresql_enabled_state(trigger_name, table_name, enabled)
|
|
160
|
+
introspection = PgSqlTriggers::DatabaseIntrospection.new
|
|
161
|
+
return unless introspection.trigger_exists?(trigger_name)
|
|
162
|
+
|
|
163
|
+
conn = ActiveRecord::Base.connection
|
|
164
|
+
quoted_table = conn.quote_table_name(table_name.to_s)
|
|
165
|
+
quoted_trigger = conn.quote_table_name(trigger_name.to_s)
|
|
166
|
+
verb = enabled ? "ENABLE" : "DISABLE"
|
|
167
|
+
conn.execute("ALTER TABLE #{quoted_table} #{verb} TRIGGER #{quoted_trigger};")
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
Rails.logger.warn("[REGISTER] Could not sync trigger enabled state: #{e.message}") if defined?(Rails.logger)
|
|
170
|
+
end
|
|
146
171
|
end
|
|
147
172
|
end
|
|
148
173
|
end
|
|
@@ -1,15 +1,72 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module PgSqlTriggers
|
|
4
6
|
module Registry
|
|
5
7
|
class Validator
|
|
6
|
-
|
|
8
|
+
VALID_EVENTS = %w[insert update delete truncate].freeze
|
|
9
|
+
VALID_TIMINGS = %w[before after instead_of].freeze
|
|
10
|
+
VALID_FOR_EACH = %w[row statement].freeze
|
|
11
|
+
|
|
7
12
|
def self.validate!
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
errors = []
|
|
14
|
+
|
|
15
|
+
PgSqlTriggers::TriggerRegistry.where(source: "dsl").find_each do |trigger|
|
|
16
|
+
errors.concat(validate_dsl_trigger(trigger))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return true if errors.empty?
|
|
20
|
+
|
|
21
|
+
raise PgSqlTriggers::ValidationError.new(
|
|
22
|
+
"Registry validation failed:\n#{errors.map { |e| " - #{e}" }.join("\n")}",
|
|
23
|
+
error_code: "VALIDATION_FAILED",
|
|
24
|
+
context: { errors: errors }
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
class << self
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_dsl_trigger(trigger)
|
|
31
|
+
errors = []
|
|
32
|
+
name = trigger.trigger_name
|
|
33
|
+
definition = parse_definition(trigger.definition)
|
|
34
|
+
|
|
35
|
+
errors << "Trigger '#{name}': missing table_name" if definition["table_name"].blank?
|
|
36
|
+
|
|
37
|
+
events = Array(definition["events"])
|
|
38
|
+
if events.empty?
|
|
39
|
+
errors << "Trigger '#{name}': events cannot be empty"
|
|
40
|
+
else
|
|
41
|
+
invalid = events - VALID_EVENTS
|
|
42
|
+
if invalid.any?
|
|
43
|
+
errors << "Trigger '#{name}': invalid events #{invalid.inspect} (valid: #{VALID_EVENTS.inspect})"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
errors << "Trigger '#{name}': missing function_name" if definition["function_name"].blank?
|
|
48
|
+
|
|
49
|
+
timing = definition["timing"].to_s
|
|
50
|
+
if timing.present? && VALID_TIMINGS.exclude?(timing)
|
|
51
|
+
errors << "Trigger '#{name}': invalid timing '#{timing}' (valid: #{VALID_TIMINGS.inspect})"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
for_each = definition["for_each"].to_s
|
|
55
|
+
if for_each.present? && VALID_FOR_EACH.exclude?(for_each)
|
|
56
|
+
errors << "Trigger '#{name}': invalid for_each '#{for_each}' (valid: #{VALID_FOR_EACH.inspect})"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
errors
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_definition(definition_json)
|
|
63
|
+
return {} if definition_json.blank?
|
|
64
|
+
|
|
65
|
+
JSON.parse(definition_json)
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
{}
|
|
68
|
+
end
|
|
11
69
|
end
|
|
12
|
-
# rubocop:enable Naming/PredicateMethod
|
|
13
70
|
end
|
|
14
71
|
end
|
|
15
72
|
end
|