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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ PgSqlTriggers::DSL.pg_sql_trigger "<%= trigger_name %>" do
4
+ table :<%= table_name %>
5
+ on <%= events_list %>
6
+ function :<%= function_name %>
7
+
8
+ version 1
9
+ enabled true
10
+ timing :<%= timing %>
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= trigger_class_name %> < PgSqlTriggers::Migration
4
+ def up
5
+ execute <<-SQL
6
+ CREATE OR REPLACE FUNCTION <%= function_name %>()
7
+ RETURNS TRIGGER AS $$
8
+ BEGIN
9
+ -- TODO: implement trigger logic
10
+ RETURN NEW;
11
+ END;
12
+ $$ LANGUAGE plpgsql;
13
+ SQL
14
+
15
+ execute <<-SQL
16
+ CREATE TRIGGER <%= trigger_name %>
17
+ <%= timing.upcase %> <%= events_sql %> ON <%= table_name %>
18
+ FOR EACH ROW
19
+ EXECUTE FUNCTION <%= function_name %>();
20
+ SQL
21
+ end
22
+
23
+ def down
24
+ execute <<-SQL
25
+ DROP TRIGGER IF EXISTS <%= trigger_name %> ON <%= table_name %>;
26
+ DROP FUNCTION IF EXISTS <%= function_name %>();
27
+ SQL
28
+ end
29
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module PgSqlTriggers
8
+ module Generators
9
+ class TriggerGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Generates a pg_sql_triggers DSL file and migration for a new trigger."
15
+
16
+ argument :trigger_name, type: :string,
17
+ desc: "Name of the trigger (e.g. notify_on_insert_users)"
18
+ argument :table_name, type: :string,
19
+ desc: "Database table the trigger attaches to (e.g. users)"
20
+ argument :events, type: :array, default: ["insert"], banner: "EVENT ...",
21
+ desc: "Trigger events: insert, update, delete (default: insert)"
22
+
23
+ class_option :timing, type: :string, default: "before",
24
+ desc: "Trigger timing: before or after (default: before)"
25
+ class_option :function, type: :string,
26
+ desc: "Function name (default: TRIGGER_NAME_function)"
27
+
28
+ def self.next_migration_number(_dirname)
29
+ existing = if Rails.root.join("db/triggers").exist?
30
+ Rails.root.glob("db/triggers/*.rb")
31
+ .map { |f| File.basename(f, ".rb").split("_").first.to_i }
32
+ .reject(&:zero?)
33
+ .max || 0
34
+ else
35
+ 0
36
+ end
37
+
38
+ now = Time.now.utc
39
+ base = now.strftime("%Y%m%d%H%M%S").to_i
40
+ base = existing + 1 if existing.positive? && base <= existing
41
+ base
42
+ end
43
+
44
+ def create_dsl_file
45
+ template "trigger_dsl.rb.tt", "app/triggers/#{trigger_name}.rb"
46
+ end
47
+
48
+ def create_migration_file
49
+ template "trigger_migration_full.rb.tt", "db/triggers/#{migration_file_name}.rb"
50
+ end
51
+
52
+ private
53
+
54
+ def function_name
55
+ options[:function].presence || "#{trigger_name}_function"
56
+ end
57
+
58
+ def timing
59
+ options[:timing]
60
+ end
61
+
62
+ def events_list
63
+ events.map { |e| ":#{e}" }.join(", ")
64
+ end
65
+
66
+ def events_sql
67
+ events.map(&:upcase).join(" OR ")
68
+ end
69
+
70
+ def trigger_class_name
71
+ "Add#{trigger_name.camelize}"
72
+ end
73
+
74
+ def migration_file_name
75
+ "#{migration_number}_#{trigger_name}"
76
+ end
77
+
78
+ def migration_number
79
+ self.class.next_migration_number(nil)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -22,12 +22,12 @@ module PgSqlTriggers
22
22
  JOIN pg_namespace n ON c.relnamespace = n.oid
23
23
  JOIN pg_proc p ON t.tgfoid = p.oid
24
24
  WHERE NOT t.tgisinternal
25
- AND n.nspname = 'public'
25
+ AND n.nspname = $1
26
26
  AND t.tgname NOT LIKE 'RI_%'
27
27
  ORDER BY c.relname, t.tgname;
28
28
  SQL
29
29
 
30
- execute_query(sql)
30
+ execute_query(sql, [schema_name])
31
31
  end
