pg_sql_triggers 1.1.0 → 1.2.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +15 -0
  3. data/CHANGELOG.md +61 -0
  4. data/COVERAGE.md +32 -28
  5. data/README.md +31 -2
  6. data/RELEASE.md +1 -1
  7. data/app/controllers/pg_sql_triggers/application_controller.rb +1 -1
  8. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +10 -0
  9. data/app/controllers/pg_sql_triggers/migrations_controller.rb +62 -10
  10. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +161 -0
  11. data/app/controllers/pg_sql_triggers/triggers_controller.rb +165 -0
  12. data/app/models/pg_sql_triggers/trigger_registry.rb +123 -0
  13. data/app/views/pg_sql_triggers/dashboard/index.html.erb +40 -2
  14. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +81 -0
  15. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +85 -0
  16. data/app/views/pg_sql_triggers/tables/show.html.erb +36 -2
  17. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +129 -0
  18. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +136 -0
  19. data/app/views/pg_sql_triggers/triggers/show.html.erb +186 -0
  20. data/config/routes.rb +9 -0
  21. data/docs/README.md +2 -2
  22. data/docs/api-reference.md +252 -4
  23. data/docs/getting-started.md +1 -1
  24. data/docs/kill-switch.md +3 -3
  25. data/docs/web-ui.md +82 -17
  26. data/lib/generators/pg_sql_triggers/templates/README +1 -1
  27. data/lib/pg_sql_triggers/registry/manager.rb +28 -13
  28. data/lib/pg_sql_triggers/registry.rb +41 -0
  29. data/lib/pg_sql_triggers/sql/capsule.rb +79 -0
  30. data/lib/pg_sql_triggers/sql/executor.rb +200 -0
  31. data/lib/pg_sql_triggers/version.rb +1 -1
  32. metadata +18 -12
@@ -114,6 +114,102 @@ end
114
114
 
115
115
  **Returns**: `true` if all triggers are valid
116
116
 
117
+ ### `PgSqlTriggers::Registry.enable(trigger_name, actor:, confirmation: nil)`
118
+
119
+ Enables a trigger with permission and kill switch checks.
120
+
121
+ ```ruby
122
+ # Enable trigger
123
+ PgSqlTriggers::Registry.enable(
124
+ "users_email_validation",
125
+ actor: { type: "user", id: "admin@example.com" },
126
+ confirmation: "EXECUTE TRIGGER_ENABLE"
127
+ )
128
+
129
+ # With current user as actor
130
+ actor = { type: "user", id: current_user.email }
131
+ PgSqlTriggers::Registry.enable("billing_trigger", actor: actor, confirmation: "EXECUTE TRIGGER_ENABLE")
132
+ ```
133
+
134
+ **Parameters**:
135
+ - `trigger_name` (String): The name of the trigger to enable
136
+ - `actor` (Hash): Information about who is performing the operation (requires `:type` and `:id` keys)
137
+ - `confirmation` (String, optional): Kill switch confirmation text
138
+
139
+ **Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
140
+
141
+ **Returns**: `true` on success
142
+
143
+ ### `PgSqlTriggers::Registry.disable(trigger_name, actor:, confirmation: nil)`
144
+
145
+ Disables a trigger with permission and kill switch checks.
146
+
147
+ ```ruby
148
+ # Disable trigger
149
+ PgSqlTriggers::Registry.disable(
150
+ "users_email_validation",
151
+ actor: { type: "user", id: "admin@example.com" },
152
+ confirmation: "EXECUTE TRIGGER_DISABLE"
153
+ )
154
+ ```
155
+
156
+ **Parameters**:
157
+ - `trigger_name` (String): The name of the trigger to disable
158
+ - `actor` (Hash): Information about who is performing the operation
159
+ - `confirmation` (String, optional): Kill switch confirmation text
160
+
161
+ **Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
162
+
163
+ **Returns**: `true` on success
164
+
165
+ ### `PgSqlTriggers::Registry.drop(trigger_name, actor:, reason:, confirmation: nil)`
166
+
167
+ Drops a trigger from the database and removes it from the registry.
168
+
169
+ ```ruby
170
+ # Drop trigger
171
+ PgSqlTriggers::Registry.drop(
172
+ "old_trigger",
173
+ actor: { type: "user", id: "admin@example.com" },
174
+ reason: "No longer needed in production",
175
+ confirmation: "EXECUTE TRIGGER_DROP"
176
+ )
177
+ ```
178
+
179
+ **Parameters**:
180
+ - `trigger_name` (String): The name of the trigger to drop
181
+ - `actor` (Hash): Information about who is performing the operation (Admin permission required)
182
+ - `reason` (String): Required explanation for why the trigger is being dropped (logged for audit)
183
+ - `confirmation` (String, optional): Kill switch confirmation text
184
+
185
+ **Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
186
+
187
+ **Returns**: `true` on success
188
+
189
+ ### `PgSqlTriggers::Registry.re_execute(trigger_name, actor:, reason:, confirmation: nil)`
190
+
191
+ Re-executes a trigger by dropping and recreating it from the registry definition. Useful for fixing drifted triggers.
192
+
193
+ ```ruby
194
+ # Re-execute drifted trigger
195
+ PgSqlTriggers::Registry.re_execute(
196
+ "drifted_trigger",
197
+ actor: { type: "user", id: "admin@example.com" },
198
+ reason: "Fix drift detected in production",
199
+ confirmation: "EXECUTE TRIGGER_RE_EXECUTE"
200
+ )
201
+ ```
202
+
203
+ **Parameters**:
204
+ - `trigger_name` (String): The name of the trigger to re-execute
205
+ - `actor` (Hash): Information about who is performing the operation (Admin permission required)
206
+ - `reason` (String): Required explanation for why the trigger is being re-executed (logged for audit)
207
+ - `confirmation` (String, optional): Kill switch confirmation text
208
+
209
+ **Raises**: `PgSqlTriggers::PermissionError`, `PgSqlTriggers::KillSwitchError`, `ArgumentError`
210
+
211
+ **Returns**: `true` on success
212
+
117
213
  ## Migrator API
