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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +47 -0
  3. data/.rubocop.yml +4 -1
  4. data/CHANGELOG.md +112 -1
  5. data/COVERAGE.md +58 -0
  6. data/Goal.md +450 -123
  7. data/README.md +53 -215
  8. data/app/controllers/pg_sql_triggers/application_controller.rb +46 -0
  9. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
  10. data/app/controllers/pg_sql_triggers/generator_controller.rb +76 -8
  11. data/app/controllers/pg_sql_triggers/migrations_controller.rb +18 -0
  12. data/app/models/pg_sql_triggers/trigger_registry.rb +93 -12
  13. data/app/views/layouts/pg_sql_triggers/application.html.erb +34 -1
  14. data/app/views/pg_sql_triggers/dashboard/index.html.erb +70 -30
  15. data/app/views/pg_sql_triggers/generator/new.html.erb +22 -4
  16. data/app/views/pg_sql_triggers/generator/preview.html.erb +244 -16
  17. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +221 -0
  18. data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +40 -0
  19. data/app/views/pg_sql_triggers/tables/index.html.erb +0 -2
  20. data/app/views/pg_sql_triggers/tables/show.html.erb +3 -4
  21. data/config/initializers/pg_sql_triggers.rb +69 -0
  22. data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +3 -1
  23. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
  24. data/docs/README.md +66 -0
  25. data/docs/api-reference.md +681 -0
  26. data/docs/configuration.md +541 -0
  27. data/docs/getting-started.md +135 -0
  28. data/docs/kill-switch.md +586 -0
  29. data/docs/screenshots/.gitkeep +1 -0
  30. data/docs/screenshots/Generate Trigger.png +0 -0
  31. data/docs/screenshots/Triggers Page.png +0 -0
  32. data/docs/screenshots/kill error.png +0 -0
  33. data/docs/screenshots/kill modal for migration down.png +0 -0
  34. data/docs/usage-guide.md +493 -0
  35. data/docs/web-ui.md +353 -0
  36. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +3 -1
  37. data/lib/generators/pg_sql_triggers/templates/initializer.rb +44 -2
  38. data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
  39. data/lib/pg_sql_triggers/drift/detector.rb +187 -0
  40. data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
  41. data/lib/pg_sql_triggers/drift.rb +14 -11
  42. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
  43. data/lib/pg_sql_triggers/generator/form.rb +3 -1
  44. data/lib/pg_sql_triggers/generator/service.rb +82 -26
  45. data/lib/pg_sql_triggers/migration.rb +1 -1
  46. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
  47. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
  48. data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
  49. data/lib/pg_sql_triggers/migrator.rb +85 -3
  50. data/lib/pg_sql_triggers/registry/manager.rb +100 -13
  51. data/lib/pg_sql_triggers/sql/kill_switch.rb +300 -0
  52. data/lib/pg_sql_triggers/testing/dry_run.rb +5 -7
  53. data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
  54. data/lib/pg_sql_triggers/testing/safe_executor.rb +23 -11
  55. data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
  56. data/lib/pg_sql_triggers/version.rb +1 -1
  57. data/lib/pg_sql_triggers.rb +24 -0
  58. data/lib/tasks/trigger_migrations.rake +33 -0
  59. data/scripts/generate_coverage_report.rb +129 -0
  60. metadata +45 -5
@@ -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
 
@@ -38,7 +42,7 @@ module PgSqlTriggers
38
42
  def migrations
39
43
  return [] unless Dir.exist?(migrations_path)
40
44
 
41
- files = Dir.glob(migrations_path.join("*.rb")).sort
45
+ files = Dir.glob(migrations_path.join("*.rb"))
42
46
  files.map do |file|
43
47
  basename = File.basename(file, ".rb")
44
48
  # Handle Rails migration format: YYYYMMDDHHMMSS_name
@@ -78,7 +82,19 @@ module PgSqlTriggers
78
82
  end
79
83
  end
80
84
 
81
- def run_up(target_version = nil)
85
+ def run_up(target_version = nil, confirmation: nil)
86
+ # Check kill switch before running migrations
87
+ # This provides protection when called directly from console
88
+ # When called from rake tasks, the ENV override will already be in place
89
+ # Use ENV["CONFIRMATION_TEXT"] if confirmation is not provided (for rake task compatibility)
90
+ confirmation ||= ENV.fetch("CONFIRMATION_TEXT", nil)
91
+ PgSqlTriggers::SQL::KillSwitch.check!(
92
+ operation: :migrator_run_up,
93
+ environment: Rails.env,
94
+ confirmation: confirmation,
95
+ actor: { type: "Console", id: "Migrator.run_up" }
96
+ )
97
+
82
98
  if target_version
83
99
  # Apply a specific migration version
84
100
  migration_to_apply = migrations.find { |m| m.version == target_version }
@@ -102,7 +118,19 @@ module PgSqlTriggers
102
118
  end
103
119
  end
104
120
 
105
- def run_down(target_version = nil)
121
+ def run_down(target_version = nil, confirmation: nil)
122
+ # Check kill switch before running migrations
123
+ # This provides protection when called directly from console
124
+ # When called from rake tasks, the ENV override will already be in place
125
+ # Use ENV["CONFIRMATION_TEXT"] if confirmation is not provided (for rake task compatibility)
126
+ confirmation ||= ENV.fetch("CONFIRMATION_TEXT", nil)
127
+ PgSqlTriggers::SQL::KillSwitch.check!(
128
+ operation: :migrator_run_down,
129
+ environment: Rails.env,
130
+ confirmation: confirmation,
131
+ actor: { type: "Console", id: "Migrator.run_down" }
132
+ )
133
+
106
134
  current_ver = current_version
107
135
  return if current_ver.zero?
108
136
 
@@ -134,6 +162,7 @@ module PgSqlTriggers
134
162
  end
135
163
  end
136
164
 
165
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
137
166
  def run_migration(migration, direction)
138
167
  require migration.path
139
168
 
@@ -162,7 +191,58 @@ module PgSqlTriggers
162
191
  end
163
192
  end
164
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
+
165
244
  ActiveRecord::Base.transaction do
245
+ # Create a fresh instance for actual execution
166
246
  migration_instance = migration_class.new
167
247
  migration_instance.public_send(direction)
168
248
 
@@ -187,6 +267,7 @@ module PgSqlTriggers
187
267
  raise StandardError,
188
268
  "Error running trigger migration #{migration.filename}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
189
269
  end
270
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
190
271
 
191
272
  def status
192
273
  ensure_migrations_table!
@@ -234,4 +315,5 @@ module PgSqlTriggers
234
315
  end
235
316
  end
236
317
  end
318
+ # rubocop:enable Metrics/ClassLength
237
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 = 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,31 +46,87 @@ 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
- 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
 
29
74
  def list
30
- TriggerRegistry.all
75
+ PgSqlTriggers::TriggerRegistry.all
31
76
  end
32
77
 
33
- delegate :enabled, to: :TriggerRegistry
78
+ delegate :enabled, to: PgSqlTriggers::TriggerRegistry
79
+
80
+ delegate :disabled, to: PgSqlTriggers::TriggerRegistry
81
+
82
+ delegate :for_table, to: PgSqlTriggers::TriggerRegistry
83
+
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
34
111
 
35
- delegate :disabled, to: :TriggerRegistry
112
+ private
36
113
 
37
- delegate :for_table, to: :TriggerRegistry
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?
38
119
 
39
- def diff
40
- # Compare DSL definitions with actual database state
41
- # This will be implemented in the Drift::Detector
42
- Drift.detect
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