pg_sql_triggers 1.0.1 → 1.1.1
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/CHANGELOG.md +88 -0
- data/COVERAGE.md +58 -0
- data/Goal.md +180 -138
- data/README.md +8 -2
- data/RELEASE.md +1 -1
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
- data/app/controllers/pg_sql_triggers/generator_controller.rb +67 -5
- data/app/models/pg_sql_triggers/trigger_registry.rb +73 -10
- data/app/views/pg_sql_triggers/generator/new.html.erb +18 -0
- data/app/views/pg_sql_triggers/generator/preview.html.erb +233 -13
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +32 -0
- data/config/initializers/pg_sql_triggers.rb +69 -0
- data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +2 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/README.md +2 -2
- data/docs/api-reference.md +22 -4
- data/docs/getting-started.md +1 -1
- data/docs/kill-switch.md +3 -3
- data/docs/usage-guide.md +73 -0
- data/docs/web-ui.md +14 -0
- data/lib/generators/pg_sql_triggers/templates/README +1 -1
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +2 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +8 -0
- 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 +81 -25
- 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 +58 -0
- data/lib/pg_sql_triggers/registry/manager.rb +96 -9
- data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
- 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 +12 -0
- data/scripts/generate_coverage_report.rb +129 -0
- metadata +19 -12
data/docs/usage-guide.md
CHANGED
|
@@ -26,6 +26,7 @@ PgSqlTriggers::DSL.pg_sql_trigger "users_email_validation" do
|
|
|
26
26
|
|
|
27
27
|
version 1
|
|
28
28
|
enabled false
|
|
29
|
+
timing :before
|
|
29
30
|
|
|
30
31
|
when_env :production
|
|
31
32
|
end
|
|
@@ -79,6 +80,14 @@ when_env :production # Only in production
|
|
|
79
80
|
when_env :staging, :production # Multiple environments
|
|
80
81
|
```
|
|
81
82
|
|
|
83
|
+
#### `timing`
|
|
84
|
+
Specifies when the trigger fires relative to the event (BEFORE or AFTER):
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
timing :before # Trigger fires before constraint checks (default)
|
|
88
|
+
timing :after # Trigger fires after constraint checks
|
|
89
|
+
```
|
|
90
|
+
|
|
82
91
|
### Complete Example
|
|
83
92
|
|
|
84
93
|
```ruby
|
|
@@ -90,11 +99,75 @@ PgSqlTriggers::DSL.pg_sql_trigger "orders_billing_trigger" do
|
|
|
90
99
|
|
|
91
100
|
version 2
|
|
92
101
|
enabled true
|
|
102
|
+
timing :after
|
|
93
103
|
|
|
94
104
|
when_env :production, :staging
|
|
95
105
|
end
|
|
96
106
|
```
|
|
97
107
|
|
|
108
|
+
## Trigger Generator
|
|
109
|
+
|
|
110
|
+
PgSqlTriggers provides a web-based generator and Rails generators for creating trigger definitions and migrations quickly.
|
|
111
|
+
|
|
112
|
+
### Web UI Generator
|
|
113
|
+
|
|
114
|
+
The web UI generator provides a user-friendly interface for creating triggers:
|
|
115
|
+
|
|
116
|
+
1. Navigate to `/pg_sql_triggers/generator/new` in your browser
|
|
117
|
+
2. Fill in the trigger details:
|
|
118
|
+
- **Trigger Name**: Lowercase letters, numbers, and underscores only
|
|
119
|
+
- **Table Name**: The PostgreSQL table to attach the trigger to
|
|
120
|
+
- **Function Name**: The PostgreSQL function name (must match the function body)
|
|
121
|
+
- **Timing**: When the trigger fires - BEFORE (before constraint checks) or AFTER (after constraint checks)
|
|
122
|
+
- **Events**: Select one or more events (INSERT, UPDATE, DELETE, TRUNCATE)
|
|
123
|
+
- **Function Body**: The complete PostgreSQL function definition
|
|
124
|
+
- **Version**: Starting version number (default: 1)
|
|
125
|
+
- **Enabled**: Whether the trigger should be enabled initially
|
|
126
|
+
- **Environments**: Optional environment restrictions
|
|
127
|
+
- **Condition**: Optional WHEN condition for the trigger
|
|
128
|
+
3. Preview the generated DSL and migration code (includes timing and condition display)
|
|
129
|
+
4. Create the trigger files
|
|
130
|
+
|
|
131
|
+
The generator creates:
|
|
132
|
+
- A DSL definition file in `app/triggers/`
|
|
133
|
+
- A migration file in `db/triggers/`
|
|
134
|
+
- A registry entry in the database
|
|
135
|
+
|
|
136
|
+
### Rails Generators
|
|
137
|
+
|
|
138
|
+
You can also use Rails generators to create trigger migrations:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# Generate a trigger migration
|
|
142
|
+
rails generate trigger:migration add_user_validation
|
|
143
|
+
|
|
144
|
+
# Or using the full namespace
|
|
145
|
+
rails generate pg_sql_triggers:trigger_migration add_user_validation
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
This creates a migration file in `db/triggers/` that you can edit to add your trigger logic.
|
|
149
|
+
|
|
150
|
+
### Generator Features
|
|
151
|
+
|
|
152
|
+
The generator handles:
|
|
153
|
+
- **Function Name Formatting**: Automatically quotes function names with special characters
|
|
154
|
+
- **Multiple Environments**: Supports multiple environment restrictions
|
|
155
|
+
- **Condition Escaping**: Properly escapes quotes in WHEN conditions
|
|
156
|
+
- **Event Combinations**: Handles single or multiple events (INSERT, UPDATE, DELETE, TRUNCATE)
|
|
157
|
+
- **Migration Numbering**: Automatically generates sequential migration numbers
|
|
158
|
+
- **Error Handling**: Graceful error handling with detailed error messages
|
|
159
|
+
|
|
160
|
+
### Generator Edge Cases
|
|
161
|
+
|
|
162
|
+
The generator properly handles:
|
|
163
|
+
- Function names with special characters (quoted vs unquoted)
|
|
164
|
+
- Multiple environments in a single trigger
|
|
165
|
+
- Complex WHEN conditions with quotes
|
|
166
|
+
- All event type combinations
|
|
167
|
+
- Standalone gem usage (without Rails context)
|
|
168
|
+
- Migration number collisions
|
|
169
|
+
- Blank events and environments (filtered automatically)
|
|
170
|
+
|
|
98
171
|
## Trigger Migrations
|
|
99
172
|
|
|
100
173
|
Trigger migrations work similarly to Rails schema migrations but are specifically for PostgreSQL triggers and functions.
|
data/docs/web-ui.md
CHANGED
|
@@ -289,6 +289,20 @@ end
|
|
|
289
289
|

