pg_sql_triggers 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +0 -0
  3. data/.rspec +0 -0
  4. data/.rubocop.yml +6 -16
  5. data/AGENTS.md +8 -0
  6. data/CHANGELOG.md +354 -0
  7. data/COVERAGE.md +39 -41
  8. data/LICENSE +0 -0
  9. data/README.md +44 -26
  10. data/RELEASE.md +0 -0
  11. data/Rakefile +5 -0
  12. data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
  13. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
  14. data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
  15. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
  16. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
  17. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
  18. data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
  19. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
  20. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
  21. data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
  22. data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
  23. data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
  24. data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
  25. data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
  26. data/app/models/pg_sql_triggers/application_record.rb +0 -0
  27. data/app/models/pg_sql_triggers/audit_log.rb +29 -47
  28. data/app/models/pg_sql_triggers/trigger_registry.rb +137 -74
  29. data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
  30. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
  31. data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -27
  32. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
  33. data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
  34. data/app/views/pg_sql_triggers/tables/index.html.erb +27 -18
  35. data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
  36. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
  37. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
  38. data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
  39. data/config/initializers/pg_sql_triggers.rb +0 -0
  40. data/config/routes.rb +0 -14
  41. data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
  42. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
  43. data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
  44. data/db/migrate/20260228162233_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  45. data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
  46. data/docs/README.md +3 -0
  47. data/docs/api-reference.md +176 -152
  48. data/docs/audit-trail.md +1 -1
  49. data/docs/configuration.md +196 -3
  50. data/docs/getting-started.md +31 -16
  51. data/docs/kill-switch.md +0 -0
  52. data/docs/permissions.md +6 -9
  53. data/docs/troubleshooting.md +0 -0
  54. data/docs/ui-guide.md +0 -0
  55. data/docs/usage-guide.md +112 -67
  56. data/docs/web-ui.md +3 -103
  57. data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
  58. data/lib/generators/pg_sql_triggers/templates/README +0 -0
  59. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
  60. data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
  61. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  62. data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
  63. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  64. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  65. data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
  66. data/lib/pg_sql_triggers/alerting.rb +77 -0
  67. data/lib/pg_sql_triggers/database_introspection.rb +0 -0
  68. data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
  69. data/lib/pg_sql_triggers/drift/db_queries.rb +26 -13
  70. data/lib/pg_sql_triggers/drift/detector.rb +59 -38
  71. data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
  72. data/lib/pg_sql_triggers/drift.rb +5 -0
  73. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +68 -20
  74. data/lib/pg_sql_triggers/dsl.rb +0 -0
  75. data/lib/pg_sql_triggers/engine.rb +49 -0
  76. data/lib/pg_sql_triggers/errors.rb +0 -0
  77. data/lib/pg_sql_triggers/events_checksum.rb +114 -0
  78. data/lib/pg_sql_triggers/migration.rb +5 -6
  79. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +85 -82
  80. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
  81. data/lib/pg_sql_triggers/migrator/safety_validator.rb +34 -12
  82. data/lib/pg_sql_triggers/migrator.rb +137 -94
  83. data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
  84. data/lib/pg_sql_triggers/permissions.rb +1 -0
  85. data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
  86. data/lib/pg_sql_triggers/registry/manager.rb +60 -21
  87. data/lib/pg_sql_triggers/registry/validator.rb +287 -6
  88. data/lib/pg_sql_triggers/registry.rb +0 -0
  89. data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
  90. data/lib/pg_sql_triggers/sql/kill_switch.rb +154 -275
  91. data/lib/pg_sql_triggers/sql.rb +0 -6
  92. data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
  93. data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
  94. data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
  95. data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
  96. data/lib/pg_sql_triggers/testing.rb +0 -0
  97. data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
  98. data/lib/pg_sql_triggers/version.rb +1 -1
  99. data/lib/pg_sql_triggers.rb +21 -1
  100. data/lib/tasks/trigger_migrations.rake +235 -152
  101. data/rakelib/pg_sql_triggers_environment.rake +9 -0
  102. data/scripts/generate_coverage_report.rb +4 -1
  103. data/sig/pg_sql_triggers.rbs +0 -0
  104. metadata +68 -22
  105. data/Goal.md +0 -742
  106. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  107. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  108. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  109. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  110. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  111. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  112. data/lib/generators/trigger/migration_generator.rb +0 -60
  113. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  114. data/lib/pg_sql_triggers/generator/service.rb +0 -339
  115. data/lib/pg_sql_triggers/generator.rb +0 -8
  116. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  117. data/lib/pg_sql_triggers/sql/executor.rb +0 -200
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "schema_dumper_extension"
4
+ require_relative "trigger_structure_dumper"
5
+
3
6
  module PgSqlTriggers
