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
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # Drift alerting: configurable callback when drift detection finds drifted, dropped, or unknown
5
+ # triggers. Use with +trigger:check_drift+ or +PgSqlTriggers::Alerting.check_and_notify+.
6
+ module Alerting
7
+ ALERTABLE_STATES = [
8
+ PgSqlTriggers::DRIFT_STATE_DRIFTED,
9
+ PgSqlTriggers::DRIFT_STATE_DROPPED,
10
+ PgSqlTriggers::DRIFT_STATE_UNKNOWN
11
+ ].freeze
12
+
13
+ class << self
14
+ # @param result [Hash] a single drift result from {Drift::Detector}
15
+ # @return [Boolean]
16
+ def alertable?(result)
17
+ ALERTABLE_STATES.include?(result[:state])
18
+ end
19
+
20
+ # @param results [Array<Hash>] drift results from {Drift::Detector.detect_all}
21
+ # @return [Array<Hash>]
22
+ def filter_alertable(results)
23
+ results.select { |r| alertable?(r) }
24
+ end
25
+
26
+ # Runs drift detection for all triggers, invokes +PgSqlTriggers.drift_notifier+ when configured
27
+ # and there is at least one alertable result, and emits +ActiveSupport::Notifications+ when
28
+ # available.
29
+ #
30
+ # The notifier receives one argument: an Array of alertable result hashes (same shape as
31
+ # {Drift::Detector}). For advanced use, a second keyword argument +all_results:+ is passed
32
+ # with the full result set.
33
+ #
34
+ # @return [Hash] +:results+ (all), +:alertable+ (subset), +:notified+ (Boolean)
35
+ def check_and_notify
36
+ results = PgSqlTriggers::Drift::Detector.detect_all
37
+ alertable = filter_alertable(results)
38
+ notified = false
39
+
40
+ payload = {
41
+ results: results,
42
+ alertable: alertable,
43
+ alertable_count: alertable.size,
44
+ total_count: results.size,
45
+ notified: false
46
+ }
47
+
48
+ instrument("pg_sql_triggers.drift_check", payload) do
49
+ if alertable.any? && PgSqlTriggers.drift_notifier
50
+ begin
51
+ PgSqlTriggers.drift_notifier.call(alertable, all_results: results)
52
+ notified = true
53
+ rescue StandardError => e
54
+ payload[:notifier_error] = e.message
55
+ if defined?(Rails.logger) && Rails.logger
56
+ Rails.logger.error("PgSqlTriggers drift_notifier failed: #{e.class}: #{e.message}")
57
+ end
58
+ end
59
+ end
60
+ payload[:notified] = notified
61
+ end
62
+
63
+ { results: results, alertable: alertable, notified: notified }
64
+ end
65
+
66
+ private
67
+
68
+ def instrument(name, payload, &block)
69
+ if defined?(ActiveSupport::Notifications)
70
+ ActiveSupport::Notifications.instrument(name, payload, &block)
71
+ else
72
+ block.call
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
File without changes
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # Normalizes deferrable / initially metadata for checksum calculation so
5
+ # {Registry::Manager}, {TriggerRegistry#calculate_checksum}, and {Drift::Detector}
6
+ # stay aligned with PostgreSQL's pg_trigger flags.
7
+ module DeferralChecksum
8
+ module_function
9
+
10
+ # @return [Array<String>] always three elements: constraint flag, deferrable mode, initially mode
11
+ def parts(constraint_trigger:, deferrable:, initially:)
12
+ constraint = ActiveModel::Type::Boolean.new.cast(constraint_trigger)
13
+ return ["0", "", ""] unless constraint
14
+
15
+ deferrable_sym =
16
+ if deferrable.nil? || deferrable.to_s.strip.empty?
17
+ nil
18
+ else
19
+ deferrable.to_sym
20
+ end
21
+
22
+ deferrable_key = deferrable_sym == :deferrable ? "deferrable" : "not_deferrable"
23
+
24
+ initially_key = if deferrable_key == "deferrable"
25
+ case initially&.to_sym
26
+ when :deferred then "deferred"
27
+ else "immediate"
28
+ end
29
+ else
30
+ ""
31
+ end
32
+
33
+ ["1", deferrable_key, initially_key]
34
+ end
35
+
36
+ # @param db_trigger [Hash] row from {Drift::DbQueries} including tgconstraint, tgdeferrable, tginitdeferred
37
+ # @return [Array<String>] three elements matching {.parts}
38
+ def parts_from_db(db_trigger)
39
+ constraint = db_trigger["tgconstraint"].to_i.nonzero?
40
+ return ["0", "", ""] unless constraint
41
+
42
+ deferrable = ActiveModel::Type::Boolean.new.cast(db_trigger["tgdeferrable"])
43
+ deferrable_key = deferrable ? "deferrable" : "not_deferrable"
44
+
45
+ initially_key = if deferrable
46
+ ActiveModel::Type::Boolean.new.cast(db_trigger["tginitdeferred"]) ? "deferred" : "immediate"
47
+ else
48
+ ""
49
+ end
50
+
51
+ ["1", deferrable_key, initially_key]
52
+ end
53
+ end
54
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module PgSqlTriggers
4
4
  module Drift
