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
@@ -12,25 +12,24 @@ module PgSqlTriggers
12
12
  # Compare expected state from migration with actual database state
13
13
  # Returns a comparison result with diff information
14
14
  def compare(migration_instance, direction: :up)
15
- expected = extract_expected_state(migration_instance, direction)
15
+ captured_sql = capture_sql(migration_instance, direction)
16
+ compare_sql(captured_sql)
17
+ end
18
+
19
+ # Compare using pre-captured SQL (avoids re-instantiating the migration class).
20
+ # Returns a comparison result with diff information.
21
+ def compare_sql(captured_sql)
22
+ expected = parse_sql_to_state(captured_sql)
16
23
  actual = extract_actual_state(expected)
17
24
  generate_diff(expected, actual)
18
25
  end
19
26
 
20
27
  private
21
28
 
22
- # Extract expected SQL and state from migration instance
23
- def extract_expected_state(migration_instance, direction)
24
- captured_sql = capture_sql(migration_instance, direction)
25
- parse_sql_to_state(captured_sql)
26
- end
27
-
28
29
  # Capture SQL that would be executed by the migration
29
30
  def capture_sql(migration_instance, direction)
30
31
  captured = []
31
32
 
32
- # Override execute to capture SQL instead of executing
33
- # Since we use a separate instance for comparison, we don't need to restore
34
33
  migration_instance.define_singleton_method(:execute) do |sql|
35
34
  captured << sql.to_s.strip
36
35
  end
@@ -34,26 +34,41 @@ module PgSqlTriggers
34
34
  raise UnsafeOperationError.new(error_message, violations)
35
35
  end
36
36
 
37
+ # Validate using pre-captured SQL (avoids re-instantiating the migration class).
38
+ # Raises UnsafeOperationError if unsafe patterns are detected.
39
+ def validate_sql!(captured_sql, direction: :up, allow_unsafe: false) # rubocop:disable Lint/UnusedMethodArgument
40
+ return if allow_unsafe
41
+
42
+ violations = detect_unsafe_patterns_from_sql(captured_sql)
43
+ return if violations.empty?
44
+
45
+ error_message = build_error_message(violations, "(migration)")
46
+ raise UnsafeOperationError.new(error_message, violations)
47
+ end
48
+
37
49
  # Detect unsafe patterns in migration SQL
38
50
  # Returns array of violation hashes
39
51
  def detect_unsafe_patterns(migration_instance, direction)
40
- violations = []
41
-
42
- # Capture SQL that would be executed
43
52
  captured_sql = capture_sql(migration_instance, direction)
53
+ detect_unsafe_patterns_from_sql(captured_sql)
54
+ end
44
55
 
45
- # Parse SQL to detect unsafe patterns
46
- sql_operations = parse_sql_operations(captured_sql)
56
+ private
47
57
 
48
- # Check for explicit DROP + CREATE patterns (the main safety concern)
58
+ # Core detection logic that works on pre-captured SQL.
59
+ def detect_unsafe_patterns_from_sql(captured_sql)
60
+ violations = []
61
+ sql_operations = parse_sql_operations(captured_sql)
49
62
  violations.concat(detect_drop_create_patterns(sql_operations))
50
-
51
63
  violations
52
64
  end
53
65
 
54
- private
55
-
56
- # Capture SQL that would be executed by the migration
66
+ # Capture SQL that would be executed by the migration.
67
+ # Runs the migration inside a transaction that is always rolled back so
68
+ # that any ActiveRecord migration helpers (add_column, create_table, …)
69
+ # that are called alongside explicit +execute+ calls produce no lasting
70
+ # side effects. The singleton override of +execute+ still intercepts
71
+ # raw SQL strings before they reach the database.
57
72
  def capture_sql(migration_instance, direction)
58
73
  captured = []
59
74
 
@@ -62,8 +77,13 @@ module PgSqlTriggers
62
77
  captured << sql.to_s.strip
63
78
  end
64
79
 
65
- # Call the migration method (up or down) to capture SQL
66
- migration_instance.public_send(direction)
80
+ # Run inside a transaction and roll it back so that any AR helpers
81
+ # called by the migration (add_column, create_table, etc.) do not
82
+ # persist their side effects during the capture phase.
83
+ ActiveRecord::Base.transaction do
84
+ migration_instance.public_send(direction)
85
+ raise ActiveRecord::Rollback
86
+ end
67
87
 
68
88
  captured
69
89
  end
@@ -191,13 +191,17 @@ module PgSqlTriggers
191
191
  end
