safe-migrate 0.4.0

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
# Enums AST Reference for safe-migrate

## Status

Verified against squawk_syntax 2.58.0 — July 2026

---

## 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.

---

## Scope Note — CreateType Is Not Enum-Specific

`CREATE TYPE` is a single polymorphic grammar rule covering four distinct
PostgreSQL type-creation forms, only one of which is the enum type. This
document covers the full `CreateType` node (since there is no separate
enum-only node), but the safe-migrate Visitor must always check which sub-form
is present before treating a `CreateType` as an enum.

---

# Core Nodes

## CreateType

### Verified Accessors (line 6870)

```rust
pub fn attribute_list(&self) -> Option<AttributeList>
pub fn column_list(&self) -> Option<ColumnList>
pub fn path(&self) -> Option<Path>
pub fn variant_list(&self) -> Option<VariantList>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn as_token(&self) -> Option<SyntaxToken>
pub fn create_token(&self) -> Option<SyntaxToken>
pub fn enum_token(&self) -> Option<SyntaxToken>
pub fn range_token(&self) -> Option<SyntaxToken>
pub fn type_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
CreateType =
  'create' 'type' Path
  ('as' 'enum' VariantList
| 'as' 'range' AttributeList
| 'as' ColumnList
| AttributeList) ';'?
```

### Four Mutually Exclusive Forms

| Form | SQL | Discriminator | Payload accessor |
|------|-----|----------------|-------------------|
| Enum type | `CREATE TYPE t AS ENUM (...)` | `enum_token().is_some()` | `variant_list()` |
| Range type | `CREATE TYPE t AS RANGE (...)` | `range_token().is_some()` | `attribute_list()` |
| Composite type | `CREATE TYPE t AS (...)` | `as_token().is_some()` AND `enum_token().is_none()` AND `range_token().is_none()` | `column_list()` |
| Base/shell type | `CREATE TYPE t (...)` or `CREATE TYPE t` | `as_token().is_none()` | `attribute_list()` |

**Discriminator logic must check tokens in this order**, since the absence
of `as_token()` is the only reliable signal for the base/shell type form —
both range type and base type populate `attribute_list()`, so that accessor
alone cannot distinguish them.

### safe-migrate guidance

```rust
enum CreateTypeFact {
    Enum {
        name: QualifiedName,           // from path()
        values: Vec<String>,           // from variant_list().variants()
    },
    Range {
        name: QualifiedName,
        options: Vec<AttributeFact>,   // from attribute_list()
    },
    Composite {
        name: QualifiedName,
        columns: Vec<ColumnFact>,      // from column_list()
    },
    BaseOrShell {
        name: QualifiedName,
        options: Vec<AttributeFact>,   // from attribute_list(), may be empty (shell type)
    },
}

fn classify_create_type(node: &CreateType) -> CreateTypeFact {
    if node.enum_token().is_some() {
        // Enum form
    } else if node.range_token().is_some() {
        // Range form
    } else if node.as_token().is_some() {
        // Composite form (AS without ENUM/RANGE means composite)
    } else {
        // Base/shell type form
    }
}
```