|
|
290
290
|
|
|
291
291
|
### Trigger Generator
|
|
292
|
+
|
|
293
|
+
The trigger generator provides a comprehensive form for creating triggers:
|
|
294
|
+
|
|
295
|
+
1. **Basic Information**: Trigger name, table name, function name, and function body
|
|
296
|
+
2. **Trigger Events**: Select timing (BEFORE/AFTER) and events (INSERT, UPDATE, DELETE, TRUNCATE)
|
|
297
|
+
3. **Configuration**: Version, environments, WHEN condition, and enabled state
|
|
298
|
+
4. **Preview**: Review generated DSL and migration code with timing and condition information
|
|
299
|
+
|
|
300
|
+
The preview page displays:
|
|
301
|
+
- Generated DSL code with timing
|
|
302
|
+
- Trigger configuration summary (timing, events, table, function, condition)
|
|
303
|
+
- PL/pgSQL function body (editable)
|
|
304
|
+
- SQL validation results
|
|
305
|
+
|
|
292
306
|

|
|
293
307
|
|
|
294
308
|
### Migration Management
|
|
@@ -31,6 +31,6 @@ Next steps:
|
|
|
31
31
|
|
|
32
32
|
5. Visit http://localhost:3000/pg_sql_triggers to access the UI
|
|
33
33
|
|
|
34
|
-
For more information, see: https://github.com/
|
|
34
|
+
For more information, see: https://github.com/samaswin/pg_sql_triggers
|
|
35
35
|
|
|
36
36
|
===============================================================================
|
|
@@ -14,6 +14,7 @@ class CreatePgSqlTriggersTables < ActiveRecord::Migration[6.1]
|
|
|
14
14
|
t.text :definition # Stored DSL or SQL definition
|
|
15
15
|
t.text :function_body # The actual function body
|
|
16
16
|
t.text :condition # Optional WHEN clause condition
|
|
17
|
+
t.string :timing, default: "before", null: false # Trigger timing: before or after
|
|
17
18
|
t.datetime :installed_at
|
|
18
19
|
t.datetime :last_verified_at
|
|
19
20
|
|
|
@@ -25,6 +26,7 @@ class CreatePgSqlTriggersTables < ActiveRecord::Migration[6.1]
|
|
|
25
26
|
add_index :pg_sql_triggers_registry, :enabled
|
|
26
27
|
add_index :pg_sql_triggers_registry, :source
|
|
27
28
|
add_index :pg_sql_triggers_registry, :environment
|
|
29
|
+
add_index :pg_sql_triggers_registry, :timing
|
|
28
30
|
|
|
29
31
|
# Trigger migrations table - tracks which trigger migrations have been run
|
|
30
32
|
create_table :trigger_migrations do |t|
|
|
@@ -58,4 +58,12 @@ PgSqlTriggers.configure do |config|
|
|
|
58
58
|
# Add additional tables you want to exclude:
|
|
59
59
|
# config.excluded_tables = %w[audit_logs temporary_data]
|
|
60
60
|
config.excluded_tables = []
|
|
61
|
+
|
|
62
|
+
# ========== Migration Safety Configuration ==========
|
|
63
|
+
# Prevent unsafe DROP + CREATE operations in migrations
|
|
64
|
+
# When false (default), migrations with DROP + CREATE patterns will be blocked
|
|
65
|
+
# Set to true to allow unsafe operations (not recommended)
|
|
66
|
+
# You can also override per-migration with ALLOW_UNSAFE_MIGRATIONS=true environment variable
|
|
67
|
+
# Default: false (recommended for safety)
|
|
68
|
+
config.allow_unsafe_migrations = false
|
|
61
69
|
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
module Drift
|
|
5
|
+
module DbQueries
|
|
6
|
+
class << self
|
|
7
|
+
# Fetch all triggers from database
|
|
8
|
+
def all_triggers
|
|
9
|
+
sql = <<~SQL.squish
|
|
10
|
+
SELECT
|
|
11
|
+
t.oid AS trigger_oid,
|
|
12
|
+
t.tgname AS trigger_name,
|
|
13
|
+
c.relname AS table_name,
|
|
14
|
+
n.nspname AS schema_name,
|
|
15
|
+
p.proname AS function_name,
|
|
16
|
+
pg_get_triggerdef(t.oid) AS trigger_definition,
|
|
17
|
+
pg_get_functiondef(p.oid) AS function_definition,
|
|
18
|
+
t.tgenabled AS enabled,
|
|
19
|
+
t.tgisinternal AS is_internal
|
|
20
|
+
FROM pg_trigger t
|
|
21
|
+
JOIN pg_class c ON t.tgrelid = c.oid
|
|
22
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
23
|
+
JOIN pg_proc p ON t.tgfoid = p.oid
|
|
24
|
+
WHERE NOT t.tgisinternal
|
|
25
|
+
AND n.nspname = 'public'
|
|
26
|
+
AND t.tgname NOT LIKE 'RI_%'
|
|
27
|
+
ORDER BY c.relname, t.tgname;
|
|
28
|
+
SQL
|
|
29
|
+
|
|
30
|
+
execute_query(sql)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Fetch single trigger
|
|
34
|
+
def find_trigger(trigger_name)
|
|
35
|
+
sql = <<~SQL.squish
|
|
36
|
+
SELECT
|
|
37
|
+
t.oid AS trigger_oid,
|
|
38
|
+
t.tgname AS trigger_name,
|
|
39
|
+
c.relname AS table_name,
|
|
40
|
+
n.nspname AS schema_name,
|
|
41
|
+
p.proname AS function_name,
|
|
42
|
+
pg_get_triggerdef(t.oid) AS trigger_definition,
|
|
43
|
+
pg_get_functiondef(p.oid) AS function_definition,
|
|
44
|
+
t.tgenabled AS enabled,
|
|
45
|
+
t.tgisinternal AS is_internal
|
|
46
|
+
FROM pg_trigger t
|
|
47
|
+
JOIN pg_class c ON t.tgrelid = c.oid
|
|
48
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
49
|
+
JOIN pg_proc p ON t.tgfoid = p.oid
|
|
50
|
+
WHERE t.tgname = $1
|
|
51
|
+
AND NOT t.tgisinternal
|
|
52
|
+
AND n.nspname = 'public';
|
|
53
|
+
SQL
|
|
54
|
+
|
|
55
|
+
result = execute_query(sql, [trigger_name])
|
|
56
|
+
result.first
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Fetch triggers for a specific table
|
|
60
|
+
def find_triggers_for_table(table_name)
|
|
61
|
+
sql = <<~SQL.squish
|
|
62
|
+
SELECT
|
|
63
|
+
t.oid AS trigger_oid,
|
|
64
|
+
t.tgname AS trigger_name,
|
|
65
|
+
c.relname AS table_name,
|
|
66
|
+
n.nspname AS schema_name,
|
|
67
|
+
p.proname AS function_name,
|
|
68
|
+
pg_get_triggerdef(t.oid) AS trigger_definition,
|
|
69
|
+
pg_get_functiondef(p.oid) AS function_definition,
|
|
70
|
+
t.tgenabled AS enabled,
|
|
71
|
+
t.tgisinternal AS is_internal
|
|
72
|
+
FROM pg_trigger t
|
|
73
|
+
JOIN pg_class c ON t.tgrelid = c.oid
|
|
74
|
+
JOIN pg_namespace n ON c.relnamespace = n.oid
|
|
75
|
+
JOIN pg_proc p ON t.tgfoid = p.oid
|
|
76
|
+
WHERE c.relname = $1
|
|
77
|
+
AND NOT t.tgisinternal
|
|
78
|
+
AND n.nspname = 'public'
|
|
79
|
+
AND t.tgname NOT LIKE 'RI_%'
|
|
80
|
+
ORDER BY t.tgname;
|
|
81
|
+
SQL
|
|
82
|
+
|
|
83
|
+
execute_query(sql, [table_name])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Fetch function body by function name
|
|
87
|
+
def find_function(function_name)
|
|
88
|
+
sql = <<~SQL.squish
|
|
89
|
+
SELECT
|
|
90
|
+
p.proname AS function_name,
|
|
91
|
+
pg_get_functiondef(p.oid) AS function_definition
|
|
92
|
+
FROM pg_proc p
|
|
93
|
+
JOIN pg_namespace n ON p.pronamespace = n.oid
|
|
94
|
+
WHERE p.proname = $1
|
|
95
|
+
AND n.nspname = 'public';
|
|
96
|
+
SQL
|
|
97
|
+
|
|
98
|
+
result = execute_query(sql, [function_name])
|
|
99
|
+
result.first
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def execute_query(sql, params = [])
|
|
105
|
+
if params.any?
|
|
106
|
+
# Use ActiveRecord's connection to execute parameterized queries
|
|
107
|
+
result = ActiveRecord::Base.connection.exec_query(sql, "SQL", params)
|
|
108
|
+
result.to_a
|
|
109
|
+
else
|
|
110
|
+
ActiveRecord::Base.connection.execute(sql).to_a
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -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
|