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.
- checksums.yaml +4 -4
- data/.erb_lint.yml +0 -0
- data/.rspec +0 -0
- data/.rubocop.yml +6 -16
- data/AGENTS.md +8 -0
- data/CHANGELOG.md +354 -0
- data/COVERAGE.md +39 -41
- data/LICENSE +0 -0
- data/README.md +44 -26
- data/RELEASE.md +0 -0
- data/Rakefile +5 -0
- data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
- data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
- data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
- data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
- data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
- data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
- data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
- data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
- data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
- data/app/models/pg_sql_triggers/application_record.rb +0 -0
- data/app/models/pg_sql_triggers/audit_log.rb +29 -47
- data/app/models/pg_sql_triggers/trigger_registry.rb +137 -74
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -27
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +27 -18
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
- data/config/initializers/pg_sql_triggers.rb +0 -0
- data/config/routes.rb +0 -14
- data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
- data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
- data/db/migrate/20260228162233_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
- data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
- data/docs/README.md +3 -0
- data/docs/api-reference.md +176 -152
- data/docs/audit-trail.md +1 -1
- data/docs/configuration.md +196 -3
- data/docs/getting-started.md +31 -16
- data/docs/kill-switch.md +0 -0
- data/docs/permissions.md +6 -9
- data/docs/troubleshooting.md +0 -0
- data/docs/ui-guide.md +0 -0
- data/docs/usage-guide.md +112 -67
- data/docs/web-ui.md +3 -103
- data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/README +0 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
- data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
- data/lib/pg_sql_triggers/alerting.rb +77 -0
- data/lib/pg_sql_triggers/database_introspection.rb +0 -0
- data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +26 -13
- data/lib/pg_sql_triggers/drift/detector.rb +59 -38
- data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
- data/lib/pg_sql_triggers/drift.rb +5 -0
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +68 -20
- data/lib/pg_sql_triggers/dsl.rb +0 -0
- data/lib/pg_sql_triggers/engine.rb +49 -0
- data/lib/pg_sql_triggers/errors.rb +0 -0
- data/lib/pg_sql_triggers/events_checksum.rb +114 -0
- data/lib/pg_sql_triggers/migration.rb +5 -6
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +85 -82
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +34 -12
- data/lib/pg_sql_triggers/migrator.rb +137 -94
- data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
- data/lib/pg_sql_triggers/permissions.rb +1 -0
- data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
- data/lib/pg_sql_triggers/registry/manager.rb +60 -21
- data/lib/pg_sql_triggers/registry/validator.rb +287 -6
- data/lib/pg_sql_triggers/registry.rb +0 -0
- data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
- data/lib/pg_sql_triggers/sql/kill_switch.rb +154 -275
- data/lib/pg_sql_triggers/sql.rb +0 -6
- data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
- data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
- data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
- data/lib/pg_sql_triggers/testing.rb +0 -0
- data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +21 -1
- data/lib/tasks/trigger_migrations.rake +235 -152
- data/rakelib/pg_sql_triggers_environment.rake +9 -0
- data/scripts/generate_coverage_report.rb +4 -1
- data/sig/pg_sql_triggers.rbs +0 -0
- metadata +68 -22
- data/Goal.md +0 -742
- data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
- data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
- data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
- data/lib/generators/trigger/migration_generator.rb +0 -60
- data/lib/pg_sql_triggers/generator/form.rb +0 -80
- data/lib/pg_sql_triggers/generator/service.rb +0 -339
- data/lib/pg_sql_triggers/generator.rb +0 -8
- data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
- data/lib/pg_sql_triggers/sql/executor.rb +0 -200
|
@@ -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
|
-
#
|
|
9
|
-
# delegate
|
|
10
|
-
def execute(
|
|
11
|
-
connection.execute(
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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)
|
|
File without changes
|
|
@@ -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
|
-
|
|
46
|
-
sql_operations = parse_sql_operations(captured_sql)
|
|
58
|
+
private
|
|
47
59
|
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
#
|
|
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
|
-
#
|
|
66
|
-
|
|
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
|