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
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
sql_operations = parse_sql_operations(captured_sql)
|
|
56
|
+
private
|
|
47
57
|
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
#
|
|
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
|
-
#
|
|
66
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
#
|
|
37
|
-
#
|
|
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
|
|
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
|
|