4
7
  class Engine < ::Rails::Engine
5
8
  isolate_namespace PgSqlTriggers
@@ -25,5 +28,51 @@ module PgSqlTriggers
25
28
  rake_tasks do
26
29
  load root.join("lib/tasks/trigger_migrations.rake")
27
30
  end
31
+
32
+ initializer "pg_sql_triggers.schema_integration", before: :load_config_initializers do
33
+ ActiveSupport.on_load(:active_record) do
34
+ unless ActiveRecord::SchemaDumper.ancestors.include?(PgSqlTriggers::SchemaDumperExtension)
35
+ ActiveRecord::SchemaDumper.prepend(PgSqlTriggers::SchemaDumperExtension)
36
+ end
37
+ end
38
+ end
39
+
40
+ # Warn at startup if no permission_checker is set in a protected environment.
41
+ # The default is to allow all actions (including admin-level ones), which is
42
+ # unsafe in production without an explicit checker configured.
43
+ config.after_initialize do
44
+ install_schema_load_trigger_hook
45
+
46
+ if PgSqlTriggers.permission_checker.nil? && defined?(Rails) && Rails.env.production?
47
+ Rails.logger.warn(
48
+ "[PgSqlTriggers] SECURITY WARNING: No permission_checker is configured. " \
49
+ "All actions are permitted by default, including admin-level operations " \
50
+ "(drop_trigger, execute_sql, override_drift). " \
51
+ "Set PgSqlTriggers.permission_checker in an initializer before deploying to production."
52
+ )
53
+ end
54
+ end
55
+
56
+ def self.install_schema_load_trigger_hook
57
+ return if @schema_load_trigger_hook_installed
58
+ return unless PgSqlTriggers.migrate_triggers_after_schema_load
59
+ return if ENV["SKIP_TRIGGER_MIGRATE_AFTER_SCHEMA_LOAD"].present?
60
+ return unless defined?(Rake::Task)
61
+
62
+ if defined?(Rails.application) && Rails.application.respond_to?(:load_tasks)
63
+ Rails.application.load_tasks
64
+ end
65
+
66
+ return unless Rake::Task.task_defined?("db:schema:load")
67
+
68
+ @schema_load_trigger_hook_installed = true
69
+
70
+ Rake::Task["db:schema:load"].enhance do
71
+ next unless PgSqlTriggers.migrate_triggers_after_schema_load
72
+ next if ENV["SKIP_TRIGGER_MIGRATE_AFTER_SCHEMA_LOAD"].present?
73
+
74
+ Rake::Task["trigger:migrate"].invoke
75
+ end
76
+ end
28
77
  end
29
78
  end
