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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +397 -1
- data/COVERAGE.md +26 -19
- data/GEM_ANALYSIS.md +368 -0
- data/Goal.md +276 -155
- data/README.md +45 -22
- data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +50 -0
- data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +56 -0
- data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +66 -0
- data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +117 -0
- data/app/controllers/pg_sql_triggers/application_controller.rb +10 -62
- data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +102 -0
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +4 -9
- data/app/controllers/pg_sql_triggers/tables_controller.rb +30 -4
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +3 -21
- data/app/helpers/pg_sql_triggers/permissions_helper.rb +43 -0
- data/app/models/pg_sql_triggers/audit_log.rb +106 -0
- data/app/models/pg_sql_triggers/trigger_registry.rb +218 -13
- data/app/views/layouts/pg_sql_triggers/application.html.erb +25 -6
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +177 -0
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +34 -12
- data/app/views/pg_sql_triggers/tables/index.html.erb +75 -5
- data/app/views/pg_sql_triggers/tables/show.html.erb +17 -6
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +16 -7
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +16 -7
- data/app/views/pg_sql_triggers/triggers/show.html.erb +26 -6
- data/config/routes.rb +2 -14
- data/db/migrate/20260103000001_create_pg_sql_triggers_audit_log.rb +28 -0
- data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
- data/docs/README.md +15 -5
- data/docs/api-reference.md +233 -151
- data/docs/audit-trail.md +413 -0
- data/docs/configuration.md +28 -7
- data/docs/getting-started.md +17 -16
- data/docs/permissions.md +369 -0
- data/docs/troubleshooting.md +486 -0
- data/docs/ui-guide.md +211 -0
- data/docs/usage-guide.md +38 -67
- data/docs/web-ui.md +251 -128
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
- data/lib/pg_sql_triggers/drift/detector.rb +51 -38
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
- data/lib/pg_sql_triggers/engine.rb +14 -0
- data/lib/pg_sql_triggers/errors.rb +245 -0
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
- data/lib/pg_sql_triggers/migrator.rb +53 -6
- data/lib/pg_sql_triggers/permissions/checker.rb +9 -2
- data/lib/pg_sql_triggers/registry/manager.rb +36 -11
- data/lib/pg_sql_triggers/registry/validator.rb +62 -5
- data/lib/pg_sql_triggers/registry.rb +141 -8
- data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -247
- data/lib/pg_sql_triggers/sql.rb +0 -6
- data/lib/pg_sql_triggers/testing/function_tester.rb +2 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +7 -7
- data/pg_sql_triggers.gemspec +53 -0
- metadata +35 -18
- data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
- data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
- data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
- data/docs/screenshots/.gitkeep +0 -1
- data/docs/screenshots/Generate Trigger.png +0 -0
- data/docs/screenshots/Triggers Page.png +0 -0
- data/docs/screenshots/kill error.png +0 -0
- data/docs/screenshots/kill modal for migration down.png +0 -0
- data/lib/generators/trigger/migration_generator.rb +0 -60
- data/lib/pg_sql_triggers/generator/form.rb +0 -80
- data/lib/pg_sql_triggers/generator/service.rb +0 -307
- data/lib/pg_sql_triggers/generator.rb +0 -8
- data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
- data/lib/pg_sql_triggers/sql/executor.rb +0 -200
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
22
|
+
db_trigger_map = db_triggers.index_by { |t| t["trigger_name"] }
|
|
23
|
+
|
|
43
24
|
results = registry_entries.map do |entry|
|
|
44
|
-
|
|
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.
|
|
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
|
-
|
|
44
|
+
db_trigger_map = db_triggers.index_by { |t| t["trigger_name"] }
|
|
45
|
+
|
|
64
46
|
results = registry_entries.map do |entry|
|
|
65
|
-
|
|
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.
|
|
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
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
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 =
|
|
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
|
|
31
|
-
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|