diff --git a/docs/queries.md b/docs/queries.md index 477d187..36ba5ea 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -77,7 +77,14 @@ configuration entry. ### Sources -- The `FROM` item must be a table of that schema, optionally with an alias. +- The `FROM` item must be a table of that schema, optionally with an alias. A + `LATERAL` source is rejected as `LATERAL is not supported.` and a subquery + source as `A subquery in FROM is not supported.` +- A `FROM` or `JOIN` source that names a `WITH` clause of its own query, which + in PostgreSQL reads that query and not the table of the same name, is rejected + as `A common table expression used as a source is not supported: ` + before the schema is consulted, so a `WITH` clause named like a table never + reads that table. - sqlcj models one namespace, so a source resolves by its unqualified name: `users`, `public.users`, and `"public"."users"` are one table, matched without SQL identifier delimiters and case-insensitively. A source qualified with any @@ -87,10 +94,21 @@ configuration entry. not followed; see [Schema Snapshot Input](postgresql.md#schema-snapshot-input). - A table may be joined with `JOIN`, `INNER JOIN`, `LEFT JOIN`, or - `LEFT OUTER JOIN`, and inner and left joins may be chained in any order. A - comma-separated source list and every other join modifier — `RIGHT`, `FULL`, a - bare `OUTER`, `CROSS`, `NATURAL`, `SEMI`, `APPLY`, `STRAIGHT`, `GLOBAL`, a - join hint, and `USING (...)` — are rejected. + `LEFT OUTER JOIN`, and inner and left joins may be chained in any order. +- Every other join is rejected, and a join that can be written as a supported + one says what to write: + - `FULL JOIN is not supported.` + - `RIGHT JOIN is not supported; write a LEFT JOIN with the two sources swapped.` + - `CROSS JOIN is not supported; write JOIN ... ON TRUE.` + - `NATURAL JOIN is not supported; write the join condition with ON.` + - `JOIN ... USING is not supported; write the join condition with ON.` + - `A comma-separated FROM list is not supported; write JOIN ... ON with the join condition.` + + A `LATERAL` or subquery join source is reported as the same unsupported + source as a `FROM` item of that kind, before the join kind, and every + remaining modifier — a bare `OUTER`, `SEMI`, `APPLY`, `STRAIGHT`, `GLOBAL`, + and a join hint — is rejected as + `Only unmodified INNER JOIN and LEFT JOIN clauses are supported.` - Each join requires exactly one `ON` condition, which may be any boolean expression, including `ON TRUE`. Its column references, qualified or not, resolve against the sources introduced up to and including the joined source, @@ -119,7 +137,10 @@ configuration entry. unknown column is rejected. - A result expression that is not a direct column, a wildcard, the scalar count below, or the cast projection below — such as another function call, an - arithmetic expression, or a literal — is rejected. + arithmetic expression, or a literal — is rejected as + `Cannot infer the type of in SELECT; state it with a cast and an + alias, such as SUM(amount)::numeric AS total`, naming the expression as the + query writes it, on one line. - An explicit alias names the result column, so `SELECT id AS author_id` generates the record component `authorId` while the component's type still comes from the column and its nullability from the column and its source. @@ -538,12 +559,18 @@ value is read back with its declared type. A clause that is exactly `*` returns the target table's shared `Row` record, while a `RETURNING` column list keeps the query's own `Result` record. -An aliased `*`, a computed `RETURNING` item with or without an alias, a -qualified `table.*`, an unknown column, and `RETURNING` on `:exec` are rejected. -Multi-row `VALUES`, `INSERT ... SELECT`, `UPDATE ... FROM`, `DELETE ... USING`, -and common table expressions are rejected in a returning write, while an -accepted `ON CONFLICT` clause is analyzed the same way with and without -`RETURNING`. +An aliased `*`, a qualified `table.*`, an unknown column, and `RETURNING` on +`:exec` are rejected, and a computed `RETURNING` item, with or without an alias, +is rejected as +`Unsupported RETURNING item ; return columns of the target table or *`, +naming the expression as the query writes it, on one line. A cast does not make +such an item supported, because a `RETURNING` clause returns columns of the +target table. + +Multi-row `VALUES`, `INSERT ... SELECT`, `UPDATE ... FROM`, and +`DELETE ... USING` are rejected in every write, and common table expressions in +a returning write, while an accepted `ON CONFLICT` clause is analyzed the same +way with and without `RETURNING`. ## Parameters @@ -869,20 +896,34 @@ the documented shapes are contract, tested, and safe to rely on. ### Rejected -Rejection stops the run with a diagnostic naming the query source, the query -name, and its header line. - +Rejection stops the run with one diagnostic line naming the query source, the +query name, and its header line. + +- A statement that is not a `SELECT`, an `INSERT`, an `UPDATE`, or a `DELETE`, + including `VALUES`, `TABLE`, `MERGE`, `TRUNCATE`, `EXPLAIN`, `EXECUTE`, a DDL + statement such as `CREATE TABLE`, and a `SET` statement, each rejected as + `Only SELECT, INSERT, UPDATE, and DELETE statements are supported.` before the + annotation is checked. A parenthesized `SELECT` is the query it encloses, at + any depth, so it is analyzed as that query. +- A set operation, `UNION`, `UNION ALL`, `INTERSECT`, or `EXCEPT`, written + directly or inside parentheses, rejected as + `UNION, INTERSECT, and EXCEPT are not supported; write one query per SELECT.` - A result expression that is not a direct column, a wildcard, an aliased `COUNT(*)`, or an aliased cast, including another function call, another aggregate, and a literal. A `COUNT(*)` without an alias, a cast projection without an alias, and a cast projection whose type sqlcj does not map each fail with their own diagnostic, while `COUNT(column)`, `COUNT(DISTINCT column)`, `COUNT(t.*)`, `pg_catalog.count(*)`, - `lower(name) AS n`, and the `FILTER` and `OVER` forms keep the - unsupported-expression rejection. -- A `FROM` item that is not a table, a comma-separated source list, a join that - is not a plain inner or left join, a join that does not carry exactly one - `ON` condition, and a set operation such as `UNION`. + `lower(name) AS n`, `name || 'x' AS n`, and the `FILTER` and `OVER` forms are + rejected as `Cannot infer the type of COUNT(*) OVER () in SELECT; state it + with a cast and an alias, such as SUM(amount)::numeric AS total`. +- A `FROM` or `JOIN` source that is not a table of the schema: a `LATERAL` + source, a subquery source, and a source that names a `WITH` clause of its own + query, each with the diagnostic [Sources](#sources) states. +- A join kind outside a plain inner or left join, each with the diagnostic + [Sources](#sources) states, which says what to write instead for `RIGHT`, + `CROSS`, `NATURAL`, `USING`, and a comma-separated `FROM` list, and a join + that does not carry exactly one `ON` condition. - An uncast placeholder in a location sqlcj does not analyze, including `ORDER BY $1`, a projection such as `SELECT $1 AS x`, a grouping list, a `HAVING` predicate, a subquery, a @@ -925,10 +966,9 @@ name, and its header line. placeholder, each of them also as the operand of a cast, such as `:"x"::text`, `&x::text`, or `:a.b::text`, one name whose occurrences have conflicting types, and non-contiguous or non-positive placeholder indexes. -- An `INSERT` without an explicit column list, with more than one `VALUES` row, - or built from a `SELECT`; an `UPDATE` assignment that sets a column list, such - as `SET (name, active) = ('a', TRUE)`; and a written column that the table does - not declare. +- An `INSERT` without an explicit column list; an `UPDATE` assignment that sets + a column list, such as `SET (name, active) = ('a', TRUE)`; and a written + column that the table does not declare. - A conflict clause outside the accepted upsert shape, on an `:exec` and on a returning insert alike: a `DO UPDATE` without a conflict target, which is rejected naming the two targets it accepts, and a target element that is an @@ -938,11 +978,27 @@ name, and its header line. unqualified conflict-predicate column that both the inserted table and `EXCLUDED` carry, or a predicate column qualified with a name its own predicate does not read, such as `EXCLUDED` in a conflict-target predicate. -- An aliased `*`, a computed, qualified-wildcard, or unknown `RETURNING` item; - `RETURNING` on `:exec`; and `UPDATE ... FROM`, `DELETE ... USING`, or a common - table expression in a returning write. +- An aliased `*`, a computed, qualified-wildcard, or unknown `RETURNING` item, + and `RETURNING` on `:exec`; a computed item is rejected as + [`RETURNING`](#returning) states. +- The shapes that read or write more than the one analyzed table, on an + `:exec` and on a returning write alike: an `INSERT` with more than one + `VALUES` row, rejected as `INSERT requires a single VALUES row.`; an + `INSERT ... SELECT`, rejected as `INSERT ... SELECT is not supported.`; an + `UPDATE ... FROM` or joined `UPDATE`, rejected as + `UPDATE ... FROM and joined updates are not supported.`; and a + `DELETE ... USING` or joined `DELETE`, rejected as + `DELETE ... USING and joined deletes are not supported.` A common table + expression in a returning write is rejected too. - Any annotation other than `:one`, `:optional`, `:many`, and `:exec`. +An unexpected failure of schema parsing, query analysis, or generation is not a +diagnostic of the compiled input: it is reported as one line that names the +step's own input and ends `; please report this sqlcj defect`, such as +`Internal error while analyzing query 'GetUser' in queries.sql at line 1; +please report this sqlcj defect`. The line carries neither the message nor the +class of the failure, because neither describes the compiled input. + ### Not analyzed These are outside the subset and are not part of the contract. sqlcj neither @@ -956,8 +1012,8 @@ them: `LIKE` with a literal pattern, a range whose bounds are both literal such as `id BETWEEN 1 AND 10`, `IN` with a subquery, or `= ANY` over something other than a placeholder, such as `id = ANY('{1,2}')`, -- common table expressions, and subqueries outside the `FROM` item, -- `UPDATE ... FROM` and `DELETE ... USING` on a non-returning `:exec` write, +- a common table expression whose name no source reads, and subqueries outside + the `FROM` item, - the unique index an accepted `ON CONFLICT` target matches, the constraint an `ON CONSTRAINT` target names, the column references of an `EXCLUDED` value that is not exactly `EXCLUDED.column`, and a conflict-predicate column that @@ -1274,6 +1330,41 @@ Writes and `RETURNING`: insert, a returning named insert, and an `:exec` update against PostgreSQL 16. +Unsupported queries and internal errors: + +- `SqlcjCompilerIntegrationTest.shouldRejectUnsupportedQueryBeforeWriting` + covers the whole located diagnostic line, and the absence of generated + output, of each rejected input: the set operations written directly and in + parentheses, the `VALUES`, `TABLE`, `MERGE`, `TRUNCATE`, `EXPLAIN`, + `EXECUTE`, `CREATE TABLE`, and `SET` statements, a window and a + concatenation projection, a computed `RETURNING` item, the `RIGHT`, `FULL`, + `USING`, `NATURAL`, `CROSS`, comma, and `LATERAL` joins, a `FROM` subquery, a + common table expression used as a source and one named like a schema table, + a multi-row `VALUES`, an `INSERT ... SELECT` on `:exec` and with + `RETURNING`, and an `:exec` `UPDATE ... FROM`, comma-joined `UPDATE`, + `DELETE ... USING`, and joined `DELETE`, while + `QueryAnalyzerTest.shouldRejectUnsupportedCountProjectionForm`, + `QueryAnalyzerTest.shouldRejectUnsupportedJoinModifier`, + `QueryAnalyzerTest.shouldRejectUnsupportedReturningItem`, + `QueryAnalyzerTest.shouldRejectExcludedReturningWriteForm`, and + `QueryAnalyzerTest.shouldRejectParenthesizedInsertQuerySource` cover the + projection, join, and write rejections per form, including the modifiers that + keep the general join rejection, and + `QueryAnalyzerTest.shouldAnalyzeParenthesizedSelectAsTheQueryItEncloses` and + `QueryAnalyzerTest.shouldAnalyzeInsertWhoseValuesRowIsParenthesized` cover + the doubly parenthesized read that analyzes like the query it encloses and + the parenthesized `VALUES` row that analyzes like the row it writes. +- `SqlcjCompilerIntegrationTest.shouldReportAnUnexpectedSchemaFailureAsAnInternalError`, + `SqlcjCompilerIntegrationTest.shouldReportAnUnexpectedAnalysisFailureAsAnInternalError`, + `SqlcjCompilerIntegrationTest.shouldReportAnUnexpectedGenerationFailureAsAnInternalError`, + and + `SqlcjCompilerIntegrationTest.shouldReportAnUnexpectedSharedGenerationFailureAsAnInternalError` + inject an unexpected failure into the schema, analysis, repository + generation, and shared row-record steps and cover the exact internal-error + line of each, that it carries neither the failure's message nor its class, + including a blank-message unsupported-operation failure and a + `NumberFormatException`, and that no file is written. + Parameters: - `QueryAnalyzerTest.shouldResolveBindingIndexesInTextualOrder`, diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java index 3629108..ded97cf 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -44,13 +44,18 @@ import net.sf.jsqlparser.statement.insert.InsertConflictTarget; import net.sf.jsqlparser.statement.select.AllColumns; import net.sf.jsqlparser.statement.select.AllTableColumns; +import net.sf.jsqlparser.statement.select.FromItem; import net.sf.jsqlparser.statement.select.Join; +import net.sf.jsqlparser.statement.select.LateralSubSelect; import net.sf.jsqlparser.statement.select.Limit; import net.sf.jsqlparser.statement.select.Offset; +import net.sf.jsqlparser.statement.select.ParenthesedSelect; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; import net.sf.jsqlparser.statement.select.SelectItem; +import net.sf.jsqlparser.statement.select.SetOperationList; import net.sf.jsqlparser.statement.select.Values; +import net.sf.jsqlparser.statement.select.WithItem; import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.update.UpdateSet; @@ -81,6 +86,48 @@ public final class QueryAnalyzer { private static final String MIXED_PLACEHOLDER_REJECTION = """ Indexed '$N' and named ':name' placeholders must not be mixed in one query"""; + /** The rejection of a statement kind outside the supported subset. */ + private static final String UNSUPPORTED_STATEMENT_REJECTION = """ + Only SELECT, INSERT, UPDATE, and DELETE statements are supported."""; + + /** The rejection of a set operation, which joins two queries into one. */ + private static final String SET_OPERATION_REJECTION = """ + UNION, INTERSECT, and EXCEPT are not supported; write one query per SELECT."""; + + /** + * The rejection of a projected expression that is neither a column, a + * wildcard, the scalar count, nor a cast, which is written as the query + * spells it. A cast states the type of such a projection, so the rejection + * names that fix. + */ + private static final String COMPUTED_PROJECTION_REJECTION = """ + Cannot infer the type of %s in SELECT; state it with a cast and an alias, \ + such as SUM(amount)::numeric AS total"""; + + /** + * The rejection of a returned item that is neither a column of the write + * target nor a bare {@code *}, which is written as the query spells it. A + * cast does not make such an item supported, so the rejection names the + * items that are. + */ + private static final String COMPUTED_RETURNING_REJECTION = """ + Unsupported RETURNING item %s; return columns of the target table or *"""; + + /** The rejection of a {@code LATERAL} source. */ + private static final String LATERAL_REJECTION = "LATERAL is not supported."; + + /** The rejection of a subquery source. */ + private static final String FROM_SUBQUERY_REJECTION = """ + A subquery in FROM is not supported."""; + + /** The rejection of a source that names a common table expression. */ + private static final String CTE_SOURCE_REJECTION = """ + A common table expression used as a source is not supported: %s"""; + + /** The rejection of an {@code INSERT} whose rows come from a query. */ + private static final String INSERT_SELECT_REJECTION = """ + INSERT ... SELECT is not supported."""; + /** * The rejection of a placeholder that no clause of the query types and no * cast states the type of, which names the placeholder as the query spells @@ -318,7 +365,7 @@ public QueryModel analyze(Query query, ParsedSql parsedSql, Schema schema) { Statement statement = parsedSql.statement(); if (statement instanceof Select select) { - return analyzeSelect(query, parsedSql, select, schema); + return analyzeSelectStatement(query, parsedSql, select, schema); } if (statement instanceof Insert insert) { @@ -333,18 +380,85 @@ public QueryModel analyze(Query query, ParsedSql parsedSql, Schema schema) { return analyzeDelete(query, parsedSql, delete, schema); } - throw new UnsupportedOperationException( - statement.getClass().getSimpleName() - ); + throw new UnsupportedOperationException(UNSUPPORTED_STATEMENT_REJECTION); + } + + /** + * Classifies a {@code SELECT} statement by the query it writes inside its + * parentheses, at any depth, so a parenthesized query is the query it + * encloses. A set operation and every other query form, such as a + * {@code VALUES} or {@code TABLE} statement, states no single supported + * query, so it is rejected before the annotation is checked. + */ + private QueryModel analyzeSelectStatement( + Query query, + ParsedSql parsedSql, + Select select, + Schema schema + ) { + List selects) { + List names = new ArrayList<>(); + + for (Select select : selects) { + List> withItems = select.getWithItemsList(); + + if (withItems == null) { + continue; + } + + for (WithItem withItem : withItems) { + names.add(withItem.getUnquotedAliasName()); + } + } + + return List.copyOf(names); } - private QueryModel analyzeSelect(Query query, ParsedSql parsedSql, Select select, Schema schema) { + private QueryModel analyzeSelect( + Query query, + ParsedSql parsedSql, + PlainSelect plainSelect, + List commonTableExpressions, + Schema schema + ) { requireResultQueryType(query); - PlainSelect plainSelect = select.getPlainSelect(); - Table table = getTable(plainSelect); + Table table = getTable(plainSelect, commonTableExpressions); - List sources = resolveSources(plainSelect, table, schema); + List sources = resolveSources(plainSelect, table, commonTableExpressions, schema); List columns = resolveColumns(plainSelect, parsedSql, sources, schema); @@ -366,6 +480,7 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert ReturningClause returningClause = insert.getReturningClause(); requireWriteQueryType(query, returningClause); + requireSupportedInsertSource(insert); Table table = insert.getTable(); Source source = toSource(table, schema); @@ -399,6 +514,7 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update ReturningClause returningClause = update.getReturningClause(); requireWriteQueryType(query, returningClause); + requireSupportedUpdateSources(update); Table table = update.getTable(); Source source = toSource(table, schema); @@ -407,7 +523,7 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update String rowTable = null; if (returningClause != null) { - requireSupportedReturningUpdate(update); + requireNoCommonTableExpressions(update.getWithItemsList()); columns = resolveReturningColumns(returningClause, source); rowTable = resolveReturningRowTable(returningClause, source); @@ -435,6 +551,7 @@ private QueryModel analyzeDelete(Query query, ParsedSql parsedSql, Delete delete ReturningClause returningClause = delete.getReturningClause(); requireWriteQueryType(query, returningClause); + requireSupportedDeleteSources(delete); Table table = delete.getTable(); Source source = toSource(table, schema); @@ -443,7 +560,7 @@ private QueryModel analyzeDelete(Query query, ParsedSql parsedSql, Delete delete String rowTable = null; if (returningClause != null) { - requireSupportedReturningDelete(delete); + requireNoCommonTableExpressions(delete.getWithItemsList()); columns = resolveReturningColumns(returningClause, source); rowTable = resolveReturningRowTable(returningClause, source); @@ -593,11 +710,22 @@ private List resolveReturningItem(Expression expression, Alias alia } throw new UnsupportedOperationException( - "Unsupported RETURNING expression: " - + expression.getClass().getSimpleName() + COMPUTED_RETURNING_REJECTION.formatted(written(expression)) ); } + /** + * One expression as the query writes it, on one line: the parser's own + * spelling of the expression, with every run of whitespace written as one + * space, so a diagnostic naming an expression written across lines stays + * one line. + */ + private String written(Expression expression) { + return expression.toString() + .replaceAll("\\s+", " ") + .trim(); + } + /** * Rejects the {@code INSERT} forms whose returned rows this subset does not * model, so a returning insert never silently analyzes a wider statement. @@ -612,7 +740,35 @@ private void requireSupportedReturningInsert(Insert insert) { requireNoCommonTableExpressions(insert.getWithItemsList()); } - private void requireSupportedReturningUpdate(Update update) { + /** + * Rejects an {@code INSERT} whose rows come from a query rather than from + * a {@code VALUES} row, whose written rows this subset does not model, on + * an {@code :exec} and on a returning insert alike. One level of + * parentheses around a {@code VALUES} row states that same row, and is the + * row the values of the insert are read from, so it states no query. + */ + private void requireSupportedInsertSource(Insert insert) { + Select source = insert.getSelect(); + + if (source == null) { + return; + } + + if (source instanceof ParenthesedSelect parenthesed) { + source = parenthesed.getSelect(); + } + + if (!(source instanceof Values)) { + throw new UnsupportedOperationException(INSERT_SELECT_REJECTION); + } + } + + /** + * Rejects the multi-table {@code UPDATE} forms, whose updated rows this + * subset does not model, on an {@code :exec} and on a returning update + * alike. + */ + private void requireSupportedUpdateSources(Update update) { if ( update.getFromItem() != null || update.getJoins() != null @@ -622,11 +778,14 @@ private void requireSupportedReturningUpdate(Update update) { "UPDATE ... FROM and joined updates are not supported." ); } - - requireNoCommonTableExpressions(update.getWithItemsList()); } - private void requireSupportedReturningDelete(Delete delete) { + /** + * Rejects the multi-table {@code DELETE} forms, whose deleted rows this + * subset does not model, on an {@code :exec} and on a returning delete + * alike. + */ + private void requireSupportedDeleteSources(Delete delete) { boolean using = delete.getUsingList() != null && !delete.getUsingList().isEmpty(); if (using || delete.getJoins() != null) { @@ -634,8 +793,6 @@ private void requireSupportedReturningDelete(Delete delete) { "DELETE ... USING and joined deletes are not supported." ); } - - requireNoCommonTableExpressions(delete.getWithItemsList()); } private void requireNoCommonTableExpressions(List withItems) { @@ -1107,19 +1264,83 @@ private void requireContiguousIndexes(List parameters) { } } - private Table getTable(PlainSelect plainSelect) { - if (!(plainSelect.getFromItem() instanceof Table table)) { - throw new UnsupportedOperationException("Only table sources are supported."); + private Table getTable(PlainSelect plainSelect, List commonTableExpressions) { + return requireTableSource( + plainSelect.getFromItem(), + "Only table sources are supported.", + commonTableExpressions + ); + } + + /** + * Requires one query source to be a table of the schema. A + * {@code LATERAL} source and a subquery source each state a source this + * subset does not model, and a source that names a common table + * expression of the query reads that expression rather than the schema + * table of the same name, as PostgreSQL resolves it. + */ + private Table requireTableSource( + FromItem fromItem, + String unsupportedSourceRejection, + List commonTableExpressions + ) { + requireModeledSourceKind(fromItem); + + if (!(fromItem instanceof Table table)) { + throw new UnsupportedOperationException(unsupportedSourceRejection); } + requireNoCommonTableExpressionSource(table, commonTableExpressions); + return table; } + /** + * Rejects the source kinds that state a source this subset does not model + * at all, so neither a {@code LATERAL} source nor a subquery source is + * reported as a join kind that could be rewritten. + */ + private void requireModeledSourceKind(FromItem fromItem) { + if (fromItem instanceof LateralSubSelect) { + throw new UnsupportedOperationException(LATERAL_REJECTION); + } + + if (fromItem instanceof ParenthesedSelect) { + throw new UnsupportedOperationException(FROM_SUBQUERY_REJECTION); + } + } + + /** + * Rejects a source that names a common table expression of its own query, + * before the schema is consulted, because such a source reads the + * expression and not the schema table of that name. Only an unqualified + * name can name one. + */ + private void requireNoCommonTableExpressionSource(Table table, List commonTableExpressions) { + if (table.getSchemaName() != null) { + return; + } + + String name = table.getUnquotedName(); + + boolean commonTableExpression = commonTableExpressions.stream() + .anyMatch(declared -> declared.equalsIgnoreCase(name)); + + if (commonTableExpression) { + throw new UnsupportedOperationException(CTE_SOURCE_REJECTION.formatted(name)); + } + } + /** * Resolves the ordered query sources from the base table followed by every * joined table, rejecting an exposed name that repeats. */ - private List resolveSources(PlainSelect plainSelect, Table table, Schema schema) { + private List resolveSources( + PlainSelect plainSelect, + Table table, + List commonTableExpressions, + Schema schema + ) { List sources = new ArrayList<>(); addSource(sources, table, schema, false); @@ -1133,7 +1354,7 @@ private List resolveSources(PlainSelect plainSelect, Table table, Schema for (Join join : joins) { addSource( sources, - requireSupportedJoin(join), + requireSupportedJoin(join, commonTableExpressions), schema, join.isLeft() ); @@ -1198,21 +1419,67 @@ private dev.sqlcj.schema.Table findSourceTable(Schema schema, Table table) { * table source. {@link Join#isInnerJoin()} and {@link Join#isLeft()} also * report shapes that this subset excludes, so every excluded modifier is * rejected explicitly. + * + *

