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
@@ -8,7 +8,9 @@ require_relative "migrator/pre_apply_diff_reporter"
8
8
  require_relative "migrator/safety_validator"
9
9
 
10
10
  module PgSqlTriggers
11
- # rubocop:disable Metrics/ClassLength
11
+ # rubocop:disable Metrics/ClassLength -- singleton orchestrator for migration discovery,
12
+ # safety validation, pre-apply comparison, application, and registry cleanup.
13
+ # The class-method API is the public surface; splitting into collaborators would break callers.
12
14
  class Migrator
13
15
  MIGRATIONS_TABLE_NAME = "trigger_migrations"
14
16
 
@@ -162,112 +164,110 @@ module PgSqlTriggers
162
164
  end
163
165
  end
164
166
 
165
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
166
167
  def run_migration(migration, direction)
167
168
  require migration.path
169
+ migration_class = resolve_migration_class(migration.name)
168
170
 
169
- # Extract class name from migration name
170
- # e.g., "posts_comment_count_validation" -> "PostsCommentCountValidation"
171
- base_class_name = migration.name.camelize
172
-
173
- # Try to find the class, trying multiple patterns:
174
- # 1. Direct name (for backwards compatibility)
175
- # 2. With "Add" prefix (for new migrations following Rails conventions)
176
- # 3. With PgSqlTriggers namespace
177
- migration_class = begin
178
- base_class_name.constantize
179
- rescue NameError
180
- begin
181
- # Try with "Add" prefix (Rails migration naming convention)
182
- "Add#{base_class_name}".constantize
183
- rescue NameError
184
- begin
185
- # Try with PgSqlTriggers namespace
186
- "PgSqlTriggers::#{base_class_name}".constantize
187
- rescue NameError
188
- # Try with both Add prefix and PgSqlTriggers namespace
189
- "PgSqlTriggers::Add#{base_class_name}".constantize
190
- end
191
- end
171
+ # Capture SQL once from a single inspection instance so that both the
172
+ # safety validator and comparator work from the same snapshot without
173
+ # running the migration code a second time.
174
+ captured_sql = capture_migration_sql(migration_class.new, direction)
175
+
176
+ perform_safety_validation(captured_sql, migration)
177
+ perform_pre_apply_comparison(captured_sql, migration)
178
+ apply_migration(migration_class, migration, direction)
179
+ enforce_disabled_triggers if direction == :up
180
+ rescue LoadError => e
181
+ raise StandardError, "Error loading trigger migration #{migration.filename}: #{e.message}"
182
+ rescue StandardError => e
183
+ raise StandardError,
184
+ "Error running trigger migration #{migration.filename}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
185
+ end
186
+
187
+ # Resolve the migration class from its snake_case filename. Tries several naming patterns
188
+ # so migrations can use plain CamelCase, "Add" prefix, or be nested under PgSqlTriggers.
189
+ def resolve_migration_class(migration_name)
190
+ base_class_name = migration_name.camelize
191
+ candidates = [
192
+ base_class_name,
193
+ "Add#{base_class_name}",
194
+ "PgSqlTriggers::#{base_class_name}",
195
+ "PgSqlTriggers::Add#{base_class_name}"
196
+ ]
197
+ last_error = nil
198
+ candidates.each do |candidate|
199
+ return candidate.constantize
200
+ rescue NameError => e
201
+ last_error = e
192
202
  end
203
+ raise last_error
204
+ end
193
205
 