118
214
 
119
215
  The Migrator API manages trigger migrations programmatically.
@@ -318,6 +414,124 @@ end
318
414
 
319
415
  **Returns**: Result of the block
320
416
 
417
+ ## SQL Capsule API
418
+
419
+ The SQL Capsule API provides emergency SQL execution capabilities with safety checks.
420
+
421
+ ### `PgSqlTriggers::SQL::Capsule.new(name:, environment:, purpose:, sql:, created_at: nil)`
422
+
423
+ Creates a new SQL capsule for emergency operations.
424
+
425
+ ```ruby
426
+ capsule = PgSqlTriggers::SQL::Capsule.new(
427
+ name: "fix_user_permissions",
428
+ environment: "production",
429
+ purpose: "Emergency fix for user permission issue after deployment",
430
+ sql: "UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';"
431
+ )
432
+ ```
433
+
434
+ **Parameters**:
435
+ - `name` (String): Unique name for the capsule (alphanumeric, underscores, hyphens only)
436
+ - `environment` (String): Target environment (e.g., "production", "staging")
437
+ - `purpose` (String): Description of what the capsule does and why (required for audit trail)
438
+ - `sql` (String): The SQL statement(s) to execute
439
+ - `created_at` (Time, optional): Creation timestamp (defaults to current time)
440
+
441
+ **Raises**: `ArgumentError` if validation fails
442
+
443
+ ### `capsule.checksum`
444
+
445
+ Returns the SHA256 checksum of the SQL content.
446
+
447
+ ```ruby
448
+ capsule = PgSqlTriggers::SQL::Capsule.new(name: "fix", ...)
449
+ puts capsule.checksum
450
+ # => "a3f5b8c9d2e..."
451
+ ```
452
+
453
+ **Returns**: String (SHA256 hash)
454
+
455
+ ### `capsule.to_h`
456
+
457
+ Converts the capsule to a hash for storage or serialization.
458
+
459
+ ```ruby
460
+ capsule_data = capsule.to_h
461
+ # => {
462
+ # name: "fix_user_permissions",
463
+ # environment: "production",
464
+ # purpose: "Emergency fix...",
465
+ # sql: "UPDATE users...",
466
+ # checksum: "a3f5b8c9d2e...",
467
+ # created_at: 2026-01-01 12:00:00 UTC
468
+ # }
469
+ ```
470
+
471
+ **Returns**: Hash
472
+
473
+ ## SQL Executor API
474
+
475
+ The SQL Executor API handles safe execution of SQL capsules with comprehensive logging.
476
+
477
+ ### `PgSqlTriggers::SQL::Executor.execute(capsule, actor:, confirmation: nil, dry_run: false)`
478
+
479
+ Executes a SQL capsule with safety checks and logging.
480
+
481
+ ```ruby
482
+ capsule = PgSqlTriggers::SQL::Capsule.new(
483
+ name: "emergency_fix",
484
+ environment: "production",
485
+ purpose: "Fix critical data corruption",
486
+ sql: "UPDATE orders SET status = 'completed' WHERE id IN (123, 456);"
487
+ )
488
+
489
+ # Execute the capsule
490
+ result = PgSqlTriggers::SQL::Executor.execute(
491
+ capsule,
492
+ actor: { type: "user", id: "admin@example.com" },
493
+ confirmation: "EXECUTE SQL"
494
+ )
495
+
496
+ if result[:success]
497
+ puts "SQL executed successfully"
498
+ puts "Rows affected: #{result[:data][:rows_affected]}"
499
+ else
500
+ puts "Execution failed: #{result[:message]}"
501
+ end
502
+ ```
503
+
504
+ **Parameters**:
505
+ - `capsule` (Capsule): The SQL capsule to execute
506
+ - `actor` (Hash): Information about who is executing (Admin permission required)
507
+ - `confirmation` (String, optional): Kill switch confirmation text
508
+ - `dry_run` (Boolean): If true, validates without executing (default: false)
509
+
510
+ **Returns**: Hash with `:success`, `:message`, and `:data` keys
511
+
512
+ **Raises**: Permission and kill switch errors are returned in the result hash
513
+
514
+ ### `PgSqlTriggers::SQL::Executor.execute_capsule(capsule_name, actor:, confirmation: nil, dry_run: false)`
515
+
516
+ Executes a previously stored SQL capsule by name from the registry.
517
+
518
+ ```ruby
519
+ # Execute a capsule stored in the registry
520
+ result = PgSqlTriggers::SQL::Executor.execute_capsule(
521
+ "emergency_fix",
522
+ actor: { type: "user", id: "admin@example.com" },
523
+ confirmation: "EXECUTE SQL"
524
+ )
525
+ ```
526
+
527
+ **Parameters**:
528
+ - `capsule_name` (String): Name of the capsule in the registry
529
+ - `actor` (Hash): Information about who is executing
530
+ - `confirmation` (String, optional): Kill switch confirmation text
531
+ - `dry_run` (Boolean): If true, validates without executing
532
+
533
+ **Returns**: Hash with execution results
534
+
321
535
  ## DSL API