32
32
 
33
33
  # Fetch single trigger
@@ -49,10 +49,10 @@ module PgSqlTriggers
49
49
  JOIN pg_proc p ON t.tgfoid = p.oid
50
50
  WHERE t.tgname = $1
51
51
  AND NOT t.tgisinternal
52
- AND n.nspname = 'public';
52
+ AND n.nspname = $2;
53
53
  SQL
54
54
 
55
- result = execute_query(sql, [trigger_name])
55
+ result = execute_query(sql, [trigger_name, schema_name])
56
56
  result.first
57
57
  end
58
58
 
@@ -75,12 +75,12 @@ module PgSqlTriggers
75
75
  JOIN pg_proc p ON t.tgfoid = p.oid
76
76
  WHERE c.relname = $1
77
77
  AND NOT t.tgisinternal
78
- AND n.nspname = 'public'
78
+ AND n.nspname = $2
79
79
  AND t.tgname NOT LIKE 'RI_%'
80
80
  ORDER BY t.tgname;
81
81
  SQL
82
82
 
83
- execute_query(sql, [table_name])
83
+ execute_query(sql, [table_name, schema_name])
84
84
  end
85
85
 
86
86
  # Fetch function body by function name
@@ -92,15 +92,19 @@ module PgSqlTriggers
92
92
  FROM pg_proc p
93
93
  JOIN pg_namespace n ON p.pronamespace = n.oid
94
94
  WHERE p.proname = $1
95
- AND n.nspname = 'public';
95
+ AND n.nspname = $2;
96
96
  SQL
97
97
 
98
- result = execute_query(sql, [function_name])
98
+ result = execute_query(sql, [function_name, schema_name])
99
99
  result.first
100
100
  end
101
101
 
102
102
  private
103
103
 
104
+ def schema_name
105
+ PgSqlTriggers.db_schema.to_s
106
+ end
107
+
104
108
  def execute_query(sql, params = [])
105
109
  if params.any?
106
110
  # Use ActiveRecord's connection to execute parameterized queries
@@ -11,27 +11,7 @@ module PgSqlTriggers
11
11
  def detect(trigger_name)
12
12
  registry_entry = TriggerRegistry.find_by(trigger_name: trigger_name)
13
13
  db_trigger = DbQueries.find_trigger(trigger_name)
14
-
15
- # State 1: DISABLED - Registry entry disabled
16
- return disabled_state(registry_entry, db_trigger) if registry_entry&.enabled == false
17
-
18
- # State 2: MANUAL_OVERRIDE - Marked as manual SQL
19
- return manual_override_state(registry_entry, db_trigger) if registry_entry&.source == "manual_sql"
20
-
21
- # State 3: DROPPED - Registry entry exists, DB trigger missing
22
- return dropped_state(registry_entry) if registry_entry && !db_trigger
23
-
24
- # State 4: UNKNOWN - DB trigger exists, no registry entry
25
- return unknown_state(db_trigger) if !registry_entry && db_trigger
26
-
27
- # State 5: DRIFTED - Checksum mismatch
28
- if registry_entry && db_trigger
29
- checksum_match = checksums_match?(registry_entry, db_trigger)
30
- return drifted_state(registry_entry, db_trigger) unless checksum_match
31
- end
32
-
33
- # State 6: IN_SYNC - Everything matches
34
- in_sync_state(registry_entry, db_trigger)
14
+ detect_with_preloaded(registry_entry, db_trigger)
35
15
  end
36
16
 
37
17
  # Detect drift for all triggers
@@ -39,13 +19,14 @@ module PgSqlTriggers
39
19
  registry_entries = TriggerRegistry.all.to_a
40
20
  db_triggers = DbQueries.all_triggers
41
21
 
42
- # Check each registry entry
22
+ db_trigger_map = db_triggers.index_by { |t| t["trigger_name"] }
23
+
43
24
  results = registry_entries.map do |entry|
44
- detect(entry.trigger_name)
25
+ detect_with_preloaded(entry, db_trigger_map[entry.trigger_name])
45
26
  end
46
27
 
47
28
  # Find unknown (external) triggers not in registry
48
- registry_trigger_names = registry_entries.map(&:trigger_name)
29
+ registry_trigger_names = registry_entries.to_set(&:trigger_name)
49
30
  db_triggers.each do |db_trigger|
50
31
  next if registry_trigger_names.include?(db_trigger["trigger_name"])
