safe-migrate 0.3.2

Lint PostgreSQL migrations against live database statistics to prevent blocking locks
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
# Triggers AST Reference for safe-migrate

## Status

Inspection status: complete. Cross-checked directly against postgresql.ungram
and squawk.rs in a single pass.

---

## Documentation Contract

1. Only document AST behavior that has been directly verified.
2. Do not infer PostgreSQL semantics from missing AST accessors.
3. Distinguish verified facts from unresolved areas.
4. Assume additional nodes or helpers may exist outside the inspected surface.

---

# Core Nodes

## CreateTrigger

### Verified Accessors (line 5832)

```rust
pub fn call_expr(&self) -> Option<CallExpr>
pub fn deferrable_constraint_option(&self) -> Option<DeferrableConstraintOption>
pub fn from_table(&self) -> Option<FromTable>
pub fn initially_deferred_constraint_option(&self) -> Option<InitiallyDeferredConstraintOption>
pub fn initially_immediate_constraint_option(&self) -> Option<InitiallyImmediateConstraintOption>
pub fn name(&self) -> Option<Name>
pub fn not_deferrable_constraint_option(&self) -> Option<NotDeferrableConstraintOption>
pub fn on_table(&self) -> Option<OnTable>
pub fn or_replace(&self) -> Option<OrReplace>
pub fn referencing(&self) -> Option<Referencing>
pub fn timing(&self) -> Option<Timing>
pub fn trigger_event_list(&self) -> Option<TriggerEventList>
pub fn when_condition(&self) -> Option<WhenCondition>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn constraint_token(&self) -> Option<SyntaxToken>
pub fn create_token(&self) -> Option<SyntaxToken>
pub fn each_token(&self) -> Option<SyntaxToken>
pub fn execute_token(&self) -> Option<SyntaxToken>
pub fn for_token(&self) -> Option<SyntaxToken>
pub fn function_token(&self) -> Option<SyntaxToken>
pub fn procedure_token(&self) -> Option<SyntaxToken>
pub fn row_token(&self) -> Option<SyntaxToken>
pub fn statement_token(&self) -> Option<SyntaxToken>
pub fn trigger_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
CreateTrigger =
  'create' OrReplace? 'constraint'? 'trigger' Name
  Timing
  TriggerEventList
  OnTable
  FromTable?
  DeferrableConstraintOption?
  NotDeferrableConstraintOption?
  InitiallyDeferredConstraintOption?
  InitiallyImmediateConstraintOption?
  Referencing?
  ('for' 'each'? ('row' | 'statement'))?
  WhenCondition?
  'execute' ('function' | 'procedure') CallExpr ';'?
```

Fully populated — every grammar field has a corresponding accessor. This is
one of the most completely extractable nodes in the entire AST surface
reviewed so far.

### Constraint Trigger Detection

`constraint_token().is_some()` distinguishes a `CREATE CONSTRAINT TRIGGER`
from a regular `CREATE TRIGGER`. Constraint triggers support deferred firing
via `DeferrableConstraintOption` / `NotDeferrableConstraintOption` /
`InitiallyDeferredConstraintOption` / `InitiallyImmediateConstraintOption` —
these four accessors (reusing the same constraint-deferral nodes documented
in constraints.md) are only meaningful when `constraint_token()` is present;
regular (non-constraint) triggers do not support deferred firing in
PostgreSQL even though the grammar does not structurally forbid parsing
these fields on a non-constraint trigger.

### FOR EACH ROW/STATEMENT Extraction

```rust
let for_each_row = row_token().is_some();
let for_each_statement = statement_token().is_some();
```

