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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/COVERAGE.md +58 -0
  4. data/Goal.md +180 -138
  5. data/README.md +8 -2
  6. data/RELEASE.md +1 -1
  7. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
  8. data/app/controllers/pg_sql_triggers/generator_controller.rb +67 -5
  9. data/app/models/pg_sql_triggers/trigger_registry.rb +73 -10
  10. data/app/views/pg_sql_triggers/generator/new.html.erb +18 -0
  11. data/app/views/pg_sql_triggers/generator/preview.html.erb +233 -13
  12. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +32 -0
  13. data/config/initializers/pg_sql_triggers.rb +69 -0
  14. data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +2 -0
  15. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
  16. data/docs/README.md +2 -2
  17. data/docs/api-reference.md +22 -4
  18. data/docs/getting-started.md +1 -1
  19. data/docs/kill-switch.md +3 -3
  20. data/docs/usage-guide.md +73 -0
  21. data/docs/web-ui.md +14 -0
  22. data/lib/generators/pg_sql_triggers/templates/README +1 -1
  23. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +2 -0
  24. data/lib/generators/pg_sql_triggers/templates/initializer.rb +8 -0
  25. data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
  26. data/lib/pg_sql_triggers/drift/detector.rb +187 -0
  27. data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
  28. data/lib/pg_sql_triggers/drift.rb +14 -11
  29. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
  30. data/lib/pg_sql_triggers/generator/form.rb +3 -1
  31. data/lib/pg_sql_triggers/generator/service.rb +81 -25
  32. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
  33. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
  34. data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
  35. data/lib/pg_sql_triggers/migrator.rb +58 -0
  36. data/lib/pg_sql_triggers/registry/manager.rb +96 -9
  37. data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
  38. data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
  39. data/lib/pg_sql_triggers/version.rb +1 -1
  40. data/lib/pg_sql_triggers.rb +12 -0
  41. data/scripts/generate_coverage_report.rb +129 -0
  42. 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
  ![Main Dashboard](screenshots/dashboard.png)
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
  ![Trigger Generator](screenshots/generator.png)
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/samaswin87/pg_sql_triggers
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
- # Drift states
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
- Detector.detect(trigger_name)
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