5
- module DbQueries
5
+ module DbQueries # rubocop:disable Metrics/ModuleLength -- trigger introspection SQL in one place
6
6
  class << self
7
7
  # Fetch all triggers from database
8
8
  def all_triggers
@@ -16,7 +16,10 @@ module PgSqlTriggers
16
16
  pg_get_triggerdef(t.oid) AS trigger_definition,
17
17
  pg_get_functiondef(p.oid) AS function_definition,
18
18
  t.tgenabled AS enabled,
19
- t.tgisinternal AS is_internal
19
+ t.tgisinternal AS is_internal,
20
+ t.tgconstraint AS tgconstraint,
21
+ t.tgdeferrable AS tgdeferrable,
22
+ t.tginitdeferred AS tginitdeferred
20
23
  FROM pg_trigger t
21
24
  JOIN pg_class c ON t.tgrelid = c.oid
22
25
  JOIN pg_namespace n ON c.relnamespace = n.oid
@@ -42,7 +45,10 @@ module PgSqlTriggers
42
45
  pg_get_triggerdef(t.oid) AS trigger_definition,
43
46
  pg_get_functiondef(p.oid) AS function_definition,
44
47
  t.tgenabled AS enabled,
45
- t.tgisinternal AS is_internal
48
+ t.tgisinternal AS is_internal,
49
+ t.tgconstraint AS tgconstraint,
50
+ t.tgdeferrable AS tgdeferrable,
51
+ t.tginitdeferred AS tginitdeferred
46
52
  FROM pg_trigger t
47
53
  JOIN pg_class c ON t.tgrelid = c.oid
48
54
  JOIN pg_namespace n ON c.relnamespace = n.oid
@@ -68,7 +74,10 @@ module PgSqlTriggers
68
74
  pg_get_triggerdef(t.oid) AS trigger_definition,
69
75
  pg_get_functiondef(p.oid) AS function_definition,
70
76
  t.tgenabled AS enabled,
71
- t.tgisinternal AS is_internal
77
+ t.tgisinternal AS is_internal,
78
+ t.tgconstraint AS tgconstraint,
79
+ t.tgdeferrable AS tgdeferrable,
80
+ t.tginitdeferred AS tginitdeferred
72
81
  FROM pg_trigger t
73
82
  JOIN pg_class c ON t.tgrelid = c.oid
74
83
  JOIN pg_namespace n ON c.relnamespace = n.oid
@@ -115,6 +124,6 @@ module PgSqlTriggers
115
124
  end
116
125
  end
117
126
  end
118
- end
127
+ end # rubocop:enable Metrics/ModuleLength
119
128
  end
120
129
  end
@@ -92,6 +92,12 @@ module PgSqlTriggers
92
92
  condition = extract_trigger_condition(db_trigger)
93
93
  db_for_each = extract_trigger_for_each(db_trigger)
94
94
 
95
+ deferral = PgSqlTriggers::DeferralChecksum.parts_from_db(db_trigger)
96
+
97
+ events_segment = PgSqlTriggers::EventsChecksum.canonical_from_pg_triggerdef(
98
+ db_trigger["trigger_definition"]
99
+ )
100
+
95
101
  # Use same algorithm as TriggerRegistry#calculate_checksum