51
32
 
@@ -60,13 +41,14 @@ module PgSqlTriggers
60
41
  registry_entries = TriggerRegistry.for_table(table_name).to_a
61
42
  db_triggers = DbQueries.find_triggers_for_table(table_name)
62
43
 
63
- # Check each registry entry for this table
44
+ db_trigger_map = db_triggers.index_by { |t| t["trigger_name"] }
45
+
64
46
  results = registry_entries.map do |entry|
65
- detect(entry.trigger_name)
47
+ detect_with_preloaded(entry, db_trigger_map[entry.trigger_name])
66
48
  end
67
49
 
68
50
  # Find unknown triggers on this table
69
- registry_trigger_names = registry_entries.map(&:trigger_name)
51
+ registry_trigger_names = registry_entries.to_set(&:trigger_name)
70
52
  db_triggers.each do |db_trigger|
71
53
  next if registry_trigger_names.include?(db_trigger["trigger_name"])
72
54
 
@@ -78,6 +60,20 @@ module PgSqlTriggers
78
60
 
79
61
  private
80
62
 
63
+ # Core state computation using pre-loaded data — no additional DB queries.
64
+ def detect_with_preloaded(registry_entry, db_trigger)
65
+ return disabled_state(registry_entry, db_trigger) if registry_entry&.enabled == false
66
+ return manual_override_state(registry_entry, db_trigger) if registry_entry&.source == "manual_sql"
67
+ return dropped_state(registry_entry) if registry_entry && !db_trigger
68
+ return unknown_state(db_trigger) if !registry_entry && db_trigger
69
+
70
+ if registry_entry && db_trigger && !checksums_match?(registry_entry, db_trigger)
71
+ return drifted_state(registry_entry, db_trigger)
72
+ end
73
+
74
+ in_sync_state(registry_entry, db_trigger)
75
+ end
76
+
81
77
  # Compare registry checksum with calculated DB checksum
82
78
  def checksums_match?(registry_entry, db_trigger)
83
79
  db_checksum = calculate_db_checksum(registry_entry, db_trigger)
@@ -86,32 +82,39 @@ module PgSqlTriggers
86
82
 
87
83
  # Calculate checksum from DB trigger (must match registry algorithm)
88
84
  def calculate_db_checksum(registry_entry, db_trigger)
89
- # Extract function body from the function definition
90
- function_body = extract_function_body(db_trigger)
85
+ function_body = if registry_entry.source == "dsl"
86
+ db_trigger["function_definition"] || ""
87
+ else
88
+ extract_function_body(db_trigger) || ""
89
+ end
91
90
 
92
- # Extract condition from trigger definition
91
+ # Extract condition and for_each granularity from trigger definition
93
92
  condition = extract_trigger_condition(db_trigger)
93
+ db_for_each = extract_trigger_for_each(db_trigger)
94
94
 
95
95
  # Use same algorithm as TriggerRegistry#calculate_checksum
96
96
  Digest::SHA256.hexdigest([
97
97
  registry_entry.trigger_name,
98
98
  registry_entry.table_name,
99
99
  registry_entry.version,
100
- function_body || "",
101
- condition || ""
100
+ function_body,
101
+ condition || "",
102
+ registry_entry.timing || "before",
103
+ db_for_each || "row"
102
104
  ].join)
103
105
  end
104
106
 
105
- # Extract function body from pg_get_functiondef output
107
+ # Extract just the PL/pgSQL body from pg_get_functiondef output.
108
+ # pg_get_functiondef() returns the full CREATE OR REPLACE FUNCTION statement;
109
+ # we extract only the content between the dollar-quote delimiters so the
110
+ # comparison is format-agnostic (handles $$ and $function$ styles).
106
111
  def extract_function_body(db_trigger)
107
112
  function_def = db_trigger["function_definition"]
108
113
  return nil unless function_def
109
114
 
110
- # The function definition includes CREATE OR REPLACE FUNCTION header
111
- # We need to extract just the body for comparison
112
- # For now, return the full definition
113
- # TODO: Parse and extract just the body if needed
114
- function_def
115
+ # Match any dollar-quoted string: $tag$body$tag$ (tag may be empty)
116
+ match = function_def.match(/\$([^$]*)\$(.*?)\$\1\$/m)
117
+ match ? match[2].strip : function_def.strip
115
118
  end
116
119
 