192
192
  end
193
193
 
194
+ # Capture SQL once from a single inspection instance so that both the
195
+ # safety validator and comparator work from the same snapshot without
196
+ # running the migration code a second time.
197
+ captured_sql = capture_migration_sql(migration_class.new, direction)
198
+
194
199
  # Perform safety validation (prevent unsafe DROP + CREATE operations)
195
- validation_instance = migration_class.new
196
200
  begin
197
201
  allow_unsafe = ENV["ALLOW_UNSAFE_MIGRATIONS"] == "true" ||
198
202
  (defined?(PgSqlTriggers) && PgSqlTriggers.allow_unsafe_migrations == true)
199
203
 
200
- SafetyValidator.validate!(validation_instance, direction: direction, allow_unsafe: allow_unsafe)
204
+ SafetyValidator.validate_sql!(captured_sql, direction: direction, allow_unsafe: allow_unsafe)
201
205
  rescue SafetyValidator::UnsafeOperationError => e
202
206
  # Safety validation failed - block the migration
203
207
  error_msg = "\n#{e.message}\n\n"
@@ -212,11 +216,9 @@ module PgSqlTriggers
212
216
  end
213
217
  end
214
218
 
215
- # Perform pre-apply comparison (diff expected vs actual)
216
- # Create a separate instance for comparison to avoid side effects
217
- comparison_instance = migration_class.new
219
+ # Perform pre-apply comparison (diff expected vs actual) using the same captured SQL
218
220
  begin
219
- diff_result = PreApplyComparator.compare(comparison_instance, direction: direction)
221
+ diff_result = PreApplyComparator.compare_sql(captured_sql)
220
222
 
221
223
  # Log the comparison result
222
224
  if diff_result[:has_differences]
@@ -261,6 +263,8 @@ module PgSqlTriggers
261
263
  cleanup_orphaned_registry_entries
262
264
  end
263
265
  end
266
+
267
+ enforce_disabled_triggers if direction == :up
264
268
  rescue LoadError => e
265
269
  raise StandardError, "Error loading trigger migration #{migration.filename}: #{e.message}"
266
270
  rescue StandardError => e
@@ -295,6 +299,49 @@ module PgSqlTriggers
295
299
  current_version
296
300
  end
297
301
 
302
+ private
303
+
304
+ # Capture the SQL statements a migration would execute for a given direction
305
+ # without committing any side effects. The migration's +execute+ method is
306
+ # overridden on the singleton so raw SQL strings are intercepted, and the
307
+ # whole run is wrapped in a transaction that is always rolled back so that
308
+ # any ActiveRecord migration helpers (add_column, create_table, …) don't
309
+ # persist their effects during the inspection phase.
310
+ def capture_migration_sql(migration_instance, direction)
311
+ captured = []
312
+
313
+ migration_instance.define_singleton_method(:execute) do |sql|
314
+ captured << sql.to_s.strip
315
+ end
316
+
317
+ ActiveRecord::Base.transaction do
318
+ migration_instance.public_send(direction)
319
+ raise ActiveRecord::Rollback
320
+ end
321
+
322
+ captured
323
+ end
324
+
325
+ def enforce_disabled_triggers
326
+ return unless ActiveRecord::Base.connection.table_exists?("pg_sql_triggers_registry")
327
+
328
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
329
+ PgSqlTriggers::TriggerRegistry.disabled.each do |registry|
330
+ next unless introspection.trigger_exists?(registry.trigger_name)
331
+
332
+ conn = ActiveRecord::Base.connection
333
+ quoted_table = conn.quote_table_name(registry.table_name.to_s)
334
+ quoted_trigger = conn.quote_table_name(registry.trigger_name.to_s)
335
+ conn.execute("ALTER TABLE #{quoted_table} DISABLE TRIGGER #{quoted_trigger};")
336
+ rescue StandardError => e
337
+ if defined?(Rails.logger)
338
+ Rails.logger.warn("[MIGRATOR] Could not disable trigger #{registry.trigger_name}: #{e.message}")
339
+ end
340
+ end
341
+ end
342
+
343
+ public
344
+
298
345
  # Clean up registry entries for triggers that no longer exist in the database
299
346
  # This is called after rolling back migrations to keep the registry in sync
300
347
  def cleanup_orphaned_registry_entries
@@ -22,8 +22,15 @@ module PgSqlTriggers
22
22
  unless can?(actor, action, environment: environment)
23
23
  action_sym = action.to_sym
24
24
  required_level = Permissions::ACTIONS[action_sym] || "unknown"
