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.
- checksums.yaml +4 -4
- data/.erb_lint.yml +47 -0
- data/.rubocop.yml +4 -1
- data/CHANGELOG.md +112 -1
- data/COVERAGE.md +58 -0
- data/Goal.md +450 -123
- data/README.md +53 -215
- data/app/controllers/pg_sql_triggers/application_controller.rb +46 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -1
- data/app/controllers/pg_sql_triggers/generator_controller.rb +76 -8
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +18 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +93 -12
- data/app/views/layouts/pg_sql_triggers/application.html.erb +34 -1
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +70 -30
- data/app/views/pg_sql_triggers/generator/new.html.erb +22 -4
- data/app/views/pg_sql_triggers/generator/preview.html.erb +244 -16
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +221 -0
- data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +40 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +0 -2
- data/app/views/pg_sql_triggers/tables/show.html.erb +3 -4
- data/config/initializers/pg_sql_triggers.rb +69 -0
- data/db/migrate/20251222000001_create_pg_sql_triggers_tables.rb +3 -1
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/README.md +66 -0
- data/docs/api-reference.md +681 -0
- data/docs/configuration.md +541 -0
- data/docs/getting-started.md +135 -0
- data/docs/kill-switch.md +586 -0
- data/docs/screenshots/.gitkeep +1 -0
- data/docs/screenshots/Generate Trigger.png +0 -0
- data/docs/screenshots/Triggers Page.png +0 -0
- data/docs/screenshots/kill error.png +0 -0
- data/docs/screenshots/kill modal for migration down.png +0 -0
- data/docs/usage-guide.md +493 -0
- data/docs/web-ui.md +353 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +3 -1
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +44 -2
- data/lib/pg_sql_triggers/drift/db_queries.rb +116 -0
- data/lib/pg_sql_triggers/drift/detector.rb +187 -0
- data/lib/pg_sql_triggers/drift/reporter.rb +179 -0
- data/lib/pg_sql_triggers/drift.rb +14 -11
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +15 -1
- data/lib/pg_sql_triggers/generator/form.rb +3 -1
- data/lib/pg_sql_triggers/generator/service.rb +82 -26
- data/lib/pg_sql_triggers/migration.rb +1 -1
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +344 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +143 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +258 -0
- data/lib/pg_sql_triggers/migrator.rb +85 -3
- data/lib/pg_sql_triggers/registry/manager.rb +100 -13
- data/lib/pg_sql_triggers/sql/kill_switch.rb +300 -0
- data/lib/pg_sql_triggers/testing/dry_run.rb +5 -7
- data/lib/pg_sql_triggers/testing/function_tester.rb +66 -24
- data/lib/pg_sql_triggers/testing/safe_executor.rb +23 -11
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +24 -1
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +24 -0
- data/lib/tasks/trigger_migrations.rake +33 -0
- data/scripts/generate_coverage_report.rb +129 -0
- 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 =
|
|
31
|
-
|
|
32
|
-
|
|
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})
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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[:
|
|
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[:
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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] =
|
|
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
|
|
107
|
+
BEFORE #{test_event} ON #{sanitized_table}
|
|
85
108
|
FOR EACH ROW
|
|
86
109
|
WHEN (#{sanitized_condition})
|
|
87
110
|
EXECUTE FUNCTION #{sanitized_function}();
|
data/lib/pg_sql_triggers.rb
CHANGED
|
@@ -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
|