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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f8d9043692145293beaf91342280ca01702979a6e50190fd5930d8578170291
4
- data.tar.gz: 18e9eaaa03de9932f9916bcc2110e9896a271c555a3b94f13348baf1e37c584c
3
+ metadata.gz: 3009d08e9604d3cad9af352b595fe76fb2688c7f1bf80cfbb92bbb9bbadeb996
4
+ data.tar.gz: 0155742bcf1ad97690f42c8193da6ff408f09dab26f8684c84a6954c8438f28f
5
5
  SHA512:
6
- metadata.gz: 8ed71a10f16385f318a4a596a444c07d2cb00a7d07119043c921cf43b1d2199db6d36cc30b5ea523bcbb527a817b97fbe7c507ac81a4cec3931f887c2a4606ca
7
- data.tar.gz: af0162bab6005904e1649619e5a68e1c1f36733e4f8d34f0306c9a64af07356e9f19165dda1a60f19606b36b517ee1ad34224a50b479b02b0b8efd340175f1fc
6
+ metadata.gz: f76fc2c48a00922e7265acce7d8c87e0477686540b4749aef94bbd1d4b914f7cdf76a0c28cc3f3dd816489c24606ee6b2fe424d309dbc03e70cf160fba6daad8
7
+ data.tar.gz: 4988c497e39a54d517e1f96a4328609de0bb0451658d228b46a4bd2d448267b7866ede607eb702430510a88b16fb328d801fc81a00e0d93c231ad7d94caff216
data/CHANGELOG.md CHANGED
@@ -5,7 +5,259 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.4.0] - 2026-03-01
9
+
10
+ ### Added
11
+
12
+ - **[Feature 4.1] `FOR EACH ROW` / `FOR EACH STATEMENT` DSL support** — Every PostgreSQL trigger
13
+ requires a row-level or statement-level execution granularity, but the gem previously hard-coded
14
+ `FOR EACH ROW` with no way for callers to change it. Two new DSL methods, `for_each_row` and
15
+ `for_each_statement`, let trigger definitions declare the desired granularity explicitly. The
16
+ value defaults to `"row"` so all existing definitions continue to produce `FOR EACH ROW` triggers
17
+ without modification. The field is stored in a new `for_each` column on the registry table
18
+ (migration `20260228000001_add_for_each_to_pg_sql_triggers_registry.rb`), included in all three
19
+ checksum computations (`TriggerRegistry#calculate_checksum`, `Registry::Manager#calculate_checksum`,
20
+ and `Drift::Detector#calculate_db_checksum`), extracted from live trigger definitions via a new
21
+ `extract_trigger_for_each` helper, and validated by `Registry::Validator` (only `"row"` and
22
+ `"statement"` are accepted). The SQL reconstructed by `TriggerRegistry#build_trigger_sql_from_definition`
23
+ during `re_execute!` now honours the stored `for_each` value.
24
+ ([lib/pg_sql_triggers/dsl/trigger_definition.rb](lib/pg_sql_triggers/dsl/trigger_definition.rb),
25
+ [lib/pg_sql_triggers/registry/manager.rb](lib/pg_sql_triggers/registry/manager.rb),
26
+ [lib/pg_sql_triggers/registry/validator.rb](lib/pg_sql_triggers/registry/validator.rb),
27
+ [lib/pg_sql_triggers/drift/detector.rb](lib/pg_sql_triggers/drift/detector.rb),
28
+ [app/models/pg_sql_triggers/trigger_registry.rb](app/models/pg_sql_triggers/trigger_registry.rb),
29
+ [db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb](db/migrate/20260228000001_add_for_each_to_pg_sql_triggers_registry.rb))
30
+
31
+ ### Changed
32
+
33
+ - **[Refactor 5.1] `SQL::KillSwitch` reduced from 329 to 166 lines** — The module was inflated by
34
+ four separate single-purpose log helpers (`log_allowed`, `log_override`, `log_blocked`,
35
+ `format_actor`), a `raise_blocked_error` method whose two heredocs (`message` and `recovery`)
36
+ duplicated each other across 30 lines, verbose per-method comments, and a
37
+ `rubocop:disable Metrics/ModuleLength` suppressor. Collapsed the four log helpers into one
38
+ private `log(level, status, op, env, actor, extra = nil)` method; inlined `raise_blocked_error`
39
+ as a concise 5-line `raise` call; removed all comment padding; renamed `detect_environment` →
40
+ `resolve_environment` for clarity. All three-layer semantics (config → ENV override → explicit
41
+ confirmation), public API (`active?`, `check!`, `override`, `validate_confirmation!`), log
42
+ message formats, and error message content are unchanged — all existing specs continue to pass.
43
+ ([lib/pg_sql_triggers/sql/kill_switch.rb](lib/pg_sql_triggers/sql/kill_switch.rb))
44
+
45
+ - **[Design] Eliminated N+1 queries in drift detection** — `Drift::Detector.detect_all` and
46
+ `detect_for_table` previously called `detect(trigger_name)` per registry entry, which issued a
47
+ `TriggerRegistry.find_by` and a `DbQueries.find_trigger` DB query for every row (N+1 pattern).
48
+ These methods now build an `index_by` hash from the bulk-fetched result of `DbQueries.all_triggers`
49
+ and map over pre-loaded registry entries, eliminating all per-entry lookups. A new private method
50
+ `detect_with_preloaded(registry_entry, db_trigger)` performs state computation with zero
51
+ additional queries.
52
+ ([lib/pg_sql_triggers/drift/detector.rb](lib/pg_sql_triggers/drift/detector.rb))
53
+
54
+ - **[Design] Thread-safe registry cache** — `Registry::Manager._registry_cache` was stored in a
55
+ class-level instance variable mutated without synchronisation, creating a race condition on
56
+ multi-threaded Puma servers. A `REGISTRY_CACHE_MUTEX = Mutex.new` constant now guards all reads
57
+ and writes to `@_registry_cache` via `REGISTRY_CACHE_MUTEX.synchronize`.
58
+ ([lib/pg_sql_triggers/registry/manager.rb](lib/pg_sql_triggers/registry/manager.rb))
59
+
60
+ - **[Design] Configurable PostgreSQL schema** — Every SQL query in `Drift::DbQueries` hard-coded
61
+ `n.nspname = 'public'`, making the gem unusable in applications that manage triggers in
62
+ non-public schemas. A new configuration attribute `PgSqlTriggers.db_schema` (default: `"public"`)
63
+ replaces all hard-coded schema literals; the value is passed as a bind parameter via a private
64
+ `schema_name` helper. Override in an initialiser:
65
+ `PgSqlTriggers.db_schema = "app"`.
66
+ ([lib/pg_sql_triggers.rb](lib/pg_sql_triggers.rb),
67
+ [lib/pg_sql_triggers/drift/db_queries.rb](lib/pg_sql_triggers/drift/db_queries.rb))
68
+
69
+ - **[Design] Idiomatic DSL accessor methods** — `TriggerDefinition#version`, `#enabled`, and
70
+ `#timing` used a dual-purpose getter/setter pattern (`def version(v = nil)`) that silently
71
+ returned the current value when called with no argument, making typos invisible. Replaced with
72
+ standard `attr_accessor :version, :enabled` and a custom `timing=(val)` writer that converts to
73
+ string, matching the Ruby/Rails `attr_accessor` convention. DSL block syntax changes from
74
+ `version 1` to `self.version = 1` and `enabled true` to `self.enabled = true`.
75
+ ([lib/pg_sql_triggers/dsl/trigger_definition.rb](lib/pg_sql_triggers/dsl/trigger_definition.rb))
76
+
77
+ - **[Design] `capture_state` now includes `function_body`** — The audit snapshot captured by
78
+ `TriggerRegistry#capture_state` (used in before/after diffs for all audit log entries) omitted
79
+ `function_body`, making it impossible to see the actual function content in audit trail diffs.
80
+ The field is now included in all captured state hashes.
81
+ ([app/models/pg_sql_triggers/trigger_registry.rb](app/models/pg_sql_triggers/trigger_registry.rb))
82
+
83
+ - **[Design 5.4] `Migrator#run_migration` reduced from three migration instances to two** —
84
+ `run_migration` previously instantiated the migration class three times: once for
85
+ `SafetyValidator`, once for `PreApplyComparator`, and once for actual execution. This meant
86
+ the migration code ran twice before execution, making the behaviour unpredictable if the
87
+ migration had side effects that escaped the `execute` override. A new private
88
+ `capture_migration_sql(instance, direction)` helper (wrapped in a rolled-back transaction)
89
+ captures SQL once from a single inspection instance. `SafetyValidator.validate_sql!` and
90
+ `PreApplyComparator.compare_sql` are new entry points that accept pre-captured SQL directly,
91
+ avoiding a second run of the migration code. Execution still uses a fresh instance. The
92
+ existing `validate!` and `compare` instance-based APIs are retained for backward compatibility
93
+ with specs.
94
+ ([lib/pg_sql_triggers/migrator.rb](lib/pg_sql_triggers/migrator.rb),
95
+ [lib/pg_sql_triggers/migrator/safety_validator.rb](lib/pg_sql_triggers/migrator/safety_validator.rb),
96
+ [lib/pg_sql_triggers/migrator/pre_apply_comparator.rb](lib/pg_sql_triggers/migrator/pre_apply_comparator.rb))
97
+
98
+ ### Deprecated
99
+
100
+ - **`DSL::TriggerDefinition#when_env`** — Environment-specific trigger declarations cause schema
101
+ drift between environments and make triggers impossible to test fully outside production.
102
+ `when_env` now emits a `warn`-level deprecation message on every call and will be removed in a
103
+ future major version. Use application-level configuration to gate trigger behaviour by
104
+ environment instead.
105
+ ([lib/pg_sql_triggers/dsl/trigger_definition.rb](lib/pg_sql_triggers/dsl/trigger_definition.rb))
106
+
107
+ ### Removed
108
+
109
+ - **[Refactor 5.3] Web UI trigger generator removed; replaced with Rails CLI generator** —
110
+ `GeneratorController` provided a browser form for generating trigger DSL files and migrations
111
+ at runtime, writing files directly to the server's filesystem. This is a security and
112
+ auditability concern in production (server-side file writes, no code review gate). The
113
+ controller, its two views (`new`, `preview`), the `/generator` routes, `Generator::Service`,
114
+ `Generator::Form`, and all related specs have been removed. Code generation is now a local,
115
+ CLI-driven action via a new `pg_sql_triggers:trigger` Rails generator:
116
+ ```
117
+ rails generate pg_sql_triggers:trigger TRIGGER_NAME TABLE_NAME [EVENTS...] [--timing before|after] [--function fn_name]
118
+ ```
119
+ The generator produces `app/triggers/TRIGGER_NAME.rb` (DSL stub) and
120
+ `db/triggers/TIMESTAMP_TRIGGER_NAME.rb` (migration with function + trigger SQL) directly into
121
+ the working tree, where they go through version control and code review like any other source
122
+ file. The `autoload :Generator` entry is also removed from `PgSqlTriggers`.
123
+ ([lib/generators/pg_sql_triggers/trigger_generator.rb](lib/generators/pg_sql_triggers/trigger_generator.rb),
124
+ [config/routes.rb](config/routes.rb))
125
+
126
+ - **[Refactor 5.2] `SQL::Capsule` and `SQL::Executor` removed** — These classes implemented a
127
+ named-SQL-snippet execution system ("SQL capsules") for emergency operations. The feature is a
128
+ general-purpose dangerous-SQL runner that has nothing specifically to do with trigger management;
129
+ bundling it in this gem conflated concerns and enlarged the web UI's attack surface. Removed:
130
+ `lib/pg_sql_triggers/sql/capsule.rb`, `lib/pg_sql_triggers/sql/executor.rb`,
131
+ `app/controllers/pg_sql_triggers/sql_capsules_controller.rb`, the two associated views, the
132
+ `/sql_capsules` routes, and all related specs. `SQL::KillSwitch` is retained — it continues to
133
+ gate trigger re-execution and migration operations. The `PgSqlTriggers::SQL.execute_capsule`
134
+ convenience method is also removed.
135
+ ([lib/pg_sql_triggers/sql.rb](lib/pg_sql_triggers/sql.rb),
136
+ [config/routes.rb](config/routes.rb))
137
+
138
+ - **Duplicate `trigger:migration` generator namespace** — Two generator namespaces existed for the
139
+ same task: `rails g pg_sql_triggers:trigger_migration` and `rails g trigger:migration`. The
140
+ `Trigger::Generators::MigrationGenerator` (`lib/generators/trigger/`) has been removed; use
141
+ `rails g pg_sql_triggers:trigger_migration` exclusively.
142
+ ([lib/generators/trigger/migration_generator.rb](lib/generators/trigger/migration_generator.rb))
143
+
144
+ ### Security
145
+
146
+ - **[High] Production warning when no `permission_checker` is configured** — The gem's default
147
+ behaviour is to allow all actions (including admin-level operations such as `drop_trigger`,
148
+ `execute_sql`, and `override_drift`) when no `permission_checker` is set. A newly deployed
149
+ application with no extra configuration silently granted every actor full admin access.
150
+ The engine now emits a `Rails.logger.warn` at startup when the app boots in production and
151
+ `PgSqlTriggers.permission_checker` is still `nil`, making the misconfiguration visible in
152
+ production logs immediately on deploy.
153
+ ([lib/pg_sql_triggers/engine.rb](lib/pg_sql_triggers/engine.rb))
154
+
155
+ - **[High] `Registry::Validator` was a no-op stub** — `Validator.validate!` returned `true`
156
+ unconditionally, meaning malformed DSL definitions (missing `table_name`, empty or invalid
157
+ `events`, missing `function_name`, unrecognised `timing` values) silently passed validation
158
+ and were written to the registry. Replaced the stub with real validation: every `source: "dsl"`
159
+ entry in the registry has its stored `definition` JSON parsed and checked against required
160
+ fields and allowed values (`insert / update / delete / truncate` for events;
161
+ `before / after / instead_of` for timing). All errors are collected across all triggers and
162
+ surfaced in a single `ValidationError` listing every violation. Non-DSL entries are not
163
+ validated. Unparseable JSON is treated as an empty definition, which itself fails validation.
164
+ ([lib/pg_sql_triggers/registry/validator.rb](lib/pg_sql_triggers/registry/validator.rb))
165
+
166
+ ### Fixed
167
+ - **[High] `enabled: false` DSL option was cosmetic — trigger still fired in PostgreSQL** —
168
+ The `enabled` field was stored in the registry and surfaced in drift reports, but no
169
+ `ALTER TABLE … DISABLE TRIGGER` was ever issued across three code paths, so the trigger
170
+ continued to fire regardless of the DSL flag.
171
+
172
+ *Gap 1 — `Registry::Manager#register`*: A new private `sync_postgresql_enabled_state` helper
173
+ is called after every create or update that affects the `enabled` field. On **create**, the
174
+ helper fires only when `definition.enabled` is falsy (a newly created PostgreSQL trigger is
175
+ always enabled by default). On **update**, `enabled_changed` is captured before the `update!`
176
+ call so the comparison is always against the old value; the sync fires only when `enabled`
177
+ actually flipped. The helper checks `DatabaseIntrospection#trigger_exists?` before issuing
178
+ any SQL, making it safe to call at app boot before migrations have run, and rescues any error
179
+ with a `Rails.logger.warn` so a transient DB issue cannot crash the registration path.
180
+
181
+ *Gap 2 — `Migrator#run_migration` (`:up`)*: `CREATE TRIGGER` always leaves the trigger
182
+ enabled in PostgreSQL. A new private `enforce_disabled_triggers` method is called after each
183
+ `:up` migration transaction commits; it iterates over all `TriggerRegistry.disabled` entries
184
+ and issues `ALTER TABLE … DISABLE TRIGGER` for any that exist in the database. A per-iteration
185
+ `rescue` ensures one failure does not block the rest.
186
+
187
+ *Gap 3 — `TriggerRegistry#update_registry_after_re_execute`*: `re_execute!` drops and
188
+ recreates the trigger, leaving it always enabled in PostgreSQL. The method previously also
189
+ forced `enabled: true` into the registry `update!` call, overwriting any previously stored
190
+ `false` value. The `enabled: true` is removed from the `update!` so the stored state is
191
+ preserved; if `enabled` is `false`, an `ALTER TABLE … DISABLE TRIGGER` is issued immediately
192
+ after the registry update, within the same transaction.
193
+
194
+ Spec coverage added for all three gaps:
195
+ - `registry_spec.rb` — four cases in a new `"with PostgreSQL enabled state sync"` context:
196
+ create with `enabled: false` when trigger exists in DB, create when trigger not yet in DB
197
+ (no SQL, no error), update `true → false`, and update `false → true`.
198
+ - `trigger_registry_spec.rb` — new `"when registry entry has enabled: false"` context inside
199
+ `#re_execute!`: verifies `enabled` is not flipped to `true` and that `DISABLE TRIGGER` SQL
200
+ is issued after recreation.
201
+ - `migrator_spec.rb` — new `".run_migration with enforce_disabled_triggers"` describe block:
202
+ runs a real migration that creates a trigger, confirms `tgenabled = 'D'` in `pg_trigger`
203
+ afterward.
204
+
205
+ ([lib/pg_sql_triggers/registry/manager.rb](lib/pg_sql_triggers/registry/manager.rb),
206
+ [lib/pg_sql_triggers/migrator.rb](lib/pg_sql_triggers/migrator.rb),
207
+ [app/models/pg_sql_triggers/trigger_registry.rb](app/models/pg_sql_triggers/trigger_registry.rb),
208
+ [spec/pg_sql_triggers/registry_spec.rb](spec/pg_sql_triggers/registry_spec.rb),
209
+ [spec/pg_sql_triggers/trigger_registry_spec.rb](spec/pg_sql_triggers/trigger_registry_spec.rb),
210
+ [spec/pg_sql_triggers/migrator_spec.rb](spec/pg_sql_triggers/migrator_spec.rb))
211
+
212
+ - **[Medium] `enabled` defaulted to `false` in the DSL** — Every newly declared trigger had
213
+ `@enabled = false`, meaning triggers were silently disabled unless the author explicitly added
214
+ `self.enabled = true`. Deployments that omitted the flag would register a disabled trigger that
215
+ appears in the registry but never fires, with no warning. Changed the default to `true` so
216
+ triggers are active unless explicitly disabled.
217
+ ([lib/pg_sql_triggers/dsl/trigger_definition.rb](lib/pg_sql_triggers/dsl/trigger_definition.rb))
218
+
219
+ - **[Critical] Drift detector checksum excluded `timing` field** — `Drift::Detector#calculate_db_checksum`
220
+ was hashing without the `timing` attribute, while `TriggerRegistry#calculate_checksum` and
221
+ `Registry::Manager#calculate_checksum` both include it. Any trigger with a non-default timing
222
+ value (`"after"`) permanently showed as `DRIFTED` even when fully in sync.
223
+ ([lib/pg_sql_triggers/drift/detector.rb](lib/pg_sql_triggers/drift/detector.rb))
224
+
225
+ - **[Critical] `extract_function_body` returned the full `CREATE FUNCTION` statement** —
226
+ `pg_get_functiondef()` returns the complete DDL including the `CREATE OR REPLACE FUNCTION`
227
+ header and language clause. The detector was hashing that entire string while the registry
228
+ stores only the PL/pgSQL body, so checksums never matched and every trigger appeared `DRIFTED`.
229
+ Fixed by extracting only the content between dollar-quote delimiters (`$$` / `$function$`).
230
+ ([lib/pg_sql_triggers/drift/detector.rb](lib/pg_sql_triggers/drift/detector.rb))
231
+
232
+ - **[Critical] DSL triggers stored `"placeholder"` as checksum causing permanent false drift** —
233
+ `Registry::Manager#calculate_checksum` returned the literal string `"placeholder"` whenever
234
+ `function_body` was blank (the normal case for DSL-defined triggers). The drift detector then
235
+ computed a real SHA256 hash and compared it to `"placeholder"`, so all DSL triggers always
236
+ appeared `DRIFTED`. Fixed by computing a real checksum using `""` for `function_body`, and
237
+ teaching the detector to also use `""` for `function_body` when the registry source is `"dsl"`.
238
+ ([lib/pg_sql_triggers/registry/manager.rb](lib/pg_sql_triggers/registry/manager.rb),
239
+ [lib/pg_sql_triggers/drift/detector.rb](lib/pg_sql_triggers/drift/detector.rb))
240
+
241
+ - **[Critical] `re_execute!` always raised for DSL-defined triggers** —
242
+ `TriggerRegistry#re_execute!` raised `StandardError` immediately when `function_body` was
243
+ blank, which is always the case for DSL triggers (the primary use path). Added a
244
+ `build_trigger_sql_from_definition` private helper that reconstructs a valid `CREATE TRIGGER`
245
+ SQL statement from the stored DSL definition JSON (`function_name`, `timing`, `events`,
246
+ `condition`). `re_execute!` now falls back to this reconstructed SQL when `function_body` is
247
+ absent, making the method functional for DSL triggers.
248
+ ([app/models/pg_sql_triggers/trigger_registry.rb](app/models/pg_sql_triggers/trigger_registry.rb))
249
+
250
+ - **[High] `SafetyValidator#capture_sql` executed migration side effects during capture** —
251
+ `capture_sql` monkey-patched only the `execute` method, so any ActiveRecord migration helpers
252
+ (`add_column`, `create_table`, etc.) called by the migration ran for real as a side effect of
253
+ the safety check. Wrapped the migration invocation in
254
+ `ActiveRecord::Base.transaction { …; raise ActiveRecord::Rollback }` so all schema changes
255
+ are rolled back after SQL capture.
256
+ ([lib/pg_sql_triggers/migrator/safety_validator.rb](lib/pg_sql_triggers/migrator/safety_validator.rb))
257
+
258
+ - **[Low] Dead `trigger_name` expression in `drop!`** — A bare `trigger_name` expression inside
259
+ the `drop!` transaction block was a no-op whose return value was silently discarded. Removed.
260
+ ([app/models/pg_sql_triggers/trigger_registry.rb](app/models/pg_sql_triggers/trigger_registry.rb))
9
261
 