322
536
 
323
537
  The DSL API is used to define triggers in your application.
@@ -487,17 +701,51 @@ trigger.disable!(confirmation: "EXECUTE TRIGGER_DISABLE")
487
701
 
488
702
  **Returns**: `true` on success
489
703
 
490
- #### `drop!(confirmation: nil)`
704
+ #### `drop!(reason:, confirmation: nil, actor: nil)`
491
705
 
492
- Drops the trigger from the database.
706
+ Drops the trigger from the database and removes it from the registry.
493
707
 
494
708
  ```ruby
495
- trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "users_email_validation")
496
- trigger.drop!(confirmation: "EXECUTE TRIGGER_DROP")
709
+ trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "old_trigger")
710
+
711
+ # Drop with reason (required)
712
+ trigger.drop!(
713
+ reason: "No longer needed in production",
714
+ confirmation: "EXECUTE TRIGGER_DROP",
715
+ actor: { type: "user", id: "admin@example.com" }
716
+ )
497
717
  ```
498
718
 
499
719
  **Parameters**:
720
+ - `reason` (String, required): Explanation for why the trigger is being dropped (logged for audit trail)
500
721
  - `confirmation` (String, optional): Kill switch confirmation text
722
+ - `actor` (Hash, optional): Information about who is performing the operation
723
+
724
+ **Raises**: `ArgumentError` if reason is blank, `PgSqlTriggers::KillSwitchError`
725
+
726
+ **Returns**: `true` on success
727
+
728
+ #### `re_execute!(reason:, confirmation: nil, actor: nil)`
729
+
730
+ Re-executes the trigger by dropping and recreating it from the registry definition. Useful for fixing drifted triggers.
731
+
732
+ ```ruby
733
+ trigger = PgSqlTriggers::TriggerRegistry.find_by(trigger_name: "drifted_trigger")
734
+
735
+ # Re-execute to fix drift
736
+ trigger.re_execute!(
737
+ reason: "Fix drift detected after manual database changes",
738
+ confirmation: "EXECUTE TRIGGER_RE_EXECUTE",
739
+ actor: { type: "user", id: "admin@example.com" }
740
+ )
741
+ ```
742
+
743
+ **Parameters**:
744
+ - `reason` (String, required): Explanation for why the trigger is being re-executed (logged for audit trail)
745
+ - `confirmation` (String, optional): Kill switch confirmation text
746
+ - `actor` (Hash, optional): Information about who is performing the operation
747
+
748
+ **Raises**: `ArgumentError` if reason is blank or function_body is missing, `PgSqlTriggers::KillSwitchError`, `StandardError`
501
749
 