File without changes
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # Normalizes trigger event lists (including PostgreSQL +UPDATE OF col1, col2+) for checksums so
5
+ # {TriggerRegistry#calculate_checksum}, {Registry::Manager#calculate_checksum}, and
6
+ # {Drift::Detector#calculate_db_checksum} stay aligned.
7
+ module EventsChecksum
8
+ module_function
9
+
10
+ # @param defn [Hash] parsed registry +definition+ JSON (string or symbol keys)
11
+ # @return [String] canonical token string, or empty when +events+ is missing/empty
12
+ def canonical_from_definition(defn)
13
+ hash = stringify(defn)
14
+ events = Array(hash["events"]).map(&:to_s).map(&:downcase)
15
+ return "" if events.empty?
16
+
17
+ columns = normalized_columns(hash["columns"])
18
+ build_tokens(events, columns).sort.join("|")
19
+ end
20
+
21
+ # @param definition_json [String, nil]
22
+ def segment_from_definition_json(definition_json)
23
+ return "" if definition_json.blank?
24
+
25
+ canonical_from_definition(JSON.parse(definition_json))
26
+ rescue JSON::ParserError
27
+ ""
28
+ end
29
+
30
+ # @param trigger_def [String] +pg_get_triggerdef+ output
31
+ def canonical_from_pg_triggerdef(trigger_def)
32
+ return "" if trigger_def.blank?
33
+
34
+ clause = extract_events_clause(trigger_def)
35
+ return "" if clause.blank?
36
+
37
+ parse_events_clause(clause).sort.join("|")
38
+ end
39
+
40
+ # SQL fragment for the event list (e.g. +INSERT OR UPDATE OF "email"+), preserving DSL event order.
41
+ def events_sql_fragment(defn, quote_column:)
42
+ hash = stringify(defn)
43
+ events = Array(hash["events"]).map(&:to_s).map(&:downcase)
44
+ columns = ordered_columns(hash["columns"])
45
+ return "INSERT" if events.empty?
46
+
47
+ events.map do |ev|
48
+ if ev == "update" && columns.any?
49
+ quoted = columns.map { |c| quote_column.call(c) }.join(", ")
50
+ "UPDATE OF #{quoted}"
51
+ else
52
+ ev.upcase
53
+ end
54
+ end.join(" OR ")
55
+ end
56
+
57
+ def normalized_columns(raw)
58
+ ordered_columns(raw).map(&:downcase).sort
59
+ end
60
+
61
+ def ordered_columns(raw)
62
+ Array(raw).flatten.compact.map { |c| c.to_s.strip }.reject(&:empty?)
63
+ end
64
+
65
+ def build_tokens(events, sorted_lowercase_columns)
66
+ cols_join = sorted_lowercase_columns.join(",")
67
+ events.map do |ev|
68
+ if ev == "update" && cols_join.present?
69
+ "update:#{cols_join}"
70
+ else
71
+ ev
72
+ end
73
+ end
74
+ end
75
+
76
+ def extract_events_clause(trigger_def)
77
+ s = trigger_def.to_s.squish
78
+ m = s.match(/(?:BEFORE|AFTER|INSTEAD\s+OF)\s+(.+?)\s+ON\s+/i)
79
+ return "" unless m
80
+
81
+ m.captures.first.to_s.strip
82
+ end
83
+
84
+ def parse_events_clause(clause)
85
+ clause.split(/\s+OR\s+/i).filter_map do |part|
86
+ part = part.strip
87
+ next if part.empty?
88
+
89
+ um = part.match(/\AUPDATE\s+OF\s+(.+)\z/i)
90
+ if um
91
+ cols = um[1].split(",").map { |c| normalize_pg_identifier(c) }.sort
92
+ "update:#{cols.join(',')}"
93
+ else
94
+ part.downcase
95
+ end
96
+ end
97
+ end
98
+
99
+ def normalize_pg_identifier(fragment)
100
+ f = fragment.to_s.strip
101
+ if f.start_with?('"') && f.end_with?('"') && f.length >= 2
102
+ f[1...-1].gsub('""', '"').downcase
103
+ else
104
+ f.downcase
105
+ end
106
+ end
107
+
108
+ def stringify(defn)
109
+ return {} if defn.nil?
110
+
111
+ defn.transform_keys(&:to_s)
112
+ end
113
+ end
114
+ end
@@ -4,12 +4,11 @@ module PgSqlTriggers
4
4
  class Migration < ActiveRecord::Migration[6.1]
5
5
  # Base class for trigger migrations
6
6
  # Similar to ActiveRecord::Migration but for trigger-specific migrations
7
-
8
- # rubocop:disable Rails/Delegate
9
- # delegate doesn't work here due to argument forwarding issues in this context
10
- def execute(sql)
11
- connection.execute(sql)
7
+ #
8
+ # Cannot use `delegate` here: ActiveRecord::Migration defines a class-method
9
+ # `delegate` (schema DSL) that shadows Module's `delegate` on Rails 8+.
10
+ def execute(...)
11
+ connection.execute(...)
12
12
  end
13
- # rubocop:enable Rails/Delegate
14
13
  end
15
14
  end
@@ -5,32 +5,32 @@ require_relative "../drift/db_queries"
5
5
  module PgSqlTriggers
6
6
  class Migrator
7
7
  # Pre-apply comparator that extracts expected SQL from migrations
8
- # and compares it with the current database state
9
- # rubocop:disable Metrics/ClassLength
8
+ # and compares it with the current database state.
9
+ # rubocop:disable Metrics/ClassLength -- all SQL parsing + diffing lives here so the two
10
+ # halves (parse migration SQL -> expected state, diff against actual) stay co-located.
10
11
  class PreApplyComparator
