pg_sql_triggers 1.3.0 → 1.4.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +253 -1
  3. data/GEM_ANALYSIS.md +368 -0
  4. data/README.md +20 -23
  5. data/app/models/pg_sql_triggers/trigger_registry.rb +42 -6
  6. data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
  7. data/app/views/pg_sql_triggers/dashboard/index.html.erb +1 -4
  8. data/app/views/pg_sql_triggers/tables/index.html.erb +1 -4
  9. data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
  10. data/config/routes.rb +0 -14
  11. data/db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
  12. data/docs/api-reference.md +44 -153
  13. data/docs/configuration.md +24 -3
  14. data/docs/getting-started.md +17 -16
  15. data/docs/usage-guide.md +38 -67
  16. data/docs/web-ui.md +3 -103
  17. data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
  18. data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
  19. data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
  20. data/lib/pg_sql_triggers/drift/db_queries.rb +12 -8
  21. data/lib/pg_sql_triggers/drift/detector.rb +51 -38
  22. data/lib/pg_sql_triggers/dsl/trigger_definition.rb +17 -23
  23. data/lib/pg_sql_triggers/engine.rb +14 -0
  24. data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +8 -9
  25. data/lib/pg_sql_triggers/migrator/safety_validator.rb +32 -12
  26. data/lib/pg_sql_triggers/migrator.rb +53 -6
  27. data/lib/pg_sql_triggers/registry/manager.rb +36 -11
  28. data/lib/pg_sql_triggers/registry/validator.rb +62 -5
  29. data/lib/pg_sql_triggers/sql/kill_switch.rb +153 -275
  30. data/lib/pg_sql_triggers/sql.rb +0 -6
  31. data/lib/pg_sql_triggers/version.rb +1 -1
  32. data/lib/pg_sql_triggers.rb +4 -1
  33. data/pg_sql_triggers.gemspec +53 -0
  34. metadata +7 -13
  35. data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
  36. data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
  37. data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
  38. data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
  39. data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
  40. data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
  41. data/lib/generators/trigger/migration_generator.rb +0 -60
  42. data/lib/pg_sql_triggers/generator/form.rb +0 -80
  43. data/lib/pg_sql_triggers/generator/service.rb +0 -339
  44. data/lib/pg_sql_triggers/generator.rb +0 -8
  45. data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
  46. data/lib/pg_sql_triggers/sql/executor.rb +0 -200
@@ -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,39 @@ 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
94
 
95
95
  # Use same algorithm as TriggerRegistry#calculate_checksum
96
96
  Digest::SHA256.hexdigest([
97
97
  registry_entry.trigger_name,
98
98
  registry_entry.table_name,
99
99
  registry_entry.version,
100
- function_body || "",
101
- condition || ""
100
+ function_body,
101
+ condition || "",
102
+ registry_entry.timing || "before",
103
+ db_for_each || "row"
102
104
  ].join)
103
105
  end
104
106
 
105
- # Extract function body from pg_get_functiondef output
107
+ # Extract just the PL/pgSQL body from pg_get_functiondef output.
108
+ # pg_get_functiondef() returns the full CREATE OR REPLACE FUNCTION statement;
109
+ # we extract only the content between the dollar-quote delimiters so the
110
+ # comparison is format-agnostic (handles $$ and $function$ styles).
106
111
  def extract_function_body(db_trigger)
107
112
  function_def = db_trigger["function_definition"]
108
113
  return nil unless function_def
109
114
 
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
115
+ # Match any dollar-quoted string: $tag$body$tag$ (tag may be empty)
116
+ match = function_def.match(/\$([^$]*)\$(.*?)\$\1\$/m)
117
+ match ? match[2].strip : function_def.strip
115
118
  end
116
119
 
117
120
  # Extract WHEN condition from trigger definition
@@ -125,6 +128,16 @@ module PgSqlTriggers
125
128
  match ? match[1].strip : nil
126
129
  end
127
130
 
