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,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
@@ -143,14 +143,23 @@ module PgSqlTriggers
143
143
  expected = expected_confirmation(operation)
144
144
 
145
145
  if confirmation.nil? || confirmation.strip.empty?
146
- raise PgSqlTriggers::KillSwitchError,
147
- "Confirmation text required. Expected: '#{expected}'"
146
+ raise PgSqlTriggers::KillSwitchError.new(
147
+ "Confirmation text required. Expected: '#{expected}'",
148
+ error_code: "KILL_SWITCH_CONFIRMATION_REQUIRED",
149
+ recovery_suggestion: "Provide the confirmation text: #{expected}",
150
+ context: { operation: operation, expected_confirmation: expected }
151
+ )
148
152
  end
149
153
 
150
154
  return if confirmation.strip == expected
151
155
 
152
- raise PgSqlTriggers::KillSwitchError,
153
- "Invalid confirmation text. Expected: '#{expected}', got: '#{confirmation.strip}'"
156
+ raise PgSqlTriggers::KillSwitchError.new(
157
+ "Invalid confirmation text. Expected: '#{expected}', got: '#{confirmation.strip}'",
158
+ error_code: "KILL_SWITCH_CONFIRMATION_INVALID",
159
+ recovery_suggestion: "Use the exact confirmation text: #{expected}",
160
+ context: { operation: operation, expected_confirmation: expected,
161
+ provided_confirmation: confirmation.strip }
162
+ )
154
163
  end
155
164
 
156
165
  private
@@ -291,7 +300,26 @@ module PgSqlTriggers
291
300
  Make sure you understand the implications before proceeding.
292
301
  ERROR
293
302
 
294
- raise PgSqlTriggers::KillSwitchError, message
303
+ recovery = <<~RECOVERY
304
+ To override, provide the confirmation text: #{expected}
305
+
306
+ For CLI/rake tasks:
307
+ KILL_SWITCH_OVERRIDE=true CONFIRMATION_TEXT="#{expected}" rake your:task
308
+
309
+ For console operations:
310
+ PgSqlTriggers::SQL::KillSwitch.override(confirmation: "#{expected}") do
311
+ # your operation here
312
+ end
313
+
314
+ For UI operations, enter the confirmation text in the modal.
315
+ RECOVERY
316
+
317
+ raise PgSqlTriggers::KillSwitchError.new(
318
+ message,
319
+ error_code: "KILL_SWITCH_ACTIVE",
320
+ recovery_suggestion: recovery.strip,
321
+ context: { operation: operation, environment: environment, expected_confirmation: expected }
322
+ )
295
323
  end
296
324
  end
297
325
  end
@@ -110,6 +110,8 @@ module PgSqlTriggers
110
110
  results[:function_executed] = false
111
111
  results[:success] = false
112
112
  results[:errors] << "Error during function verification: #{e.message}"
113
+ # Also add the original error message to ensure it's searchable in tests
114
+ results[:errors] << e.message unless results[:errors].include?(e.message)
113
115
  results[:output] << "✓ Function created (verification failed)"
114
116
  end
115
117
  else
@@ -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.3.0"
15
15
  end
@@ -1,15 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "pg_sql_triggers/version"
4
+ require_relative "pg_sql_triggers/errors"
4
5
  require_relative "pg_sql_triggers/engine"
5
6
 
6
7
  module PgSqlTriggers
7
- class Error < StandardError; end
8
- class PermissionError < Error; end
9
- class DriftError < Error; end
10
- class KillSwitchError < Error; end
11
- class ValidationError < Error; end
12
- class UnsafeMigrationError < Error; end
8
+ # Error classes are defined in lib/pg_sql_triggers/errors.rb
9
+ # They include error codes, standardized messages, and recovery suggestions
13
10
 
14
11
  # Configuration
15
12
  mattr_accessor :kill_switch_enabled
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - samaswin
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: pg
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -196,36 +210,51 @@ files:
196
210
  - RELEASE.md
197
211
  - Rakefile
198
212
  - app/assets/javascripts/pg_sql_triggers/application.js
213
+ - app/assets/javascripts/pg_sql_triggers/trigger_actions.js
199
214
  - app/assets/stylesheets/pg_sql_triggers/application.css
