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
@@ -12,6 +12,7 @@ module PgSqlTriggers
12
12
  @enabled = false
13
13
  @environments = []
14
14
  @condition = nil
15
+ @timing = "before"
15
16
  end
16
17
 
17
18
  def table(table_name)
@@ -50,6 +51,18 @@ module PgSqlTriggers
50
51
  @condition = condition_sql
51
52
  end
52
53
 
54
+ def timing(timing_value = nil)
55
+ if timing_value.nil?
56
+ @timing
57
+ else
58
+ @timing = timing_value.to_s
59
+ end
60
+ end
61
+
62
+ def function_body
63
+ nil # DSL definitions don't include function_body directly
64
+ end
65
+
53
66
  def to_h
54
67
  {
55
68
  name: @name,
@@ -59,7 +72,8 @@ module PgSqlTriggers
59
72
  version: @version,
60
73
  enabled: @enabled,
61
74
  environments: @environments,
62
- condition: @condition
75
+ condition: @condition,
76
+ timing: @timing
63
77
  }
64
78
  end
65
79
  end
@@ -6,7 +6,7 @@ module PgSqlTriggers
6
6
  include ActiveModel::Model
7
7
 
8
8
  attr_accessor :trigger_name, :table_name, :function_name,
9
- :version, :enabled, :condition,
9
+ :version, :enabled, :condition, :timing,
10
10
  :generate_function_stub, :events, :environments,
11
11
  :function_body
12
12
 
@@ -23,6 +23,7 @@ module PgSqlTriggers
23
23
  }
24
24
  validates :version, presence: true, numericality: { only_integer: true, greater_than: 0 }
25
25
  validates :function_body, presence: true
26
+ validates :timing, inclusion: { in: %w[before after], message: "must be 'before' or 'after'" }
26
27
  validate :at_least_one_event
27
28
  validate :function_name_matches_body
28
29
 
@@ -38,6 +39,7 @@ module PgSqlTriggers
38
39
  @generate_function_stub = true if @generate_function_stub.nil?
39
40
  @events ||= []
40
41
  @environments ||= []
42
+ @timing ||= "before" # Default to "before" if not specified
41
43
  end
42
44
 
43
45
  def default_function_body
@@ -6,6 +6,7 @@ require "active_support/core_ext/string/inflections"
6
6
 
7
7
  module PgSqlTriggers
8
8
  module Generator
9
+ # rubocop:disable Metrics/ClassLength
9
10
  class Service
10
11
  class << self
11
12
  def generate_dsl(form)
@@ -31,6 +32,7 @@ module PgSqlTriggers
31
32
  #{' '}
32
33
  version #{form.version}
33
34
  enabled #{form.enabled}
35
+ timing :#{form.timing || 'before'}
34
36
  RUBY
35
37
 
36
38
  code += " when_env #{environments_list}\n" if form.environments.compact_blank.any?
@@ -51,8 +53,9 @@ module PgSqlTriggers
51
53
  function_body_sql = form.function_body.strip
52
54
 
53
55
  # Build the trigger creation SQL
56
+ timing_value = (form.timing || "before").upcase
54
57
  trigger_sql = "CREATE TRIGGER #{form.trigger_name}\n"
55
- trigger_sql += "BEFORE #{events_sql} ON #{form.table_name}\n"
58
+ trigger_sql += "#{timing_value} #{events_sql} ON #{form.table_name}\n"
56
59
  trigger_sql += "FOR EACH ROW\n"
57
60
  trigger_sql += "WHEN (#{form.condition})\n" if form.condition.present?
58
61
  trigger_sql += "EXECUTE FUNCTION #{form.function_name}();"
@@ -61,6 +64,11 @@ module PgSqlTriggers
61
64
  down_sql = "DROP TRIGGER IF EXISTS #{form.trigger_name} ON #{form.table_name};\n"
62
65
  down_sql += "DROP FUNCTION IF EXISTS #{form.function_name}();"
