pg_sql_triggers 1.0.1 → 1.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 +4 -4
- data/CHANGELOG.md +83 -0
- data/COVERAGE.md +58 -0
- data/Goal.md +180 -138
- data/README.md +6 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
- data/app/controllers/pg_sql_triggers/generator_controller.rb +67 -5
- data/app/models/pg_sql_triggers/trigger_registry.rb +73 -10
- data/app/views/pg_sql_triggers/generator/new.html.erb +18 -0
- data/app/views/pg_sql_triggers/generator/preview.html.erb +233 -13
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +32 -0
- data/config/initializers/pg_sql_triggers.rb +69 -0
- data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +2 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/api-reference.md +22 -4
- data/docs/usage-guide.md +73 -0
- data/docs/web-ui.md +14 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +2 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +8 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
- data/lib/pg_sql_triggers/drift/detector.rb +187 -0
- data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
- data/lib/pg_sql_triggers/drift.rb +14 -11
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
- data/lib/pg_sql_triggers/generator/form.rb +3 -1
- data/lib/pg_sql_triggers/generator/service.rb +81 -25
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
- data/lib/pg_sql_triggers/migrator.rb +58 -0
- data/lib/pg_sql_triggers/registry/manager.rb +96 -9
- data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +12 -0
- data/scripts/generate_coverage_report.rb +129 -0
- metadata +12 -2
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
class Migrator
|
|
5
|
+
# Formats pre-apply comparison results into human-readable diff reports
|
|
6
|
+
class PreApplyDiffReporter
|
|
7
|
+
class << self
|
|
8
|
+
# Format diff result as a string report
|
|
9
|
+
def format(diff_result, migration_name: nil)
|
|
10
|
+
return "No differences detected. Migration is safe to apply." unless diff_result[:has_differences]
|
|
11
|
+
|
|
12
|
+
output = []
|
|
13
|
+
output << ("=" * 80)
|
|
14
|
+
output << "Pre-Apply Comparison Report"
|
|
15
|
+
output << "Migration: #{migration_name}" if migration_name
|
|
16
|
+
output << ("=" * 80)
|
|
17
|
+
output << ""
|
|
18
|
+
|
|
19
|
+
# Report on functions
|
|
20
|
+
if diff_result[:functions].any?
|
|
21
|
+
output << "Functions:"
|
|
22
|
+
output << ("-" * 80)
|
|
23
|
+
diff_result[:functions].each do |func_diff|
|
|
24
|
+
output.concat(format_function_diff(func_diff))
|
|
25
|
+
output << ""
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Report on triggers
|
|
30
|
+
if diff_result[:triggers].any?
|
|
31
|
+
output << "Triggers:"
|
|
32
|
+
output << ("-" * 80)
|
|
33
|
+
diff_result[:triggers].each do |trigger_diff|
|
|
34
|
+
output.concat(format_trigger_diff(trigger_diff))
|
|
35
|
+
output << ""
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Report on drops (for down migrations)
|
|
40
|
+
if diff_result[:drops]&.any?
|
|
41
|
+
output << "Drops:"
|
|
42
|
+
output << ("-" * 80)
|
|
43
|
+
diff_result[:drops].each do |drop|
|
|
44
|
+
output << " - Will #{drop[:type] == :trigger ? 'drop trigger' : 'drop function'}: #{drop[:name]}"
|
|
45
|
+
end
|
|
46
|
+
output << ""
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
output << ("=" * 80)
|
|
50
|
+
output << ""
|
|
51
|
+
output << "⚠️ WARNING: This migration will modify existing database objects."
|
|
52
|
+
output << "Review the differences above before proceeding."
|
|
53
|
+
|
|
54
|
+
output.join("\n")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Format a concise summary for console output
|
|
58
|
+
def format_summary(diff_result)
|
|
59
|
+
return "✓ No differences - safe to apply" unless diff_result[:has_differences]
|
|
60
|
+
|
|
61
|
+
summary = []
|
|
62
|
+
summary << "⚠️ Differences detected:"
|
|
63
|
+
|
|
64
|
+
new_count = diff_result[:functions].count { |f| f[:status] == :new } +
|
|
65
|
+
diff_result[:triggers].count { |t| t[:status] == :new }
|
|
66
|
+
modified_count = diff_result[:functions].count { |f| f[:status] == :modified } +
|
|
67
|
+
diff_result[:triggers].count { |t| t[:status] == :modified }
|
|
68
|
+
|
|
69
|
+
summary << " - #{new_count} new object(s) will be created"
|
|
70
|
+
summary << " - #{modified_count} existing object(s) will be modified"
|
|
71
|
+
|
|
72
|
+
summary.join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def format_function_diff(func_diff)
|
|
78
|
+
case func_diff[:status]
|
|
79
|
+
when :new
|
|
80
|
+
[
|
|
81
|
+
" Function: #{func_diff[:function_name]}",
|
|
82
|
+
" Status: NEW (will be created)"
|
|
83
|
+
]
|
|
84
|
+
when :modified
|
|
85
|
+
[
|
|
86
|
+
" Function: #{func_diff[:function_name]}",
|
|
87
|
+
" Status: MODIFIED (will overwrite existing function)",
|
|
88
|
+
" Expected:",
|
|
89
|
+
indent_text(func_diff[:expected], 6),
|
|
90
|
+
" Current:",
|
|
91
|
+
indent_text(func_diff[:actual], 6)
|
|
92
|
+
]
|
|
93
|
+
when :unchanged
|
|
94
|
+
[
|
|
95
|
+
" Function: #{func_diff[:function_name]}",
|
|
96
|
+
" Status: UNCHANGED"
|
|
97
|
+
]
|
|
98
|
+
else
|
|
99
|
+
[
|
|
100
|
+
" Function: #{func_diff[:function_name]}",
|
|
101
|
+
" Status: #{func_diff[:status]}"
|
|
102
|
+
]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def format_trigger_diff(trigger_diff)
|
|
107
|
+
output = []
|
|
108
|
+
output << " Trigger: #{trigger_diff[:trigger_name]}"
|
|
109
|
+
case trigger_diff[:status]
|
|
110
|
+
when :new
|
|
111
|
+
output << " Status: NEW (will be created)"
|
|
112
|
+
output << " Definition:"
|
|
113
|
+
output << indent_text(trigger_diff[:expected], 6)
|
|
114
|
+
when :modified
|
|
115
|
+
output << " Status: MODIFIED (will overwrite existing trigger)"
|
|
116
|
+
|
|
117
|
+
if trigger_diff[:differences]&.any?
|
|
118
|
+
output << " Differences:"
|
|
119
|
+
trigger_diff[:differences].each do |diff|
|
|
120
|
+
output << " - #{diff}"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
output << " Expected:"
|
|
125
|
+
output << indent_text(trigger_diff[:expected], 6)
|
|
126
|
+
output << " Current:"
|
|
127
|
+
output << indent_text(trigger_diff[:actual], 6)
|
|
128
|
+
when :unchanged
|
|
129
|
+
output << " Status: UNCHANGED"
|
|
130
|
+
else
|
|
131
|
+
output << " Status: #{trigger_diff[:status]}"
|
|
132
|
+
end
|
|
133
|
+
output
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def indent_text(text, spaces)
|
|
137
|
+
indent = " " * spaces
|
|
138
|
+
text.to_s.lines.map { |line| "#{indent}#{line.chomp}" }.join("\n")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../drift/db_queries"
|
|
4
|
+
|
|
5
|
+
module PgSqlTriggers
|
|
6
|
+
class Migrator
|
|
7
|
+
# Validates that migrations don't blindly DROP + CREATE objects
|
|
8
|
+
# This ensures safety by detecting unsafe patterns and blocking them
|
|
9
|
+
class SafetyValidator
|
|
10
|
+
# Error raised when unsafe DROP + CREATE operations are detected
|
|
11
|
+
class UnsafeOperationError < PgSqlTriggers::UnsafeMigrationError
|
|
12
|
+
def initialize(message, violations)
|
|
13
|
+
super(message)
|
|
14
|
+
@violations = violations
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :violations
|
|
18
|
+
|
|
19
|
+
def violation_summary
|
|
20
|
+
@violations.map { |v| " - #{v[:message]}" }.join("\n")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
# Validate that a migration doesn't perform unsafe DROP + CREATE operations
|
|
26
|
+
# Raises UnsafeOperationError if unsafe patterns are detected
|
|
27
|
+
def validate!(migration_instance, direction: :up, allow_unsafe: false)
|
|
28
|
+
return if allow_unsafe
|
|
29
|
+
|
|
30
|
+
violations = detect_unsafe_patterns(migration_instance, direction)
|
|
31
|
+
return if violations.empty?
|
|
32
|
+
|
|
33
|
+
error_message = build_error_message(violations, migration_instance.class.name)
|
|
34
|
+
raise UnsafeOperationError.new(error_message, violations)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Detect unsafe patterns in migration SQL
|
|
38
|
+
# Returns array of violation hashes
|
|
39
|
+
def detect_unsafe_patterns(migration_instance, direction)
|
|
40
|
+
violations = []
|
|
41
|
+
|
|
42
|
+
# Capture SQL that would be executed
|
|
43
|
+
captured_sql = capture_sql(migration_instance, direction)
|
|
44
|
+
|
|
45
|
+
# Parse SQL to detect unsafe patterns
|
|
46
|
+
sql_operations = parse_sql_operations(captured_sql)
|
|
47
|
+
|
|
48
|
+
# Check for explicit DROP + CREATE patterns (the main safety concern)
|
|
49
|
+
violations.concat(detect_drop_create_patterns(sql_operations))
|
|
50
|
+
|
|
51
|
+
violations
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Capture SQL that would be executed by the migration
|
|
57
|
+
def capture_sql(migration_instance, direction)
|
|
58
|
+
captured = []
|
|
59
|
+
|
|
60
|
+
# Override execute to capture SQL instead of executing
|
|
61
|
+
migration_instance.define_singleton_method(:execute) do |sql|
|
|
62
|
+
captured << sql.to_s.strip
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Call the migration method (up or down) to capture SQL
|
|
66
|
+
migration_instance.public_send(direction)
|
|
67
|
+
|
|
68
|
+
captured
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse SQL into structured operations
|
|
72
|
+
def parse_sql_operations(sql_array)
|
|
73
|
+
operations = {
|
|
74
|
+
drops: [],
|
|
75
|
+
creates: [],
|
|
76
|
+
replaces: []
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
sql_array.each do |sql|
|
|
80
|
+
sql_normalized = sql.squish
|
|
81
|
+
|
|
82
|
+
# Parse DROP statements
|
|
83
|
+
if sql_normalized.match?(/DROP\s+(TRIGGER|FUNCTION)/i)
|
|
84
|
+
drop_info = parse_drop(sql)
|
|
85
|
+
operations[:drops] << drop_info if drop_info
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Parse CREATE statements (without OR REPLACE)
|
|
89
|
+
if sql_normalized.match?(/CREATE\s+(?!OR\s+REPLACE)(TRIGGER|FUNCTION)/i)
|
|
90
|
+
create_info = parse_create(sql)
|
|
91
|
+
operations[:creates] << create_info if create_info
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Parse CREATE OR REPLACE statements
|
|
95
|
+
# (only for functions - PostgreSQL doesn't support CREATE OR REPLACE TRIGGER)
|
|
96
|
+
if sql_normalized.match?(/CREATE\s+OR\s+REPLACE\s+FUNCTION/i)
|
|
97
|
+
replace_info = parse_replace(sql)
|
|
98
|
+
operations[:replaces] << replace_info if replace_info
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
operations
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Parse DROP SQL statement
|
|
106
|
+
def parse_drop(sql)
|
|
107
|
+
if sql.match?(/DROP\s+TRIGGER/i)
|
|
108
|
+
match = sql.match(/DROP\s+TRIGGER\s+(?:IF\s+EXISTS\s+)?(\w+)\s+ON\s+(\w+)/i)
|
|
109
|
+
return nil unless match
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
type: :trigger,
|
|
113
|
+
name: match[1],
|
|
114
|
+
table_name: match[2],
|
|
115
|
+
sql: sql
|
|
116
|
+
}
|
|
117
|
+
elsif sql.match?(/DROP\s+FUNCTION/i)
|
|
118
|
+
match = sql.match(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(\w+)\s*\(\)?/i)
|
|
119
|
+
return nil unless match
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
type: :function,
|
|
123
|
+
name: match[1],
|
|
124
|
+
sql: sql
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Parse CREATE SQL statement (without OR REPLACE)
|
|
130
|
+
def parse_create(sql)
|
|
131
|
+
if sql.match?(/CREATE\s+TRIGGER/i)
|
|
132
|
+
match = sql.match(/CREATE\s+TRIGGER\s+(\w+)\s+.*?\s+ON\s+(\w+)/i)
|
|
133
|
+
return nil unless match
|
|
134
|
+
|
|
135
|
+
{
|
|
136
|
+
type: :trigger,
|
|
137
|
+
name: match[1],
|
|
138
|
+
table_name: match[2],
|
|
139
|
+
sql: sql
|
|
140
|
+
}
|
|
141
|
+
elsif sql.match?(/CREATE\s+FUNCTION/i)
|
|
142
|
+
match = sql.match(/CREATE\s+FUNCTION\s+(\w+)\s*\([^)]*\)/i)
|
|
143
|
+
return nil unless match
|
|
144
|
+
|
|
145
|
+
# Extract function body for comparison
|
|
146
|
+
body_match = sql.match(/\$\$(.+?)\$\$/m) || sql.match(/AS\s+(.+)/im)
|
|
147
|
+
function_body = body_match ? body_match[1].strip : sql
|
|
148
|
+
|
|
149
|
+
{
|
|
150
|
+
type: :function,
|
|
151
|
+
name: match[1],
|
|
152
|
+
function_body: function_body,
|
|
153
|
+
sql: sql
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Parse CREATE OR REPLACE SQL statement (only for functions)
|
|
159
|
+
def parse_replace(sql)
|
|
160
|
+
# PostgreSQL doesn't support CREATE OR REPLACE TRIGGER, only functions
|
|
161
|
+
return unless sql.match?(/CREATE\s+OR\s+REPLACE\s+FUNCTION/i)
|
|
162
|
+
|
|
163
|
+
match = sql.match(/CREATE\s+OR\s+REPLACE\s+FUNCTION\s+(\w+)\s*\([^)]*\)/i)
|
|
164
|
+
return nil unless match
|
|
165
|
+
|
|
166
|
+
# Extract function body for comparison
|
|
167
|
+
body_match = sql.match(/\$\$(.+?)\$\$/m) || sql.match(/AS\s+(.+)/im)
|
|
168
|
+
function_body = body_match ? body_match[1].strip : sql
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
type: :function,
|
|
172
|
+
name: match[1],
|
|
173
|
+
function_body: function_body,
|
|
174
|
+
sql: sql
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Detect explicit DROP + CREATE patterns
|
|
179
|
+
# This is unsafe because it drops existing objects and recreates them without validation
|
|
180
|
+
def detect_drop_create_patterns(operations)
|
|
181
|
+
violations = []
|
|
182
|
+
|
|
183
|
+
# Check if any DROP is followed by a CREATE of the same object
|
|
184
|
+
operations[:drops].each do |drop|
|
|
185
|
+
matching_create = operations[:creates].find do |create|
|
|
186
|
+
type_match = create[:type] == drop[:type]
|
|
187
|
+
name_match = create[:name] == drop[:name]
|
|
188
|
+
# For triggers, also match on table_name
|
|
189
|
+
table_match = if drop[:type] == :trigger
|
|
190
|
+
create[:table_name] == drop[:table_name]
|
|
191
|
+
else
|
|
192
|
+
true # Functions don't have table_name
|
|
193
|
+
end
|
|
194
|
+
type_match && name_match && table_match
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
next unless matching_create
|
|
198
|
+
|
|
199
|
+
object_type = drop[:type] == :trigger ? "trigger" : "function"
|
|
200
|
+
existing_object = case drop[:type]
|
|
201
|
+
when :function
|
|
202
|
+
function_exists?(drop[:name])
|
|
203
|
+
when :trigger
|
|
204
|
+
trigger_exists?(drop[:name])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Only flag as unsafe if the object actually exists
|
|
208
|
+
next unless existing_object
|
|
209
|
+
|
|
210
|
+
violations << {
|
|
211
|
+
type: :drop_create_pattern,
|
|
212
|
+
message: "Unsafe DROP + CREATE pattern detected for #{object_type} '#{drop[:name]}'. " \
|
|
213
|
+
"Migration will drop existing #{object_type} and recreate it. " \
|
|
214
|
+
"For functions, use CREATE OR REPLACE FUNCTION instead. " \
|
|
215
|
+
"For triggers, drop and recreate is sometimes necessary, but ensure this is intentional.",
|
|
216
|
+
drop_sql: drop[:sql],
|
|
217
|
+
create_sql: matching_create[:sql],
|
|
218
|
+
object_name: drop[:name],
|
|
219
|
+
object_type: drop[:type]
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
violations
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Check if function exists in database
|
|
227
|
+
def function_exists?(function_name)
|
|
228
|
+
Drift::DbQueries.find_function(function_name).present?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Check if trigger exists in database
|
|
232
|
+
def trigger_exists?(trigger_name)
|
|
233
|
+
Drift::DbQueries.find_trigger(trigger_name).present?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Build error message from violations
|
|
237
|
+
def build_error_message(violations, migration_class_name)
|
|
238
|
+
message = []
|
|
239
|
+
message << ("=" * 80)
|
|
240
|
+
message << "UNSAFE MIGRATION DETECTED"
|
|
241
|
+
message << "Migration: #{migration_class_name}"
|
|
242
|
+
message << ("=" * 80)
|
|
243
|
+
message << ""
|
|
244
|
+
message << "The migration contains unsafe DROP + CREATE operations that would:"
|
|
245
|
+
message << ""
|
|
246
|
+
message << violations.map { |v| " - #{v[:message]}" }.join("\n")
|
|
247
|
+
message << ""
|
|
248
|
+
message << "To proceed despite these warnings, set ALLOW_UNSAFE_MIGRATIONS=true"
|
|
249
|
+
message << "or configure pg_sql_triggers to allow unsafe operations."
|
|
250
|
+
message << ""
|
|
251
|
+
message << ("=" * 80)
|
|
252
|
+
|
|
253
|
+
message.join("\n")
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
require "ostruct"
|
|
4
4
|
require "active_support/core_ext/string/inflections"
|
|
5
5
|
require "active_support/core_ext/module/delegation"
|
|
6
|
+
require_relative "migrator/pre_apply_comparator"
|
|
7
|
+
require_relative "migrator/pre_apply_diff_reporter"
|
|
8
|
+
require_relative "migrator/safety_validator"
|
|
6
9
|
|
|
7
10
|
module PgSqlTriggers
|
|
11
|
+
# rubocop:disable Metrics/ClassLength
|
|
8
12
|
class Migrator
|
|
9
13
|
MIGRATIONS_TABLE_NAME = "trigger_migrations"
|
|
10
14
|
|
|
@@ -158,6 +162,7 @@ module PgSqlTriggers
|
|
|
158
162
|
end
|
|
159
163
|
end
|
|
160
164
|
|
|
165
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
161
166
|
def run_migration(migration, direction)
|
|
162
167
|
require migration.path
|
|
163
168
|
|
|
@@ -186,7 +191,58 @@ module PgSqlTriggers
|
|
|
186
191
|
end
|
|
187
192
|
end
|
|
188
193
|
|
|
194
|
+
# Perform safety validation (prevent unsafe DROP + CREATE operations)
|
|
195
|
+
validation_instance = migration_class.new
|
|
196
|
+
begin
|
|
197
|
+
allow_unsafe = ENV["ALLOW_UNSAFE_MIGRATIONS"] == "true" ||
|
|
198
|
+
(defined?(PgSqlTriggers) && PgSqlTriggers.allow_unsafe_migrations == true)
|
|
199
|
+
|
|
200
|
+
SafetyValidator.validate!(validation_instance, direction: direction, allow_unsafe: allow_unsafe)
|
|
201
|
+
rescue SafetyValidator::UnsafeOperationError => e
|
|
202
|
+
# Safety validation failed - block the migration
|
|
203
|
+
error_msg = "\n#{e.message}\n\n"
|
|
204
|
+
Rails.logger.error(error_msg) if defined?(Rails.logger)
|
|
205
|
+
Rails.logger.debug error_msg if ENV["VERBOSE"] != "false" || defined?(Rails::Console)
|
|
206
|
+
raise StandardError, "Migration blocked due to unsafe DROP + CREATE operations. " \
|
|
207
|
+
"Review the errors above and set ALLOW_UNSAFE_MIGRATIONS=true if you must proceed."
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
# Don't fail the migration if validation fails for other reasons - just log it
|
|
210
|
+
if defined?(Rails.logger)
|
|
211
|
+
Rails.logger.warn("Safety validation failed for migration #{migration.name}: #{e.message}")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
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
|
|
218
|
+
begin
|
|
219
|
+
diff_result = PreApplyComparator.compare(comparison_instance, direction: direction)
|
|
220
|
+
|
|
221
|
+
# Log the comparison result
|
|
222
|
+
if diff_result[:has_differences]
|
|
223
|
+
diff_report = PreApplyDiffReporter.format(diff_result, migration_name: migration.name)
|
|
224
|
+
if defined?(Rails.logger)
|
|
225
|
+
Rails.logger.warn("Pre-apply comparison for migration #{migration.name}:\n#{diff_report}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# In verbose mode or when called from console, print the diff
|
|
229
|
+
if ENV["VERBOSE"] != "false" || defined?(Rails::Console)
|
|
230
|
+
Rails.logger.debug { "\n#{PreApplyDiffReporter.format_summary(diff_result)}\n" }
|
|
231
|
+
end
|
|
232
|
+
elsif defined?(Rails.logger)
|
|
233
|
+
Rails.logger.info(
|
|
234
|
+
"Pre-apply comparison: No differences detected for migration #{migration.name}"
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
# Don't fail the migration if comparison fails - just log it
|
|
239
|
+
if defined?(Rails.logger)
|
|
240
|
+
Rails.logger.warn("Pre-apply comparison failed for migration #{migration.name}: #{e.message}")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
189
244
|
ActiveRecord::Base.transaction do
|
|
245
|
+
# Create a fresh instance for actual execution
|
|
190
246
|
migration_instance = migration_class.new
|
|
191
247
|
migration_instance.public_send(direction)
|
|
192
248
|
|
|
@@ -211,6 +267,7 @@ module PgSqlTriggers
|
|
|
211
267
|
raise StandardError,
|
|
212
268
|
"Error running trigger migration #{migration.filename}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
213
269
|
end
|
|
270
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
214
271
|
|
|
215
272
|
def status
|
|
216
273
|
ensure_migrations_table!
|
|
@@ -258,4 +315,5 @@ module PgSqlTriggers
|
|
|
258
315
|
end
|
|
259
316
|
end
|
|
260
317
|
end
|
|
318
|
+
# rubocop:enable Metrics/ClassLength
|
|
261
319
|
end
|
|
@@ -4,9 +4,40 @@ module PgSqlTriggers
|
|
|
4
4
|
module Registry
|
|
5
5
|
class Manager
|
|
6
6
|
class << self
|
|
7
|
+
# Request-level cache to avoid N+1 queries when loading multiple trigger files
|
|
8
|
+
# This cache is cleared after each request/transaction to ensure data consistency
|
|
9
|
+
def _registry_cache
|
|
10
|
+
@_registry_cache ||= {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def _clear_registry_cache
|
|
14
|
+
@_registry_cache = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Batch load existing triggers into cache to avoid N+1 queries
|
|
18
|
+
# Call this before registering multiple triggers for better performance
|
|
19
|
+
def preload_triggers(trigger_names)
|
|
20
|
+
return if trigger_names.empty?
|
|
21
|
+
|
|
22
|
+
# Find all triggers that aren't already cached
|
|
23
|
+
uncached_names = trigger_names - _registry_cache.keys
|
|
24
|
+
return if uncached_names.empty?
|
|
25
|
+
|
|
26
|
+
# Batch load all uncached triggers in a single query
|
|
27
|
+
PgSqlTriggers::TriggerRegistry.where(trigger_name: uncached_names).find_each do |trigger|
|
|
28
|
+
_registry_cache[trigger.trigger_name] = trigger
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
7
32
|
def register(definition)
|
|
8
33
|
trigger_name = definition.name
|
|
9
|
-
|
|
34
|
+
|
|
35
|
+
# Use cached lookup if available to avoid N+1 queries during trigger file loading
|
|
36
|
+
existing = _registry_cache[trigger_name] ||=
|
|
37
|
+
PgSqlTriggers::TriggerRegistry.find_by(trigger_name: trigger_name)
|
|
38
|
+
|
|
39
|
+
# Calculate checksum using field-concatenation (consistent with TriggerRegistry model)
|
|
40
|
+
checksum = calculate_checksum(definition)
|
|
10
41
|
|
|
11
42
|
attributes = {
|
|
12
43
|
trigger_name: definition.name,
|
|
@@ -15,14 +46,28 @@ module PgSqlTriggers
|
|
|
15
46
|
enabled: definition.enabled,
|
|
16
47
|
source: "dsl",
|
|
17
48
|
environment: definition.environments.join(","),
|
|
18
|
-
definition: definition.to_h.to_json
|
|
49
|
+
definition: definition.to_h.to_json,
|
|
50
|
+
checksum: checksum
|
|
19
51
|
}
|
|
20
52
|
|
|
21
53
|
if existing
|
|
22
|
-
|
|
23
|
-
|
|
54
|
+
begin
|
|
55
|
+
existing.update!(attributes)
|
|
56
|
+
# Update cache with the modified record (reload to get fresh data)
|
|
57
|
+
reloaded = existing.reload
|
|
58
|
+
_registry_cache[trigger_name] = reloaded
|
|
59
|
+
reloaded
|
|
60
|
+
rescue ActiveRecord::RecordNotFound
|
|
61
|
+
# Cached record was deleted, create a new one
|
|
62
|
+
new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
|
|
63
|
+
_registry_cache[trigger_name] = new_record
|
|
64
|
+
new_record
|
|
65
|
+
end
|
|
24
66
|
else
|
|
25
|
-
PgSqlTriggers::TriggerRegistry.create!(attributes
|
|
67
|
+
new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
|
|
68
|
+
# Cache the newly created record
|
|
69
|
+
_registry_cache[trigger_name] = new_record
|
|
70
|
+
new_record
|
|
26
71
|
end
|
|
27
72
|
end
|
|
28
73
|
|
|
@@ -36,10 +81,52 @@ module PgSqlTriggers
|
|
|
36
81
|
|
|
37
82
|
delegate :for_table, to: PgSqlTriggers::TriggerRegistry
|
|
38
83
|
|
|
39
|
-
def diff
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
84
|
+
def diff(trigger_name = nil)
|
|
85
|
+
PgSqlTriggers::Drift.detect(trigger_name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def drifted
|
|
89
|
+
PgSqlTriggers::Drift::Detector.detect_all.select do |r|
|
|
90
|
+
r[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def in_sync
|
|
95
|
+
PgSqlTriggers::Drift::Detector.detect_all.select do |r|
|
|
96
|
+
r[:state] == PgSqlTriggers::DRIFT_STATE_IN_SYNC
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def unknown_triggers
|
|
101
|
+
PgSqlTriggers::Drift::Detector.detect_all.select do |r|
|
|
102
|
+
r[:state] == PgSqlTriggers::DRIFT_STATE_UNKNOWN
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def dropped
|
|
107
|
+
PgSqlTriggers::Drift::Detector.detect_all.select do |r|
|
|
108
|
+
r[:state] == PgSqlTriggers::DRIFT_STATE_DROPPED
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def calculate_checksum(definition)
|
|
115
|
+
# DSL definitions don't have function_body, so use placeholder
|
|
116
|
+
# Generator forms have function_body, so calculate real checksum
|
|
117
|
+
function_body_value = definition.respond_to?(:function_body) ? definition.function_body : nil
|
|
118
|
+
return "placeholder" if function_body_value.blank?
|
|
119
|
+
|
|
120
|
+
# Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
|
|
121
|
+
require "digest"
|
|
122
|
+
Digest::SHA256.hexdigest([
|
|
123
|
+
definition.name,
|
|
124
|
+
definition.table_name,
|
|
125
|
+
definition.version,
|
|
126
|
+
function_body_value,
|
|
127
|
+
definition.condition || "",
|
|
128
|
+
definition.timing || "before"
|
|
129
|
+
].join)
|
|
43
130
|
end
|
|
44
131
|
end
|
|
45
132
|
end
|