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
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ class Migrator
5
+ # Formats pre-apply comparison results into human-readable diff reports
6
+ class PreApplyDiffReporter
7
+ class << self
8
+ # Format diff result as a string report
9
+ def format(diff_result, migration_name: nil)
10
+ return "No differences detected. Migration is safe to apply." unless diff_result[:has_differences]
11
+
12
+ output = []
13
+ output << ("=" * 80)
14
+ output << "Pre-Apply Comparison Report"
15
+ output << "Migration: #{migration_name}" if migration_name
16
+ output << ("=" * 80)
17
+ output << ""
18
+
19
+ # Report on functions
20
+ if diff_result[:functions].any?
21
+ output << "Functions:"
22
+ output << ("-" * 80)
23
+ diff_result[:functions].each do |func_diff|
24
+ output.concat(format_function_diff(func_diff))
25
+ output << ""
26
+ end
27
+ end
28
+
29
+ # Report on triggers
30
+ if diff_result[:triggers].any?
31
+ output << "Triggers:"
32
+ output << ("-" * 80)
33
+ diff_result[:triggers].each do |trigger_diff|
34
+ output.concat(format_trigger_diff(trigger_diff))
35
+ output << ""
36
+ end
37
+ end
38
+
39
+ # Report on drops (for down migrations)
40
+ if diff_result[:drops]&.any?
41
+ output << "Drops:"
42
+ output << ("-" * 80)
43
+ diff_result[:drops].each do |drop|
44
+ output << " - Will #{drop[:type] == :trigger ? 'drop trigger' : 'drop function'}: #{drop[:name]}"
45
+ end
46
+ output << ""
47
+ end
48
+
49
+ output << ("=" * 80)
50
+ output << ""
51
+ output << "⚠️ WARNING: This migration will modify existing database objects."
52
+ output << "Review the differences above before proceeding."
53
+
54
+ output.join("\n")
55
+ end
56
+
57
+ # Format a concise summary for console output
58
+ def format_summary(diff_result)
59
+ return "✓ No differences - safe to apply" unless diff_result[:has_differences]
60
+
61
+ summary = []
62
+ summary << "⚠️ Differences detected:"
63
+
64
+ new_count = diff_result[:functions].count { |f| f[:status] == :new } +
65
+ diff_result[:triggers].count { |t| t[:status] == :new }
66
+ modified_count = diff_result[:functions].count { |f| f[:status] == :modified } +
67
+ diff_result[:triggers].count { |t| t[:status] == :modified }
68
+
69
+ summary << " - #{new_count} new object(s) will be created"
70
+ summary << " - #{modified_count} existing object(s) will be modified"
71
+
72
+ summary.join("\n")
73
+ end
74
+
75
+ private
76
+
77
+ def format_function_diff(func_diff)
78
+ case func_diff[:status]
79
+ when :new
80
+ [
81
+ " Function: #{func_diff[:function_name]}",
82
+ " Status: NEW (will be created)"
83
+ ]
84
+ when :modified
85
+ [
86
+ " Function: #{func_diff[:function_name]}",
87
+ " Status: MODIFIED (will overwrite existing function)",
88
+ " Expected:",
89
+ indent_text(func_diff[:expected], 6),
90
+ " Current:",
91
+ indent_text(func_diff[:actual], 6)
92
+ ]
93
+ when :unchanged
94
+ [
95
+ " Function: #{func_diff[:function_name]}",
96
+ " Status: UNCHANGED"
97
+ ]
98
+ else
99
+ [
100
+ " Function: #{func_diff[:function_name]}",
101
+ " Status: #{func_diff[:status]}"
102
+ ]
103
+ end
104
+ end
105
+
106
+ def format_trigger_diff(trigger_diff)
107
+ output = []
108
+ output << " Trigger: #{trigger_diff[:trigger_name]}"
109
+ case trigger_diff[:status]
110
+ when :new
111
+ output << " Status: NEW (will be created)"
112
+ output << " Definition:"
113
+ output << indent_text(trigger_diff[:expected], 6)
114
+ when :modified
115
+ output << " Status: MODIFIED (will overwrite existing trigger)"
116
+
117
+ if trigger_diff[:differences]&.any?
118
+ output << " Differences:"
119
+ trigger_diff[:differences].each do |diff|
120
+ output << " - #{diff}"
121
+ end
122
+ end
123
+
124
+ output << " Expected:"
125
+ output << indent_text(trigger_diff[:expected], 6)
126
+ output << " Current:"
127
+ output << indent_text(trigger_diff[:actual], 6)
128
+ when :unchanged
129
+ output << " Status: UNCHANGED"
130
+ else
131
+ output << " Status: #{trigger_diff[:status]}"
132
+ end
133
+ output
134
+ end
135
+
136
+ def indent_text(text, spaces)
137
+ indent = " " * spaces
138
+ text.to_s.lines.map { |line| "#{indent}#{line.chomp}" }.join("\n")
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,258 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drift/db_queries"
4
+
5
+ module PgSqlTriggers
6
+ class Migrator
7
+ # Validates that migrations don't blindly DROP + CREATE objects
8
+ # This ensures safety by detecting unsafe patterns and blocking them
9
+ class SafetyValidator
10
+ # Error raised when unsafe DROP + CREATE operations are detected
11
+ class UnsafeOperationError < PgSqlTriggers::UnsafeMigrationError
12
+ def initialize(message, violations)
13
+ super(message)
14
+ @violations = violations
15
+ end
16
+
17
+ attr_reader :violations
18
+
19
+ def violation_summary
20
+ @violations.map { |v| " - #{v[:message]}" }.join("\n")
21
+ end
22
+ end
23
+
24
+ class << self
25
+ # Validate that a migration doesn't perform unsafe DROP + CREATE operations
26
+ # Raises UnsafeOperationError if unsafe patterns are detected
27
+ def validate!(migration_instance, direction: :up, allow_unsafe: false)
28
+ return if allow_unsafe
29
+
30
+ violations = detect_unsafe_patterns(migration_instance, direction)
31
+ return if violations.empty?
32
+
33
+ error_message = build_error_message(violations, migration_instance.class.name)
34
+ raise UnsafeOperationError.new(error_message, violations)
35
+ end
36
+
37
+ # Detect unsafe patterns in migration SQL
38
+ # Returns array of violation hashes
39
+ def detect_unsafe_patterns(migration_instance, direction)
40
+ violations = []
41
+
42
+ # Capture SQL that would be executed
43
+ captured_sql = capture_sql(migration_instance, direction)
44
+
45
+ # Parse SQL to detect unsafe patterns
46
+ sql_operations = parse_sql_operations(captured_sql)
47
+
48
+ # Check for explicit DROP + CREATE patterns (the main safety concern)
49
+ violations.concat(detect_drop_create_patterns(sql_operations))
50
+
51
+ violations
52
+ end
53
+
54
+ private
55
+
56
+ # Capture SQL that would be executed by the migration
57
+ def capture_sql(migration_instance, direction)
58
+ captured = []
59
+
60
+ # Override execute to capture SQL instead of executing
61
+ migration_instance.define_singleton_method(:execute) do |sql|
62
+ captured << sql.to_s.strip
63
+ end
64
+
65
+ # Call the migration method (up or down) to capture SQL
66
+ migration_instance.public_send(direction)
67
+
68
+ captured
69
+ end
70
+
71
+ # Parse SQL into structured operations
72
+ def parse_sql_operations(sql_array)
73
+ operations = {
74
+ drops: [],
75
+ creates: [],
76
+ replaces: []
77
+ }
78
+
79
+ sql_array.each do |sql|
80
+ sql_normalized = sql.squish
81
+
82
+ # Parse DROP statements
83
+ if sql_normalized.match?(/DROP\s+(TRIGGER|FUNCTION)/i)
84
+ drop_info = parse_drop(sql)
85
+ operations[:drops] << drop_info if drop_info
86
+ end
87
+
88
+ # Parse CREATE statements (without OR REPLACE)
89
+ if sql_normalized.match?(/CREATE\s+(?!OR\s+REPLACE)(TRIGGER|FUNCTION)/i)
90
+ create_info = parse_create(sql)
91
+ operations[:creates] << create_info if create_info
92
+ end
93
+
94
+ # Parse CREATE OR REPLACE statements
95
+ # (only for functions - PostgreSQL doesn't support CREATE OR REPLACE TRIGGER)
96
+ if sql_normalized.match?(/CREATE\s+OR\s+REPLACE\s+FUNCTION/i)
97
+ replace_info = parse_replace(sql)
98
+ operations[:replaces] << replace_info if replace_info
99
+ end
100
+ end
101
+
102
+ operations
103
+ end
104
+
105
+ # Parse DROP SQL statement
106
+ def parse_drop(sql)
107
+ if sql.match?(/DROP\s+TRIGGER/i)
108
+ match = sql.match(/DROP\s+TRIGGER\s+(?:IF\s+EXISTS\s+)?(\w+)\s+ON\s+(\w+)/i)
109
+ return nil unless match
110
+
111
+ {
112
+ type: :trigger,
113
+ name: match[1],
114
+ table_name: match[2],
115
+ sql: sql
116
+ }
117
+ elsif sql.match?(/DROP\s+FUNCTION/i)
118
+ match = sql.match(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(\w+)\s*\(\)?/i)
119
+ return nil unless match
120
+
121
+ {
122
+ type: :function,
123
+ name: match[1],
124
+ sql: sql
125
+ }
126
+ end
127
+ end
128
+
129
+ # Parse CREATE SQL statement (without OR REPLACE)
130
+ def parse_create(sql)
131
+ if sql.match?(/CREATE\s+TRIGGER/i)
132
+ match = sql.match(/CREATE\s+TRIGGER\s+(\w+)\s+.*?\s+ON\s+(\w+)/i)
133
+ return nil unless match
134
+
135
+ {
136
+ type: :trigger,
137
+ name: match[1],
138
+ table_name: match[2],
139
+ sql: sql
140
+ }
141
+ elsif sql.match?(/CREATE\s+FUNCTION/i)
142
+ match = sql.match(/CREATE\s+FUNCTION\s+(\w+)\s*\([^)]*\)/i)
143
+ return nil unless match
144
+
145
+ # Extract function body for comparison
146
+ body_match = sql.match(/\$\$(.+?)\$\$/m) || sql.match(/AS\s+(.+)/im)
147
+ function_body = body_match ? body_match[1].strip : sql
148
+
149
+ {
150
+ type: :function,
151
+ name: match[1],
152
+ function_body: function_body,
153
+ sql: sql
154
+ }
155
+ end
156
+ end
157
+
158
+ # Parse CREATE OR REPLACE SQL statement (only for functions)
159
+ def parse_replace(sql)
160
+ # PostgreSQL doesn't support CREATE OR REPLACE TRIGGER, only functions
161
+ return unless sql.match?(/CREATE\s+OR\s+REPLACE\s+FUNCTION/i)
162
+
163
+ match = sql.match(/CREATE\s+OR\s+REPLACE\s+FUNCTION\s+(\w+)\s*\([^)]*\)/i)
164
+ return nil unless match
165
+
166
+ # Extract function body for comparison
167
+ body_match = sql.match(/\$\$(.+?)\$\$/m) || sql.match(/AS\s+(.+)/im)
168
+ function_body = body_match ? body_match[1].strip : sql
169
+
170
+ {
171
+ type: :function,
172
+ name: match[1],
173
+ function_body: function_body,
174
+ sql: sql
175
+ }
176
+ end
177
+
178
+ # Detect explicit DROP + CREATE patterns
179
+ # This is unsafe because it drops existing objects and recreates them without validation
180
+ def detect_drop_create_patterns(operations)
181
+ violations = []
182
+
183
+ # Check if any DROP is followed by a CREATE of the same object
184
+ operations[:drops].each do |drop|
185
+ matching_create = operations[:creates].find do |create|
186
+ type_match = create[:type] == drop[:type]
187
+ name_match = create[:name] == drop[:name]
188
+ # For triggers, also match on table_name
189
+ table_match = if drop[:type] == :trigger
190
+ create[:table_name] == drop[:table_name]
191
+ else
192
+ true # Functions don't have table_name
193
+ end
194
+ type_match && name_match && table_match
195
+ end
196
+
197
+ next unless matching_create
198
+
199
+ object_type = drop[:type] == :trigger ? "trigger" : "function"
200
+ existing_object = case drop[:type]
201
+ when :function
202
+ function_exists?(drop[:name])
203
+ when :trigger
204
+ trigger_exists?(drop[:name])
205
+ end
206
+
207
+ # Only flag as unsafe if the object actually exists
208
+ next unless existing_object
209
+
210
+ violations << {
211
+ type: :drop_create_pattern,
212
+ message: "Unsafe DROP + CREATE pattern detected for #{object_type} '#{drop[:name]}'. " \
213
+ "Migration will drop existing #{object_type} and recreate it. " \
214
+ "For functions, use CREATE OR REPLACE FUNCTION instead. " \
215
+ "For triggers, drop and recreate is sometimes necessary, but ensure this is intentional.",
216
+ drop_sql: drop[:sql],
217
+ create_sql: matching_create[:sql],
218
+ object_name: drop[:name],
219
+ object_type: drop[:type]
220
+ }
221
+ end
222
+
223
+ violations
224
+ end
225
+
226
+ # Check if function exists in database
227
+ def function_exists?(function_name)
228
+ Drift::DbQueries.find_function(function_name).present?
229
+ end
230
+
231
+ # Check if trigger exists in database
232
+ def trigger_exists?(trigger_name)
233
+ Drift::DbQueries.find_trigger(trigger_name).present?
234
+ end
235
+
236
+ # Build error message from violations
237
+ def build_error_message(violations, migration_class_name)
238
+ message = []
239
+ message << ("=" * 80)
240
+ message << "UNSAFE MIGRATION DETECTED"
241
+ message << "Migration: #{migration_class_name}"
242
+ message << ("=" * 80)
243
+ message << ""
244
+ message << "The migration contains unsafe DROP + CREATE operations that would:"
245
+ message << ""
246
+ message << violations.map { |v| " - #{v[:message]}" }.join("\n")
247
+ message << ""
248
+ message << "To proceed despite these warnings, set ALLOW_UNSAFE_MIGRATIONS=true"
249
+ message << "or configure pg_sql_triggers to allow unsafe operations."
250
+ message << ""
251
+ message << ("=" * 80)
252
+
253
+ message.join("\n")
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
@@ -3,8 +3,12 @@
3
3
  require "ostruct"