194
- # Perform safety validation (prevent unsafe DROP + CREATE operations)
195
- validation_instance = migration_class.new
196
- begin
197
- allow_unsafe = ENV["ALLOW_UNSAFE_MIGRATIONS"] == "true" ||
198
- (defined?(PgSqlTriggers) && PgSqlTriggers.allow_unsafe_migrations == true)
199
-
200
- SafetyValidator.validate!(validation_instance, direction: direction, allow_unsafe: allow_unsafe)
201
- rescue SafetyValidator::UnsafeOperationError => e
202
- # Safety validation failed - block the migration
203
- error_msg = "\n#{e.message}\n\n"
204
- Rails.logger.error(error_msg) if defined?(Rails.logger)
205
- Rails.logger.debug error_msg if ENV["VERBOSE"] != "false" || defined?(Rails::Console)
206
- raise StandardError, "Migration blocked due to unsafe DROP + CREATE operations. " \
207
- "Review the errors above and set ALLOW_UNSAFE_MIGRATIONS=true if you must proceed."
208
- rescue StandardError => e
209
- # Don't fail the migration if validation fails for other reasons - just log it
210
- if defined?(Rails.logger)
211
- Rails.logger.warn("Safety validation failed for migration #{migration.name}: #{e.message}")
212
- end
206
+ # Run the safety validator against captured SQL and translate any
207
+ # UnsafeOperationError into a generic StandardError that halts the migration.
208
+ def perform_safety_validation(captured_sql, migration)
209
+ allow_unsafe = ENV["ALLOW_UNSAFE_MIGRATIONS"] == "true" ||
210
+ (defined?(PgSqlTriggers) && PgSqlTriggers.allow_unsafe_migrations == true)
211
+ SafetyValidator.validate_sql!(captured_sql, allow_unsafe: allow_unsafe)
212
+ rescue SafetyValidator::UnsafeOperationError => e
213
+ error_msg = "\n#{e.message}\n\n"
214
+ Rails.logger.error(error_msg) if defined?(Rails.logger)
215
+ Rails.logger.debug error_msg if ENV["VERBOSE"] != "false" || defined?(Rails::Console)
216
+ raise StandardError, "Migration blocked due to unsafe DROP + CREATE operations. " \
217
+ "Review the errors above and set ALLOW_UNSAFE_MIGRATIONS=true if you must proceed."
218
+ rescue StandardError => e
219
+ # Don't fail the migration if validation fails for other reasons just log it
220
+ return unless defined?(Rails.logger)
221
+
222
+ Rails.logger.warn("Safety validation failed for migration #{migration.name}: #{e.message}")
223
+ end
224
+
225
+ # Run the pre-apply comparator and log the results.
226
+ # Any failure in the comparator itself is swallowed and logged to avoid
227
+ # breaking migrations that are otherwise safe.
228
+ def perform_pre_apply_comparison(captured_sql, migration)
229
+ diff_result = PreApplyComparator.compare_sql(captured_sql)
230
+
231
+ if diff_result[:has_differences]
232
+ log_pre_apply_differences(diff_result, migration)
233
+ elsif defined?(Rails.logger)
234
+ Rails.logger.info("Pre-apply comparison: No differences detected for migration #{migration.name}")
213
235
  end
236
+ rescue StandardError => e
237
+ return unless defined?(Rails.logger)
214
238
 
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
218
- begin
219
- diff_result = PreApplyComparator.compare(comparison_instance, direction: direction)
220
-
221
- # Log the comparison result
222
- if diff_result[:has_differences]
223
- diff_report = PreApplyDiffReporter.format(diff_result, migration_name: migration.name)
224
- if defined?(Rails.logger)
225
- Rails.logger.warn("Pre-apply comparison for migration #{migration.name}:\n#{diff_report}")
226
- end
227
-
228
- # In verbose mode or when called from console, print the diff
229
- if ENV["VERBOSE"] != "false" || defined?(Rails::Console)
230
- Rails.logger.debug { "\n#{PreApplyDiffReporter.format_summary(diff_result)}\n" }
231
- end
232
- elsif defined?(Rails.logger)
233
- Rails.logger.info(
234
- "Pre-apply comparison: No differences detected for migration #{migration.name}"
235
- )
236
- end
237
- rescue StandardError => e
238
- # Don't fail the migration if comparison fails - just log it
239
- if defined?(Rails.logger)
240
- Rails.logger.warn("Pre-apply comparison failed for migration #{migration.name}: #{e.message}")
241
- end
239
+ Rails.logger.warn("Pre-apply comparison failed for migration #{migration.name}: #{e.message}")
240
+ end
241
+
242
+ def log_pre_apply_differences(diff_result, migration)
243
+ diff_report = PreApplyDiffReporter.format(diff_result, migration_name: migration.name)
244
+ if defined?(Rails.logger)
245
+ msg = "Pre-apply comparison for migration #{migration.name}:\n#{diff_report}"
246
+ Rails.logger.warn(msg)
242
247
  end
248
+ return unless ENV["VERBOSE"] != "false" || defined?(Rails::Console)
243
249
 
244
- ActiveRecord::Base.transaction do
245
- # Create a fresh instance for actual execution
246
- migration_instance = migration_class.new
247
- migration_instance.public_send(direction)
250
+ Rails.logger.debug { "\n#{PreApplyDiffReporter.format_summary(diff_result)}\n" }
251
+ end
248
252
 
