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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +47 -0
  3. data/.rubocop.yml +4 -1
  4. data/CHANGELOG.md +112 -1
  5. data/COVERAGE.md +58 -0
  6. data/Goal.md +450 -123
  7. data/README.md +53 -215
  8. data/app/controllers/pg_sql_triggers/application_controller.rb +46 -0
  9. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
  10. data/app/controllers/pg_sql_triggers/generator_controller.rb +76 -8
  11. data/app/controllers/pg_sql_triggers/migrations_controller.rb +18 -0
  12. data/app/models/pg_sql_triggers/trigger_registry.rb +93 -12
  13. data/app/views/layouts/pg_sql_triggers/application.html.erb +34 -1
  14. data/app/views/pg_sql_triggers/dashboard/index.html.erb +70 -30
  15. data/app/views/pg_sql_triggers/generator/new.html.erb +22 -4
  16. data/app/views/pg_sql_triggers/generator/preview.html.erb +244 -16
  17. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +221 -0
  18. data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +40 -0
  19. data/app/views/pg_sql_triggers/tables/index.html.erb +0 -2
  20. data/app/views/pg_sql_triggers/tables/show.html.erb +3 -4
  21. data/config/initializers/pg_sql_triggers.rb +69 -0
  22. data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +3 -1
  23. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
  24. data/docs/README.md +66 -0
  25. data/docs/api-reference.md +681 -0
  26. data/docs/configuration.md +541 -0
  27. data/docs/getting-started.md +135 -0
  28. data/docs/kill-switch.md +586 -0
  29. data/docs/screenshots/.gitkeep +1 -0
  30. data/docs/screenshots/Generate Trigger.png +0 -0
  31. data/docs/screenshots/Triggers Page.png +0 -0
  32. data/docs/screenshots/kill error.png +0 -0
  33. data/docs/screenshots/kill modal for migration down.png +0 -0
  34. data/docs/usage-guide.md +493 -0
  35. data/docs/web-ui.md +353 -0
  36. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +3 -1
  37. data/lib/generators/pg_sql_triggers/templates/initializer.rb +44 -2
  38. data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
  39. data/lib/pg_sql_triggers/drift/detector.rb +187 -0
  40. data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
  41. data/lib/pg_sql_triggers/drift.rb +14 -11
  42. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
  43. data/lib/pg_sql_triggers/generator/form.rb +3 -1
  44. data/lib/pg_sql_triggers/generator/service.rb +82 -26
  45. data/lib/pg_sql_triggers/migration.rb +1 -1
  46. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
  47. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
  48. data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
  49. data/lib/pg_sql_triggers/migrator.rb +85 -3
  50. data/lib/pg_sql_triggers/registry/manager.rb +100 -13
  51. data/lib/pg_sql_triggers/sql/kill_switch.rb +300 -0
  52. data/lib/pg_sql_triggers/testing/dry_run.rb +5 -7
  53. data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
  54. data/lib/pg_sql_triggers/testing/safe_executor.rb +23 -11
  55. data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
  56. data/lib/pg_sql_triggers/version.rb +1 -1
  57. data/lib/pg_sql_triggers.rb +24 -0
  58. data/lib/tasks/trigger_migrations.rake +33 -0
  59. data/scripts/generate_coverage_report.rb +129 -0
  60. metadata +45 -5
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drift/db_queries"
4
+
5
+ module PgSqlTriggers
6
+ class Migrator
7
+ # Pre-apply comparator that extracts expected SQL from migrations
8
+ # and compares it with the current database state
9
+ # rubocop:disable Metrics/ClassLength
10
+ class PreApplyComparator
11
+ class << self
12
+ # Compare expected state from migration with actual database state
13
+ # Returns a comparison result with diff information
14
+ def compare(migration_instance, direction: :up)
15
+ expected = extract_expected_state(migration_instance, direction)
16
+ actual = extract_actual_state(expected)
17
+ generate_diff(expected, actual)
18
+ end
19
+
20
+ private
21
+
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
+ # Capture SQL that would be executed by the migration
29
+ def capture_sql(migration_instance, direction)
30
+ captured = []
31
+
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
+ migration_instance.define_singleton_method(:execute) do |sql|
35
+ captured << sql.to_s.strip
36
+ end
37
+
38
+ # Call the migration method (up or down) to capture SQL
39
+ migration_instance.public_send(direction)
40
+
41
+ captured
42
+ end
43
+
44
+ # Parse captured SQL into structured state (triggers, functions)
45
+ def parse_sql_to_state(sql_array)
46
+ state = {
47
+ functions: [],
48
+ triggers: []
49
+ }
50
+
51
+ sql_array.each do |sql|
52
+ sql_normalized = sql.squish
53
+
54
+ # Parse CREATE FUNCTION statements
55
+ if sql_normalized.match?(/CREATE\s+(OR\s+REPLACE\s+)?FUNCTION/i)
56
+ function_info = parse_function_sql(sql)
57
+ state[:functions] << function_info if function_info
58
+ end
59
+
60
+ # Parse CREATE TRIGGER statements
61
+ if sql_normalized.match?(/CREATE\s+TRIGGER/i)
62
+ trigger_info = parse_trigger_sql(sql)
63
+ state[:triggers] << trigger_info if trigger_info
64
+ end
65
+
66
+ # Parse DROP statements (for down migrations)
67
+ next unless sql_normalized.match?(/DROP\s+(TRIGGER|FUNCTION)/i)
68
+
69
+ drop_info = parse_drop_sql(sql)
70
+ state[:drops] ||= []
71
+ state[:drops] << drop_info if drop_info
72
+ end
73
+
74
+ state
75
+ end
76
+
77
+ # Parse function SQL to extract function name and body
78
+ def parse_function_sql(sql)
79
+ # Match CREATE [OR REPLACE] FUNCTION function_name(...) ... AS $$ body $$
80
+ match = sql.match(/CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(\w+)\s*\([^)]*\)/i)
81
+ return nil unless match
82
+
83
+ function_name = match[1]
84
+
85
+ # Extract function body (between $$ markers or AS ...)
86
+ body_match = sql.match(/\$\$(.+?)\$\$/m) || sql.match(/AS\s+(.+)/im)
87
+ function_body = body_match ? body_match[1].strip : sql
88
+
89
+ {
90
+ function_name: function_name,
91
+ function_body: function_body,
92
+ full_sql: sql
93
+ }
94
+ end
95
+
96
+ # Parse trigger SQL to extract trigger details
97
+ def parse_trigger_sql(sql)
98
+ # Match CREATE TRIGGER trigger_name BEFORE/AFTER events ON table_name ...
99
+ match = sql.match(/CREATE\s+TRIGGER\s+(\w+)\s+(BEFORE|AFTER)\s+(.+?)\s+ON\s+(\w+)/i)
100
+ return nil unless match
101
+
102
+ trigger_name = match[1]
103
+ timing = match[2]
104
+ events = match[3].strip.split(/\s+OR\s+/i).map(&:strip)
105
+ table_name = match[4]
106
+
107
+ # Extract WHEN condition if present
108
+ condition_match = sql.match(/WHEN\s+\(([^)]+)\)/i)
109
+ condition = condition_match ? condition_match[1].strip : nil
110
+
111
+ # Extract function name
112
+ function_match = sql.match(/EXECUTE\s+FUNCTION\s+(\w+)\s*\(\)/i)
113
+ function_name = function_match ? function_match[1] : nil
114
+
115
+ {
116
+ trigger_name: trigger_name,
117
+ table_name: table_name,
118
+ timing: timing,
119
+ events: events,
120
+ condition: condition,
121
+ function_name: function_name,
122
+ full_sql: sql
123
+ }
124
+ end
125
+
126
+ # Parse DROP SQL statements
127
+ def parse_drop_sql(sql)
128
+ if sql.match?(/DROP\s+TRIGGER/i)
129
+ match = sql.match(/DROP\s+TRIGGER\s+(?:IF\s+EXISTS\s+)?(\w+)\s+ON\s+(\w+)/i)
130
+ return nil unless match
131
+
132
+ {
133
+ type: :trigger,
134
+ name: match[1],
135
+ table_name: match[2]
136
+ }
137
+ elsif sql.match?(/DROP\s+FUNCTION/i)
138
+ match = sql.match(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(\w+)\s*\(\)/i)
139
+ return nil unless match
140
+
141
+ {
142
+ type: :function,
143
+ name: match[1]
144
+ }
145
+ end
146
+ end
147
+
148
+ # Extract actual state from database
149
+ def extract_actual_state(expected)
150
+ actual = {
151
+ functions: {},
152
+ triggers: {}
153
+ }
154
+
155
+ # Get actual functions from database
156
+ expected[:functions].each do |expected_func|
157
+ db_func = Drift::DbQueries.find_function(expected_func[:function_name])
158
+ actual[:functions][expected_func[:function_name]] = if db_func
159
+ {
160
+ function_name: db_func["function_name"],
161
+ function_body: db_func["function_definition"],
162
+ exists: true
163
+ }
164
+ else
165
+ {
166
+ function_name: expected_func[:function_name],
167
+ exists: false
168
+ }
169
+ end
170
+ end
171
+
172
+ # Get actual triggers from database
173
+ expected[:triggers].each do |expected_trigger|
174
+ db_trigger = Drift::DbQueries.find_trigger(expected_trigger[:trigger_name])
175
+ if db_trigger
176
+ actual[:triggers][expected_trigger[:trigger_name]] = {
177
+ trigger_name: db_trigger["trigger_name"],
178
+ table_name: db_trigger["table_name"],
179
+ function_name: db_trigger["function_name"],
180
+ trigger_definition: db_trigger["trigger_definition"],
181
+ function_definition: db_trigger["function_definition"],
182
+ exists: true
183
+ }
184
+ else
185
+ actual[:triggers][expected_trigger[:trigger_name]] = {
186
+ trigger_name: expected_trigger[:trigger_name],
187
+ table_name: expected_trigger[:table_name],
188
+ exists: false
189
+ }
190
+ end
191
+ end
192
+
193
+ actual
194
+ end
195
+
196
+ # Generate diff between expected and actual state
197
+ # rubocop:disable Metrics/MethodLength
198
+ def generate_diff(expected, actual)
199
+ diff = {
200
+ has_differences: false,
201
+ functions: [],
202
+ triggers: [],
203
+ drops: expected[:drops] || []
204
+ }
205
+
206
+ # Compare functions
207
+ expected[:functions].each do |expected_func|
208
+ func_name = expected_func[:function_name]
209
+ actual_func = actual[:functions][func_name]
210
+
211
+ if !actual_func || !actual_func[:exists]
212
+ diff[:functions] << {
213
+ function_name: func_name,
214
+ status: :new,
215
+ expected: expected_func[:function_body],
216
+ actual: nil,
217
+ message: "Function will be created"
218
+ }
219
+ diff[:has_differences] = true
220
+ elsif actual_func[:function_body] != expected_func[:function_body]
221
+ diff[:functions] << {
222
+ function_name: func_name,
223
+ status: :modified,
224
+ expected: expected_func[:function_body],
225
+ actual: actual_func[:function_body],
226
+ message: "Function body differs from expected"
227
+ }
228
+ diff[:has_differences] = true
229
+ else
230
+ diff[:functions] << {
231
+ function_name: func_name,
232
+ status: :unchanged,
233
+ message: "Function matches expected state"
234
+ }
235
+ end
236
+ end
237
+
238
+ # Compare triggers
239
+ expected[:triggers].each do |expected_trigger|
240
+ trigger_name = expected_trigger[:trigger_name]
241
+ actual_trigger = actual[:triggers][trigger_name]
242
+
243
+ if !actual_trigger || !actual_trigger[:exists]
244
+ diff[:triggers] << {
245
+ trigger_name: trigger_name,
246
+ status: :new,
247
+ expected: expected_trigger[:full_sql],
248
+ actual: nil,
249
+ message: "Trigger will be created"
250
+ }
251
+ diff[:has_differences] = true
252
+ else
253
+ # Compare trigger definitions
254
+ expected_def = normalize_trigger_definition(expected_trigger)
255
+ actual_def = normalize_trigger_definition_from_db(actual_trigger)
256
+
257
+ if expected_def == actual_def
258
+ diff[:triggers] << {
259
+ trigger_name: trigger_name,
260
+ status: :unchanged,
261
+ message: "Trigger matches expected state"
262
+ }
263
+ else
264
+ diff[:triggers] << {
265
+ trigger_name: trigger_name,
266
+ status: :modified,
267
+ expected: expected_trigger[:full_sql],
268
+ actual: actual_trigger[:trigger_definition],
269
+ message: "Trigger definition differs from expected",
270
+ differences: compare_trigger_details(expected_trigger, actual_trigger)
271
+ }
272
+ diff[:has_differences] = true
273
+ end
274
+ end
275
+ end
276
+
277
+ diff
278
+ end
279
+ # rubocop:enable Metrics/MethodLength
280
+
281
+ # Normalize trigger definition for comparison
282
+ def normalize_trigger_definition(trigger)
283
+ {
284
+ trigger_name: trigger[:trigger_name],
285
+ table_name: trigger[:table_name],
286
+ events: trigger[:events].sort,
287
+ condition: trigger[:condition],
288
+ function_name: trigger[:function_name]
289
+ }
290
+ end
291
+
292
+ # Normalize trigger definition from database for comparison
293
+ def normalize_trigger_definition_from_db(db_trigger)
294
+ # Parse trigger definition from pg_get_triggerdef output
295
+ def_str = db_trigger[:trigger_definition] || ""
296
+
297
+ # Extract events, condition, etc. from definition string
298
+ events_match = def_str.match(/BEFORE\s+(.+?)\s+ON/i) || def_str.match(/AFTER\s+(.+?)\s+ON/i)
299
+ events = events_match ? events_match[1].split(/\s+OR\s+/i).map(&:strip).sort : []
300
+
301
+ condition_match = def_str.match(/WHEN\s+\(([^)]+)\)/i)
302
+ condition = condition_match ? condition_match[1].strip : nil
303
+
304
+ {
305
+ trigger_name: db_trigger[:trigger_name],
306
+ table_name: db_trigger[:table_name],
307
+ events: events,
308
+ condition: condition,
309
+ function_name: db_trigger[:function_name]
310
+ }
311
+ end
312
+
313
+ # Compare trigger details to find specific differences
314
+ def compare_trigger_details(expected, actual_db_trigger)
315
+ differences = []
316
+
317
+ # Normalize actual trigger for comparison
318
+ actual = normalize_trigger_definition_from_db(actual_db_trigger)
319
+
320
+ if expected[:table_name] != actual[:table_name]
321
+ differences << "Table name: expected '#{expected[:table_name]}', actual '#{actual[:table_name]}'"
322
+ end
323
+
324
+ expected_events = expected[:events].sort
325
+ actual_events = actual[:events] || []
326
+ if expected_events != actual_events.sort
327
+ differences << "Events: expected #{expected_events.inspect}, actual #{actual_events.inspect}"
328
+ end
329
+
330
+ if expected[:condition] != actual[:condition]
331
+ differences << "Condition: expected '#{expected[:condition]}', actual '#{actual[:condition]}'"
332
+ end
333
+
334
+ if expected[:function_name] != actual[:function_name]
335
+ differences << "Function: expected '#{expected[:function_name]}', actual '#{actual[:function_name]}'"
336
+ end
337
+
338
+ differences
339
+ end
340
+ end
341
+ end
342
+ # rubocop:enable Metrics/ClassLength
343
+ end
344
+ end
@@ -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