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