249
- connection = ActiveRecord::Base.connection
250
- version_str = connection.quote(migration.version.to_s)
253
+ # Execute the migration inside a transaction and record the version change.
254
+ def apply_migration(migration_class, migration, direction)
255
+ ActiveRecord::Base.transaction do
256
+ migration_class.new.public_send(direction)
257
+ record_migration_version(migration.version, direction)
258
+ cleanup_orphaned_registry_entries if direction == :down
259
+ end
260
+ end
251
261
 
252
- if direction == :up
253
- connection.execute(
254
- "INSERT INTO #{MIGRATIONS_TABLE_NAME} (version) VALUES (#{version_str})"
255
- )
256
- else
257
- connection.execute(
258
- "DELETE FROM #{MIGRATIONS_TABLE_NAME} WHERE version = #{version_str}"
259
- )
260
- # Clean up registry entries for triggers that no longer exist in database
261
- cleanup_orphaned_registry_entries
262
- end
262
+ def record_migration_version(version, direction)
263
+ connection = ActiveRecord::Base.connection
264
+ version_str = connection.quote(version.to_s)
265
+ if direction == :up
266
+ connection.execute("INSERT INTO #{MIGRATIONS_TABLE_NAME} (version) VALUES (#{version_str})")
267
+ else
268
+ connection.execute("DELETE FROM #{MIGRATIONS_TABLE_NAME} WHERE version = #{version_str}")
263
269
  end
264
- rescue LoadError => e
265
- raise StandardError, "Error loading trigger migration #{migration.filename}: #{e.message}"
266
- rescue StandardError => e
267
- raise StandardError,
268
- "Error running trigger migration #{migration.filename}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
269
270
  end
270
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
271
271
 
272
272
  def status
273
273
  ensure_migrations_table!
@@ -295,6 +295,49 @@ module PgSqlTriggers
295
295
  current_version
296
296
  end
297
297
 
298
+ private
299
+
300
+ # Capture the SQL statements a migration would execute for a given direction
301
+ # without committing any side effects. The migration's +execute+ method is
302
+ # overridden on the singleton so raw SQL strings are intercepted, and the
303
+ # whole run is wrapped in a transaction that is always rolled back so that
304
+ # any ActiveRecord migration helpers (add_column, create_table, …) don't
305
+ # persist their effects during the inspection phase.
306
+ def capture_migration_sql(migration_instance, direction)
307
+ captured = []
308
+
309
+ migration_instance.define_singleton_method(:execute) do |sql|
310
+ captured << sql.to_s.strip
311
+ end
312
+
313
+ ActiveRecord::Base.transaction do
314
+ migration_instance.public_send(direction)
315
+ raise ActiveRecord::Rollback
316
+ end
317
+
318
+ captured
319
+ end
320
+
321
+ def enforce_disabled_triggers
322
+ return unless ActiveRecord::Base.connection.table_exists?("pg_sql_triggers_registry")
323
+
324
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
325
+ PgSqlTriggers::TriggerRegistry.disabled.each do |registry|
326
+ next unless introspection.trigger_exists?(registry.trigger_name)
327
+
328
+ conn = ActiveRecord::Base.connection
329
+ quoted_table = conn.quote_table_name(registry.table_name.to_s)
330
+ quoted_trigger = conn.quote_table_name(registry.trigger_name.to_s)
331
+ conn.execute("ALTER TABLE #{quoted_table} DISABLE TRIGGER #{quoted_trigger};")
332
+ rescue StandardError => e
333
+ if defined?(Rails.logger)
334
+ Rails.logger.warn("[MIGRATOR] Could not disable trigger #{registry.trigger_name}: #{e.message}")
335
+ end
336
+ end
337
+ end
338
+
339
+ public
340
+
298
341
  # Clean up registry entries for triggers that no longer exist in the database
299
342
  # This is called after rolling back migrations to keep the registry in sync
300
343
  def cleanup_orphaned_registry_entries
@@ -17,24 +17,21 @@ module PgSqlTriggers
17
17
  true
18
18
  end
19
19
 
20
- # rubocop:disable Naming/PredicateMethod
21
20
  def self.check!(actor, action, environment: nil)
22
- unless can?(actor, action, environment: environment)
23
- action_sym = action.to_sym
24
- required_level = Permissions::ACTIONS[action_sym] || "unknown"
25
- message = "Permission denied: #{action_sym} requires #{required_level} level access"
26
- recovery = "Contact your administrator to request #{required_level} level access for this operation."
21
+ return true if can?(actor, action, environment: environment)
27
22
 
