diff --git a/docs/postgresql.md b/docs/postgresql.md index faf12ed..6fdd73a 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -376,6 +376,12 @@ are recorded with their declared type; see | Table-level `FOREIGN KEY (...) REFERENCES ...`, named or unnamed | Accepted and ignored. | | Table-level `CHECK (...)`, named or unnamed | Accepted and ignored. | | Table-level `EXCLUDE USING ... (...)`, named or unnamed | Accepted and ignored. | +| `LIKE t`, with any `INCLUDING` and `EXCLUDING` options | Accepted. Copies the columns, types, and nullability of the modeled table `t` at the position the clause stands in. | +| `PARTITION OF t`, with `FOR VALUES FROM ... TO ...`, `FOR VALUES IN (...)`, `FOR VALUES WITH (MODULUS ...)`, or `DEFAULT` | Accepted. Copies the columns, types, and nullability of the modeled table `t` and keeps following its column changes; see [Ordered Table DDL](#ordered-table-ddl). | +| An empty element list, `()` | Accepted and modeled as a table without columns. | +| `AS SELECT ...` | Rejected as `Unsupported CREATE TABLE clause: AS SELECT at line `. | +| `OF ` | Rejected as `Unsupported CREATE TABLE clause: OF at line `. | +| `INHERITS (p)` | Rejected as `Unsupported CREATE TABLE clause: INHERITS at line `. | | A table statement sqlcj's parser cannot read, such as `ALTER FOREIGN TABLE` | Rejected; see [Ignored Statements](#ignored-statements). | | Any other table-constraint kind | Rejected. | | Unparsable SQL | Rejected. | @@ -386,6 +392,36 @@ pipeline reads it, so it changes no generated type, method, parameter, or result component. "Ignored" means the construct is accepted as valid schema input and is not carried into the model at all. +A `CREATE TABLE` element list may state column definitions, table constraints, +and `LIKE` clauses in any order, and the table's columns are modeled in that +written order, as PostgreSQL creates them, each `LIKE` contributing its source's +columns at its own position. A partition starts with its parent's columns, +before every element of its own list. A column name that is repeated, by two +column definitions, by a `LIKE` and a column definition, or by two `LIKE` +clauses, fails as `Column already exists in table : `. + +A `LIKE`'s `INCLUDING` and `EXCLUDING` options cannot change a copied column's +type or nullability and are ignored, and neither `LIKE` nor `PARTITION OF` +copies its source's recorded constraints: a copy records only the constraints +its own statement states. A `LIKE` copy is independent of its source afterwards, +as in PostgreSQL. A `LIKE` or `PARTITION OF` source sqlcj does not model fails +as `Table not found in schema: `. + +Because sqlcj models one namespace, a `LIKE` or `PARTITION OF` source and an +`ATTACH` or `DETACH PARTITION` partition resolve by their unqualified name, so +`public.t` is `t`. Until sqlcj models namespaces, such a name qualified with any +other schema names a table sqlcj leaves unmodeled and fails as +`Table not found in schema: .` rather than resolving to a modeled `t`. + +A `CREATE TABLE` with neither an element list, `()`, nor `PARTITION OF`, such as +`CREATE TABLE c LIKE t` without the parentheses PostgreSQL requires, states no +columns at all and is rejected as +`Unsupported schema statement: CREATE TABLE at line `. A form sqlcj's parser +cannot read, such as the column options `PARTITION OF` and `OF ` allow +(`id WITH OPTIONS NOT NULL`) or `AS SELECT ... WITH NO DATA`, is rejected as +unreadable table DDL with the parser's reason; see +[Ignored Statements](#ignored-statements). + ## Ordered Table DDL A schema of `CREATE TABLE` statements alone is modeled exactly as it was before @@ -397,14 +433,16 @@ statements and files before them left; the enum-type statements are listed in | --- | --- | | `CREATE TABLE` | Adds the table after the tables already modeled. | | `CREATE TABLE IF NOT EXISTS` | Does nothing when the table already exists. | -| `DROP TABLE`, with one or several names | Removes each named table. | +| `DROP TABLE`, with one or several names | Removes each named table and, recursively, every partition of it, as PostgreSQL does. | | `DROP TABLE IF EXISTS` | Does nothing for a name that is not modeled. | | `ALTER TABLE ... ADD COLUMN` | Appends the column, typed exactly as a `CREATE TABLE` column of the same declaration, and records its column-level `PRIMARY KEY` or `UNIQUE`. | | `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` | Does nothing when the column already exists. | | `ALTER TABLE ... DROP COLUMN` | Removes the column and every recorded constraint that lists it. | | `ALTER TABLE ... DROP COLUMN IF EXISTS` | Does nothing when the column is not modeled. | | `ALTER TABLE ... RENAME COLUMN` | Renames the column in its position and in the constraints that list it. | -| `ALTER TABLE ... RENAME TO` | Renames the table in its position, keeping its columns and constraints. | +| `ALTER TABLE ... RENAME TO` | Renames the table in its position, keeping its columns and constraints, and keeps every partition of it linked to the new name. | +| `ALTER TABLE ... ATTACH PARTITION` | Links a modeled table to the altered one, so it follows its column changes from then on. A partition sqlcj does not model is ignored. | +| `ALTER TABLE ... DETACH PARTITION` | Ends the link of a modeled partition. A partition sqlcj does not model is ignored. | | `ALTER TABLE ... ALTER COLUMN ... TYPE` | Maps or records the new type as a `CREATE TABLE` column of that type is mapped or recorded, keeping the column's position and its nullability, which a type change does not state. | | `ALTER TABLE ... ALTER COLUMN ... SET NOT NULL` | Models the column non-null. | | `ALTER TABLE ... ALTER COLUMN ... DROP NOT NULL` | Models the column nullable. | @@ -416,6 +454,16 @@ One `ALTER TABLE` may state several actions; they are applied in the written order. Table and column names are matched case-insensitively, as query analysis looks them up, after their SQL identifier delimiters are removed. +PostgreSQL applies every column change of a partitioned table to its partitions, +so a partition created by `PARTITION OF`, or attached by `ATTACH PARTITION` when +sqlcj models its table, stays linked to its parent: `ADD COLUMN`, +`DROP COLUMN`, `RENAME COLUMN`, `ALTER COLUMN ... TYPE`, `SET NOT NULL`, and +`DROP NOT NULL` on the parent are applied to its partitions as well, +recursively, so a nested partition follows too. A partition keeps its own name +when its parent is renamed, `DROP TABLE` of the parent removes it, and +`DETACH PARTITION` ends the link, after which the parent's later column changes +no longer reach it. A `LIKE` copy is never linked to its source. + Outside the `IF [NOT] EXISTS` forms above, a statement that refers to a table or a column that is not modeled is rejected, and so is a statement that would give two tables, or two columns of one table, the same name, as PostgreSQL rejects it: @@ -476,6 +524,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: at line `, quoting the action's own SQL | +| A `CREATE TABLE ... AS SELECT`, `OF `, or `INHERITS`, whose columns sqlcj cannot determine | `Unsupported CREATE TABLE clause: at line `, naming `AS SELECT`, `OF `, or `INHERITS` | | An `ALTER TYPE ... SET SCHEMA` of a modeled enum type | `Unsupported ALTER TYPE action: SET SCHEMA at line ` | | 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 `, quoting the opening words | @@ -505,7 +554,7 @@ statement still resolves its table: | `ALTER [COLUMN] c DROP EXPRESSION` | Only the readable spelling; `DROP EXPRESSION IF EXISTS` is unreadable. | | `ALTER [COLUMN] c ADD GENERATED ... AS IDENTITY`, `SET GENERATED ...`, a sequence option such as `SET INCREMENT BY 2`, `RESTART`, and `DROP IDENTITY [IF EXISTS]` | PostgreSQL requires the column to be `NOT NULL` already, so an identity never changes nullability. | | `ENABLE`, `DISABLE`, `FORCE`, and `NO FORCE ROW LEVEL SECURITY` | | -| `ATTACH PARTITION` and `DETACH PARTITION` | The partition's own table is modeled by its own statements. | +| `ATTACH PARTITION` and `DETACH PARTITION` of a table sqlcj does not model | A modeled partition is linked or unlinked instead, as [Ordered Table DDL](#ordered-table-ddl) states. | | `OWNER TO` | | | `ENABLE TRIGGER`, `DISABLE TRIGGER`, `ENABLE REPLICA TRIGGER`, and `ENABLE ALWAYS TRIGGER`, and the same four for `RULE` | | | `VALIDATE CONSTRAINT` | | @@ -701,6 +750,8 @@ and recorded type: ```text sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported schema statement: ALTER FOREIGN TABLE at line 12 sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported ALTER TABLE action: SET SCHEMA archive at line 18 +sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported CREATE TABLE clause: AS SELECT at line 24 +sqlcj: Invalid schema source /home/dev/project/schema.sql: Table not found in schema: reporting.events sqlcj: Invalid schema source /home/dev/project/schema.sql: Encountered unexpected token: ";" at line 4, column 1 sqlcj: Invalid query 'ListTags' in /home/dev/project/queries.sql at line 5: Column 'tags' has unsupported type JSONB[] ``` @@ -857,6 +908,29 @@ Enum types: - `DefaultSchemaParserTest.shouldRejectUnsupportedSchemaStatement` and `DefaultSchemaParserTest.shouldThrowSchemaParseExceptionForInvalidSql` cover the rejected input. +- `DefaultSchemaParserTest.shouldCopyTheColumnsOfALikeSource` covers a `LIKE` + alone with its options and a `public`-qualified source, + `DefaultSchemaParserTest.shouldModelTheColumnsOfAnElementListInWrittenOrder` + covers a `LIKE` written before and after a column definition and two `LIKE` + clauses, and + `DefaultSchemaParserTest.shouldRecordOnlyTheOwnConstraintsOfALikeTable` + covers the source constraints that are not copied beside the copy's own. +- `DefaultSchemaParserTest.shouldCopyTheColumnsOfAPartitionParent` covers the + range, list, hash, and `DEFAULT` bounds and the `public`-qualified parent, + `DefaultSchemaParserTest.shouldRecordTheTableConstraintsOfAPartition` covers + the constraint list a `PARTITION OF` may state, and + `DefaultSchemaParserTest.shouldModelAnEmptyElementListAsATableWithoutColumns` + covers `()`. +- `DefaultSchemaParserTest.shouldReportARepeatedColumnNameOfALikeSource`, + `DefaultSchemaParserTest.shouldReportTheMissingSourceOfACreatedTable`, + `DefaultSchemaParserTest.shouldRejectTheCreateTableClausesWhoseColumnsItCannotDetermine`, + and `DefaultSchemaParserTest.shouldRejectASourceOrPartitionOfAnotherSchema` + cover the repeated column name, the missing source, each rejected clause at + the line of its own statement, and the interim rule for a source and a + partition qualified with another schema. +- `SqlcjCompilerIntegrationTest.shouldGenerateCompilableRepositoriesOverCopiedTableColumns` + generates and compiles `SELECT *` row records and a repository over a `LIKE` + table and a `PARTITION OF` table. - `PostgresIntegrationTest.shouldExecuteGeneratedQueryForSnapshotWithIgnoredTableConstraints` proves that such a snapshot is valid PostgreSQL DDL and compiles and executes. @@ -882,6 +956,19 @@ Ordered table DDL: `DefaultSchemaParserTest.shouldIgnoreDropColumnIfExistsForAMissingColumn`, and `DefaultSchemaParserTest.shouldReportTheMissingColumnOfAnAlterTableIfExists` cover the `IF [NOT] EXISTS` variants. +- `DefaultSchemaParserTest.shouldApplyEveryColumnActionOfAParentToItsPartition` + and `DefaultSchemaParserTest.shouldApplyAColumnActionToANestedPartition` + cover each propagated column action and a nested partition; + `DefaultSchemaParserTest.shouldKeepThePartitionLinkThroughARenameOfTheParent` + and `DefaultSchemaParserTest.shouldDropThePartitionsOfADroppedParent` cover + the parent's rename and `DROP TABLE`, and + `DefaultSchemaParserTest.shouldDropAParentNamedBeforeItsPartition` covers the + one `DROP TABLE` that names a parent before one of its partitions; + `DefaultSchemaParserTest.shouldStopPropagationAfterDetachPartition` and + `DefaultSchemaParserTest.shouldStartPropagationAfterAttachPartition` cover the + ended and the started link; and + `DefaultSchemaParserTest.shouldNotFollowTheSourceOfALikeCopy` covers the + `LIKE` copy that stays independent of its source. - `DefaultSchemaParserTest.shouldReportTheMissingTableOfAStatement`, `DefaultSchemaParserTest.shouldReportTheMissingColumnOfAnAlterTableAction`, `DefaultSchemaParserTest.shouldReportAStatementThatRepeatsATableName`, @@ -908,8 +995,9 @@ Ignored statements: named and unnamed constraint actions. - `DefaultSchemaParserTest.shouldIgnoreTheAlterTableActionsThatCannotChangeAColumn` covers one spelling per ignored `ALTER TABLE` action, including the lower-case - and `ALTER TABLE ONLY public.users` spellings and two actions classified by - their words in one statement. + and `ALTER TABLE ONLY public.users` spellings, the unqualified and + `public`-qualified `ATTACH` and `DETACH PARTITION` of a partition sqlcj does + not model, and two actions classified by their words in one statement. - `DefaultSchemaParserTest.shouldNotResolveAnIgnoredStatementAgainstTheSchema` covers that an ignored statement, including a view over an unmodeled table and a `TRUNCATE` of one, may name an unmodeled table or column; diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/Table.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/Table.java index 8029c45..55d15e5 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/Table.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/Table.java @@ -2,14 +2,26 @@ import java.util.List; +/** + * One modeled table. {@code partitionOf} names the partitioned table this one + * is a partition of, or is {@code null} when the table is not a partition, + * because PostgreSQL applies every column change of a partitioned table to its + * partitions. + */ public record Table( String name, List columns, - List constraints + List constraints, + String partitionOf ) { public Table { columns = List.copyOf(columns); constraints = List.copyOf(constraints); } + + /** A table that is not a partition of another table. */ + public Table(String name, List columns, List constraints) { + this(name, columns, constraints, null); + } } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java index 4fe9795..1739bbd 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/schema/parser/DefaultSchemaParser.java @@ -11,11 +11,13 @@ import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.schema.MultiPartName; +import net.sf.jsqlparser.statement.LikeClause; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.UnsupportedStatement; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterExpression; +import net.sf.jsqlparser.statement.alter.AlterExpressionPartition; import net.sf.jsqlparser.statement.alter.AlterOperation; import net.sf.jsqlparser.statement.alter.AlterType; import net.sf.jsqlparser.statement.create.table.CheckConstraint; @@ -24,6 +26,7 @@ import net.sf.jsqlparser.statement.create.table.ExcludeConstraint; import net.sf.jsqlparser.statement.create.table.ForeignKeyIndex; import net.sf.jsqlparser.statement.create.table.Index; +import net.sf.jsqlparser.statement.create.table.TableElement; import net.sf.jsqlparser.statement.create.type.CreateType; import net.sf.jsqlparser.statement.create.type.EnumTypeDefinition; import net.sf.jsqlparser.statement.drop.Drop; @@ -298,7 +301,7 @@ private boolean statesWords(List tokens, int index, String... words) { */ private void apply(Statement statement, int line, List tables, List enums) { if (statement instanceof CreateTable createTable) { - applyCreateTable(createTable, enums, tables); + applyCreateTable(createTable, line, enums, tables); } else if (statement instanceof Alter alter) { applyAlterTable(alter, line, enums, tables); } else if (statement instanceof Drop drop && drop.getObjectType() == Drop.ObjectType.TABLE) { @@ -586,7 +589,7 @@ private Table renameColumnEnumType(Table table, String oldName, String newName) ); } - return new Table(table.name(), columns, table.constraints()); + return new Table(table.name(), columns, table.constraints(), table.partitionOf()); } /** @@ -648,10 +651,12 @@ private int labelPosition(AlterType alterType, EnumType enumType) { /** * Appends one table. A repeated table name fails, as PostgreSQL rejects it, - * unless the statement declares {@code IF NOT EXISTS}. + * unless the statement declares {@code IF NOT EXISTS}. A clause that states + * columns sqlcj cannot determine is rejected naming the clause. */ private void applyCreateTable( CreateTable createTable, + int line, List enums, List
tables ) { @@ -665,17 +670,70 @@ private void applyCreateTable( throw tableAlreadyExists(tableName); } - tables.add(parseTable(createTable, enums)); + rejectUnsupportedCreateTableClause(createTable, line); + + tables.add(parseTable(createTable, line, enums, tables)); + } + + /** + * Rejects the {@code CREATE TABLE} clauses whose columns stand nowhere in + * the schema source: a query, a composite type, and an inherited table, + * whose parent's columns PostgreSQL copies and keeps following. + */ + private void rejectUnsupportedCreateTableClause(CreateTable createTable, int line) { + if (createTable.getSelect() != null) { + throw unsupportedCreateTableClause("AS SELECT", line); + } + + if (createTable.getOfType() != null) { + throw unsupportedCreateTableClause("OF " + createTable.getOfType(), line); + } + + if (inheritsAnotherTable(createTable)) { + throw unsupportedCreateTableClause("INHERITS", line); + } } - /** Removes every named table, keeping the order of the remaining ones. */ + /** + * Reports whether a {@code CREATE TABLE} states {@code INHERITS}, which the + * parser reports only among the table options, with the case it was written + * in. + */ + private boolean inheritsAnotherTable(CreateTable createTable) { + List options = createTable.getTableOptionsStrings(); + + return options != null && options.stream().anyMatch("INHERITS"::equalsIgnoreCase); + } + + /** + * Removes every named table, and every partition of one, keeping the order + * of the remaining ones. A name a preceding name of the same statement + * already removed as a partition of it is left alone, because it is dropped + * either way. + */ private void applyDropTable(Drop drop, List
tables) { + for (String tableName : droppedTableNames(drop, tables)) { + int index = indexOfTable(tables, tableName); + + if (index >= 0) { + dropTable(tables, index); + } + } + } + + /** + * The modeled names of one {@code DROP TABLE}, all resolved against the + * tables as they stand before it drops anything, as PostgreSQL resolves + * every name of one statement before deleting any of them. A name that is + * not modeled fails, unless the statement declares {@code IF EXISTS}. + */ + private List droppedTableNames(Drop drop, List
tables) { + List names = new ArrayList<>(); + for (net.sf.jsqlparser.schema.Table dropped : drop.getNames()) { String tableName = dropped.getUnquotedName(); - int index = indexOfTable(tables, tableName); - - if (index < 0) { + if (indexOfTable(tables, tableName) < 0) { if (drop.isIfExists()) { continue; } @@ -683,8 +741,41 @@ private void applyDropTable(Drop drop, List
tables) { throw tableNotFound(tableName); } - tables.remove(index); + names.add(tableName); + } + + return names; + } + + /** + * Removes one table and, recursively, every partition of it, as PostgreSQL + * drops the partitions of a partitioned table along with it. + */ + private void dropTable(List
tables, int index) { + String tableName = tables.get(index).name(); + + tables.remove(index); + + int partition = indexOfPartition(tables, tableName); + + while (partition >= 0) { + dropTable(tables, partition); + + partition = indexOfPartition(tables, tableName); + } + } + + /** The first partition of {@code parentName}, or {@code -1}. */ + private int indexOfPartition(List
tables, String parentName) { + for (int i = 0; i < tables.size(); i++) { + String parent = tables.get(i).partitionOf(); + + if (parent != null && parent.equalsIgnoreCase(parentName)) { + return i; + } } + + return -1; } /** @@ -706,8 +797,76 @@ private void applyAlterTable(Alter alter, int line, List enums, List enums, + List
tables, + int index + ) { + String tableName = tables.get(index).name(); + + tables.set(index, applyAlterExpression(line, expression, enums, tables, index)); + + if (!propagatesToPartitions(expression)) { + return; + } + + for (int i = 0; i < tables.size(); i++) { + String parent = tables.get(i).partitionOf(); + + if (parent != null && parent.equalsIgnoreCase(tableName)) { + applyToTableAndPartitions(line, expression, enums, tables, i); + } + } + } + + /** + * Reports whether one {@code ALTER TABLE} action changes a modeled column + * and therefore reaches the partitions of its table: {@code ADD COLUMN}, + * {@code DROP COLUMN}, {@code RENAME COLUMN}, a new column type, + * {@code SET NOT NULL}, and {@code DROP NOT NULL}. + */ + private boolean propagatesToPartitions(AlterExpression expression) { + AlterOperation operation = expression.getOperation(); + + if (operation == AlterOperation.ADD) { + return isNotEmpty(expression.getColDataTypeList()); + } + + if (operation == AlterOperation.DROP) { + return expression.getColumnName() != null; + } + + if (operation == AlterOperation.RENAME) { + return expression.getColumnOldName() != null; + } + + if (operation != AlterOperation.ALTER) { + return false; + } + + if (isNotEmpty(expression.getColumnSetDefaultList()) || isNotEmpty(expression.getColumnDropDefaultList())) { + return false; + } + + if (isNotEmpty(expression.getColDataTypeList())) { + return statesNewTypes(expression.getColDataTypeList()); } + + return isNotEmpty(expression.getColumnSetNotNullList()) + || isNotEmpty(expression.getColumnDropNotNullList()); } /** @@ -748,6 +907,10 @@ private Table applyAlterExpression( return alterColumns(line, expression, enums, table); } + if (operation == AlterOperation.ATTACH_PARTITION || operation == AlterOperation.DETACH_PARTITION) { + return changePartitionLink(expression, tables, table); + } + if (isIgnoredConstraintAction(expression) || isIgnoredTableAction(operation)) { return table; } @@ -759,19 +922,56 @@ private Table applyAlterExpression( throw unsupportedAlterTableAction(line, expression); } + /** + * Links or unlinks one partition of the altered table, leaving the altered + * table itself unchanged. {@code ATTACH PARTITION} links the stated table to + * the altered one, so it follows its column changes from then on, and + * {@code DETACH PARTITION} ends that link. A partition sqlcj does not model + * is ignored, like any other statement about an unmodeled table. + */ + private Table changePartitionLink( + AlterExpression expression, + List
tables, + Table table + ) { + if (!(expression instanceof AlterExpressionPartition partition)) { + return table; + } + + int index = indexOfReferencedTable(tables, partition.getPartitionTable()); + + if (index < 0) { + return table; + } + + Table attached = tables.get(index); + + tables.set( + index, + new Table( + attached.name(), + attached.columns(), + attached.constraints(), + expression.getOperation() == AlterOperation.ATTACH_PARTITION + ? table.name() + : null + ) + ); + + return table; + } + /** * Reports whether one {@code ALTER TABLE} action is a table-level action the * parser has a form of its own for that cannot change a column: the - * row-level-security switches and attaching or detaching a partition. + * row-level-security switches. */ private boolean isIgnoredTableAction(AlterOperation operation) { return switch (operation) { case ENABLE_ROW_LEVEL_SECURITY, DISABLE_ROW_LEVEL_SECURITY, FORCE_ROW_LEVEL_SECURITY, - NO_FORCE_ROW_LEVEL_SECURITY, - ATTACH_PARTITION, - DETACH_PARTITION -> true; + NO_FORCE_ROW_LEVEL_SECURITY -> true; default -> false; }; } @@ -1010,7 +1210,7 @@ private Table addColumns(AlterExpression expression, List enums, Table constraints.addAll(parseColumnConstraints(definition)); } - return new Table(table.name(), columns, constraints); + return new Table(table.name(), columns, constraints, table.partitionOf()); } /** Removes one column and every constraint that lists it. */ @@ -1035,7 +1235,7 @@ private Table dropColumn(AlterExpression expression, Table table) { .filter(constraint -> !listsColumn(constraint, columnName)) .toList(); - return new Table(table.name(), columns, constraints); + return new Table(table.name(), columns, constraints, table.partitionOf()); } /** @@ -1077,10 +1277,14 @@ private Table renameColumn(AlterExpression expression, Table table) { .map(constraint -> renameConstraintColumn(constraint, oldName, newName)) .toList(); - return new Table(table.name(), columns, constraints); + return new Table(table.name(), columns, constraints, table.partitionOf()); } - /** Renames one table in its position, keeping its columns and constraints. */ + /** + * Renames one table in its position, keeping its columns and constraints, + * and names the new name in every partition of it, so a partition keeps + * following its parent. + */ private Table renameTable(AlterExpression expression, List
tables, int index) { String newName = MultiPartName.unquote(expression.getNewTableName()); @@ -1092,7 +1296,18 @@ private Table renameTable(AlterExpression expression, List
tables, int in Table table = tables.get(index); - return new Table(newName, table.columns(), table.constraints()); + tables.replaceAll(partition -> renamePartitionParent(partition, table.name(), newName)); + + return new Table(newName, table.columns(), table.constraints(), table.partitionOf()); + } + + /** Names {@code newName} as the parent of a partition of {@code oldName}. */ + private Table renamePartitionParent(Table table, String oldName, String newName) { + if (table.partitionOf() == null || !table.partitionOf().equalsIgnoreCase(oldName)) { + return table; + } + + return new Table(table.name(), table.columns(), table.constraints(), newName); } /** @@ -1133,7 +1348,7 @@ private Table changeColumnTypes( ); } - return new Table(table.name(), columns, table.constraints()); + return new Table(table.name(), columns, table.constraints(), table.partitionOf()); } /** Sets the nullability of each named column in its position. */ @@ -1169,7 +1384,7 @@ private Table changeNullability( ); } - return new Table(table.name(), columns, table.constraints()); + return new Table(table.name(), columns, table.constraints(), table.partitionOf()); } private boolean listsColumn(Constraint constraint, String columnName) { @@ -1204,6 +1419,36 @@ private int indexOfTable(List
tables, String tableName) { return -1; } + /** + * The position of the table one statement names beside the table it + * creates or alters, the source of a {@code LIKE} or {@code PARTITION OF} + * and the partition of {@code ATTACH} or {@code DETACH PARTITION}, or + * {@code -1} when sqlcj does not model it. + * + *

sqlcj models one namespace, so an unqualified and a + * {@code public}-qualified name are the same table. A name qualified with + * any other schema names a table sqlcj leaves unmodeled, and fails as a + * missing table rather than resolving to a modeled table of the same + * unqualified name. + */ + private int indexOfReferencedTable( + List

tables, + net.sf.jsqlparser.schema.Table referenced + ) { + String tableName = referenced.getUnquotedName(); + String declaredSchema = referenced.getSchemaName(); + + if (declaredSchema != null) { + String schemaName = MultiPartName.unquote(declaredSchema); + + if (!"public".equalsIgnoreCase(schemaName)) { + throw tableNotFound(schemaName + "." + tableName); + } + } + + return indexOfTable(tables, tableName); + } + /** Enum type names are matched case-insensitively, as a column names them. */ private int indexOfEnum(List enums, String typeName) { for (int i = 0; i < enums.size(); i++) { @@ -1299,21 +1544,69 @@ private UnsupportedOperationException unsupportedOpening(String opening, int lin ); } - private Table parseTable(CreateTable createTable, List enums) { + /** + * The failure of one rejected {@code CREATE TABLE} clause, naming the + * clause. + */ + private UnsupportedOperationException unsupportedCreateTableClause(String clause, int line) { + return new UnsupportedOperationException( + "Unsupported CREATE TABLE clause: %s at line %d".formatted(clause, line) + ); + } + + /** + * Parses one table, whose columns come from its own column definitions, in + * written order, from the modeled source of each {@code LIKE} at its + * position in the element list, and, for a partition, from its parent + * before all of them, as PostgreSQL creates them. A repeated column name + * fails, as PostgreSQL rejects it. + * + *

A {@code LIKE}'s {@code INCLUDING} and {@code EXCLUDING} options + * cannot change a copied column's type or nullability and are ignored, and + * neither {@code LIKE} nor {@code PARTITION OF} copies its source's + * recorded constraints. Only the table's own table constraints are + * recorded, in a partition as in any other table. + * + *

A {@code CREATE TABLE} with neither an element list, the empty element + * list {@code ()}, nor {@code PARTITION OF} states columns sqlcj cannot + * determine and is rejected as unsupported table DDL. + */ + private Table parseTable( + CreateTable createTable, + int line, + List enums, + List

tables + ) { String tableName = createTable.getTable().getUnquotedName(); List columns = new ArrayList<>(); List constraints = new ArrayList<>(); + String partitionOf = null; - for (ColumnDefinition definition : createTable.getColumnDefinitions()) { - Column column = parseColumn(definition, enums); + if (createTable.getPartitionOf() != null) { + Table parent = referencedTable(tables, createTable.getPartitionOf()); - if (indexOfColumn(columns, column.name()) >= 0) { - throw columnAlreadyExists(tableName, column.name()); - } + partitionOf = parent.name(); + columns.addAll(parent.columns()); + } - columns.add(column); - constraints.addAll(parseColumnConstraints(definition)); + List elements = createTable.getTableElements(); + + if (elements == null) { + if (partitionOf == null && !statesEmptyElementList(createTable)) { + throw unsupportedOpening("CREATE TABLE", line); + } + } else { + for (TableElement element : elements) { + if (element instanceof ColumnDefinition definition) { + addColumn(columns, tableName, parseColumn(definition, enums)); + constraints.addAll(parseColumnConstraints(definition)); + } else if (element instanceof LikeClause like) { + for (Column column : referencedTable(tables, like.getTable()).columns()) { + addColumn(columns, tableName, column); + } + } + } } constraints.addAll(parseTableConstraints(createTable)); @@ -1321,10 +1614,48 @@ private Table parseTable(CreateTable createTable, List enums) { return new Table( tableName, columns, - constraints + constraints, + partitionOf ); } + /** + * Reports whether a {@code CREATE TABLE} states the empty element list + * {@code ()}, a table without columns, which the parser reports as the + * first of the table options instead of as an element list. + */ + private boolean statesEmptyElementList(CreateTable createTable) { + List options = createTable.getTableOptionsStrings(); + + return options != null && !options.isEmpty() && "()".equals(options.get(0)); + } + + /** + * The modeled table a {@code LIKE} or {@code PARTITION OF} names as the + * source of its columns. A source sqlcj does not model fails as a missing + * table, because the created table's columns would otherwise be unknown. + */ + private Table referencedTable( + List
tables, + net.sf.jsqlparser.schema.Table referenced + ) { + int index = indexOfReferencedTable(tables, referenced); + + if (index < 0) { + throw tableNotFound(referenced.getUnquotedName()); + } + + return tables.get(index); + } + + private void addColumn(List columns, String tableName, Column column) { + if (indexOfColumn(columns, column.name()) >= 0) { + throw columnAlreadyExists(tableName, column.name()); + } + + columns.add(column); + } + /** * Parses one column, whose declared type the shared column type mapping * resolves or records with its declared type text instead of failing the diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java index 6d68948..8c43f63 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -3192,6 +3192,78 @@ name VARCHAR(255) ); } + /** + * A table whose columns come from a {@code LIKE} and a partition whose + * columns come from its parent are modeled with those columns, so a + * {@code SELECT *} over either of them generates a row record and a + * repository that compile. The parent's later added column reaches its + * partition but not the independent {@code LIKE} copy. + */ + @Test + void shouldGenerateCompilableRepositoriesOverCopiedTableColumns() throws IOException { + Path schemaFile = tempDir.resolve("schema.sql"); + Path queriesFile = tempDir.resolve("queries.sql"); + Path generatedDirectory = tempDir.resolve("generated"); + + Files.writeString( + schemaFile, + """ + CREATE TABLE events + ( + id BIGINT NOT NULL, + tenant VARCHAR(64) NOT NULL, + payload TEXT + ); + + CREATE TABLE events_copy (LIKE events INCLUDING ALL); + + CREATE TABLE events_recent PARTITION OF events + FOR VALUES FROM (1) TO (100); + + ALTER TABLE events ADD COLUMN note TEXT; + """ + ); + + Files.writeString( + queriesFile, + """ + -- name: ListCopiedEvents :many + SELECT * + FROM events_copy + WHERE tenant = $1; + + -- name: ListRecentEvents :many + SELECT * + FROM events_recent + WHERE tenant = $1; + """ + ); + + generateUsersRepository( + List.of(schemaFile.toString()), + queriesFile, + generatedDirectory + ); + + assertEquals( + List.of("Long id", "String tenant", "String payload"), + recordComponents( + Files.readString(generatedDirectory.resolve("generated/EventsCopyRow.java")), + "EventsCopyRow" + ) + ); + + assertEquals( + List.of("Long id", "String tenant", "String payload", "String note"), + recordComponents( + Files.readString(generatedDirectory.resolve("generated/EventsRecentRow.java")), + "EventsRecentRow" + ) + ); + + assertCompiles(generatedDirectory, tempDir.resolve("classes")); + } + /** Generates the one repository of the {@code Users} group. */ private Path generateUsersRepository( List schema, diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java index 17485fc..7d6b703 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/schema/parser/DefaultSchemaParserTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -99,6 +100,30 @@ region text NOT NULL REFERENCES region (code), COMMENT ON COLUMN stages.handle IS 'Appears in public links'; """; + /** + * The schema the {@code LIKE} and {@code PARTITION OF} tests copy their + * columns from. + */ + private static final String SOURCE_SCHEMA = """ + CREATE TABLE tags ( + label VARCHAR(32) NOT NULL + ); + + CREATE TABLE events ( + id BIGINT NOT NULL PRIMARY KEY, + tenant VARCHAR(64) NOT NULL, + payload TEXT, + UNIQUE (tenant, payload) + ); + """; + + /** The columns the {@code events} table of {@link #SOURCE_SCHEMA} states. */ + private static final List EVENT_COLUMNS = List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("tenant", ColumnType.VARCHAR, false), + new Column("payload", ColumnType.TEXT, true) + ); + /** The third migration file, which reshapes the table the second creates. */ private static final String RESHAPE_STAGE_MIGRATION = """ ALTER TABLE stages RENAME TO stage; @@ -822,7 +847,9 @@ CREATE TABLE users ( "ALTER TABLE users FORCE ROW LEVEL SECURITY;", "ALTER TABLE users NO FORCE ROW LEVEL SECURITY;", "ALTER TABLE users ATTACH PARTITION users_p1 FOR VALUES FROM (1) TO (2);", + "ALTER TABLE users ATTACH PARTITION public.users_p1 DEFAULT;", "ALTER TABLE users DETACH PARTITION users_p1;", + "ALTER TABLE users DETACH PARTITION public.users_p1;", "ALTER TABLE users OWNER TO app;", "ALTER TABLE users ENABLE TRIGGER users_touch;", "ALTER TABLE users DISABLE TRIGGER users_touch;", @@ -2675,6 +2702,512 @@ void shouldRejectSetSchemaOfAModeledEnum() { ); } + /** + * A {@code LIKE} copies its source's columns, types, and nullability, in + * the position it stands in, whatever {@code INCLUDING} and + * {@code EXCLUDING} options it states, and whether its source is named + * unqualified or qualified with {@code public}. + */ + @ParameterizedTest + @ValueSource( + strings = { + "CREATE TABLE events_copy (LIKE events);", + "CREATE TABLE events_copy (LIKE events INCLUDING ALL);", + "CREATE TABLE events_copy (LIKE events EXCLUDING ALL);", + "CREATE TABLE events_copy " + + "(LIKE events INCLUDING DEFAULTS EXCLUDING CONSTRAINTS INCLUDING IDENTITY);", + "CREATE TABLE events_copy (LIKE public.events INCLUDING ALL);" + } + ) + void shouldCopyTheColumnsOfALikeSource(String statement) { + Table copy = table(parser.parse(parser.parse(SOURCE_SCHEMA), statement), "events_copy"); + + assertEquals(EVENT_COLUMNS, copy.columns()); + assertEquals(List.of(), copy.constraints()); + assertNull(copy.partitionOf()); + } + + /** + * Every element of a {@code CREATE TABLE} list contributes its columns in + * the written order, as PostgreSQL creates them, so a {@code LIKE} may + * stand before or after a column definition and beside another + * {@code LIKE}. + */ + @Test + void shouldModelTheColumnsOfAnElementListInWrittenOrder() { + Schema source = parser.parse(SOURCE_SCHEMA); + + assertEquals( + List.of("id", "tenant", "payload", "note"), + columnNames( + parser.parse(source, "CREATE TABLE c (LIKE events, note TEXT);"), + "c" + ) + ); + + assertEquals( + List.of("note", "id", "tenant", "payload"), + columnNames( + parser.parse(source, "CREATE TABLE c (note TEXT, LIKE events);"), + "c" + ) + ); + + assertEquals( + List.of("label", "id", "tenant", "payload"), + columnNames( + parser.parse(source, "CREATE TABLE c (LIKE tags, LIKE events);"), + "c" + ) + ); + } + + /** + * A {@code LIKE} records the table's own table constraints, like any other + * {@code CREATE TABLE}, and never its source's recorded constraints. + */ + @Test + void shouldRecordOnlyTheOwnConstraintsOfALikeTable() { + Schema schema = parser.parse( + parser.parse(SOURCE_SCHEMA), + "CREATE TABLE events_copy (LIKE events INCLUDING ALL, UNIQUE (id));" + ); + + assertEquals( + List.of( + new Constraint(ConstraintType.PRIMARY_KEY, List.of("id")), + new Constraint(ConstraintType.UNIQUE, List.of("tenant", "payload")) + ), + table(schema, "events").constraints() + ); + + assertEquals( + List.of(new Constraint(ConstraintType.UNIQUE, List.of("id"))), + table(schema, "events_copy").constraints() + ); + } + + /** + * A column name a {@code LIKE} repeats fails, as PostgreSQL rejects it, + * whether it is repeated by a column definition or by a second + * {@code LIKE}. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "CREATE TABLE c (LIKE events, tenant TEXT);" + + "|Column already exists in table c: tenant", + "CREATE TABLE c (tenant TEXT, LIKE events);" + + "|Column already exists in table c: tenant", + "CREATE TABLE c (LIKE events, LIKE events);" + + "|Column already exists in table c: id" + } + ) + void shouldReportARepeatedColumnNameOfALikeSource(String statement, String message) { + Schema schema = parser.parse(SOURCE_SCHEMA); + + assertEquals( + message, + assertThrows( + IllegalArgumentException.class, + () -> parser.parse(schema, statement) + ) + .getMessage() + ); + } + + /** + * A {@code LIKE} or {@code PARTITION OF} source sqlcj does not model fails + * as a missing table, because the created table's columns would otherwise + * be unknown. + */ + @ParameterizedTest + @ValueSource( + strings = { + "CREATE TABLE c (LIKE archive);", + "CREATE TABLE c PARTITION OF archive DEFAULT;" + } + ) + void shouldReportTheMissingSourceOfACreatedTable(String statement) { + Schema schema = parser.parse(SOURCE_SCHEMA); + + assertEquals( + "Table not found in schema: archive", + assertThrows( + IllegalArgumentException.class, + () -> parser.parse(schema, statement) + ) + .getMessage() + ); + } + + /** + * A {@code PARTITION OF} copies its parent's columns, whatever bound it + * states, and records its parent, which it keeps following. + */ + @ParameterizedTest + @ValueSource( + strings = { + "CREATE TABLE events_2026 PARTITION OF events FOR VALUES FROM (1) TO (100);", + "CREATE TABLE events_2026 PARTITION OF events FOR VALUES IN ('a', 'b');", + "CREATE TABLE events_2026 PARTITION OF events " + + "FOR VALUES WITH (MODULUS 4, REMAINDER 0);", + "CREATE TABLE events_2026 PARTITION OF events DEFAULT;", + "CREATE TABLE events_2026 PARTITION OF public.events DEFAULT;" + } + ) + void shouldCopyTheColumnsOfAPartitionParent(String statement) { + Table partition = table( + parser.parse(parser.parse(SOURCE_SCHEMA), statement), + "events_2026" + ); + + assertEquals(EVENT_COLUMNS, partition.columns()); + assertEquals(List.of(), partition.constraints()); + assertEquals("events", partition.partitionOf()); + } + + /** + * The table constraints a {@code PARTITION OF} may state are recorded as in + * any other {@code CREATE TABLE}, beside the columns it copies. + */ + @Test + void shouldRecordTheTableConstraintsOfAPartition() { + Table partition = table( + parser.parse( + parser.parse(SOURCE_SCHEMA), + "CREATE TABLE events_2026 PARTITION OF events (PRIMARY KEY (id)) " + + "FOR VALUES FROM (1) TO (100);" + ), + "events_2026" + ); + + assertEquals(EVENT_COLUMNS, partition.columns()); + + assertEquals( + List.of(new Constraint(ConstraintType.PRIMARY_KEY, List.of("id"))), + partition.constraints() + ); + + assertEquals("events", partition.partitionOf()); + } + + /** An empty element list is a table without columns. */ + @ParameterizedTest + @ValueSource( + strings = { + "CREATE TABLE marker ();", + "CREATE TABLE marker ( );" + } + ) + void shouldModelAnEmptyElementListAsATableWithoutColumns(String statement) { + Table marker = table(parser.parse(parser.parse(SOURCE_SCHEMA), statement), "marker"); + + assertEquals(List.of(), marker.columns()); + assertEquals(List.of(), marker.constraints()); + assertNull(marker.partitionOf()); + } + + /** + * A {@code CREATE TABLE} whose columns sqlcj cannot determine is rejected + * naming the clause and the line its own statement begins on. A statement + * with neither an element list, {@code ()}, nor {@code PARTITION OF} states + * no columns at all and is rejected as unsupported table DDL. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "CREATE TABLE summary AS SELECT id FROM events;" + + "|Unsupported CREATE TABLE clause: AS SELECT at line 3", + "CREATE TABLE typed OF event_type;" + + "|Unsupported CREATE TABLE clause: OF event_type at line 3", + "CREATE TABLE typed OF public.event_type;" + + "|Unsupported CREATE TABLE clause: OF public.event_type at line 3", + "CREATE TABLE child (id BIGINT) INHERITS (events);" + + "|Unsupported CREATE TABLE clause: INHERITS at line 3", + "create table child (id bigint) inherits (events);" + + "|Unsupported CREATE TABLE clause: INHERITS at line 3", + "CREATE TABLE events_copy LIKE events;" + + "|Unsupported schema statement: CREATE TABLE at line 3" + } + ) + void shouldRejectTheCreateTableClausesWhoseColumnsItCannotDetermine( + String statement, + String message + ) { + Schema schema = parser.parse(SOURCE_SCHEMA); + + assertEquals( + message, + assertThrows( + UnsupportedOperationException.class, + () -> parser.parse( + schema, + "CREATE INDEX events_tenant_idx ON events (tenant);\n\n" + statement + "\n" + ) + ) + .getMessage() + ); + } + + /** + * PostgreSQL applies every column change of a partitioned table to its + * partitions, so each modeled column action of a parent reaches its + * partition, which keeps the columns its parent has. + */ + @Test + void shouldApplyEveryColumnActionOfAParentToItsPartition() { + Schema schema = partitionedSchema(); + + schema = parser.parse(schema, "ALTER TABLE events ADD COLUMN note TEXT;"); + + assertEquals( + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("tenant", ColumnType.VARCHAR, false), + new Column("payload", ColumnType.TEXT, true), + new Column("note", ColumnType.TEXT, true) + ), + table(schema, "events_2026").columns() + ); + + for ( + String statement : List.of( + "ALTER TABLE events DROP COLUMN payload;", + "ALTER TABLE events RENAME COLUMN note TO remark;", + "ALTER TABLE events ALTER COLUMN remark TYPE VARCHAR(16);", + "ALTER TABLE events ALTER COLUMN remark SET NOT NULL;", + "ALTER TABLE events ALTER COLUMN tenant DROP NOT NULL;" + ) + ) { + schema = parser.parse(schema, statement); + + assertEquals( + table(schema, "events").columns(), + table(schema, "events_2026").columns(), + statement + ); + } + + assertEquals( + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("tenant", ColumnType.VARCHAR, true), + new Column("remark", ColumnType.VARCHAR, false) + ), + table(schema, "events_2026").columns() + ); + } + + /** A column action of a parent reaches the partitions of its partitions. */ + @Test + void shouldApplyAColumnActionToANestedPartition() { + Schema schema = parser.parse( + partitionedSchema(), + "CREATE TABLE events_2026_01 PARTITION OF events_2026 FOR VALUES FROM (1) TO (10);" + ); + + assertEquals("events_2026", table(schema, "events_2026_01").partitionOf()); + + schema = parser.parse(schema, "ALTER TABLE events ADD COLUMN note TEXT;"); + + assertEquals( + List.of("id", "tenant", "payload", "note"), + columnNames(schema, "events_2026_01") + ); + } + + /** + * Renaming a partitioned table keeps its partitions, which name the new + * name from then on and keep following their parent's column changes. + */ + @Test + void shouldKeepThePartitionLinkThroughARenameOfTheParent() { + Schema schema = parser.parse( + partitionedSchema(), + "ALTER TABLE events RENAME TO event_log;" + ); + + assertEquals(List.of("tags", "event_log", "events_2026"), tableNames(schema)); + assertEquals("event_log", table(schema, "events_2026").partitionOf()); + + schema = parser.parse(schema, "ALTER TABLE event_log ADD COLUMN note TEXT;"); + + assertEquals( + List.of("id", "tenant", "payload", "note"), + columnNames(schema, "events_2026") + ); + } + + /** + * Dropping a partitioned table removes its partitions, recursively, as + * PostgreSQL does, and keeps the order of the remaining tables. + */ + @Test + void shouldDropThePartitionsOfADroppedParent() { + Schema schema = parser.parse( + partitionedSchema(), + "CREATE TABLE events_2026_01 PARTITION OF events_2026 FOR VALUES FROM (1) TO (10);" + ); + + assertEquals( + List.of("tags"), + tableNames(parser.parse(schema, "DROP TABLE events;")) + ); + } + + /** + * PostgreSQL resolves every name of one {@code DROP TABLE} before deleting + * any of them, so naming a partitioned table before one of its partitions, + * however that partition was linked and however deep it stands, removes + * both instead of failing on the partition the parent's drop already + * removed. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "CREATE TABLE events_2026 PARTITION OF events DEFAULT;" + + "|DROP TABLE events, events_2026;", + "CREATE TABLE events_2026 (id BIGINT NOT NULL);" + + " ALTER TABLE events ATTACH PARTITION events_2026 DEFAULT;" + + "|DROP TABLE events, events_2026;", + "CREATE TABLE events_2026 PARTITION OF events DEFAULT;" + + " CREATE TABLE events_2026_01 PARTITION OF events_2026 DEFAULT;" + + "|DROP TABLE events, events_2026_01;" + } + ) + void shouldDropAParentNamedBeforeItsPartition(String partitions, String statement) { + Schema schema = parser.parse(parser.parse(SOURCE_SCHEMA), partitions); + + assertEquals( + List.of("tags"), + tableNames(parser.parse(schema, statement)) + ); + } + + /** + * {@code DETACH PARTITION} of a modeled partition ends the link, so the + * parent's later column changes no longer reach it. + */ + @Test + void shouldStopPropagationAfterDetachPartition() { + Schema schema = parser.parse( + partitionedSchema(), + "ALTER TABLE events DETACH PARTITION events_2026;" + ); + + assertNull(table(schema, "events_2026").partitionOf()); + + schema = parser.parse(schema, "ALTER TABLE events ADD COLUMN note TEXT;"); + + assertEquals(EVENT_COLUMNS, table(schema, "events_2026").columns()); + } + + /** + * {@code ATTACH PARTITION} of a modeled table starts the link, so the + * parent's later column changes reach it, whether the partition is named + * unqualified or qualified with {@code public}. + */ + @ParameterizedTest + @ValueSource( + strings = { + "ALTER TABLE events ATTACH PARTITION events_2026 FOR VALUES FROM (1) TO (100);", + "ALTER TABLE events ATTACH PARTITION public.events_2026 DEFAULT;" + } + ) + void shouldStartPropagationAfterAttachPartition(String statement) { + Schema schema = parser.parse( + parser.parse(SOURCE_SCHEMA), + """ + CREATE TABLE events_2026 ( + id BIGINT NOT NULL, + tenant VARCHAR(64) NOT NULL, + payload TEXT + ); + """ + ); + + schema = parser.parse(schema, statement); + + assertEquals("events", table(schema, "events_2026").partitionOf()); + + schema = parser.parse(schema, "ALTER TABLE events ADD COLUMN note TEXT;"); + + assertEquals( + List.of("id", "tenant", "payload", "note"), + columnNames(schema, "events_2026") + ); + } + + /** + * A {@code LIKE} copy is independent of its source afterwards, as in + * PostgreSQL, so the source's later column changes do not reach it. + */ + @Test + void shouldNotFollowTheSourceOfALikeCopy() { + Schema schema = parser.parse( + parser.parse(SOURCE_SCHEMA), + "CREATE TABLE events_copy (LIKE events);" + ); + + schema = parser.parse(schema, "ALTER TABLE events ADD COLUMN note TEXT;"); + + assertEquals(EVENT_COLUMNS, table(schema, "events_copy").columns()); + assertNull(table(schema, "events_copy").partitionOf()); + } + + /** + * Until sqlcj models namespaces, a table name qualified with a schema other + * than {@code public} names a table it leaves unmodeled, so a + * {@code LIKE} or {@code PARTITION OF} source and an {@code ATTACH} or + * {@code DETACH PARTITION} partition written that way fails as a missing + * table rather than resolving to the modeled table of the same unqualified + * name. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "CREATE TABLE c (LIKE reporting.events);" + + "|Table not found in schema: reporting.events", + "CREATE TABLE events_2026 PARTITION OF reporting.events DEFAULT;" + + "|Table not found in schema: reporting.events", + "ALTER TABLE events ATTACH PARTITION reporting.events_2026 DEFAULT;" + + "|Table not found in schema: reporting.events_2026", + "ALTER TABLE events DETACH PARTITION reporting.events_2026;" + + "|Table not found in schema: reporting.events_2026" + } + ) + void shouldRejectASourceOrPartitionOfAnotherSchema(String statement, String message) { + Schema schema = parser.parse(SOURCE_SCHEMA); + + assertEquals( + message, + assertThrows( + IllegalArgumentException.class, + () -> parser.parse(schema, statement) + ) + .getMessage() + ); + } + + /** The schema whose {@code events} table the copying tests read. */ + private Schema partitionedSchema() { + return parser.parse( + parser.parse(SOURCE_SCHEMA), + "CREATE TABLE events_2026 PARTITION OF events FOR VALUES FROM (1) TO (100);" + ); + } + + private List columnNames(Schema schema, String tableName) { + return table(schema, tableName).columns().stream() + .map(Column::name) + .toList(); + } + /** A schema declaring the enum type the enum tests apply statements to. */ private Schema enumSchema() { return parser.parse("CREATE TYPE stage_setting AS ENUM ('indoor', 'outdoor');");