The joined source is classified first, because a source this subset + * does not model cannot be written as a supported join at all, and the join + * kinds that can be written another way are then reported with that + * rewrite. Every remaining modifier keeps the general rejection. */ - private Table requireSupportedJoin(Join join) { + private Table requireSupportedJoin(Join join, List commonTableExpressions) { + requireModeledSourceKind(join.getRightItem()); + requireSupportedJoinKind(join); + if (!isSupportedInnerJoin(join) && !isSupportedLeftJoin(join)) { throw new UnsupportedOperationException( "Only unmodified INNER JOIN and LEFT JOIN clauses are supported." ); } - if (!(join.getRightItem() instanceof Table table)) { + return requireTableSource( + join.getRightItem(), + "Only table join sources are supported.", + commonTableExpressions + ); + } + + /** + * Rejects each join kind that joins its sources by a rule this subset does + * not model, naming the join to write instead where one exists. + */ + private void requireSupportedJoinKind(Join join) { + if (join.isFull()) { + throw new UnsupportedOperationException("FULL JOIN is not supported."); + } + + if (join.isRight()) { throw new UnsupportedOperationException( - "Only table join sources are supported." + "RIGHT JOIN is not supported; write a LEFT JOIN with the two sources swapped." ); } - return table; + if (join.isCross()) { + throw new UnsupportedOperationException( + "CROSS JOIN is not supported; write JOIN ... ON TRUE." + ); + } + + if (join.isNatural()) { + throw new UnsupportedOperationException( + "NATURAL JOIN is not supported; write the join condition with ON." + ); + } + + if (!join.getUsingColumns().isEmpty()) { + throw new UnsupportedOperationException( + "JOIN ... USING is not supported; write the join condition with ON." + ); + } + + if (join.isSimple()) { + throw new UnsupportedOperationException( + "A comma-separated FROM list is not supported; write JOIN ... ON with the join condition." + ); + } } private boolean isSupportedInnerJoin(Join join) { @@ -2172,8 +2439,7 @@ private List resolveColumns( } throw new UnsupportedOperationException( - "Unsupported SELECT expression: " - + expression.getClass().getSimpleName() + COMPUTED_PROJECTION_REJECTION.formatted(written(expression)) ); } diff --git a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java index 426f770..eb15b4e 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/compiler/SqlcjCompiler.java @@ -4,6 +4,7 @@ import dev.sqlcj.analysis.QueryGroupModel; import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.config.Config; +import dev.sqlcj.config.ConfigurationException; import dev.sqlcj.generator.CodeGenerator; import dev.sqlcj.generator.GeneratedFile; import dev.sqlcj.generator.JavaCodeGenerator; @@ -12,8 +13,10 @@ import dev.sqlcj.parser.Query; import dev.sqlcj.schema.Schema; import dev.sqlcj.schema.parser.DefaultSchemaParser; +import dev.sqlcj.schema.parser.SchemaParseException; import dev.sqlcj.schema.parser.SchemaParser; import dev.sqlcj.sql.ParsedSql; +import dev.sqlcj.sql.SqlParseException; import dev.sqlcj.sql.SqlParser; import java.io.IOException; @@ -23,15 +26,58 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; public final class SqlcjCompiler { + /** + * The end of every internal-error diagnostic, which reports a failure sqlcj + * itself did not foresee and therefore describes no input. + */ + private static final String INTERNAL_ERROR_SUFFIX = "; please report this sqlcj defect"; + private final SourceLoader sourceLoader = new DefaultSourceLoader(); - private final SqlParser sqlParser = new SqlParser(); - private final QueryAnalyzer queryAnalyzer = new QueryAnalyzer(); private final GeneratedFileWriter generatedFileWriter = new GeneratedFileWriter(); private final GeneratedOutputManifest generatedOutputManifest = new GeneratedOutputManifest(); - private final SchemaParser schemaParser = new DefaultSchemaParser(); + + private final SchemaParser schemaParser; + + /** Parses and analyzes one query of one group against that group's schema. */ + private final BiFunction queryAnalysis; + + /** The generator of one configured package, by that package's name. */ + private final Function codeGenerators; + + public SqlcjCompiler() { + this( + new DefaultSchemaParser(), + defaultQueryAnalysis(), + JavaCodeGenerator::new + ); + } + + SqlcjCompiler( + SchemaParser schemaParser, + BiFunction queryAnalysis, + Function codeGenerators + ) { + this.schemaParser = schemaParser; + this.queryAnalysis = queryAnalysis; + this.codeGenerators = codeGenerators; + } + + /** Parses one query's SQL source and analyzes it against its schema. */ + private static BiFunction defaultQueryAnalysis() { + SqlParser sqlParser = new SqlParser(); + QueryAnalyzer queryAnalyzer = new QueryAnalyzer(); + + return (query, schema) -> { + ParsedSql parsedSql = sqlParser.parse(query.sql()); + + return queryAnalyzer.analyze(query, parsedSql, schema); + }; + } /** * Compiles every configured entry before any generated file is written, so @@ -46,9 +92,11 @@ public final class SqlcjCompiler { public void compile(Config config) { List sources = sourceLoader.load(config); - CodeGenerator codeGenerator = new JavaCodeGenerator(config.java().packageName()); + String packageName = config.java().packageName(); + + CodeGenerator codeGenerator = codeGenerators.apply(packageName); - List files = generate(sources, codeGenerator); + List files = generate(sources, codeGenerator, packageName); Path outputDirectory = Path.of(config.java().out()); @@ -71,7 +119,11 @@ public void compile(Config config) { * Generates one repository per configured query group, followed by the row * records and the enum types those groups share. */ - private List generate(List sources, CodeGenerator codeGenerator) { + private List generate( + List sources, + CodeGenerator codeGenerator, + String packageName + ) { List files = new ArrayList<>(); Map generatedRepositories = new HashMap<>(); @@ -89,12 +141,36 @@ private List generate(List sources, CodeGenerator codeGen files.add(file); } - files.addAll(codeGenerator.generateRows()); - files.addAll(codeGenerator.generateEnums()); + files.addAll(generateShared(codeGenerator, packageName)); return files; } + /** + * Generates the row records and the enum types the groups of one package + * share, which belong to the package rather than to one group, so their own + * diagnostics name the package and not a group. + */ + private List generateShared(CodeGenerator codeGenerator, String packageName) { + try { + List shared = new ArrayList<>(codeGenerator.generateRows()); + + shared.addAll(codeGenerator.generateEnums()); + + return shared; + } catch (RuntimeException e) { + if (isDiagnostic(e)) { + throw e; + } + + throw internalError( + "Internal error while generating the shared row records and enums of package '%s'" + .formatted(packageName), + e + ); + } + } + /** * Applies the schema files of one entry in order to that entry's schema, so * a table keeps the position of the file that declares it and a later file @@ -114,6 +190,13 @@ private Schema parseSchemaFile(Schema schema, SchemaFile schemaFile) { try { return schemaParser.parse(schema, schemaFile.sql()); } catch (RuntimeException e) { + if (!isDiagnostic(e)) { + throw internalError( + "Internal error while parsing schema source %s".formatted(schemaFile.path()), + e + ); + } + throw new CompilationException( "Invalid schema source %s: %s".formatted(schemaFile.path(), reason(e)), e @@ -138,10 +221,16 @@ private QueryGroupModel analyze(Source source, Schema schema) { */ private QueryModel analyzeQuery(Source source, Query query, Schema schema) { try { - ParsedSql parsedSql = sqlParser.parse(query.sql()); - - return queryAnalyzer.analyze(query, parsedSql, schema); + return queryAnalysis.apply(query, schema); } catch (RuntimeException e) { + if (!isDiagnostic(e)) { + throw internalError( + "Internal error while analyzing query '%s' in %s at line %d" + .formatted(query.name(), source.queriesPath(), query.line()), + e + ); + } + throw new CompilationException( "Invalid query '%s' in %s at line %d: %s" .formatted( @@ -168,6 +257,14 @@ private GeneratedFile generateRepository( try { return codeGenerator.generate(group); } catch (RuntimeException e) { + if (!isDiagnostic(e)) { + throw internalError( + "Internal error while generating query group '%s' in %s" + .formatted(group.name(), source.queriesPath()), + e + ); + } + throw new CompilationException( "Invalid query group '%s' in %s: %s" .formatted(group.name(), source.queriesPath(), reason(e)), @@ -176,6 +273,44 @@ private GeneratedFile generateRepository( } } + /** + * Reports whether a failure of one compilation step is a diagnostic of a + * compiled input, which sqlcj states in SQL terms. + * + *

The schema parser, the SQL parser, the configuration, and the compiler + * itself have exceptions of their own, and the schema parser, the analyzer, + * and the generator state a diagnostic by convention as an unsupported + * operation or an illegal argument that carries a message. Every other + * exception, including a subclass such as {@link NumberFormatException} and + * an exception of that convention's types without a message, is a failure + * sqlcj did not foresee. + */ + private boolean isDiagnostic(RuntimeException e) { + if ( + e instanceof SqlParseException + || e instanceof SchemaParseException + || e instanceof ConfigurationException + || e instanceof CompilationException + ) { + return true; + } + + boolean convention = e.getClass() == UnsupportedOperationException.class + || e.getClass() == IllegalArgumentException.class; + + return convention && e.getMessage() != null && !e.getMessage().isBlank(); + } + + /** + * Reports an unexpected failure of one compilation step as one line that + * names the step's own input and asks for a report, and carries neither the + * message nor the class of the failure, which describes no compiled input. + * The failure itself stays as the cause. + */ + private CompilationException internalError(String step, RuntimeException e) { + return new CompilationException(step + INTERNAL_ERROR_SUFFIX, e); + } + /** Uses the first message line so a diagnostic stays focused. */ private String reason(RuntimeException e) { String message = e.getMessage(); diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java index cf9b12d..a53ed10 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -506,6 +506,36 @@ void shouldAnalyzeSelectWithExplicitColumns() { ); } + /** + * A parenthesized {@code SELECT} is the query it encloses, at any depth, so + * it analyzes to the model of that query. + */ + @Test + void shouldAnalyzeParenthesizedSelectAsTheQueryItEncloses() { + String sql = "((SELECT id FROM users WHERE id = $1))"; + + QueryModel model = analyzer.analyze( + new Query("GetUser", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals("users", model.table()); + + assertEquals( + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + assertEquals("((SELECT id FROM users WHERE id = ?))", model.executableSql()); + } + @Test void shouldResolveAllColumns() { Query query = new Query( @@ -1750,19 +1780,33 @@ void shouldNumberNamedProjectionPlaceholderBeforeThePredicateOne() { "SELECT name, COUNT(*) FROM users GROUP BY name" + "|COUNT(*) requires a result alias, such as COUNT(*) AS total.", "SELECT COUNT(id) AS n FROM users" - + "|Unsupported SELECT expression: Function", + + "|Cannot infer the type of COUNT(id) in SELECT;" + + " state it with a cast and an alias," + + " such as SUM(amount)::numeric AS total", "SELECT COUNT(DISTINCT id) AS n FROM users" - + "|Unsupported SELECT expression: Function", + + "|Cannot infer the type of COUNT(DISTINCT id) in SELECT;" + + " state it with a cast and an alias," + + " such as SUM(amount)::numeric AS total", "SELECT COUNT(u.*) AS n FROM users u" - + "|Unsupported SELECT expression: Function", + + "|Cannot infer the type of COUNT(u.*) in SELECT;" + + " state it with a cast and an alias," + + " such as SUM(amount)::numeric AS total", "SELECT pg_catalog.count(*) AS n FROM users" - + "|Unsupported SELECT expression: Function", + + "|Cannot infer the type of pg_catalog.count(*) in SELECT;" + + " state it with a cast and an alias," + + " such as SUM(amount)::numeric AS total", "SELECT lower(name) AS n FROM users" - + "|Unsupported SELECT expression: Function", + + "|Cannot infer the type of lower(name) in SELECT;" + + " state it with a cast and an alias," + + " such as SUM(amount)::numeric AS total", "SELECT COUNT(*) FILTER (WHERE id > 1) AS n FROM users" - + "|Unsupported SELECT expression: AnalyticExpression", + + "|Cannot infer the type of COUNT(*) FILTER (WHERE id > 1) in SELECT;" + + " state it with a cast and an alias," + + " such as SUM(amount)::numeric AS total", "SELECT COUNT(*) OVER () AS n FROM users" - + "|Unsupported SELECT expression: AnalyticExpression" + + "|Cannot infer the type of COUNT(*) OVER () in SELECT;" + + " state it with a cast and an alias," + + " such as SUM(amount)::numeric AS total" } ) void shouldRejectUnsupportedCountProjectionForm(String sql, String message) { @@ -2654,21 +2698,41 @@ void shouldRejectExcludedJoinReportedAsInnerJoin(String fromClause) { * Only a bare or {@code INNER} join and a {@code LEFT} join in its two * spellings are supported, so every other qualifier stays rejected, * including the qualifiers that also report {@code isOuter()} or - * {@code isLeft()}. + * {@code isLeft()}. A join kind that can be written as a supported join + * names that rewrite, and every other modifier keeps the general + * rejection. */ @ParameterizedTest - @ValueSource( - strings = { - "RIGHT JOIN profiles p ON p.user_id = u.id", - "RIGHT OUTER JOIN profiles p ON p.user_id = u.id", - "FULL OUTER JOIN profiles p ON p.user_id = u.id", - "OUTER JOIN profiles p ON p.user_id = u.id", - "NATURAL LEFT JOIN profiles p", - "LEFT SEMI JOIN profiles p ON p.user_id = u.id", + @CsvSource( + delimiter = '|', + value = { + "RIGHT JOIN profiles p ON p.user_id = u.id" + + "|RIGHT JOIN is not supported;" + + " write a LEFT JOIN with the two sources swapped.", + "RIGHT OUTER JOIN profiles p ON p.user_id = u.id" + + "|RIGHT JOIN is not supported;" + + " write a LEFT JOIN with the two sources swapped.", + "FULL OUTER JOIN profiles p ON p.user_id = u.id" + + "|FULL JOIN is not supported.", + "FULL JOIN profiles p ON p.user_id = u.id" + + "|FULL JOIN is not supported.", + "CROSS JOIN profiles p" + + "|CROSS JOIN is not supported; write JOIN ... ON TRUE.", + "NATURAL JOIN profiles p" + + "|NATURAL JOIN is not supported; write the join condition with ON.", + "NATURAL LEFT JOIN profiles p" + + "|NATURAL JOIN is not supported; write the join condition with ON.", + "JOIN profiles p USING (user_id)" + + "|JOIN ... USING is not supported; write the join condition with ON.", "LEFT JOIN profiles p USING (user_id)" + + "|JOIN ... USING is not supported; write the join condition with ON.", + "OUTER JOIN profiles p ON p.user_id = u.id" + + "|Only unmodified INNER JOIN and LEFT JOIN clauses are supported.", + "LEFT SEMI JOIN profiles p ON p.user_id = u.id" + + "|Only unmodified INNER JOIN and LEFT JOIN clauses are supported." } ) - void shouldRejectUnsupportedJoinModifier(String joinClause) { + void shouldRejectUnsupportedJoinModifier(String joinClause, String message) { String sql = """ SELECT u.id FROM users u @@ -2683,10 +2747,7 @@ void shouldRejectUnsupportedJoinModifier(String joinClause) { () -> analyzer.analyze(query, parsedSql, joinSchema) ); - assertEquals( - "Only unmodified INNER JOIN and LEFT JOIN clauses are supported.", - exception.getMessage() - ); + assertEquals(message, exception.getMessage()); } /** @@ -4510,15 +4571,21 @@ INSERT INTO users (id) VALUES ($1) RETURNING id AS "User Id" "INSERT INTO users (id) VALUES ($1) RETURNING * x" + "|RETURNING * must not be aliased.", "INSERT INTO users (id) VALUES ($1) RETURNING id + 1" - + "|Unsupported RETURNING expression: Addition", + + "|Unsupported RETURNING item id + 1;" + + " return columns of the target table or *", "INSERT INTO users (id) VALUES ($1) RETURNING id + 1 AS x" - + "|Unsupported RETURNING expression: Addition", + + "|Unsupported RETURNING item id + 1;" + + " return columns of the target table or *", "INSERT INTO users (id) VALUES ($1) RETURNING users.*" + "|Only an unqualified RETURNING * is supported.", "INSERT INTO users (id) VALUES ($1) RETURNING users.* AS x" + "|Only an unqualified RETURNING * is supported.", "DELETE FROM users WHERE id = $1 RETURNING count(id)" - + "|Unsupported RETURNING expression: Function" + + "|Unsupported RETURNING item count(id);" + + " return columns of the target table or *", + "DELETE FROM users WHERE id = $1 RETURNING lower(name)" + + "|Unsupported RETURNING item lower(name);" + + " return columns of the target table or *" } ) void shouldRejectUnsupportedReturningItem(String sql, String message) { @@ -4556,6 +4623,8 @@ void shouldRejectUnknownReturningColumn() { delimiter = '|', value = { "INSERT INTO users (id) SELECT id FROM users RETURNING id" + + "|INSERT ... SELECT is not supported.", + "INSERT INTO users (id) (VALUES ($1)) RETURNING id" + "|RETURNING requires an INSERT with a single VALUES row.", "INSERT INTO users (id) VALUES ($1), ($2) RETURNING id" + "|INSERT requires a single VALUES row.", @@ -5069,6 +5138,62 @@ void shouldAnalyzeNamedInsertWithNonBindingValues() { ); } + /** + * A {@code VALUES} row written inside parentheses states the same single + * row, so the insert analyzes to the parameters, binding order, and + * executable SQL of the row it writes. + */ + @Test + void shouldAnalyzeInsertWhoseValuesRowIsParenthesized() { + String sql = "INSERT INTO users (id, name) (VALUES ($1, $2))"; + + QueryModel model = analyzer.analyze( + new Query("CreateUser", QueryType.EXEC, sql), + parser.parse(sql), + writeSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "id", ColumnType.BIGINT), + new QueryParameter(2, "name", ColumnType.VARCHAR) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2), model.bindingParameterIndexes()); + + assertEquals( + "INSERT INTO users (id, name) (VALUES (?, ?))", + model.executableSql() + ); + } + + /** + * Parentheses around a query source state a query all the same, so such an + * insert stays rejected on an {@code :exec} and on a returning insert + * alike. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + value = { + "EXEC|INSERT INTO users (id, name) (SELECT id, name FROM users)", + "ONE|INSERT INTO users (id, name) (SELECT id, name FROM users) RETURNING id" + } + ) + void shouldRejectParenthesizedInsertQuerySource(QueryType type, String sql) { + Query query = new Query("CopyUsers", type, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, writeSchema) + ); + + assertEquals("INSERT ... SELECT is not supported.", exception.getMessage()); + } + /** * A returning insert accepts non-binding values without changing the * columns it returns. 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 907beb4..a17c71f 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -1,14 +1,28 @@ package dev.sqlcj.compiler; +import dev.sqlcj.analysis.QueryAnalyzer; +import dev.sqlcj.analysis.QueryGroupModel; +import dev.sqlcj.analysis.QueryModel; import dev.sqlcj.config.Config; import dev.sqlcj.config.JavaConfig; import dev.sqlcj.config.SqlConfig; +import dev.sqlcj.generator.CodeGenerator; +import dev.sqlcj.generator.GeneratedFile; +import dev.sqlcj.generator.JavaCodeGenerator; +import dev.sqlcj.parser.Query; import dev.sqlcj.runtime.JdbcQueryExecutor; import dev.sqlcj.runtime.QueryCardinalityException; import dev.sqlcj.runtime.QueryExecutor; +import dev.sqlcj.schema.Schema; +import dev.sqlcj.schema.parser.DefaultSchemaParser; +import dev.sqlcj.schema.parser.SchemaParser; +import dev.sqlcj.sql.SqlParser; import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import javax.tools.JavaCompiler; import javax.tools.ToolProvider; @@ -29,6 +43,8 @@ import java.util.Arrays; import java.util.List; import java.util.UUID; +import java.util.function.BiFunction; +import java.util.function.Function; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertArrayEquals; @@ -2313,6 +2329,358 @@ void shouldRejectAnonymousPlaceholderBeforeWriting() { assertFalse(Files.exists(generatedDirectory)); } + /** + * Every statement kind, set operation, projection, {@code RETURNING} item, + * join kind, and source outside the supported subset is reported as one + * located line in SQL terms, before any file is written. + */ + @ParameterizedTest + @MethodSource("rejectedQueries") + void shouldRejectUnsupportedQueryBeforeWriting(String type, String sql, String reason) { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileJoinQueries( + "-- name: Q %s\n%s;\n".formatted(type, sql), + generatedDirectory + ) + ); + + assertEquals( + "Invalid query 'Q' in %s at line 1: ".formatted(tempDir.resolve("queries.sql")) + reason, + exception.getMessage() + ); + + assertFalse(Files.exists(generatedDirectory)); + } + + private static Stream rejectedQueries() { + String setOperation = "UNION, INTERSECT, and EXCEPT are not supported;" + + " write one query per SELECT."; + + String statement = "Only SELECT, INSERT, UPDATE, and DELETE statements are supported."; + + return Stream.of( + Arguments.of(":many", "SELECT id FROM users UNION SELECT id FROM users", setOperation), + Arguments.of(":many", "SELECT id FROM users UNION ALL SELECT id FROM users", setOperation), + Arguments.of(":many", "SELECT id FROM users INTERSECT SELECT id FROM users", setOperation), + Arguments.of(":many", "(SELECT id FROM users UNION SELECT id FROM users)", setOperation), + Arguments.of(":exec", "VALUES (1)", statement), + Arguments.of(":many", "TABLE users", statement), + Arguments.of( + ":exec", + "MERGE INTO users u USING profiles p ON p.user_id = u.id" + + " WHEN MATCHED THEN UPDATE SET name = p.nickname", + statement + ), + Arguments.of(":exec", "TRUNCATE TABLE users", statement), + Arguments.of(":many", "EXPLAIN SELECT id FROM users", statement), + Arguments.of(":exec", "EXECUTE archive_users(1)", statement), + Arguments.of(":exec", "CREATE TABLE archived_users (id BIGINT)", statement), + Arguments.of(":exec", "SET statement_timeout = 0", statement), + Arguments.of( + ":many", + "SELECT COUNT(*) OVER () AS n FROM users", + "Cannot infer the type of COUNT(*) OVER () in SELECT;" + + " state it with a cast and an alias, such as SUM(amount)::numeric AS total" + ), + Arguments.of( + ":many", + "SELECT name || 'x' AS n FROM users", + "Cannot infer the type of name || 'x' in SELECT;" + + " state it with a cast and an alias, such as SUM(amount)::numeric AS total" + ), + Arguments.of( + ":one", + "DELETE FROM users WHERE id = $1 RETURNING lower(name)", + "Unsupported RETURNING item lower(name);" + + " return columns of the target table or *" + ), + Arguments.of( + ":many", + "SELECT u.id FROM users u RIGHT JOIN profiles p ON p.user_id = u.id", + "RIGHT JOIN is not supported; write a LEFT JOIN with the two sources swapped." + ), + Arguments.of( + ":many", + "SELECT u.id FROM users u FULL JOIN profiles p ON p.user_id = u.id", + "FULL JOIN is not supported." + ), + Arguments.of( + ":many", + "SELECT u.id FROM users u JOIN profiles p USING (id)", + "JOIN ... USING is not supported; write the join condition with ON." + ), + Arguments.of( + ":many", + "SELECT u.id FROM users u NATURAL JOIN profiles p", + "NATURAL JOIN is not supported; write the join condition with ON." + ), + Arguments.of( + ":many", + "SELECT u.id FROM users u CROSS JOIN profiles p", + "CROSS JOIN is not supported; write JOIN ... ON TRUE." + ), + Arguments.of( + ":many", + "SELECT u.id FROM users u, profiles p", + "A comma-separated FROM list is not supported;" + + " write JOIN ... ON with the join condition." + ), + Arguments.of( + ":many", + "SELECT u.id FROM users u" + + " JOIN LATERAL (SELECT p.nickname FROM profiles p WHERE p.user_id = u.id) l" + + " ON TRUE", + "LATERAL is not supported." + ), + Arguments.of( + ":many", + "SELECT s.id FROM (SELECT id FROM users) s", + "A subquery in FROM is not supported." + ), + Arguments.of( + ":many", + "WITH recent AS (SELECT id FROM users) SELECT id FROM recent", + "A common table expression used as a source is not supported: recent" + ), + Arguments.of( + ":many", + "WITH users AS (SELECT id FROM users) SELECT id FROM users", + "A common table expression used as a source is not supported: users" + ), + Arguments.of( + ":exec", + "INSERT INTO users (id, name) VALUES ($1, $2), ($3, $4)", + "INSERT requires a single VALUES row." + ), + Arguments.of( + ":exec", + "INSERT INTO users (id, name) SELECT id, nickname FROM profiles", + "INSERT ... SELECT is not supported." + ), + Arguments.of( + ":many", + "INSERT INTO users (id, name) SELECT id, nickname FROM profiles RETURNING id", + "INSERT ... SELECT is not supported." + ), + Arguments.of( + ":exec", + "UPDATE users SET name = $2 FROM profiles WHERE users.id = $1", + "UPDATE ... FROM and joined updates are not supported." + ), + Arguments.of( + ":exec", + "UPDATE users, profiles SET name = $2 WHERE users.id = $1", + "UPDATE ... FROM and joined updates are not supported." + ), + Arguments.of( + ":exec", + "DELETE FROM users USING profiles WHERE users.id = $1", + "DELETE ... USING and joined deletes are not supported." + ), + Arguments.of( + ":exec", + "DELETE users FROM users JOIN profiles ON profiles.user_id = users.id" + + " WHERE users.id = $1", + "DELETE ... USING and joined deletes are not supported." + ) + ); + } + + /** + * An unexpected failure of the schema step is reported as one located + * internal-error line that carries neither the message nor the class of the + * failure. + */ + @Test + void shouldReportAnUnexpectedSchemaFailureAsAnInternalError() { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileSeamQueries( + (schema, sql) -> { + throw new IllegalStateException("secret"); + }, + defaultQueryAnalysis(), + JavaCodeGenerator::new, + generatedDirectory + ) + ); + + assertInternalError( + "Internal error while parsing schema source %s".formatted(tempDir.resolve("schema.sql")), + "IllegalStateException", + exception, + generatedDirectory + ); + } + + /** + * An unexpected failure of the analysis step is reported as one located + * internal-error line, including a failure of the types a diagnostic uses + * that carries no message and a subclass of those types. + */ + @ParameterizedTest + @MethodSource("unexpectedAnalysisFailures") + void shouldReportAnUnexpectedAnalysisFailureAsAnInternalError(RuntimeException failure) { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileSeamQueries( + new DefaultSchemaParser(), + (query, schema) -> { + throw failure; + }, + JavaCodeGenerator::new, + generatedDirectory + ) + ); + + assertInternalError( + "Internal error while analyzing query 'GetUser' in %s at line 1" + .formatted(tempDir.resolve("queries.sql")), + failure.getClass().getSimpleName(), + exception, + generatedDirectory + ); + } + + private static Stream unexpectedAnalysisFailures() { + return Stream.of( + new NullPointerException("secret"), + new UnsupportedOperationException(" "), + new NumberFormatException("secret") + ); + } + + /** + * An unexpected failure of the generation step is reported as one located + * internal-error line naming the query group. + */ + @Test + void shouldReportAnUnexpectedGenerationFailureAsAnInternalError() { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileSeamQueries( + new DefaultSchemaParser(), + defaultQueryAnalysis(), + packageName -> new FailingCodeGenerator( + new JavaCodeGenerator(packageName), + new ClassCastException("secret"), + null + ), + generatedDirectory + ) + ); + + assertInternalError( + "Internal error while generating query group 'Users' in %s" + .formatted(tempDir.resolve("queries.sql")), + "ClassCastException", + exception, + generatedDirectory + ); + } + + /** + * An unexpected failure of the shared row records and enum types, which + * belong to no query group, is reported as one internal-error line naming + * the package. + */ + @Test + void shouldReportAnUnexpectedSharedGenerationFailureAsAnInternalError() { + Path generatedDirectory = tempDir.resolve("generated"); + + CompilationException exception = assertThrows( + CompilationException.class, + () -> compileSeamQueries( + new DefaultSchemaParser(), + defaultQueryAnalysis(), + packageName -> new FailingCodeGenerator( + new JavaCodeGenerator(packageName), + null, + new ClassCastException("secret") + ), + generatedDirectory + ) + ); + + assertInternalError( + "Internal error while generating the shared row records and enums" + + " of package 'generated'", + "ClassCastException", + exception, + generatedDirectory + ); + } + + /** + * Requires the one internal-error line of one compilation step, which + * carries neither the message nor the class of the failure it reports and + * leaves no generated output. + */ + private void assertInternalError( + String step, + String failureClass, + CompilationException exception, + Path generatedDirectory + ) { + assertEquals(step + "; please report this sqlcj defect", exception.getMessage()); + + assertFalse(exception.getMessage().contains("secret")); + assertFalse(exception.getMessage().contains(failureClass)); + assertFalse(Files.exists(generatedDirectory)); + } + + /** The compiler's own parse and analysis of one query. */ + private static BiFunction defaultQueryAnalysis() { + SqlParser sqlParser = new SqlParser(); + QueryAnalyzer queryAnalyzer = new QueryAnalyzer(); + + return (query, schema) -> queryAnalyzer.analyze( + query, + sqlParser.parse(query.sql()), + schema + ); + } + + /** A code generator whose generation steps fail instead of generating. */ + private record FailingCodeGenerator( + CodeGenerator delegate, + RuntimeException generateFailure, + RuntimeException generateRowsFailure + ) implements CodeGenerator { + + @Override + public GeneratedFile generate(QueryGroupModel group) { + if (generateFailure != null) { + throw generateFailure; + } + + return delegate.generate(group); + } + + @Override + public List generateRows() { + if (generateRowsFailure != null) { + throw generateRowsFailure; + } + + return delegate.generateRows(); + } + + @Override + public List generateEnums() { + return delegate.generateEnums(); + } + } + /** * An untyped placeholder is reported as one located line that names the * placeholder, the expression the query writes it in, and the cast that @@ -2744,6 +3112,73 @@ INSERT INTO users (id, name) assertTrue(Files.exists(classesDirectory.resolve("generated/UsersRepository.class"))); } + /** Compiles one query source against the joined users schema. */ + private void compileJoinQueries(String queries, Path generatedDirectory) throws IOException { + Path schemaFile = tempDir.resolve("schema.sql"); + Path queriesFile = tempDir.resolve("queries.sql"); + + Files.writeString(schemaFile, JOIN_SCHEMA); + Files.writeString(queriesFile, queries); + + new SqlcjCompiler().compile(usersConfig(schemaFile, queriesFile, generatedDirectory)); + } + + /** + * Compiles one supported query through a compiler whose schema parser, + * query analysis, and code generators are the given ones, so that an + * unexpected failure of one step can be injected. + */ + private void compileSeamQueries( + SchemaParser schemaParser, + BiFunction queryAnalysis, + Function codeGenerators, + Path generatedDirectory + ) throws IOException { + Path schemaFile = tempDir.resolve("schema.sql"); + Path queriesFile = tempDir.resolve("queries.sql"); + + Files.writeString( + schemaFile, + """ + CREATE TABLE users + ( + id BIGINT NOT NULL, + name VARCHAR(255) + ); + """ + ); + + Files.writeString( + queriesFile, + """ + -- name: GetUser :one + SELECT * + FROM users + WHERE id = $1; + """ + ); + + new SqlcjCompiler(schemaParser, queryAnalysis, codeGenerators) + .compile(usersConfig(schemaFile, queriesFile, generatedDirectory)); + } + + /** The one {@code Users} entry of one schema and query source. */ + private Config usersConfig(Path schemaFile, Path queriesFile, Path generatedDirectory) { + return new Config( + List.of( + new SqlConfig( + "Users", + schemaFile.toString(), + queriesFile.toString() + ) + ), + new JavaConfig( + generatedDirectory.toString(), + "generated" + ) + ); + } + private void compileUsersQueries(String queries, Path generatedDirectory) throws IOException { Path schemaFile = tempDir.resolve("schema.sql"); Path queriesFile = tempDir.resolve("queries.sql");