96
102
  Digest::SHA256.hexdigest([
97
103
  registry_entry.trigger_name,
@@ -100,7 +106,9 @@ module PgSqlTriggers
100
106
  function_body,
101
107
  condition || "",
102
108
  registry_entry.timing || "before",
103
- db_for_each || "row"
109
+ db_for_each || "row",
110
+ events_segment,
111
+ *deferral
104
112
  ].join)
105
113
  end
106
114
 
File without changes
@@ -23,5 +23,10 @@ module PgSqlTriggers
23
23
  def self.report(trigger_name)
24
24
  Reporter.report(trigger_name)
25
25
  end
26
+
27
+ # Runs full drift detection and optional external notification; see {PgSqlTriggers::Alerting}.
28
+ def self.check_and_notify
29
+ Alerting.check_and_notify
30
+ end
26
31
  end
27
32
  end
@@ -3,7 +3,8 @@
3
3
  module PgSqlTriggers
4
4
  module DSL
5
5
  class TriggerDefinition
6
- attr_accessor :name, :table_name, :events, :function_name, :environments, :condition, :version, :enabled
6
+ attr_accessor :name, :table_name, :events, :function_name, :environments, :condition, :version, :enabled,
7
+ :columns, :deferrable, :initially
7
8
  attr_reader :timing, :for_each
8
9
 
9
10
  def initialize(name)
@@ -15,6 +16,21 @@ module PgSqlTriggers
15
16
  @condition = nil
16
17
  @timing = "before"
17
18
  @for_each = "row"
19
+ @columns = nil
20
+ @constraint_trigger = false
21
+ @deferrable = nil
22
+ @initially = nil
23
+ @depends_on = []
24
+ end
25
+
26
+ # Intentionally not named `constraint_trigger?` — matches registry column and JSON key.
27
+ # The setter always casts to a boolean, and the initial value is `false`, so this returns
28
+ # the stored value directly without coercion.
29
+ attr_reader :constraint_trigger
30
+
31
+ def constraint_trigger=(value)
32
+ @constraint_trigger = ActiveModel::Type::Boolean.new.cast(value)
33
+ clear_deferral unless @constraint_trigger
18
34
  end
19
35
 
20
36
  def table(table_name)
@@ -23,6 +39,12 @@ module PgSqlTriggers
23
39
 
24
40
  def on(*events)
25
41
  @events = events.map(&:to_s)
42
+ @columns = nil
43
+ end
44
+
45
+ def on_update_of(*cols)
46
+ @events = ["update"]
47
+ @columns = cols.map(&:to_s)
26
48
  end
27
49
 
28
50
  def function(function_name)
@@ -33,6 +55,10 @@ module PgSqlTriggers
33
55
  @timing = val.to_s
34
56
  end
35
57
 
58
+ def constraint_trigger!
59
+ self.constraint_trigger = true
60
+ end
61
+
36
62
  def for_each_row
37
63
  @for_each = "row"
38
64
  end
@@ -52,6 +78,22 @@ module PgSqlTriggers
52
78
  @condition = condition_sql
53
79
  end
54
80
 
81
+ # Declares that this trigger must run after the named trigger(s) on the same table.
82
+ # PostgreSQL executes same-kind triggers in alphabetical order by name; use naming or
83
+ # validate with Registry.validate! / rake trigger:validate_order.
84
+ def depends_on(*names)
85
+ names.flatten.compact.each do |entry|
86
+ label = entry.to_s.strip
87
+ next if label.empty?
88
+
89
+ @depends_on << label unless @depends_on.include?(label)
90
+ end
91
+ end
92
+
93
+ def depends_on_names
94
+ @depends_on.dup
95
+ end
96
+
55
97
  def function_body
56
98
  nil # DSL definitions don't include function_body directly
57
99
  end
@@ -67,9 +109,21 @@ module PgSqlTriggers
67
109
  environments: @environments,
68
110
  condition: @condition,
69
111
  timing: @timing,
