Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions docs/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,10 @@ order the files are read in.

- One rule decides every statement: only the `CREATE TABLE`, `DROP TABLE`, and
`ALTER TABLE` forms listed in [Ordered Table DDL](#ordered-table-ddl) and the
`CREATE TYPE ... AS ENUM` and `ALTER TYPE ... ADD VALUE` forms listed in
[Enum Types](#enum-types) update the schema model, and every other statement
a schema file records is accepted and leaves it unchanged. Only the few
`CREATE TYPE ... AS ENUM` and `ALTER TYPE ... ADD VALUE`, `RENAME TO`, and
`RENAME VALUE` forms listed in [Enum Types](#enum-types) update the schema
model, and every other statement a schema file records is accepted and
leaves it unchanged. Only the few
statements listed in [Ignored Statements](#ignored-statements) are rejected
instead, because sqlcj cannot tell what they would do to the model.
- The statements of the schema files of one entry are applied in order to one
Expand Down Expand Up @@ -247,17 +248,29 @@ left:
| `ALTER TYPE ... ADD VALUE '<label>' BEFORE '<neighbour>'` | Inserts the label directly before the neighbour. |
| `ALTER TYPE ... ADD VALUE '<label>' AFTER '<neighbour>'` | Inserts the label directly after the neighbour. |
| `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. |
| 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>`. |
| `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. |
| `ALTER TYPE ... RENAME VALUE '<label>' TO '<new label>'` | Renames the label in its position, so the modeled labels keep PostgreSQL's sort order. |
| 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. |
| `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. |
| `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. |
| `ALTER TYPE ... RENAME TO` of a type sqlcj does not model | Ignored, because such a type is none of its enum types. |
| `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. |
| `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). |

An `ALTER TYPE` written in a form sqlcj's parser cannot read, such as a
qualified `RENAME TO` target, several actions joined by commas, or
`SET (...)`, is rejected as unreadable type DDL; see
[Ignored Statements](#ignored-statements).

Type names are matched case-insensitively, after their SQL identifier
delimiters are removed, and labels are matched exactly. A statement that repeats
a type or a label, or that refers to a type or a label that is not modeled, is
rejected as PostgreSQL rejects it:
delimiters are removed, and labels are matched exactly. A type name is the
statement's own name, so `public.stage_setting` and `stage_setting` are
different names: a `RENAME TO` or `SET SCHEMA` of a qualified name that matches
no modeled enum fails as a missing type rather than being ignored, because it
may state one. A statement that repeats a label, or that refers to a type or a
label that is not modeled, is rejected as PostgreSQL rejects it:

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

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

- `CREATE INDEX`, `CREATE UNIQUE INDEX`, `ALTER INDEX`, and `DROP INDEX`,
- `COMMENT ON` of any target, including `TABLE`, `COLUMN`, `VIEW`, `TYPE`,
Expand Down Expand Up @@ -464,7 +476,7 @@ what they would do to the schema model:
| Statement | Diagnostic |
| --- | --- |
| 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 |
| An `ALTER TYPE` action other than `ADD VALUE` | `Unsupported ALTER TYPE action at line <n>` |
| An `ALTER TYPE ... SET SCHEMA` of a modeled enum type | `Unsupported ALTER TYPE action: SET SCHEMA <schema> at line <n>` |
| 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` |
| 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 |

Expand Down
235 changes: 211 additions & 24 deletions sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -292,9 +292,9 @@ private boolean statesWords(List<String> tokens, int index, String... words) {
*
* <p>Only the statements sqlcj models are applied: {@code CREATE TABLE},
* {@code ALTER TABLE}, {@code DROP TABLE},
* {@code CREATE TYPE ... AS ENUM}, and {@code ALTER TYPE ... ADD VALUE}.
* Every other statement a schema source may record is ignored and is not
* resolved against the schema at all.
* {@code CREATE TYPE ... AS ENUM}, and {@code ALTER TYPE}. Every other
* statement a schema source may record is ignored and is not resolved
* against the schema at all.
*/
private void apply(Statement statement, int line, List<Table> tables, List<EnumType> enums) {
if (statement instanceof CreateTable createTable) {
Expand All @@ -309,11 +309,7 @@ private void apply(Statement statement, int line, List<Table> tables, List<EnumT
) {
applyCreateEnumType(createType, definition, enums);
} else if (statement instanceof AlterType alterType) {
if (alterType.getAction() != AlterType.Action.ADD_VALUE) {
throw unsupportedAlterTypeAction(line);
}

applyAddEnumValue(alterType, enums);
applyAlterType(alterType, line, enums, tables);
}
}

Expand Down Expand Up @@ -367,10 +363,12 @@ private String upperCase(String word) {
}

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

if (indexOfEnum(enums, typeName) >= 0) {
throw typeAlreadyExists(typeName);
}
int index = indexOfEnum(enums, typeName);

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

Expand All @@ -395,14 +391,208 @@ private void applyCreateEnumType(
labels.add(label);
}

enums.add(new EnumType(typeName, labels));
EnumType enumType = new EnumType(typeName, labels);

if (index < 0) {
enums.add(enumType);

return;
}

enums.set(index, enumType);
}

/**
* Applies one {@code ALTER TYPE} action, resolving the stated type name,
* with its SQL identifier delimiters removed, among the modeled enum types.
*
* <p>An action that cannot change a modeled enum is ignored without the
* type being resolved at all: ownership and the attribute actions of a
* composite type. Every other action sqlcj models resolves the type, and an
* action sqlcj does not model is rejected naming the action.
*/
private void applyAlterType(
AlterType alterType,
int line,
List<EnumType> enums,
List<Table> tables
) {
AlterType.Action action = alterType.getAction();

if (ignoresAlterTypeAction(action)) {
return;
}

if (action == AlterType.Action.ADD_VALUE) {
applyAddEnumValue(alterType, enums);

return;
}

String typeName = MultiPartName.unquote(alterType.getName());
int index = indexOfEnum(enums, typeName);

switch (action) {
case RENAME_VALUE -> renameEnumLabel(alterType, typeName, index, enums);
case RENAME -> renameEnumType(alterType, typeName, index, enums, tables);
case SET_SCHEMA -> moveEnumTypeToSchema(alterType, line, typeName, index);
default -> throw unsupportedAlterTypeAction(String.valueOf(action), line);
}
}

/**
* Reports whether one {@code ALTER TYPE} action cannot change a modeled
* enum type: {@code OWNER TO} and the attribute actions, which belong to a
* composite type.
*/
private boolean ignoresAlterTypeAction(AlterType.Action action) {
return switch (action) {
case OWNER, RENAME_ATTRIBUTE, ATTRIBUTES -> true;
default -> false;
};
}

/**
* Renames one label of an enum type in its position, so the modeled labels
* keep PostgreSQL's sort order. A missing old label and a new label the
* type already has both fail, as PostgreSQL rejects them.
*/
private void renameEnumLabel(
AlterType alterType,
String typeName,
int index,
List<EnumType> enums
) {
if (index < 0) {
throw typeNotFound(typeName);
}

EnumType enumType = enums.get(index);
String label = alterType.getValue().getNotExcapedValue();
String newLabel = alterType.getNewValue().getNotExcapedValue();

int position = enumType.labels().indexOf(label);

if (position < 0) {
throw labelNotFound(typeName, label);
}

if (enumType.labels().contains(newLabel)) {
throw labelAlreadyExists(typeName, newLabel);
}

List<String> labels = new ArrayList<>(enumType.labels());

labels.set(position, newLabel);

enums.set(index, new EnumType(enumType.name(), labels));
}

/**
* Renames one enum type in its position, keeping its labels, and renames
* the type of every column of it, scalar and array alike, because a column
* names its enum type by that name. A modeled enum of the new name is
* removed, because the {@code DROP TYPE} that freed the name is never seen;
* a column still naming it keeps that name, so it is modeled as a column of
* the renamed enum with its labels, the column PostgreSQL would have
* removed.
*/
private void renameEnumType(
AlterType alterType,
String typeName,
int index,
List<EnumType> enums,
List<Table> tables
) {
if (index < 0) {
ignoreUnmodeledType(alterType, typeName);

return;
}

String newName = MultiPartName.unquote(alterType.getNewName());
int replaced = indexOfEnum(enums, newName);
int renamed = index;

if (replaced >= 0 && replaced != index) {
enums.remove(replaced);

if (replaced < index) {
renamed--;
}
}

enums.set(renamed, new EnumType(newName, enums.get(renamed).labels()));

tables.replaceAll(table -> renameColumnEnumType(table, typeName, newName));
}

/**
* Rejects {@code SET SCHEMA} of a modeled enum type, because its columns
* keep the type in a namespace sqlcj does not model.
*/
private void moveEnumTypeToSchema(
AlterType alterType,
int line,
String typeName,
int index
) {
if (index < 0) {
ignoreUnmodeledType(alterType, typeName);

return;
}

throw unsupportedAlterTypeAction("SET SCHEMA " + alterType.getNewName(), line);
}

/**
* Ignores an {@code ALTER TYPE} action on a type sqlcj does not model,
* because such a type is none of its enum types.
*
* <p>A type name is the statement's own name with its SQL identifier
* delimiters removed, so {@code public.mood} and {@code mood} are different
* names. A qualified name that matches no modeled enum may therefore still
* state one, and fails as a missing type rather than being ignored.
*/
private void ignoreUnmodeledType(AlterType alterType, String typeName) {
if (alterType.getName().contains(".")) {
throw typeNotFound(typeName);
}
}

/** Renames the enum type of every column of {@code oldName}. */
private Table renameColumnEnumType(Table table, String oldName, String newName) {
List<Column> columns = new ArrayList<>(table.columns());

for (int i = 0; i < columns.size(); i++) {
Column column = columns.get(i);

if (column.enumType() == null || !column.enumType().equalsIgnoreCase(oldName)) {
continue;
}

columns.set(
i,
new Column(
column.name(),
column.type(),
column.nullable(),
column.unsupportedType(),
newName,
column.array(),
column.blankPadded()
)
);
}

return new Table(table.name(), columns, table.constraints());
}

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

private IllegalArgumentException typeAlreadyExists(String typeName) {
return new IllegalArgumentException("Type already exists in schema: " + typeName);
}

private IllegalArgumentException labelNotFound(String typeName, String label) {
return new IllegalArgumentException(
"Label not found in type %s: %s".formatted(typeName, label)
Expand Down Expand Up @@ -1100,9 +1286,10 @@ private UnsupportedOperationException unsupportedAlterTableAction(int line, Stri
);
}

private UnsupportedOperationException unsupportedAlterTypeAction(int line) {
/** The failure of one rejected {@code ALTER TYPE} action, naming the action. */
private UnsupportedOperationException unsupportedAlterTypeAction(String action, int line) {
return new UnsupportedOperationException(
"Unsupported ALTER TYPE action at line %d".formatted(line)
"Unsupported ALTER TYPE action: %s at line %d".formatted(action, line)
);
}

Expand Down
Loading
Loading