Neither token is on a dedicated wrapper node — both are flat tokens directly
on `CreateTrigger`, mutually exclusive per the grammar's `('row' |
'statement')` alternation. If `for_token()` is absent entirely, PostgreSQL
defaults to `FOR EACH STATEMENT`.

### Function vs Procedure Detection

```rust
let uses_function = function_token().is_some();  // EXECUTE FUNCTION
let uses_procedure = procedure_token().is_some(); // EXECUTE PROCEDURE (legacy alias)
```

`EXECUTE PROCEDURE` is a deprecated legacy spelling of `EXECUTE FUNCTION`
retained for backward compatibility — both invoke the same kind of object
(a trigger function), the keyword choice has no semantic effect.

### Event Detection

`trigger_event_list()` → `TriggerEventList.trigger_events()` →
`AstChildren<TriggerEvent>`. Multiple events combinable via `OR`:
`CREATE TRIGGER ... BEFORE INSERT OR UPDATE OR DELETE ON t ...`.

See `TriggerEvent` / `TriggerEventUpdate` sections below for the
column-specific `UPDATE OF col1, col2` extraction.

### Timing Extraction

`timing()` → `Timing`, a token-only node:
```rust
pub fn before_token(&self) -> Option<SyntaxToken>
pub fn after_token(&self) -> Option<SyntaxToken>
pub fn instead_token(&self) -> Option<SyntaxToken>
pub fn of_token(&self) -> Option<SyntaxToken>
```
`INSTEAD OF` is represented as two tokens (`instead_token()` +
`of_token()`), both must be checked together to confirm this timing variant
specifically (as opposed to `instead_token()` appearing in some other
unrelated context, though none exists for `Timing` itself since its grammar
only has the 3 alternatives shown).

### safe-migrate guidance

```rust
struct CreateTriggerFact {
    name: String,                          // from name()
    or_replace: bool,
    is_constraint_trigger: bool,           // from constraint_token()
    timing: TimingFact,                    // Before | After | InsteadOf
    events: Vec<TriggerEventFact>,         // from trigger_event_list()
    table: QualifiedName,                  // from on_table()
    from_table: Option<QualifiedName>,     // constraint trigger only, from from_table()
    for_each: ForEachFact,                 // Row | Statement (default Statement)
    when_condition: Option<ExprIr>,        // from when_condition()
    function: QualifiedName,               // from call_expr()
    referencing: Vec<ReferencingTableFact>, // from referencing()
}
```

**INSTEAD OF triggers are view-only** in PostgreSQL — they can only be
created on views, not tables. A rule validating `CreateTrigger` against the
target's kind (table vs view, tracked via `LocalState.relations`) should
flag `INSTEAD OF` triggers targeting a base table as a guaranteed PostgreSQL
failure.

---

## TriggerEvent (enum)

### Verified Members

```rust
pub enum TriggerEvent {
    TriggerEventUpdate(TriggerEventUpdate),
    // INSERT, DELETE, TRUNCATE represented as flat tokens, not enum variants
}
```

### Verified Accessors (line 17794)

```rust
pub fn trigger_event_update(&self) -> Option<TriggerEventUpdate>
pub fn delete_token(&self) -> Option<SyntaxToken>
pub fn insert_token(&self) -> Option<SyntaxToken>
pub fn truncate_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
TriggerEvent =
  'insert'
| 'delete'
| 'truncate'
| TriggerEventUpdate
```

**Important structural note:** `TriggerEvent` is not a 4-way Rust enum the
way `PartitionType` or `Constraint` are — `INSERT`/`DELETE`/`TRUNCATE` are
flat presence tokens directly on the `TriggerEvent` node itself, while
`UPDATE` is the only variant that wraps a real child node
(`TriggerEventUpdate`), because only `UPDATE` supports the `OF column_list`
qualifier.

### safe-migrate guidance

```rust
fn classify_trigger_event(event: &TriggerEvent) -> TriggerEventFact {
    if event.insert_token().is_some() {
        TriggerEventFact::Insert
    } else if event.delete_token().is_some() {
        TriggerEventFact::Delete
    } else if event.truncate_token().is_some() {
        TriggerEventFact::Truncate
    } else if let Some(update) = event.trigger_event_update() {
        TriggerEventFact::Update {
            columns: update.name_refs().map(|n| n.text()).collect(),
        }
    } else {
        TriggerEventFact::Unknown
    }
}
```

---

## TriggerEventUpdate

### Verified Accessors (line 17828)

```rust
pub fn name_refs(&self) -> AstChildren<NameRef>
pub fn of_token(&self) -> Option<SyntaxToken>
pub fn update_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
TriggerEventUpdate =
  'update'
  ('of' (NameRef (',' NameRef)*))?
```

`UPDATE OF col1, col2` is fully extractable via `name_refs()`. When
`of_token()` is absent, the trigger fires on `UPDATE` of any column —
`name_refs()` will be empty in that case, and this must be distinguished
from "trigger fires on update of zero columns" (which is not a real
PostgreSQL state) by checking `of_token().is_some()` first.

### safe-migrate guidance

A column-specific `UPDATE OF` trigger is relevant to safe-migrate's
column-rename and column-drop safety analysis: renaming or dropping a column
referenced in a trigger's `OF column_list` should be flagged, since the
trigger definition becomes stale or references a now-nonexistent column.

---

## Timing, Referencing, ReferencingTable

### Timing — Verified Accessors (line 17695)

```rust
pub fn after_token(&self) -> Option<SyntaxToken>
pub fn before_token(&self) -> Option<SyntaxToken>
pub fn instead_token(&self) -> Option<SyntaxToken>
pub fn of_token(&self) -> Option<SyntaxToken>
```

Already covered above under `CreateTrigger`'s Timing Extraction section.

### Referencing — Verified Accessors (line 14799)

```rust
pub fn referencing_tables(&self) -> AstChildren<ReferencingTable>
pub fn referencing_token(&self) -> Option<SyntaxToken>
```

### ReferencingTable — Verified Accessors (line 14814)

```rust
pub fn name_ref(&self) -> Option<NameRef>
pub fn as_token(&self) -> Option<SyntaxToken>
pub fn new_token(&self) -> Option<SyntaxToken>
pub fn old_token(&self) -> Option<SyntaxToken>
pub fn table_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
Referencing =
  'referencing' ReferencingTable*

