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.
- checksums.yaml +4 -4
- data/.erb_lint.yml +0 -0
- data/.rspec +0 -0
- data/.rubocop.yml +6 -16
- data/AGENTS.md +8 -0
- data/CHANGELOG.md +354 -0
- data/COVERAGE.md +39 -41
- data/LICENSE +0 -0
- data/README.md +44 -26
- data/RELEASE.md +0 -0
- data/Rakefile +5 -0
- data/app/assets/javascripts/pg_sql_triggers/application.js +0 -0
- data/app/assets/javascripts/pg_sql_triggers/trigger_actions.js +0 -0
- data/app/assets/stylesheets/pg_sql_triggers/application.css +0 -0
- data/app/controllers/concerns/pg_sql_triggers/error_handling.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/kill_switch_protection.rb +0 -0
- data/app/controllers/concerns/pg_sql_triggers/permission_checking.rb +6 -5
- data/app/controllers/pg_sql_triggers/application_controller.rb +0 -0
- data/app/controllers/pg_sql_triggers/audit_logs_controller.rb +81 -64
- data/app/controllers/pg_sql_triggers/dashboard_controller.rb +111 -34
- data/app/controllers/pg_sql_triggers/migrations_controller.rb +13 -14
- data/app/controllers/pg_sql_triggers/tables_controller.rb +8 -0
- data/app/controllers/pg_sql_triggers/triggers_controller.rb +1 -0
- data/app/helpers/pg_sql_triggers/dashboard_helper.rb +19 -0
- data/app/helpers/pg_sql_triggers/permissions_helper.rb +3 -2
- data/app/models/pg_sql_triggers/application_record.rb +0 -0
- data/app/models/pg_sql_triggers/audit_log.rb +29 -47
- data/app/models/pg_sql_triggers/trigger_registry.rb +137 -74
- data/app/views/layouts/pg_sql_triggers/application.html.erb +0 -1
- data/app/views/pg_sql_triggers/audit_logs/index.html.erb +9 -5
- data/app/views/pg_sql_triggers/dashboard/index.html.erb +107 -27
- data/app/views/pg_sql_triggers/shared/_confirmation_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/shared/_kill_switch_status.html.erb +0 -0
- data/app/views/pg_sql_triggers/tables/index.html.erb +27 -18
- data/app/views/pg_sql_triggers/tables/show.html.erb +0 -2
- data/app/views/pg_sql_triggers/triggers/_drop_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/_re_execute_modal.html.erb +0 -0
- data/app/views/pg_sql_triggers/triggers/show.html.erb +33 -0
- data/config/initializers/pg_sql_triggers.rb +0 -0
- data/config/routes.rb +0 -14
- data/db/migrate/{20251222000001_create_pg_sql_triggers_tables.rb → 20251222104327_create_pg_sql_triggers_tables.rb} +0 -0
- data/db/migrate/20251229071916_add_timing_to_pg_sql_triggers_registry.rb +0 -0
- data/db/migrate/{20260103000001_create_pg_sql_triggers_audit_log.rb → 20260103114508_create_pg_sql_triggers_audit_log.rb} +0 -0
- data/db/migrate/20260228162233_add_for_each_to_pg_sql_triggers_registry.rb +8 -0
- data/db/migrate/20260412185841_add_constraint_deferral_to_pg_sql_triggers_registry.rb +9 -0
- data/docs/README.md +3 -0
- data/docs/api-reference.md +176 -152
- data/docs/audit-trail.md +1 -1
- data/docs/configuration.md +196 -3
- data/docs/getting-started.md +31 -16
- data/docs/kill-switch.md +0 -0
- data/docs/permissions.md +6 -9
- data/docs/troubleshooting.md +0 -0
- data/docs/ui-guide.md +0 -0
- data/docs/usage-guide.md +112 -67
- data/docs/web-ui.md +3 -103
- data/lib/generators/pg_sql_triggers/install_generator.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/README +0 -0
- data/lib/generators/pg_sql_triggers/templates/create_pg_sql_triggers_tables.rb +0 -0
- data/lib/generators/pg_sql_triggers/templates/initializer.rb +14 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_dsl.rb.tt +11 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration.rb.erb +0 -0
- data/lib/generators/pg_sql_triggers/templates/trigger_migration_full.rb.tt +29 -0
- data/lib/generators/pg_sql_triggers/trigger_generator.rb +83 -0
- data/lib/generators/pg_sql_triggers/trigger_migration_generator.rb +0 -0
- data/lib/pg_sql_triggers/alerting.rb +77 -0
- data/lib/pg_sql_triggers/database_introspection.rb +0 -0
- data/lib/pg_sql_triggers/deferral_checksum.rb +54 -0
- data/lib/pg_sql_triggers/drift/db_queries.rb +26 -13
- data/lib/pg_sql_triggers/drift/detector.rb +59 -38
- data/lib/pg_sql_triggers/drift/reporter.rb +0 -0
- data/lib/pg_sql_triggers/drift.rb +5 -0
- data/lib/pg_sql_triggers/dsl/trigger_definition.rb +68 -20
- data/lib/pg_sql_triggers/dsl.rb +0 -0
- data/lib/pg_sql_triggers/engine.rb +49 -0
- data/lib/pg_sql_triggers/errors.rb +0 -0
- data/lib/pg_sql_triggers/events_checksum.rb +114 -0
- data/lib/pg_sql_triggers/migration.rb +5 -6
- data/lib/pg_sql_triggers/migrator/pre_apply_comparator.rb +85 -82
- data/lib/pg_sql_triggers/migrator/pre_apply_diff_reporter.rb +0 -0
- data/lib/pg_sql_triggers/migrator/safety_validator.rb +34 -12
- data/lib/pg_sql_triggers/migrator.rb +137 -94
- data/lib/pg_sql_triggers/permissions/checker.rb +12 -15
- data/lib/pg_sql_triggers/permissions.rb +1 -0
- data/lib/pg_sql_triggers/rake_development_boot.rb +65 -0
- data/lib/pg_sql_triggers/registry/manager.rb +60 -21
- data/lib/pg_sql_triggers/registry/validator.rb +287 -6
- data/lib/pg_sql_triggers/registry.rb +0 -0
- data/lib/pg_sql_triggers/schema_dumper_extension.rb +32 -0
- data/lib/pg_sql_triggers/sql/kill_switch.rb +154 -275
- data/lib/pg_sql_triggers/sql.rb +0 -6
- data/lib/pg_sql_triggers/testing/dry_run.rb +0 -0
- data/lib/pg_sql_triggers/testing/function_tester.rb +97 -107
- data/lib/pg_sql_triggers/testing/safe_executor.rb +0 -0
- data/lib/pg_sql_triggers/testing/syntax_validator.rb +0 -0
- data/lib/pg_sql_triggers/testing.rb +0 -0
- data/lib/pg_sql_triggers/trigger_structure_dumper.rb +111 -0
- data/lib/pg_sql_triggers/version.rb +1 -1
- data/lib/pg_sql_triggers.rb +21 -1
- data/lib/tasks/trigger_migrations.rake +235 -152
- data/rakelib/pg_sql_triggers_environment.rake +9 -0
- data/scripts/generate_coverage_report.rb +4 -1
- data/sig/pg_sql_triggers.rbs +0 -0
- metadata +68 -22
- data/Goal.md +0 -742
- data/app/controllers/pg_sql_triggers/generator_controller.rb +0 -213
- data/app/controllers/pg_sql_triggers/sql_capsules_controller.rb +0 -161
- data/app/views/pg_sql_triggers/generator/new.html.erb +0 -388
- data/app/views/pg_sql_triggers/generator/preview.html.erb +0 -305
- data/app/views/pg_sql_triggers/sql_capsules/new.html.erb +0 -81
- data/app/views/pg_sql_triggers/sql_capsules/show.html.erb +0 -85
- data/lib/generators/trigger/migration_generator.rb +0 -60
- data/lib/pg_sql_triggers/generator/form.rb +0 -80
- data/lib/pg_sql_triggers/generator/service.rb +0 -339
- data/lib/pg_sql_triggers/generator.rb +0 -8
- data/lib/pg_sql_triggers/sql/capsule.rb +0 -79
- 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
|
-
#
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
@@ -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
|
-
#
|
|
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
|