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
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module PgSqlTriggers
8
+ module Generators
9
+ class TriggerGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Generates a pg_sql_triggers DSL file and migration for a new trigger."
15
+
16
+ argument :trigger_name, type: :string,
17
+ desc: "Name of the trigger (e.g. notify_on_insert_users)"
18
+ argument :table_name, type: :string,
19
+ desc: "Database table the trigger attaches to (e.g. users)"
20
+ argument :events, type: :array, default: ["insert"], banner: "EVENT ...",
21
+ desc: "Trigger events: insert, update, delete (default: insert)"
22
+
23
+ class_option :timing, type: :string, default: "before",
24
+ desc: "Trigger timing: before or after (default: before)"
25
+ class_option :function, type: :string,
26
+ desc: "Function name (default: TRIGGER_NAME_function)"
27
+
28
+ def self.next_migration_number(_dirname)
29
+ existing = if Rails.root.join("db/triggers").exist?
30
+ Rails.root.glob("db/triggers/*.rb")
31
+ .map { |f| File.basename(f, ".rb").split("_").first.to_i }
32
+ .reject(&:zero?)
33
+ .max || 0
34
+ else
35
+ 0
36
+ end
37
+
38
+ now = Time.now.utc
39
+ base = now.strftime("%Y%m%d%H%M%S").to_i
40
+ base = existing + 1 if existing.positive? && base <= existing
41
+ base
42
+ end
43
+
44
+ def create_dsl_file
45
+ template "trigger_dsl.rb.tt", "app/triggers/#{trigger_name}.rb"
46
+ end
47
+
48
+ def create_migration_file
49
+ template "trigger_migration_full.rb.tt", "db/triggers/#{migration_file_name}.rb"
50
+ end
51
+
52
+ private
53
+
54
+ def function_name
55
+ options[:function].presence || "#{trigger_name}_function"
56
+ end
57
+
58
+ def timing
59
+ options[:timing]
60
+ end
61
+
62
+ def events_list
63
+ events.map { |e| ":#{e}" }.join(", ")
64
+ end
65
+
66
+ def events_sql
67
+ events.map(&:upcase).join(" OR ")
68
+ end
69
+
70
+ def trigger_class_name
71
+ "Add#{trigger_name.camelize}"
72
+ end
73
+
74
+ def migration_file_name
75
+ "#{migration_number}_#{trigger_name}"
76
+ end
77
+
78
+ def migration_number
79
+ self.class.next_migration_number(nil)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -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,18 +16,21 @@ 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
23
26
  JOIN pg_proc p ON t.tgfoid = p.oid
24
27
  WHERE NOT t.tgisinternal
25
- AND n.nspname = 'public'
28
+ AND n.nspname = $1
26
29
  AND t.tgname NOT LIKE 'RI_%'
27
30
  ORDER BY c.relname, t.tgname;
28
31
  SQL
29
32
 
30
- execute_query(sql)
33
+ execute_query(sql, [schema_name])
31
34
  end
32
35
 
33
36
  # Fetch single trigger
@@ -42,17 +45,20 @@ 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
49
55
  JOIN pg_proc p ON t.tgfoid = p.oid
50
56
  WHERE t.tgname = $1
51
57
  AND NOT t.tgisinternal
52
- AND n.nspname = 'public';
58
+ AND n.nspname = $2;
53
59
  SQL
54
60
 
55
- result = execute_query(sql, [trigger_name])
61
+ result = execute_query(sql, [trigger_name, schema_name])
56
62
  result.first
57
63
  end
58
64
 
@@ -68,19 +74,22 @@ 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
75
84
  JOIN pg_proc p ON t.tgfoid = p.oid
76
85
  WHERE c.relname = $1
77
86
  AND NOT t.tgisinternal
78
- AND n.nspname = 'public'
87
+ AND n.nspname = $2
79
88
  AND t.tgname NOT LIKE 'RI_%'
80
89
  ORDER BY t.tgname;
81
90
  SQL