25
- raise PgSqlTriggers::PermissionError,
26
- "Permission denied: #{action_sym} requires #{required_level} level access"
25
+ message = "Permission denied: #{action_sym} requires #{required_level} level access"
26
+ recovery = "Contact your administrator to request #{required_level} level access for this operation."
27
+
28
+ raise PgSqlTriggers::PermissionError.new(
29
+ message,
30
+ error_code: "PERMISSION_DENIED",
31
+ recovery_suggestion: recovery,
32
+ context: { action: action_sym, required_role: required_level, environment: environment }
33
+ )
27
34
  end
28
35
  true
29
36
  end
@@ -3,15 +3,19 @@
3
3
  module PgSqlTriggers
4
4
  module Registry
5
5
  class Manager
6
+ REGISTRY_CACHE_MUTEX = Mutex.new
7
+ private_constant :REGISTRY_CACHE_MUTEX
8
+
6
9
  class << self
7
- # Request-level cache to avoid N+1 queries when loading multiple trigger files
8
- # This cache is cleared after each request/transaction to ensure data consistency
10
+ # Request-level cache to avoid N+1 queries when loading multiple trigger files.
11
+ # Access to @_registry_cache is guarded by REGISTRY_CACHE_MUTEX so that
12
+ # concurrent threads cannot observe a partially-initialised hash.
9
13
  def _registry_cache
10
- @_registry_cache ||= {}
14
+ REGISTRY_CACHE_MUTEX.synchronize { @_registry_cache ||= {} }
11
15
  end
12
16
 
13
17
  def _clear_registry_cache
14
- @_registry_cache = {}
18
+ REGISTRY_CACHE_MUTEX.synchronize { @_registry_cache = {} }
15
19
  end
16
20
 
17
21
  # Batch load existing triggers into cache to avoid N+1 queries
@@ -52,7 +56,8 @@ module PgSqlTriggers
52
56
  source: "dsl",
53
57
  environment: definition.environments.join(","),
54
58
  definition: definition.to_h.to_json,
55
- checksum: checksum
59
+ checksum: checksum,
60
+ for_each: definition.for_each || "row"
56
61
  }
57
62
 
58
63
  if existing
@@ -60,6 +65,7 @@ module PgSqlTriggers
60
65
  attributes_changed = attributes.any? do |key, value|
61
66
  existing.send(key) != value
62
67
  end
68
+ enabled_changed = existing.enabled != definition.enabled
63
69
 
64
70
  if attributes_changed
65
71
  begin
@@ -67,6 +73,9 @@ module PgSqlTriggers
67
73
  # Update cache with the modified record (reload to get fresh data)
68
74
  reloaded = existing.reload
69
75
  _registry_cache[trigger_name] = reloaded
76
+ if enabled_changed
77
+ sync_postgresql_enabled_state(existing.trigger_name, existing.table_name, definition.enabled)
78
+ end
70
79
  reloaded
71
80
  rescue ActiveRecord::RecordNotFound
72
81
  # Cached record was deleted, create a new one
@@ -82,6 +91,9 @@ module PgSqlTriggers
82
91
  new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
83
92
  # Cache the newly created record
84
93
  _registry_cache[trigger_name] = new_record
94
+ unless definition.enabled
95
+ sync_postgresql_enabled_state(new_record.trigger_name, new_record.table_name, definition.enabled)
96
+ end
85
97
  new_record
86
98
  end
87
99
  end
@@ -127,22 +139,35 @@ module PgSqlTriggers
127
139
  private
128
140
 
129
141
  def calculate_checksum(definition)
130
- # DSL definitions don't have function_body, so use placeholder
131
- # Generator forms have function_body, so calculate real checksum
132
142
  function_body_value = definition.respond_to?(:function_body) ? definition.function_body : nil
133
- return "placeholder" if function_body_value.blank?
134
143
 
135
- # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
144
+ # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum).
145
+ # DSL definitions have no function_body — use "" so the checksum is real and comparable
146
+ # with what Drift::Detector#calculate_db_checksum computes for DSL-source triggers.
136
147
  require "digest"
137
148
  Digest::SHA256.hexdigest([
138
149
  definition.name,
139
150
  definition.table_name,
140
151
  definition.version,
141
- function_body_value,
152
+ function_body_value || "",
142
153
  definition.condition || "",
143
- definition.timing || "before"
154
+ definition.timing || "before",
155
+ definition.for_each || "row"
144
156
  ].join)
145
157
  end
