pg_sql_triggers 1.3.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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.erb_lint.yml +0 -0
  3. data/.rspec +0 -0
  4. data/.rubocop.yml +6 -16
  5. data/AGENTS.md +8 -0
  6. data/CHANGELOG.md +354 -0
  7. data/COVERAGE.md +39 -41
  8. data/LICENSE +0 -0
  9. data/README.md +44 -26
  10. data/RELEASE.md +0 -0
  11. data/Rakefile +5 -0
  12. data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
  13. data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
  14. data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
  15. data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
  16. data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
  17. data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
  18. data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
  19. data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
  20. data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
  21. data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
  22. data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
  23. data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
  24. data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
  25. data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
  26. data/app/models/pg_sql_triggers/application_record.rb +0 -0
  27. data/app/models/pg_sql_triggers/audit_log.rb +29 -47
  28. data/app/models/pg_sql_triggers/trigger_registry.rb +137 -74
  29. data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
  30. data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
  31. data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -27
  32. data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
  33. data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
  34. data/app/views/pg_sql_triggers/tables/index.html.erb +27 -18
  35. data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
  36. data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
  37. data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
  38. data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
  39. data/config/initializers/pg_sql_triggers.rb +0 -0
  40. data/config/routes.rb +0 -14
  41. data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
  42. data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
  43. data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
  44. data/db/migrate/20260228162233_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  45. data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
  46. data/docs/README.md +3 -0
  47. data/docs/api-reference.md +176 -152
  48. data/docs/audit-trail.md +1 -1
  49. data/docs/configuration.md +196 -3
  50. data/docs/getting-started.md +31 -16
  51. data/docs/kill-switch.md +0 -0
  52. data/docs/permissions.md +6 -9
  53. data/docs/troubleshooting.md +0 -0
  54. data/docs/ui-guide.md +0 -0
  55. data/docs/usage-guide.md +112 -67
  56. data/docs/web-ui.md +3 -103
  57. data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
  58. data/lib/generators/pg_sql_triggers/templates/README +0 -0
  59. data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
  60. data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
  61. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  62. data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
  63. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  64. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  65. data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
  66. data/lib/pg_sql_triggers/alerting.rb +77 -0
  67. data/lib/pg_sql_triggers/database_introspection.rb +0 -0
  68. data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
  69. data/lib/pg_sql_triggers/drift/db_queries.rb +26 -13
  70. data/lib/pg_sql_triggers/drift/detector.rb +59 -38
  71. data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
  72. data/lib/pg_sql_triggers/drift.rb +5 -0
  73. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +68 -20
  74. data/lib/pg_sql_triggers/dsl.rb +0 -0
  75. data/lib/pg_sql_triggers/engine.rb +49 -0
  76. data/lib/pg_sql_triggers/errors.rb +0 -0
  77. data/lib/pg_sql_triggers/events_checksum.rb +114 -0
  78. data/lib/pg_sql_triggers/migration.rb +5 -6
  79. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +85 -82
  80. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
  81. data/lib/pg_sql_triggers/migrator/safety_validator.rb +34 -12
  82. data/lib/pg_sql_triggers/migrator.rb +137 -94
  83. data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
  84. data/lib/pg_sql_triggers/permissions.rb +1 -0
  85. data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
  86. data/lib/pg_sql_triggers/registry/manager.rb +60 -21
  87. data/lib/pg_sql_triggers/registry/validator.rb +287 -6
  88. data/lib/pg_sql_triggers/registry.rb +0 -0
  89. data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
  90. data/lib/pg_sql_triggers/sql/kill_switch.rb +154 -275
  91. data/lib/pg_sql_triggers/sql.rb +0 -6
  92. data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
  93. data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
  94. data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
  95. data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
  96. data/lib/pg_sql_triggers/testing.rb +0 -0
  97. data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
  98. data/lib/pg_sql_triggers/version.rb +1 -1
  99. data/lib/pg_sql_triggers.rb +21 -1
  100. data/lib/tasks/trigger_migrations.rake +235 -152
  101. data/rakelib/pg_sql_triggers_environment.rake +9 -0
  102. data/scripts/generate_coverage_report.rb +4 -1
  103. data/sig/pg_sql_triggers.rbs +0 -0
  104. metadata +68 -22
  105. data/Goal.md +0 -742
  106. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  107. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  108. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  109. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  110. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  111. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  112. data/lib/generators/trigger/migration_generator.rb +0 -60
  113. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  114. data/lib/pg_sql_triggers/generator/service.rb +0 -339
  115. data/lib/pg_sql_triggers/generator.rb +0 -8
  116. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  117. data/lib/pg_sql_triggers/sql/executor.rb +0 -200