63
66
 
67
+ # Indent SQL strings to match heredoc indentation (18 spaces)
68
+ indented_function_body = indent_sql(function_body_sql, 18)
69
+ indented_trigger_sql = indent_sql(trigger_sql, 18)
70
+ indented_down_sql = indent_sql(down_sql, 18)
71
+
64
72
  <<~RUBY
65
73
  # frozen_string_literal: true
66
74
 
@@ -69,18 +77,18 @@ module PgSqlTriggers
69
77
  def up
70
78
  # Create the function
71
79
  execute <<-SQL
72
- #{function_body_sql}
80
+ #{indented_function_body}
73
81
  SQL
74
82
 
75
83
  # Create the trigger
76
84
  execute <<-SQL
77
- #{trigger_sql}
85
+ #{indented_trigger_sql}
78
86
  SQL
79
87
  end
80
88
 
81
89
  def down
82
90
  execute <<-SQL
83
- #{down_sql}
91
+ #{indented_down_sql}
84
92
  SQL
85
93
  end
86
94
  end
@@ -142,29 +150,53 @@ module PgSqlTriggers
142
150
 
143
151
  def create_trigger(form, actor: nil) # rubocop:disable Lint/UnusedMethodArgument
144
152
  paths = file_paths(form)
153
+ base_path = rails_base_path
145
154
 
146
- # Determine if we're in a Rails app context or standalone gem
147
- base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
155
+ create_trigger_files(form, paths, base_path)
156
+ registry = register_trigger(form)
157
+
158
+ build_success_response(registry, paths, form)
159
+ rescue StandardError => e
160
+ log_error(e)
161
+ build_error_response(e)
162
+ end
163
+
164
+ private
165
+
166
+ def indent_sql(sql_string, indent_level)
167
+ indent = " " * indent_level
168
+ sql_string.lines.map do |line|
169
+ stripped = line.chomp
170
+ stripped.empty? ? "" : indent + stripped
171
+ end.join("\n")
172
+ end
148
173
 
174
+ def rails_base_path
175
+ defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
176
+ end
177
+
178
+ def create_trigger_files(form, paths, base_path)
149
179
  full_migration_path = base_path.join(paths[:migration])
150
180
  full_dsl_path = base_path.join(paths[:dsl])
151
181
 
152
- # Create directories
153
182
  FileUtils.mkdir_p(full_migration_path.dirname)
154
183
  FileUtils.mkdir_p(full_dsl_path.dirname)
155
184
 
156
- # Generate content
157
185
  migration_content = generate_migration(form)
158
186
  dsl_content = generate_dsl(form)
159
- # Use function_body (required field)
160
- function_content = form.function_body
161
187
 
162
- # Write both files
163
188
  File.write(full_migration_path, migration_content)
164
189
  File.write(full_dsl_path, dsl_content)
190
+ end
165
191
 