82
91
 
83
- execute_query(sql, [table_name])
92
+ execute_query(sql, [table_name, schema_name])
84
93
  end
85
94
 
86
95
  # Fetch function body by function name
@@ -92,15 +101,19 @@ module PgSqlTriggers
92
101
  FROM pg_proc p
93
102
  JOIN pg_namespace n ON p.pronamespace = n.oid
94
103
  WHERE p.proname = $1
95
- AND n.nspname = 'public';
104
+ AND n.nspname = $2;
96
105
  SQL
97
106
 
98
- result = execute_query(sql, [function_name])
107
+ result = execute_query(sql, [function_name, schema_name])
99
108
  result.first
100
109
  end
101
110
 
102
111
  private
103
112
 
113
+ def schema_name
114
+ PgSqlTriggers.db_schema.to_s
115
+ end
116
+
104
117
  def execute_query(sql, params = [])
105
118
  if params.any?
106
119
  # Use ActiveRecord's connection to execute parameterized queries
@@ -111,6 +124,6 @@ module PgSqlTriggers
111
124
  end
112
125
  end
113
126
  end
114
- end
127
+ end # rubocop:enable Metrics/ModuleLength
115
128
  end
116
129
  end
@@ -11,27 +11,7 @@ module PgSqlTriggers
11
11
  def detect(trigger_name)
12
12
  registry_entry = TriggerRegistry.find_by(trigger_name: trigger_name)
13
13
  db_trigger = DbQueries.find_trigger(trigger_name)
14
-
15
- # State 1: DISABLED - Registry entry disabled
16
- return disabled_state(registry_entry, db_trigger) if registry_entry&.enabled == false
17
-
18
- # State 2: MANUAL_OVERRIDE - Marked as manual SQL
19
- return manual_override_state(registry_entry, db_trigger) if registry_entry&.source == "manual_sql"
20
-
21
- # State 3: DROPPED - Registry entry exists, DB trigger missing
22
- return dropped_state(registry_entry) if registry_entry && !db_trigger
23
-
24
- # State 4: UNKNOWN - DB trigger exists, no registry entry
25
- return unknown_state(db_trigger) if !registry_entry && db_trigger
26
-
27
- # State 5: DRIFTED - Checksum mismatch
28
- if registry_entry && db_trigger
29
- checksum_match = checksums_match?(registry_entry, db_trigger)
30
- return drifted_state(registry_entry, db_trigger) unless checksum_match
31
- end
32
-
33
- # State 6: IN_SYNC - Everything matches
34
- in_sync_state(registry_entry, db_trigger)
14
+ detect_with_preloaded(registry_entry, db_trigger)
35
15
  end
36
16
 
37
17
  # Detect drift for all triggers
@@ -39,13 +19,14 @@ module PgSqlTriggers
39
19
  registry_entries = TriggerRegistry.all.to_a
40
20
  db_triggers = DbQueries.all_triggers
41
21
 
42
- # Check each registry entry
22
+ db_trigger_map = db_triggers.index_by { |t| t["trigger_name"] }
23
+
43
24
  results = registry_entries.map do |entry|
44
- detect(entry.trigger_name)
25
+ detect_with_preloaded(entry, db_trigger_map[entry.trigger_name])
45
26
  end
46
27
 
47
28
  # Find unknown (external) triggers not in registry
48
- registry_trigger_names = registry_entries.map(&:trigger_name)
29
+ registry_trigger_names = registry_entries.to_set(&:trigger_name)
49
30
  db_triggers.each do |db_trigger|
50
31
  next if registry_trigger_names.include?(db_trigger["trigger_name"])
51
32
 
@@ -60,13 +41,14 @@ module PgSqlTriggers
60
41
  registry_entries = TriggerRegistry.for_table(table_name).to_a
61
42
  db_triggers = DbQueries.find_triggers_for_table(table_name)
62
43
 