131
+ # Extract FOR EACH ROW / FOR EACH STATEMENT from trigger definition.
132
+ # Returns "row" or "statement" (lowercase). Defaults to "row".
133
+ def extract_trigger_for_each(db_trigger)
134
+ trigger_def = db_trigger["trigger_definition"]
135
+ return "row" unless trigger_def
136
+
137
+ match = trigger_def.match(/FOR\s+EACH\s+(ROW|STATEMENT)/i)
138
+ match ? match[1].downcase : "row"
139
+ end
140
+
128
141
  # State helper methods
129
142
  def disabled_state(registry_entry, db_trigger)
130
143
  {
@@ -3,16 +3,18 @@
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
+ attr_reader :timing, :for_each
7
8
 
8
9
  def initialize(name)
9
10
  @name = name
10
11
  @events = []
11
12
  @version = 1
12
- @enabled = false
13
+ @enabled = true
13
14
  @environments = []
14
15
  @condition = nil
15
16
  @timing = "before"
17
+ @for_each = "row"
16
18
  end
17
19
 
18
20
  def table(table_name)
@@ -27,23 +29,22 @@ module PgSqlTriggers
27
29
  @function_name = function_name
28
30
  end
29
31
 
30
- def version(version = nil)
31
- if version.nil?
32
- @version
33
- else
34
- @version = version
35
- end
32
+ def timing=(val)
33
+ @timing = val.to_s
36
34
  end
37
35
 
38
- def enabled(enabled = nil)
39
- if enabled.nil?
40
- @enabled
41
- else
42
- @enabled = enabled
43
- end
36
+ def for_each_row
37
+ @for_each = "row"
38
+ end
39
+
40
+ def for_each_statement
41
+ @for_each = "statement"
44
42
  end
45
43
 
46
44
  def when_env(*environments)
45
+ warn "[DEPRECATION] `when_env` is deprecated and will be removed in a future version. " \
46
+ "Environment-specific trigger behavior causes schema drift between environments. " \
47
+ "Use application-level configuration instead."
47
48
  @environments = environments.map(&:to_s)
48
49
  end
49
50
 
@@ -51,14 +52,6 @@ module PgSqlTriggers
51
52
  @condition = condition_sql
52
53
  end
53
54
 
54
- def timing(timing_value = nil)
55
- if timing_value.nil?
56
- @timing
57
- else
58
- @timing = timing_value.to_s
59
- end
60
- end
61
-
62
55
  def function_body
63
56
  nil # DSL definitions don't include function_body directly
64
57
  end
@@ -73,7 +66,8 @@ module PgSqlTriggers
73
66
  enabled: @enabled,
74
67
  environments: @environments,
75
68
  condition: @condition,
76
- timing: @timing
69
+ timing: @timing,
70
+ for_each: @for_each
77
71
  }
78
72
  end
79
73
  end
@@ -25,5 +25,19 @@ module PgSqlTriggers
25
25
  rake_tasks do
26
26
  load root.join("lib/tasks/trigger_migrations.rake")
27
27
  end
28
+
29
+ # Warn at startup if no permission_checker is set in a protected environment.
30
+ # The default is to allow all actions (including admin-level ones), which is
31
+ # unsafe in production without an explicit checker configured.
32
+ config.after_initialize do
33
+ if PgSqlTriggers.permission_checker.nil? && defined?(Rails) && Rails.env.production?
34
+ Rails.logger.warn(
35
+ "[PgSqlTriggers] SECURITY WARNING: No permission_checker is configured. " \
36
+ "All actions are permitted by default, including admin-level operations " \
37
+ "(drop_trigger, execute_sql, override_drift). " \
38
+ "Set PgSqlTriggers.permission_checker in an initializer before deploying to production."
39
+ )
40
+ end
41
+ end
28
42
  end
29
43
  end
@@ -12,25 +12,24 @@ module PgSqlTriggers
12
12
  # Compare expected state from migration with actual database state
13
13
  # Returns a comparison result with diff information
14
14
  def compare(migration_instance, direction: :up)