4
4
  require "active_support/core_ext/string/inflections"
5
5
  require "active_support/core_ext/module/delegation"
6
+ require_relative "migrator/pre_apply_comparator"
7
+ require_relative "migrator/pre_apply_diff_reporter"
8
+ require_relative "migrator/safety_validator"
6
9
 
7
10
  module PgSqlTriggers
11
+ # rubocop:disable Metrics/ClassLength
8
12
  class Migrator
9
13
  MIGRATIONS_TABLE_NAME = "trigger_migrations"
10
14
 
@@ -158,6 +162,7 @@ module PgSqlTriggers
158
162
  end
159
163
  end
160
164
 
165
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
161
166
  def run_migration(migration, direction)
162
167
  require migration.path
163
168
 
@@ -186,7 +191,58 @@ module PgSqlTriggers
186
191
  end
187
192
  end
188
193
 
194
+ # Perform safety validation (prevent unsafe DROP + CREATE operations)
195
+ validation_instance = migration_class.new
196
+ begin
197
+ allow_unsafe = ENV["ALLOW_UNSAFE_MIGRATIONS"] == "true" ||
198
+ (defined?(PgSqlTriggers) && PgSqlTriggers.allow_unsafe_migrations == true)
199
+
200
+ SafetyValidator.validate!(validation_instance, direction: direction, allow_unsafe: allow_unsafe)
201
+ rescue SafetyValidator::UnsafeOperationError => e
202
+ # Safety validation failed - block the migration
203
+ error_msg = "\n#{e.message}\n\n"
204
+ Rails.logger.error(error_msg) if defined?(Rails.logger)
205
+ Rails.logger.debug error_msg if ENV["VERBOSE"] != "false" || defined?(Rails::Console)
206
+ raise StandardError, "Migration blocked due to unsafe DROP + CREATE operations. " \
207
+ "Review the errors above and set ALLOW_UNSAFE_MIGRATIONS=true if you must proceed."
208
+ rescue StandardError => e
209
+ # Don't fail the migration if validation fails for other reasons - just log it
210
+ if defined?(Rails.logger)
211
+ Rails.logger.warn("Safety validation failed for migration #{migration.name}: #{e.message}")
212
+ end
213
+ end
214
+
215
+ # Perform pre-apply comparison (diff expected vs actual)
216
+ # Create a separate instance for comparison to avoid side effects
217
+ comparison_instance = migration_class.new
218
+ begin
219
+ diff_result = PreApplyComparator.compare(comparison_instance, direction: direction)
220
+
221
+ # Log the comparison result
222
+ if diff_result[:has_differences]
223
+ diff_report = PreApplyDiffReporter.format(diff_result, migration_name: migration.name)
224
+ if defined?(Rails.logger)
225
+ Rails.logger.warn("Pre-apply comparison for migration #{migration.name}:\n#{diff_report}")
226
+ end
227
+
228
+ # In verbose mode or when called from console, print the diff
229
+ if ENV["VERBOSE"] != "false" || defined?(Rails::Console)
230
+ Rails.logger.debug { "\n#{PreApplyDiffReporter.format_summary(diff_result)}\n" }
231
+ end
232
+ elsif defined?(Rails.logger)
233
+ Rails.logger.info(
234
+ "Pre-apply comparison: No differences detected for migration #{migration.name}"
235
+ )
236
+ end
237
+ rescue StandardError => e
238
+ # Don't fail the migration if comparison fails - just log it
239
+ if defined?(Rails.logger)
240
+ Rails.logger.warn("Pre-apply comparison failed for migration #{migration.name}: #{e.message}")
241
+ end
242
+ end
243
+
189
244
  ActiveRecord::Base.transaction do
