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,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module SQL
5
+ # KillSwitch is a centralized safety gate that prevents dangerous operations
6
+ # from being executed in protected environments (typically production).
7
+ #
8
+ # It operates on three levels:
9
+ # 1. Configuration Level: Environment-based activation via PgSqlTriggers.kill_switch_enabled
10
+ # 2. Runtime Level: ENV variable override support (KILL_SWITCH_OVERRIDE)
11
+ # 3. Explicit Confirmation Level: Typed confirmation text for critical operations
12
+ #
13
+ # @example Basic usage in a dangerous operation
14
+ # KillSwitch.check!(
15
+ # operation: :migrate_up,
16
+ # environment: Rails.env,
17
+ # confirmation: params[:confirmation_text],
18
+ # actor: { type: 'UI', id: current_user.email }
19
+ # )
20
+ #
21
+ # @example Using override block
22
+ # KillSwitch.override(confirmation: "EXECUTE MIGRATE_UP") do
23
+ # # dangerous operation here
24
+ # end
25
+ #
26
+ # @example CLI usage with ENV override
27
+ # KILL_SWITCH_OVERRIDE=true CONFIRMATION_TEXT="EXECUTE MIGRATE_UP" rake pg_sql_triggers:migrate
28
+ #
29
+ # rubocop:disable Metrics/ModuleLength
30
+ module KillSwitch
31
+ # Thread-local storage key for override state
32
+ OVERRIDE_KEY = :pg_sql_triggers_kill_switch_override
33
+
34
+ class << self
35
+ # Checks if the kill switch is active for the given environment and operation.
36
+ #
37
+ # @param environment [String, Symbol, nil] The environment to check (defaults to current environment)
38
+ # @param operation [String, Symbol, nil] The operation being performed (for logging)
39
+ # @return [Boolean] true if kill switch is active, false otherwise
40
+ def active?(environment: nil, operation: nil)
41
+ # Check if kill switch is globally disabled
42
+ return false unless kill_switch_enabled?
43
+
44
+ # Detect environment
45
+ env = detect_environment(environment)
46
+
47
+ # Check if this environment is protected
48
+ is_active = protected_environment?(env)
49
+
50
+ # Log the check if logger is available and operation is provided
51
+ if logger && operation
52
+ logger.debug "[KILL_SWITCH] Check: operation=#{operation} environment=#{env} active=#{is_active}"
53
+ end
54
+
55
+ is_active
56
+ end
57
+
58
+ # Checks if an operation should be blocked by the kill switch.
59
+ # Raises KillSwitchError if the operation is blocked.
60
+ #
61
+ # @param operation [String, Symbol] The operation being performed
62
+ # @param environment [String, Symbol, nil] The environment (defaults to current)
63
+ # @param confirmation [String, nil] The confirmation text provided by the user
64
+ # @param actor [Hash, nil] Information about who is performing the operation
65
+ # @raise [PgSqlTriggers::KillSwitchError] if the operation is blocked
66
+ # @return [void]
67
+ def check!(operation:, environment: nil, confirmation: nil, actor: nil)
68
+ env = detect_environment(environment)
69
+
70
+ # Check if kill switch is active for this environment
71
+ unless active?(environment: env, operation: operation)
72
+ log_allowed(operation: operation, environment: env, actor: actor, reason: "not_protected_environment")
73
+ return
74
+ end
75
+
76
+ # Check for thread-local override
77
+ if thread_override_active?
78
+ log_override(operation: operation, environment: env, actor: actor, source: "thread_local")
79
+ return
80
+ end
81
+
82
+ # Check for ENV override
83
+ if env_override_active?
84
+ # If ENV override is present, check confirmation if required
85
+ if confirmation_required?
86
+ validate_confirmation!(confirmation, operation)
87
+ log_override(operation: operation, environment: env, actor: actor, source: "env_with_confirmation",
88
+ confirmation: confirmation)
89
+ else
90
+ log_override(operation: operation, environment: env, actor: actor, source: "env_without_confirmation")
91
+ end
92
+ return
93
+ end
94
+
95
+ # If confirmation is provided, validate it
96
+ unless confirmation.nil?
97
+ validate_confirmation!(confirmation, operation)
98
+ log_override(operation: operation, environment: env, actor: actor, source: "explicit_confirmation",
99
+ confirmation: confirmation)
100
+ return
101
+ end
102
+
103
+ # No override mechanism satisfied - block the operation
104
+ log_blocked(operation: operation, environment: env, actor: actor)
105
+ raise_blocked_error(operation: operation, environment: env)
106
+ end
107
+
108
+ # Temporarily overrides the kill switch for the duration of the block.
109
+ # Uses thread-local storage to ensure thread safety.
110
+ #
111
+ # @param confirmation [String, nil] Optional confirmation text
112
+ # @yield The block to execute with kill switch overridden
113
+ # @return The return value of the block
114
+ def override(confirmation: nil)
115
+ raise ArgumentError, "Block required for kill switch override" unless block_given?
116
+
117
+ # Validate confirmation if provided and required
118
+ if confirmation.present? && confirmation_required?
119
+ # NOTE: We can't validate against a specific operation here since we don't know it
120
+ # The block itself will call check! with the operation, which will see the override
121
+ logger&.info "[KILL_SWITCH] Override block initiated with confirmation: #{confirmation}"
122
+ end
123
+
124
+ # Set thread-local override
125
+ previous_value = Thread.current[OVERRIDE_KEY]
126
+ Thread.current[OVERRIDE_KEY] = true
127
+
128
+ begin
129
+ yield
130
+ ensure
131
+ # Restore previous value
132
+ Thread.current[OVERRIDE_KEY] = previous_value
133
+ end
134
+ end
135
+
136
+ # Validates the confirmation text against the expected pattern for the operation.
137
+ #
138
+ # @param confirmation [String, nil] The confirmation text to validate
139
+ # @param operation [String, Symbol] The operation being confirmed
140
+ # @raise [PgSqlTriggers::KillSwitchError] if confirmation is invalid
141
+ # @return [void]
142
+ def validate_confirmation!(confirmation, operation)
143
+ expected = expected_confirmation(operation)
144
+
145
+ if confirmation.nil? || confirmation.strip.empty?
146
+ raise PgSqlTriggers::KillSwitchError,
147
+ "Confirmation text required. Expected: '#{expected}'"
148
+ end
149
+
150
+ return if confirmation.strip == expected
151
+
152
+ raise PgSqlTriggers::KillSwitchError,
153
+ "Invalid confirmation text. Expected: '#{expected}', got: '#{confirmation.strip}'"
154
+ end
155
+
156
+ private
157
+
158
+ # Checks if kill switch is globally enabled in configuration
159
+ def kill_switch_enabled?
160
+ return true unless PgSqlTriggers.respond_to?(:kill_switch_enabled)
161
+
162
+ # Default to true (fail-safe) if not configured
163
+ value = PgSqlTriggers.kill_switch_enabled
164
+ value.nil? || value
165
+ end
166
+
167
+ # Checks if the given environment is protected by the kill switch
168
+ def protected_environment?(environment)
169
+ return false if environment.nil?
170
+
171
+ protected_envs = if PgSqlTriggers.respond_to?(:kill_switch_environments)
172
+ value = PgSqlTriggers.kill_switch_environments
173
+ value.nil? ? %i[production staging] : value
174
+ else
175
+ %i[production staging]
176
+ end
177
+
178
+ protected_envs = Array(protected_envs).map(&:to_s)
179
+ protected_envs.include?(environment.to_s)
180
+ end
181
+
182
+ # Detects the current environment
183
+ def detect_environment(environment)
184
+ return environment.to_s if environment.present?
185
+
186
+ # Try Rails environment
187
+ return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
188
+
189
+ # Try PgSqlTriggers default_environment
190
+ if PgSqlTriggers.respond_to?(:default_environment) && PgSqlTriggers.default_environment.respond_to?(:call)
191
+ begin
192
+ return PgSqlTriggers.default_environment.call.to_s
193
+ rescue NameError, NoMethodError # rubocop:disable Lint/ShadowedException
194
+ # Fall through to next option if default_environment proc references undefined constants
195
+ end
196
+ end
197
+
198
+ # Fall back to RAILS_ENV or RACK_ENV
199
+ ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
200
+ end
201
+
202
+ # Checks if thread-local override is active
203
+ def thread_override_active?
204
+ Thread.current[OVERRIDE_KEY] == true
205
+ end
206
+
207
+ # Checks if ENV override is active
208
+ def env_override_active?
209
+ ENV["KILL_SWITCH_OVERRIDE"]&.downcase == "true"
210
+ end
211
+
212
+ # Checks if confirmation is required for overrides
213
+ def confirmation_required?
214
+ return true unless PgSqlTriggers.respond_to?(:kill_switch_confirmation_required)
215
+
216
+ # Default to true (safer) if not configured
217
+ value = PgSqlTriggers.kill_switch_confirmation_required
218
+ value.nil? || value
219
+ end
220
+
221
+ # Generates the expected confirmation text for an operation
222
+ def expected_confirmation(operation)
223
+ if PgSqlTriggers.respond_to?(:kill_switch_confirmation_pattern) &&
224
+ PgSqlTriggers.kill_switch_confirmation_pattern.respond_to?(:call)
225
+ PgSqlTriggers.kill_switch_confirmation_pattern.call(operation)
226
+ else
227
+ # Default pattern
228
+ "EXECUTE #{operation.to_s.upcase}"
229
+ end
230
+ end
231
+
232
+ # Returns the configured logger
233
+ def logger
234
+ if PgSqlTriggers.respond_to?(:kill_switch_logger)
235
+ PgSqlTriggers.kill_switch_logger
236
+ elsif defined?(Rails) && Rails.respond_to?(:logger)
237
+ Rails.logger
238
+ end
239
+ end
240
+
241
+ # Logs an allowed operation
242
+ def log_allowed(operation:, environment:, actor:, reason:)
243
+ actor_info = format_actor(actor)
244
+ logger&.info "[KILL_SWITCH] ALLOWED: operation=#{operation} environment=#{environment} " \
245
+ "actor=#{actor_info} reason=#{reason}"
246
+ end
247
+
248
+ # Logs an overridden operation
249
+ def log_override(operation:, environment:, actor:, source:, confirmation: nil)
250
+ actor_info = format_actor(actor)
251
+ conf_info = confirmation ? " confirmation=#{confirmation}" : ""
252
+ logger&.warn "[KILL_SWITCH] OVERRIDDEN: operation=#{operation} environment=#{environment} " \
253
+ "actor=#{actor_info} source=#{source}#{conf_info}"
254
+ end
255
+
256
+ # Logs a blocked operation
257
+ def log_blocked(operation:, environment:, actor:)
258
+ actor_info = format_actor(actor)
259
+ logger&.error "[KILL_SWITCH] BLOCKED: operation=#{operation} environment=#{environment} actor=#{actor_info}"
260
+ end
261
+
262
+ # Formats actor information for logging
263
+ def format_actor(actor)
264
+ return "unknown" if actor.nil?
265
+ return actor.to_s unless actor.is_a?(Hash)
266
+
267
+ "#{actor[:type] || 'unknown'}:#{actor[:id] || 'unknown'}"
268
+ end
269
+
270
+ # Raises a kill switch error with helpful message
271
+ def raise_blocked_error(operation:, environment:)
272
+ expected = expected_confirmation(operation)
273
+
274
+ message = <<~ERROR
275
+ Kill switch is active for #{environment} environment.
276
+ Operation '#{operation}' has been blocked for safety.
277
+
278
+ To override this protection, you must provide confirmation.
279
+
280
+ For CLI/rake tasks, use:
281
+ KILL_SWITCH_OVERRIDE=true CONFIRMATION_TEXT="#{expected}" rake your:task
282
+
283
+ For console operations, use:
284
+ PgSqlTriggers::SQL::KillSwitch.override(confirmation: "#{expected}") do
285
+ # your dangerous operation here
286
+ end
287
+
288
+ For UI operations, enter the confirmation text: #{expected}
289
+
290
+ This protection prevents accidental destructive operations in production.
291
+ Make sure you understand the implications before proceeding.
292
+ ERROR
293
+
294
+ raise PgSqlTriggers::KillSwitchError, message
295
+ end
296
+ end
297
+ end
298
+ # rubocop:enable Metrics/ModuleLength
299
+ end
300
+ end
@@ -27,14 +27,12 @@ module PgSqlTriggers
27
27
  trigger_timing = "BEFORE" # Could be configurable