15
- expected = extract_expected_state(migration_instance, direction)
15
+ captured_sql = capture_sql(migration_instance, direction)
16
+ compare_sql(captured_sql)
17
+ end
18
+
19
+ # Compare using pre-captured SQL (avoids re-instantiating the migration class).
20
+ # Returns a comparison result with diff information.
21
+ def compare_sql(captured_sql)
22
+ expected = parse_sql_to_state(captured_sql)
16
23
  actual = extract_actual_state(expected)
17
24
  generate_diff(expected, actual)
18
25
  end
19
26
 
20
27
  private
21
28
 
22
- # Extract expected SQL and state from migration instance
23
- def extract_expected_state(migration_instance, direction)
24
- captured_sql = capture_sql(migration_instance, direction)
25
- parse_sql_to_state(captured_sql)
26
- end
27
-
28
29
  # Capture SQL that would be executed by the migration
29
30
  def capture_sql(migration_instance, direction)
30
31
  captured = []
31
32
 
32
- # Override execute to capture SQL instead of executing
33
- # Since we use a separate instance for comparison, we don't need to restore
34
33
  migration_instance.define_singleton_method(:execute) do |sql|
35
34
  captured << sql.to_s.strip
36
35
  end
@@ -34,26 +34,41 @@ module PgSqlTriggers
34
34
  raise UnsafeOperationError.new(error_message, violations)
35
35
  end
36
36
 
37
+ # Validate using pre-captured SQL (avoids re-instantiating the migration class).
38
+ # Raises UnsafeOperationError if unsafe patterns are detected.
39
+ def validate_sql!(captured_sql, direction: :up, allow_unsafe: false) # rubocop:disable Lint/UnusedMethodArgument
40
+ return if allow_unsafe
41
+
42
+ violations = detect_unsafe_patterns_from_sql(captured_sql)
43
+ return if violations.empty?
44
+
45
+ error_message = build_error_message(violations, "(migration)")
46
+ raise UnsafeOperationError.new(error_message, violations)
47
+ end
48
+
37
49
  # Detect unsafe patterns in migration SQL
38
50
  # Returns array of violation hashes
39
51
  def detect_unsafe_patterns(migration_instance, direction)
40
- violations = []
41
-
42
- # Capture SQL that would be executed
43
52
  captured_sql = capture_sql(migration_instance, direction)
53
+ detect_unsafe_patterns_from_sql(captured_sql)
54
+ end
44
55
 
45
- # Parse SQL to detect unsafe patterns
46
- sql_operations = parse_sql_operations(captured_sql)
56
+ private
47
57
 
48
- # Check for explicit DROP + CREATE patterns (the main safety concern)
58
+ # Core detection logic that works on pre-captured SQL.
59
+ def detect_unsafe_patterns_from_sql(captured_sql)
60
+ violations = []
61
+ sql_operations = parse_sql_operations(captured_sql)
49
62
  violations.concat(detect_drop_create_patterns(sql_operations))
50
-
51
63
  violations
52
64
  end
53
65
 
54
- private
55
-
56
- # Capture SQL that would be executed by the migration
66
+ # Capture SQL that would be executed by the migration.
67
+ # Runs the migration inside a transaction that is always rolled back so
68
+ # that any ActiveRecord migration helpers (add_column, create_table, …)
69
+ # that are called alongside explicit +execute+ calls produce no lasting
70
+ # side effects. The singleton override of +execute+ still intercepts
71
+ # raw SQL strings before they reach the database.
57
72
  def capture_sql(migration_instance, direction)
58
73
  captured = []
59
74
 
@@ -62,8 +77,13 @@ module PgSqlTriggers
62
77
  captured << sql.to_s.strip
63
78
  end
64
79
 
65
- # Call the migration method (up or down) to capture SQL
66
- migration_instance.public_send(direction)
80
+ # Run inside a transaction and roll it back so that any AR helpers
81
+ # called by the migration (add_column, create_table, etc.) do not
82
+ # persist their side effects during the capture phase.
83
+ ActiveRecord::Base.transaction do
84
+ migration_instance.public_send(direction)
85
+ raise ActiveRecord::Rollback
86
+ end
67
87
 
