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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -0
  3. data/CHANGELOG.md +200 -0
  4. data/COVERAGE.md +45 -34
  5. data/Goal.md +276 -155
  6. data/README.md +56 -1
  7. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +50 -0
  8. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +56 -0
  9. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +66 -0
  10. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +117 -0
  11. data/app/controllers/pg_sql_triggers/application_controller.rb +10 -62
  12. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +102 -0
  13. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +6 -1
  14. data/app/controllers/pg_sql_triggers/migrations_controller.rb +62 -10
  15. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +161 -0
  16. data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
  17. data/app/controllers/pg_sql_triggers/triggers_controller.rb +147 -0
  18. data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
  19. data/app/models/pg_sql_triggers/audit_log.rb +106 -0
  20. data/app/models/pg_sql_triggers/trigger_registry.rb +297 -5
  21. data/app/views/layouts/pg_sql_triggers/application.html.erb +26 -6
  22. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
  23. data/app/views/pg_sql_triggers/dashboard/index.html.erb +65 -2
  24. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +81 -0
  25. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +85 -0
  26. data/app/views/pg_sql_triggers/tables/index.html.erb +76 -3
  27. data/app/views/pg_sql_triggers/tables/show.html.erb +49 -2
  28. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +138 -0
  29. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +145 -0
  30. data/app/views/pg_sql_triggers/triggers/show.html.erb +206 -0
  31. data/config/routes.rb +11 -0
  32. data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
  33. data/docs/README.md +15 -5
  34. data/docs/api-reference.md +443 -4
  35. data/docs/audit-trail.md +413 -0
  36. data/docs/configuration.md +6 -6
  37. data/docs/permissions.md +369 -0
  38. data/docs/troubleshooting.md +486 -0
  39. data/docs/ui-guide.md +211 -0
  40. data/docs/web-ui.md +328 -40
  41. data/lib/pg_sql_triggers/errors.rb +245 -0
  42. data/lib/pg_sql_triggers/generator/service.rb +32 -0
  43. data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
  44. data/lib/pg_sql_triggers/registry/manager.rb +28 -13
  45. data/lib/pg_sql_triggers/registry.rb +176 -2
  46. data/lib/pg_sql_triggers/sql/capsule.rb +79 -0
  47. data/lib/pg_sql_triggers/sql/executor.rb +200 -0
  48. data/lib/pg_sql_triggers/sql/kill_switch.rb +33 -5
  49. data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
  50. data/lib/pg_sql_triggers/version.rb +1 -1
  51. data/lib/pg_sql_triggers.rb +3 -6
  52. metadata +38 -6
  53. data/docs/screenshots/.gitkeep +0 -1
  54. data/docs/screenshots/Generate Trigger.png +0 -0
  55. data/docs/screenshots/Triggers Page.png +0 -0
  56. data/docs/screenshots/kill error.png +0 -0
  57. 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
- raise PgSqlTriggers::PermissionError,
26
- "Permission denied: #{action_sym} requires #{required_level} level access"
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
- existing = _registry_cache[trigger_name] ||=
37
- PgSqlTriggers::TriggerRegistry.find_by(trigger_name: trigger_name)
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
- begin
55
- existing.update!(attributes)
56
- # Update cache with the modified record (reload to get fresh data)
57
- reloaded = existing.reload
58
- _registry_cache[trigger_name] = reloaded
59
- reloaded
60
- rescue ActiveRecord::RecordNotFound
61
- # Cached record was deleted, create a new one
62
- new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
63
- _registry_cache[trigger_name] = new_record
64
- new_record
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
- def self.diff
29
- Manager.diff
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