pg_sql_triggers 1.2.0 → 1.4.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +397 -1
  3. data/COVERAGE.md +26 -19
  4. data/GEM_ANALYSIS.md +368 -0
  5. data/Goal.md +276 -155
  6. data/README.md +45 -22
  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 +4 -9
  14. data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
  15. data/app/controllers/pg_sql_triggers/triggers_controller.rb +3 -21
  16. data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
  17. data/app/models/pg_sql_triggers/audit_log.rb +106 -0
  18. data/app/models/pg_sql_triggers/trigger_registry.rb +218 -13
  19. data/app/views/layouts/pg_sql_triggers/application.html.erb +25 -6
  20. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
  21. data/app/views/pg_sql_triggers/dashboard/index.html.erb +34 -12
  22. data/app/views/pg_sql_triggers/tables/index.html.erb +75 -5
  23. data/app/views/pg_sql_triggers/tables/show.html.erb +17 -6
  24. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +16 -7
  25. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +16 -7
  26. data/app/views/pg_sql_triggers/triggers/show.html.erb +26 -6
  27. data/config/routes.rb +2 -14
  28. data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
  29. data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  30. data/docs/README.md +15 -5
  31. data/docs/api-reference.md +233 -151
  32. data/docs/audit-trail.md +413 -0
  33. data/docs/configuration.md +28 -7
  34. data/docs/getting-started.md +17 -16
  35. data/docs/permissions.md +369 -0
  36. data/docs/troubleshooting.md +486 -0
  37. data/docs/ui-guide.md +211 -0
  38. data/docs/usage-guide.md +38 -67
  39. data/docs/web-ui.md +251 -128
  40. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  41. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  42. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  43. data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
  44. data/lib/pg_sql_triggers/drift/detector.rb +51 -38
  45. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
  46. data/lib/pg_sql_triggers/engine.rb +14 -0
  47. data/lib/pg_sql_triggers/errors.rb +245 -0
  48. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
  49. data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
  50. data/lib/pg_sql_triggers/migrator.rb +53 -6
  51. data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
  52. data/lib/pg_sql_triggers/registry/manager.rb +36 -11
  53. data/lib/pg_sql_triggers/registry/validator.rb +62 -5
  54. data/lib/pg_sql_triggers/registry.rb +141 -8
  55. data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -247
  56. data/lib/pg_sql_triggers/sql.rb +0 -6
  57. data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
  58. data/lib/pg_sql_triggers/version.rb +1 -1
  59. data/lib/pg_sql_triggers.rb +7 -7
  60. data/pg_sql_triggers.gemspec +53 -0
  61. metadata +35 -18
  62. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  63. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  64. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  65. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  66. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  67. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  68. data/docs/screenshots/.gitkeep +0 -1
  69. data/docs/screenshots/Generate Trigger.png +0 -0
  70. data/docs/screenshots/Triggers Page.png +0 -0
  71. data/docs/screenshots/kill error.png +0 -0
  72. data/docs/screenshots/kill modal for migration down.png +0 -0
  73. data/lib/generators/trigger/migration_generator.rb +0 -60
  74. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  75. data/lib/pg_sql_triggers/generator/service.rb +0 -307
  76. data/lib/pg_sql_triggers/generator.rb +0 -8
  77. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  78. data/lib/pg_sql_triggers/sql/executor.rb +0 -200