68
88
  captured
69
89
  end
@@ -191,13 +191,17 @@ module PgSqlTriggers
191
191
  end
192
192
  end
193
193
 
194
+ # Capture SQL once from a single inspection instance so that both the
195
+ # safety validator and comparator work from the same snapshot without
196
+ # running the migration code a second time.
197
+ captured_sql = capture_migration_sql(migration_class.new, direction)
198
+
194
199
  # Perform safety validation (prevent unsafe DROP + CREATE operations)
195
- validation_instance = migration_class.new
196
200
  begin
197
201
  allow_unsafe = ENV["ALLOW_UNSAFE_MIGRATIONS"] == "true" ||
198
202
  (defined?(PgSqlTriggers) && PgSqlTriggers.allow_unsafe_migrations == true)
199
203
 
200
- SafetyValidator.validate!(validation_instance, direction: direction, allow_unsafe: allow_unsafe)
204
+ SafetyValidator.validate_sql!(captured_sql, direction: direction, allow_unsafe: allow_unsafe)
201
205
  rescue SafetyValidator::UnsafeOperationError => e
202
206
  # Safety validation failed - block the migration
203
207
  error_msg = "\n#{e.message}\n\n"
@@ -212,11 +216,9 @@ module PgSqlTriggers
212
216
  end
213
217
  end
214
218
 
215
- # Perform pre-apply comparison (diff expected vs actual)
216
- # Create a separate instance for comparison to avoid side effects
217
- comparison_instance = migration_class.new
219
+ # Perform pre-apply comparison (diff expected vs actual) using the same captured SQL
218
220
  begin
219
- diff_result = PreApplyComparator.compare(comparison_instance, direction: direction)
221
+ diff_result = PreApplyComparator.compare_sql(captured_sql)
220
222
 
221
223
  # Log the comparison result
222
224
  if diff_result[:has_differences]
@@ -261,6 +263,8 @@ module PgSqlTriggers
261
263
  cleanup_orphaned_registry_entries
262
264
  end
263
265
  end
266
+
267
+ enforce_disabled_triggers if direction == :up
264
268
  rescue LoadError => e
265
269
  raise StandardError, "Error loading trigger migration #{migration.filename}: #{e.message}"
266
270
  rescue StandardError => e
@@ -295,6 +299,49 @@ module PgSqlTriggers
295
299
  current_version
296
300
  end
297
301
 
302
+ private
303
+
304
+ # Capture the SQL statements a migration would execute for a given direction
305
+ # without committing any side effects. The migration's +execute+ method is
306
+ # overridden on the singleton so raw SQL strings are intercepted, and the
307
+ # whole run is wrapped in a transaction that is always rolled back so that
308
+ # any ActiveRecord migration helpers (add_column, create_table, …) don't
309
+ # persist their effects during the inspection phase.
310
+ def capture_migration_sql(migration_instance, direction)
311
+ captured = []
312
+
313
+ migration_instance.define_singleton_method(:execute) do |sql|
314
+ captured << sql.to_s.strip
315
+ end
316
+
317
+ ActiveRecord::Base.transaction do
318
+ migration_instance.public_send(direction)
319
+ raise ActiveRecord::Rollback
320
+ end
321
+
322
+ captured
323
+ end
324
+
325
+ def enforce_disabled_triggers
326
+ return unless ActiveRecord::Base.connection.table_exists?("pg_sql_triggers_registry")
327
+
328
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
329
+ PgSqlTriggers::TriggerRegistry.disabled.each do |registry|
330
+ next unless introspection.trigger_exists?(registry.trigger_name)
331
+
332
+ conn = ActiveRecord::Base.connection
333
+ quoted_table = conn.quote_table_name(registry.table_name.to_s)
334
+ quoted_trigger = conn.quote_table_name(registry.trigger_name.to_s)
335
+ conn.execute("ALTER TABLE #{quoted_table} DISABLE TRIGGER #{quoted_trigger};")
336
+ rescue StandardError => e
337
+ if defined?(Rails.logger)
338
+ Rails.logger.warn("[MIGRATOR] Could not disable trigger #{registry.trigger_name}: #{e.message}")
339
+ end
340
+ end
341
+ end
342
+
343
+ public
344
+
298
345
  # Clean up registry entries for triggers that no longer exist in the database