28
- raise PgSqlTriggers::PermissionError.new(
29
- message,
30
- error_code: "PERMISSION_DENIED",
31
- recovery_suggestion: recovery,
32
- context: { action: action_sym, required_role: required_level, environment: environment }
33
- )
34
- end
35
- true
23
+ action_sym = action.to_sym
24
+ required_level = Permissions::ACTIONS[action_sym] || "unknown"
25
+ message = "Permission denied: #{action_sym} requires #{required_level} level access"
26
+ recovery = "Contact your administrator to request #{required_level} level access for this operation."
27
+
28
+ raise PgSqlTriggers::PermissionError.new(
29
+ message,
30
+ error_code: "PERMISSION_DENIED",
31
+ recovery_suggestion: recovery,
32
+ context: { action: action_sym, required_role: required_level, environment: environment }
33
+ )
36
34
  end
37
- # rubocop:enable Naming/PredicateMethod
38
35
  end
39
36
  end
40
37
  end
@@ -20,6 +20,7 @@ module PgSqlTriggers
20
20
  generate_trigger: OPERATOR,
21
21
  test_trigger: OPERATOR,
22
22
  drop_trigger: ADMIN,
23
+ # Admin-level SQL; for host `permission_checker` / custom tooling (not used by built-in UI)
23
24
  execute_sql: ADMIN,
24
25
  override_drift: ADMIN
25
26
  }.freeze
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_record"
5
+ require "logger"
6
+
7
+ # Minimal Rails app for running engine Rake tasks from the gem repo (see +rakelib/+).
8
+ module PgSqlTriggersRakeDevApp
9
+ class Application < ::Rails::Application
10
+ config.root = Pathname.new(Dir.pwd)
11
+ config.eager_load = false
12
+ config.active_support.deprecation = :stderr
13
+ config.secret_key_base = "rake_dev_secret_for_pg_sql_triggers_gem"
14
+ config.logger = Logger.new($stdout, level: Logger::ERROR)
15
+ end
16
+ end
17
+
18
+ # Boots a minimal Rails app and ActiveRecord so engine +lib/tasks+ Rake tasks work when
19
+ # +bundle exec rake trigger:*+ is run from the gem repository (no host application).
20
+ module PgSqlTriggers
21
+ module RakeDevelopmentBoot
22
+ module_function
23
+
24
+ def boot!
25
+ return if @booted
26
+
27
+ ENV["RAILS_ENV"] ||= "development"
28
+
29
+ require_relative "../pg_sql_triggers"
30
+
31
+ unless Rails.application
32
+ PgSqlTriggersRakeDevApp::Application.config.paths["app/views"] <<
33
+ PgSqlTriggers::Engine.root.join("app/views").to_s
34
+ PgSqlTriggersRakeDevApp::Application.initialize!
35
+ end
36
+
37
+ establish_connection_from_env!
38
+ load_engine_models
39
+ @booted = true
40
+ end
41
+
42
+ def establish_connection_from_env!
43
+ return if ::ActiveRecord::Base.connected?
44
+
45
+ test_db_config = ENV["DATABASE_URL"] || {
46
+ adapter: "postgresql",
47
+ database: ENV["TEST_DATABASE"] || "pg_sql_triggers_test",
48
+ username: ENV["TEST_DB_USER"] || "postgres",
49
+ password: ENV["TEST_DB_PASSWORD"] || "",
50
+ host: ENV["TEST_DB_HOST"] || "localhost"
51
+ }
52
+
53
+ ::ActiveRecord::Base.establish_connection(test_db_config)
54
+ ::ActiveRecord::Base.connection
55
+ rescue StandardError => e
56
+ warn "PgSqlTriggers: could not connect to PostgreSQL for Rake tasks: #{e.message}"
57
+ raise
58
+ end
59
+
60
+ def load_engine_models
61
+ engine_root = PgSqlTriggers::Engine.root
62
+ Dir[engine_root.join("app/models/**/*.rb")].each { |f| require f }
63
+ end
64
+ end
65
+ end
@@ -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
@@ -43,23 +47,14 @@ module PgSqlTriggers
43
47
 
44
48
  # Calculate checksum using field-concatenation (consistent with TriggerRegistry model)
45
49
  checksum = calculate_checksum(definition)
46
-
47
- attributes = {
48
- trigger_name: definition.name,
49
- table_name: definition.table_name,
50
- version: definition.version,
51
- enabled: definition.enabled,
52
- source: "dsl",
53
- environment: definition.environments.join(","),
54
- definition: definition.to_h.to_json,
55
- checksum: checksum
56
- }
50
+ attributes = registration_attributes(definition, checksum)
57
51
 
58
52
  if existing
