pg_sql_triggers 1.4.0 → 1.5.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/.erb_lint.yml +0 -0
- data/.rspec +0 -0
- data/.rubocop.yml +6 -16
- data/AGENTS.md +8 -0
- data/CHANGELOG.md +104 -2
- data/COVERAGE.md +39 -41
- data/LICENSE +0 -0
- data/README.md +24 -3
- data/RELEASE.md +0 -0
- data/Rakefile +5 -0
- data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
- data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
- data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
- data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
- data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
- data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
- data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
- data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
- data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
- data/app/models/pg_sql_triggers/application_record.rb +0 -0
- data/app/models/pg_sql_triggers/audit_log.rb +29 -47
- data/app/models/pg_sql_triggers/trigger_registry.rb +105 -78
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -0
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -24
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +26 -14
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
- data/config/initializers/pg_sql_triggers.rb +0 -0
- data/config/routes.rb +0 -0
- data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
- data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
- data/db/migrate/{20260228000001_add_for_each_to_pg_sql_triggers_registry.rb → 20260228162233_add_for_each_to_pg_sql_triggers_registry.rb} +0 -0
- data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
- data/docs/README.md +3 -0
- data/docs/api-reference.md +133 -0
- data/docs/audit-trail.md +1 -1
- data/docs/configuration.md +172 -0
- data/docs/getting-started.md +14 -0
- data/docs/kill-switch.md +0 -0
- data/docs/permissions.md +6 -9
- data/docs/troubleshooting.md +0 -0
- data/docs/ui-guide.md +0 -0
- data/docs/usage-guide.md +74 -0
- data/docs/web-ui.md +0 -0
- data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/README +0 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +0 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +0 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +0 -0
- data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
- data/lib/pg_sql_triggers/alerting.rb +77 -0
- data/lib/pg_sql_triggers/database_introspection.rb +0 -0
- data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +14 -5
- data/lib/pg_sql_triggers/drift/detector.rb +9 -1
- data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
- data/lib/pg_sql_triggers/drift.rb +5 -0
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +56 -2
- data/lib/pg_sql_triggers/dsl.rb +0 -0
- data/lib/pg_sql_triggers/engine.rb +35 -0
- data/lib/pg_sql_triggers/errors.rb +0 -0
- data/lib/pg_sql_triggers/events_checksum.rb +114 -0
- data/lib/pg_sql_triggers/migration.rb +5 -6
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +77 -73
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +3 -1
- data/lib/pg_sql_triggers/migrator.rb +90 -94
- data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
- data/lib/pg_sql_triggers/permissions.rb +1 -0
- data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
- data/lib/pg_sql_triggers/registry/manager.rb +27 -13
- data/lib/pg_sql_triggers/registry/validator.rb +226 -2
- data/lib/pg_sql_triggers/registry.rb +0 -0
- data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
- data/lib/pg_sql_triggers/sql/kill_switch.rb +2 -1
- data/lib/pg_sql_triggers/sql.rb +0 -0
- data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
- data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
- data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
- data/lib/pg_sql_triggers/testing.rb +0 -0
- data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +17 -0
- data/lib/tasks/trigger_migrations.rake +235 -152
- data/rakelib/pg_sql_triggers_environment.rake +9 -0
- data/scripts/generate_coverage_report.rb +4 -1
- data/sig/pg_sql_triggers.rbs +0 -0
- metadata +65 -13
- data/GEM_ANALYSIS.md +0 -368
- data/Goal.md +0 -742
- data/pg_sql_triggers.gemspec +0 -53
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
require "tsort"
|
|
4
6
|
|
|
5
7
|
module PgSqlTriggers
|
|
6
8
|
module Registry
|
|
7
|
-
class Validator
|
|
9
|
+
class Validator # rubocop:disable Metrics/ClassLength -- validation rules grouped in one class
|
|
8
10
|
VALID_EVENTS = %w[insert update delete truncate].freeze
|
|
9
11
|
VALID_TIMINGS = %w[before after instead_of].freeze
|
|
10
12
|
VALID_FOR_EACH = %w[row statement].freeze
|
|
11
13
|
|
|
12
14
|
def self.validate!
|
|
13
15
|
errors = []
|
|
16
|
+
dsl_triggers = PgSqlTriggers::TriggerRegistry.where(source: "dsl").to_a
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
dsl_triggers.each do |trigger|
|
|
16
19
|
errors.concat(validate_dsl_trigger(trigger))
|
|
17
20
|
end
|
|
18
21
|
|
|
22
|
+
errors.concat(collect_dependency_and_order_errors(dsl_triggers))
|
|
23
|
+
|
|
19
24
|
return true if errors.empty?
|
|
20
25
|
|
|
21
26
|
raise PgSqlTriggers::ValidationError.new(
|
|
@@ -24,9 +29,137 @@ module PgSqlTriggers
|
|
|
24
29
|
context: { errors: errors }
|
|
25
30
|
)
|
|
26
31
|
end
|
|
32
|
+
|
|
33
|
+
# Returns dependency-related errors (missing refs, cycles, incompatible pairs, name order).
|
|
34
|
+
# Used by rake trigger:validate_order and included in {.validate!}.
|
|
35
|
+
def self.trigger_order_validation_errors
|
|
36
|
+
dsl_triggers = PgSqlTriggers::TriggerRegistry.where(source: "dsl").to_a
|
|
37
|
+
collect_dependency_and_order_errors(dsl_triggers)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Prerequisite and dependent DSL triggers for the trigger detail page.
|
|
41
|
+
def self.related_triggers_for_show(trigger_record)
|
|
42
|
+
empty = { prerequisites: [], dependents: [] }
|
|
43
|
+
return empty if trigger_record.blank? || trigger_record.source != "dsl"
|
|
44
|
+
|
|
45
|
+
defn = parse_definition(trigger_record.definition)
|
|
46
|
+
prerequisite_names = normalize_depends_on(defn)
|
|
47
|
+
prerequisites = prerequisite_names.filter_map do |dep_name|
|
|
48
|
+
PgSqlTriggers::TriggerRegistry.find_by(source: "dsl", trigger_name: dep_name)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
dependents = []
|
|
52
|
+
PgSqlTriggers::TriggerRegistry.where(source: "dsl").find_each do |row|
|
|
53
|
+
next if row.id == trigger_record.id
|
|
54
|
+
|
|
55
|
+
other = parse_definition(row.definition)
|
|
56
|
+
dependents << row if normalize_depends_on(other).include?(trigger_record.trigger_name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
prerequisites: prerequisites.sort_by(&:trigger_name),
|
|
61
|
+
dependents: dependents.sort_by(&:trigger_name)
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
27
65
|
class << self
|
|
28
66
|
private
|
|
29
67
|
|
|
68
|
+
def normalize_depends_on(defn)
|
|
69
|
+
raw = defn["depends_on"]
|
|
70
|
+
list = case raw
|
|
71
|
+
when nil then []
|
|
72
|
+
when Array then raw
|
|
73
|
+
else [raw]
|
|
74
|
+
end
|
|
75
|
+
list.flatten.compact.map { |entry| entry.to_s.strip }.reject(&:empty?).uniq
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def effective_timing(defn)
|
|
79
|
+
return "after" if ActiveModel::Type::Boolean.new.cast(defn["constraint_trigger"])
|
|
80
|
+
|
|
81
|
+
(defn["timing"].presence || "before").to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def for_each_level(defn)
|
|
85
|
+
(defn["for_each"].presence || "row").to_s
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def event_names_for_overlap(defn)
|
|
89
|
+
Array(defn["events"]).to_set(&:to_s)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def compatibility_errors(child_row, child_defn, parent_row, parent_defn)
|
|
93
|
+
child_name = child_row.trigger_name
|
|
94
|
+
parent_name = parent_row.trigger_name
|
|
95
|
+
errs = []
|
|
96
|
+
if child_row.table_name != parent_row.table_name
|
|
97
|
+
errs << "Trigger '#{child_name}': depends_on '#{parent_name}' must reference a trigger on the same " \
|
|
98
|
+
"table (#{child_row.table_name} vs #{parent_row.table_name})"
|
|
99
|
+
end
|
|
100
|
+
if effective_timing(child_defn) != effective_timing(parent_defn)
|
|
101
|
+
errs << "Trigger '#{child_name}': depends_on '#{parent_name}' requires the same timing " \
|
|
102
|
+
"(#{effective_timing(child_defn)} vs #{effective_timing(parent_defn)})"
|
|
103
|
+
end
|
|
104
|
+
if for_each_level(child_defn) != for_each_level(parent_defn)
|
|
105
|
+
errs << "Trigger '#{child_name}': depends_on '#{parent_name}' requires the same FOR EACH " \
|
|
106
|
+
"(#{for_each_level(child_defn)} vs #{for_each_level(parent_defn)})"
|
|
107
|
+
end
|
|
108
|
+
ch_ev = event_names_for_overlap(child_defn)
|
|
109
|
+
pa_ev = event_names_for_overlap(parent_defn)
|
|
110
|
+
if (ch_ev & pa_ev).empty?
|
|
111
|
+
errs << "Trigger '#{child_name}': depends_on '#{parent_name}' requires overlapping events"
|
|
112
|
+
end
|
|
113
|
+
errs
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def collect_dependency_and_order_errors(dsl_triggers)
|
|
117
|
+
errors = []
|
|
118
|
+
by_name = dsl_triggers.index_by(&:trigger_name)
|
|
119
|
+
valid_edges = []
|
|
120
|
+
|
|
121
|
+
dsl_triggers.each do |child|
|
|
122
|
+
child_name = child.trigger_name
|
|
123
|
+
child_defn = parse_definition(child.definition)
|
|
124
|
+
deps = normalize_depends_on(child_defn)
|
|
125
|
+
deps.each do |parent_name|
|
|
126
|
+
if parent_name == child_name
|
|
127
|
+
errors << "Trigger '#{child_name}': depends_on cannot reference itself"
|
|
128
|
+
next
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
parent = by_name[parent_name]
|
|
132
|
+
unless parent
|
|
133
|
+
errors << "Trigger '#{child_name}': depends_on references unknown trigger '#{parent_name}'"
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
parent_defn = parse_definition(parent.definition)
|
|
138
|
+
compat = compatibility_errors(child, child_defn, parent, parent_defn)
|
|
139
|
+
errors.concat(compat)
|
|
140
|
+
next if compat.any?
|
|
141
|
+
|
|
142
|
+
valid_edges << [parent_name, child_name]
|
|
143
|
+
unless parent_name < child_name
|
|
144
|
+
errors << "Trigger '#{child_name}': depends_on '#{parent_name}' must sort before " \
|
|
145
|
+
"'#{child_name}' alphabetically (PostgreSQL fires same-kind triggers in name order)"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
errors.concat(cycle_dependency_errors(valid_edges))
|
|
151
|
+
errors
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def cycle_dependency_errors(edges)
|
|
155
|
+
return [] if edges.empty?
|
|
156
|
+
|
|
157
|
+
DependsOnSorter.new(edges).tsort
|
|
158
|
+
[]
|
|
159
|
+
rescue TSort::Cyclic
|
|
160
|
+
["depends_on: circular dependency chain detected among DSL triggers"]
|
|
161
|
+
end
|
|
162
|
+
|
|
30
163
|
def validate_dsl_trigger(trigger)
|
|
31
164
|
errors = []
|
|
32
165
|
name = trigger.trigger_name
|
|
@@ -46,6 +179,8 @@ module PgSqlTriggers
|
|
|
46
179
|
|
|
47
180
|
errors << "Trigger '#{name}': missing function_name" if definition["function_name"].blank?
|
|
48
181
|
|
|
182
|
+
errors.concat(validate_update_columns(name, events, definition))
|
|
183
|
+
|
|
49
184
|
timing = definition["timing"].to_s
|
|
50
185
|
if timing.present? && VALID_TIMINGS.exclude?(timing)
|
|
51
186
|
errors << "Trigger '#{name}': invalid timing '#{timing}' (valid: #{VALID_TIMINGS.inspect})"
|
|
@@ -56,9 +191,78 @@ module PgSqlTriggers
|
|
|
56
191
|
errors << "Trigger '#{name}': invalid for_each '#{for_each}' (valid: #{VALID_FOR_EACH.inspect})"
|
|
57
192
|
end
|
|
58
193
|
|
|
194
|
+
errors.concat(validate_deferral(name, definition, timing))
|
|
195
|
+
|
|
196
|
+
errors
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def validate_deferral(name, definition, timing)
|
|
200
|
+
constraint = ActiveModel::Type::Boolean.new.cast(definition["constraint_trigger"])
|
|
201
|
+
deferrable_val = definition["deferrable"].presence&.to_s
|
|
202
|
+
initially_val = definition["initially"].presence&.to_s
|
|
203
|
+
|
|
204
|
+
errors = []
|
|
205
|
+
errors.concat(constraint_deferral_errors(name, constraint, timing, definition))
|
|
206
|
+
errors.concat(deferral_value_errors(name, deferrable_val, initially_val))
|
|
207
|
+
errors
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def constraint_deferral_errors(name, constraint, timing, definition)
|
|
211
|
+
deferrable_val = definition["deferrable"].presence&.to_s
|
|
212
|
+
initially_val = definition["initially"].presence&.to_s
|
|
213
|
+
errors = []
|
|
214
|
+
if (deferrable_val.present? || initially_val.present?) && !constraint
|
|
215
|
+
errors << "Trigger '#{name}': deferrable/initially require constraint_trigger (CONSTRAINT TRIGGER)"
|
|
216
|
+
end
|
|
217
|
+
if constraint && timing.to_s != "after"
|
|
218
|
+
errors << "Trigger '#{name}': constraint triggers must use after timing"
|
|
219
|
+
end
|
|
220
|
+
if constraint && events_include_truncate?(definition)
|
|
221
|
+
errors << "Trigger '#{name}': constraint triggers cannot use TRUNCATE events"
|
|
222
|
+
end
|
|
223
|
+
errors
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
VALID_DEFERRABLE = %w[deferrable not_deferrable].freeze
|
|
227
|
+
VALID_INITIALLY = %w[deferred immediate].freeze
|
|
228
|
+
private_constant :VALID_DEFERRABLE, :VALID_INITIALLY
|
|
229
|
+
|
|
230
|
+
def deferral_value_errors(name, deferrable_val, initially_val)
|
|
231
|
+
errors = []
|
|
232
|
+
if deferrable_val.present? && VALID_DEFERRABLE.exclude?(deferrable_val)
|
|
233
|
+
errors << "Trigger '#{name}': invalid deferrable '#{deferrable_val}' (valid: #{VALID_DEFERRABLE.inspect})"
|
|
234
|
+
end
|
|
235
|
+
if initially_val.present? && VALID_INITIALLY.exclude?(initially_val)
|
|
236
|
+
errors << "Trigger '#{name}': invalid initially '#{initially_val}' (valid: #{VALID_INITIALLY.inspect})"
|
|
237
|
+
end
|
|
238
|
+
if initially_val.present? && deferrable_val != "deferrable"
|
|
239
|
+
errors << "Trigger '#{name}': initially requires deferrable to be 'deferrable'"
|
|
240
|
+
end
|
|
59
241
|
errors
|
|
60
242
|
end
|
|
61
243
|
|
|
244
|
+
def events_include_truncate?(definition)
|
|
245
|
+
Array(definition["events"]).map(&:to_s).include?("truncate")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def validate_update_columns(name, events, definition)
|
|
249
|
+
errs = []
|
|
250
|
+
cols = Array(definition["columns"]).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?)
|
|
251
|
+
return errs if cols.empty?
|
|
252
|
+
|
|
253
|
+
unless events.map(&:to_s).include?("update")
|
|
254
|
+
errs << "Trigger '#{name}': columns require an update event " \
|
|
255
|
+
"(use on_update_of or include :update)"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
cols.each do |col|
|
|
259
|
+
next if col.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
260
|
+
|
|
261
|
+
errs << "Trigger '#{name}': invalid column name #{col.inspect} (use simple SQL identifiers, no quoting)"
|
|
262
|
+
end
|
|
263
|
+
errs
|
|
264
|
+
end
|
|
265
|
+
|
|
62
266
|
def parse_definition(definition_json)
|
|
63
267
|
return {} if definition_json.blank?
|
|
64
268
|
|
|
@@ -67,6 +271,26 @@ module PgSqlTriggers
|
|
|
67
271
|
{}
|
|
68
272
|
end
|
|
69
273
|
end
|
|
274
|
+
|
|
275
|
+
# Internal helper for cycle detection using Ruby's TSort.
|
|
276
|
+
class DependsOnSorter # :nodoc:
|
|
277
|
+
include TSort
|
|
278
|
+
|
|
279
|
+
def initialize(edges)
|
|
280
|
+
@adjacency = Hash.new { |h, k| h[k] = [] }
|
|
281
|
+
edges.each { |(from, to)| @adjacency[from] << to }
|
|
282
|
+
@nodes = (@adjacency.keys | @adjacency.values.flatten).uniq
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def tsort_each_node(&block)
|
|
286
|
+
@nodes.each(&block)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def tsort_each_child(node, &block)
|
|
290
|
+
Array(@adjacency[node]).each(&block)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
private_constant :DependsOnSorter
|
|
70
294
|
end
|
|
71
295
|
end
|
|
72
296
|
end
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PgSqlTriggers
|
|
4
|
+
# Prepended onto ActiveRecord::SchemaDumper to document that triggers live outside schema.rb.
|
|
5
|
+
module SchemaDumperExtension
|
|
6
|
+
def trailer(stream)
|
|
7
|
+
stream.puts PgSqlTriggers::TriggerStructureDumper.schema_rb_annotation(connection: @connection) if append_notes?
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def append_notes?
|
|
14
|
+
PgSqlTriggers.append_trigger_notes_to_schema_dump &&
|
|
15
|
+
defined?(Rails) &&
|
|
16
|
+
Rails.application &&
|
|
17
|
+
ruby_schema_format?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def ruby_schema_format?
|
|
21
|
+
cfg = Rails.application.config
|
|
22
|
+
# Minimal Rails apps (e.g. tests without ActiveRecord::Railtie) have no config.active_record.
|
|
23
|
+
return true unless cfg.respond_to?(:active_record)
|
|
24
|
+
|
|
25
|
+
ar_cfg = cfg.active_record
|
|
26
|
+
return true unless ar_cfg.respond_to?(:schema_format)
|
|
27
|
+
|
|
28
|
+
format = ar_cfg.schema_format
|
|
29
|
+
format.nil? || format.to_sym == :ruby
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -51,7 +51,8 @@ module PgSqlTriggers
|
|
|
51
51
|
if PgSqlTriggers.respond_to?(:default_environment) && PgSqlTriggers.default_environment.respond_to?(:call)
|
|
52
52
|
begin
|
|
53
53
|
return PgSqlTriggers.default_environment.call.to_s
|
|
54
|
-
rescue NameError
|
|
54
|
+
rescue NameError => e
|
|
55
|
+
# NoMethodError inherits from NameError, so both are rescued by this clause.
|
|
55
56
|
logger&.debug "[KILL_SWITCH] Could not resolve default_environment: #{e.message}"
|
|
56
57
|
end
|
|
57
58
|
end
|
data/lib/pg_sql_triggers/sql.rb
CHANGED
|
File without changes
|
|
File without changes
|
|
@@ -7,122 +7,60 @@ module PgSqlTriggers
|
|
|
7
7
|
@trigger = trigger_registry
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
FUNCTION_NAME_PATTERN = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i
|
|
11
|
+
|
|
12
|
+
# Test ONLY the function, not the trigger.
|
|
13
|
+
#
|
|
14
|
+
# +test_context+ is accepted for API compatibility with future invocation logic.
|
|
15
|
+
# It is normalised to an empty hash when +nil+ so callers can pass either.
|
|
12
16
|
def test_function_only(test_context: {})
|
|
17
|
+
test_context ||= {}
|
|
13
18
|
results = {
|
|
14
19
|
function_created: false,
|
|
15
20
|
function_executed: false,
|
|
16
21
|
errors: [],
|
|
17
|
-
output: []
|
|
22
|
+
output: [],
|
|
23
|
+
context: test_context
|
|
18
24
|
}
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
results
|
|
23
|
-
results[:errors] << "Function body is missing"
|
|
24
|
-
return results
|
|
26
|
+
return fail_result(results, "Function body is missing") if @trigger.function_body.blank?
|
|
27
|
+
unless extract_function_name_from_body
|
|
28
|
+
return fail_result(results, "Function body does not contain a valid CREATE FUNCTION statement")
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
match = @trigger.function_body.match(pattern)
|
|
32
|
-
function_name_from_body = match[1] if match
|
|
33
|
-
end
|
|
31
|
+
run_function_test_transaction(results)
|
|
32
|
+
results[:output] << "\n⚠ Function rolled back (test mode)"
|
|
33
|
+
results
|
|
34
|
+
end
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def fail_result(results, error_message)
|
|
39
|
+
results[:success] = false
|
|
40
|
+
results[:errors] << error_message
|
|
41
|
+
results
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def extract_function_name_from_body
|
|
45
|
+
return nil if @trigger.function_body.blank?
|
|
46
|
+
|
|
47
|
+
match = @trigger.function_body.match(FUNCTION_NAME_PATTERN)
|
|
48
|
+
match && match[1]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def extract_function_name_from_definition
|
|
52
|
+
return nil if @trigger.definition.blank?
|
|
53
|
+
|
|
54
|
+
definition = JSON.parse(@trigger.definition)
|
|
55
|
+
definition["function_name"] || definition["name"]
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
41
59
|
|
|
42
|
-
|
|
60
|
+
def run_function_test_transaction(results)
|
|
43
61
|
ActiveRecord::Base.transaction do
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ActiveRecord::Base.connection.execute(@trigger.function_body)
|
|
47
|
-
results[:function_created] = true
|
|
48
|
-
results[:output] << "✓ Function created in test transaction"
|
|
49
|
-
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
50
|
-
results[:success] = false
|
|
51
|
-
results[:errors] << "Error during function creation: #{e.message}"
|
|
52
|
-
# Don't raise here, let it fall through to ensure block for rollback
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Try to invoke function directly (if test context provided)
|
|
56
|
-
# Note: Empty hash {} is not "present" in Rails, so check if it's not nil
|
|
57
|
-
if results[:function_created]
|
|
58
|
-
# This would require custom invocation logic
|
|
59
|
-
# For now, just verify it was created - if function was successfully created,
|
|
60
|
-
# we can assume it exists and is executable within the transaction
|
|
61
|
-
function_name = nil
|
|
62
|
-
|
|
63
|
-
# First, try to extract from function_body (most reliable)
|
|
64
|
-
if @trigger.function_body.present?
|
|
65
|
-
# Extract function name from CREATE FUNCTION statement
|
|
66
|
-
# Match: CREATE [OR REPLACE] FUNCTION function_name(...)
|
|
67
|
-
pattern = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i
|
|
68
|
-
match = @trigger.function_body.match(pattern)
|
|
69
|
-
function_name = match[1] if match
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# Fallback to definition JSON if function_body extraction failed
|
|
73
|
-
if function_name.blank? && @trigger.definition.present?
|
|
74
|
-
definition = begin
|
|
75
|
-
JSON.parse(@trigger.definition)
|
|
76
|
-
rescue StandardError
|
|
77
|
-
{}
|
|
78
|
-
end
|
|
79
|
-
function_name = definition["function_name"] || definition[:function_name] ||
|
|
80
|
-
definition["name"] || definition[:name]
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Verify function exists in database by checking pg_proc
|
|
84
|
-
# Try to verify via query if function_name is available
|
|
85
|
-
if function_name.present?
|
|
86
|
-
begin
|
|
87
|
-
sanitized_name = begin
|
|
88
|
-
ActiveRecord::Base.connection.quote_string(function_name)
|
|
89
|
-
rescue StandardError => e
|
|
90
|
-
# If quote_string fails, use the function name as-is (less safe but allows test to continue)
|
|
91
|
-
results[:errors] << "Error during function name sanitization: #{e.message}"
|
|
92
|
-
function_name
|
|
93
|
-
end
|
|
94
|
-
check_sql = <<~SQL.squish
|
|
95
|
-
SELECT COUNT(*) as count
|
|
96
|
-
FROM pg_proc p
|
|
97
|
-
JOIN pg_namespace n ON p.pronamespace = n.oid
|
|
98
|
-
WHERE p.proname = '#{sanitized_name}'
|
|
99
|
-
AND n.nspname = 'public'
|
|
100
|
-
SQL
|
|
101
|
-
|
|
102
|
-
result = ActiveRecord::Base.connection.execute(check_sql).first
|
|
103
|
-
results[:function_executed] = result && result["count"].to_i.positive?
|
|
104
|
-
results[:output] << if results[:function_executed]
|
|
105
|
-
"✓ Function exists and is callable"
|
|
106
|
-
else
|
|
107
|
-
"✓ Function created (verified via successful creation)"
|
|
108
|
-
end
|
|
109
|
-
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
110
|
-
results[:function_executed] = false
|
|
111
|
-
results[:success] = false
|
|
112
|
-
results[:errors] << "Error during function verification: #{e.message}"
|
|
113
|
-
# Also add the original error message to ensure it's searchable in tests
|
|
114
|
-
results[:errors] << e.message unless results[:errors].include?(e.message)
|
|
115
|
-
results[:output] << "✓ Function created (verification failed)"
|
|
116
|
-
end
|
|
117
|
-
else
|
|
118
|
-
# If we can't extract function name, assume it was created successfully
|
|
119
|
-
# since function_created is true
|
|
120
|
-
results[:function_executed] = true
|
|
121
|
-
results[:output] << "✓ Function created (execution verified via successful creation)"
|
|
122
|
-
end
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# Set success to true only if no errors occurred and function was created
|
|
62
|
+
create_function_in_transaction(results)
|
|
63
|
+
verify_function_in_transaction(results) if results[:function_created]
|
|
126
64
|
results[:success] = results[:errors].empty? && results[:function_created]
|
|
127
65
|
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
128
66
|
results[:success] = false
|
|
@@ -130,11 +68,63 @@ module PgSqlTriggers
|
|
|
130
68
|
ensure
|
|
131
69
|
raise ActiveRecord::Rollback
|
|
132
70
|
end
|
|
71
|
+
end
|
|
133
72
|
|
|
134
|
-
|
|
135
|
-
|
|
73
|
+
def create_function_in_transaction(results)
|
|
74
|
+
ActiveRecord::Base.connection.execute(@trigger.function_body)
|
|
75
|
+
results[:function_created] = true
|
|
76
|
+
results[:output] << "✓ Function created in test transaction"
|
|
77
|
+
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
78
|
+
results[:success] = false
|
|
79
|
+
results[:errors] << "Error during function creation: #{e.message}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def verify_function_in_transaction(results)
|
|
83
|
+
function_name = extract_function_name_from_body || extract_function_name_from_definition
|
|
84
|
+
|
|
85
|
+
if function_name.blank?
|
|
86
|
+
results[:function_executed] = true
|
|
87
|
+
results[:output] << "✓ Function created (execution verified via successful creation)"
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
verify_function_in_pg_proc(function_name, results)
|
|
136
92
|
end
|
|
137
|
-
|
|
93
|
+
|
|
94
|
+
def verify_function_in_pg_proc(function_name, results)
|
|
95
|
+
sanitized_name = safe_quote_function_name(function_name, results)
|
|
96
|
+
check_sql = <<~SQL.squish
|
|
97
|
+
SELECT COUNT(*) as count
|
|
98
|
+
FROM pg_proc p
|
|
99
|
+
JOIN pg_namespace n ON p.pronamespace = n.oid
|
|
100
|
+
WHERE p.proname = '#{sanitized_name}'
|
|
101
|
+
AND n.nspname = 'public'
|
|
102
|
+
SQL
|
|
103
|
+
|
|
104
|
+
result = ActiveRecord::Base.connection.execute(check_sql).first
|
|
105
|
+
results[:function_executed] = result && result["count"].to_i.positive?
|
|
106
|
+
results[:output] << if results[:function_executed]
|
|
107
|
+
"✓ Function exists and is callable"
|
|
108
|
+
else
|
|
109
|
+
"✓ Function created (verified via successful creation)"
|
|
110
|
+
end
|
|
111
|
+
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
112
|
+
results[:function_executed] = false
|
|
113
|
+
results[:success] = false
|
|
114
|
+
results[:errors] << "Error during function verification: #{e.message}"
|
|
115
|
+
results[:errors] << e.message unless results[:errors].include?(e.message)
|
|
116
|
+
results[:output] << "✓ Function created (verification failed)"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def safe_quote_function_name(function_name, results)
|
|
120
|
+
ActiveRecord::Base.connection.quote_string(function_name)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
# If quote_string fails, use the function name as-is (less safe but allows test to continue)
|
|
123
|
+
results[:errors] << "Error during function name sanitization: #{e.message}"
|
|
124
|
+
function_name
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
public
|
|
138
128
|
|
|
139
129
|
# Check if function already exists in database
|
|
140
130
|
def function_exists?
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module PgSqlTriggers
|
|
7
|
+
# Builds a SQL snapshot of PostgreSQL triggers for db/trigger_structure.sql and
|
|
8
|
+
# emits schema.rb annotations so teams know triggers are outside schema.rb.
|
|
9
|
+
class TriggerStructureDumper
|
|
10
|
+
class << self
|
|
11
|
+
def resolve_path(override = nil)
|
|
12
|
+
base = override || PgSqlTriggers.trigger_structure_sql_path
|
|
13
|
+
resolved = base.respond_to?(:call) ? base.call : base
|
|
14
|
+
resolved ||= default_path
|
|
15
|
+
Pathname(resolved.to_s)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def default_path
|
|
19
|
+
raise "Rails.root is required" unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
20
|
+
|
|
21
|
+
Rails.root.join("db/trigger_structure.sql")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def dump_to(path = nil, connection: ActiveRecord::Base.connection)
|
|
25
|
+
target = resolve_path(path)
|
|
26
|
+
target.dirname.mkpath
|
|
27
|
+
target.write(generate_sql(connection: connection))
|
|
28
|
+
target
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def load_from(path = nil, connection: ActiveRecord::Base.connection)
|
|
32
|
+
target = resolve_path(path)
|
|
33
|
+
raise Errno::ENOENT, target.to_s unless target.file?
|
|
34
|
+
|
|
35
|
+
sql = target.read
|
|
36
|
+
return if sql.strip.empty?
|
|
37
|
+
|
|
38
|
+
connection.raw_connection.exec(sql)
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def generate_sql(connection: ActiveRecord::Base.connection)
|
|
43
|
+
header = <<~SQL
|
|
44
|
+
-- pg_sql_triggers trigger_structure.sql
|
|
45
|
+
-- Generated at: #{Time.now.utc.iso8601}
|
|
46
|
+
--
|
|
47
|
+
-- Apply with: bin/rails trigger:load
|
|
48
|
+
-- Prefer checking this file into version control alongside db/triggers migrations.
|
|
49
|
+
SQL
|
|
50
|
+
|
|
51
|
+
rows = trigger_rows(connection)
|
|
52
|
+
parts = [header]
|
|
53
|
+
|
|
54
|
+
rows.each do |row|
|
|
55
|
+
trigger_name = row["trigger_name"] || row[:trigger_name]
|
|
56
|
+
parts << ""
|
|
57
|
+
parts << "-- Trigger: #{trigger_name}"
|
|
58
|
+
append_definition(parts, row["function_definition"] || row[:function_definition])
|
|
59
|
+
append_definition(parts, row["trigger_definition"] || row[:trigger_definition])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
parts.join("\n").strip.concat("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def schema_rb_annotation(connection: ActiveRecord::Base.connection)
|
|
66
|
+
names = managed_trigger_names(connection)
|
|
67
|
+
lines = []
|
|
68
|
+
lines << " # ---------------------------------------------------------------------------"
|
|
69
|
+
lines << " # pg_sql_triggers: PostgreSQL triggers are not captured in schema.rb."
|
|
70
|
+
lines << " # After db:schema:load, run: bin/rails trigger:migrate (or trigger:load)."
|
|
71
|
+
lines << " # SQL snapshot: db/trigger_structure.sql (bin/rails trigger:dump)."
|
|
72
|
+
lines << " # For full fidelity use config.active_record.schema_format = :sql."
|
|
73
|
+
lines << if names.any?
|
|
74
|
+
" # Managed triggers (#{names.length}): #{names.join(', ')}"
|
|
75
|
+
else
|
|
76
|
+
" # Managed triggers: (none registered in pg_sql_triggers_registry)"
|
|
77
|
+
end
|
|
78
|
+
lines << " # ---------------------------------------------------------------------------"
|
|
79
|
+
lines.join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def managed_trigger_names(connection)
|
|
83
|
+
return [] unless connection.table_exists?("pg_sql_triggers_registry")
|
|
84
|
+
|
|
85
|
+
connection.select_values(
|
|
86
|
+
"SELECT trigger_name FROM pg_sql_triggers_registry ORDER BY trigger_name"
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def trigger_rows(connection)
|
|
93
|
+
if connection.table_exists?("pg_sql_triggers_registry")
|
|
94
|
+
managed_trigger_names(connection).filter_map do |name|
|
|
95
|
+
PgSqlTriggers::Drift::DbQueries.find_trigger(name)
|
|
96
|
+
end
|
|
97
|
+
else
|
|
98
|
+
PgSqlTriggers::Drift::DbQueries.all_triggers
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def append_definition(parts, definition)
|
|
103
|
+
stmt = definition.to_s.strip
|
|
104
|
+
return if stmt.empty?
|
|
105
|
+
|
|
106
|
+
stmt += ";" unless stmt.end_with?(";")
|
|
107
|
+
parts << stmt
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|