299
346
  # This is called after rolling back migrations to keep the registry in sync
300
347
  def cleanup_orphaned_registry_entries
@@ -3,15 +3,19 @@
3
3
  module PgSqlTriggers
4
4
  module Registry
5
5
  class Manager
6
+ REGISTRY_CACHE_MUTEX = Mutex.new
7
+ private_constant :REGISTRY_CACHE_MUTEX
8
+
6
9
  class << self
7
- # Request-level cache to avoid N+1 queries when loading multiple trigger files
8
- # This cache is cleared after each request/transaction to ensure data consistency
10
+ # Request-level cache to avoid N+1 queries when loading multiple trigger files.
11
+ # Access to @_registry_cache is guarded by REGISTRY_CACHE_MUTEX so that
12
+ # concurrent threads cannot observe a partially-initialised hash.
9
13
  def _registry_cache
10
- @_registry_cache ||= {}
14
+ REGISTRY_CACHE_MUTEX.synchronize { @_registry_cache ||= {} }
11
15
  end
12
16
 
13
17
  def _clear_registry_cache
14
- @_registry_cache = {}
18
+ REGISTRY_CACHE_MUTEX.synchronize { @_registry_cache = {} }
15
19
  end
16
20
 
17
21
  # Batch load existing triggers into cache to avoid N+1 queries
@@ -52,7 +56,8 @@ module PgSqlTriggers
52
56
  source: "dsl",
53
57
  environment: definition.environments.join(","),
54
58
  definition: definition.to_h.to_json,
55
- checksum: checksum
59
+ checksum: checksum,
60
+ for_each: definition.for_each || "row"
56
61
  }
57
62
 
58
63
  if existing
@@ -60,6 +65,7 @@ module PgSqlTriggers
60
65
  attributes_changed = attributes.any? do |key, value|
61
66
  existing.send(key) != value
62
67
  end
68
+ enabled_changed = existing.enabled != definition.enabled
63
69
 
64
70
  if attributes_changed
65
71
  begin
@@ -67,6 +73,9 @@ module PgSqlTriggers
67
73
  # Update cache with the modified record (reload to get fresh data)
68
74
  reloaded = existing.reload
69
75
  _registry_cache[trigger_name] = reloaded
76
+ if enabled_changed
77
+ sync_postgresql_enabled_state(existing.trigger_name, existing.table_name, definition.enabled)
78
+ end
70
79
  reloaded
71
80
  rescue ActiveRecord::RecordNotFound
72
81
  # Cached record was deleted, create a new one
@@ -82,6 +91,9 @@ module PgSqlTriggers
82
91
  new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
83
92
  # Cache the newly created record
84
93
  _registry_cache[trigger_name] = new_record
94
+ unless definition.enabled
95
+ sync_postgresql_enabled_state(new_record.trigger_name, new_record.table_name, definition.enabled)
96
+ end
85
97
  new_record
86
98
  end
87
99
  end
@@ -127,22 +139,35 @@ module PgSqlTriggers
127
139
  private
128
140
 
129
141
  def calculate_checksum(definition)
130
- # DSL definitions don't have function_body, so use placeholder
131
- # Generator forms have function_body, so calculate real checksum
132
142
  function_body_value = definition.respond_to?(:function_body) ? definition.function_body : nil
133
- return "placeholder" if function_body_value.blank?
134
143
 
135
- # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
144
+ # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum).
145
+ # DSL definitions have no function_body — use "" so the checksum is real and comparable
146
+ # with what Drift::Detector#calculate_db_checksum computes for DSL-source triggers.
136
147
  require "digest"