ReferencingTable =
  ('old' | 'new') 'table' 'as'? NameRef
```

Represents `REFERENCING OLD TABLE AS old_rows NEW TABLE AS new_rows` —
PostgreSQL's transition table feature for statement-level triggers, allowing
the trigger function to see the full set of rows affected by the triggering
statement, not just one row at a time.

### safe-migrate guidance

```rust
struct ReferencingTableFact {
    is_old: bool,        // from old_token().is_some()
    is_new: bool,         // from new_token().is_some() (mutually exclusive with is_old)
    alias: String,        // from name_ref().text()
}
```

---

## DropTrigger

### Verified Accessors (line 8350)

```rust
pub fn if_exists(&self) -> Option<IfExists>
pub fn on_table(&self) -> Option<OnTable>
pub fn path(&self) -> Option<Path>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn cascade_token(&self) -> Option<SyntaxToken>
pub fn drop_token(&self) -> Option<SyntaxToken>
pub fn restrict_token(&self) -> Option<SyntaxToken>
pub fn trigger_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
DropTrigger =
  'drop' 'trigger' IfExists? Path OnTable
  ('cascade' | 'restrict')? ';'?
```

Single trigger name only (`path()`, not plural) — unlike most other `Drop*`
nodes in this AST surface (`DropSequence`, `DropType`, `DropSchema`,
`DropMaterializedView`, `DropDomain`), `DropTrigger` does **not** support
multiple trigger names per statement, matching real PostgreSQL syntax
(`DROP TRIGGER` only ever takes one trigger name, since trigger names are
scoped per-table and `ON table_name` is a required singular clause).

---

## AlterTrigger

### Verified Accessors (line 2102)

```rust
pub fn depends_on_extension(&self) -> Option<DependsOnExtension>
pub fn name_ref(&self) -> Option<NameRef>
pub fn no_depends_on_extension(&self) -> Option<NoDependsOnExtension>
pub fn on_table(&self) -> Option<OnTable>
pub fn rename_to(&self) -> Option<RenameTo>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn alter_token(&self) -> Option<SyntaxToken>
pub fn trigger_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
AlterTrigger =
  'alter' 'trigger' NameRef OnTable
  (
    RenameTo
  | DependsOnExtension
  | NoDependsOnExtension
  ) ';'?
```

3 mutually exclusive forms confirmed, all with direct accessors. Fully resolved.

### safe-migrate guidance

```rust
enum AlterTriggerFact {
    Rename { table: QualifiedName, from: String, to: String },
    DependsOnExtension { table: QualifiedName, trigger: String, extension: String },
    NoDependsOnExtension { table: QualifiedName, trigger: String, extension: String },
}
```

`DEPENDS ON EXTENSION` marks the trigger as an internal dependency of an
extension — if that extension is later dropped, the trigger drops with it
automatically. This is relevant to the dependency graph: a trigger with this
marking should be linked to its owning extension as an additional edge, not
treated as an independent object.

---

## ALTER TABLE ... ENABLE/DISABLE TRIGGER

These appear as `AlterTableAction` variants (documented as such in the
original AST inventory used to bootstrap this documentation set), not as
standalone top-level statements. PostgreSQL syntax:

```sql
ALTER TABLE t ENABLE TRIGGER trigger_name;
ALTER TABLE t ENABLE TRIGGER ALL;
ALTER TABLE t ENABLE TRIGGER USER;
ALTER TABLE t ENABLE ALWAYS TRIGGER trigger_name;
ALTER TABLE t ENABLE REPLICA TRIGGER trigger_name;
ALTER TABLE t DISABLE TRIGGER trigger_name;
```

### Verified Accessors

```rust
// EnableTrigger (line 9005)
pub fn enable_token(&self) -> Option<SyntaxToken>
pub fn trigger_token(&self) -> Option<SyntaxToken>

// DisableTrigger (line 6526)
pub fn disable_token(&self) -> Option<SyntaxToken>
pub fn trigger_token(&self) -> Option<SyntaxToken>

