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.
Files changed (107) 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 +104 -2
  7. data/COVERAGE.md +39 -41
  8. data/LICENSE +0 -0
  9. data/README.md +24 -3
  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 +105 -78
  29. data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -0
  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 -24
  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 +26 -14
  35. data/app/views/pg_sql_triggers/tables/show.html.erb +0 -0
  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 -0
  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/{20260228000001_add_for_each_to_pg_sql_triggers_registry.rb → 20260228162233_add_for_each_to_pg_sql_triggers_registry.rb} +0 -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 +133 -0
  48. data/docs/audit-trail.md +1 -1
  49. data/docs/configuration.md +172 -0
  50. data/docs/getting-started.md +14 -0
  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 +74 -0
  56. data/docs/web-ui.md +0 -0
  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 +0 -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 +0 -0
  64. data/lib/generators/pg_sql_triggers/trigger_generator.rb +0 -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 +14 -5
  70. data/lib/pg_sql_triggers/drift/detector.rb +9 -1
  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 +56 -2
  74. data/lib/pg_sql_triggers/dsl.rb +0 -0
  75. data/lib/pg_sql_triggers/engine.rb +35 -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 +77 -73
  80. data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
  81. data/lib/pg_sql_triggers/migrator/safety_validator.rb +3 -1
  82. data/lib/pg_sql_triggers/migrator.rb +90 -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 +27 -13
  87. data/lib/pg_sql_triggers/registry/validator.rb +226 -2
  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 +2 -1
  91. data/lib/pg_sql_triggers/sql.rb +0 -0
  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 +17 -0
  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 +65 -13
  105. data/GEM_ANALYSIS.md +0 -368
  106. data/Goal.md +0 -742
  107. 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
- PgSqlTriggers::TriggerRegistry.where(source: "dsl").find_each do |trigger|
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, NoMethodError => e # rubocop:disable Lint/ShadowedException
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
File without changes
File without changes
@@ -7,122 +7,60 @@ module PgSqlTriggers
7
7
  @trigger = trigger_registry
8
8
  end
9
9
 
10
- # Test ONLY the function, not the trigger
11
- # rubocop:disable Lint/UnusedMethodArgument
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
- # Check if function_body is present
21
- if @trigger.function_body.blank?
22
- results[:success] = false
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
- # Extract function name to verify it matches
28
- function_name_from_body = nil
29
- if @trigger.function_body.present?
30
- pattern = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/i
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
- # If function_body doesn't contain a valid function definition, fail early
36
- unless function_name_from_body
37
- results[:success] = false
38
- results[:errors] << "Function body does not contain a valid CREATE FUNCTION statement"
39
- return results
40
- end
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
- # rubocop:disable Metrics/BlockLength
60
+ def run_function_test_transaction(results)
43
61
  ActiveRecord::Base.transaction do
44
- # Create function in transaction
45
- begin
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
- results[:output] << "\n⚠ Function rolled back (test mode)"
135
- results
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
- # rubocop:enable Lint/UnusedMethodArgument, Metrics/BlockLength
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