@@ -1,307 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "digest"
5
- require "active_support/core_ext/string/inflections"
6
-
7
- module PgSqlTriggers
8
- module Generator
9
- # rubocop:disable Metrics/ClassLength
10
- class Service
11
- class << self
12
- def generate_dsl(form)
13
- # Generate DSL trigger definition
14
- events_list = form.events.compact_blank.map { |e| ":#{e}" }.join(", ")
15
- environments_list = form.environments.compact_blank.map { |e| ":#{e}" }.join(", ")
16
-
17
- # Format function name (can be string or symbol)
18
- function_ref = if form.function_name.to_s.match?(/\A[a-z0-9_]+\z/)
19
- ":#{form.function_name}"
20
- else
21
- "\"#{form.function_name}\""
22
- end
23
-
24
- code = <<~RUBY
25
- # frozen_string_literal: true
26
-
27
- # Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
28
- PgSqlTriggers::DSL.pg_sql_trigger "#{form.trigger_name}" do
29
- table :#{form.table_name}
30
- on #{events_list}
31
- function #{function_ref}
32
- #{' '}
33
- version #{form.version}
34
- enabled #{form.enabled}
35
- timing :#{form.timing || 'before'}
36
- RUBY
37
-
38
- code += " when_env #{environments_list}\n" if form.environments.compact_blank.any?
39
-
40
- code += " when_condition \"#{form.condition.gsub('"', '\\"')}\"\n" if form.condition.present?
41
-
42
- code += "end\n"
43
-
44
- code
45
- end
46
-
47
- def generate_migration(form)
48
- # Generate migration class code
49
- # Use Rails migration naming convention: Add{TriggerName}
50
- # This avoids Zeitwerk autoload conflicts with app/triggers files
51
- class_name = "Add#{form.trigger_name.camelize}"
52
- events_sql = form.events.compact_blank.map(&:upcase).join(" OR ")
53
- function_body_sql = form.function_body.strip
54
-
55
- # Build the trigger creation SQL
56
- timing_value = (form.timing || "before").upcase
57
- trigger_sql = "CREATE TRIGGER #{form.trigger_name}\n"
58
- trigger_sql += "#{timing_value} #{events_sql} ON #{form.table_name}\n"
59
- trigger_sql += "FOR EACH ROW\n"
60
- trigger_sql += "WHEN (#{form.condition})\n" if form.condition.present?
61
- trigger_sql += "EXECUTE FUNCTION #{form.function_name}();"
62
-
63
- # Build the down method SQL
64
- down_sql = "DROP TRIGGER IF EXISTS #{form.trigger_name} ON #{form.table_name};\n"
65
- down_sql += "DROP FUNCTION IF EXISTS #{form.function_name}();"
66
-
67
- # Indent SQL strings to match heredoc indentation (18 spaces)
68
- indented_function_body = indent_sql(function_body_sql, 18)
69
- indented_trigger_sql = indent_sql(trigger_sql, 18)
70
- indented_down_sql = indent_sql(down_sql, 18)
71
-
72
- <<~RUBY
73
- # frozen_string_literal: true
74
-
75
- # Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
76
- class #{class_name} < PgSqlTriggers::Migration
77
- def up
78
- # Create the function
79
- execute <<-SQL
80
- #{indented_function_body}
81
- SQL
82
-
83
- # Create the trigger
84
- execute <<-SQL
85
- #{indented_trigger_sql}
86
- SQL
87
- end
88
-
89
- def down
90
- execute <<-SQL
91
- #{indented_down_sql}
92
- SQL
93
- end
94
- end
95
- RUBY
96
- end
97
-
98
- def generate_function_stub(form)
99
- return nil unless form.generate_function_stub
100
-
101
- # Generate PL/pgSQL function template
102
- events_display = form.events.compact_blank.join(", ").upcase
103
-
104
- <<~SQL
105
- -- PL/pgSQL function for trigger: #{form.trigger_name}
106
- -- Table: #{form.table_name}
107
- -- Events: #{events_display}
108
- -- Generated by pg_sql_triggers on #{Time.current.strftime('%Y-%m-%d %H:%M:%S')}
109
- --
110
- -- TODO: Implement your trigger logic below
111
-
112
- CREATE OR REPLACE FUNCTION #{form.function_name}()
113
- RETURNS TRIGGER AS $$
114
- BEGIN
115
- -- Access OLD record: OLD.column_name (for UPDATE, DELETE)
116
- -- Access NEW record: NEW.column_name (for INSERT, UPDATE)
117
-
118
- -- Example: Validate a field
119
- -- IF NEW.some_field > threshold THEN
120
- -- RAISE EXCEPTION 'Validation failed: %', NEW.some_field;
121
- -- END IF;
122
-
123
- -- TODO: Add your validation/business logic here
124
- RAISE NOTICE 'Trigger function % called for %', TG_NAME, TG_OP;
125
-
126
- -- For INSERT/UPDATE triggers, return NEW
127
- -- For DELETE triggers, return OLD
128
- -- For TRUNCATE triggers, return NULL
129
- IF TG_OP = 'DELETE' THEN
130
- RETURN OLD;
131
- ELSIF TG_OP = 'TRUNCATE' THEN
132
- RETURN NULL;
133
- ELSE
134
- RETURN NEW;
135
- END IF;
136
- END;
137
- $$ LANGUAGE plpgsql;
138
- SQL
139
- end
140
-
141
- def file_paths(form)
142
- # NOTE: These paths are relative to the host Rails app, not the gem
143
- # Generate both migration file and DSL file
144
- migration_version = next_migration_number
145
- {
146
- migration: "db/triggers/#{migration_version}_#{form.trigger_name}.rb",
147
- dsl: "app/triggers/#{form.trigger_name}.rb"
148
- }
149
- end
150
-
151
- def create_trigger(form, actor: nil) # rubocop:disable Lint/UnusedMethodArgument
152
- paths = file_paths(form)
153
- base_path = rails_base_path
154
-
155
- create_trigger_files(form, paths, base_path)
156
- registry = register_trigger(form)
157
-
158
- build_success_response(registry, paths, form)
159
- rescue StandardError => e
160
- log_error(e)
161
- build_error_response(e)
162
- end
163
-
164
- private
165
-
166
- def indent_sql(sql_string, indent_level)
167
- indent = " " * indent_level
168
- sql_string.lines.map do |line|
169
- stripped = line.chomp
170
- stripped.empty? ? "" : indent + stripped
171
- end.join("\n")
172
- end
173
-
174
- def rails_base_path
175
- defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
176
- end
177
-
178
- def create_trigger_files(form, paths, base_path)
179
- full_migration_path = base_path.join(paths[:migration])
180
- full_dsl_path = base_path.join(paths[:dsl])
181
-
182
- FileUtils.mkdir_p(full_migration_path.dirname)
183
- FileUtils.mkdir_p(full_dsl_path.dirname)
184
-
185
- migration_content = generate_migration(form)
186
- dsl_content = generate_dsl(form)
187
-
188
- File.write(full_migration_path, migration_content)
189
- File.write(full_dsl_path, dsl_content)
190
- end
191
-
192
- def register_trigger(form)
193
- definition = build_trigger_definition(form)
194
- attributes = build_registry_attributes(form, definition)
195
- TriggerRegistry.create!(attributes)
196
- end
197
-
198
- def build_trigger_definition(form)
199
- {
200
- name: form.trigger_name,
201
- table_name: form.table_name,
202
- events: form.events.compact_blank,
203
- function_name: form.function_name,
204
- version: form.version,
205
- enabled: form.enabled,
206
- environments: form.environments.compact_blank,
207
- condition: form.condition,
208
- timing: form.timing || "before",
209
- function_body: form.function_body
210
- }
211
- end
212
-
213
- def build_registry_attributes(form, definition)
214
- attributes = {
215
- trigger_name: form.trigger_name,
216
- table_name: form.table_name,
217
- version: form.version,
218
- enabled: form.enabled,
219
- source: "dsl",
220
- environment: form.environments.compact_blank.join(",").presence,
221
- definition: definition.to_json,
222
- function_body: form.function_body,
223
- checksum: calculate_checksum(definition)
224
- }
225
-
226
- add_conditional_attributes(attributes, form)
227
- attributes
228
- end
229
-
230
- def add_conditional_attributes(attributes, form)
231
- column_names = TriggerRegistry.column_names
232
-
233
- attributes[:condition] = form.condition.presence if column_names.include?("condition")
234
- attributes[:timing] = (form.timing || "before") if column_names.include?("timing")
235
- end
236
-
237
- def build_success_response(registry, paths, form)
238
- {
239
- success: true,
240
- registry_id: registry.id,
241
- migration_path: paths[:migration],
242
- dsl_path: paths[:dsl],
243
- metadata: {
244
- trigger_name: form.trigger_name,
245
- table_name: form.table_name,
246
- events: form.events.compact_blank,
247
- files_created: [paths[:migration], paths[:dsl]]
248
- }
249
- }
250
- end
251
-
252
- def log_error(error)
253
- return unless defined?(Rails)
254
-
255
- Rails.logger.error("Trigger generation failed: #{error.message}")
256
- Rails.logger.error(error.backtrace.join("\n"))
257
- end
258
-
259
- def build_error_response(error)
260
- {
261
- success: false,
262
- error: error.message
263
- }
264
- end
265
-
266
- def next_migration_number
267
- # Determine if we're in a Rails app context or standalone gem
268
- base_path = defined?(Rails) && Rails.respond_to?(:root) ? Rails.root : Pathname.new(Dir.pwd)
269
- triggers_path = base_path.join("db", "triggers")
270
-
271
- # Get the highest migration number from existing migrations
272
- existing = if Dir.exist?(triggers_path)
273
- Dir.glob(triggers_path.join("*.rb"))
274
- .map { |f| File.basename(f, ".rb").split("_").first.to_i }
275
- .reject(&:zero?)
276
- .max || 0
277
- else
278
- 0
279
- end
280
-
281
- # Generate next timestamp-based version
282
- # Format: YYYYMMDDHHMMSS
283
- now = Time.now.utc
284
- base = now.strftime("%Y%m%d%H%M%S").to_i
285
-
286
- # If we have existing migrations, ensure we're incrementing
287
- base = existing + 1 if existing.positive? && base <= existing
288
-
289
- base
290
- end
291
-
292
- def calculate_checksum(definition)
293
- # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
294
- Digest::SHA256.hexdigest([
295
- definition[:name],
296
- definition[:table_name],
297
- definition[:version],
298
- definition[:function_body] || "",
299
- definition[:condition] || "",
300
- definition[:timing] || "before"
301
- ].join)
302
- end
303
- end
304
- end
305
- # rubocop:enable Metrics/ClassLength
306
- end
307
- end
@@ -1,8 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module PgSqlTriggers
4
- module Generator
5
- autoload :Service, "pg_sql_triggers/generator/service"
6
- autoload :Form, "pg_sql_triggers/generator/form"
7
- end
8
- end
@@ -1,79 +0,0 @@
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
@@ -1,200 +0,0 @@
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