pg_sql_triggers 1.0.0 → 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/.erb_lint.yml +47 -0
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +112 -1
- data/COVERAGE.md +58 -0
- data/Goal.md +450 -123
- data/README.md +53 -215
- data/app/controllers/pg_sql_triggers/application_controller.rb +46 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
- data/app/controllers/pg_sql_triggers/generator_controller.rb +76 -8
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +18 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +93 -12
- data/app/views/layouts/pg_sql_triggers/application.html.erb +34 -1
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +70 -30
- data/app/views/pg_sql_triggers/generator/new.html.erb +22 -4
- data/app/views/pg_sql_triggers/generator/preview.html.erb +244 -16
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +221 -0
- data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +40 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +0 -2
- data/app/views/pg_sql_triggers/tables/show.html.erb +3 -4
- data/config/initializers/pg_sql_triggers.rb +69 -0
- data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +3 -1
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/README.md +66 -0
- data/docs/api-reference.md +681 -0
- data/docs/configuration.md +541 -0
- data/docs/getting-started.md +135 -0
- data/docs/kill-switch.md +586 -0
- data/docs/screenshots/.gitkeep +1 -0
- data/docs/screenshots/Generate Trigger.png +0 -0
- data/docs/screenshots/Triggers Page.png +0 -0
- data/docs/screenshots/kill error.png +0 -0
- data/docs/screenshots/kill modal for migration down.png +0 -0
- data/docs/usage-guide.md +493 -0
- data/docs/web-ui.md +353 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +3 -1
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +44 -2
- 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 +82 -26
- data/lib/pg_sql_triggers/migration.rb +1 -1
- 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 +85 -3
- data/lib/pg_sql_triggers/registry/manager.rb +100 -13
- data/lib/pg_sql_triggers/sql/kill_switch.rb +300 -0
- data/lib/pg_sql_triggers/testing/dry_run.rb +5 -7
- data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
- data/lib/pg_sql_triggers/testing/safe_executor.rb +23 -11
- 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 +24 -0
- data/lib/tasks/trigger_migrations.rake +33 -0
- data/scripts/generate_coverage_report.rb +129 -0
- metadata +45 -5
|
@@ -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
|
|
|
@@ -38,7 +42,7 @@ module PgSqlTriggers
|
|
|
38
42
|
def migrations
|
|
39
43
|
return [] unless Dir.exist?(migrations_path)
|
|
40
44
|
|
|
41
|
-
files = Dir.glob(migrations_path.join("*.rb"))
|
|
45
|
+
files = Dir.glob(migrations_path.join("*.rb"))
|
|
42
46
|
files.map do |file|
|
|
43
47
|
basename = File.basename(file, ".rb")
|
|
44
48
|
# Handle Rails migration format: YYYYMMDDHHMMSS_name
|
|
@@ -78,7 +82,19 @@ module PgSqlTriggers
|
|
|
78
82
|
end
|
|
79
83
|
end
|
|
80
84
|
|
|
81
|
-
def run_up(target_version = nil)
|
|
85
|
+
def run_up(target_version = nil, confirmation: nil)
|
|
86
|
+
# Check kill switch before running migrations
|
|
87
|
+
# This provides protection when called directly from console
|
|
88
|
+
# When called from rake tasks, the ENV override will already be in place
|
|
89
|
+
# Use ENV["CONFIRMATION_TEXT"] if confirmation is not provided (for rake task compatibility)
|
|
90
|
+
confirmation ||= ENV.fetch("CONFIRMATION_TEXT", nil)
|
|
91
|
+
PgSqlTriggers::SQL::KillSwitch.check!(
|
|
92
|
+
operation: :migrator_run_up,
|
|
93
|
+
environment: Rails.env,
|
|
94
|
+
confirmation: confirmation,
|
|
95
|
+
actor: { type: "Console", id: "Migrator.run_up" }
|
|
96
|
+
)
|
|
97
|
+
|
|
82
98
|
if target_version
|
|
83
99
|
# Apply a specific migration version
|
|
84
100
|
migration_to_apply = migrations.find { |m| m.version == target_version }
|
|
@@ -102,7 +118,19 @@ module PgSqlTriggers
|
|
|
102
118
|
end
|
|
103
119
|
end
|
|
104
120
|
|
|
105
|
-
def run_down(target_version = nil)
|
|
121
|
+
def run_down(target_version = nil, confirmation: nil)
|
|
122
|
+
# Check kill switch before running migrations
|
|
123
|
+
# This provides protection when called directly from console
|
|
124
|
+
# When called from rake tasks, the ENV override will already be in place
|
|
125
|
+
# Use ENV["CONFIRMATION_TEXT"] if confirmation is not provided (for rake task compatibility)
|
|
126
|
+
confirmation ||= ENV.fetch("CONFIRMATION_TEXT", nil)
|
|
127
|
+
PgSqlTriggers::SQL::KillSwitch.check!(
|
|
128
|
+
operation: :migrator_run_down,
|
|
129
|
+
environment: Rails.env,
|
|
130
|
+
confirmation: confirmation,
|
|
131
|
+
actor: { type: "Console", id: "Migrator.run_down" }
|
|
132
|
+
)
|
|
133
|
+
|
|
106
134
|
current_ver = current_version
|
|
107
135
|
return if current_ver.zero?
|
|
108
136
|
|
|
@@ -134,6 +162,7 @@ module PgSqlTriggers
|
|
|
134
162
|
end
|
|
135
163
|
end
|
|
136
164
|
|
|
165
|
+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
137
166
|
def run_migration(migration, direction)
|
|
138
167
|
require migration.path
|
|
139
168
|
|
|
@@ -162,7 +191,58 @@ module PgSqlTriggers
|
|
|
162
191
|
end
|
|
163
192
|
end
|
|
164
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
|
+
|
|
165
244
|
ActiveRecord::Base.transaction do
|
|
245
|
+
# Create a fresh instance for actual execution
|
|
166
246
|
migration_instance = migration_class.new
|
|
167
247
|
migration_instance.public_send(direction)
|
|
168
248
|
|
|
@@ -187,6 +267,7 @@ module PgSqlTriggers
|
|
|
187
267
|
raise StandardError,
|
|
188
268
|
"Error running trigger migration #{migration.filename}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
|
189
269
|
end
|
|
270
|
+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
190
271
|
|
|
191
272
|
def status
|
|
192
273
|
ensure_migrations_table!
|
|
@@ -234,4 +315,5 @@ module PgSqlTriggers
|
|
|
234
315
|
end
|
|
235
316
|
end
|
|
236
317
|
end
|
|
318
|
+
# rubocop:enable Metrics/ClassLength
|
|
237
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,31 +46,87 @@ 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
|
-
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
|
|
|
29
74
|
def list
|
|
30
|
-
TriggerRegistry.all
|
|
75
|
+
PgSqlTriggers::TriggerRegistry.all
|
|
31
76
|
end
|
|
32
77
|
|
|
33
|
-
delegate :enabled, to:
|
|
78
|
+
delegate :enabled, to: PgSqlTriggers::TriggerRegistry
|
|
79
|
+
|
|
80
|
+
delegate :disabled, to: PgSqlTriggers::TriggerRegistry
|
|
81
|
+
|
|
82
|
+
delegate :for_table, to: PgSqlTriggers::TriggerRegistry
|
|
83
|
+
|
|
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
|
|
34
111
|
|
|
35
|
-
|
|
112
|
+
private
|
|
36
113
|
|
|
37
|
-
|
|
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?
|
|
38
119
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|