// EnableAlwaysTrigger (line 8910)
pub fn always_token(&self) -> Option<SyntaxToken>
pub fn enable_token(&self) -> Option<SyntaxToken>
pub fn trigger_token(&self) -> Option<SyntaxToken>

// EnableReplicaTrigger (line 8948)
pub fn enable_token(&self) -> Option<SyntaxToken>
pub fn replica_token(&self) -> Option<SyntaxToken>
pub fn trigger_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
EnableTrigger =
  'enable' 'trigger'

EnableReplicaTrigger =
  'enable' 'replica' 'trigger'

EnableAlwaysTrigger =
  'enable' 'always' 'trigger'

DisableTrigger =
  'disable' 'trigger'
```

### Critical Finding — Confirmed Grammar Gap (Fully Resolved)

All four variants are **entirely token-only**. None of them carry a trigger
name, an `ALL` indicator, or a `USER` indicator. This was verified directly
against squawk.rs for all four node types — every accessor is a
`SyntaxToken`, none return a child node that could carry an identifier.
Additionally, postgresql.ungram confirms these four variants sit as flat,
unwrapped alternatives directly inside `AlterTableAction`'s own alternation —
there is no wrapping node anywhere in the grammar that could carry the
trigger name alongside them.

**This means: `ALTER TABLE t ENABLE TRIGGER trigger_name` can be detected as
"a trigger enable operation occurred on table t" via the `AlterTableAction`
variant type, but which specific trigger (or whether it's `ALL`/`USER`)
cannot be extracted from this AST in any form, by any node, anywhere in this
grammar.** This is a genuine, significant gap for safe-migrate, since
enabling or disabling a trigger has real safety implications (e.g. disabling
a trigger that enforces a data integrity invariant, then performing writes,
then re-enabling it — a known risky migration pattern) that cannot be
evaluated without knowing which trigger is affected.

### safe-migrate guidance

```rust
enum TriggerToggleFact {
    Enable,           // target trigger unknown — presence-only
    EnableAlways,      // target trigger unknown — presence-only
    EnableReplica,     // target trigger unknown — presence-only
    Disable,           // target trigger unknown — presence-only
}
```

Given this gap, any rule evaluating trigger enable/disable safety can only
flag "a trigger toggle occurred on table X" generically — it cannot
distinguish disabling a critical audit trigger from disabling a harmless
logging trigger, nor can it confirm whether `ALL` triggers (including
constraint-enforcing ones) were targeted. This should be treated
conservatively: any `DisableTrigger`/variant occurring should be flagged at
minimum tier-2 (warning), since the specific risk cannot be assessed and the
operation is inherently capable of disabling integrity-critical triggers
without the simulator being able to confirm otherwise.

---

# Verified Findings Summary

## Confirmed Complete

- `CreateTrigger`: fully resolved, fully populated accessor surface
- `TriggerEvent` / `TriggerEventList` / `TriggerEventUpdate`: fully resolved
- `Timing`: fully resolved
- `Referencing` / `ReferencingTable`: fully resolved
- `DropTrigger`: fully resolved, single-name-only confirmed correct per
  PostgreSQL semantics (not a gap, matches real SQL grammar)
- `AlterTrigger`: fully resolved, all 3 forms verified

## Grammar-Confirmed Limitations

- `EnableTrigger`, `DisableTrigger`, `EnableAlwaysTrigger`,
  `EnableReplicaTrigger`: confirmed entirely token-only across all four
  variants, with no wrapping node anywhere in the grammar capable of
  carrying the target trigger name or `ALL`/`USER` indicator. This is fully
  resolved as a genuine, final grammar-level limitation — the target trigger
  is not captured anywhere in this AST. Significant safety-relevant gap
  given the data-integrity implications of trigger enable/disable operations.

## Grammar Cross-Check

This document was written with postgresql.ungram available from the start.
All nodes cross-checked in this single pass, including a follow-up check of
`AlterTableAction`'s grammar shape that fully resolved the enable/disable
trigger name question as a confirmed, final limitation rather than an
open accessor-location question.

---

# Remaining Open Questions

None remaining. The deferred question about whether `AlterTableAction`
carries the trigger name as a sibling has been resolved: postgresql.ungram
confirms `EnableTrigger` (and the other three variants) appear as flat,
unwrapped alternatives directly inside the `AlterTableAction` enum:

```
AlterTableAction =
  ...
| InheritTable
| NoInheritTable
| EnableTrigger
| EnableReplicaTrigger
| EnableReplicaRule
| EnableAlwaysTrigger
  ...
```

There is no wrapping node that could carry a trigger name alongside these
variants — the grammar confirms the gap is real and final, not an artifact
of under-inspection. This finding is now considered fully resolved as a
confirmed grammar-level limitation.