158
+
159
+ def sync_postgresql_enabled_state(trigger_name, table_name, enabled)
160
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
161
+ return unless introspection.trigger_exists?(trigger_name)
162
+
163
+ conn = ActiveRecord::Base.connection
164
+ quoted_table = conn.quote_table_name(table_name.to_s)
165
+ quoted_trigger = conn.quote_table_name(trigger_name.to_s)
166
+ verb = enabled ? "ENABLE" : "DISABLE"
167
+ conn.execute("ALTER TABLE #{quoted_table} #{verb} TRIGGER #{quoted_trigger};")
168
+ rescue StandardError => e
169
+ Rails.logger.warn("[REGISTER] Could not sync trigger enabled state: #{e.message}") if defined?(Rails.logger)
170
+ end
146
171
  end
147
172
  end
148
173
  end
@@ -1,15 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module PgSqlTriggers
4
6
  module Registry
5
7
  class Validator
6
- # rubocop:disable Naming/PredicateMethod
8
+ VALID_EVENTS = %w[insert update delete truncate].freeze
9
+ VALID_TIMINGS = %w[before after instead_of].freeze
10
+ VALID_FOR_EACH = %w[row statement].freeze
11
+
7
12
  def self.validate!
8
- # Validates all registry entries
9
- # This is a placeholder implementation
10
- true
13
+ errors = []
14
+
15
+ PgSqlTriggers::TriggerRegistry.where(source: "dsl").find_each do |trigger|
16
+ errors.concat(validate_dsl_trigger(trigger))
17
+ end
18
+
19
+ return true if errors.empty?
20
+
21
+ raise PgSqlTriggers::ValidationError.new(
22
+ "Registry validation failed:\n#{errors.map { |e| " - #{e}" }.join("\n")}",
23
+ error_code: "VALIDATION_FAILED",
24
+ context: { errors: errors }
25
+ )
26
+ end
27
+ class << self
28
+ private
29
+
30
+ def validate_dsl_trigger(trigger)
31
+ errors = []
32
+ name = trigger.trigger_name
33
+ definition = parse_definition(trigger.definition)
34
+
35
+ errors << "Trigger '#{name}': missing table_name" if definition["table_name"].blank?
36
+
37
+ events = Array(definition["events"])
38
+ if events.empty?
39
+ errors << "Trigger '#{name}': events cannot be empty"
40
+ else
41
+ invalid = events - VALID_EVENTS
42
+ if invalid.any?
43
+ errors << "Trigger '#{name}': invalid events #{invalid.inspect} (valid: #{VALID_EVENTS.inspect})"
44
+ end
45
+ end
46
+
47
+ errors << "Trigger '#{name}': missing function_name" if definition["function_name"].blank?
48
+
49
+ timing = definition["timing"].to_s
50
+ if timing.present? && VALID_TIMINGS.exclude?(timing)
51
+ errors << "Trigger '#{name}': invalid timing '#{timing}' (valid: #{VALID_TIMINGS.inspect})"
52
+ end
53
+
54
+ for_each = definition["for_each"].to_s
55
+ if for_each.present? && VALID_FOR_EACH.exclude?(for_each)
56
+ errors << "Trigger '#{name}': invalid for_each '#{for_each}' (valid: #{VALID_FOR_EACH.inspect})"
57
+ end
58
+
59
+ errors
60
+ end
61
+
62
+ def parse_definition(definition_json)
63
+ return {} if definition_json.blank?
64
+
65
+ JSON.parse(definition_json)
66
+ rescue JSON::ParserError
67
+ {}
68
+ end
11
69
  end
12
- # rubocop:enable Naming/PredicateMethod
13
70
  end
14
71
  end
15
72
  end