215
+ - app/controllers/concerns/pg_sql_triggers/error_handling.rb
216
+ - app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb
217
+ - app/controllers/concerns/pg_sql_triggers/permission_checking.rb
200
218
  - app/controllers/pg_sql_triggers/application_controller.rb
219
+ - app/controllers/pg_sql_triggers/audit_logs_controller.rb
201
220
  - app/controllers/pg_sql_triggers/dashboard_controller.rb
202
221
  - app/controllers/pg_sql_triggers/generator_controller.rb
203
222
  - app/controllers/pg_sql_triggers/migrations_controller.rb
223
+ - app/controllers/pg_sql_triggers/sql_capsules_controller.rb
204
224
  - app/controllers/pg_sql_triggers/tables_controller.rb
225
+ - app/controllers/pg_sql_triggers/triggers_controller.rb
226
+ - app/helpers/pg_sql_triggers/permissions_helper.rb
205
227
  - app/models/pg_sql_triggers/application_record.rb
228
+ - app/models/pg_sql_triggers/audit_log.rb
206
229
  - app/models/pg_sql_triggers/trigger_registry.rb
207
230
  - app/views/layouts/pg_sql_triggers/application.html.erb
231
+ - app/views/pg_sql_triggers/audit_logs/index.html.erb
208
232
  - app/views/pg_sql_triggers/dashboard/index.html.erb
209
233
  - app/views/pg_sql_triggers/generator/new.html.erb
210
234
  - app/views/pg_sql_triggers/generator/preview.html.erb
211
235
  - app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb
212
236
  - app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb
237
+ - app/views/pg_sql_triggers/sql_capsules/new.html.erb
238
+ - app/views/pg_sql_triggers/sql_capsules/show.html.erb
213
239
  - app/views/pg_sql_triggers/tables/index.html.erb
214
240
  - app/views/pg_sql_triggers/tables/show.html.erb
241
+ - app/views/pg_sql_triggers/triggers/_drop_modal.html.erb
242
+ - app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb
243
+ - app/views/pg_sql_triggers/triggers/show.html.erb
215
244
  - config/initializers/pg_sql_triggers.rb
216
245
  - config/routes.rb
217
246
  - db/migrate/20251222000001_create_pg_sql_triggers_tables.rb
218
247
  - db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb
248
+ - db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb
219
249
  - docs/README.md
220
250
  - docs/api-reference.md
251
+ - docs/audit-trail.md
221
252
  - docs/configuration.md
222
253
  - docs/getting-started.md
223
254
  - docs/kill-switch.md
224
- - docs/screenshots/.gitkeep
225
- - docs/screenshots/Generate Trigger.png
226
- - docs/screenshots/Triggers Page.png
227
- - docs/screenshots/kill error.png
228
- - docs/screenshots/kill modal for migration down.png
255
+ - docs/permissions.md
256
+ - docs/troubleshooting.md
257
+ - docs/ui-guide.md
229
258
  - docs/usage-guide.md
230
259
  - docs/web-ui.md
231
260
  - lib/generators/pg_sql_triggers/install_generator.rb
@@ -244,6 +273,7 @@ files:
244
273
  - lib/pg_sql_triggers/dsl.rb
245
274
  - lib/pg_sql_triggers/dsl/trigger_definition.rb
246
275
  - lib/pg_sql_triggers/engine.rb
276
+ - lib/pg_sql_triggers/errors.rb
247
277
  - lib/pg_sql_triggers/generator.rb
248
278
  - lib/pg_sql_triggers/generator/form.rb
249
279
  - lib/pg_sql_triggers/generator/service.rb
@@ -258,6 +288,8 @@ files:
258
288
  - lib/pg_sql_triggers/registry/manager.rb
259
289
  - lib/pg_sql_triggers/registry/validator.rb
260
290
  - lib/pg_sql_triggers/sql.rb
291
+ - lib/pg_sql_triggers/sql/capsule.rb
292
+ - lib/pg_sql_triggers/sql/executor.rb
261
293
  - lib/pg_sql_triggers/sql/kill_switch.rb
262
294
  - lib/pg_sql_triggers/testing.rb
263
295
  - lib/pg_sql_triggers/testing/dry_run.rb
@@ -1 +0,0 @@
1
- # This directory is for storing screenshot images referenced in the documentation.
Binary file
Binary file
Binary file