pg_sql_triggers 1.1.1 → 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.
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
 
@@ -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
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ module SQL
5
+ # Executor handles the execution of SQL capsules with safety checks and logging
6
+ #
7
+ # @example Execute a SQL capsule
8
+ # capsule = PgSqlTriggers::SQL::Capsule.new(name: "fix", ...)
9
+ # result = PgSqlTriggers::SQL::Executor.execute(capsule, actor: current_actor, confirmation: "EXECUTE FIX")
10
+ #
11
+ class Executor
12
+ class << self
13
+ # Executes a SQL capsule with safety checks
14
+ #
15
+ # @param capsule [Capsule] The SQL capsule to execute
16
+ # @param actor [Hash] Information about who is executing the capsule
17
+ # @param confirmation [String, nil] The confirmation text for kill switch
18
+ # @param dry_run [Boolean] If true, only validate without executing
19
+ # @return [Hash] Result of the execution with :success, :message, and :data keys
20
+ def execute(capsule, actor:, confirmation: nil, dry_run: false)
21
+ validate_capsule!(capsule)
22
+
23
+ # Check permissions
24
+ check_permissions!(actor)
25
+
26
+ # Check kill switch
27
+ check_kill_switch!(capsule, actor, confirmation)
28
+
29
+ # Log the execution attempt
30
+ log_execution_attempt(capsule, actor, dry_run)
31
+
32
+ if dry_run
33
+ return {
34
+ success: true,
35
+ message: "Dry run successful. SQL would be executed.",
36
+ data: { checksum: capsule.checksum }
37
+ }
38
+ end
39
+
40
+ # Execute in transaction
41
+ result = execute_in_transaction(capsule, actor)
42
+
43
+ # Update registry after successful execution
44
+ update_registry(capsule) if result[:success]
45
+
46
+ # Log the result
47
+ log_execution_result(capsule, actor, result)
48
+
49
+ result
50
+ rescue StandardError => e
51
+ log_execution_error(capsule, actor, e)
52
+ {
53
+ success: false,
54
+ message: "Execution failed: #{e.message}",
55
+ error: e
56
+ }
57
+ end
58
+
59
+ # Executes a SQL capsule by name from the registry
60
+ #
61
+ # @param capsule_name [String] The name of the capsule to execute
62
+ # @param actor [Hash] Information about who is executing the capsule
63
+ # @param confirmation [String, nil] The confirmation text for kill switch
64
+ # @param dry_run [Boolean] If true, only validate without executing
65
+ # @return [Hash] Result of the execution
66
+ def execute_capsule(capsule_name, actor:, confirmation: nil, dry_run: false)
67
+ capsule = load_capsule_from_registry(capsule_name)
68
+
69
+ unless capsule
70
+ return {
71
+ success: false,
72
+ message: "Capsule '#{capsule_name}' not found in registry"
73
+ }
74
+ end
75
+
76
+ execute(capsule, actor: actor, confirmation: confirmation, dry_run: dry_run)
77
+ end
78
+
79
+ private
80
+
81
+ def validate_capsule!(capsule)
82
+ raise ArgumentError, "Capsule must be a PgSqlTriggers::SQL::Capsule" unless capsule.is_a?(Capsule)
83
+ end
84
+
85
+ def check_permissions!(actor)
86
+ PgSqlTriggers::Permissions.check!(actor, :execute_sql)
87
+ rescue PgSqlTriggers::PermissionError => e
88
+ raise PgSqlTriggers::PermissionError,
89
+ "SQL capsule execution requires Admin role: #{e.message}"
90
+ end
91
+
92
+ def check_kill_switch!(_capsule, actor, confirmation)
93
+ PgSqlTriggers::SQL::KillSwitch.check!(
94
+ operation: :execute_sql_capsule,
95
+ environment: Rails.env,
96
+ confirmation: confirmation,
97
+ actor: actor
98
+ )
99
+ end
100
+
101
+ def execute_in_transaction(capsule, _actor)
102
+ ActiveRecord::Base.transaction do
103
+ result = ActiveRecord::Base.connection.execute(capsule.sql)
104
+
105
+ {
106
+ success: true,
107
+ message: "SQL capsule '#{capsule.name}' executed successfully",
108
+ data: {
109
+ checksum: capsule.checksum,
110
+ rows_affected: result.cmd_tuples || 0
111
+ }
112
+ }
113
+ end
114
+ end
115
+
116
+ def update_registry(capsule)
117
+ # Check if capsule already exists in registry
118
+ registry_entry = PgSqlTriggers::TriggerRegistry.find_or_initialize_by(
119
+ trigger_name: capsule.registry_trigger_name
120
+ )
121
+
122
+ registry_entry.assign_attributes(
123
+ table_name: "manual_sql_execution",
124
+ version: Time.current.to_i,
125
+ checksum: capsule.checksum,
126
+ source: "manual_sql",
127
+ function_body: capsule.sql,
128
+ condition: capsule.purpose,
129
+ environment: capsule.environment,
130
+ enabled: true,
131
+ last_executed_at: Time.current
132
+ )
133
+
134
+ registry_entry.save!
135
+ rescue StandardError => e
136
+ logger&.error "[SQL_CAPSULE] Failed to update registry: #{e.message}"
137
+ # Don't fail the execution if registry update fails
138
+ end
139
+
140
+ def load_capsule_from_registry(capsule_name)
141
+ trigger_name = "sql_capsule_#{capsule_name}"
142
+ registry_entry = PgSqlTriggers::TriggerRegistry.find_by(
143
+ trigger_name: trigger_name,
144
+ source: "manual_sql"
145
+ )
146
+
147
+ return nil unless registry_entry
148
+
149
+ Capsule.new(
150
+ name: capsule_name,
151
+ environment: registry_entry.environment || Rails.env.to_s,
152
+ purpose: registry_entry.condition || "No purpose specified",
153
+ sql: registry_entry.function_body,
154
+ created_at: registry_entry.created_at
155
+ )
156
+ end
157
+
158
+ # Logging methods
159
+
160
+ def log_execution_attempt(capsule, actor, dry_run)
161
+ mode = dry_run ? "DRY_RUN" : "EXECUTE"
162
+ logger&.info "[SQL_CAPSULE] #{mode} ATTEMPT: name=#{capsule.name} " \
163
+ "environment=#{capsule.environment} actor=#{format_actor(actor)}"
164
+ end
165
+
166
+ def log_execution_result(capsule, actor, result)
167
+ status = result[:success] ? "SUCCESS" : "FAILED"
168
+ logger&.info "[SQL_CAPSULE] #{status}: name=#{capsule.name} " \
169
+ "environment=#{capsule.environment} actor=#{format_actor(actor)} " \
170
+ "checksum=#{capsule.checksum}"
171
+ end
172
+
173
+ def log_execution_error(capsule, actor, error)
174
+ # Handle case where capsule might not be valid
175
+ capsule_name = capsule.respond_to?(:name) ? capsule.name : "invalid_capsule"
176
+ environment = capsule.respond_to?(:environment) ? capsule.environment : "unknown"
177
+
178
+ logger&.error "[SQL_CAPSULE] ERROR: name=#{capsule_name} " \
179
+ "environment=#{environment} actor=#{format_actor(actor)} " \
180
+ "error=#{error.class.name} message=#{error.message}"
181
+ end
182
+
183
+ def format_actor(actor)
184
+ return "unknown" if actor.nil?
185
+ return actor.to_s unless actor.is_a?(Hash)
186
+
187
+ "#{actor[:type] || 'unknown'}:#{actor[:id] || 'unknown'}"
188
+ end
189
+
190
+ def logger
191
+ if PgSqlTriggers.respond_to?(:logger) && PgSqlTriggers.logger
192
+ PgSqlTriggers.logger
193
+ elsif defined?(Rails) && Rails.respond_to?(:logger)
194
+ Rails.logger
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -11,5 +11,5 @@
11
11
  # 3. Run: bundle exec rake release