@@ -1,15 +1,296 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "set"
5
+ require "tsort"
6
+
3
7
  module PgSqlTriggers
4
8
  module Registry
5
- class Validator
6
- # rubocop:disable Naming/PredicateMethod
9
+ class Validator # rubocop:disable Metrics/ClassLength -- validation rules grouped in one class
10
+ VALID_EVENTS = %w[insert update delete truncate].freeze
11
+ VALID_TIMINGS = %w[before after instead_of].freeze
12
+ VALID_FOR_EACH = %w[row statement].freeze
13
+
7
14
  def self.validate!
8
- # Validates all registry entries
9
- # This is a placeholder implementation
10
- true
15
+ errors = []
16
+ dsl_triggers = PgSqlTriggers::TriggerRegistry.where(source: "dsl").to_a
17
+
18
+ dsl_triggers.each do |trigger|
19
+ errors.concat(validate_dsl_trigger(trigger))
20
+ end
21
+
22
+ errors.concat(collect_dependency_and_order_errors(dsl_triggers))
23
+
24
+ return true if errors.empty?
25
+
26
+ raise PgSqlTriggers::ValidationError.new(
27
+ "Registry validation failed:\n#{errors.map { |e| " - #{e}" }.join("\n")}",
28
+ error_code: "VALIDATION_FAILED",
29
+ context: { errors: errors }
30
+ )
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
+
65
+ class << self
66
+ private
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
+
163
+ def validate_dsl_trigger(trigger)
164
+ errors = []
165
+ name = trigger.trigger_name
166
+ definition = parse_definition(trigger.definition)
167
+
168
+ errors << "Trigger '#{name}': missing table_name" if definition["table_name"].blank?
169
+
170
+ events = Array(definition["events"])
171
+ if events.empty?
172
+ errors << "Trigger '#{name}': events cannot be empty"
173
+ else
174
+ invalid = events - VALID_EVENTS
175
+ if invalid.any?
176
+ errors << "Trigger '#{name}': invalid events #{invalid.inspect} (valid: #{VALID_EVENTS.inspect})"
177
+ end
178
+ end
179
+
180
+ errors << "Trigger '#{name}': missing function_name" if definition["function_name"].blank?
181
+
182
+ errors.concat(validate_update_columns(name, events, definition))
183
+
184
+ timing = definition["timing"].to_s
185
+ if timing.present? && VALID_TIMINGS.exclude?(timing)
186
+ errors << "Trigger '#{name}': invalid timing '#{timing}' (valid: #{VALID_TIMINGS.inspect})"
187
+ end
188
+
189
+ for_each = definition["for_each"].to_s
190
+ if for_each.present? && VALID_FOR_EACH.exclude?(for_each)
191
+ errors << "Trigger '#{name}': invalid for_each '#{for_each}' (valid: #{VALID_FOR_EACH.inspect})"
192
+ end
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
241
+ errors
242
+ end
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
+
266
+ def parse_definition(definition_json)
267
+ return {} if definition_json.blank?
268
+
269
+ JSON.parse(definition_json)
270
+ rescue JSON::ParserError
271
+ {}
272
+ end
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
11
292
  end
12
- # rubocop:enable Naming/PredicateMethod
293
+ private_constant :DependsOnSorter
13
294
  end
14
295
  end
15
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