245
+ # Create a fresh instance for actual execution
190
246
  migration_instance = migration_class.new
191
247
  migration_instance.public_send(direction)
192
248
 
@@ -211,6 +267,7 @@ module PgSqlTriggers
211
267
  raise StandardError,
212
268
  "Error running trigger migration #{migration.filename}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
213
269
  end
270
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
214
271
 
215
272
  def status
216
273
  ensure_migrations_table!
@@ -258,4 +315,5 @@ module PgSqlTriggers
258
315
  end
259
316
  end
260
317
  end
318
+ # rubocop:enable Metrics/ClassLength
261
319
  end
@@ -4,9 +4,40 @@ module PgSqlTriggers
4
4
  module Registry
5
5
  class Manager
6
6
  class << self
7
+ # Request-level cache to avoid N+1 queries when loading multiple trigger files
8
+ # This cache is cleared after each request/transaction to ensure data consistency
9
+ def _registry_cache
10
+ @_registry_cache ||= {}
11
+ end
12
+
13
+ def _clear_registry_cache
14
+ @_registry_cache = {}
15
+ end
16
+
17
+ # Batch load existing triggers into cache to avoid N+1 queries
18
+ # Call this before registering multiple triggers for better performance
19
+ def preload_triggers(trigger_names)
20
+ return if trigger_names.empty?
21
+
22
+ # Find all triggers that aren't already cached
23
+ uncached_names = trigger_names - _registry_cache.keys
24
+ return if uncached_names.empty?
25
+
26
+ # Batch load all uncached triggers in a single query
27
+ PgSqlTriggers::TriggerRegistry.where(trigger_name: uncached_names).find_each do |trigger|
28
+ _registry_cache[trigger.trigger_name] = trigger
29
+ end
30
+ end
31
+
7
32
  def register(definition)