63
- # Check each registry entry for this table
44
+ db_trigger_map = db_triggers.index_by { |t| t["trigger_name"] }
45
+
64
46
  results = registry_entries.map do |entry|
65
- detect(entry.trigger_name)
47
+ detect_with_preloaded(entry, db_trigger_map[entry.trigger_name])
66
48
  end
67
49
 
68
50
  # Find unknown triggers on this table
69
- registry_trigger_names = registry_entries.map(&:trigger_name)
51
+ registry_trigger_names = registry_entries.to_set(&:trigger_name)
70
52
  db_triggers.each do |db_trigger|
71
53
  next if registry_trigger_names.include?(db_trigger["trigger_name"])
72
54
 
@@ -78,6 +60,20 @@ module PgSqlTriggers
78
60
 
79
61
  private
80
62
 
63
+ # Core state computation using pre-loaded data — no additional DB queries.
64
+ def detect_with_preloaded(registry_entry, db_trigger)
65
+ return disabled_state(registry_entry, db_trigger) if registry_entry&.enabled == false
66
+ return manual_override_state(registry_entry, db_trigger) if registry_entry&.source == "manual_sql"
67
+ return dropped_state(registry_entry) if registry_entry && !db_trigger
68
+ return unknown_state(db_trigger) if !registry_entry && db_trigger
69
+
70
+ if registry_entry && db_trigger && !checksums_match?(registry_entry, db_trigger)
71
+ return drifted_state(registry_entry, db_trigger)
72
+ end
73
+
74
+ in_sync_state(registry_entry, db_trigger)
75
+ end
76
+
81
77
  # Compare registry checksum with calculated DB checksum
82
78
  def checksums_match?(registry_entry, db_trigger)
83
79
  db_checksum = calculate_db_checksum(registry_entry, db_trigger)
@@ -86,32 +82,47 @@ module PgSqlTriggers
86
82
 
87
83
  # Calculate checksum from DB trigger (must match registry algorithm)
88
84
  def calculate_db_checksum(registry_entry, db_trigger)
89
- # Extract function body from the function definition
90
- function_body = extract_function_body(db_trigger)
85
+ function_body = if registry_entry.source == "dsl"
86
+ db_trigger["function_definition"] || ""
87
+ else
88
+ extract_function_body(db_trigger) || ""
89
+ end
91
90
 
92
- # Extract condition from trigger definition
91
+ # Extract condition and for_each granularity from trigger definition
93
92
  condition = extract_trigger_condition(db_trigger)
93
+ db_for_each = extract_trigger_for_each(db_trigger)
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
+ )
94
100
 
95
101
  # Use same algorithm as TriggerRegistry#calculate_checksum
96
102
  Digest::SHA256.hexdigest([
97
103
  registry_entry.trigger_name,
98
104
  registry_entry.table_name,
99
105
  registry_entry.version,
100
- function_body || "",
101
- condition || ""
106
+ function_body,
107
+ condition || "",
108
+ registry_entry.timing || "before",
109
+ db_for_each || "row",
110
+ events_segment,
111
+ *deferral
102
112
  ].join)
103
113
  end
104
114
 
105
- # Extract function body from pg_get_functiondef output
115
+ # Extract just the PL/pgSQL body from pg_get_functiondef output.
116
+ # pg_get_functiondef() returns the full CREATE OR REPLACE FUNCTION statement;
117
+ # we extract only the content between the dollar-quote delimiters so the
118
+ # comparison is format-agnostic (handles $$ and $function$ styles).
106
119
  def extract_function_body(db_trigger)
107
120
  function_def = db_trigger["function_definition"]
108
121
  return nil unless function_def
109
122
 
110
- # The function definition includes CREATE OR REPLACE FUNCTION header
111
- # We need to extract just the body for comparison
112
- # For now, return the full definition
113
- # TODO: Parse and extract just the body if needed
114
- function_def
123
+ # Match any dollar-quoted string: $tag$body$tag$ (tag may be empty)
124
+ match = function_def.match(/\$([^$]*)\$(.*?)\$\1\$/m)
125
+ match ? match[2].strip : function_def.strip
115
126
  end