166
- # Register in TriggerRegistry
167
- definition = {
192
+ def register_trigger(form)
193
+ definition = build_trigger_definition(form)
194
+ attributes = build_registry_attributes(form, definition)
195
+ TriggerRegistry.create!(attributes)
196
+ end
197
+
198
+ def build_trigger_definition(form)
199
+ {
168
200
  name: form.trigger_name,
169
201
  table_name: form.table_name,
170
202
  events: form.events.compact_blank,
@@ -172,9 +204,13 @@ module PgSqlTriggers
172
204
  version: form.version,
173
205
  enabled: form.enabled,
174
206
  environments: form.environments.compact_blank,
175
- condition: form.condition
207
+ condition: form.condition,
208
+ timing: form.timing || "before",
209
+ function_body: form.function_body
176
210
  }
211
+ end
177
212
 
213
+ def build_registry_attributes(form, definition)
178
214
  attributes = {
179
215
  trigger_name: form.trigger_name,
180
216
  table_name: form.table_name,
@@ -183,15 +219,22 @@ module PgSqlTriggers
183
219
  source: "dsl",
184
220
  environment: form.environments.compact_blank.join(",").presence,
185
221
  definition: definition.to_json,
186
- function_body: function_content,
222
+ function_body: form.function_body,
187
223
  checksum: calculate_checksum(definition)
188
224
  }
189
225
 
190
- # Only include condition if the column exists and value is present
191
- attributes[:condition] = form.condition.presence if TriggerRegistry.column_names.include?("condition")
226
+ add_conditional_attributes(attributes, form)
227
+ attributes
228
+ end
192
229
 
193
- registry = TriggerRegistry.create!(attributes)
230
+ def add_conditional_attributes(attributes, form)
231
+ column_names = TriggerRegistry.column_names
194
232
 
233
+ attributes[:condition] = form.condition.presence if column_names.include?("condition")
234
+ attributes[:timing] = (form.timing || "before") if column_names.include?("timing")
235
+ end
236
+
237
+ def build_success_response(registry, paths, form)
195
238
  {
196
239
  success: true,
197
240
  registry_id: registry.id,
@@ -204,18 +247,22 @@ module PgSqlTriggers
204
247
  files_created: [paths[:migration], paths[:dsl]]
205
248
  }
206
249
  }
207
- rescue StandardError => e
208
- Rails.logger.error("Trigger generation failed: #{e.message}") if defined?(Rails)
209
- Rails.logger.error(e.backtrace.join("\n")) if defined?(Rails)
250
+ end
251
+
252
+ def log_error(error)
253
+ return unless defined?(Rails)
254
+
255
+ Rails.logger.error("Trigger generation failed: #{error.message}")
256
+ Rails.logger.error(error.backtrace.join("\n"))
257
+ end
210
258
 
259
+ def build_error_response(error)
211
260
  {
212
261
  success: false,
213
- error: e.message
262
+ error: error.message
214
263
  }
215
264
  end
216
265
 
217
- private
218
-
219
266
  def next_migration_number
220
267
  # Determine if we're in a Rails app context or standalone gem
221
268
  base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
@@ -243,9 +290,18 @@ module PgSqlTriggers
243
290
  end
244
291
 
245
292
  def calculate_checksum(definition)
246
- Digest::SHA256.hexdigest(definition.to_json)
293
+ # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
294
+ Digest::SHA256.hexdigest([
295
+ definition[:name],
296
+ definition[:table_name],
297
+ definition[:version],
298
+ definition[:function_body] || "",
299
+ definition[:condition] || "",
300
+ definition[:timing] || "before"
301
+ ].join)
247
302
  end
248
303
  end
249
304
  end
305
+ # rubocop:enable Metrics/ClassLength
250
306
  end
