diff --git a/docs/postgresql.md b/docs/postgresql.md index 66d4835..cdedc1b 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -93,8 +93,9 @@ file, a list of files, or a directory of `.sql` migration files; see order the files are read in. - Only `CREATE TABLE`, `DROP TABLE`, and the `ALTER TABLE` forms listed in - [Ordered Table DDL](#ordered-table-ddl) are accepted. Any other statement in a - schema file is rejected. + [Ordered Table DDL](#ordered-table-ddl) update the schema model. The + statements listed in [Ignored Statements](#ignored-statements) are accepted + and leave it unchanged. Any other statement in a schema file is rejected. - The statements of the schema files of one entry are applied in order to one schema model, so each of them sees the tables and columns the statements and files before it left. @@ -152,7 +153,7 @@ selected-column list. | Column-level `CHECK (...)` | Accepted and ignored. | | Table-level `FOREIGN KEY (...) REFERENCES ...`, named or unnamed | Accepted and ignored. | | Table-level `CHECK (...)`, named or unnamed | Accepted and ignored. | -| Any statement outside [Ordered Table DDL](#ordered-table-ddl), such as `CREATE VIEW` | Rejected. | +| Any statement outside [Ordered Table DDL](#ordered-table-ddl) and [Ignored Statements](#ignored-statements), such as `CREATE VIEW` | Rejected. | | Any other table-constraint kind | Rejected. | | Unparsable SQL | Rejected. | @@ -184,8 +185,8 @@ statements and files before them left: | `ALTER TABLE ... ALTER COLUMN ... SET NOT NULL` | Models the column non-null. | | `ALTER TABLE ... ALTER COLUMN ... DROP NOT NULL` | Models the column nullable. | | `ALTER TABLE IF EXISTS ...` | Does nothing when the table is not modeled. It covers only the table, so a missing column of a modeled table still fails. | -| Any other `ALTER TABLE` action, such as `SET DEFAULT` or `ADD CONSTRAINT` | Rejected. | -| `DROP` of anything other than a table | Rejected. | +| Any other `ALTER TABLE` action outside [Ignored Statements](#ignored-statements), such as `SET DEFAULT` | Rejected. | +| `DROP` of an object that is neither a table nor listed in [Ignored Statements](#ignored-statements), such as `DROP VIEW` | Rejected. | 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 @@ -202,6 +203,64 @@ sqlcj: Invalid schema source /home/dev/project/sql/migrations/V2__orders.sql: Ta sqlcj: Invalid schema source /home/dev/project/sql/migrations/V2__orders.sql: Column already exists in table orders: total ``` +## Ignored Statements + +A schema file records the whole history of a database, not only its tables, so +these statements are accepted and leave the schema model exactly as the +statements and files before them left it: + +| Statement | Notes | +| --- | --- | +| `CREATE INDEX`, `CREATE UNIQUE INDEX` | | +| `ALTER INDEX` | | +| `DROP INDEX`, `DROP INDEX IF EXISTS` | | +| `COMMENT ON TABLE`, `COMMENT ON COLUMN`, `COMMENT ON VIEW` | These are the only `COMMENT ON` targets sqlcj's parser reads; see below. | +| `CREATE EXTENSION`, `CREATE EXTENSION IF NOT EXISTS` | | +| `CREATE SEQUENCE`, `ALTER SEQUENCE`, `DROP SEQUENCE` | | +| `GRANT`, `REVOKE` | | +| `CREATE FUNCTION`, `CREATE OR REPLACE FUNCTION` | Only with a body delimited by the untagged `$$ ... $$`, such as `AS $$ ... $$`; see below. | +| `DROP FUNCTION`, `DROP FUNCTION IF EXISTS` | | +| `CREATE TRIGGER`, `DROP TRIGGER` | | +| `INSERT`, `UPDATE`, `DELETE` | A migration's data statements do not change the modeled tables. | +| `CREATE TYPE ... AS ENUM` | The type is not modeled, so a column of it is recorded as an unsupported type. | +| `ALTER TABLE ... ADD [CONSTRAINT name] PRIMARY KEY (...)` | Named or unnamed. | +| `ALTER TABLE ... ADD [CONSTRAINT name] UNIQUE (...)` | Named or unnamed. | +| `ALTER TABLE ... ADD [CONSTRAINT name] FOREIGN KEY (...) REFERENCES ...` | Named or unnamed. | +| `ALTER TABLE ... ADD CONSTRAINT name CHECK (...)` | The unnamed `ADD CHECK (...)` spelling is a syntax error for sqlcj's parser; see below. | +| `ALTER TABLE ... DROP CONSTRAINT [IF EXISTS] name` | | +| `ALTER TABLE ... RENAME CONSTRAINT` | | + +A constraint an `ALTER TABLE` adds is not recorded, so it is not carried into +the schema model the way a `CREATE TABLE` constraint is. + +An ignored statement is not resolved against the schema at all, so it may name +a table or a column the snapshot does not model. An ignored `ALTER TABLE` +action is the one exception: the statement still resolves its table, so +`ALTER TABLE payments ADD CONSTRAINT payments_pkey PRIMARY KEY (id)` fails when +`payments` is not modeled, exactly as any other `ALTER TABLE` of a missing +table does. An ignored action may stand alone or beside modeled actions of one +`ALTER TABLE`, so +`ALTER TABLE users ADD COLUMN age INTEGER, ADD CONSTRAINT users_age_check CHECK (age > 0)` +appends `age` and records nothing for the constraint. + +Three limitations follow from what sqlcj's SQL parser reads, and sqlcj does not +split or pre-process the SQL to work around them: + +- A `CREATE FUNCTION` is ignored only when its body is delimited by the untagged + `$$ ... $$`, which is the only dollar-quote delimiter sqlcj's parser reads. A + tagged delimiter such as `$body$ ... $body$` is not read at all: it is a + syntax error, reported for the closing delimiter, as in + `Encountered unexpected token: "$body$" at line 1, column 74`. A body written + any other way, such as `AS 'SELECT 1' LANGUAGE sql`, is captured through the + end of the file, so such a function is rejected at the line it begins on and + nothing after it is applied. +- `COMMENT ON` is read only for the `TABLE`, `COLUMN`, and `VIEW` targets. Any + other target, such as `COMMENT ON TYPE`, is a syntax error rather than an + ignored statement. +- `ALTER TABLE ... ADD CHECK (...)` without a constraint name is a syntax + error. The named `ADD CONSTRAINT name CHECK (...)` spelling is ignored as + listed above. + ## Nulls Every generated method parameter and every generated result component uses a @@ -289,13 +348,13 @@ written by an earlier successful run is left unchanged. The writing step itself is sequential rather than atomic; see [Generated Output and Failures](configuration.md#generated-output-and-failures). -A schema message names the schema source and the offending statement, the table -or column the statement refers to, or the syntax error with the line and column -it was found at. A query message names the +A schema message names the schema source and the offending statement with the +line it begins on, the table or column the statement refers to, or the syntax +error with the line and column it was found at. A query message names the query, its source, its header line, and the offending column and recorded type: ```text -sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported schema statement: CreateView +sqlcj: Invalid schema source /home/dev/project/schema.sql: Unsupported schema statement: CreateView at line 12 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 VARCHAR[] ``` @@ -381,6 +440,33 @@ Ordered table DDL: covers the file-naming diagnostic of a missing reference and that no file is written. +Ignored statements: + +- `DefaultSchemaParserTest.shouldIgnoreDocumentedStatements` covers one spelling + per listed statement, including the lower-case `ALTER INDEX` form and + `CREATE OR REPLACE FUNCTION ... $$ ... $$ LANGUAGE plpgsql`, and + `DefaultSchemaParserTest.shouldIgnoreConstraintAlterTableActions` covers the + named and unnamed constraint actions. +- `DefaultSchemaParserTest.shouldNotResolveAnIgnoredStatementAgainstTheSchema` + covers that an ignored statement may name an unmodeled table or column, and + `DefaultSchemaParserTest.shouldReportTheMissingTableOfAnIgnoredConstraintAction` + covers that an ignored `ALTER TABLE` action still resolves its table. +- `DefaultSchemaParserTest.shouldApplyAModeledActionBesideAnIgnoredConstraintAction` + covers an ignored action beside a modeled one. +- `DefaultSchemaParserTest.shouldRejectStatementOutsideTheIgnoredList` covers the + kind and the line of each rejected statement, including the single-quoted + function body, and + `DefaultSchemaParserTest.shouldRejectUnsupportedSchemaStatement` and + `DefaultSchemaParserTest.shouldReportTheLineOfARejectedStatementAfterCommentsAndAFunctionBody` + cover the reported line after comments that contain a statement separator and + after a multi-line dollar-quoted body. +- `DefaultSchemaParserTest.shouldRejectASingleQuotedFunctionBodyThatCapturesALaterDollarQuotedBody` + covers that a single-quoted body is rejected at its own line even when it + captures a later dollar-quoted body. +- `SqlcjCompilerIntegrationTest.shouldReportTheMigrationFileAndLineOfAnUnsupportedStatement` + covers the migration file and line of a rejected statement that follows an + ignored one, and that no file is written. + Nulls: - `PostgresIntegrationTest.shouldBindAndReadNullValuesThroughGeneratedCode` 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 3b399f5..43e2114 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 @@ -8,23 +8,43 @@ import dev.sqlcj.schema.Table; import dev.sqlcj.sql.SqlParseReason; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserConstants; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Token; import net.sf.jsqlparser.schema.MultiPartName; 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.AlterOperation; +import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; +import net.sf.jsqlparser.statement.comment.Comment; +import net.sf.jsqlparser.statement.create.extension.CreateExtension; +import net.sf.jsqlparser.statement.create.function.CreateFunction; +import net.sf.jsqlparser.statement.create.index.CreateIndex; +import net.sf.jsqlparser.statement.create.sequence.CreateSequence; import net.sf.jsqlparser.statement.create.table.CheckConstraint; import net.sf.jsqlparser.statement.create.table.ColumnDefinition; import net.sf.jsqlparser.statement.create.table.CreateTable; import net.sf.jsqlparser.statement.create.table.ForeignKeyIndex; import net.sf.jsqlparser.statement.create.table.Index; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; +import net.sf.jsqlparser.statement.create.type.CreateType; +import net.sf.jsqlparser.statement.create.type.EnumTypeDefinition; +import net.sf.jsqlparser.statement.delete.Delete; import net.sf.jsqlparser.statement.drop.Drop; +import net.sf.jsqlparser.statement.grant.Grant; +import net.sf.jsqlparser.statement.grant.Revoke; +import net.sf.jsqlparser.statement.insert.Insert; +import net.sf.jsqlparser.statement.update.Update; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; public class DefaultSchemaParser implements SchemaParser { @@ -32,15 +52,30 @@ public class DefaultSchemaParser implements SchemaParser { /** One parenthesized type argument group, such as {@code (10, 2)}. */ private static final Pattern TYPE_ARGUMENTS = Pattern.compile("\\([^)]*\\)"); + /** + * The opening words of an {@code ALTER INDEX} statement, which the parser + * reports only as an opaque unsupported statement. + */ + private static final Pattern ALTER_INDEX = Pattern.compile( + "^ALTER\\s+INDEX\\b", + Pattern.CASE_INSENSITIVE + ); + @Override public Schema parse(Schema schema, String sql) { + AtomicReference parser = new AtomicReference<>(); + try { - Statements statements = CCJSqlParserUtil.parseStatements(sql); + Statements statements = CCJSqlParserUtil.parseStatements(sql, parser::set); List tables = new ArrayList<>(schema.tables()); - for (Statement statement : statements) { - apply(statement, tables); + if (!statements.isEmpty()) { + List lines = statementLines(parser.get()); + + for (int i = 0; i < statements.size(); i++) { + apply(statements.get(i), statementLine(lines, i), tables); + } } return new Schema(tables); @@ -49,19 +84,125 @@ public Schema parse(Schema schema, String sql) { } } + /** + * The line each statement of one parsed source begins on, in order. + * + *

The parser reports no position per statement, so the lines are read + * from the token chain of its parse tree: the first token, and the first + * token after each statement separator, begins a statement. A comment is a + * special token outside the chain and a dollar-quoted function body is one + * literal token, so neither contributes a separator. + */ + private List statementLines(CCJSqlParser parser) { + List lines = new ArrayList<>(); + + Token token = parser.getASTRoot().jjtGetFirstToken(); + boolean starting = true; + + while (token != null && token.kind != CCJSqlParserConstants.EOF) { + if (token.kind == CCJSqlParserConstants.ST_SEMICOLON) { + starting = true; + } else { + if (starting) { + lines.add(token.beginLine); + } + + starting = false; + } + + token = token.next; + } + + return lines; + } + + /** + * The line of the statement at {@code index}, clamped to the last line + * found. The clamp only covers a source whose statements are not separated + * by semicolons, which PostgreSQL itself rejects. + */ + private int statementLine(List lines, int index) { + return lines.get(Math.min(index, lines.size() - 1)); + } + /** Applies one statement to the tables the statements before it left. */ - private void apply(Statement statement, List

tables) { + private void apply(Statement statement, int line, List
tables) { if (statement instanceof CreateTable createTable) { applyCreateTable(createTable, tables); } else if (statement instanceof Alter alter) { - applyAlterTable(alter, tables); + applyAlterTable(alter, line, tables); } else if (statement instanceof Drop drop && drop.getObjectType() == Drop.ObjectType.TABLE) { applyDropTable(drop, tables); - } else { - throw unsupportedStatement(statement); + } else if (!isIgnored(statement)) { + throw unsupportedStatement(statement, line); } } + /** + * Reports whether a statement is one of the documented statements a schema + * source may state without changing the schema model. Such a statement is + * not resolved against the schema at all. + */ + private boolean isIgnored(Statement statement) { + if (statement instanceof Drop drop) { + return isIgnoredDropObject(drop); + } + + if (statement instanceof CreateFunction createFunction) { + return hasDollarQuotedBody(createFunction); + } + + if (statement instanceof CreateType createType) { + return createType.getDefinition() instanceof EnumTypeDefinition; + } + + if (statement instanceof UnsupportedStatement unsupported) { + return ALTER_INDEX.matcher(unsupported.toString()).find(); + } + + return statement instanceof CreateIndex + || statement instanceof Comment + || statement instanceof CreateExtension + || statement instanceof CreateSequence + || statement instanceof AlterSequence + || statement instanceof Grant + || statement instanceof Revoke + || statement instanceof CreateTrigger + || statement instanceof Insert + || statement instanceof Update + || statement instanceof Delete; + } + + /** + * A dropped table is modeled and the listed dropped objects are ignored; + * any other dropped object, such as a view, is rejected. + */ + private boolean isIgnoredDropObject(Drop drop) { + return switch (drop.getObjectType()) { + case INDEX, SEQUENCE, FUNCTION, TRIGGER -> true; + default -> false; + }; + } + + /** + * Reports whether a {@code CREATE FUNCTION} states a dollar-quoted body, + * which the parser lexes as one literal token. It captures a body written + * any other way through the end of the source, so such a function is + * rejected instead of ignored. + * + *

A captured statement separator and everything after it is part of a + * later statement rather than of this function, so only the parts before the + * first {@code ";"} part decide. + */ + private boolean hasDollarQuotedBody(CreateFunction createFunction) { + List parts = createFunction.getFunctionDeclarationParts(); + + return parts != null + && parts.stream() + .takeWhile(part -> !";".equals(part)) + .anyMatch(part -> StringValue.getDollarQuoteDelimiter(part) != null); + } + /** * Appends one table. A repeated table name fails, as PostgreSQL rejects it, * unless the statement declares {@code IF NOT EXISTS}. @@ -104,7 +245,7 @@ private void applyDropTable(Drop drop, List

tables) { * of the altered table. {@code ALTER TABLE IF EXISTS} covers only the table, * so a missing column of an existing table still fails. */ - private void applyAlterTable(Alter alter, List
tables) { + private void applyAlterTable(Alter alter, int line, List
tables) { String tableName = alter.getTable().getUnquotedName(); int index = indexOfTable(tables, tableName); @@ -118,18 +259,19 @@ private void applyAlterTable(Alter alter, List
tables) { } for (AlterExpression expression : alter.getAlterExpressions()) { - tables.set(index, applyAlterExpression(alter, expression, tables, index)); + tables.set(index, applyAlterExpression(alter, line, expression, tables, index)); } } /** - * Applies one {@code ALTER TABLE} action and returns the altered table. An - * action that cannot be recognized as one of the supported forms, such as - * {@code SET DEFAULT} or {@code ADD CONSTRAINT}, is rejected like any other - * unsupported statement. + * Applies one {@code ALTER TABLE} action and returns the altered table. A + * constraint action is accepted and leaves the table unchanged; an action + * that cannot be recognized as one of those forms, such as + * {@code SET DEFAULT}, is rejected like any other unsupported statement. */ private Table applyAlterExpression( Alter alter, + int line, AlterExpression expression, List
tables, int index @@ -154,20 +296,58 @@ private Table applyAlterExpression( } if (operation == AlterOperation.ALTER) { - return alterColumns(alter, expression, table); + return alterColumns(alter, line, expression, table); + } + + if (isIgnoredConstraintAction(expression)) { + return table; + } + + throw unsupportedStatement(alter, line); + } + + /** + * Reports whether one {@code ALTER TABLE} action states a constraint sqlcj + * accepts without modeling it: adding a primary-key, unique, foreign-key, or + * check constraint, dropping a named constraint, or renaming one. The table + * itself is still resolved, so a missing table fails as it does for any + * other {@code ALTER TABLE}. + */ + private boolean isIgnoredConstraintAction(AlterExpression expression) { + AlterOperation operation = expression.getOperation(); + + if (operation == AlterOperation.ADD) { + return !isNotEmpty(expression.getColDataTypeList()) + && isIgnoredConstraintKind(expression.getIndex()); } - throw unsupportedStatement(alter); + if (operation == AlterOperation.DROP) { + return expression.getColumnName() == null + && expression.getConstraintName() != null; + } + + return operation == AlterOperation.RENAME_CONSTRAINT; + } + + private boolean isIgnoredConstraintKind(Index index) { + if (index == null) { + return false; + } + + return switch (index.getKind()) { + case PRIMARY_KEY, UNIQUE, FOREIGN_KEY, CHECK -> true; + default -> false; + }; } /** * Applies one {@code ALTER COLUMN} action: a new type, which keeps the * column's position and nullability, or a nullability change. */ - private Table alterColumns(Alter alter, AlterExpression expression, Table table) { + private Table alterColumns(Alter alter, int line, AlterExpression expression, Table table) { if (isNotEmpty(expression.getColDataTypeList())) { if (!statesNewTypes(expression.getColDataTypeList())) { - throw unsupportedStatement(alter); + throw unsupportedStatement(alter, line); } return changeColumnTypes(expression, table); @@ -193,7 +373,7 @@ private Table alterColumns(Alter alter, AlterExpression expression, Table table) ); } - throw unsupportedStatement(alter); + throw unsupportedStatement(alter, line); } /** @@ -437,9 +617,10 @@ private IllegalArgumentException columnAlreadyExists(String tableName, String co ); } - private UnsupportedOperationException unsupportedStatement(Statement statement) { + private UnsupportedOperationException unsupportedStatement(Statement statement, int line) { return new UnsupportedOperationException( - "Unsupported schema statement: " + statement.getClass().getSimpleName() + "Unsupported schema statement: %s at line %d" + .formatted(statement.getClass().getSimpleName(), line) ); } 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 4ad1000..96e5c68 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -2445,6 +2445,68 @@ void shouldReportTheMigrationFileThatAltersAMissingTable() throws IOException { assertFalse(Files.exists(generatedDirectory)); } + /** + * A migration whose ignored statements load names the file and the line of + * the first statement sqlcj does not accept. + */ + @Test + void shouldReportTheMigrationFileAndLineOfAnUnsupportedStatement() throws IOException { + Path migrations = Files.createDirectories(tempDir.resolve("migrations")); + + Files.writeString( + migrations.resolve("V1__users.sql"), + """ + CREATE TABLE users + ( + id BIGINT NOT NULL + ); + """ + ); + + Path unsupported = migrations.resolve("V2__views.sql"); + + Files.writeString( + unsupported, + """ + CREATE INDEX users_id_idx ON users (id); + + CREATE VIEW active_users AS + SELECT id + FROM users; + """ + ); + + Path queriesFile = tempDir.resolve("queries.sql"); + + Files.writeString( + queriesFile, + """ + -- name: ListUsers :many + SELECT id + FROM users; + """ + ); + + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> generateUsersRepository( + List.of(migrations.toString()), + queriesFile, + generatedDirectory + ) + ); + + assertEquals( + "Invalid schema source %s: ".formatted(unsupported) + + "Unsupported schema statement: CreateView at line 3", + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + /** A schema failure names the migration file that contains it. */ @Test void shouldReportTheMigrationFileThatFailsToParse() throws IOException { 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 fcfc703..48d62f9 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 @@ -546,10 +546,15 @@ CREATE TABLE users ( CREATE VIEW active_users AS SELECT id FROM users; """; - assertThrows( + UnsupportedOperationException exception = assertThrows( UnsupportedOperationException.class, () -> parser.parse(sql) ); + + assertEquals( + "Unsupported schema statement: CreateView at line 5", + exception.getMessage() + ); } /** @@ -566,7 +571,7 @@ void shouldRejectUnsupportedAlterTableAction() { ); assertEquals( - "Unsupported schema statement: Alter", + "Unsupported schema statement: Alter at line 1", exception.getMessage() ); } @@ -585,7 +590,7 @@ void shouldRejectAlterColumnSetStatistics() { ); assertEquals( - "Unsupported schema statement: Alter", + "Unsupported schema statement: Alter at line 1", exception.getMessage() ); } @@ -607,7 +612,224 @@ void shouldRejectAlterColumnAddIdentity() { ); assertEquals( - "Unsupported schema statement: Alter", + "Unsupported schema statement: Alter at line 1", + exception.getMessage() + ); + } + + /** + * Every statement of the documented ignored list is accepted and leaves the + * schema the statements before it left. + */ + @ParameterizedTest + @ValueSource( + strings = { + "CREATE INDEX users_email_idx ON users (email);", + "CREATE UNIQUE INDEX users_email_idx ON users (email);", + "ALTER INDEX users_email_idx RENAME TO users_mail_idx;", + "alter index users_email_idx rename to users_mail_idx;", + "DROP INDEX users_email_idx;", + "DROP INDEX IF EXISTS users_email_idx;", + "COMMENT ON TABLE users IS 'the users';", + "COMMENT ON COLUMN users.email IS 'the email';", + "COMMENT ON VIEW active_users IS 'the active users';", + "CREATE EXTENSION pgcrypto;", + "CREATE EXTENSION IF NOT EXISTS pgcrypto;", + "CREATE SEQUENCE users_id_seq;", + "ALTER SEQUENCE users_id_seq RESTART WITH 1;", + "DROP SEQUENCE users_id_seq;", + "GRANT SELECT ON users TO readonly;", + "REVOKE SELECT ON users FROM readonly;", + "CREATE OR REPLACE FUNCTION touch() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ " + + "LANGUAGE plpgsql;", + "DROP FUNCTION touch();", + "DROP FUNCTION IF EXISTS touch();", + "CREATE TRIGGER users_touch BEFORE UPDATE ON users FOR EACH ROW " + + "EXECUTE FUNCTION touch();", + "DROP TRIGGER users_touch ON users;", + "INSERT INTO users (id) VALUES (1);", + "UPDATE users SET name = 'new';", + "DELETE FROM users;", + "CREATE TYPE status AS ENUM ('draft', 'sent');" + } + ) + void shouldIgnoreDocumentedStatements(String statement) { + assertEquals(parser.parse(BASE_SCHEMA).tables(), applied(statement).tables()); + } + + /** + * An ignored statement is not resolved against the schema, so it names a + * table or a column the schema does not model without failing. + */ + @Test + void shouldNotResolveAnIgnoredStatementAgainstTheSchema() { + Schema schema = applied(""" + CREATE INDEX payments_total_idx ON payments (total); + COMMENT ON COLUMN users.nickname IS 'the nickname'; + INSERT INTO payments (total) VALUES (1); + """); + + assertEquals(parser.parse(BASE_SCHEMA).tables(), schema.tables()); + } + + /** + * An {@code ALTER TABLE} action that states a constraint is accepted and + * leaves the table unchanged, named or unnamed. + */ + @ParameterizedTest + @ValueSource( + strings = { + "ALTER TABLE users ADD CONSTRAINT users_pkey PRIMARY KEY (id);", + "ALTER TABLE users ADD PRIMARY KEY (id);", + "ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);", + "ALTER TABLE users ADD UNIQUE (email);", + "ALTER TABLE orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) " + + "REFERENCES users (id);", + "ALTER TABLE orders ADD FOREIGN KEY (user_id) REFERENCES users (id);", + "ALTER TABLE users ADD CONSTRAINT users_id_check CHECK (id > 0);", + "ALTER TABLE users DROP CONSTRAINT users_email_key;", + "ALTER TABLE users DROP CONSTRAINT IF EXISTS users_email_key;", + "ALTER TABLE users RENAME CONSTRAINT users_email_key TO users_mail_key;" + } + ) + void shouldIgnoreConstraintAlterTableActions(String statement) { + assertEquals(parser.parse(BASE_SCHEMA).tables(), applied(statement).tables()); + } + + /** + * An ignored constraint action still resolves its table, so an + * {@code ALTER TABLE} of a table the schema does not model fails. + */ + @Test + void shouldReportTheMissingTableOfAnIgnoredConstraintAction() { + assertEquals( + "Table not found in schema: payments", + failureMessage("ALTER TABLE payments ADD CONSTRAINT payments_pkey PRIMARY KEY (id);") + ); + } + + /** + * A modeled action beside an ignored constraint action of one + * {@code ALTER TABLE} is still applied. + */ + @Test + void shouldApplyAModeledActionBesideAnIgnoredConstraintAction() { + Schema schema = applied( + "ALTER TABLE users ADD COLUMN age INTEGER, " + + "ADD CONSTRAINT users_age_check CHECK (age > 0);" + ); + + Table users = table(schema, "users"); + + assertEquals( + List.of( + new Column("id", ColumnType.BIGINT, false), + new Column("email", ColumnType.VARCHAR, true), + new Column("name", ColumnType.VARCHAR, true), + new Column("age", ColumnType.INTEGER, true) + ), + users.columns() + ); + + assertEquals( + List.of(new Constraint(ConstraintType.UNIQUE, List.of("email"))), + users.constraints() + ); + } + + /** + * A statement outside the ignored list is rejected naming its kind and the + * line it begins on. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "CREATE VIEW active_users AS SELECT id FROM users;|CreateView", + "CREATE TYPE address AS (street TEXT, city TEXT);|CreateType", + "ALTER TYPE status ADD VALUE 'archived';|AlterType", + "CREATE DOMAIN positive AS INTEGER CHECK (VALUE > 0);|CreateDomain", + "CREATE SCHEMA app;|CreateSchema", + "DROP VIEW active_users;|Drop", + "SELECT id FROM users;|PlainSelect", + "ALTER TABLE users ALTER COLUMN name SET DEFAULT 'new';|Alter", + "CREATE FUNCTION one() RETURNS integer AS 'SELECT 1' LANGUAGE sql;|CreateFunction", + "ALTER FUNCTION touch() RENAME TO touched;|UnsupportedStatement" + } + ) + void shouldRejectStatementOutsideTheIgnoredList(String statement, String kind) { + Schema schema = parser.parse(BASE_SCHEMA); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> parser.parse(schema, statement) + ); + + assertEquals( + "Unsupported schema statement: %s at line 1".formatted(kind), + exception.getMessage() + ); + } + + /** + * A single-quoted function body captures the rest of the source, including a + * later dollar-quoted body, so the function is still rejected at the line it + * begins on and nothing after it is applied. + */ + @Test + void shouldRejectASingleQuotedFunctionBodyThatCapturesALaterDollarQuotedBody() { + String sql = """ + CREATE TABLE users (id BIGINT); + CREATE FUNCTION one() RETURNS integer AS 'SELECT 1' LANGUAGE sql; + CREATE TABLE later (id BIGINT); + DROP TABLE users; + CREATE FUNCTION touch() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql; + """; + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> parser.parse(sql) + ); + + assertEquals( + "Unsupported schema statement: CreateFunction at line 2", + exception.getMessage() + ); + } + + /** + * The reported line is the line the rejected statement itself begins on, + * even after comments that contain a statement separator and after a + * multi-line dollar-quoted function body. + */ + @Test + void shouldReportTheLineOfARejectedStatementAfterCommentsAndAFunctionBody() { + String sql = """ + -- a line comment with a ; separator + /* a block comment + with a ; separator */ + CREATE TABLE tags ( + id BIGINT NOT NULL + ); + + CREATE OR REPLACE FUNCTION touch() RETURNS trigger AS $$ + BEGIN + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + -- another ; separator + CREATE VIEW active_tags AS SELECT id FROM tags; + """; + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> parser.parse(sql) + ); + + assertEquals( + "Unsupported schema statement: CreateView at line 15", exception.getMessage() ); }