10
262
  ## [1.3.0] - 2026-01-05
11
263
 
data/GEM_ANALYSIS.md ADDED
@@ -0,0 +1,368 @@
1
+ # pg_sql_triggers — Gem Analysis
2
+
3
+ > Analysed against version **1.3.0** · February 2026
4
+
5
+ ---
6
+
7
+ ## Table of Contents
8
+ 1. [Critical Bugs](#1-critical-bugs)
9
+ 2. [Design Issues](#2-design-issues)
10
+ 3. [Security Concerns](#3-security-concerns)
11
+ 4. [Missing Features](#4-missing-features)
12
+ 5. [Unnecessary / Over-engineered Features](#5-unnecessary--over-engineered-features)
13
+ 6. [Low-coverage Hotspots](#6-low-coverage-hotspots)
14
+ 7. [Summary Table](#7-summary-table)
15
+
16
+ ---
17
+
18
+ ## 1. Critical Bugs
19
+
20
+ ### 1.1 Checksum Algorithm Is Inconsistent — Will Always Report False Drift
21
+
22
+ Three places compute a trigger checksum. They use different field sets:
23
+
24
+ | Location | Fields included |
25
+ |---|---|
26
+ | `TriggerRegistry#calculate_checksum` | `trigger_name, table_name, version, function_body, condition, timing` |
27
+ | `Registry::Manager#calculate_checksum` | `name, table_name, version, function_body, condition, timing` |
28
+ | `Drift::Detector#calculate_db_checksum` | `trigger_name, table_name, version, function_body, condition` ← **missing `timing`** |
29
+
30
+ Because the registry stores a checksum that includes `timing`, but the drift detector recomputes the checksum without `timing`, **every trigger that has a non-default timing value will permanently show as `DRIFTED`** even when fully in sync.
31
+
32
+ Files:
33
+ - [lib/pg_sql_triggers/drift/detector.rb:88-103](lib/pg_sql_triggers/drift/detector.rb#L88-L103)
34
+ - [app/models/pg_sql_triggers/trigger_registry.rb:318-327](app/models/pg_sql_triggers/trigger_registry.rb#L318-L327)
35
+ - [lib/pg_sql_triggers/registry/manager.rb:129-145](lib/pg_sql_triggers/registry/manager.rb#L129-L145)
36
+
37
+ ---
38
+
39
+ ### 1.2 `extract_function_body` Returns the Full `CREATE FUNCTION` Statement
40
+
41
+ `Detector#extract_function_body` returns `function_def` unchanged — the full output of `pg_get_functiondef()`, including the `CREATE OR REPLACE FUNCTION` header. The registry stores only the PL/pgSQL body. These two strings will never match, so **the drift detector will report every trigger as DRIFTED regardless of actual state**.
42
+
43
+ ```ruby
44
+ # lib/pg_sql_triggers/drift/detector.rb:106-114
45
+ def extract_function_body(db_trigger)
46
+ function_def = db_trigger["function_definition"]
47
+ return nil unless function_def
48
+
49
+ # TODO: Parse and extract just the body if needed
50
+ function_def # ← returns entire CREATE FUNCTION definition
51
+ end
52
+ ```
53
+
54
+ The `# TODO` comment confirms this is incomplete.
55
+
56
+ ---
57
+
58
+ ### 1.3 DSL Triggers Store `"placeholder"` as Checksum → Permanent False Drift
59
+
60
+ When a DSL trigger definition has no `function_body` (which is the normal case — `TriggerDefinition#function_body` always returns `nil`), `Manager#calculate_checksum` detects `function_body_value.blank?` and stores the literal string `"placeholder"` in the registry. The drift detector then computes a real SHA256 hash from the database and compares it to `"placeholder"`, so **all DSL-defined triggers always appear DRIFTED**.
61
+
62
+ ```ruby
63
+ # lib/pg_sql_triggers/registry/manager.rb:133
64
+ return "placeholder" if function_body_value.blank?
65
+ ```
66
+
67
+ ---
68
+
69
+ ### 1.4 `re_execute!` Is Broken for All DSL-Defined Triggers
70
+
71
+ `TriggerRegistry#re_execute!` raises immediately if `function_body` is blank:
72
+
73
+ ```ruby
74
+ raise StandardError, "Cannot re-execute: missing function_body" if function_body.blank?
75
+ ```
76
+
77
+ Since `TriggerDefinition#function_body` always returns `nil`, DSL-defined triggers (the primary usage path) can **never be re-executed** through this method. The method is effectively dead for the main use case.
78
+
79
+ File: [app/models/pg_sql_triggers/trigger_registry.rb:289](app/models/pg_sql_triggers/trigger_registry.rb#L289)
80
+
81
+ ---
82
+
83
+ ### 1.5 `SafetyValidator#capture_sql` Actually Executes the Migration
84
+
85
+ The safety validator captures SQL by monkey-patching the instance's `execute` method, then calling the migration's `up`/`down` method:
86
+
87
+ ```ruby
88
+ # lib/pg_sql_triggers/migrator/safety_validator.rb:57-68
89
+ def capture_sql(migration_instance, direction)
90
+ captured = []
91
+ migration_instance.define_singleton_method(:execute) do |sql|
92
+ captured << sql.to_s.strip
93
+ end
94
+ migration_instance.public_send(direction) # ← runs the migration method
95
+ captured
96
+ end
97
+ ```
98
+
99
+ If the migration does anything other than call `execute` (e.g. calls `add_column`, `create_table`, or any other ActiveRecord migration helper), those side effects run for real during the safety check. Then `run_migration` creates two more instances and runs them again — meaning a single migration run can execute the migration's code path **three times**.
100
+
101
+ ---
102
+
103
+ ### 1.6 Dead Code in `drop!`
104
+
105
+ Inside `drop!`, there is a bare expression `trigger_name` on its own line that does nothing:
106
+
107
+ ```ruby
108
+ # app/models/pg_sql_triggers/trigger_registry.rb:249-251
109
+ ActiveRecord::Base.transaction do
110
+ drop_trigger_from_database
111
+ trigger_name # ← no-op; return value is discarded
112
+ destroy!
113
+ ```
114
+
115
+ This is likely a leftover from an assignment like `name = trigger_name` that was refactored away.
116
+
117
+ ---
118
+
119
+ ## 2. Design Issues
120
+
121
+ ### 2.1 N+1 Queries in All Drift Registry Methods
122
+
123
+ `Registry::Manager#drifted`, `#in_sync`, `#unknown_triggers`, and `#dropped` all call `Drift::Detector.detect_all`, which loops over every registry entry and calls `detect(entry.trigger_name)` per entry. Each `detect` call performs `TriggerRegistry.find_by(trigger_name:)` — an individual SQL query. For N triggers, this is N+1 queries.
124
+
125
+ ```ruby
126
+ # lib/pg_sql_triggers/drift/detector.rb:38-56
127
+ def detect_all
128
+ registry_entries = TriggerRegistry.all.to_a # 1 query
129
+ db_triggers = DbQueries.all_triggers # 1 query
130
+ results = registry_entries.map do |entry|
131
+ detect(entry.trigger_name) # 1 query per entry ← N+1
132
+ end
133
+ ```
134
+
135
+ ---
136
+
137
+ ### 2.2 Class-Level Cache Is Not Thread-Safe
138
+
139
+ `Registry::Manager._registry_cache` is stored in a class-level instance variable (`@_registry_cache`). In a multi-threaded Puma server, concurrent requests share and mutate this hash without synchronisation, creating a potential race condition where one thread's cache write corrupts another thread's lookup.
140
+
141
+ File: [lib/pg_sql_triggers/registry/manager.rb:9-15](lib/pg_sql_triggers/registry/manager.rb#L9-L15)
142
+
143
+ ---
144
+
145
+ ### 2.3 `DbQueries` Hard-Codes the `public` Schema
146
+
147
+ Every SQL query filters `n.nspname = 'public'`. Applications using non-public schemas (multi-tenant schemas, `app`, `audit`, etc.) cannot use this gem at all.
148
+
149
+ File: [lib/pg_sql_triggers/drift/db_queries.rb](lib/pg_sql_triggers/drift/db_queries.rb) — lines 27, 51, 78, 93
150
+
151
+ ---
152
+
153
+ ### 2.4 `when_env` DSL Option Is an Anti-Pattern
154
+
155
+ The DSL supports environment-specific triggers:
156
+
157
+ ```ruby
158
+ PgSqlTriggers::DSL.pg_sql_trigger "users_email_validation" do
159
+ when_env :production
160
+ end
161
+ ```
162
+
163
+ Having triggers that exist only in production but not in staging or development makes it impossible to test them fully. Schema drift between environments is guaranteed and bugs will only surface in production. Environment-specific behavior belongs in configuration, not in trigger definitions.
164
+
165
+ ---
166
+
167
+ ### 2.5 Dual Generator Paths Create Ambiguity
168
+
169
+ Two separate generator namespaces exist for the same task:
170
+
171
+ - `lib/generators/pg_sql_triggers/trigger_migration_generator.rb` → invoked with `rails g pg_sql_triggers:trigger_migration`
172
+ - `lib/generators/trigger/migration_generator.rb` → invoked with `rails g trigger:migration`
173
+
174
+ There is no documentation explaining which to prefer, and having both increases maintenance surface without benefit.
175
+
176
+ ---
177
+
178
+ ### 2.6 `capture_state` Does Not Include `function_body`
179
+
180
+ The audit log state snapshot captures `enabled`, `version`, `checksum`, `table_name`, `source`, `environment`, and `installed_at` — but not `function_body`. Diffs recorded in audit logs will not show what the function body was before/after an operation, making the audit trail incomplete for the most important change: the function itself.
181
+
182
+ File: [app/models/pg_sql_triggers/trigger_registry.rb:414-424](app/models/pg_sql_triggers/trigger_registry.rb#L414-L424)
183
+
184
+ ---
185
+
186
+ ### 2.7 `enabled` and `version` Use Non-Idiomatic Dual-Purpose Methods in DSL
187
+
188
+ ```ruby
189
+ def version(version = nil)
190
+ if version.nil?
191
+ @version
192
+ else
193
+ @version = version
194
+ end
195
+ end
196
+ ```
197
+
198
+ Ruby DSLs conventionally use `attr_accessor` for simple getters/setters or separate `def version=(v)` / `def version` methods. The current pattern is confusing because calling `version` with no arguments returns the current value rather than raising `ArgumentError`, making typos silent.
199
+
200
+ ---
201
+
202
+ ## 3. Security Concerns
203
+
204
+ ### 3.1 Default Permissions Allow Everything
205
+
206
+ `Permissions::Checker#can?` returns `true` for all actors and all actions when no `permission_checker` is configured:
207
+
208
+ ```ruby
209
+ # lib/pg_sql_triggers/permissions/checker.rb:16-17
210
+ # Default behavior: allow all permissions
211
+ true
212
+ ```
213
+
214
+ A newly installed gem with no extra configuration grants every user full ADMIN access (including `drop_trigger`, `execute_sql`, `override_drift`). The comment says "This should be overridden in production via configuration", but there is no warning in the initializer or README to flag this as a critical step before go-live.
215
+
216
+ ---
217
+
218
+ ### 3.2 `Registry::Validator` Is a No-Op
219
+
220
+ The validator that should catch invalid DSL definitions before they are registered is a stub that always returns `true`:
221
+
222
+ ```ruby
223
+ # lib/pg_sql_triggers/registry/validator.rb:7-11
224
+ def self.validate!
225
+ # This is a placeholder implementation
226
+ true
227
+ end
228
+ ```
229
+
230
+ Its test confirms only that it returns `true`. No actual validation (duplicate names, missing table, invalid events, etc.) occurs. Malformed definitions silently pass.
231
+
232
+ ---
233
+
234
+ ## 4. Missing Features
235
+
236
+ ### 4.1 No `FOR EACH ROW` / `FOR EACH STATEMENT` DSL Support
237
+
238
+ PostgreSQL requires every trigger to specify row-level or statement-level execution. The gem's DSL has no `row_level` or `statement_level` option. The generated SQL presumably hard-codes one, but users have no way to choose.
239
+
240
+ ### 4.2 No Multi-Schema Support
241
+
242
+ As noted in §2.3, the entire introspection layer is hard-coded to the `public` schema. There is no `schema` DSL option, no configuration for a default schema, and no way to manage triggers in non-public schemas.
243
+
244
+ ### 4.3 No Column-Level Trigger Support (`OF column_name`)
245
+
246
+ PostgreSQL `UPDATE OF col1, col2` triggers (which fire only when specific columns change) are not representable in the DSL. This is a common performance optimisation for audit triggers.
247
+
248
+ ### 4.4 No Deferred Trigger Support
249
+
250
+ The DSL provides no way to declare `DEFERRABLE INITIALLY DEFERRED` or `DEFERRABLE INITIALLY IMMEDIATE` triggers, which are essential for some referential integrity patterns.
251
+
252
+ ### 4.5 No `schema.rb` / `structure.sql` Integration
253
+
254
+ Trigger definitions are managed in a separate `db/triggers/` migration system that is invisible to `rails db:schema:dump`. Restoring a database from `schema.rb` will not recreate triggers. The gem should hook into Rails' structure dump (or document clearly that `structure.sql` must be used and provide a rake task to populate it).
255
+
256
+ ### 4.6 No External Alerting for Drift
257
+
258
+ Drift detection runs on demand (web UI check or API call) but there is no mechanism to push alerts to Slack, PagerDuty, or email when drift is detected. A cron-triggered rake task with configurable notification hooks would make this production-ready.
259
+
260
+ ### 4.7 No Search or Filter in the Web UI
261
+
262
+ The dashboard and trigger list pages have no search, filter by table, filter by drift state, or pagination. At scale (many triggers), the UI becomes unwieldy.
263
+
264
+ ### 4.8 No Trigger Dependency or Ordering Declaration
265
+
266
+ When multiple triggers fire on the same event for the same table, PostgreSQL fires them in alphabetical order by name. There is no DSL primitive to declare intended ordering or express that trigger A must run before trigger B.
267
+
268
+ ### 4.9 No Export / Import of Trigger Definitions
269
+
270
+ There is no way to export the current state of all triggers to a portable format (JSON, YAML) or import definitions from another project. This makes it hard to migrate or share trigger libraries between applications.
271
+
272
+ ---
273
+
274
+ ## 5. Unnecessary / Over-engineered Features
275
+
276
+ ### 5.1 `SQL::KillSwitch` Is ~1,200 Lines for a Three-Layer Check
277
+
278
+ The kill switch provides three layers of protection (config, ENV override, confirmation text). The core logic is straightforward, but the module spans over 1,200 lines. Much of this length is verbose logging, repeated helper methods, and defensive checks that could be reduced to ~100 lines without losing functionality. The complexity also means it accounts for 96% of its own test coverage (4% uncovered in a safety-critical module).
279
+
280
+ ### 5.2 SQL Capsules Are a Separate Concern
281
+
282
+ `SQL::Capsule` and `SQL::Executor` implement a named-SQL-snippet execution system for "emergency operations". This is a useful concept, but it has nothing specifically to do with trigger management — it is a general-purpose dangerous-SQL runner. Bundling it in this gem conflates concerns and increases the attack surface of the gem's web UI.
283
+
284
+ ### 5.3 Web UI Generator Is Code-Generation Against the Principle of Code-First
285
+
286
+ The `GeneratorController` provides a browser form for generating trigger DSL files and migrations. In practice, triggers are infrastructure — they should be authored in code review, not through a web UI. The UI generator produces files on disk on the server at runtime, which is a security and auditability concern in production environments.
287
+
288
+ **Recommendation:** Apply options A + C together.
289
+
290
+ 1. **Immediately restrict routes to dev/test** (one-liner, no behaviour change in production):
291
+ ```ruby
292
+ # config/routes.rb
293
+ if Rails.env.development? || Rails.env.test?
294
+ mount PgSqlTriggers::Engine => "/pg_sql_triggers"
295
+ end
296
+ ```
297
+
298
+ 2. **Replace the web generator with a Rails generator** so code generation is a local, CLI-driven action that produces files that go straight into version control:
299
+ ```bash
300
+ rails generate pg_sql_triggers:trigger users after_insert
301
+ ```
302
+ Once the Rails generator exists, the `GeneratorController` routes and views can be removed entirely. This is the idiomatic Rails pattern and eliminates server-side file writes at runtime.
303
+
304
+ ### 5.4 Three Migration Instances Per Migration Run
305
+
306
+ `Migrator#run_migration` instantiates the migration class three separate times:
307
+ 1. `validation_instance` — for `SafetyValidator` (which calls `direction` on it)
308
+ 2. `comparison_instance` — for `PreApplyComparator`
309
+ 3. `migration_instance` — for actual execution
310
+
311
+ If either the safety validator or comparator raises a non-`StandardError` or has side effects, the behaviour is unpredictable. A single instance should be captured once for SQL inspection, then a clean instance used for execution.
312
+
313
+ ### 5.5 `enabled` Defaults to `false` in the DSL
314
+
315
+ The DSL initialises `@enabled = false`, meaning every newly declared trigger is disabled by default. This is a surprising default — users who forget to add `enabled true` will deploy triggers that silently do nothing, with no warning.
316
+
317
+ ```ruby
318
+ # lib/pg_sql_triggers/dsl/trigger_definition.rb:12
319
+ @enabled = false
320
+ ```
321
+
322
+ ---
323
+
324
+ ## 6. Low-coverage Hotspots
325
+
326
+ | File | Coverage | Risk |
327
+ |---|---|---|
328
+ | `config/routes.rb` | 12% | Route misconfiguration goes undetected |
329
+ | `migrations_controller.rb` | 82.76% | Error paths in the most dangerous controller |
330
+ | `permission_checking.rb` | 85.37% | Security-critical concern with gaps |
331
+ | `testing/function_tester.rb` | 89.71% | Testing utilities that are themselves untested |
332
+ | `sql/kill_switch.rb` | 96.04% | Safety mechanism with 4% uncovered paths |
333
+
334
+ ---
335
+
336
+ ## 7. Summary Table
337
+
338
+ | # | Category | Severity | Description |
339
+ |---|---|---|---|
340
+ | 1.1 | Bug | **Critical** | Checksum algorithm omits `timing` in drift detector — false drift always reported |
341
+ | 1.2 | Bug | **Critical** | `extract_function_body` returns full `CREATE FUNCTION` SQL — checksum never matches |
342
+ | 1.3 | Bug | **Critical** | DSL triggers store `"placeholder"` checksum — always shows as DRIFTED |
343
+ | 1.4 | Bug | **Critical** | `re_execute!` always raises for DSL triggers — feature is broken for primary use case |
344
+ | 1.5 | Bug | **High** | `SafetyValidator` executes migration code as side effect of capture |
345
+ | 1.6 | Bug | **Low** | Dead `trigger_name` expression in `drop!` |
346
+ | 2.1 | Design | **High** | N+1 queries in all drift state filter methods |
347
+ | 2.2 | Design | **High** | Class-level registry cache is not thread-safe |
348
+ | 2.3 | Design | **High** | Hard-coded `public` schema — multi-schema apps unsupported |
349
+ | 2.4 | Design | **Medium** | `when_env` DSL anti-pattern guarantees env drift |
350
+ | 2.5 | Design | **Low** | Duplicate generator namespaces with no guidance |
351
+ | 2.6 | Design | **Medium** | Audit state snapshot omits `function_body` |
352
+ | 2.7 | Design | **Low** | Non-idiomatic dual-purpose DSL methods |
353
+ | 3.1 | Security | **High** | Default permissions allow all actions — no safe default |
354
+ | 3.2 | Security | **High** | `Registry::Validator` is a stub that accepts any input |
355
+ | 4.1 | Missing | **High** | No `FOR EACH ROW` / `FOR EACH STATEMENT` DSL option |
356
+ | 4.2 | Missing | **High** | No multi-schema support |
357
+ | 4.3 | Missing | **Medium** | No column-level trigger (`UPDATE OF col`) |
358
+ | 4.4 | Missing | **Medium** | No deferred trigger support |
359
+ | 4.5 | Missing | **High** | No `schema.rb` / `structure.sql` integration |
360
+ | 4.6 | Missing | **Medium** | No external alerting for detected drift |
361
+ | 4.7 | Missing | **Low** | No search/filter in web UI |
362
+ | 4.8 | Missing | **Low** | No trigger ordering or dependency declaration |
363
+ | 4.9 | Missing | **Low** | No export/import of trigger definitions |
364
+ | 5.1 | Unnecessary | **Low** | Kill switch is ~1,200 lines for a 3-layer check |
365
+ | 5.2 | Unnecessary | **Medium** | SQL Capsules are an unrelated concern bundled in the gem |
366
+ | 5.3 | Unnecessary | **Medium** | Web UI generator creates files on production server |
367
+ | 5.4 | Unnecessary | **Medium** | Three migration instances per run (safety + compare + execute) |
368
+ | 5.5 | Unnecessary | **Medium** | `enabled false` default silently disables triggers in production |