A visitor that assumes every `CreateType` is an enum (a reasonable but wrong
assumption if this file didn't exist) will misclassify range types, composite
types, and shell types as enums with zero values — or worse, crash on
`variant_list()` being `None`.

---

## VariantList

### Verified Accessors (line 20784)

```rust
pub fn variants(&self) -> AstChildren<Variant>
pub fn l_paren_token(&self) -> Option<SyntaxToken>
pub fn r_paren_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
VariantList =
  '('
  (Variant (',' Variant)*)
  ')'
```

Matches exactly — comma-separated list of `Variant` in parens.

---

## Variant

### Verified Accessors (line 20773)

```rust
pub fn literal(&self) -> Option<Literal>
```

### Grammar Confirmation

```
Variant =
  Literal
```

A single enum value, represented as a string `Literal` (PostgreSQL enum
values are always quoted strings: `'value1'`, `'value2'`).

### safe-migrate guidance

```rust
fn extract_enum_values(variant_list: &VariantList) -> Vec<String> {
    variant_list.variants()
        .filter_map(|v| v.literal())
        .filter_map(|lit| /* extract string value from Literal */)
        .collect()
}
```

---

## DropType

### Verified Accessors (line 9519)

```rust
pub fn if_exists(&self) -> Option<IfExists>
pub fn paths(&self) -> AstChildren<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 type_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
DropType =
  'drop' 'type' IfExists? (Path (',' Path)*)
  ('cascade' | 'restrict')? ';'?
```

Multi-name drop confirmed (`paths()` plural), consistent with the pattern
seen across `DropSchema`, `DropSequence`, `DropMaterializedView`, etc.

### safe-migrate guidance

`DROP TYPE` on an enum used by any existing column is a hard PostgreSQL
failure (the type is in use). This is a strong tier-1 (block) candidate
unless the dependency graph confirms zero columns currently reference the type.

---

## AlterType

### Verified Accessors (line 2716)

```rust
pub fn add_value(&self) -> Option<AddValue>
pub fn alter_type_actions(&self) -> AstChildren<AlterTypeAction>
pub fn owner_to(&self) -> Option<OwnerTo>
pub fn path(&self) -> Option<Path>
pub fn rename_attribute(&self) -> Option<RenameAttribute>
pub fn rename_to(&self) -> Option<RenameTo>
pub fn rename_value(&self) -> Option<RenameValue>
pub fn set_options(&self) -> Option<SetOptions>
pub fn set_schema(&self) -> Option<SetSchema>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn alter_token(&self) -> Option<SyntaxToken>
pub fn type_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
AlterType =
  'alter' 'type' Path
  (
    AlterTypeAction (',' AlterTypeAction)*
  | OwnerTo
  | SetSchema
  | SetOptions
  | RenameTo
  | RenameAttribute
  | RenameValue
  | AddValue
  ) ';'?
```

8 mutually exclusive top-level forms, all confirmed present as distinct
accessors. `AlterTypeAction` is comma-separated (multiple actions allowed in
a single statement) when that form is chosen — every other form is singular.

### Form-to-Accessor Mapping

| Grammar form | Accessor | Applies to |
|--------------|----------|------------|
| `AlterTypeAction*` | `alter_type_actions()` | Composite types (attribute add/drop/alter) |
| `OwnerTo` | `owner_to()` | Any type |
| `SetSchema` | `set_schema()` | Any type |
| `SetOptions` | `set_options()` | Base types (type-level options) |
| `RenameTo` | `rename_to()` | Any type |
| `RenameAttribute` | `rename_attribute()` | Composite types |
| `RenameValue` | `rename_value()` | **Enum types only** |
| `AddValue` | `add_value()` | **Enum types only** |

For safe-migrate's enum-specific analysis, only `add_value()` and
`rename_value()` are relevant; the other six forms apply to composite or
base types, or are type-agnostic (owner/schema/name changes).

### safe-migrate guidance

```rust
enum AlterTypeFact {
    AddEnumValue { ... },        // from add_value()
    RenameEnumValue { ... },     // from rename_value()
    CompositeAttributeChange(Vec<AlterTypeActionFact>),  // from alter_type_actions()
    OwnerChange { ... },
    SchemaChange { ... },
    OptionsChange { ... },
    Rename { ... },
    AttributeRename { ... },
}
```

---

## AddValue

### Verified Accessors (line 254)

```rust
pub fn if_not_exists(&self) -> Option<IfNotExists>
pub fn literal(&self) -> Option<Literal>
pub fn value_position(&self) -> Option<ValuePosition>
pub fn add_token(&self) -> Option<SyntaxToken>
pub fn value_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
AddValue =
  'add' 'value' IfNotExists? Literal ValuePosition?
```

Fully populated payload — confirms `ALTER TYPE t ADD VALUE [IF NOT EXISTS]
'newvalue' [BEFORE|AFTER 'existing']` is fully extractable.

### safe-migrate guidance

```rust
struct AddEnumValueFact {
    new_value: String,                    // from literal()
    if_not_exists: bool,                  // from if_not_exists().is_some()
    position: Option<ValuePositionFact>,  // from value_position()
}
```

**PostgreSQL semantic note relevant to safe-migrate:** `ALTER TYPE ... ADD
VALUE` cannot run inside a transaction block in older PostgreSQL versions
(pre-12) if the new value is used in the same transaction; PostgreSQL 12+
relaxed this for the `ADD VALUE` itself but still disallows using the new
value in the same transaction it was added in. This is a `TransactionFrame`-
relevant constraint — the rule engine should check whether `ADD VALUE` and
any subsequent use of that value occur within the same simulated transaction.

---

## ValuePosition (enum)

### Verified Members

```rust
pub enum ValuePosition {
    BeforeValue(BeforeValue),
    AfterValue(AfterValue),
}
```

### Grammar Confirmation

```
ValuePosition =
  BeforeValue
| AfterValue

BeforeValue =
  'before' Literal

AfterValue =
  'after' Literal
```

Both variants carry a single `Literal` — the existing enum value to position
relative to.

### Verified Accessors

```rust
// BeforeValue (line 3379)
pub fn literal(&self) -> Option<Literal>
pub fn before_token(&self) -> Option<SyntaxToken>

// AfterValue (line 359)
pub fn literal(&self) -> Option<Literal>
pub fn after_token(&self) -> Option<SyntaxToken>
```

### safe-migrate guidance

```rust
enum ValuePositionFact {
    Before(String),  // existing value to insert before
    After(String),   // existing value to insert after
}
```

The referenced existing value should be validated against the enum's known
current value set (tracked in `LocalState`) — referencing a non-existent
value is a guaranteed PostgreSQL failure, a strong tier-1 candidate.

---

## RenameValue

### Verified Accessors (line 17070)

```rust
pub fn literal(&self) -> Option<Literal>
pub fn rename_token(&self) -> Option<SyntaxToken>
pub fn to_token(&self) -> Option<SyntaxToken>
pub fn value_token(&self) -> Option<SyntaxToken>
```

### Grammar Discrepancy — IMPORTANT

postgresql.ungram shows:

```
RenameValue =
  'rename' 'value' Literal 'to' Literal
```

Two `Literal` children (old value, new value), but the verified Rust
accessor only exposes a **single** `literal()` via `support::child()`, which
returns only the **first** matching child of that type — it cannot
distinguish or retrieve the second `Literal`.

**This is the same pattern of bug already found in `PartitionForValuesFrom`
(partitions.md)** — a flat single-type accessor cannot disambiguate multiple
same-typed children in the grammar. This means:

- The **old value** (the value being renamed) is extractable via `literal()`.
- The **new value** (the replacement name) is **not extractable** through
  any verified accessor on this node. `support::child::<Literal>()` only
  returns the first match; there is no second accessor (`literal2()`, a
  `literals()` plural accessor, or similar) exposed for this node.

This is a genuine, confirmed extraction gap — not resolved by checking the
handwritten extension inventory (no `impl ast::RenameValue` extension exists
in `src/ast/node_ext.rs`).

### safe-migrate guidance

```rust
struct RenameEnumValueFact {
    old_value: String,           // literal() — extractable
    new_value: Option<String>,   // NOT extractable from this node alone
}
```

**This is a real limitation for safe-migrate.** `ALTER TYPE t RENAME VALUE
'old' TO 'new'` can be detected, and the old value identified, but the new
value name cannot be determined from this AST in its current form. Any rule
needing to validate the new value (e.g. checking for collisions with
existing enum values) cannot do so with the information available. This
should be flagged as a `Confidence::Tainted` mutation, or the new value
should be treated as unknown/wildcard for safety purposes — i.e. assume the
rename could introduce any value, including a colliding one, since the
specific new value cannot be statically confirmed.

### Status

```
AST verified
Old value (first Literal): extractable via literal()
New value (second Literal): confirmed NOT extractable — flat accessor only
  returns first child of type Literal; no handwritten extension exists to
  disambiguate. This is a genuine, confirmed gap, not an inference.
```

---

# Verified Findings Summary

## Confirmed Complete

- `CreateType`: fully resolved including all 4 mutually exclusive sub-forms
- `VariantList`: fully resolved
- `Variant`: fully resolved
- `DropType`: fully resolved
- `AlterType`: fully resolved including all 8 sub-forms
- `AddValue`: fully resolved, fully populated payload
- `ValuePosition` / `BeforeValue` / `AfterValue`: fully resolved

## Confirmed Partial — Genuine Extraction Gap

- `RenameValue`: old value extractable, new value **not extractable** due to
  a flat single-type accessor being unable to disambiguate two `Literal`
  children of the same type. This is analogous to the `PartitionForValuesFrom`
  finding in partitions.md but in this case there is no resolver-level
  workaround available (unlike the partition case, there's no external
  context like a column count to derive the split point from) — the second
  value is simply unrecoverable from this AST.

## Grammar Cross-Check

All nodes cross-checked against `src/postgresql.ungram` and the `squawk-syntax` source code in `src/ast/generated/nodes.rs` and `src/ast/node_ext.rs`.

---

# Remaining Open Questions

None remaining. The previously listed question about `RenameValue`'s second
`Literal` (the "to" value) has been reclassified from "open question" to
"confirmed grammar-level limitation":

The exhaustive `impl ast::*` inventory in `src/ast/node_ext.rs` (lines 1-1042)
already found no `impl ast::RenameValue` handwritten extension block — the same
inventory that confirmed `ForeignKeyConstraint`'s `from_columns()`/`to_columns()`
extensions DO exist at line 358 of `src/ast/node_ext.rs`. Since someone clearly
recognized and solved the identical "two same-typed Literal children" problem for
`ForeignKeyConstraint` but did not write an equivalent for `RenameValue`, the
absence is an intentional or overlooked gap, not an unexplored surface area.
The `RenameValue` new-value extraction gap documented in the `RenameValue`
section above is treated as confirmed, matching the same status as
`PartitionForValuesFrom`'s multi-column boundary ambiguity (partitions.md) —
a known, accepted limitation of this AST version.