11
12
  class << self
12
13
  # Compare expected state from migration with actual database state
13
14
  # Returns a comparison result with diff information
14
15
  def compare(migration_instance, direction: :up)
15
- expected = extract_expected_state(migration_instance, direction)
16
+ captured_sql = capture_sql(migration_instance, direction)
17
+ compare_sql(captured_sql)
18
+ end
19
+
20
+ # Compare using pre-captured SQL (avoids re-instantiating the migration class).
21
+ # Returns a comparison result with diff information.
22
+ def compare_sql(captured_sql)
23
+ expected = parse_sql_to_state(captured_sql)
16
24
  actual = extract_actual_state(expected)
17
25
  generate_diff(expected, actual)
18
26
  end
19
27
 
20
28
  private
21
29
 
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
30
  # Capture SQL that would be executed by the migration
29
31
  def capture_sql(migration_instance, direction)
30
32
  captured = []
31
33
 
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
34
  migration_instance.define_singleton_method(:execute) do |sql|
35
35
  captured << sql.to_s.strip
36
36
  end
@@ -57,8 +57,8 @@ module PgSqlTriggers
57
57
  state[:functions] << function_info if function_info
58
58
  end
59
59
 
60
- # Parse CREATE TRIGGER statements
61
- if sql_normalized.match?(/CREATE\s+TRIGGER/i)
60
+ # Parse CREATE [CONSTRAINT] TRIGGER statements
61
+ if sql_normalized.match?(/CREATE\s+(?:CONSTRAINT\s+)?TRIGGER/i)
62
62
  trigger_info = parse_trigger_sql(sql)
63
63
  state[:triggers] << trigger_info if trigger_info
64
64
  end
@@ -95,8 +95,8 @@ module PgSqlTriggers
95
95
 
96
96
  # Parse trigger SQL to extract trigger details
97
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)
98
+ # Match CREATE [CONSTRAINT] TRIGGER trigger_name BEFORE/AFTER events ON table_name ...
99
+ match = sql.match(/CREATE\s+(?:CONSTRAINT\s+)?TRIGGER\s+(\w+)\s+(BEFORE|AFTER)\s+(.+?)\s+ON\s+(\w+)/i)
100
100
  return nil unless match
101
101
 
102
102
  trigger_name = match[1]
@@ -194,7 +194,6 @@ module PgSqlTriggers
194
194
  end
195
195
 
196
196
  # Generate diff between expected and actual state
197
- # rubocop:disable Metrics/MethodLength
198
197
  def generate_diff(expected, actual)
199
198
  diff = {
200
199
  has_differences: false,
@@ -203,80 +202,84 @@ module PgSqlTriggers
203
202
  drops: expected[:drops] || []
204
203
  }
205
204
 
206
- # Compare functions
207
205
  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
206
+ diff_entry = diff_function_entry(expected_func, actual[:functions][expected_func[:function_name]])
207
+ diff[:functions] << diff_entry
208
+ diff[:has_differences] = true if diff_entry[:status] != :unchanged
236
209
  end
237
210
 
238
- # Compare triggers
239
211
  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
212
+ diff_entry = diff_trigger_entry(expected_trigger, actual[:triggers][expected_trigger[:trigger_name]])
213
+ diff[:triggers] << diff_entry
214
+ diff[:has_differences] = true if diff_entry[:status] != :unchanged
275
215
  end
276
216
 
277
217
  diff
278
218
  end