137
148
  Digest::SHA256.hexdigest([
138
149
  definition.name,
139
150
  definition.table_name,
140
151
  definition.version,
141
- function_body_value,
152
+ function_body_value || "",
142
153
  definition.condition || "",
143
- definition.timing || "before"
154
+ definition.timing || "before",
155
+ definition.for_each || "row"
144
156
  ].join)
145
157
  end
158
+
159
+ def sync_postgresql_enabled_state(trigger_name, table_name, enabled)
160
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
161
+ return unless introspection.trigger_exists?(trigger_name)
162
+
163
+ conn = ActiveRecord::Base.connection
164
+ quoted_table = conn.quote_table_name(table_name.to_s)
165
+ quoted_trigger = conn.quote_table_name(trigger_name.to_s)
166
+ verb = enabled ? "ENABLE" : "DISABLE"
167
+ conn.execute("ALTER TABLE #{quoted_table} #{verb} TRIGGER #{quoted_trigger};")
168
+ rescue StandardError => e
169
+ Rails.logger.warn("[REGISTER] Could not sync trigger enabled state: #{e.message}") if defined?(Rails.logger)
170
+ end
146
171
  end
147
172
  end
148
173
  end
@@ -1,15 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module PgSqlTriggers
4
6
  module Registry
5
7
  class Validator
6
- # rubocop:disable Naming/PredicateMethod
8
+ VALID_EVENTS = %w[insert update delete truncate].freeze
9
+ VALID_TIMINGS = %w[before after instead_of].freeze
10
+ VALID_FOR_EACH = %w[row statement].freeze
11
+
7
12
  def self.validate!
8
- # Validates all registry entries
9
- # This is a placeholder implementation
10
- true
13
+ errors = []
14
+
15
+ PgSqlTriggers::TriggerRegistry.where(source: "dsl").find_each do |trigger|
16
+ errors.concat(validate_dsl_trigger(trigger))
17
+ end
18
+
19
+ return true if errors.empty?
20
+
21
+ raise PgSqlTriggers::ValidationError.new(
22
+ "Registry validation failed:\n#{errors.map { |e| " - #{e}" }.join("\n")}",
23
+ error_code: "VALIDATION_FAILED",
24
+ context: { errors: errors }
25
+ )
26
+ end
27
+ class << self
28
+ private
29
+
30
+ def validate_dsl_trigger(trigger)
31
+ errors = []
32
+ name = trigger.trigger_name
33
+ definition = parse_definition(trigger.definition)
34
+
35
+ errors << "Trigger '#{name}': missing table_name" if definition["table_name"].blank?
36
+
37
+ events = Array(definition["events"])
38
+ if events.empty?
39
+ errors << "Trigger '#{name}': events cannot be empty"
40
+ else
41
+ invalid = events - VALID_EVENTS
42
+ if invalid.any?
43
+ errors << "Trigger '#{name}': invalid events #{invalid.inspect} (valid: #{VALID_EVENTS.inspect})"
44
+ end
45
+ end
46
+
47
+ errors << "Trigger '#{name}': missing function_name" if definition["function_name"].blank?
48
+
49
+ timing = definition["timing"].to_s
50
+ if timing.present? && VALID_TIMINGS.exclude?(timing)
51
+ errors << "Trigger '#{name}': invalid timing '#{timing}' (valid: #{VALID_TIMINGS.inspect})"
52
+ end
53
+
54
+ for_each = definition["for_each"].to_s
55
+ if for_each.present? && VALID_FOR_EACH.exclude?(for_each)
56
+ errors << "Trigger '#{name}': invalid for_each '#{for_each}' (valid: #{VALID_FOR_EACH.inspect})"
57
+ end
58
+
59
+ errors
60
+ end
61
+
62
+ def parse_definition(definition_json)
63
+ return {} if definition_json.blank?
64
+
65
+ JSON.parse(definition_json)
66
+ rescue JSON::ParserError
67
+ {}
68
+ end
11
69
  end
12
- # rubocop:enable Naming/PredicateMethod
13
70
  end
14
71
  end
15
72
  end