70
- for_each: @for_each
112
+ for_each: @for_each,
113
+ columns: @columns,
114
+ constraint_trigger: @constraint_trigger == true,
115
+ deferrable: @deferrable&.to_s,
116
+ initially: @initially&.to_s,
117
+ depends_on: @depends_on.dup
71
118
  }
72
119
  end
120
+
121
+ private
122
+
123
+ def clear_deferral
124
+ @deferrable = nil
125
+ @initially = nil
126
+ end
73
127
  end
74
128
  end
75
129
  end
File without changes
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "schema_dumper_extension"
4
+ require_relative "trigger_structure_dumper"
5
+
3
6
  module PgSqlTriggers
4
7
  class Engine < ::Rails::Engine
5
8
  isolate_namespace PgSqlTriggers
@@ -26,10 +29,20 @@ module PgSqlTriggers
26
29
  load root.join("lib/tasks/trigger_migrations.rake")
27
30
  end
28
31
 
32
+ initializer "pg_sql_triggers.schema_integration", before: :load_config_initializers do
33
+ ActiveSupport.on_load(:active_record) do
34
+ unless ActiveRecord::SchemaDumper.ancestors.include?(PgSqlTriggers::SchemaDumperExtension)
35
+ ActiveRecord::SchemaDumper.prepend(PgSqlTriggers::SchemaDumperExtension)
36
+ end
37
+ end
38
+ end
39
+
29
40
  # Warn at startup if no permission_checker is set in a protected environment.
30
41
  # The default is to allow all actions (including admin-level ones), which is
31
42
  # unsafe in production without an explicit checker configured.
32
43
  config.after_initialize do
44
+ install_schema_load_trigger_hook
45
+
33
46
  if PgSqlTriggers.permission_checker.nil? && defined?(Rails) && Rails.env.production?
34
47
  Rails.logger.warn(
35
48
  "[PgSqlTriggers] SECURITY WARNING: No permission_checker is configured. " \
@@ -39,5 +52,27 @@ module PgSqlTriggers
39
52
  )
40
53
  end
41
54
  end
55
+
56
+ def self.install_schema_load_trigger_hook
57
+ return if @schema_load_trigger_hook_installed
58
+ return unless PgSqlTriggers.migrate_triggers_after_schema_load
59
+ return if ENV["SKIP_TRIGGER_MIGRATE_AFTER_SCHEMA_LOAD"].present?
60
+ return unless defined?(Rake::Task)
61
+
62
+ if defined?(Rails.application) && Rails.application.respond_to?(:load_tasks)
63
+ Rails.application.load_tasks
64
+ end
65
+
66
+ return unless Rake::Task.task_defined?("db:schema:load")
67
+
68
+ @schema_load_trigger_hook_installed = true
69
+
70
+ Rake::Task["db:schema:load"].enhance do
71
+ next unless PgSqlTriggers.migrate_triggers_after_schema_load
72
+ next if ENV["SKIP_TRIGGER_MIGRATE_AFTER_SCHEMA_LOAD"].present?
73
+
74
+ Rake::Task["trigger:migrate"].invoke
75
+ end
76
+ end
42
77
  end
43
78
  end