279
- # rubocop:enable Metrics/MethodLength
219
+
220
+ # Build a single function diff entry (:new / :modified / :unchanged).
221
+ def diff_function_entry(expected_func, actual_func)
222
+ func_name = expected_func[:function_name]
223
+
224
+ if !actual_func || !actual_func[:exists]
225
+ {
226
+ function_name: func_name,
227
+ status: :new,
228
+ expected: expected_func[:function_body],
229
+ actual: nil,
230
+ message: "Function will be created"
231
+ }
232
+ elsif actual_func[:function_body] != expected_func[:function_body]
233
+ {
234
+ function_name: func_name,
235
+ status: :modified,
236
+ expected: expected_func[:function_body],
237
+ actual: actual_func[:function_body],
238
+ message: "Function body differs from expected"
239
+ }
240
+ else
241
+ {
242
+ function_name: func_name,
243
+ status: :unchanged,
244
+ message: "Function matches expected state"
245
+ }
246
+ end
247
+ end
248
+
249
+ # Build a single trigger diff entry (:new / :modified / :unchanged).
250
+ def diff_trigger_entry(expected_trigger, actual_trigger)
251
+ trigger_name = expected_trigger[:trigger_name]
252
+
253
+ if !actual_trigger || !actual_trigger[:exists]
254
+ return {
255
+ trigger_name: trigger_name,
256
+ status: :new,
257
+ expected: expected_trigger[:full_sql],
258
+ actual: nil,
259
+ message: "Trigger will be created"
260
+ }
261
+ end
262
+
263
+ expected_def = normalize_trigger_definition(expected_trigger)
264
+ actual_def = normalize_trigger_definition_from_db(actual_trigger)
265
+
266
+ if expected_def == actual_def
267
+ {
268
+ trigger_name: trigger_name,
269
+ status: :unchanged,
270
+ message: "Trigger matches expected state"
271
+ }
272
+ else
273
+ {
274
+ trigger_name: trigger_name,
275
+ status: :modified,
276
+ expected: expected_trigger[:full_sql],
277
+ actual: actual_trigger[:trigger_definition],
278
+ message: "Trigger definition differs from expected",
279
+ differences: compare_trigger_details(expected_trigger, actual_trigger)
280
+ }
281
+ end
282
+ end
280
283
 
281
284
  # Normalize trigger definition for comparison
282
285
  def normalize_trigger_definition(trigger)
@@ -34,26 +34,43 @@ 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
+ # Direction is intentionally not a parameter here – the captured SQL already reflects
39
+ # the direction that was executed to produce it.
40
+ # Raises UnsafeOperationError if unsafe patterns are detected.
41
+ def validate_sql!(captured_sql, allow_unsafe: false)
42
+ return if allow_unsafe
43
+
44
+ violations = detect_unsafe_patterns_from_sql(captured_sql)
45
+ return if violations.empty?
46
+
47
+ error_message = build_error_message(violations, "(migration)")
48
+ raise UnsafeOperationError.new(error_message, violations)
49
+ end
50
+
37
51
  # Detect unsafe patterns in migration SQL
38
52
  # Returns array of violation hashes
39
53
  def detect_unsafe_patterns(migration_instance, direction)
40
- violations = []
41
-
42
- # Capture SQL that would be executed
43
54
  captured_sql = capture_sql(migration_instance, direction)
55
+ detect_unsafe_patterns_from_sql(captured_sql)
56
+ end
44
57
 
45
- # Parse SQL to detect unsafe patterns
46
- sql_operations = parse_sql_operations(captured_sql)
58
+ private
47
59
 
48
- # Check for explicit DROP + CREATE patterns (the main safety concern)
60
+ # Core detection logic that works on pre-captured SQL.
61
+ def detect_unsafe_patterns_from_sql(captured_sql)
62
+ violations = []
63
+ sql_operations = parse_sql_operations(captured_sql)
49
64
  violations.concat(detect_drop_create_patterns(sql_operations))
50
-
51
65
  violations
52
66
  end
53
67
 
54
- private
55
-
56
- # Capture SQL that would be executed by the migration
68
+ # Capture SQL that would be executed by the migration.
69
+ # Runs the migration inside a transaction that is always rolled back so
70
+ # that any ActiveRecord migration helpers (add_column, create_table, …)
71
+ # that are called alongside explicit +execute+ calls produce no lasting
72
+ # side effects. The singleton override of +execute+ still intercepts
73
+ # raw SQL strings before they reach the database.
57
74
  def capture_sql(migration_instance, direction)
58
75
  captured = []
59
76
 
@@ -62,8 +79,13 @@ module PgSqlTriggers
62
79
  captured << sql.to_s.strip
63
80
  end
64
81
 
65
- # Call the migration method (up or down) to capture SQL
66
- migration_instance.public_send(direction)
82
+ # Run inside a transaction and roll it back so that any AR helpers
83
+ # called by the migration (add_column, create_table, etc.) do not
84
+ # persist their side effects during the capture phase.
85
+ ActiveRecord::Base.transaction do
86
+ migration_instance.public_send(direction)
87
+ raise ActiveRecord::Rollback
88
+ end
67
89
 
68
90
  captured
69
91
  end