251
307
  end
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../drift/db_queries"
4
+
5
+ module PgSqlTriggers
6
+ class Migrator
7
+ # Pre-apply comparator that extracts expected SQL from migrations
8
+ # and compares it with the current database state
9
+ # rubocop:disable Metrics/ClassLength
10
+ class PreApplyComparator
11
+ class << self
12
+ # Compare expected state from migration with actual database state
13
+ # Returns a comparison result with diff information
14
+ def compare(migration_instance, direction: :up)
15
+ expected = extract_expected_state(migration_instance, direction)
16
+ actual = extract_actual_state(expected)
17
+ generate_diff(expected, actual)
18
+ end
19
+
20
+ private
21
+
22
+ # Extract expected SQL and state from migration instance
23
+ def extract_expected_state(migration_instance, direction)
24
+ captured_sql = capture_sql(migration_instance, direction)
25
+ parse_sql_to_state(captured_sql)
26
+ end
27
+
28
+ # Capture SQL that would be executed by the migration
29
+ def capture_sql(migration_instance, direction)
30
+ captured = []
31
+
32
+ # Override execute to capture SQL instead of executing
33
+ # Since we use a separate instance for comparison, we don't need to restore
34
+ migration_instance.define_singleton_method(:execute) do |sql|
35
+ captured << sql.to_s.strip
36
+ end
37
+
38
+ # Call the migration method (up or down) to capture SQL
39
+ migration_instance.public_send(direction)
40
+
41
+ captured
42
+ end
43
+
44
+ # Parse captured SQL into structured state (triggers, functions)
45
+ def parse_sql_to_state(sql_array)
46
+ state = {
47
+ functions: [],
48
+ triggers: []
49
+ }
50
+
51
+ sql_array.each do |sql|
52
+ sql_normalized = sql.squish
53
+
54
+ # Parse CREATE FUNCTION statements
55
+ if sql_normalized.match?(/CREATE\s+(OR\s+REPLACE\s+)?FUNCTION/i)
56
+ function_info = parse_function_sql(sql)
57
+ state[:functions] << function_info if function_info
58
+ end
59
+
60
+ # Parse CREATE TRIGGER statements
61
+ if sql_normalized.match?(/CREATE\s+TRIGGER/i)
62
+ trigger_info = parse_trigger_sql(sql)
63
+ state[:triggers] << trigger_info if trigger_info
64
+ end
65
+
66
+ # Parse DROP statements (for down migrations)
67
+ next unless sql_normalized.match?(/DROP\s+(TRIGGER|FUNCTION)/i)
68
+
69
+ drop_info = parse_drop_sql(sql)
70
+ state[:drops] ||= []
71
+ state[:drops] << drop_info if drop_info
72
+ end
73
+
74
+ state
75
+ end
76
+
77
+ # Parse function SQL to extract function name and body
78
+ def parse_function_sql(sql)
79
+ # Match CREATE [OR REPLACE] FUNCTION function_name(...) ... AS $$ body $$
80
+ match = sql.match(/CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(\w+)\s*\([^)]*\)/i)
81
+ return nil unless match
82
+
83
+ function_name = match[1]
84
+
85
+ # Extract function body (between $$ markers or AS ...)
86
+ body_match = sql.match(/\$\$(.+?)\$\$/m) || sql.match(/AS\s+(.+)/im)
87
+ function_body = body_match ? body_match[1].strip : sql
88
+
89
+ {
90
+ function_name: function_name,
91
+ function_body: function_body,
92
+ full_sql: sql
93
+ }
94
+ end
95
+
96
+ # Parse trigger SQL to extract trigger details
97
+ def parse_trigger_sql(sql)
98
+ # Match CREATE TRIGGER trigger_name BEFORE/AFTER events ON table_name ...
99
+ match = sql.match(/CREATE\s+TRIGGER\s+(\w+)\s+(BEFORE|AFTER)\s+(.+?)\s+ON\s+(\w+)/i)
100
+ return nil unless match
101
+
102
+ trigger_name = match[1]
103
+ timing = match[2]
104
+ events = match[3].strip.split(/\s+OR\s+/i).map(&:strip)
105
+ table_name = match[4]
106
+
107
+ # Extract WHEN condition if present
108
+ condition_match = sql.match(/WHEN\s+\(([^)]+)\)/i)
109
+ condition = condition_match ? condition_match[1].strip : nil
110
+
111
+ # Extract function name
112
+ function_match = sql.match(/EXECUTE\s+FUNCTION\s+(\w+)\s*\(\)/i)
113
+ function_name = function_match ? function_match[1] : nil
114
+
115
+ {
116
+ trigger_name: trigger_name,
117
+ table_name: table_name,
118
+ timing: timing,
119
+ events: events,
120
+ condition: condition,
121
+ function_name: function_name,
122
+ full_sql: sql
123
+ }
124
+ end
125
+
126
+ # Parse DROP SQL statements
127
+ def parse_drop_sql(sql)
128
+ if sql.match?(/DROP\s+TRIGGER/i)
129
+ match = sql.match(/DROP\s+TRIGGER\s+(?:IF\s+EXISTS\s+)?(\w+)\s+ON\s+(\w+)/i)
130
+ return nil unless match
131
+
132
+ {
133
+ type: :trigger,
134
+ name: match[1],
135
+ table_name: match[2]
136
+ }
137
+ elsif sql.match?(/DROP\s+FUNCTION/i)
138
+ match = sql.match(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(\w+)\s*\(\)/i)
139
+ return nil unless match
140
+
141
+ {
142
+ type: :function,
143
+ name: match[1]
144
+ }
145
+ end
146
+ end
147
+
148
+ # Extract actual state from database
149
+ def extract_actual_state(expected)
150
+ actual = {
151
+ functions: {},
152
+ triggers: {}
153
+ }
154
+
155
+ # Get actual functions from database
156
+ expected[:functions].each do |expected_func|
157
+ db_func = Drift::DbQueries.find_function(expected_func[:function_name])
158
+ actual[:functions][expected_func[:function_name]] = if db_func
159
+ {
160
+ function_name: db_func["function_name"],
161
+ function_body: db_func["function_definition"],
162
+ exists: true
163
+ }
164
+ else
165
+ {
166
+ function_name: expected_func[:function_name],
167
+ exists: false
168
+ }
169
+ end
170
+ end
171
+
172
+ # Get actual triggers from database
173
+ expected[:triggers].each do |expected_trigger|
174
+ db_trigger = Drift::DbQueries.find_trigger(expected_trigger[:trigger_name])
175
+ if db_trigger
176
+ actual[:triggers][expected_trigger[:trigger_name]] = {
177
+ trigger_name: db_trigger["trigger_name"],
178
+ table_name: db_trigger["table_name"],
179
+ function_name: db_trigger["function_name"],
180
+ trigger_definition: db_trigger["trigger_definition"],
181
+ function_definition: db_trigger["function_definition"],
182
+ exists: true
183
+ }
184
+ else
185
+ actual[:triggers][expected_trigger[:trigger_name]] = {
186
+ trigger_name: expected_trigger[:trigger_name],
187
+ table_name: expected_trigger[:table_name],
188
+ exists: false
189
+ }
190
+ end
191
+ end
192
+
193
+ actual
194
+ end
195
+
196
+ # Generate diff between expected and actual state
197
+ # rubocop:disable Metrics/MethodLength
198
+ def generate_diff(expected, actual)
199
+ diff = {
200
+ has_differences: false,
201
+ functions: [],
202
+ triggers: [],
203
+ drops: expected[:drops] || []
204
+ }
205
+
206
+ # Compare functions
207
+ expected[:functions].each do |expected_func|
208
+ func_name = expected_func[:function_name]
209
+ actual_func = actual[:functions][func_name]
210
+
211
+ if !actual_func || !actual_func[:exists]
212
+ diff[:functions] << {
213
+ function_name: func_name,
214
+ status: :new,
215
+ expected: expected_func[:function_body],
216
+ actual: nil,
217
+ message: "Function will be created"
218
+ }
219
+ diff[:has_differences] = true
220
+ elsif actual_func[:function_body] != expected_func[:function_body]
221
+ diff[:functions] << {
222
+ function_name: func_name,
223
+ status: :modified,
224
+ expected: expected_func[:function_body],
225
+ actual: actual_func[:function_body],
226
+ message: "Function body differs from expected"
227
+ }
228
+ diff[:has_differences] = true
229
+ else
230
+ diff[:functions] << {
231
+ function_name: func_name,
232
+ status: :unchanged,
233
+ message: "Function matches expected state"
234
+ }
235
+ end
236
+ end
237
+
238
+ # Compare triggers
239
+ expected[:triggers].each do |expected_trigger|
240
+ trigger_name = expected_trigger[:trigger_name]
241
+ actual_trigger = actual[:triggers][trigger_name]
242
+
243
+ if !actual_trigger || !actual_trigger[:exists]
244
+ diff[:triggers] << {
245
+ trigger_name: trigger_name,
246
+ status: :new,
247
+ expected: expected_trigger[:full_sql],
248
+ actual: nil,
249
+ message: "Trigger will be created"
250
+ }
251
+ diff[:has_differences] = true
252
+ else
253
+ # Compare trigger definitions
254
+ expected_def = normalize_trigger_definition(expected_trigger)
255
+ actual_def = normalize_trigger_definition_from_db(actual_trigger)
256
+
257
+ if expected_def == actual_def
258
+ diff[:triggers] << {
259
+ trigger_name: trigger_name,
260
+ status: :unchanged,
261
+ message: "Trigger matches expected state"
262
+ }
263
+ else
264
+ diff[:triggers] << {
265
+ trigger_name: trigger_name,
266
+ status: :modified,
267
+ expected: expected_trigger[:full_sql],
268
+ actual: actual_trigger[:trigger_definition],
269
+ message: "Trigger definition differs from expected",
270
+ differences: compare_trigger_details(expected_trigger, actual_trigger)
271
+ }
272
+ diff[:has_differences] = true
273
+ end
274
+ end
275
+ end
276
+
277
+ diff
278
+ end
279
+ # rubocop:enable Metrics/MethodLength
280
+
281
+ # Normalize trigger definition for comparison
282
+ def normalize_trigger_definition(trigger)
283
+ {
284
+ trigger_name: trigger[:trigger_name],
285
+ table_name: trigger[:table_name],
286
+ events: trigger[:events].sort,
287
+ condition: trigger[:condition],
288
+ function_name: trigger[:function_name]
289
+ }
290
+ end
291
+
292
+ # Normalize trigger definition from database for comparison
293
+ def normalize_trigger_definition_from_db(db_trigger)
294
+ # Parse trigger definition from pg_get_triggerdef output
295
+ def_str = db_trigger[:trigger_definition] || ""
296
+
297
+ # Extract events, condition, etc. from definition string
298
+ events_match = def_str.match(/BEFORE\s+(.+?)\s+ON/i) || def_str.match(/AFTER\s+(.+?)\s+ON/i)
299
+ events = events_match ? events_match[1].split(/\s+OR\s+/i).map(&:strip).sort : []
300
+
301
+ condition_match = def_str.match(/WHEN\s+\(([^)]+)\)/i)
302
+ condition = condition_match ? condition_match[1].strip : nil
303
+
304
+ {
305
+ trigger_name: db_trigger[:trigger_name],
306
+ table_name: db_trigger[:table_name],
307
+ events: events,
308
+ condition: condition,
309
+ function_name: db_trigger[:function_name]
310
+ }
311
+ end
312
+
313
+ # Compare trigger details to find specific differences
314
+ def compare_trigger_details(expected, actual_db_trigger)
315
+ differences = []
316
+
317
+ # Normalize actual trigger for comparison
318
+ actual = normalize_trigger_definition_from_db(actual_db_trigger)
319
+
320
+ if expected[:table_name] != actual[:table_name]
321
+ differences << "Table name: expected '#{expected[:table_name]}', actual '#{actual[:table_name]}'"
322
+ end
323
+
324
+ expected_events = expected[:events].sort
325
+ actual_events = actual[:events] || []
326
+ if expected_events != actual_events.sort
327
+ differences << "Events: expected #{expected_events.inspect}, actual #{actual_events.inspect}"
328
+ end
329
+
330
+ if expected[:condition] != actual[:condition]
331
+ differences << "Condition: expected '#{expected[:condition]}', actual '#{actual[:condition]}'"
332
+ end
333
+
334
+ if expected[:function_name] != actual[:function_name]
335
+ differences << "Function: expected '#{expected[:function_name]}', actual '#{actual[:function_name]}'"
336
+ end
337
+
338
+ differences
339
+ end
340
+ end
341
+ end
342
+ # rubocop:enable Metrics/ClassLength
343
+ end
344
+ end