@@ -1,59 +1,187 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgSqlTriggers
4
+ # Registry module provides a unified API for querying and managing triggers.
5
+ #
6
+ # @example Query triggers
7
+ # # List all triggers
8
+ # triggers = PgSqlTriggers::Registry.list
9
+ #
10
+ # # Get enabled/disabled triggers
11
+ # enabled = PgSqlTriggers::Registry.enabled
12
+ # disabled = PgSqlTriggers::Registry.disabled
13
+ #
14
+ # # Get triggers for a specific table
15
+ # user_triggers = PgSqlTriggers::Registry.for_table(:users)
16
+ #
17
+ # # Check for drift
18
+ # drift_info = PgSqlTriggers::Registry.diff
19
+ #
20
+ # @example Manage triggers
21
+ # # Enable a trigger
22
+ # PgSqlTriggers::Registry.enable("users_email_validation",
23
+ # actor: current_user,
24
+ # confirmation: "EXECUTE TRIGGER_ENABLE")
25
+ #
26
+ # # Disable a trigger
27
+ # PgSqlTriggers::Registry.disable("users_email_validation",
28
+ # actor: current_user,
29
+ # confirmation: "EXECUTE TRIGGER_DISABLE")
30
+ #
31
+ # # Drop a trigger
32
+ # PgSqlTriggers::Registry.drop("old_trigger",
33
+ # actor: current_user,
34
+ # reason: "No longer needed",
35
+ # confirmation: "EXECUTE TRIGGER_DROP")
36
+ #
37
+ # # Re-execute a trigger
38
+ # PgSqlTriggers::Registry.re_execute("drifted_trigger",
39
+ # actor: current_user,
40
+ # reason: "Fix drift",
41
+ # confirmation: "EXECUTE TRIGGER_RE_EXECUTE")
4
42
  module Registry
5
43
  autoload :Manager, "pg_sql_triggers/registry/manager"
6
44
  autoload :Validator, "pg_sql_triggers/registry/validator"
7
45
 
46
+ # Registers a trigger definition in the registry.
47
+ #
48
+ # @param definition [PgSqlTriggers::DSL::TriggerDefinition] The trigger definition to register
49
+ # @return [PgSqlTriggers::TriggerRegistry] The registered trigger record
8
50
  def self.register(definition)
9
51
  Manager.register(definition)
10
52
  end
11
53
 
54
+ # Returns all registered triggers.
55
+ #
56
+ # @return [ActiveRecord::Relation<PgSqlTriggers::TriggerRegistry>] All trigger records
12
57
  def self.list
13
58
  Manager.list
14
59
  end
15
60
 
61
+ # Returns only enabled triggers.
62
+ #
63
+ # @return [ActiveRecord::Relation<PgSqlTriggers::TriggerRegistry>] Enabled trigger records
16
64
  def self.enabled
17
65
  Manager.enabled
18
66
  end
19
67
 
68
+ # Returns only disabled triggers.
69
+ #
70
+ # @return [ActiveRecord::Relation<PgSqlTriggers::TriggerRegistry>] Disabled trigger records
20
71
  def self.disabled
21
72
  Manager.disabled
22
73
  end
23
74
 
75
+ # Returns triggers for a specific table.
76
+ #
77
+ # @param table_name [String, Symbol] The table name to filter by
78
+ # @return [ActiveRecord::Relation<PgSqlTriggers::TriggerRegistry>] Triggers for the specified table
24
79
  def self.for_table(table_name)
25
80
  Manager.for_table(table_name)
26
81
  end
27
82
 
28
- def self.diff
29
- Manager.diff
83
+ # Checks for drift between DSL definitions and database state.
84
+ #
85
+ # @param trigger_name [String, nil] Optional trigger name to check specific trigger, or nil for all triggers
86
+ # @return [Hash] Drift information with keys: :in_sync, :drifted, :manual_override, :disabled, :dropped, :unknown
87
+ def self.diff(trigger_name = nil)
88
+ Manager.diff(trigger_name)
30
89
  end
31
90
 
91
+ # Returns all triggers that have drifted from their expected state.
92
+ #
93
+ # @return [Array<Hash>] Array of drift result hashes for drifted triggers
94
+ def self.drifted
95
+ Manager.drifted
96
+ end
97
+
98
+ # Returns all triggers that are in sync with their expected state.
99
+ #
100
+ # @return [Array<Hash>] Array of drift result hashes for in-sync triggers
101
+ def self.in_sync
102
+ Manager.in_sync
103
+ end
104
+
105
+ # Returns all unknown (external) triggers not managed by this gem.
106
+ #
107
+ # @return [Array<Hash>] Array of drift result hashes for unknown triggers
108
+ def self.unknown_triggers
109
+ Manager.unknown_triggers
110
+ end
111
+
112
+ # Returns all triggers that have been dropped from the database.
113
+ #
114
+ # @return [Array<Hash>] Array of drift result hashes for dropped triggers
115
+ def self.dropped
116
+ Manager.dropped
117
+ end
118
+
119
+ # Validates all triggers in the registry.
120
+ #
121
+ # @raise [PgSqlTriggers::ValidationError] If validation fails
122
+ # @return [true] If validation passes
32
123
  def self.validate!
33
124
  Validator.validate!
34
125
  end
35
126
 