12
12
  # See RELEASE.md for detailed release instructions
13
13
  module PgSqlTriggers
14
- VERSION = "1.1.1"
14
+ VERSION = "1.2.0"
15
15
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_sql_triggers
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - samaswin
@@ -201,7 +201,9 @@ files:
201
201
  - app/controllers/pg_sql_triggers/dashboard_controller.rb
202
202
  - app/controllers/pg_sql_triggers/generator_controller.rb
203
203
  - app/controllers/pg_sql_triggers/migrations_controller.rb
204
+ - app/controllers/pg_sql_triggers/sql_capsules_controller.rb
204
205
  - app/controllers/pg_sql_triggers/tables_controller.rb
206
+ - app/controllers/pg_sql_triggers/triggers_controller.rb
205
207
  - app/models/pg_sql_triggers/application_record.rb
206
208
  - app/models/pg_sql_triggers/trigger_registry.rb
207
209
  - app/views/layouts/pg_sql_triggers/application.html.erb
@@ -210,8 +212,13 @@ files:
210
212
  - app/views/pg_sql_triggers/generator/preview.html.erb
211
213
  - app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb
212
214
  - app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb
215
+ - app/views/pg_sql_triggers/sql_capsules/new.html.erb
216
+ - app/views/pg_sql_triggers/sql_capsules/show.html.erb
213
217
  - app/views/pg_sql_triggers/tables/index.html.erb
214
218
  - app/views/pg_sql_triggers/tables/show.html.erb
219
+ - app/views/pg_sql_triggers/triggers/_drop_modal.html.erb
220
+ - app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb
221
+ - app/views/pg_sql_triggers/triggers/show.html.erb
215
222
  - config/initializers/pg_sql_triggers.rb
216
223
  - config/routes.rb
217
224
  - db/migrate/20251222000001_create_pg_sql_triggers_tables.rb
@@ -258,6 +265,8 @@ files:
258
265
  - lib/pg_sql_triggers/registry/manager.rb
259
266
  - lib/pg_sql_triggers/registry/validator.rb
260
267
  - lib/pg_sql_triggers/sql.rb
268
+ - lib/pg_sql_triggers/sql/capsule.rb
269
+ - lib/pg_sql_triggers/sql/executor.rb
261
270
  - lib/pg_sql_triggers/sql/kill_switch.rb
262
271
  - lib/pg_sql_triggers/testing.rb
263
272
  - lib/pg_sql_triggers/testing/dry_run.rb