59
53
  # Check if attributes have actually changed to avoid unnecessary queries
60
54
  attributes_changed = attributes.any? do |key, value|
61
55
  existing.send(key) != value
62
56
  end
57
+ enabled_changed = existing.enabled != definition.enabled
63
58
 
64
59
  if attributes_changed
65
60
  begin
@@ -67,6 +62,9 @@ module PgSqlTriggers
67
62
  # Update cache with the modified record (reload to get fresh data)
68
63
  reloaded = existing.reload
69
64
  _registry_cache[trigger_name] = reloaded
65
+ if enabled_changed
66
+ sync_postgresql_enabled_state(existing.trigger_name, existing.table_name, definition.enabled)
67
+ end
70
68
  reloaded
71
69
  rescue ActiveRecord::RecordNotFound
72
70
  # Cached record was deleted, create a new one
@@ -82,6 +80,9 @@ module PgSqlTriggers
82
80
  new_record = PgSqlTriggers::TriggerRegistry.create!(attributes)
83
81
  # Cache the newly created record
84
82
  _registry_cache[trigger_name] = new_record
83
+ unless definition.enabled
84
+ sync_postgresql_enabled_state(new_record.trigger_name, new_record.table_name, definition.enabled)
85
+ end
85
86
  new_record
86
87
  end
87
88
  end
@@ -126,23 +127,61 @@ module PgSqlTriggers
126
127
 
127
128
  private
128
129
 
130
+ def registration_attributes(definition, checksum)
131
+ {
132
+ trigger_name: definition.name,
133
+ table_name: definition.table_name,
134
+ version: definition.version,
135
+ enabled: definition.enabled,
136
+ source: "dsl",
137
+ environment: definition.environments.join(","),
138
+ definition: definition.to_h.to_json,
139
+ checksum: checksum,
140
+ for_each: definition.for_each || "row",
141
+ constraint_trigger: definition.respond_to?(:constraint_trigger) && definition.constraint_trigger,
142
+ deferrable: definition.respond_to?(:deferrable) ? definition.deferrable&.to_s.presence : nil,
143
+ initially: definition.respond_to?(:initially) ? definition.initially&.to_s.presence : nil
144
+ }
145
+ end
146
+
129
147
  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
148
  function_body_value = definition.respond_to?(:function_body) ? definition.function_body : nil
133
- return "placeholder" if function_body_value.blank?
134
149
 
135
- # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum)
150
+ # Use field-concatenation algorithm (consistent with TriggerRegistry#calculate_checksum).
151
+ # DSL definitions have no function_body — use "" so the checksum is real and comparable
152
+ # with what Drift::Detector#calculate_db_checksum computes for DSL-source triggers.
136
153
  require "digest"
154
+ deferral = PgSqlTriggers::DeferralChecksum.parts(
155
+ constraint_trigger: definition.respond_to?(:constraint_trigger) && definition.constraint_trigger,
156
+ deferrable: definition.respond_to?(:deferrable) ? definition.deferrable : nil,
157
+ initially: definition.respond_to?(:initially) ? definition.initially : nil
158
+ )
159
+ events_segment = PgSqlTriggers::EventsChecksum.canonical_from_definition(definition.to_h)
137
160
  Digest::SHA256.hexdigest([
138
161
  definition.name,
139
162
  definition.table_name,
140
163
  definition.version,
141
- function_body_value,
164
+ function_body_value || "",
142
165
  definition.condition || "",
143
- definition.timing || "before"
166
+ definition.timing || "before",
167
+ definition.for_each || "row",
168
+ events_segment,
169
+ *deferral
144
170
  ].join)
145
171
  end
172
+
173
+ def sync_postgresql_enabled_state(trigger_name, table_name, enabled)
174
+ introspection = PgSqlTriggers::DatabaseIntrospection.new
175
+ return unless introspection.trigger_exists?(trigger_name)
176
+
177
+ conn = ActiveRecord::Base.connection
178
+ quoted_table = conn.quote_table_name(table_name.to_s)
179
+ quoted_trigger = conn.quote_table_name(trigger_name.to_s)
180
+ verb = enabled ? "ENABLE" : "DISABLE"
181
+ conn.execute("ALTER TABLE #{quoted_table} #{verb} TRIGGER #{quoted_trigger};")
182
+ rescue StandardError => e
183
+ Rails.logger.warn("[REGISTER] Could not sync trigger enabled state: #{e.message}") if defined?(Rails.logger)
184
+ end
146
185
  end
147
186
  end
148
187
  end