117
120
  # Extract WHEN condition from trigger definition
@@ -125,6 +128,16 @@ module PgSqlTriggers
125
128
  match ? match[1].strip : nil
126
129
  end
127
130
 
131
+ # Extract FOR EACH ROW / FOR EACH STATEMENT from trigger definition.
132
+ # Returns "row" or "statement" (lowercase). Defaults to "row".
133
+ def extract_trigger_for_each(db_trigger)
134
+ trigger_def = db_trigger["trigger_definition"]
135
+ return "row" unless trigger_def
136
+
137
+ match = trigger_def.match(/FOR\s+EACH\s+(ROW|STATEMENT)/i)
138
+ match ? match[1].downcase : "row"
139
+ end
140
+
128
141
  # State helper methods
129
142
  def disabled_state(registry_entry, db_trigger)
130
143
  {
@@ -3,16 +3,18 @@
3
3
  module PgSqlTriggers
4
4
  module DSL
5
5
  class TriggerDefinition
6
- attr_accessor :name, :table_name, :events, :function_name, :environments, :condition
6
+ attr_accessor :name, :table_name, :events, :function_name, :environments, :condition, :version, :enabled
7
+ attr_reader :timing, :for_each
7
8
 
8
9
  def initialize(name)
9
10
  @name = name
10
11
  @events = []
11
12
  @version = 1
12
- @enabled = false
13
+ @enabled = true
13
14
  @environments = []
14
15
  @condition = nil
15
16
  @timing = "before"
17
+ @for_each = "row"
16
18
  end
17
19
 
18
20
  def table(table_name)
@@ -27,23 +29,22 @@ module PgSqlTriggers
27
29
  @function_name = function_name
28
30
  end
29
31
 
30
- def version(version = nil)
31
- if version.nil?
32
- @version
33
- else
34
- @version = version
35
- end
32
+ def timing=(val)
33
+ @timing = val.to_s
36
34
  end
37
35
 
38
- def enabled(enabled = nil)
39
- if enabled.nil?
40
- @enabled
41
- else
42
- @enabled = enabled
43
- end
36
+ def for_each_row
37
+ @for_each = "row"
38
+ end
39
+
40
+ def for_each_statement
41
+ @for_each = "statement"
44
42
  end
45
43
 
46
44
  def when_env(*environments)
45
+ warn "[DEPRECATION] `when_env` is deprecated and will be removed in a future version. " \
46
+ "Environment-specific trigger behavior causes schema drift between environments. " \
47
+ "Use application-level configuration instead."
47
48
  @environments = environments.map(&:to_s)
48
49
  end
49
50
 
@@ -51,14 +52,6 @@ module PgSqlTriggers
51
52
  @condition = condition_sql
52
53
  end
53
54
 
54
- def timing(timing_value = nil)
55
- if timing_value.nil?
56
- @timing
57
- else
58
- @timing = timing_value.to_s
59
- end
60
- end
61
-
62
55
  def function_body
63
56
  nil # DSL definitions don't include function_body directly
64
57
  end
@@ -73,7 +66,8 @@ module PgSqlTriggers
73
66
  enabled: @enabled,
74
67
  environments: @environments,
75
68
  condition: @condition,
76
- timing: @timing
69
+ timing: @timing,
70
+ for_each: @for_each
77
71
  }
78
72
  end
79
73
  end
@@ -25,5 +25,19 @@ module PgSqlTriggers
25
25
  rake_tasks do
26
26
  load root.join("lib/tasks/trigger_migrations.rake")
27
27
  end
28
+
29
+ # Warn at startup if no permission_checker is set in a protected environment.
30
+ # The default is to allow all actions (including admin-level ones), which is
31
+ # unsafe in production without an explicit checker configured.
32
+ config.after_initialize do
33
+ if PgSqlTriggers.permission_checker.nil? && defined?(Rails) && Rails.env.production?
34
+ Rails.logger.warn(
35
+ "[PgSqlTriggers] SECURITY WARNING: No permission_checker is configured. " \
36
+ "All actions are permitted by default, including admin-level operations " \
37
+ "(drop_trigger, execute_sql, override_drift). " \
38
+ "Set PgSqlTriggers.permission_checker in an initializer before deploying to production."
39
+ )
40
+ end
41
+ end
28
42
  end
29
43
  end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # Base error class for all PgSqlTriggers errors
