pg_sql_triggers 1.1.1 → 1.3.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/.rubocop.yml +15 -0
- data/CHANGELOG.md +200 -0
- data/COVERAGE.md +45 -34
- data/Goal.md +276 -155
- data/README.md +56 -1
- data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +50 -0
- data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +56 -0
- data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +66 -0
- data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +117 -0
- data/app/controllers/pg_sql_triggers/application_controller.rb +10 -62
- data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +102 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +6 -1
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +62 -10
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +161 -0
- data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +147 -0
- data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
- data/app/models/pg_sql_triggers/audit_log.rb +106 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +297 -5
- data/app/views/layouts/pg_sql_triggers/application.html.erb +26 -6
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +65 -2
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +81 -0
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +85 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +76 -3
- data/app/views/pg_sql_triggers/tables/show.html.erb +49 -2
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +138 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +145 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +206 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
- data/docs/README.md +15 -5
- data/docs/api-reference.md +443 -4
- data/docs/audit-trail.md +413 -0
- data/docs/configuration.md +6 -6
- data/docs/permissions.md +369 -0
- data/docs/troubleshooting.md +486 -0
- data/docs/ui-guide.md +211 -0
- data/docs/web-ui.md +328 -40
- data/lib/pg_sql_triggers/errors.rb +245 -0
- data/lib/pg_sql_triggers/generator/service.rb +32 -0
- data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
- data/lib/pg_sql_triggers/registry/manager.rb +28 -13
- data/lib/pg_sql_triggers/registry.rb +176 -2
- data/lib/pg_sql_triggers/sql/capsule.rb +79 -0
- data/lib/pg_sql_triggers/sql/executor.rb +200 -0
- data/lib/pg_sql_triggers/sql/kill_switch.rb +33 -5
- data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +3 -6
- metadata +38 -6
- data/docs/screenshots/.gitkeep +0 -1
- 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
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
# Base error class for all PgSqlTriggers errors
|
|
5
|
+
#
|
|
6
|
+
# All errors in PgSqlTriggers inherit from this base class and include
|
|
7
|
+
# error codes for programmatic handling, standardized messages, and
|
|
8
|
+
# recovery suggestions.
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :error_code, :recovery_suggestion, :context
|
|
11
|
+
|
|
12
|
+
def initialize(message = nil, error_code: nil, recovery_suggestion: nil, context: {})
|
|
13
|
+
@context = context || {}
|
|
14
|
+
@error_code = error_code || default_error_code
|
|
15
|
+
@recovery_suggestion = recovery_suggestion || default_recovery_suggestion
|
|
16
|
+
super(message || default_message)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns a user-friendly error message suitable for UI display
|
|
20
|
+
def user_message
|
|
21
|
+
msg = message
|
|
22
|
+
msg += "\n\nRecovery: #{recovery_suggestion}" if recovery_suggestion
|
|
23
|
+
msg
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns error details as a hash for programmatic access
|
|
27
|
+
def to_h
|
|
28
|
+
{
|
|
29
|
+
error_class: self.class.name,
|
|
30
|
+
error_code: error_code,
|
|
31
|
+
message: message,
|
|
32
|
+
recovery_suggestion: recovery_suggestion,
|
|
33
|
+
context: context
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
protected
|
|
38
|
+
|
|
39
|
+
def default_error_code
|
|
40
|
+
# Convert class name to error code (e.g., "PermissionError" -> "PERMISSION_ERROR")
|
|
41
|
+
class_name = self.class.name.split("::").last
|
|
42
|
+
class_name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
|
|
43
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
44
|
+
.upcase
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def default_message
|
|
48
|
+
"An error occurred in PgSqlTriggers"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def default_recovery_suggestion
|
|
52
|
+
"Please check the logs for more details and contact support if the issue persists."
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Error raised when permission checks fail
|
|
57
|
+
#
|
|
58
|
+
# @example
|
|
59
|
+
# raise PgSqlTriggers::PermissionError.new(
|
|
60
|
+
# "Permission denied: enable_trigger requires Operator level access",
|
|
61
|
+
# error_code: "PERMISSION_DENIED",
|
|
62
|
+
# recovery_suggestion: "Contact your administrator to request Operator or Admin access",
|
|
63
|
+
# context: { action: :enable_trigger, required_role: "Operator" }
|
|
64
|
+
# )
|
|
65
|
+
class PermissionError < Error
|
|
66
|
+
def default_error_code
|
|
67
|
+
"PERMISSION_DENIED"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def default_message
|
|
71
|
+
"Permission denied for this operation"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def default_recovery_suggestion
|
|
75
|
+
if context[:required_role]
|
|
76
|
+
"This operation requires #{context[:required_role]} level access. " \
|
|
77
|
+
"Contact your administrator to request appropriate permissions."
|
|
78
|
+
else
|
|
79
|
+
"This operation requires elevated permissions. Contact your administrator."
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Error raised when kill switch blocks an operation
|
|
85
|
+
#
|
|
86
|
+
# @example
|
|
87
|
+
# raise PgSqlTriggers::KillSwitchError.new(
|
|
88
|
+
# "Kill switch is active for production environment",
|
|
89
|
+
# error_code: "KILL_SWITCH_ACTIVE",
|
|
90
|
+
# recovery_suggestion: "Provide confirmation text to override: EXECUTE OPERATION_NAME",
|
|
91
|
+
# context: { operation: :trigger_enable, environment: "production" }
|
|
92
|
+
# )
|
|
93
|
+
class KillSwitchError < Error
|
|
94
|
+
def default_error_code
|
|
95
|
+
"KILL_SWITCH_ACTIVE"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def default_message
|
|
99
|
+
"Kill switch is active for this environment"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def default_recovery_suggestion
|
|
103
|
+
ctx = @context || {}
|
|
104
|
+
ctx[:operation] || "this operation"
|
|
105
|
+
environment = ctx[:environment] || "this environment"
|
|
106
|
+
"Kill switch is active for #{environment}. " \
|
|
107
|
+
"To override, provide the required confirmation text. " \
|
|
108
|
+
"For CLI/rake tasks, use: KILL_SWITCH_OVERRIDE=true CONFIRMATION_TEXT=\"...\" rake your:task"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Error raised when drift is detected
|
|
113
|
+
#
|
|
114
|
+
# @example
|
|
115
|
+
# raise PgSqlTriggers::DriftError.new(
|
|
116
|
+
# "Trigger 'users_email_validation' has drifted from definition",
|
|
117
|
+
# error_code: "DRIFT_DETECTED",
|
|
118
|
+
# recovery_suggestion: "Run migration to sync trigger, or re-execute trigger to apply current definition",
|
|
119
|
+
# context: { trigger_name: "users_email_validation", drift_type: "function_body" }
|
|
120
|
+
# )
|
|
121
|
+
class DriftError < Error
|
|
122
|
+
def default_error_code
|
|
123
|
+
"DRIFT_DETECTED"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def default_message
|
|
127
|
+
"Trigger has drifted from its definition"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def default_recovery_suggestion
|
|
131
|
+
trigger_name = context[:trigger_name] || "trigger"
|
|
132
|
+
"Trigger '#{trigger_name}' has drifted. " \
|
|
133
|
+
"Run 'rake trigger:migrate' to sync the trigger, or use the re-execute feature " \
|
|
134
|
+
"to apply the current definition."
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Error raised when validation fails
|
|
139
|
+
#
|
|
140
|
+
# @example
|
|
141
|
+
# raise PgSqlTriggers::ValidationError.new(
|
|
142
|
+
# "Invalid trigger definition: table name is required",
|
|
143
|
+
# error_code: "VALIDATION_FAILED",
|
|
144
|
+
# recovery_suggestion: "Ensure all required fields are provided in the trigger definition",
|
|
145
|
+
# context: { field: :table_name, errors: ["is required"] }
|
|
146
|
+
# )
|
|
147
|
+
class ValidationError < Error
|
|
148
|
+
def default_error_code
|
|
149
|
+
"VALIDATION_FAILED"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def default_message
|
|
153
|
+
"Validation failed"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def default_recovery_suggestion
|
|
157
|
+
if context[:field]
|
|
158
|
+
"Please fix the #{context[:field]} field and try again."
|
|
159
|
+
else
|
|
160
|
+
"Please review the input and ensure all required fields are provided."
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Error raised when SQL execution fails
|
|
166
|
+
#
|
|
167
|
+
# @example
|
|
168
|
+
# raise PgSqlTriggers::ExecutionError.new(
|
|
169
|
+
# "SQL execution failed: syntax error near 'INVALID'",
|
|
170
|
+
# error_code: "EXECUTION_FAILED",
|
|
171
|
+
# recovery_suggestion: "Review SQL syntax and ensure all references are valid",
|
|
172
|
+
# context: { sql: "SELECT * FROM...", database_error: "..." }
|
|
173
|
+
# )
|
|
174
|
+
class ExecutionError < Error
|
|
175
|
+
def default_error_code
|
|
176
|
+
"EXECUTION_FAILED"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def default_message
|
|
180
|
+
"SQL execution failed"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def default_recovery_suggestion
|
|
184
|
+
if context[:database_error]
|
|
185
|
+
"Review the SQL syntax and database error. Ensure all table and column names are correct."
|
|
186
|
+
else
|
|
187
|
+
"Review the SQL and ensure it is valid PostgreSQL syntax."
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Error raised when unsafe migrations are attempted
|
|
193
|
+
#
|
|
194
|
+
# @example
|
|
195
|
+
# raise PgSqlTriggers::UnsafeMigrationError.new(
|
|
196
|
+
# "Migration contains unsafe DROP + CREATE operations",
|
|
197
|
+
# error_code: "UNSAFE_MIGRATION",
|
|
198
|
+
# recovery_suggestion: "Review migration safety or set allow_unsafe_migrations=true",
|
|
199
|
+
# context: { violations: [...] }
|
|
200
|
+
# )
|
|
201
|
+
class UnsafeMigrationError < Error
|
|
202
|
+
def default_error_code
|
|
203
|
+
"UNSAFE_MIGRATION"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def default_message
|
|
207
|
+
"Migration contains unsafe operations"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def default_recovery_suggestion
|
|
211
|
+
"Review the migration for unsafe operations. " \
|
|
212
|
+
"If you are certain the migration is safe, you can set " \
|
|
213
|
+
"PgSqlTriggers.configure { |c| c.allow_unsafe_migrations = true } " \
|
|
214
|
+
"or use the kill switch override mechanism."
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Error raised when a trigger is not found
|
|
219
|
+
#
|
|
220
|
+
# @example
|
|
221
|
+
# raise PgSqlTriggers::NotFoundError.new(
|
|
222
|
+
# "Trigger 'users_email_validation' not found",
|
|
223
|
+
# error_code: "TRIGGER_NOT_FOUND",
|
|
224
|
+
# recovery_suggestion: "Verify trigger name or create the trigger first",
|
|
225
|
+
# context: { trigger_name: "users_email_validation" }
|
|
226
|
+
# )
|
|
227
|
+
class NotFoundError < Error
|
|
228
|
+
def default_error_code
|
|
229
|
+
"NOT_FOUND"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def default_message
|
|
233
|
+
"Resource not found"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def default_recovery_suggestion
|
|
237
|
+
if context[:trigger_name]
|
|
238
|
+
"Trigger '#{context[:trigger_name]}' not found. " \
|
|
239
|
+
"Verify the trigger name or create the trigger first using the generator or DSL."
|
|
240
|
+
else
|
|
241
|
+
"The requested resource was not found. Verify the identifier and try again."
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -6,9 +6,24 @@ require "active_support/core_ext/string/inflections"
|
|
|
6
6
|
|
|
7
7
|
module PgSqlTriggers
|
|
8
8
|
module Generator
|
|
9
|
+
# Service object for generating trigger DSL files, migration files, and registering triggers.
|
|
10
|
+
#
|
|
11
|
+
# This service follows the service object pattern with class methods for stateless operations.
|
|
12
|
+
#
|
|
13
|
+
# @example Generate trigger files
|
|
14
|
+
# form = PgSqlTriggers::Generator::Form.new(trigger_name: "users_email_validation", ...)
|
|
15
|
+
# result = PgSqlTriggers::Generator::Service.create_trigger(form, actor: current_user)
|
|
16
|
+
#
|
|
17
|
+
# if result[:success]
|
|
18
|
+
# puts "Created: #{result[:migration_path]}"
|
|
19
|
+
# end
|
|
9
20
|
# rubocop:disable Metrics/ClassLength
|
|
10
21
|
class Service
|
|
11
22
|
class << self
|
|
23
|
+
# Generates the DSL trigger definition code from a form.
|
|
24
|
+
#
|
|
25
|
+
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
26
|
+
# @return [String] The generated DSL code
|
|
12
27
|
def generate_dsl(form)
|
|
13
28
|
# Generate DSL trigger definition
|
|
14
29
|
events_list = form.events.compact_blank.map { |e| ":#{e}" }.join(", ")
|
|
@@ -44,6 +59,10 @@ module PgSqlTriggers
|
|
|
44
59
|
code
|
|
45
60
|
end
|
|
46
61
|
|
|
62
|
+
# Generates the migration file code from a form.
|
|
63
|
+
#
|
|
64
|
+
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
65
|
+
# @return [String] The generated migration code
|
|
47
66
|
def generate_migration(form)
|
|
48
67
|
# Generate migration class code
|
|
49
68
|
# Use Rails migration naming convention: Add{TriggerName}
|
|
@@ -95,6 +114,10 @@ module PgSqlTriggers
|
|
|
95
114
|
RUBY
|
|
96
115
|
end
|
|
97
116
|
|
|
117
|
+
# Generates a PL/pgSQL function stub template.
|
|
118
|
+
#
|
|
119
|
+
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
120
|
+
# @return [String, nil] The generated function stub SQL, or nil if not requested
|
|
98
121
|
def generate_function_stub(form)
|
|
99
122
|
return nil unless form.generate_function_stub
|
|
100
123
|
|
|
@@ -138,6 +161,10 @@ module PgSqlTriggers
|
|
|
138
161
|
SQL
|
|
139
162
|
end
|
|
140
163
|
|
|
164
|
+
# Returns the file paths where the migration and DSL files will be created.
|
|
165
|
+
#
|
|
166
|
+
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
167
|
+
# @return [Hash] Hash with :migration and :dsl keys containing relative file paths
|
|
141
168
|
def file_paths(form)
|
|
142
169
|
# NOTE: These paths are relative to the host Rails app, not the gem
|
|
143
170
|
# Generate both migration file and DSL file
|
|
@@ -148,6 +175,11 @@ module PgSqlTriggers
|
|
|
148
175
|
}
|
|
149
176
|
end
|
|
150
177
|
|
|
178
|
+
# Creates trigger files (DSL and migration) and registers the trigger in the registry.
|
|
179
|
+
#
|
|
180
|
+
# @param form [PgSqlTriggers::Generator::Form] The form containing trigger parameters
|
|
181
|
+
# @param actor [Hash, nil] Optional actor information for audit logging
|
|
182
|
+
# @return [Hash] Result hash with :success (Boolean), :migration_path, :dsl_path, and optional :error
|
|
151
183
|
def create_trigger(form, actor: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
152
184
|
paths = file_paths(form)
|
|
153
185
|
base_path = rails_base_path
|
|
@@ -22,8 +22,15 @@ module PgSqlTriggers
|
|
|
22
22
|
unless can?(actor, action, environment: environment)
|
|
23
23
|
action_sym = action.to_sym
|
|
24
24
|
required_level = Permissions::ACTIONS[action_sym] || "unknown"
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
message = "Permission denied: #{action_sym} requires #{required_level} level access"
|
|
26
|
+
recovery = "Contact your administrator to request #{required_level} level access for this operation."
|
|
27
|
+
|
|
28
|
+
raise PgSqlTriggers::PermissionError.new(
|
|
29
|
+
message,
|
|
30
|
+
error_code: "PERMISSION_DENIED",
|
|
31
|
+
recovery_suggestion: recovery,
|
|
32
|
+
context: { action: action_sym, required_role: required_level, environment: environment }
|
|
33
|
+
)
|
|
27
34
|
end
|
|
28
35
|
true
|
|
29
36
|
end
|
|
@@ -33,8 +33,13 @@ module PgSqlTriggers
|
|
|
33
33
|
trigger_name = definition.name
|
|
34
34
|
|
|
35
35
|
# Use cached lookup if available to avoid N+1 queries during trigger file loading
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
# Explicitly check cache first to avoid query in some Ruby versions where ||= may evaluate RHS
|
|
37
|
+
existing = if _registry_cache.key?(trigger_name)
|
|
38
|
+
_registry_cache[trigger_name]
|
|
39
|
+
else
|
|
40
|
+
_registry_cache[trigger_name] =
|
|
41
|
+
PgSqlTriggers::TriggerRegistry.find_by(trigger_name: trigger_name)
|
|
42
|
+
end
|
|
38
43
|
|
|
39
44
|
# Calculate checksum using field-concatenation (consistent with TriggerRegistry model)
|
|
40
45
|
checksum = calculate_checksum(definition)
|
|
@@ -51,17 +56,27 @@ module PgSqlTriggers
|
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
if existing
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
# Check if attributes have actually changed to avoid unnecessary queries
|
|
60
|
+
attributes_changed = attributes.any? do |key, value|
|
|
61
|
+
existing.send(key) != value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if attributes_changed
|
|
65
|
+
begin
|
|
66
|
+
existing.update!(attributes)
|
|
67
|
+
# Update cache with the modified record (reload to get fresh data)
|
|
68
|
+
reloaded = existing.reload
|
|
69
|
+
_registry_cache[trigger_name] = reloaded
|
|
70
|
+
reloaded
|
|
71
|
+
rescue ActiveRecord::RecordNotFound
|
|
72
|
+
# Cached record was deleted, create a new one
|
|
73
|
+
new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
|
|
74
|
+
_registry_cache[trigger_name] = new_record
|
|
75
|
+
new_record
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
# No changes, return cached record without any queries
|
|
79
|
+
existing
|
|
65
80
|
end
|
|
66
81
|
else
|
|
67
82
|
new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
|
|
@@ -1,36 +1,210 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PgSqlTriggers
|
|
4
|
+
# Registry module provides a unified API for querying and managing triggers.
|
|
5
|
+
#
|
|
6
|
+
# @example Query triggers
|
|
7
|
+
# # List all triggers
|
|
8
|
+
# triggers = PgSqlTriggers::Registry.list
|
|
9
|
+
#
|
|
10
|
+
# # Get enabled/disabled triggers
|
|
11
|
+
# enabled = PgSqlTriggers::Registry.enabled
|
|
12
|
+
# disabled = PgSqlTriggers::Registry.disabled
|
|
13
|
+
#
|
|
14
|
+
# # Get triggers for a specific table
|
|
15
|
+
# user_triggers = PgSqlTriggers::Registry.for_table(:users)
|
|
16
|
+
#
|
|
17
|
+
# # Check for drift
|
|
18
|
+
# drift_info = PgSqlTriggers::Registry.diff
|
|
19
|
+
#
|
|
20
|
+
# @example Manage triggers
|
|
21
|
+
# # Enable a trigger
|
|
22
|
+
# PgSqlTriggers::Registry.enable("users_email_validation",
|
|
23
|
+
# actor: current_user,
|
|
24
|
+
# confirmation: "EXECUTE TRIGGER_ENABLE")
|
|
25
|
+
#
|
|
26
|
+
# # Disable a trigger
|
|
27
|
+
# PgSqlTriggers::Registry.disable("users_email_validation",
|
|
28
|
+
# actor: current_user,
|
|
29
|
+
# confirmation: "EXECUTE TRIGGER_DISABLE")
|
|
30
|
+
#
|
|
31
|
+
# # Drop a trigger
|
|
32
|
+
# PgSqlTriggers::Registry.drop("old_trigger",
|
|
33
|
+
# actor: current_user,
|
|
34
|
+
# reason: "No longer needed",
|
|
35
|
+
# confirmation: "EXECUTE TRIGGER_DROP")
|
|
36
|
+
#
|
|
37
|
+
# # Re-execute a trigger
|
|
38
|
+
# PgSqlTriggers::Registry.re_execute("drifted_trigger",
|
|
39
|
+
# actor: current_user,
|
|
40
|
+
# reason: "Fix drift",
|
|
41
|
+
# confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
|
|
4
42
|
module Registry
|
|
5
43
|
autoload :Manager, "pg_sql_triggers/registry/manager"
|
|
6
44
|
autoload :Validator, "pg_sql_triggers/registry/validator"
|
|
7
45
|
|
|
46
|
+
# Registers a trigger definition in the registry.
|
|
47
|
+
#
|
|
48
|
+
# @param definition [PgSqlTriggers::DSL::TriggerDefinition] The trigger definition to register
|
|
49
|
+
# @return [PgSqlTriggers::TriggerRegistry] The registered trigger record
|
|
8
50
|
def self.register(definition)
|
|
9
51
|
Manager.register(definition)
|
|
10
52
|
end
|
|
11
53
|
|
|
54
|
+
# Returns all registered triggers.
|
|
55
|
+
#
|
|
56
|
+
# @return [ActiveRecord::Relation<PgSqlTriggers::TriggerRegistry>] All trigger records
|
|
12
57
|
def self.list
|
|
13
58
|
Manager.list
|
|
14
59
|
end
|
|
15
60
|
|
|
61
|
+
# Returns only enabled triggers.
|
|
62
|
+
#
|
|
63
|
+
# @return [ActiveRecord::Relation<PgSqlTriggers::TriggerRegistry>] Enabled trigger records
|
|
16
64
|
def self.enabled
|
|
17
65
|
Manager.enabled
|
|
18
66
|
end
|
|
19
67
|
|
|
68
|
+
# Returns only disabled triggers.
|
|
69
|
+
#
|
|
70
|
+
# @return [ActiveRecord::Relation<PgSqlTriggers::TriggerRegistry>] Disabled trigger records
|
|
20
71
|
def self.disabled
|
|
21
72
|
Manager.disabled
|
|
22
73
|
end
|
|
23
74
|
|
|
75
|
+
# Returns triggers for a specific table.
|
|
76
|
+
#
|
|
77
|
+
# @param table_name [String, Symbol] The table name to filter by
|
|
78
|
+
# @return [ActiveRecord::Relation<PgSqlTriggers::TriggerRegistry>] Triggers for the specified table
|
|
24
79
|
def self.for_table(table_name)
|
|
25
80
|
Manager.for_table(table_name)
|
|
26
81
|
end
|
|
27
82
|
|
|
28
|
-
|
|
29
|
-
|
|
83
|
+
# Checks for drift between DSL definitions and database state.
|
|
84
|
+
#
|
|
85
|
+
# @param trigger_name [String, nil] Optional trigger name to check specific trigger, or nil for all triggers
|
|
86
|
+
# @return [Hash] Drift information with keys: :in_sync, :drifted, :manual_override, :disabled, :dropped, :unknown
|
|
87
|
+
def self.diff(trigger_name = nil)
|
|
88
|
+
Manager.diff(trigger_name)
|
|
30
89
|
end
|
|
31
90
|
|
|
91
|
+
# Returns all triggers that have drifted from their expected state.
|
|
92
|
+
#
|
|
93
|
+
# @return [Array<Hash>] Array of drift result hashes for drifted triggers
|
|
94
|
+
def self.drifted
|
|
95
|
+
Manager.drifted
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns all triggers that are in sync with their expected state.
|
|
99
|
+
#
|
|
100
|
+
# @return [Array<Hash>] Array of drift result hashes for in-sync triggers
|
|
101
|
+
def self.in_sync
|
|
102
|
+
Manager.in_sync
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns all unknown (external) triggers not managed by this gem.
|
|
106
|
+
#
|
|
107
|
+
# @return [Array<Hash>] Array of drift result hashes for unknown triggers
|
|
108
|
+
def self.unknown_triggers
|
|
109
|
+
Manager.unknown_triggers
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns all triggers that have been dropped from the database.
|
|
113
|
+
#
|
|
114
|
+
# @return [Array<Hash>] Array of drift result hashes for dropped triggers
|
|
115
|
+
def self.dropped
|
|
116
|
+
Manager.dropped
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Validates all triggers in the registry.
|
|
120
|
+
#
|
|
121
|
+
# @raise [PgSqlTriggers::ValidationError] If validation fails
|
|
122
|
+
# @return [true] If validation passes
|
|
32
123
|
def self.validate!
|
|
33
124
|
Validator.validate!
|
|
34
125
|
end
|
|
126
|
+
|
|
127
|
+
# Enables a trigger by name.
|
|
128
|
+
#
|
|
129
|
+
# @param trigger_name [String] The name of the trigger to enable
|
|
130
|
+
# @param actor [Hash] Information about who is performing the action (must have :type and :id keys)
|
|
131
|
+
# @param confirmation [String, nil] Optional confirmation text for kill switch protection
|
|
132
|
+
# @raise [PgSqlTriggers::PermissionError] If actor lacks permission
|
|
133
|
+
# @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
|
|
134
|
+
# @raise [PgSqlTriggers::NotFoundError] If trigger not found
|
|
135
|
+
# @return [PgSqlTriggers::TriggerRegistry] The updated trigger record
|
|
136
|
+
def self.enable(trigger_name, actor:, confirmation: nil)
|
|
137
|
+
check_permission!(actor, :enable_trigger)
|
|
138
|
+
trigger = find_trigger!(trigger_name)
|
|
139
|
+
trigger.enable!(confirmation: confirmation, actor: actor)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Disables a trigger by name.
|
|
143
|
+
#
|
|
144
|
+
# @param trigger_name [String] The name of the trigger to disable
|
|
145
|
+
# @param actor [Hash] Information about who is performing the action (must have :type and :id keys)
|
|
146
|
+
# @param confirmation [String, nil] Optional confirmation text for kill switch protection
|
|
147
|
+
# @raise [PgSqlTriggers::PermissionError] If actor lacks permission
|
|
148
|
+
# @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
|
|
149
|
+
# @raise [PgSqlTriggers::NotFoundError] If trigger not found
|
|
150
|
+
# @return [PgSqlTriggers::TriggerRegistry] The updated trigger record
|
|
151
|
+
def self.disable(trigger_name, actor:, confirmation: nil)
|
|
152
|
+
check_permission!(actor, :disable_trigger)
|
|
153
|
+
trigger = find_trigger!(trigger_name)
|
|
154
|
+
trigger.disable!(confirmation: confirmation, actor: actor)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Drops a trigger by name.
|
|
158
|
+
#
|
|
159
|
+
# @param trigger_name [String] The name of the trigger to drop
|
|
160
|
+
# @param actor [Hash] Information about who is performing the action (must have :type and :id keys)
|
|
161
|
+
# @param reason [String] Required reason for dropping the trigger
|
|
162
|
+
# @param confirmation [String, nil] Optional confirmation text for kill switch protection
|
|
163
|
+
# @raise [PgSqlTriggers::PermissionError] If actor lacks permission
|
|
164
|
+
# @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
|
|
165
|
+
# @raise [PgSqlTriggers::NotFoundError] If trigger not found
|
|
166
|
+
# @raise [ArgumentError] If reason is missing or empty
|
|
167
|
+
# @return [true] If drop succeeds
|
|
168
|
+
def self.drop(trigger_name, actor:, reason:, confirmation: nil)
|
|
169
|
+
check_permission!(actor, :drop_trigger)
|
|
170
|
+
trigger = find_trigger!(trigger_name)
|
|
171
|
+
trigger.drop!(reason: reason, confirmation: confirmation, actor: actor)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Re-executes a trigger by name (drops and recreates it).
|
|
175
|
+
#
|
|
176
|
+
# @param trigger_name [String] The name of the trigger to re-execute
|
|
177
|
+
# @param actor [Hash] Information about who is performing the action (must have :type and :id keys)
|
|
178
|
+
# @param reason [String] Required reason for re-executing the trigger
|
|
179
|
+
# @param confirmation [String, nil] Optional confirmation text for kill switch protection
|
|
180
|
+
# @raise [PgSqlTriggers::PermissionError] If actor lacks permission
|
|
181
|
+
# @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
|
|
182
|
+
# @raise [PgSqlTriggers::NotFoundError] If trigger not found
|
|
183
|
+
# @raise [ArgumentError] If reason is missing or empty
|
|
184
|
+
# @return [PgSqlTriggers::TriggerRegistry] The updated trigger record
|
|
185
|
+
def self.re_execute(trigger_name, actor:, reason:, confirmation: nil)
|
|
186
|
+
check_permission!(actor, :drop_trigger) # Re-execute requires same permission as drop
|
|
187
|
+
trigger = find_trigger!(trigger_name)
|
|
188
|
+
trigger.re_execute!(reason: reason, confirmation: confirmation, actor: actor)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Private helper methods
|
|
192
|
+
|
|
193
|
+
def self.find_trigger!(trigger_name)
|
|
194
|
+
PgSqlTriggers::TriggerRegistry.find_by!(trigger_name: trigger_name)
|
|
195
|
+
rescue ActiveRecord::RecordNotFound
|
|
196
|
+
raise PgSqlTriggers::NotFoundError.new(
|
|
197
|
+
"Trigger '#{trigger_name}' not found in registry",
|
|
198
|
+
error_code: "TRIGGER_NOT_FOUND",
|
|
199
|
+
recovery_suggestion: "Verify the trigger name or create the trigger first using the generator or DSL.",
|
|
200
|
+
context: { trigger_name: trigger_name }
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
private_class_method :find_trigger!
|
|
204
|
+
|
|
205
|
+
def self.check_permission!(actor, action)
|
|
206
|
+
PgSqlTriggers::Permissions.check!(actor, action)
|
|
207
|
+
end
|
|
208
|
+
private_class_method :check_permission!
|
|
35
209
|
end
|
|
36
210
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module PgSqlTriggers
|
|
6
|
+
module SQL
|
|
7
|
+
# Capsule represents a named SQL capsule with environment declaration and purpose
|
|
8
|
+
# Used for emergency operations and manual SQL execution
|
|
9
|
+
#
|
|
10
|
+
# @example Creating a SQL capsule
|
|
11
|
+
# capsule = PgSqlTriggers::SQL::Capsule.new(
|
|
12
|
+
# name: "fix_user_permissions",
|
|
13
|
+
# environment: "production",
|
|
14
|
+
# purpose: "Emergency fix for user permission issue",
|
|
15
|
+
# sql: "UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';"
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class Capsule
|
|
19
|
+
attr_reader :name, :environment, :purpose, :sql, :created_at
|
|
20
|
+
|
|
21
|
+
# @param name [String] The name of the SQL capsule
|
|
22
|
+
# @param environment [String] The environment this capsule is intended for
|
|
23
|
+
# @param purpose [String] Description of what this capsule does and why
|
|
24
|
+
# @param sql [String] The SQL to execute
|
|
25
|
+
# @param created_at [Time, nil] The timestamp when the capsule was created (defaults to now)
|
|
26
|
+
def initialize(name:, environment:, purpose:, sql:, created_at: nil)
|
|
27
|
+
@name = name
|
|
28
|
+
@environment = environment
|
|
29
|
+
@purpose = purpose
|
|
30
|
+
@sql = sql
|
|
31
|
+
@created_at = created_at || Time.current
|
|
32
|
+
validate!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Calculates the checksum of the SQL content
|
|
36
|
+
# @return [String] The SHA256 checksum of the SQL
|
|
37
|
+
def checksum
|
|
38
|
+
@checksum ||= Digest::SHA256.hexdigest(sql.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Converts the capsule to a hash suitable for storage
|
|
42
|
+
# @return [Hash] The capsule data
|
|
43
|
+
def to_h
|
|
44
|
+
{
|
|
45
|
+
name: name,
|
|
46
|
+
environment: environment,
|
|
47
|
+
purpose: purpose,
|
|
48
|
+
sql: sql,
|
|
49
|
+
checksum: checksum,
|
|
50
|
+
created_at: created_at
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns the registry trigger name for this capsule
|
|
55
|
+
# SQL capsules are stored in the registry with a special naming pattern
|
|
56
|
+
# @return [String] The trigger name for registry storage
|
|
57
|
+
def registry_trigger_name
|
|
58
|
+
"sql_capsule_#{name}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def validate!
|
|
64
|
+
errors = []
|
|
65
|
+
errors << "Name cannot be blank" if name.nil? || name.to_s.strip.empty?
|
|
66
|
+
errors << "Environment cannot be blank" if environment.nil? || environment.to_s.strip.empty?
|
|
67
|
+
errors << "Purpose cannot be blank" if purpose.nil? || purpose.to_s.strip.empty?
|
|
68
|
+
errors << "SQL cannot be blank" if sql.nil? || sql.to_s.strip.empty?
|
|
69
|
+
|
|
70
|
+
# Validate name format (alphanumeric, underscores, hyphens only)
|
|
71
|
+
unless name.to_s.match?(/\A[a-z0-9_-]+\z/i)
|
|
72
|
+
errors << "Name must contain only letters, numbers, underscores, and hyphens"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, "Invalid capsule: #{errors.join(', ')}" if errors.any?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|