502
750
  **Returns**: `true` on success
503
751
 
@@ -132,4 +132,4 @@ You should see your trigger listed in the dashboard.
132
132
 
133
133
  ## Examples Repository
134
134
 
135
- For working examples and a complete demonstration of PgSqlTriggers in action, check out the [example repository](https://github.com/samaswin87/pg_triggers_example).
135
+ For working examples and a complete demonstration of PgSqlTriggers in action, check out the [example repository](https://github.com/samaswin/pg_triggers_example).
data/docs/kill-switch.md CHANGED
@@ -422,21 +422,21 @@ All kill switch events are logged with comprehensive information.
422
422
  Operation was prevented:
423
423
 
424
424
  ```
425
- [KILL_SWITCH] BLOCKED: operation=trigger_migrate environment=production actor=CLI:ashwin reason=no_override
425
+ [KILL_SWITCH] BLOCKED: operation=trigger_migrate environment=production actor=CLI:samaswin reason=no_override
426
426
  ```
427
427
 
428
428
  #### OVERRIDDEN
429
429
  Operation allowed with valid override:
430
430
 
431
431
  ```
432
- [KILL_SWITCH] OVERRIDDEN: operation=trigger_migrate environment=production actor=CLI:ashwin source=env_with_confirmation confirmation=EXECUTE TRIGGER_MIGRATE
432
+ [KILL_SWITCH] OVERRIDDEN: operation=trigger_migrate environment=production actor=CLI:samaswin source=env_with_confirmation confirmation=EXECUTE TRIGGER_MIGRATE
433
433
  ```
434
434
 
435
435
  #### ALLOWED
436
436
  Operation allowed (not in protected environment):
437
437
 
438
438
  ```
439
- [KILL_SWITCH] ALLOWED: operation=trigger_migrate environment=development actor=CLI:ashwin reason=not_protected_environment
439
+ [KILL_SWITCH] ALLOWED: operation=trigger_migrate environment=development actor=CLI:samaswin reason=not_protected_environment
440
440
  ```
441
441
 
442
442
  ### Audit Information
data/docs/web-ui.md CHANGED
@@ -93,9 +93,56 @@ Available actions depend on trigger state and your permissions:
93
93
  - **Enable/Disable**: Toggle trigger activation
94
94
  - **Apply**: Apply generated trigger definition
95
95
  - **Drop**: Remove trigger from database (Admin only)
96
+ - **Re-Execute**: Drop and recreate trigger from registry definition (Admin only)
96
97
  - **View SQL**: See the trigger's SQL definition
97
98
  - **View Diff**: Compare DSL vs database state
98
99
 
100
+ ### Drop Trigger
101
+
102
+ The drop action permanently removes a trigger from the database and registry.
103
+
104
+ 1. Navigate to the trigger detail page
105
+ 2. Click the "Drop Trigger" button
106
+ 3. A modal will appear requiring:
107
+ - **Reason**: Explanation for dropping the trigger (required for audit trail)
108
+ - **Confirmation**: In protected environments, type the exact confirmation text shown
109
+ 4. Review the warning message
110
+ 5. Click "Drop Trigger" to confirm
111
+
112
+ **Important Notes**:
113
+ - This action is **irreversible** - the trigger will be permanently removed
114
+ - Requires **Admin** permission level
115
+ - Protected by kill switch in production environments
116
+ - Reason is logged for compliance and audit purposes
117
+ - The trigger is removed from both the database and the registry
118
+
119
+ ### Re-Execute Trigger
120
+
121
+ The re-execute action fixes drifted triggers by dropping and recreating them from the registry definition.
122
+
123
+ 1. Navigate to the trigger detail page
124
+ 2. If the trigger is drifted, you'll see a drift warning
125
+ 3. Click the "Re-Execute" button
126
+ 4. A modal will appear showing:
127
+ - **Drift Comparison**: Differences between database state and registry definition
128
+ - **Reason Field**: Explanation for re-executing (required for audit trail)
129
+ - **Confirmation**: In protected environments, type the exact confirmation text shown
130
+ 5. Review the drift differences to understand what will change
131
+ 6. Click "Re-Execute Trigger" to confirm
132
+
133
+ **What Happens**:
134
+ 1. Current trigger is dropped from the database
135
+ 2. New trigger is created using the registry definition (function_body, events, timing, condition)
136
+ 3. Registry is updated with execution timestamp
137
+ 4. Operation is logged with reason and actor information
138
+
139
+ **Important Notes**:
140
+ - Requires **Admin** permission level
141
+ - Protected by kill switch in production environments
142
+ - Reason is logged for compliance and audit purposes
143
+ - Executes in a database transaction (rolls back on error)
144
+ - Best used to fix triggers that have drifted from their DSL definition
145
+
99
146
  ## Migration Management
100
147
 
101
148
  The Web UI provides full migration management capabilities.
@@ -159,33 +206,51 @@ After each migration action:
159
206
 
160
207
  ## SQL Capsules
161
208
 
162
- SQL Capsules provide emergency escape hatches for executing SQL directly.
209
+ SQL Capsules provide emergency escape hatches for executing SQL directly with comprehensive safety checks and audit logging.
163
210
 
164
211
  ### When to Use SQL Capsules
165
212
 
166
213
  Use SQL Capsules for:
167
214
  - Emergency fixes in production
168
- - Quick database queries
215
+ - Critical data corrections
169
216
  - Testing SQL functions
170
217
  - Debugging trigger behavior
171
-
172
- ### Executing SQL
173
-
174
- 1. Navigate to "SQL Capsules" tab
175
- 2. Enter your SQL query:
176
- ```sql
177
- SELECT * FROM pg_sql_triggers_registry;
178
- ```
179
- 3. Click "Execute"
180
- 4. In production, enter confirmation text: `EXECUTE SQL`
181
- 5. Review results in the output panel
218
+ - One-off database operations
219
+
220
+ ### Creating and Executing SQL Capsules
221
+
222
+ 1. Navigate to "SQL Capsules" → "New SQL Capsule"
223
+ 2. Fill in the capsule form:
224
+ - **Name**: Unique identifier (alphanumeric, underscores, hyphens only)
225
+ - **Environment**: Target environment (e.g., production, staging)
226
+ - **Purpose**: Detailed explanation of what the SQL does and why (required for audit trail)
227
+ - **SQL**: The SQL statement(s) to execute
228
+ 3. Click "Create and Execute" or "Save for Later"
229
+ 4. Review the capsule details on the confirmation page
230
+ 5. In protected environments, enter confirmation text when prompted
231
+ 6. Click "Execute" to run the SQL
232
+ 7. Review the execution results
233
+
234
+ ### Viewing Capsule History
235
+
236
+ 1. Navigate to "SQL Capsules" → "History"
237
+ 2. View list of previously executed capsules with:
238
+ - Name and purpose
239
+ - Environment and timestamp
240
+ - SQL checksum
241
+ - Execution status
242
+ 3. Click on a capsule to view details
243
+ 4. Re-execute historical capsules if needed
182
244
 
183
245
  ### Safety Features
184
246
 
185
- - **Production Protection**: Requires confirmation in protected environments
186
- - **Read-Only Mode**: Optional configuration for limiting to SELECT queries
187
- - **Query Logging**: All SQL execution is logged
188
- - **Permission Checks**: Requires Admin permission level
247
+ - **Admin Permission Required**: Only Admin users can create and execute SQL capsules
248
+ - **Production Protection**: Requires typed confirmation in protected environments
249
+ - **Kill Switch Integration**: All executions are protected by kill switch
250
+ - **Comprehensive Logging**: All operations logged with actor, timestamp, and checksum
251
+ - **Transactional Execution**: SQL runs in a transaction and rolls back on error
252
+ - **Registry Storage**: All capsules are stored in the registry with checksums
253
+ - **Purpose Tracking**: Required purpose field ensures all executions are documented
189
254
 
190
255
  ### Example SQL Capsules
191
256
 
@@ -31,6 +31,6 @@ Next steps:
31
31
 
32
32
  5. Visit http://localhost:3000/pg_sql_triggers to access the UI
33
33
 
34
- For more information, see: https://github.com/samaswin87/pg_sql_triggers
34
+ For more information, see: https://github.com/samaswin/pg_sql_triggers
35
35
 
36
36
  ===============================================================================
@@ -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)
@@ -32,5 +32,46 @@ module PgSqlTriggers
32
32
  def self.validate!
33
33
  Validator.validate!
34
34
  end
35
+
36
+ # Console APIs for trigger operations
37
+ # These methods provide a convenient interface for managing triggers from the Rails console
38
+
39
+ def self.enable(trigger_name, actor:, confirmation: nil)
40
+ check_permission!(actor, :enable_trigger)
41
+ trigger = find_trigger!(trigger_name)
42
+ trigger.enable!(confirmation: confirmation)
43
+ end
44
+
45
+ def self.disable(trigger_name, actor:, confirmation: nil)
46
+ check_permission!(actor, :disable_trigger)
47
+ trigger = find_trigger!(trigger_name)
48
+ trigger.disable!(confirmation: confirmation)
49
+ end
50
+
51
+ def self.drop(trigger_name, actor:, reason:, confirmation: nil)
52
+ check_permission!(actor, :drop_trigger)
53
+ trigger = find_trigger!(trigger_name)
54
+ trigger.drop!(reason: reason, confirmation: confirmation, actor: actor)
55
+ end
56
+
57
+ def self.re_execute(trigger_name, actor:, reason:, confirmation: nil)
58
+ check_permission!(actor, :drop_trigger) # Re-execute requires same permission as drop
59
+ trigger = find_trigger!(trigger_name)
60
+ trigger.re_execute!(reason: reason, confirmation: confirmation, actor: actor)
61
+ end
62
+
63
+ # Private helper methods
64
+
65
+ def self.find_trigger!(trigger_name)
66
+ PgSqlTriggers::TriggerRegistry.find_by!(trigger_name: trigger_name)
67
+ rescue ActiveRecord::RecordNotFound
68
+ raise ArgumentError, "Trigger '#{trigger_name}' not found in registry"
69
+ end
70
+ private_class_method :find_trigger!
71
+
72
+ def self.check_permission!(actor, action)
73
+ PgSqlTriggers::Permissions.check!(actor, action)
74
+ end
75
+ private_class_method :check_permission!
35
76
  end
36
77
  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