8
33
  trigger_name = definition.name
9
- existing = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: trigger_name)
34
+
35
+ # Use cached lookup if available to avoid N+1 queries during trigger file loading
36
+ existing = _registry_cache[trigger_name] ||=
37
+ PgSqlTriggers::TriggerRegistry.find_by(trigger_name: trigger_name)
38
+
39
+ # Calculate checksum using field-concatenation (consistent with TriggerRegistry model)
40
+ checksum = calculate_checksum(definition)
10
41
 
11
42
  attributes = {
12
43
  trigger_name: definition.name,
@@ -15,14 +46,28 @@ module PgSqlTriggers
15
46
  enabled: definition.enabled,
16
47
  source: "dsl",
17
48
  environment: definition.environments.join(","),
18
- definition: definition.to_h.to_json
49
+ definition: definition.to_h.to_json,
50
+ checksum: checksum
19
51
  }
20
52
 
21
53
  if existing
22
- existing.update!(attributes)
23
- existing
54
+ begin
55
+ existing.update!(attributes)
56
+ # Update cache with the modified record (reload to get fresh data)
57
+ reloaded = existing.reload
58
+ _registry_cache[trigger_name] = reloaded
59
+ reloaded
60
+ rescue ActiveRecord::RecordNotFound
61
+ # Cached record was deleted, create a new one
62
+ new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
63
+ _registry_cache[trigger_name] = new_record
64
+ new_record
65
+ end
24
66
  else
