Skip to content

Commit 6034302

Browse files
authored
Merge pull request #66 from sqlcj-dev/feature/model-enum-renames
feat(schema): model enum renames and replace an enum on its name
2 parents e2c0607 + eb458da commit 6034302

4 files changed

Lines changed: 633 additions & 41 deletions

File tree

‎docs/postgresql.md‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,10 @@ order the files are read in.
9494

9595
- One rule decides every statement: only the `CREATE TABLE`, `DROP TABLE`, and
9696
`ALTER TABLE` forms listed in [Ordered Table DDL](#ordered-table-ddl) and the
97-
`CREATE TYPE ... AS ENUM` and `ALTER TYPE ... ADD VALUE` forms listed in
98-
[Enum Types](#enum-types) update the schema model, and every other statement
99-
a schema file records is accepted and leaves it unchanged. Only the few
97+
`CREATE TYPE ... AS ENUM` and `ALTER TYPE ... ADD VALUE`, `RENAME TO`, and
98+
`RENAME VALUE` forms listed in [Enum Types](#enum-types) update the schema
99+
model, and every other statement a schema file records is accepted and
100+
leaves it unchanged. Only the few
100101
statements listed in [Ignored Statements](#ignored-statements) are rejected
101102
instead, because sqlcj cannot tell what they would do to the model.
102103
- The statements of the schema files of one entry are applied in order to one
@@ -247,17 +248,29 @@ left:
247248
| `ALTER TYPE ... ADD VALUE '<label>' BEFORE '<neighbour>'` | Inserts the label directly before the neighbour. |
248249
| `ALTER TYPE ... ADD VALUE '<label>' AFTER '<neighbour>'` | Inserts the label directly after the neighbour. |
249250
| `ALTER TYPE ... ADD VALUE IF NOT EXISTS '<label>'` | Does nothing when the type already has the label. As PostgreSQL does, the existing label decides before the neighbour, so a neighbour the type does not have is not resolved at all. |
250-
| Any other `ALTER TYPE` action, such as `RENAME TO`, `RENAME VALUE`, `OWNER TO`, `SET SCHEMA`, or an attribute change | Rejected as `Unsupported ALTER TYPE action at line <n>`. |
251+
| `ALTER TYPE ... RENAME TO <name>` | Renames the type in its position, keeping its labels, and every column of that type, scalar and array alike, follows it. |
252+
| `ALTER TYPE ... RENAME VALUE '<label>' TO '<new label>'` | Renames the label in its position, so the modeled labels keep PostgreSQL's sort order. |
253+
| A `CREATE TYPE ... AS ENUM (...)` or an `ALTER TYPE ... RENAME TO` whose name a modeled enum already has | Replaces that enum in its position with the newly declared or renamed one, because the `DROP TYPE ... CASCADE` that freed the name is never seen. This is how the migration that replaces an enum loads, in either of its usual shapes: renaming the old type away before declaring the new one under its name, or declaring the new type beside it and renaming the new type onto its name once the columns are retyped. A column that still names a replaced enum keeps that name, so it is modeled as a column of the replacing enum with its labels, which is the same unseen `DROP TYPE ... CASCADE` case: PostgreSQL would have removed the column. |
254+
| `ALTER TYPE ... OWNER TO`, `RENAME ATTRIBUTE`, `ADD ATTRIBUTE`, `DROP ATTRIBUTE`, and `ALTER ATTRIBUTE` | Ignored. Ownership cannot change a modeled enum and the attribute actions belong to a composite type, so the stated type is not resolved at all. |
255+
| `ALTER TYPE ... SET SCHEMA <schema>` | Rejected for a modeled enum as `Unsupported ALTER TYPE action: SET SCHEMA <schema> at line <n>`, because the columns of that type would keep it in a namespace sqlcj does not model. Ignored for a type sqlcj does not model. |
256+
| `ALTER TYPE ... RENAME TO` of a type sqlcj does not model | Ignored, because such a type is none of its enum types. |
251257
| `CREATE TYPE` of a composite, range, or shell type | Ignored. A column of such a type is recorded with its declared type, as [Unsupported Types and DDL](#unsupported-types-and-ddl) describes. |
252258
| `DROP TYPE` | Ignored. sqlcj's parser cannot read the statement, and an unreadable statement that does not open as table or type DDL is ignored, so an enum the schema models stays modeled with its labels and the columns of that type stay modeled; see [Ignored Statements](#ignored-statements). |
253259

260+
An `ALTER TYPE` written in a form sqlcj's parser cannot read, such as a
261+
qualified `RENAME TO` target, several actions joined by commas, or
262+
`SET (...)`, is rejected as unreadable type DDL; see
263+
[Ignored Statements](#ignored-statements).
264+
254265
Type names are matched case-insensitively, after their SQL identifier
255-
delimiters are removed, and labels are matched exactly. A statement that repeats
256-
a type or a label, or that refers to a type or a label that is not modeled, is
257-
rejected as PostgreSQL rejects it:
266+
delimiters are removed, and labels are matched exactly. A type name is the
267+
statement's own name, so `public.stage_setting` and `stage_setting` are
268+
different names: a `RENAME TO` or `SET SCHEMA` of a qualified name that matches
269+
no modeled enum fails as a missing type rather than being ignored, because it
270+
may state one. A statement that repeats a label, or that refers to a type or a
271+
label that is not modeled, is rejected as PostgreSQL rejects it:
258272

259273
```text
260-
sqlcj: Invalid schema source /home/dev/project/sql/migrations/V2__stages.sql: Type already exists in schema: stage_setting
261274
sqlcj: Invalid schema source /home/dev/project/sql/migrations/V2__stages.sql: Type not found in schema: stage_setting
262275
sqlcj: Invalid schema source /home/dev/project/sql/migrations/V2__stages.sql: Label already exists in type stage_setting: indoor
263276
sqlcj: Invalid schema source /home/dev/project/sql/migrations/V2__stages.sql: Label not found in type stage_setting: covered
@@ -424,9 +437,8 @@ keep up to date.
424437

425438
The modeled statements are the `CREATE TABLE`, `DROP TABLE`, and `ALTER TABLE`
426439
forms of [Ordered Table DDL](#ordered-table-ddl) and the
427-
`CREATE TYPE ... AS ENUM` and `ALTER TYPE ... ADD VALUE` forms of
428-
[Enum Types](#enum-types). Everything else a migration history holds is ignored,
429-
including
440+
`CREATE TYPE ... AS ENUM` and `ALTER TYPE` forms of [Enum Types](#enum-types).
441+
Everything else a migration history holds is ignored, including
430442

431443
- `CREATE INDEX`, `CREATE UNIQUE INDEX`, `ALTER INDEX`, and `DROP INDEX`,
432444
- `COMMENT ON` of any target, including `TABLE`, `COLUMN`, `VIEW`, `TYPE`,
@@ -464,7 +476,7 @@ what they would do to the schema model:
464476
| Statement | Diagnostic |
465477
| --- | --- |
466478
| An `ALTER TABLE` action outside [Ordered Table DDL](#ordered-table-ddl) and the ignored actions below | `Unsupported ALTER TABLE action: <action> at line <n>`, quoting the action's own SQL |
467-
| An `ALTER TYPE` action other than `ADD VALUE` | `Unsupported ALTER TYPE action at line <n>` |
479+
| An `ALTER TYPE ... SET SCHEMA` of a modeled enum type | `Unsupported ALTER TYPE action: SET SCHEMA <schema> at line <n>` |
468480
| A statement sqlcj's parser cannot read that opens as table or type DDL, outside the `ALTER TABLE` actions below | The parser's own reason with the line and column of the unexpected token, as in `Encountered unexpected token: "DATA" at line 18, column 41` |
469481
| A statement sqlcj's parser reports only as opaque text, or as several statements, and that opens as table or type DDL | `Unsupported schema statement: ALTER FOREIGN TABLE at line <n>`, quoting the opening words |
470482

‎sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java‎

Lines changed: 211 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,9 @@ private boolean statesWords(List<String> tokens, int index, String... words) {
292292
*
293293
* <p>Only the statements sqlcj models are applied: {@code CREATE TABLE},
294294
* {@code ALTER TABLE}, {@code DROP TABLE},
295-
* {@code CREATE TYPE ... AS ENUM}, and {@code ALTER TYPE ... ADD VALUE}.
296-
* Every other statement a schema source may record is ignored and is not
297-
* resolved against the schema at all.
295+
* {@code CREATE TYPE ... AS ENUM}, and {@code ALTER TYPE}. Every other
296+
* statement a schema source may record is ignored and is not resolved
297+
* against the schema at all.
298298
*/
299299
private void apply(Statement statement, int line, List<Table> tables, List<EnumType> enums) {
300300
if (statement instanceof CreateTable createTable) {
@@ -309,11 +309,7 @@ private void apply(Statement statement, int line, List<Table> tables, List<EnumT
309309
) {
310310
applyCreateEnumType(createType, definition, enums);
311311
} else if (statement instanceof AlterType alterType) {
312-
if (alterType.getAction() != AlterType.Action.ADD_VALUE) {
313-
throw unsupportedAlterTypeAction(line);
314-
}
315-
316-
applyAddEnumValue(alterType, enums);
312+
applyAlterType(alterType, line, enums, tables);
317313
}
318314
}
319315

@@ -367,10 +363,12 @@ private String upperCase(String word) {
367363
}
368364

369365
/**
370-
* Appends one enum type with its declared labels. A repeated type name
371-
* fails, as PostgreSQL rejects it, and so does a repeated label. An enum is
372-
* the only type sqlcj models; a {@code CREATE TYPE} of any other kind is
373-
* ignored, and a column of such a type is recorded with its declared type.
366+
* Appends one enum type with its declared labels, and replaces a modeled
367+
* enum of the same name in its position, because the {@code DROP TYPE} that
368+
* freed the name is never seen. A repeated label fails, as PostgreSQL
369+
* rejects it. An enum is the only type sqlcj models; a {@code CREATE TYPE}
370+
* of any other kind is ignored, and a column of such a type is recorded
371+
* with its declared type.
374372
*/
375373
private void applyCreateEnumType(
376374
CreateType createType,
@@ -379,9 +377,7 @@ private void applyCreateEnumType(
379377
) {
380378
String typeName = MultiPartName.unquote(createType.getName());
381379

382-
if (indexOfEnum(enums, typeName) >= 0) {
383-
throw typeAlreadyExists(typeName);
384-
}
380+
int index = indexOfEnum(enums, typeName);
385381

386382
List<String> labels = new ArrayList<>();
387383

@@ -395,14 +391,208 @@ private void applyCreateEnumType(
395391
labels.add(label);
396392
}
397393

398-
enums.add(new EnumType(typeName, labels));
394+
EnumType enumType = new EnumType(typeName, labels);
395+
396+
if (index < 0) {
397+
enums.add(enumType);
398+
399+
return;
400+
}
401+
402+
enums.set(index, enumType);
403+
}
404+
405+
/**
406+
* Applies one {@code ALTER TYPE} action, resolving the stated type name,
407+
* with its SQL identifier delimiters removed, among the modeled enum types.
408+
*
409+
* <p>An action that cannot change a modeled enum is ignored without the
410+
* type being resolved at all: ownership and the attribute actions of a
411+
* composite type. Every other action sqlcj models resolves the type, and an
412+
* action sqlcj does not model is rejected naming the action.
413+
*/
414+
private void applyAlterType(
415+
AlterType alterType,
416+
int line,
417+
List<EnumType> enums,
418+
List<Table> tables
419+
) {
420+
AlterType.Action action = alterType.getAction();
421+
422+
if (ignoresAlterTypeAction(action)) {
423+
return;
424+
}
425+
426+
if (action == AlterType.Action.ADD_VALUE) {
427+
applyAddEnumValue(alterType, enums);
428+
429+
return;
430+
}
431+
432+
String typeName = MultiPartName.unquote(alterType.getName());
433+
int index = indexOfEnum(enums, typeName);
434+
435+
switch (action) {
436+
case RENAME_VALUE -> renameEnumLabel(alterType, typeName, index, enums);
437+
case RENAME -> renameEnumType(alterType, typeName, index, enums, tables);
438+
case SET_SCHEMA -> moveEnumTypeToSchema(alterType, line, typeName, index);
439+
default -> throw unsupportedAlterTypeAction(String.valueOf(action), line);
440+
}
441+
}
442+
443+
/**
444+
* Reports whether one {@code ALTER TYPE} action cannot change a modeled
445+
* enum type: {@code OWNER TO} and the attribute actions, which belong to a
446+
* composite type.
447+
*/
448+
private boolean ignoresAlterTypeAction(AlterType.Action action) {
449+
return switch (action) {
450+
case OWNER, RENAME_ATTRIBUTE, ATTRIBUTES -> true;
451+
default -> false;
452+
};
453+
}
454+
455+
/**
456+
* Renames one label of an enum type in its position, so the modeled labels
457+
* keep PostgreSQL's sort order. A missing old label and a new label the
458+
* type already has both fail, as PostgreSQL rejects them.
459+
*/
460+
private void renameEnumLabel(
461+
AlterType alterType,
462+
String typeName,
463+
int index,
464+
List<EnumType> enums
465+
) {
466+
if (index < 0) {
467+
throw typeNotFound(typeName);
468+
}
469+
470+
EnumType enumType = enums.get(index);
471+
String label = alterType.getValue().getNotExcapedValue();
472+
String newLabel = alterType.getNewValue().getNotExcapedValue();
473+
474+
int position = enumType.labels().indexOf(label);
475+
476+
if (position < 0) {
477+
throw labelNotFound(typeName, label);
478+
}
479+
480+
if (enumType.labels().contains(newLabel)) {
481+
throw labelAlreadyExists(typeName, newLabel);
482+
}
483+
484+
List<String> labels = new ArrayList<>(enumType.labels());
485+
486+
labels.set(position, newLabel);
487+
488+
enums.set(index, new EnumType(enumType.name(), labels));
489+
}
490+
491+
/**
492+
* Renames one enum type in its position, keeping its labels, and renames
493+
* the type of every column of it, scalar and array alike, because a column
494+
* names its enum type by that name. A modeled enum of the new name is
495+
* removed, because the {@code DROP TYPE} that freed the name is never seen;
496+
* a column still naming it keeps that name, so it is modeled as a column of
497+
* the renamed enum with its labels, the column PostgreSQL would have
498+
* removed.
499+
*/
500+
private void renameEnumType(
501+
AlterType alterType,
502+
String typeName,
503+
int index,
504+
List<EnumType> enums,
505+
List<Table> tables
506+
) {
507+
if (index < 0) {
508+
ignoreUnmodeledType(alterType, typeName);
509+
510+
return;
511+
}
512+
513+
String newName = MultiPartName.unquote(alterType.getNewName());
514+
int replaced = indexOfEnum(enums, newName);
515+
int renamed = index;
516+
517+
if (replaced >= 0 && replaced != index) {
518+
enums.remove(replaced);
519+
520+
if (replaced < index) {
521+
renamed--;
522+
}
523+
}
524+
525+
enums.set(renamed, new EnumType(newName, enums.get(renamed).labels()));
526+
527+
tables.replaceAll(table -> renameColumnEnumType(table, typeName, newName));
528+
}
529+
530+
/**
531+
* Rejects {@code SET SCHEMA} of a modeled enum type, because its columns
532+
* keep the type in a namespace sqlcj does not model.
533+
*/
534+
private void moveEnumTypeToSchema(
535+
AlterType alterType,
536+
int line,
537+
String typeName,
538+
int index
539+
) {
540+
if (index < 0) {
541+
ignoreUnmodeledType(alterType, typeName);
542+
543+
return;
544+
}
545+
546+
throw unsupportedAlterTypeAction("SET SCHEMA " + alterType.getNewName(), line);
547+
}
548+
549+
/**
550+
* Ignores an {@code ALTER TYPE} action on a type sqlcj does not model,
551+
* because such a type is none of its enum types.
552+
*
553+
* <p>A type name is the statement's own name with its SQL identifier
554+
* delimiters removed, so {@code public.mood} and {@code mood} are different
555+
* names. A qualified name that matches no modeled enum may therefore still
556+
* state one, and fails as a missing type rather than being ignored.
557+
*/
558+
private void ignoreUnmodeledType(AlterType alterType, String typeName) {
559+
if (alterType.getName().contains(".")) {
560+
throw typeNotFound(typeName);
561+
}
562+
}
563+
564+
/** Renames the enum type of every column of {@code oldName}. */
565+
private Table renameColumnEnumType(Table table, String oldName, String newName) {
566+
List<Column> columns = new ArrayList<>(table.columns());
567+
568+
for (int i = 0; i < columns.size(); i++) {
569+
Column column = columns.get(i);
570+
571+
if (column.enumType() == null || !column.enumType().equalsIgnoreCase(oldName)) {
572+
continue;
573+
}
574+
575+
columns.set(
576+
i,
577+
new Column(
578+
column.name(),
579+
column.type(),
580+
column.nullable(),
581+
column.unsupportedType(),
582+
newName,
583+
column.array(),
584+
column.blankPadded()
585+
)
586+
);
587+
}
588+
589+
return new Table(table.name(), columns, table.constraints());
399590
}
400591

401592
/**
402593
* Inserts one label into an enum type: after the last label by default, and
403594
* otherwise directly before or after the stated neighbor, so the modeled
404-
* labels stay in PostgreSQL's sort order. Adding a value is the only
405-
* {@code ALTER TYPE} action sqlcj models; every other action is rejected.
595+
* labels stay in PostgreSQL's sort order.
406596
*
407597
* <p>As PostgreSQL does, {@code IF NOT EXISTS} is decided by the label
408598
* alone: a label the type already has is a no-op even when the stated
@@ -1063,10 +1253,6 @@ private IllegalArgumentException typeNotFound(String typeName) {
10631253
return new IllegalArgumentException("Type not found in schema: " + typeName);
10641254
}
10651255

1066-
private IllegalArgumentException typeAlreadyExists(String typeName) {
1067-
return new IllegalArgumentException("Type already exists in schema: " + typeName);
1068-
}
1069-
10701256
private IllegalArgumentException labelNotFound(String typeName, String label) {
10711257
return new IllegalArgumentException(
10721258
"Label not found in type %s: %s".formatted(typeName, label)
@@ -1100,9 +1286,10 @@ private UnsupportedOperationException unsupportedAlterTableAction(int line, Stri
11001286
);
11011287
}
11021288

1103-
private UnsupportedOperationException unsupportedAlterTypeAction(int line) {
1289+
/** The failure of one rejected {@code ALTER TYPE} action, naming the action. */
1290+
private UnsupportedOperationException unsupportedAlterTypeAction(String action, int line) {
11041291
return new UnsupportedOperationException(
1105-
"Unsupported ALTER TYPE action at line %d".formatted(line)
1292+
"Unsupported ALTER TYPE action: %s at line %d".formatted(action, line)
11061293
);
11071294
}
11081295

0 commit comments

Comments
 (0)