116
127
 
117
128
  # Extract WHEN condition from trigger definition
@@ -125,6 +136,16 @@ module PgSqlTriggers
125
136
  match ? match[1].strip : nil
126
137
  end
127
138
 
139
+ # Extract FOR EACH ROW / FOR EACH STATEMENT from trigger definition.
140
+ # Returns "row" or "statement" (lowercase). Defaults to "row".
141
+ def extract_trigger_for_each(db_trigger)
142
+ trigger_def = db_trigger["trigger_definition"]
143
+ return "row" unless trigger_def
144
+
145
+ match = trigger_def.match(/FOR\s+EACH\s+(ROW|STATEMENT)/i)
146
+ match ? match[1].downcase : "row"
147
+ end
148
+
128
149
  # State helper methods
129
150
  def disabled_state(registry_entry, db_trigger)
130
151
  {
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,16 +3,34 @@
3
3
  module PgSqlTriggers
4
4
  module DSL
5
5
  class TriggerDefinition
6
- attr_accessor :name, :table_name, :events, :function_name, :environments, :condition
6
+ attr_accessor :name, :table_name, :events, :function_name, :environments, :condition, :version, :enabled,
7
+ :columns, :deferrable, :initially
8
+ attr_reader :timing, :for_each
7
9
 
8
10
  def initialize(name)
9
11
  @name = name
10
12
  @events = []
11
13
  @version = 1
12
- @enabled = false
14
+ @enabled = true
13
15
  @environments = []
14
16
  @condition = nil
15
17
  @timing = "before"
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
16
34
  end
17
35
 
18
36
  def table(table_name)
@@ -21,29 +39,38 @@ module PgSqlTriggers
21
39
 
22
40
  def on(*events)
23
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)
24
48
  end
25
49
 
26
50
  def function(function_name)
27
51
  @function_name = function_name
28
52
  end
29
53
 
30
- def version(version = nil)
31
- if version.nil?
32
- @version
33
- else
34
- @version = version
35
- end
54
+ def timing=(val)
55
+ @timing = val.to_s
36
56
  end
37
57
 
38
- def enabled(enabled = nil)
39
- if enabled.nil?
40
- @enabled
41
- else
42
- @enabled = enabled
43
- end
58
+ def constraint_trigger!
59
+ self.constraint_trigger = true
60
+ end
61
+
62
+ def for_each_row
63
+ @for_each = "row"
64
+ end
65
+
66
+ def for_each_statement
67
+ @for_each = "statement"
44
68
  end
45
69
 
46
70
  def when_env(*environments)
71
+ warn "[DEPRECATION] `when_env` is deprecated and will be removed in a future version. " \
72
+ "Environment-specific trigger behavior causes schema drift between environments. " \
73
+ "Use application-level configuration instead."
47
74
  @environments = environments.map(&:to_s)
48
75
  end
49
76
 
@@ -51,14 +78,22 @@ module PgSqlTriggers
51
78
  @condition = condition_sql
52
79
  end
53
80
 
54
- def timing(timing_value = nil)
55
- if timing_value.nil?
56
- @timing
57
- else
58
- @timing = timing_value.to_s
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)
59
90
  end
60
91
  end
61
92
 
93
+ def depends_on_names
94
+ @depends_on.dup
95
+ end
96
+
62
97
  def function_body
63
98
  nil # DSL definitions don't include function_body directly
64
99
  end
@@ -73,9 +108,22 @@ module PgSqlTriggers
73
108
  enabled: @enabled,
74
109
  environments: @environments,
75
110
  condition: @condition,
76
- timing: @timing
111
+ timing: @timing,
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
77
118
  }
78
119
  end
120
+
121
+ private
122
+
123
+ def clear_deferral
124
+ @deferrable = nil
125
+ @initially = nil
126
+ end
79
127
  end
80
128
  end
81
129
  end
File without changes