File without changes
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgSqlTriggers
4
+ # Normalizes trigger event lists (including PostgreSQL +UPDATE OF col1, col2+) for checksums so
5
+ # {TriggerRegistry#calculate_checksum}, {Registry::Manager#calculate_checksum}, and
6
+ # {Drift::Detector#calculate_db_checksum} stay aligned.
7
+ module EventsChecksum
8
+ module_function
9
+
10
+ # @param defn [Hash] parsed registry +definition+ JSON (string or symbol keys)
11
+ # @return [String] canonical token string, or empty when +events+ is missing/empty
12
+ def canonical_from_definition(defn)
13
+ hash = stringify(defn)
14
+ events = Array(hash["events"]).map(&:to_s).map(&:downcase)
15
+ return "" if events.empty?
16
+
17
+ columns = normalized_columns(hash["columns"])
18
+ build_tokens(events, columns).sort.join("|")
19
+ end
20
+
21
+ # @param definition_json [String, nil]
22
+ def segment_from_definition_json(definition_json)
23
+ return "" if definition_json.blank?
24
+
25
+ canonical_from_definition(JSON.parse(definition_json))
26
+ rescue JSON::ParserError
27
+ ""
28
+ end
29
+
30
+ # @param trigger_def [String] +pg_get_triggerdef+ output
31
+ def canonical_from_pg_triggerdef(trigger_def)
32
+ return "" if trigger_def.blank?
33
+
34
+ clause = extract_events_clause(trigger_def)
35
+ return "" if clause.blank?
36
+
37
+ parse_events_clause(clause).sort.join("|")
38
+ end
39
+
40
+ # SQL fragment for the event list (e.g. +INSERT OR UPDATE OF "email"+), preserving DSL event order.
41
+ def events_sql_fragment(defn, quote_column:)
42
+ hash = stringify(defn)
43
+ events = Array(hash["events"]).map(&:to_s).map(&:downcase)
44
+ columns = ordered_columns(hash["columns"])
45
+ return "INSERT" if events.empty?
46
+
47
+ events.map do |ev|
48
+ if ev == "update" && columns.any?
49
+ quoted = columns.map { |c| quote_column.call(c) }.join(", ")
50
+ "UPDATE OF #{quoted}"
51
+ else
52
+ ev.upcase
53
+ end
54
+ end.join(" OR ")
55
+ end
56
+
57
+ def normalized_columns(raw)
58
+ ordered_columns(raw).map(&:downcase).sort
59
+ end
60
+
61
+ def ordered_columns(raw)
62
+ Array(raw).flatten.compact.map { |c| c.to_s.strip }.reject(&:empty?)
63
+ end
64
+
65
+ def build_tokens(events, sorted_lowercase_columns)
66
+ cols_join = sorted_lowercase_columns.join(",")
67
+ events.map do |ev|
68
+ if ev == "update" && cols_join.present?
69
+ "update:#{cols_join}"
70
+ else
71
+ ev
72
+ end
73
+ end
74
+ end
75
+
76
+ def extract_events_clause(trigger_def)
77
+ s = trigger_def.to_s.squish
78
+ m = s.match(/(?:BEFORE|AFTER|INSTEAD\s+OF)\s+(.+?)\s+ON\s+/i)
79
+ return "" unless m
80
+
81
+ m.captures.first.to_s.strip
82
+ end
83
+
84
+ def parse_events_clause(clause)
85
+ clause.split(/\s+OR\s+/i).filter_map do |part|
86
+ part = part.strip
87
+ next if part.empty?
88
+
89
+ um = part.match(/\AUPDATE\s+OF\s+(.+)\z/i)
90
+ if um
91
+ cols = um[1].split(",").map { |c| normalize_pg_identifier(c) }.sort
92
+ "update:#{cols.join(',')}"
93
+ else
94
+ part.downcase
95
+ end
96
+ end
97
+ end
98
+
99
+ def normalize_pg_identifier(fragment)
100
+ f = fragment.to_s.strip
101
+ if f.start_with?('"') && f.end_with?('"') && f.length >= 2
102
+ f[1...-1].gsub('""', '"').downcase
103
+ else
104
+ f.downcase
105
+ end
106
+ end
107
+
108
+ def stringify(defn)
109
+ return {} if defn.nil?
110
+
111
+ defn.transform_keys(&:to_s)
112
+ end
113
+ end
114
+ end
@@ -4,12 +4,11 @@ module PgSqlTriggers
4
4
  class Migration < ActiveRecord::Migration[6.1]
5
5
  # Base class for trigger migrations
6
6
  # Similar to ActiveRecord::Migration but for trigger-specific migrations
7
-
8
- # rubocop:disable Rails/Delegate
9
- # delegate doesn't work here due to argument forwarding issues in this context
10
- def execute(sql)
11
- connection.execute(sql)
7
+ #
8
+ # Cannot use `delegate` here: ActiveRecord::Migration defines a class-method
9
+ # `delegate` (schema DSL) that shadows Module's `delegate` on Rails 8+.
10
+ def execute(...)
11
+ connection.execute(...)
12
12
  end
13
- # rubocop:enable Rails/Delegate
14
13
  end
15
14
  end