25
- PgSqlTriggers::TriggerRegistry.create!(attributes.merge(checksum: "placeholder"))
67
+ new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
68
+ # Cache the newly created record
69
+ _registry_cache[trigger_name] = new_record
70
+ new_record
26
71
  end
27
72
  end
28
73
 
@@ -36,10 +81,52 @@ module PgSqlTriggers
36
81
 
37
82
  delegate :for_table, to: PgSqlTriggers::TriggerRegistry
38
83
 
39
- def diff
40
- # Compare DSL definitions with actual database state
41
- # This will be implemented in the Drift::Detector
42
- Drift.detect
84
+ def diff(trigger_name = nil)
85
+ PgSqlTriggers::Drift.detect(trigger_name)
86
+ end
87
+
88
+ def drifted
89
+ PgSqlTriggers::Drift::Detector.detect_all.select do |r|
90
+ r[:state] == PgSqlTriggers::DRIFT_STATE_DRIFTED
91
+ end
92
+ end
93
+
94
+ def in_sync
95
+ PgSqlTriggers::Drift::Detector.detect_all.select do |r|
96
+ r[:state] == PgSqlTriggers::DRIFT_STATE_IN_SYNC
97
+ end
98
+ end
99
+
100
+ def unknown_triggers
101
+ PgSqlTriggers::Drift::Detector.detect_all.select do |r|
102
+ r[:state] == PgSqlTriggers::DRIFT_STATE_UNKNOWN
103
+ end
104
+ end
105
+
106
+ def dropped
107
+ PgSqlTriggers::Drift::Detector.detect_all.select do |r|
108
+ r[:state] == PgSqlTriggers::DRIFT_STATE_DROPPED
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ def calculate_checksum(definition)
115
+ # DSL definitions don't have function_body, so use placeholder
116
+ # Generator forms have function_body, so calculate real checksum
117
+ function_body_value = definition.respond_to?(:function_body) ? definition.function_body : nil
118
+ return "placeholder" if function_body_value.blank?
119
+
120
+ # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
121
+ require "digest"
122
+ Digest::SHA256.hexdigest([
123
+ definition.name,
124
+ definition.table_name,
125
+ definition.version,
126
+ function_body_value,
127
+ definition.condition || "",
128
+ definition.timing || "before"
129
+ ].join)
43
130
  end
44
131
  end
45
132
  end