5
+ #
6
+ # All errors in PgSqlTriggers inherit from this base class and include
7
+ # error codes for programmatic handling, standardized messages, and
8
+ # recovery suggestions.
9
+ class Error < StandardError
10
+ attr_reader :error_code, :recovery_suggestion, :context
11
+
12
+ def initialize(message = nil, error_code: nil, recovery_suggestion: nil, context: {})
13
+ @context = context || {}
14
+ @error_code = error_code || default_error_code
15
+ @recovery_suggestion = recovery_suggestion || default_recovery_suggestion
16
+ super(message || default_message)
17
+ end
18
+
19
+ # Returns a user-friendly error message suitable for UI display
20
+ def user_message
21
+ msg = message
22
+ msg += "\n\nRecovery: #{recovery_suggestion}" if recovery_suggestion
23
+ msg
24
+ end
25
+
26
+ # Returns error details as a hash for programmatic access
27
+ def to_h
28
+ {
29
+ error_class: self.class.name,
30
+ error_code: error_code,
31
+ message: message,
32
+ recovery_suggestion: recovery_suggestion,
33
+ context: context
34
+ }
35
+ end
36
+
37
+ protected
38
+
39
+ def default_error_code
40
+ # Convert class name to error code (e.g., "PermissionError" -> "PERMISSION_ERROR")
41
+ class_name = self.class.name.split("::").last
42
+ class_name.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
43
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
44
+ .upcase
45
+ end
46
+
47
+ def default_message
48
+ "An error occurred in PgSqlTriggers"
49
+ end
50
+
51
+ def default_recovery_suggestion
52
+ "Please check the logs for more details and contact support if the issue persists."
53
+ end
54
+ end
55
+
56
+ # Error raised when permission checks fail
57
+ #
58
+ # @example
59
+ # raise PgSqlTriggers::PermissionError.new(
60
+ # "Permission denied: enable_trigger requires Operator level access",
61
+ # error_code: "PERMISSION_DENIED",
62
+ # recovery_suggestion: "Contact your administrator to request Operator or Admin access",
63
+ # context: { action: :enable_trigger, required_role: "Operator" }
64
+ # )
65
+ class PermissionError < Error
66
+ def default_error_code
67
+ "PERMISSION_DENIED"
68
+ end
69
+
70
+ def default_message
71
+ "Permission denied for this operation"
72
+ end
73
+
74
+ def default_recovery_suggestion
75
+ if context[:required_role]
76
+ "This operation requires #{context[:required_role]} level access. " \
77
+ "Contact your administrator to request appropriate permissions."
78
+ else
79
+ "This operation requires elevated permissions. Contact your administrator."
80
+ end
81
+ end
82
+ end
83
+
84
+ # Error raised when kill switch blocks an operation
85
+ #
86
+ # @example
87
+ # raise PgSqlTriggers::KillSwitchError.new(
88
+ # "Kill switch is active for production environment",
89
+ # error_code: "KILL_SWITCH_ACTIVE",
90
+ # recovery_suggestion: "Provide confirmation text to override: EXECUTE OPERATION_NAME",
91
+ # context: { operation: :trigger_enable, environment: "production" }
92
+ # )
93
+ class KillSwitchError < Error
94
+ def default_error_code
95
+ "KILL_SWITCH_ACTIVE"
96
+ end
97
+
98
+ def default_message
99
+ "Kill switch is active for this environment"
100
+ end
101
+
102
+ def default_recovery_suggestion
103
+ ctx = @context || {}
104
+ ctx[:operation] || "this operation"
105
+ environment = ctx[:environment] || "this environment"
106
+ "Kill switch is active for #{environment}. " \
107
+ "To override, provide the required confirmation text. " \
108
+ "For CLI/rake tasks, use: KILL_SWITCH_OVERRIDE=true CONFIRMATION_TEXT=\"...\" rake your:task"
109
+ end
110
+ end
111
+
112
+ # Error raised when drift is detected
113
+ #
114
+ # @example
115
+ # raise PgSqlTriggers::DriftError.new(
116
+ # "Trigger 'users_email_validation' has drifted from definition",
117
+ # error_code: "DRIFT_DETECTED",
118
+ # recovery_suggestion: "Run migration to sync trigger, or re-execute trigger to apply current definition",
119
+ # context: { trigger_name: "users_email_validation", drift_type: "function_body" }
120
+ # )
121
+ class DriftError < Error
122
+ def default_error_code
123
+ "DRIFT_DETECTED"
124
+ end
125
+
126
+ def default_message
127
+ "Trigger has drifted from its definition"
128
+ end
129
+
130
+ def default_recovery_suggestion
131
+ trigger_name = context[:trigger_name] || "trigger"
132
+ "Trigger '#{trigger_name}' has drifted. " \
133
+ "Run 'rake trigger:migrate' to sync the trigger, or use the re-execute feature " \
134
+ "to apply the current definition."
135
+ end
136
+ end
137
+
138
+ # Error raised when validation fails
139
+ #
140
+ # @example
141
+ # raise PgSqlTriggers::ValidationError.new(
142
+ # "Invalid trigger definition: table name is required",
143
+ # error_code: "VALIDATION_FAILED",
144
+ # recovery_suggestion: "Ensure all required fields are provided in the trigger definition",
145
+ # context: { field: :table_name, errors: ["is required"] }
146
+ # )
147
+ class ValidationError < Error
148
+ def default_error_code
149
+ "VALIDATION_FAILED"
150
+ end
151
+
152
+ def default_message
153
+ "Validation failed"
154
+ end
155
+
156
+ def default_recovery_suggestion
157
+ if context[:field]
158
+ "Please fix the #{context[:field]} field and try again."
159
+ else
160
+ "Please review the input and ensure all required fields are provided."
161
+ end
162
+ end
163
+ end
164
+
165
+ # Error raised when SQL execution fails
166
+ #
167
+ # @example
168
+ # raise PgSqlTriggers::ExecutionError.new(
169
+ # "SQL execution failed: syntax error near 'INVALID'",
170
+ # error_code: "EXECUTION_FAILED",
171
+ # recovery_suggestion: "Review SQL syntax and ensure all references are valid",
172
+ # context: { sql: "SELECT * FROM...", database_error: "..." }
173
+ # )
174
+ class ExecutionError < Error
175
+ def default_error_code
176
+ "EXECUTION_FAILED"
177
+ end
178
+
179
+ def default_message
180
+ "SQL execution failed"
181
+ end
182
+
183
+ def default_recovery_suggestion
184
+ if context[:database_error]
185
+ "Review the SQL syntax and database error. Ensure all table and column names are correct."
186
+ else
187
+ "Review the SQL and ensure it is valid PostgreSQL syntax."
188
+ end
189
+ end
190
+ end
191
+
192
+ # Error raised when unsafe migrations are attempted
193
+ #
194
+ # @example
195
+ # raise PgSqlTriggers::UnsafeMigrationError.new(
196
+ # "Migration contains unsafe DROP + CREATE operations",
197
+ # error_code: "UNSAFE_MIGRATION",
198
+ # recovery_suggestion: "Review migration safety or set allow_unsafe_migrations=true",
199
+ # context: { violations: [...] }
200
+ # )
201
+ class UnsafeMigrationError < Error
202
+ def default_error_code
203
+ "UNSAFE_MIGRATION"
204
+ end
205
+
206
+ def default_message
207
+ "Migration contains unsafe operations"
208
+ end
209
+
210
+ def default_recovery_suggestion
211
+ "Review the migration for unsafe operations. " \
212
+ "If you are certain the migration is safe, you can set " \
213
+ "PgSqlTriggers.configure { |c| c.allow_unsafe_migrations = true } " \
214
+ "or use the kill switch override mechanism."
215
+ end
216
+ end
217
+
218
+ # Error raised when a trigger is not found
219
+ #
220
+ # @example
221
+ # raise PgSqlTriggers::NotFoundError.new(
222
+ # "Trigger 'users_email_validation' not found",
223
+ # error_code: "TRIGGER_NOT_FOUND",
224
+ # recovery_suggestion: "Verify trigger name or create the trigger first",
225
+ # context: { trigger_name: "users_email_validation" }
226
+ # )
227
+ class NotFoundError < Error
228
+ def default_error_code
229
+ "NOT_FOUND"
230
+ end
231
+
232
+ def default_message
233
+ "Resource not found"
234
+ end
235
+
236
+ def default_recovery_suggestion
237
+ if context[:trigger_name]
238
+ "Trigger '#{context[:trigger_name]}' not found. " \
239
+ "Verify the trigger name or create the trigger first using the generator or DSL."
240
+ else
241
+ "The requested resource was not found. Verify the identifier and try again."
242
+ end
243
+ end
244
+ end
245
+ end