36
- # Console APIs for trigger operations
37
- # These methods provide a convenient interface for managing triggers from the Rails console
38
-
127
+ # Enables a trigger by name.
128
+ #
129
+ # @param trigger_name [String] The name of the trigger to enable
130
+ # @param actor [Hash] Information about who is performing the action (must have :type and :id keys)
131
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
132
+ # @raise [PgSqlTriggers::PermissionError] If actor lacks permission
133
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
134
+ # @raise [PgSqlTriggers::NotFoundError] If trigger not found
135
+ # @return [PgSqlTriggers::TriggerRegistry] The updated trigger record
39
136
  def self.enable(trigger_name, actor:, confirmation: nil)
40
137
  check_permission!(actor, :enable_trigger)
41
138
  trigger = find_trigger!(trigger_name)
42
- trigger.enable!(confirmation: confirmation)
139
+ trigger.enable!(confirmation: confirmation, actor: actor)
43
140
  end
44
141
 
142
+ # Disables a trigger by name.
143
+ #
144
+ # @param trigger_name [String] The name of the trigger to disable
145
+ # @param actor [Hash] Information about who is performing the action (must have :type and :id keys)
146
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
147
+ # @raise [PgSqlTriggers::PermissionError] If actor lacks permission
148
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
149
+ # @raise [PgSqlTriggers::NotFoundError] If trigger not found
150
+ # @return [PgSqlTriggers::TriggerRegistry] The updated trigger record
45
151
  def self.disable(trigger_name, actor:, confirmation: nil)
46
152
  check_permission!(actor, :disable_trigger)
47
153
  trigger = find_trigger!(trigger_name)
48
- trigger.disable!(confirmation: confirmation)
154
+ trigger.disable!(confirmation: confirmation, actor: actor)
49
155
  end
50
156
 
157
+ # Drops a trigger by name.
158
+ #
159
+ # @param trigger_name [String] The name of the trigger to drop
160
+ # @param actor [Hash] Information about who is performing the action (must have :type and :id keys)
161
+ # @param reason [String] Required reason for dropping the trigger
162
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
163
+ # @raise [PgSqlTriggers::PermissionError] If actor lacks permission
164
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
165
+ # @raise [PgSqlTriggers::NotFoundError] If trigger not found
166
+ # @raise [ArgumentError] If reason is missing or empty
167
+ # @return [true] If drop succeeds
51
168
  def self.drop(trigger_name, actor:, reason:, confirmation: nil)
52
169
  check_permission!(actor, :drop_trigger)
53
170
  trigger = find_trigger!(trigger_name)
54
171
  trigger.drop!(reason: reason, confirmation: confirmation, actor: actor)
55
172
  end
56
173
 
174
+ # Re-executes a trigger by name (drops and recreates it).
175
+ #
176
+ # @param trigger_name [String] The name of the trigger to re-execute
177
+ # @param actor [Hash] Information about who is performing the action (must have :type and :id keys)
178
+ # @param reason [String] Required reason for re-executing the trigger
179
+ # @param confirmation [String, nil] Optional confirmation text for kill switch protection
180
+ # @raise [PgSqlTriggers::PermissionError] If actor lacks permission
181
+ # @raise [PgSqlTriggers::KillSwitchError] If kill switch blocks the operation
182
+ # @raise [PgSqlTriggers::NotFoundError] If trigger not found
183
+ # @raise [ArgumentError] If reason is missing or empty
184
+ # @return [PgSqlTriggers::TriggerRegistry] The updated trigger record
57
185
  def self.re_execute(trigger_name, actor:, reason:, confirmation: nil)
58
186
  check_permission!(actor, :drop_trigger) # Re-execute requires same permission as drop
59
187
  trigger = find_trigger!(trigger_name)
@@ -65,7 +193,12 @@ module PgSqlTriggers
65
193
  def self.find_trigger!(trigger_name)
66
194
  PgSqlTriggers::TriggerRegistry.find_by!(trigger_name: trigger_name)
67
195
  rescue ActiveRecord::RecordNotFound
68
- raise ArgumentError, "Trigger '#{trigger_name}' not found in registry"
196
+ raise PgSqlTriggers::NotFoundError.new(
197
+ "Trigger '#{trigger_name}' not found in registry",
198
+ error_code: "TRIGGER_NOT_FOUND",
199
+ recovery_suggestion: "Verify the trigger name or create the trigger first using the generator or DSL.",
200
+ context: { trigger_name: trigger_name }
201
+ )
69
202
  end
70
203
  private_class_method :find_trigger!
71
204