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,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require_relative "db_queries"
|
|
5
|
+
|
|
6
|
+
module PgSqlTriggers
|
|
7
|
+
module Drift
|
|
8
|
+
class Detector
|
|
9
|
+
class << self
|
|
10
|
+
# Detect drift for a single trigger
|
|
11
|
+
def detect(trigger_name)
|
|
12
|
+
registry_entry = TriggerRegistry.find_by(trigger_name: trigger_name)
|
|
13
|
+
db_trigger = DbQueries.find_trigger(trigger_name)
|
|
14
|
+
|
|
15
|
+
# State 1: DISABLED - Registry entry disabled
|
|
16
|
+
return disabled_state(registry_entry, db_trigger) if registry_entry&.enabled == false
|
|
17
|
+
|
|
18
|
+
# State 2: MANUAL_OVERRIDE - Marked as manual SQL
|
|
19
|
+
return manual_override_state(registry_entry, db_trigger) if registry_entry&.source == "manual_sql"
|
|
20
|
+
|
|
21
|
+
# State 3: DROPPED - Registry entry exists, DB trigger missing
|
|
22
|
+
return dropped_state(registry_entry) if registry_entry && !db_trigger
|
|
23
|
+
|
|
24
|
+
# State 4: UNKNOWN - DB trigger exists, no registry entry
|
|
25
|
+
return unknown_state(db_trigger) if !registry_entry && db_trigger
|
|
26
|
+
|
|
27
|
+
# State 5: DRIFTED - Checksum mismatch
|
|
28
|
+
if registry_entry && db_trigger
|
|
29
|
+
checksum_match = checksums_match?(registry_entry, db_trigger)
|
|
30
|
+
return drifted_state(registry_entry, db_trigger) unless checksum_match
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# State 6: IN_SYNC - Everything matches
|
|
34
|
+
in_sync_state(registry_entry, db_trigger)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Detect drift for all triggers
|
|
38
|
+
def detect_all
|
|
39
|
+
registry_entries = TriggerRegistry.all.to_a
|
|
40
|
+
db_triggers = DbQueries.all_triggers
|
|
41
|
+
|
|
42
|
+
# Check each registry entry
|
|
43
|
+
results = registry_entries.map do |entry|
|
|
44
|
+
detect(entry.trigger_name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Find unknown (external) triggers not in registry
|
|
48
|
+
registry_trigger_names = registry_entries.map(&:trigger_name)
|
|
49
|
+
db_triggers.each do |db_trigger|
|
|
50
|
+
next if registry_trigger_names.include?(db_trigger["trigger_name"])
|
|
51
|
+
|
|
52
|
+
results << unknown_state(db_trigger)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
results
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Detect drift for a specific table
|
|
59
|
+
def detect_for_table(table_name)
|
|
60
|
+
registry_entries = TriggerRegistry.for_table(table_name).to_a
|
|
61
|
+
db_triggers = DbQueries.find_triggers_for_table(table_name)
|
|
62
|
+
|
|
63
|
+
# Check each registry entry for this table
|
|
64
|
+
results = registry_entries.map do |entry|
|
|
65
|
+
detect(entry.trigger_name)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Find unknown triggers on this table
|
|
69
|
+
registry_trigger_names = registry_entries.map(&:trigger_name)
|
|
70
|
+
db_triggers.each do |db_trigger|
|
|
71
|
+
next if registry_trigger_names.include?(db_trigger["trigger_name"])
|
|
72
|
+
|
|
73
|
+
results << unknown_state(db_trigger)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
results
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# Compare registry checksum with calculated DB checksum
|
|
82
|
+
def checksums_match?(registry_entry, db_trigger)
|
|
83
|
+
db_checksum = calculate_db_checksum(registry_entry, db_trigger)
|
|
84
|
+
registry_entry.checksum == db_checksum
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Calculate checksum from DB trigger (must match registry algorithm)
|
|
88
|
+
def calculate_db_checksum(registry_entry, db_trigger)
|
|
89
|
+
# Extract function body from the function definition
|
|
90
|
+
function_body = extract_function_body(db_trigger)
|
|
91
|
+
|
|
92
|
+
# Extract condition from trigger definition
|
|
93
|
+
condition = extract_trigger_condition(db_trigger)
|
|
94
|
+
|
|
95
|
+
# Use same algorithm as TriggerRegistry#calculate_checksum
|
|
96
|
+
Digest::SHA256.hexdigest([
|
|
97
|
+
registry_entry.trigger_name,
|
|
98
|
+
registry_entry.table_name,
|
|
99
|
+
registry_entry.version,
|
|
100
|
+
function_body || "",
|
|
101
|
+
condition || ""
|
|
102
|
+
].join)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Extract function body from pg_get_functiondef output
|
|
106
|
+
def extract_function_body(db_trigger)
|
|
107
|
+
function_def = db_trigger["function_definition"]
|
|
108
|
+
return nil unless function_def
|
|
109
|
+
|
|
110
|
+
# The function definition includes CREATE OR REPLACE FUNCTION header
|
|
111
|
+
# We need to extract just the body for comparison
|
|
112
|
+
# For now, return the full definition
|
|
113
|
+
# TODO: Parse and extract just the body if needed
|
|
114
|
+
function_def
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Extract WHEN condition from trigger definition
|
|
118
|
+
def extract_trigger_condition(db_trigger)
|
|
119
|
+
trigger_def = db_trigger["trigger_definition"]
|
|
120
|
+
return nil unless trigger_def
|
|
121
|
+
|
|
122
|
+
# Extract WHEN clause from trigger definition
|
|
123
|
+
# Example: "... WHEN ((new.email IS NOT NULL)) EXECUTE ..."
|
|
124
|
+
match = trigger_def.match(/WHEN\s+\((.+?)\)\s+EXECUTE/i)
|
|
125
|
+
match ? match[1].strip : nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# State helper methods
|
|
129
|
+
def disabled_state(registry_entry, db_trigger)
|
|
130
|
+
{
|
|
131
|
+
state: PgSqlTriggers::DRIFT_STATE_DISABLED,
|
|
132
|
+
registry_entry: registry_entry,
|
|
133
|
+
db_trigger: db_trigger,
|
|
134
|
+
details: "Trigger is disabled in registry"
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def manual_override_state(registry_entry, db_trigger)
|
|
139
|
+
{
|
|
140
|
+
state: PgSqlTriggers::DRIFT_STATE_MANUAL_OVERRIDE,
|
|
141
|
+
registry_entry: registry_entry,
|
|
142
|
+
db_trigger: db_trigger,
|
|
143
|
+
details: "Trigger marked as manual SQL override"
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def dropped_state(registry_entry)
|
|
148
|
+
{
|
|
149
|
+
state: PgSqlTriggers::DRIFT_STATE_DROPPED,
|
|
150
|
+
registry_entry: registry_entry,
|
|
151
|
+
db_trigger: nil,
|
|
152
|
+
details: "Trigger exists in registry but not in database"
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def unknown_state(db_trigger)
|
|
157
|
+
{
|
|
158
|
+
state: PgSqlTriggers::DRIFT_STATE_UNKNOWN,
|
|
159
|
+
registry_entry: nil,
|
|
160
|
+
db_trigger: db_trigger,
|
|
161
|
+
details: "Trigger exists in database but not in registry (external)"
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def drifted_state(registry_entry, db_trigger)
|
|
166
|
+
{
|
|
167
|
+
state: PgSqlTriggers::DRIFT_STATE_DRIFTED,
|
|
168
|
+
registry_entry: registry_entry,
|
|
169
|
+
db_trigger: db_trigger,
|
|
170
|
+
checksum_match: false,
|
|
171
|
+
details: "Trigger has drifted (checksum mismatch between registry and database)"
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def in_sync_state(registry_entry, db_trigger)
|
|
176
|
+
{
|
|
177
|
+
state: PgSqlTriggers::DRIFT_STATE_IN_SYNC,
|
|
178
|
+
registry_entry: registry_entry,
|
|
179
|
+
db_trigger: db_trigger,
|
|
180
|
+
checksum_match: true,
|
|
181
|
+
details: "Trigger matches registry (in sync)"
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "detector"
|
|
4
|
+
|
|
5
|
+
module PgSqlTriggers
|
|
6
|
+
module Drift
|
|
7
|
+
class Reporter
|
|
8
|
+
class << self
|
|
9
|
+
# Generate a summary report
|
|
10
|
+
def summary
|
|
11
|
+
results = Detector.detect_all
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
total: results.count,
|
|
15
|
+
in_sync: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_IN_SYNC },
|
|
16
|
+
drifted: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED },
|
|
17
|
+
disabled: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DISABLED },
|
|
18
|
+
dropped: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DROPPED },
|
|
19
|
+
unknown: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_UNKNOWN },
|
|
20
|
+
manual_override: results.count { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_MANUAL_OVERRIDE }
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Generate detailed report for a trigger
|
|
25
|
+
def report(trigger_name)
|
|
26
|
+
result = Detector.detect(trigger_name)
|
|
27
|
+
|
|
28
|
+
output = []
|
|
29
|
+
output << ("=" * 80)
|
|
30
|
+
output << "Drift Report: #{trigger_name}"
|
|
31
|
+
output << ("=" * 80)
|
|
32
|
+
output << ""
|
|
33
|
+
|
|
34
|
+
# State
|
|
35
|
+
output << "State: #{format_state(result[:state])}"
|
|
36
|
+
output << ""
|
|
37
|
+
|
|
38
|
+
# Details
|
|
39
|
+
output << "Details: #{result[:details]}"
|
|
40
|
+
output << ""
|
|
41
|
+
|
|
42
|
+
# Registry info
|
|
43
|
+
if result[:registry_entry]
|
|
44
|
+
output << "Registry Information:"
|
|
45
|
+
output << " Table: #{result[:registry_entry].table_name}"
|
|
46
|
+
output << " Version: #{result[:registry_entry].version}"
|
|
47
|
+
output << " Enabled: #{result[:registry_entry].enabled}"
|
|
48
|
+
output << " Source: #{result[:registry_entry].source}"
|
|
49
|
+
output << " Checksum: #{result[:registry_entry].checksum}"
|
|
50
|
+
output << ""
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Database info
|
|
54
|
+
if result[:db_trigger]
|
|
55
|
+
output << "Database Information:"
|
|
56
|
+
output << " Table: #{result[:db_trigger]['table_name']}"
|
|
57
|
+
output << " Function: #{result[:db_trigger]['function_name']}"
|
|
58
|
+
output << " Enabled: #{result[:db_trigger]['enabled']}"
|
|
59
|
+
output << ""
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# If drifted, show diff
|
|
63
|
+
output << diff(trigger_name) if result[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED
|
|
64
|
+
|
|
65
|
+
output << ("=" * 80)
|
|
66
|
+
|
|
67
|
+
output.join("\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Generate diff view (expected vs actual)
|
|
71
|
+
def diff(trigger_name)
|
|
72
|
+
result = Detector.detect(trigger_name)
|
|
73
|
+
|
|
74
|
+
return "No drift detected" if result[:state] != PgSqlTriggers::DRIFT_STATE_DRIFTED
|
|
75
|
+
|
|
76
|
+
output = []
|
|
77
|
+
output << "Drift Comparison:"
|
|
78
|
+
output << ("-" * 80)
|
|
79
|
+
|
|
80
|
+
registry_entry = result[:registry_entry]
|
|
81
|
+
db_trigger = result[:db_trigger]
|
|
82
|
+
|
|
83
|
+
# Version comparison
|
|
84
|
+
output << "Version:"
|
|
85
|
+
output << " Registry: #{registry_entry.version}"
|
|
86
|
+
output << " Database: (version not stored in DB)"
|
|
87
|
+
output << ""
|
|
88
|
+
|
|
89
|
+
# Checksum comparison
|
|
90
|
+
output << "Checksum:"
|
|
91
|
+
output << " Registry: #{registry_entry.checksum}"
|
|
92
|
+
output << " Database: (calculated from current DB state)"
|
|
93
|
+
output << ""
|
|
94
|
+
|
|
95
|
+
# Function comparison
|
|
96
|
+
output << "Function:"
|
|
97
|
+
output << " Registry Function Body:"
|
|
98
|
+
output << indent_text(registry_entry.function_body || "(not set)", 4)
|
|
99
|
+
output << ""
|
|
100
|
+
output << " Database Function Definition:"
|
|
101
|
+
output << indent_text(db_trigger["function_definition"] || "(not found)", 4)
|
|
102
|
+
output << ""
|
|
103
|
+
|
|
104
|
+
# Condition comparison
|
|
105
|
+
if registry_entry.respond_to?(:condition) && registry_entry.condition.present?
|
|
106
|
+
output << "Condition:"
|
|
107
|
+
output << " Registry: #{registry_entry.condition}"
|
|
108
|
+
# Extract condition from DB trigger definition
|
|
109
|
+
trigger_def = db_trigger["trigger_definition"]
|
|
110
|
+
db_condition = trigger_def.match(/WHEN\s+\((.+?)\)\s+EXECUTE/i)&.[](1)
|
|
111
|
+
output << " Database: #{db_condition || '(none)'}"
|
|
112
|
+
output << ""
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
output << ("-" * 80)
|
|
116
|
+
|
|
117
|
+
output.join("\n")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Generate simple text list of drifted triggers
|
|
121
|
+
def drifted_list
|
|
122
|
+
results = Detector.detect_all
|
|
123
|
+
drifted = results.select { |r| r[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED }
|
|
124
|
+
|
|
125
|
+
return "No drifted triggers found" if drifted.empty?
|
|
126
|
+
|
|
127
|
+
output = []
|
|
128
|
+
output << "Drifted Triggers (#{drifted.count}):"
|
|
129
|
+
output << ""
|
|
130
|
+
|
|
131
|
+
drifted.each do |result|
|
|
132
|
+
entry = result[:registry_entry]
|
|
133
|
+
output << " - #{entry.trigger_name} (#{entry.table_name})"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
output.join("\n")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Generate problematic triggers list (for dashboard)
|
|
140
|
+
def problematic_list
|
|
141
|
+
results = Detector.detect_all
|
|
142
|
+
results.select do |r|
|
|
143
|
+
[
|
|
144
|
+
PgSqlTriggers::DRIFT_STATE_DRIFTED,
|
|
145
|
+
PgSqlTriggers::DRIFT_STATE_DROPPED,
|
|
146
|
+
PgSqlTriggers::DRIFT_STATE_UNKNOWN
|
|
147
|
+
].include?(r[:state])
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def format_state(state)
|
|
154
|
+
case state
|
|
155
|
+
when PgSqlTriggers::DRIFT_STATE_IN_SYNC
|
|
156
|
+
"IN SYNC"
|
|
157
|
+
when PgSqlTriggers::DRIFT_STATE_DRIFTED
|
|
158
|
+
"DRIFTED"
|
|
159
|
+
when PgSqlTriggers::DRIFT_STATE_DROPPED
|
|
160
|
+
"DROPPED"
|
|
161
|
+
when PgSqlTriggers::DRIFT_STATE_UNKNOWN
|
|
162
|
+
"UNKNOWN (External)"
|
|
163
|
+
when PgSqlTriggers::DRIFT_STATE_DISABLED
|
|
164
|
+
"DISABLED"
|
|
165
|
+
when PgSqlTriggers::DRIFT_STATE_MANUAL_OVERRIDE
|
|
166
|
+
"MANUAL OVERRIDE"
|
|
167
|
+
else
|
|
168
|
+
"UNKNOWN STATE"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def indent_text(text, spaces)
|
|
173
|
+
indent = " " * spaces
|
|
174
|
+
text.to_s.lines.map { |line| "#{indent}#{line}" }.join
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -2,23 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module PgSqlTriggers
|
|
4
4
|
module Drift
|
|
5
|
+
autoload :DbQueries, "pg_sql_triggers/drift/db_queries"
|
|
5
6
|
autoload :Detector, "pg_sql_triggers/drift/detector"
|
|
6
7
|
autoload :Reporter, "pg_sql_triggers/drift/reporter"
|
|
7
8
|
|
|
8
|
-
#
|
|
9
|
-
MANAGED_IN_SYNC = "managed_in_sync"
|
|
10
|
-
MANAGED_DRIFTED = "managed_drifted"
|
|
11
|
-
MANUAL_OVERRIDE = "manual_override"
|
|
12
|
-
DISABLED = "disabled"
|
|
13
|
-
DROPPED = "dropped"
|
|
14
|
-
UNKNOWN = "unknown"
|
|
15
|
-
|
|
9
|
+
# Convenience method for detecting drift
|
|
16
10
|
def self.detect(trigger_name = nil)
|
|
17
|
-
|
|
11
|
+
if trigger_name
|
|
12
|
+
Detector.detect(trigger_name)
|
|
13
|
+
else
|
|
14
|
+
Detector.detect_all
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Convenience method for reporting
|
|
19
|
+
def self.summary
|
|
20
|
+
Reporter.summary
|
|
18
21
|
end
|
|
19
22
|
|
|
20
|
-
def self.report
|
|
21
|
-
Reporter.report
|
|
23
|
+
def self.report(trigger_name)
|
|
24
|
+
Reporter.report(trigger_name)
|
|
22
25
|
end
|
|
23
26
|
end
|
|
24
27
|
end
|
|
@@ -12,6 +12,7 @@ module PgSqlTriggers
|
|
|
12
12
|
@enabled = false
|
|
13
13
|
@environments = []
|
|
14
14
|
@condition = nil
|
|
15
|
+
@timing = "before"
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
def table(table_name)
|
|
@@ -50,6 +51,18 @@ module PgSqlTriggers
|
|
|
50
51
|
@condition = condition_sql
|
|
51
52
|
end
|
|
52
53
|
|
|
54
|
+
def timing(timing_value = nil)
|
|
55
|
+
if timing_value.nil?
|
|
56
|
+
@timing
|
|
57
|
+
else
|
|
58
|
+
@timing = timing_value.to_s
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def function_body
|
|
63
|
+
nil # DSL definitions don't include function_body directly
|
|
64
|
+
end
|
|
65
|
+
|
|
53
66
|
def to_h
|
|
54
67
|
{
|
|
55
68
|
name: @name,
|
|
@@ -59,7 +72,8 @@ module PgSqlTriggers
|
|
|
59
72
|
version: @version,
|
|
60
73
|
enabled: @enabled,
|
|
61
74
|
environments: @environments,
|
|
62
|
-
condition: @condition
|
|
75
|
+
condition: @condition,
|
|
76
|
+
timing: @timing
|
|
63
77
|
}
|
|
64
78
|
end
|
|
65
79
|
end
|
|
@@ -6,7 +6,7 @@ module PgSqlTriggers
|
|
|
6
6
|
include ActiveModel::Model
|
|
7
7
|
|
|
8
8
|
attr_accessor :trigger_name, :table_name, :function_name,
|
|
9
|
-
:version, :enabled, :condition,
|
|
9
|
+
:version, :enabled, :condition, :timing,
|
|
10
10
|
:generate_function_stub, :events, :environments,
|
|
11
11
|
:function_body
|
|
12
12
|
|
|
@@ -23,6 +23,7 @@ module PgSqlTriggers
|
|
|
23
23
|
}
|
|
24
24
|
validates :version, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
|
25
25
|
validates :function_body, presence: true
|
|
26
|
+
validates :timing, inclusion: { in: %w[before after], message: "must be 'before' or 'after'" }
|
|
26
27
|
validate :at_least_one_event
|
|
27
28
|
validate :function_name_matches_body
|
|
28
29
|
|
|
@@ -38,6 +39,7 @@ module PgSqlTriggers
|
|
|
38
39
|
@generate_function_stub = true if @generate_function_stub.nil?
|
|
39
40
|
@events ||= []
|
|
40
41
|
@environments ||= []
|
|
42
|
+
@timing ||= "before" # Default to "before" if not specified
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def default_function_body
|
|
@@ -6,6 +6,7 @@ require "active_support/core_ext/string/inflections"
|
|
|
6
6
|
|
|
7
7
|
module PgSqlTriggers
|
|
8
8
|
module Generator
|
|
9
|
+
# rubocop:disable Metrics/ClassLength
|
|
9
10
|
class Service
|
|
10
11
|
class << self
|
|
11
12
|
def generate_dsl(form)
|
|
@@ -31,6 +32,7 @@ module PgSqlTriggers
|
|
|
31
32
|
#{' '}
|
|
32
33
|
version #{form.version}
|
|
33
34
|
enabled #{form.enabled}
|
|
35
|
+
timing :#{form.timing || 'before'}
|
|
34
36
|
RUBY
|
|
35
37
|
|
|
36
38
|
code += " when_env #{environments_list}\n" if form.environments.compact_blank.any?
|
|
@@ -51,8 +53,9 @@ module PgSqlTriggers
|
|
|
51
53
|
function_body_sql = form.function_body.strip
|
|
52
54
|
|
|
53
55
|
# Build the trigger creation SQL
|
|
56
|
+
timing_value = (form.timing || "before").upcase
|
|
54
57
|
trigger_sql = "CREATE TRIGGER #{form.trigger_name}\n"
|
|
55
|
-
trigger_sql += "
|
|
58
|
+
trigger_sql += "#{timing_value} #{events_sql} ON #{form.table_name}\n"
|
|
56
59
|
trigger_sql += "FOR EACH ROW\n"
|
|
57
60
|
trigger_sql += "WHEN (#{form.condition})\n" if form.condition.present?
|
|
58
61
|
trigger_sql += "EXECUTE FUNCTION #{form.function_name}();"
|
|
@@ -61,6 +64,11 @@ module PgSqlTriggers
|
|
|
61
64
|
down_sql = "DROP TRIGGER IF EXISTS #{form.trigger_name} ON #{form.table_name};\n"
|
|
62
65
|
down_sql += "DROP FUNCTION IF EXISTS #{form.function_name}();"
|
|
63
66
|
|
|
67
|
+
# Indent SQL strings to match heredoc indentation (18 spaces)
|
|
68
|
+
indented_function_body = indent_sql(function_body_sql, 18)
|
|
69
|
+
indented_trigger_sql = indent_sql(trigger_sql, 18)
|
|
70
|
+
indented_down_sql = indent_sql(down_sql, 18)
|
|
71
|
+
|
|
64
72
|
<<~RUBY
|
|
65
73
|
# frozen_string_literal: true
|
|
66
74
|
|
|
@@ -69,18 +77,18 @@ module PgSqlTriggers
|
|
|
69
77
|
def up
|
|
70
78
|
# Create the function
|
|
71
79
|
execute <<-SQL
|
|
72
|
-
#{
|
|
80
|
+
#{indented_function_body}
|
|
73
81
|
SQL
|
|
74
82
|
|
|
75
83
|
# Create the trigger
|
|
76
84
|
execute <<-SQL
|
|
77
|
-
#{
|
|
85
|
+
#{indented_trigger_sql}
|
|
78
86
|
SQL
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
def down
|
|
82
90
|
execute <<-SQL
|
|
83
|
-
#{
|
|
91
|
+
#{indented_down_sql}
|
|
84
92
|
SQL
|
|
85
93
|
end
|
|
86
94
|
end
|
|
@@ -140,31 +148,55 @@ module PgSqlTriggers
|
|
|
140
148
|
}
|
|
141
149
|
end
|
|
142
150
|
|
|
143
|
-
def create_trigger(form,
|
|
151
|
+
def create_trigger(form, actor: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
144
152
|
paths = file_paths(form)
|
|
153
|
+
base_path = rails_base_path
|
|
145
154
|
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
create_trigger_files(form, paths, base_path)
|
|
156
|
+
registry = register_trigger(form)
|
|
157
|
+
|
|
158
|
+
build_success_response(registry, paths, form)
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
log_error(e)
|
|
161
|
+
build_error_response(e)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def indent_sql(sql_string, indent_level)
|
|
167
|
+
indent = " " * indent_level
|
|
168
|
+
sql_string.lines.map do |line|
|
|
169
|
+
stripped = line.chomp
|
|
170
|
+
stripped.empty? ? "" : indent + stripped
|
|
171
|
+
end.join("\n")
|
|
172
|
+
end
|
|
148
173
|
|
|
174
|
+
def rails_base_path
|
|
175
|
+
defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def create_trigger_files(form, paths, base_path)
|
|
149
179
|
full_migration_path = base_path.join(paths[:migration])
|
|
150
180
|
full_dsl_path = base_path.join(paths[:dsl])
|
|
151
181
|
|
|
152
|
-
# Create directories
|
|
153
182
|
FileUtils.mkdir_p(full_migration_path.dirname)
|
|
154
183
|
FileUtils.mkdir_p(full_dsl_path.dirname)
|
|
155
184
|
|
|
156
|
-
# Generate content
|
|
157
185
|
migration_content = generate_migration(form)
|
|
158
186
|
dsl_content = generate_dsl(form)
|
|
159
|
-
# Use function_body (required field)
|
|
160
|
-
function_content = form.function_body
|
|
161
187
|
|
|
162
|
-
# Write both files
|
|
163
188
|
File.write(full_migration_path, migration_content)
|
|
164
189
|
File.write(full_dsl_path, dsl_content)
|
|
190
|
+
end
|
|
165
191
|
|
|
166
|
-
|
|
167
|
-
definition =
|
|
192
|
+
def register_trigger(form)
|
|
193
|
+
definition = build_trigger_definition(form)
|
|
194
|
+
attributes = build_registry_attributes(form, definition)
|
|
195
|
+
TriggerRegistry.create!(attributes)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def build_trigger_definition(form)
|
|
199
|
+
{
|
|
168
200
|
name: form.trigger_name,
|
|
169
201
|
table_name: form.table_name,
|
|
170
202
|
events: form.events.compact_blank,
|
|
@@ -172,9 +204,13 @@ module PgSqlTriggers
|
|
|
172
204
|
version: form.version,
|
|
173
205
|
enabled: form.enabled,
|
|
174
206
|
environments: form.environments.compact_blank,
|
|
175
|
-
condition: form.condition
|
|
207
|
+
condition: form.condition,
|
|
208
|
+
timing: form.timing || "before",
|
|
209
|
+
function_body: form.function_body
|
|
176
210
|
}
|
|
211
|
+
end
|
|
177
212
|
|
|
213
|
+
def build_registry_attributes(form, definition)
|
|
178
214
|
attributes = {
|
|
179
215
|
trigger_name: form.trigger_name,
|
|
180
216
|
table_name: form.table_name,
|
|
@@ -183,15 +219,22 @@ module PgSqlTriggers
|
|
|
183
219
|
source: "dsl",
|
|
184
220
|
environment: form.environments.compact_blank.join(",").presence,
|
|
185
221
|
definition: definition.to_json,
|
|
186
|
-
function_body:
|
|
222
|
+
function_body: form.function_body,
|
|
187
223
|
checksum: calculate_checksum(definition)
|
|
188
224
|
}
|
|
189
225
|
|
|
190
|
-
|
|
191
|
-
attributes
|
|
226
|
+
add_conditional_attributes(attributes, form)
|
|
227
|
+
attributes
|
|
228
|
+
end
|
|
192
229
|
|
|
193
|
-
|
|
230
|
+
def add_conditional_attributes(attributes, form)
|
|
231
|
+
column_names = TriggerRegistry.column_names
|
|
194
232
|
|
|
233
|
+
attributes[:condition] = form.condition.presence if column_names.include?("condition")
|
|
234
|
+
attributes[:timing] = (form.timing || "before") if column_names.include?("timing")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def build_success_response(registry, paths, form)
|
|
195
238
|
{
|
|
196
239
|
success: true,
|
|
197
240
|
registry_id: registry.id,
|
|
@@ -204,18 +247,22 @@ module PgSqlTriggers
|
|
|
204
247
|
files_created: [paths[:migration], paths[:dsl]]
|
|
205
248
|
}
|
|
206
249
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def log_error(error)
|
|
253
|
+
return unless defined?(Rails)
|
|
254
|
+
|
|
255
|
+
Rails.logger.error("Trigger generation failed: #{error.message}")
|
|
256
|
+
Rails.logger.error(error.backtrace.join("\n"))
|
|
257
|
+
end
|
|
210
258
|
|
|
259
|
+
def build_error_response(error)
|
|
211
260
|
{
|
|
212
261
|
success: false,
|
|
213
|
-
error:
|
|
262
|
+
error: error.message
|
|
214
263
|
}
|
|
215
264
|
end
|
|
216
265
|
|
|
217
|
-
private
|
|
218
|
-
|
|
219
266
|
def next_migration_number
|
|
220
267
|
# Determine if we're in a Rails app context or standalone gem
|
|
221
268
|
base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
|
|
@@ -243,9 +290,18 @@ module PgSqlTriggers
|
|
|
243
290
|
end
|
|
244
291
|
|
|
245
292
|
def calculate_checksum(definition)
|
|
246
|
-
|
|
293
|
+
# Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
|
|
294
|
+
Digest::SHA256.hexdigest([
|
|
295
|
+
definition[:name],
|
|
296
|
+
definition[:table_name],
|
|
297
|
+
definition[:version],
|
|
298
|
+
definition[:function_body] || "",
|
|
299
|
+
definition[:condition] || "",
|
|
300
|
+
definition[:timing] || "before"
|
|
301
|
+
].join)
|
|
247
302
|
end
|
|
248
303
|
end
|
|
249
304
|
end
|
|
305
|
+
# rubocop:enable Metrics/ClassLength
|
|
250
306
|
end
|
|
251
307
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PgSqlTriggers
|
|
4
|
-
class Migration < ActiveRecord::Migration[6.
|
|
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
7
|
|