28
28
  trigger_level = "ROW" # Could be configurable
29
29
 
30
- trigger_sql = <<~SQL.squish
31
- CREATE TRIGGER #{@trigger.trigger_name}
32
- #{trigger_timing} #{events} ON #{@trigger.table_name}
33
- FOR EACH #{trigger_level}
34
- SQL
30
+ trigger_sql = "CREATE TRIGGER #{@trigger.trigger_name} " \
31
+ "#{trigger_timing} #{events} ON #{@trigger.table_name} " \
32
+ "FOR EACH #{trigger_level}"
35
33
 
36
- trigger_sql += "WHEN (#{@trigger.condition})\n" if @trigger.condition.present?
37
- trigger_sql += "EXECUTE FUNCTION #{definition['function_name']}();"
34
+ trigger_sql += " WHEN (#{@trigger.condition})" if @trigger.condition.present?
35
+ trigger_sql += " EXECUTE FUNCTION #{definition['function_name']}();"
38
36
 
39
37
  sql_parts << {
40
38
  type: "CREATE TRIGGER",
@@ -8,6 +8,7 @@ module PgSqlTriggers
8
8
  end
9
9
 
10
10
  # Test ONLY the function, not the trigger
11
+ # rubocop:disable Lint/UnusedMethodArgument
11
12
  def test_function_only(test_context: {})
12
13
  results = {
13
14
  function_created: false,
@@ -16,15 +17,44 @@ module PgSqlTriggers
16
17
  output: []
17
18
  }
18
19
 
20
+ # Check if function_body is present
21
+ if @trigger.function_body.blank?
22
+ results[:success] = false
23
+ results[:errors] << "Function body is missing"
24
+ return results
25
+ end
26
+
27
+ # Extract function name to verify it matches
28
+ function_name_from_body = nil
29
+ if @trigger.function_body.present?
30
+ pattern = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i
31
+ match = @trigger.function_body.match(pattern)
32
+ function_name_from_body = match[1] if match
33
+ end
34
+
35
+ # If function_body doesn't contain a valid function definition, fail early
36
+ unless function_name_from_body
37
+ results[:success] = false
38
+ results[:errors] << "Function body does not contain a valid CREATE FUNCTION statement"
39
+ return results
40
+ end
41
+
42
+ # rubocop:disable Metrics/BlockLength
19
43
  ActiveRecord::Base.transaction do
20
44
  # Create function in transaction
21
- ActiveRecord::Base.connection.execute(@trigger.function_body)
22
- results[:function_created] = true
23
- results[:output] << "✓ Function created in test transaction"
45
+ begin
46
+ ActiveRecord::Base.connection.execute(@trigger.function_body)
47
+ results[:function_created] = true
48
+ results[:output] << "✓ Function created in test transaction"
49
+ rescue ActiveRecord::StatementInvalid, StandardError => e
50
+ results[:success] = false
51
+ results[:errors] << "Error during function creation: #{e.message}"
52
+ # Don't raise here, let it fall through to ensure block for rollback
53
+ end
24
54
 
25
55
  # Try to invoke function directly (if test context provided)
26
56
  # Note: Empty hash {} is not "present" in Rails, so check if it's not nil
27
- if !test_context.nil? && results[:function_created]
57
+ if results[:function_created]
28
58
  # This would require custom invocation logic
29
59
  # For now, just verify it was created - if function was successfully created,
30
60
  # we can assume it exists and is executable within the transaction
@@ -51,40 +81,50 @@ module PgSqlTriggers
51
81
  end
52
82
 
53
83
  # Verify function exists in database by checking pg_proc
54
- # Since the function was created successfully (function_created is true),
55
- # it exists and is executable
56
- results[:function_executed] = true
57
-
58
84
  # Try to verify via query if function_name is available
59
85
  if function_name.present?
60
- sanitized_name = ActiveRecord::Base.connection.quote_string(function_name)
61
- check_sql = <<~SQL.squish
62
- SELECT COUNT(*) as count
63
- FROM pg_proc p
64
- JOIN pg_namespace n ON p.pronamespace = n.oid
65
- WHERE p.proname = '#{sanitized_name}'
66
- AND n.nspname = 'public'
67
- SQL
68
-
69
86
  begin
87
+ sanitized_name = begin
88
+ ActiveRecord::Base.connection.quote_string(function_name)
89
+ rescue StandardError => e
90
+ # If quote_string fails, use the function name as-is (less safe but allows test to continue)
91
+ results[:errors] << "Error during function name sanitization: #{e.message}"
92
+ function_name
93
+ end
94
+ check_sql = <<~SQL.squish
95
+ SELECT COUNT(*) as count
96
+ FROM pg_proc p
97
+ JOIN pg_namespace n ON p.pronamespace = n.oid
98
+ WHERE p.proname = '#{sanitized_name}'
99
+ AND n.nspname = 'public'
100
+ SQL
101
+
70
102
  result = ActiveRecord::Base.connection.execute(check_sql).first
71
- results[:output] << if result && result["count"].to_i.positive?
103
+ results[:function_executed] = result && result["count"].to_i.positive?
104
+ results[:output] << if results[:function_executed]
72
105
  "✓ Function exists and is callable"
73
106
  else
74
107
  "✓ Function created (verified via successful creation)"
75
108
  end
76
- rescue StandardError
77
- results[:output] << "✓ Function created (verified via successful creation)"
109
+ rescue ActiveRecord::StatementInvalid, StandardError => e
110
+ results[:function_executed] = false
111
+ results[:success] = false
112
+ results[:errors] << "Error during function verification: #{e.message}"
113
+ results[:output] << "✓ Function created (verification failed)"
78
114
  end
79
115
  else
116
+ # If we can't extract function name, assume it was created successfully
117
+ # since function_created is true
118
+ results[:function_executed] = true
80
119
  results[:output] << "✓ Function created (execution verified via successful creation)"
81
120
  end
82
121
  end
83
122
 
84
- results[:success] = true
85
- rescue ActiveRecord::StatementInvalid => e
123
+ # Set success to true only if no errors occurred and function was created
124
+ results[:success] = results[:errors].empty? && results[:function_created]
125
+ rescue ActiveRecord::StatementInvalid, StandardError => e
86
126
  results[:success] = false
87
- results[:errors] << e.message
127
+ results[:errors] << e.message unless results[:errors].include?(e.message)
88
128
  ensure
89
129
  raise ActiveRecord::Rollback
90
130
  end
@@ -92,6 +132,7 @@ module PgSqlTriggers
92
132
  results[:output] << "\n⚠ Function rolled back (test mode)"
93
133
  results
94
134
  end
135
+ # rubocop:enable Lint/UnusedMethodArgument, Metrics/BlockLength
95
136
 
96
137
  # Check if function already exists in database
97
138
  def function_exists?
@@ -100,7 +141,8 @@ module PgSqlTriggers
100
141
  rescue StandardError
101
142
  {}
102
143
  end
103
- function_name = definition["function_name"]
144
+ function_name = definition["function_name"] || definition["name"] ||
145
+ definition[:function_name] || definition[:name]
104
146
  return false if function_name.blank?
105
147
 
106
148
  sanitized_name = ActiveRecord::Base.connection.quote_string(function_name)
@@ -26,21 +26,33 @@ module PgSqlTriggers
26
26
  end
27
27
 
28
28
  # Step 2: Create trigger
29
- trigger_sql = DryRun.new(@trigger).generate_sql[:sql_parts]
30
- .find { |p| p[:type] == "CREATE TRIGGER" }[:sql]
31
- ActiveRecord::Base.connection.execute(trigger_sql)
32
- results[:trigger_created] = true
33
- results[:output] << "✓ Trigger created successfully"
29
+ begin
30
+ sql_parts = DryRun.new(@trigger).generate_sql[:sql_parts]
31
+ trigger_part = sql_parts.find { |p| p[:type] == "CREATE TRIGGER" }
32
+ if trigger_part && trigger_part[:sql]
33
+ ActiveRecord::Base.connection.execute(trigger_part[:sql])
34
+ results[:trigger_created] = true
35
+ results[:output] << "✓ Trigger created successfully"
36
+ else
37
+ results[:errors] << "Could not find CREATE TRIGGER SQL in generated SQL parts"
38
+ end
39
+ rescue StandardError => e
40
+ results[:errors] << "Error generating trigger SQL: #{e.message}"
41
+ end
34
42
 
35
43
  # Step 3: Test with sample data (if provided)
36
- if test_data
37
- test_sql = build_test_insert(test_data)
38
- ActiveRecord::Base.connection.execute(test_sql)
39
- results[:test_insert_executed] = true
40
- results[:output] << "✓ Test insert executed successfully"
44
+ if test_data && results[:trigger_created]
45
+ begin
46
+ test_sql = build_test_insert(test_data)
47
+ ActiveRecord::Base.connection.execute(test_sql)
48
+ results[:test_insert_executed] = true
49
+ results[:output] << "✓ Test insert executed successfully"
50
+ rescue StandardError => e
51
+ results[:errors] << "Error executing test insert: #{e.message}"
52
+ end
41
53
  end
42
54
 
43
- results[:success] = true
55
+ results[:success] = results[:errors].empty?
44
56
  rescue ActiveRecord::StatementInvalid => e
45
57
  results[:success] = false
46
58
  results[:errors] << e.message
@@ -65,10 +65,33 @@ module PgSqlTriggers
65
65
  {}
66
66
  end
67
67
  function_name = definition["function_name"] || "test_validation_function"
68
+ events = Array(definition["events"] || [])
68
69
  sanitized_table = ActiveRecord::Base.connection.quote_string(@trigger.table_name)
69
70
  sanitized_function = ActiveRecord::Base.connection.quote_string(function_name)
70
71
  sanitized_condition = @trigger.condition
71
72
 
73
+ # Check if condition references OLD values
74
+ condition_uses_old = sanitized_condition.match?(/\bOLD\./i)
75
+
76
+ # Determine which event to use for validation
77
+ # If condition uses OLD, we must use UPDATE or DELETE since INSERT doesn't have OLD
78
+ # If condition doesn't use OLD, we can use INSERT
79
+ if condition_uses_old
80
+ # Condition references OLD, so it can't be used with INSERT
81
+ if events.include?("insert")
82
+ return {
83
+ valid: false,
84
+ error: "WHEN condition cannot reference OLD values for INSERT triggers. " \
85
+ "Use UPDATE or DELETE events, or modify condition to only use NEW values."
86
+ }
87
+ end
88
+ # Use UPDATE for validation if available (it has OLD), otherwise use DELETE
89
+ test_event = events.include?("update") ? "UPDATE" : "DELETE"
90
+ else
91
+ # Condition doesn't reference OLD, so INSERT is fine
92
+ test_event = "INSERT"
93
+ end
94
+
72
95
  # Validate condition by creating a temporary trigger with the condition
73
96
  # This is the only way to validate WHEN conditions since they use NEW/OLD
74
97
  test_function_sql = <<~SQL.squish
@@ -81,7 +104,7 @@ module PgSqlTriggers
81
104
 
82
105
  test_trigger_sql = <<~SQL.squish
83
106
  CREATE TRIGGER test_validation_trigger
84
- BEFORE INSERT ON #{sanitized_table}
107
+ BEFORE #{test_event} ON #{sanitized_table}
85
108
  FOR EACH ROW
86
109
  WHEN (#{sanitized_condition})
87
110
  EXECUTE FUNCTION #{sanitized_function}();
@@ -11,5 +11,5 @@
11
11
  # 3. Run: bundle exec rake release
12
12
  # See RELEASE.md for detailed release instructions
13
13
  module PgSqlTriggers
14
- VERSION = "1.0.0"
14
+ VERSION = "1.1.0"
15
15
  end
@@ -9,11 +9,24 @@ module PgSqlTriggers
9
9
  class DriftError < Error; end
10
10
  class KillSwitchError < Error; end
11
11
  class ValidationError < Error; end
12
+ class UnsafeMigrationError < Error; end
12
13
 
13
14
  # Configuration
14
15
  mattr_accessor :kill_switch_enabled
15
16
  self.kill_switch_enabled = true
16
17
 
18
+ mattr_accessor :kill_switch_environments
19
+ self.kill_switch_environments = %i[production staging]
20
+
21
+ mattr_accessor :kill_switch_confirmation_required
22
+ self.kill_switch_confirmation_required = true
23
+
24
+ mattr_accessor :kill_switch_confirmation_pattern
25
+ self.kill_switch_confirmation_pattern = ->(operation) { "EXECUTE #{operation.to_s.upcase}" }
26
+
27
+ mattr_accessor :kill_switch_logger
28
+ self.kill_switch_logger = nil # Will default to Rails.logger if available
29
+
17
30
  mattr_accessor :default_environment
18
31
  self.default_environment = -> { Rails.env }
19
32
 
@@ -23,6 +36,17 @@ module PgSqlTriggers
23
36
  mattr_accessor :excluded_tables
24
37
  self.excluded_tables = []
25
38
 
39
+ mattr_accessor :allow_unsafe_migrations
40
+ self.allow_unsafe_migrations = false
41
+
42
+ # Drift states
43
+ DRIFT_STATE_IN_SYNC = "in_sync"
44
+ DRIFT_STATE_DRIFTED = "drifted"
45
+ DRIFT_STATE_MANUAL_OVERRIDE = "manual_override"
46
+ DRIFT_STATE_DISABLED = "disabled"
47
+ DRIFT_STATE_DROPPED = "dropped"
48
+ DRIFT_STATE_UNKNOWN = "unknown"
49
+
26
50
  def self.configure
27
51
  yield self
28
52
  end