From 1a2c271768592b4e3cef5a4bf1f56b84a5646d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Mon, 12 Oct 2026 21:29:17 +0300 Subject: [PATCH] feat(analysis): analyze CTEs and FROM subqueries as derived sources --- docs/queries.md | 172 +++-- .../dev/sqlcj/analysis/QueryAnalyzer.java | 642 ++++++++++++++---- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 551 ++++++++++++++- .../compiler/PostgresIntegrationTest.java | 90 +++ .../ReferenceServiceIntegrationTest.java | 10 - .../SqlcjCompilerIntegrationTest.java | 77 ++- .../unsupported/queries/cte_source.sql | 7 - .../unsupported/queries/from_subquery.sql | 7 - 8 files changed, 1332 insertions(+), 224 deletions(-) delete mode 100644 sqlcj-cli/src/test/resources/reference-service/unsupported/queries/cte_source.sql delete mode 100644 sqlcj-cli/src/test/resources/reference-service/unsupported/queries/from_subquery.sql diff --git a/docs/queries.md b/docs/queries.md index f86a80f..056a1fe 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -77,14 +77,49 @@ configuration entry. ### Sources -- 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. +- The `FROM` item must be a table of that schema, a `WITH` clause in scope, or a + subquery, optionally with an alias. A `LATERAL` source is rejected as + `LATERAL is not supported.`, before the source is resolved, and a doubly + parenthesized source, such as `FROM ((SELECT ...)) s`, as + `Only table sources are supported.` +- A `FROM` or `JOIN` source whose unqualified name matches a `WITH` clause in + scope reads that clause and not the table of the same name, as PostgreSQL + resolves it, matched case-insensitively and taking the innermost declaration + of that name. A schema-qualified name always names a table. +- A `WITH` clause and a subquery source are **derived sources**: the source's + columns are the result columns of its own `SELECT`, in order, with the names, + types, enum types, arrays, and nullability that `SELECT` states. A derived + source returns no shared `Row` record, so `SELECT * FROM ` + over one returns this query's own result record even when the source is named + like a table, and every column of a left-joined derived source is nullable, + as it is for a left-joined table. +- The body of a derived source is analyzed exactly like a top-level `SELECT`: + it is classified inside its own parentheses, so a set operation or another + statement form there is rejected with that diagnostic; its sources resolve and + its joins follow the join rules; its projection follows the projection rules, + including the rejection of a computed item without a cast and an alias; and + its `ON`, `WHERE`, `LIMIT`, and `OFFSET` placeholders are typed by those + clause rules, so `WHERE total = $1` inside a `WITH` body needs no cast. +- A subquery source requires an alias, + `A subquery in FROM requires an alias, such as (SELECT ...) AS recent.`, and + sees no source of the query around it, as PostgreSQL scopes a subquery that is + not `LATERAL`. +- A column list on a `WITH` name or on a subquery alias, such as + `WITH recent (n) AS (...)` or `(SELECT ...) AS s(n)`, is rejected as + `A column list on is not supported; alias the columns in its SELECT + instead.`, and a derived source two of whose columns expose one name, compared + ignoring case, as + `Derived source has more than one column named ; give each a + distinct alias.` +- Every `WITH` clause of every statement is analyzed in the order the query + writes it, whether or not a source reads it, so its placeholders are typed by + its own clause rules. A clause sees the clauses written before it in its own + list and those of every enclosing query, so a clause named like a table reads + that table inside its own body. `MATERIALIZED` and `NOT MATERIALIZED` are + accepted, while `WITH RECURSIVE` is rejected as + `WITH RECURSIVE is not supported.` and a data-modifying body as + `A data-modifying statement in WITH is not supported; run it as a query of its + own.` - 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 @@ -93,8 +128,9 @@ configuration entry. target of an `INSERT`, an `UPDATE`, and a `DELETE` alike. `search_path` is 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 table or a derived source may be joined with `JOIN`, `INNER JOIN`, + `LEFT JOIN`, or `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.` @@ -104,10 +140,10 @@ configuration entry. - `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 + A `LATERAL` join source is reported as the same unsupported source as a + `FROM` item of that kind, before the join kind, while every other joined + source is resolved after it, 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, @@ -595,9 +631,15 @@ column of the target table either, so it is rejected as a computed item, with or without an alias. 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`. +`DELETE ... USING` are rejected in every write, while an accepted +`ON CONFLICT` clause is analyzed the same way with and without `RETURNING`. + +A write may declare `WITH` clauses of its own, with or without `RETURNING`. +Each body is analyzed by the rules [Sources](#sources) states, so its +placeholders are typed from its own columns, while the returned columns stay +the write target's. No clause of a supported write resolves a source beside its +target, so such a clause is read only by a predicate subquery, which reaches +the database as written. ## Parameters @@ -621,6 +663,10 @@ string literal, a quoted identifier, or a comment is not a parameter. `pageSize` rather than `limit`. - The Java type of a placeholder is the type of the column it is compared with, assigned to, or inserted into. +- The clause rules apply in an analyzed nested `SELECT` — a `WITH` body and a + subquery source — exactly as they do at top level, so a placeholder of + such a body's `ON`, `WHERE`, `LIMIT`, or `OFFSET` clause is typed and named + from its own clause without a cast. - A placeholder written as the direct operand of `::type` or `CAST(... AS type)` is typed by that cast instead, wherever it appears in an accepted statement: a projection, a join `ON` condition, `WHERE`, `GROUP BY`, @@ -640,7 +686,10 @@ string literal, a quoted identifier, or a comment is not a parameter. column — so `name = $1::text` names `name` as `name = $1` does. An indexed cast placeholder that is the whole `LIMIT` row count or `OFFSET` value is named `limit` or `offset`, as an uncast one is. - Every other indexed cast placeholder is named `param` + These names come from the clause the placeholder stands in, in an analyzed + nested `SELECT` as at top level, so `orders.total = $1::numeric` in a `WITH` + body names `total`. Every other indexed cast placeholder, including one in a + clause or a nested query sqlcj does not analyze, is named `param` after its own index, as in `param1` for `$1`, and colliding names take the usual numeric suffix. - A column written with an array subscript or slice names no parameter, because @@ -940,6 +989,10 @@ query name, and its header line. - 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 `WITH` body and a subquery source are classified inside their own + parentheses the same way, so a set operation there is rejected with that + diagnostic and another statement form, such as a `VALUES` body, with the + statement diagnostic. - 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 @@ -949,9 +1002,14 @@ query name, and its header line. `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 `FROM` or `JOIN` source outside the subset: a `LATERAL` source, a doubly + parenthesized source, a subquery source without an alias, a column list on a + `WITH` name or on a subquery alias, and a derived source two of whose columns + expose one name, each with the diagnostic [Sources](#sources) states. +- A `WITH` clause outside the subset, on a read and on a write alike: a + `WITH RECURSIVE` clause and a data-modifying body, each with the diagnostic + [Sources](#sources) states. Both are rejected whether or not a source reads + the clause, because every body is analyzed. - 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 @@ -1032,8 +1090,7 @@ query name, and its header line. `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. + `DELETE ... USING and joined deletes are not supported.` - Any annotation other than `:one`, `:optional`, `:many`, and `:exec`. An unexpected failure of schema parsing, query analysis, or generation is not a @@ -1056,8 +1113,10 @@ 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}')`, -- a common table expression whose name no source reads, and subqueries outside - the `FROM` item, +- subqueries outside a `FROM` or `JOIN` item, such as `EXISTS`, + `IN (SELECT ...)`, a scalar comparison subquery, and one in a projection, + `GROUP BY`, `HAVING`, `ORDER BY`, a written value, or `RETURNING`, whose + tables and columns PostgreSQL rather than sqlcj checks, - 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 @@ -1065,9 +1124,9 @@ them: A clause that is not analyzed still binds the placeholders a cast types inside it, in textual order, and nothing else of it is checked. A cast placeholder in a -`GROUP BY`, `HAVING`, `ORDER BY`, `WITH` body, or subquery therefore generates -one typed method parameter while PostgreSQL rather than sqlcj checks the -surrounding expression, including its tables and columns. +`GROUP BY`, `HAVING`, `ORDER BY`, or subquery therefore generates one typed +method parameter while PostgreSQL rather than sqlcj checks the surrounding +expression, including its tables and columns. sqlcj itself provides no macros, dynamic `IN` expansion, or query-building API. The `= ANY` list predicate is the only analyzed array operator. An array @@ -1283,6 +1342,36 @@ Reads: `PostgresIntegrationTest.shouldExecuteGeneratedFilteredLeftJoinAgainstPostgres` executes a filtered left join against PostgreSQL 16 whose filtered-out and unmatched rows read the joined components as `null`. +- `QueryAnalyzerTest.shouldResolveCommonTableExpressionSource`, + `QueryAnalyzerTest.shouldResolveChainedCommonTableExpressions`, + `QueryAnalyzerTest.shouldReadTheSchemaTableInsideACommonTableExpressionNamedLikeIt`, + `QueryAnalyzerTest.shouldResolveCommonTableExpressionDeclaredInsideASubquerySource`, + `QueryAnalyzerTest.shouldResolveSubquerySourceOfAJoin`, + `QueryAnalyzerTest.shouldReadColumnsOfALeftJoinedDerivedSourceAsNullable`, + `QueryAnalyzerTest.shouldResolveCountAndCastColumnsThroughADerivedSource`, + `QueryAnalyzerTest.shouldResolveQuerySpecificResultForAllColumnsOfADerivedSource`, + `QueryAnalyzerTest.shouldBindDerivedSourceParametersInTextualOrder`, + `QueryAnalyzerTest.shouldAnalyzeACommonTableExpressionReadOnlyInsideAPredicateSubquery`, + `QueryAnalyzerTest.shouldAcceptMaterializedCommonTableExpression`, + `QueryAnalyzerTest.shouldRejectAnOuterSourceReferenceInsideASubquerySource`, and + `QueryAnalyzerTest.shouldRejectExcludedDerivedSourceForm` cover the derived + sources: a `WITH` source whose placeholder is typed and named from its own + column, chained clauses, a clause named like a table whose body reads that + table, a clause declared inside a subquery source, a joined subquery, the + nullable columns of a left-joined derived source selected by name and by + wildcard, an aliased `COUNT(*)` and an aliased cast read through a derived + source, the query-specific result of `SELECT *` over one, the textual binding + order across a `WITH` body, a subquery source, and the outer query, a clause + read only inside a predicate subquery, the accepted `MATERIALIZED` spellings, + the outer source a subquery body does not see, and every rejected + derived-source and `WITH` form, including a data-modifying body reported + without an internal error; + `SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaForDerivedSources` + compiles a repository over a left-joined `WITH` report and a paged subquery + source, and + `PostgresIntegrationTest.shouldExecuteGeneratedDerivedSourcesAgainstPostgres` + executes both against PostgreSQL 16, reading the unmatched report row's + derived column as `null`. - `QueryAnalyzerTest.shouldResolveRowTableForFullRowSelect`, `QueryAnalyzerTest.shouldNotResolveRowTableForQuerySpecificResult`, `QueryAnalyzerTest.shouldNotResolveRowTableForJoinedWildcard`, and @@ -1299,7 +1388,11 @@ Writes and `RETURNING`: `QueryAnalyzerTest.shouldRejectUnsupportedReturningItem`, `QueryAnalyzerTest.shouldRejectUnknownReturningColumn`, and `QueryAnalyzerTest.shouldRejectExcludedReturningWriteForm` cover the write - shapes. + shapes, and + `QueryAnalyzerTest.shouldAnalyzeReturningWriteWithACommonTableExpression` and + `QueryAnalyzerTest.shouldAnalyzeCommonTableExpressionOfAWriteWithoutReturning` + cover the `WITH` clause of a returning and of an `:exec` write, whose body + types its own placeholders while the returned columns stay the target's. - `QueryAnalyzerTest.shouldNameInsertReturningColumnsFromTheirAliases`, `QueryAnalyzerTest.shouldNameUpdateReturningColumnFromABareAlias`, `QueryAnalyzerTest.shouldNameDeleteReturningColumnQualifiedThroughTheTargetAlias`, @@ -1382,18 +1475,20 @@ Unsupported queries and internal errors: 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 + `USING`, `NATURAL`, `CROSS`, comma, and `LATERAL` joins, a column list on a + subquery alias, a subquery source without an alias, a `WITH RECURSIVE` + clause, a data-modifying `WITH` body, 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.shouldRejectExcludedReturningWriteForm`, + `QueryAnalyzerTest.shouldRejectExcludedDerivedSourceForm`, and `QueryAnalyzerTest.shouldRejectParenthesizedInsertQuerySource` cover the - projection, join, and write rejections per form, including the modifiers that - keep the general join rejection, and + projection, join, derived-source, 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 @@ -1556,9 +1651,8 @@ Reference service: covers the whole located diagnostic line, and the absence of generated output, of every entry of the reference service's unsupported query catalogue: the two set operations, the `RIGHT`, `FULL`, `USING`, `NATURAL`, - `CROSS`, comma, and `LATERAL` joins, a common table expression used as a - source, a `FROM` subquery, a multi-row `VALUES`, an `INSERT ... SELECT`, an - `UPDATE ... FROM`, a `DELETE ... USING`, an uncast `lower(:email)`, and a - placeholder beside a subscripted column, and + `CROSS`, comma, and `LATERAL` joins, a multi-row `VALUES`, an + `INSERT ... SELECT`, an `UPDATE ... FROM`, a `DELETE ... USING`, an uncast + `lower(:email)`, and a placeholder beside a subscripted column, and `ReferenceServiceIntegrationTest.shouldCatalogueExactlyTheUnsupportedQueryFiles` covers that the catalogue directory holds exactly those queries. 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 639a341..24d09f1 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -34,14 +34,17 @@ import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; import net.sf.jsqlparser.schema.MultiPartName; import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.ParenthesedStatement; import net.sf.jsqlparser.statement.ReturningClause; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.create.table.Index; import net.sf.jsqlparser.statement.delete.Delete; +import net.sf.jsqlparser.statement.delete.ParenthesedDelete; import net.sf.jsqlparser.statement.insert.ConflictActionType; import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.insert.InsertConflictAction; import net.sf.jsqlparser.statement.insert.InsertConflictTarget; +import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.select.AllColumns; import net.sf.jsqlparser.statement.select.AllTableColumns; import net.sf.jsqlparser.statement.select.FromItem; @@ -56,6 +59,7 @@ 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.ParenthesedUpdate; import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.update.UpdateSet; @@ -116,13 +120,34 @@ public final class QueryAnalyzer { /** 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 subquery source without an alias, which exposes no + * name its columns could be read through. + */ + private static final String UNALIASED_FROM_SUBQUERY_REJECTION = """ + A subquery in FROM requires an alias, such as (SELECT ...) AS recent."""; + + /** + * The rejection of a column list on a derived source, written on a + * {@code WITH} name or on a subquery alias, which renames the columns of + * its {@code SELECT} by position. + */ + private static final String DERIVED_SOURCE_COLUMN_LIST_REJECTION = """ + A column list on %s is not supported; alias the columns in its SELECT instead."""; + + /** + * The rejection of a derived source two of whose columns expose one name, + * which makes a reference to that name ambiguous. + */ + private static final String DERIVED_SOURCE_COLUMN_NAME_REJECTION = """ + Derived source %s has more than one column named %s; give each a distinct alias."""; - /** 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 a recursive {@code WITH} clause. */ + private static final String RECURSIVE_WITH_REJECTION = "WITH RECURSIVE is not supported."; + + /** The rejection of a {@code WITH} body that writes rows. */ + private static final String DATA_MODIFYING_WITH_REJECTION = """ + A data-modifying statement in WITH is not supported; run it as a query of its own."""; /** The rejection of an {@code INSERT} whose rows come from a query. */ private static final String INSERT_SELECT_REJECTION = """ @@ -173,8 +198,48 @@ public final class QueryAnalyzer { * its alias when present and otherwise its table name. A left-joined * source contributes no row when the join finds no match, so every column * read from it is nullable regardless of its schema declaration. + * + *

A derived source — a common table expression or a subquery source — + * carries the result columns of its own {@code SELECT} under its exposed + * name instead of a schema table's columns. + */ + private record Source( + String name, + dev.sqlcj.schema.Table table, + boolean derived, + boolean leftJoined + ) { + + /** + * The schema's declared name of the table whose complete row this + * source contributes, and {@code null} for a derived source, whose + * columns are specific to the query that declares it. + */ + private String rowTable() { + return derived + ? null + : table.name(); + } + } + + /** + * The inputs every analyzed {@code SELECT} of one statement shares: the + * parsed source, the schema it resolves against, and the placeholders its + * clauses type. */ - private record Source(String name, dev.sqlcj.schema.Table table, boolean leftJoined) { + private record Analysis(ParsedSql parsedSql, Schema schema, Placeholders placeholders) { + } + + /** + * The analyzed facts of one {@code SELECT}: the name the query writes for + * its first source, its ordered sources, and its result columns in + * selected order, which are the columns a derived source exposes. + */ + private record AnalyzedSelect( + String table, + List sources, + List columns + ) { } /** One column reference resolved against the ordered query sources. */ @@ -384,11 +449,12 @@ public QueryModel analyze(Query query, ParsedSql parsedSql, Schema schema) { } /** - * 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. + * Analyzes a {@code SELECT} statement, which is classified by the query it + * writes inside its parentheses, at any depth, so a parenthesized query is + * the query it encloses. The analyzed read is the top-level statement, so + * the annotation, the row table, and the query model are decided here, + * while the sources, the result columns, and the clause placeholders follow + * the rules every analyzed {@code SELECT} shares. */ private QueryModel analyzeSelectStatement( Query query, @@ -396,6 +462,60 @@ private QueryModel analyzeSelectStatement( Select select, Schema schema ) { + List selects = parenthesisLevels(select); + + PlainSelect plainSelect = requirePlainSelect(selects); + + return analyzePlainSelect( + plainSelect, + analysis, + analyzeCommonTableExpressions(selects, analysis, commonTableExpressions) + ); + } + + /** + * The {@code SELECT} of each parenthesis level of one query, outermost + * first, because a parenthesized query is the query it encloses, at any + * depth, and each level may declare a {@code WITH} list of its own. + */ + private List selects = new ArrayList<>(); Select enclosed = select; @@ -407,14 +527,21 @@ private QueryModel analyzeSelectStatement( selects.add(enclosed); } + return selects; + } + + /** + * Requires the query a {@code SELECT} encloses to be a single plain + * {@code SELECT}. 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 and before any + * {@code WITH} body is analyzed. + */ + private PlainSelect requirePlainSelect(List selects) { - List names = new ArrayList<>(); + private AnalyzedSelect analyzePlainSelect( + PlainSelect plainSelect, + Analysis analysis, + List commonTableExpressions + ) { + List sources = resolveSources(plainSelect, analysis, commonTableExpressions); + + List columns = resolveColumns(plainSelect, analysis, sources); + + resolveBindingParameters(plainSelect, sources, analysis.placeholders()); + + return new AnalyzedSelect( + declaredSourceName(plainSelect.getFromItem(), sources.get(0)), + sources, + columns + ); + } + + /** + * The name the query writes for its first source, which the analyzed model + * carries as the query's table: the table's own name as the query spells + * it, and the exposed name of a derived source, which names no table. + */ + private String declaredSourceName(FromItem fromItem, Source source) { + return source.derived() + ? source.name() + : ((Table) fromItem).getUnquotedName(); + } + + /** + * Analyzes the {@code WITH} items each parenthesis level of one query + * declares, in the order the query writes them, and reports the derived + * sources they declare after the ones the queries around it declared. + * + *

Every body is analyzed, whether or not a source reads it, so its + * placeholders are typed by its own clause rules. A body sees the common + * table expressions written before it and those of every enclosing query, + * so a name declared here reads the schema table of that name inside its + * own body. + */ + private List analyzeCommonTableExpressions( + List selects) { } for (WithItem withItem : withItems) { - names.add(withItem.getUnquotedAliasName()); + scope.add(analyzeCommonTableExpression(withItem, analysis, List.copyOf(scope))); } } - return List.copyOf(names); + return List.copyOf(scope); } - private QueryModel analyzeSelect( - Query query, - ParsedSql parsedSql, - PlainSelect plainSelect, - List commonTableExpressions, - Schema schema - ) { - requireResultQueryType(query); + /** + * Analyzes the {@code WITH} items of one statement, which are the only ones + * a write declares, because no clause of a supported write reads a source + * beside its target. + */ + private void analyzeCommonTableExpressions(List> withItems, Analysis analysis) { + if (withItems == null) { + return; + } - Table table = getTable(plainSelect, commonTableExpressions); + List scope = new ArrayList<>(); - List sources = resolveSources(plainSelect, table, commonTableExpressions, schema); + for (WithItem withItem : withItems) { + scope.add(analyzeCommonTableExpression(withItem, analysis, List.copyOf(scope))); + } + } - List columns = resolveColumns(plainSelect, parsedSql, sources, schema); + /** + * Analyzes one {@code WITH} item into the derived source it declares, whose + * columns are its body's result columns. A recursive item reads itself, and + * a data-modifying one writes rows, neither of which this subset models. + */ + private Source analyzeCommonTableExpression( + WithItem withItem, + Analysis analysis, + List commonTableExpressions + ) { + if (withItem.isRecursive()) { + throw new UnsupportedOperationException(RECURSIVE_WITH_REJECTION); + } - Placeholders placeholders = toPlaceholders(parsedSql, schema); + Select body = requireSelectBody(withItem); + String name = withItem.getUnquotedAliasName(); - resolveBindingParameters(plainSelect, sources, placeholders); + if (withItem.getWithItemList() != null) { + throw new UnsupportedOperationException( + DERIVED_SOURCE_COLUMN_LIST_REJECTION.formatted(name) + ); + } - return toQueryModel( - query, - parsedSql, - table.getUnquotedName(), - columns, - placeholders, - resolveSelectRowTable(plainSelect, sources) + return toDerivedSource( + name, + analyzeNestedSelect(body, analysis, commonTableExpressions).columns(), + false ); } + /** + * The {@code SELECT} one {@code WITH} item states inside its parentheses. A + * body that writes rows is rejected from the parsed statement kind, because + * reading such a body as a {@code SELECT} is an error of the parser's own + * model, and every other body states no query at all. + */ + private Select requireSelectBody(WithItem withItem) { + ParenthesedStatement statement = withItem.getParenthesedStatement(); + + if (statement instanceof ParenthesedSelect select) { + return select.getSelect(); + } + + if ( + statement instanceof ParenthesedInsert + || statement instanceof ParenthesedUpdate + || statement instanceof ParenthesedDelete + ) { + throw new UnsupportedOperationException(DATA_MODIFYING_WITH_REJECTION); + } + + throw new UnsupportedOperationException(UNSUPPORTED_STATEMENT_REJECTION); + } + private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert, Schema schema) { ReturningClause returningClause = insert.getReturningClause(); @@ -485,6 +698,13 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert Table table = insert.getTable(); Source source = toSource(table, schema); + Placeholders placeholders = toPlaceholders(parsedSql, schema); + + analyzeCommonTableExpressions( + insert.getWithItemsList(), + new Analysis(parsedSql, schema, placeholders) + ); + List columns = List.of(); String rowTable = null; @@ -495,8 +715,6 @@ private QueryModel analyzeInsert(Query query, ParsedSql parsedSql, Insert insert rowTable = resolveReturningRowTable(returningClause, source); } - Placeholders placeholders = toPlaceholders(parsedSql, schema); - resolveInsertParameters(insert, source.table(), placeholders); resolveConflictParameters(insert, source, placeholders); @@ -519,18 +737,21 @@ private QueryModel analyzeUpdate(Query query, ParsedSql parsedSql, Update update Table table = update.getTable(); Source source = toSource(table, schema); + Placeholders placeholders = toPlaceholders(parsedSql, schema); + + analyzeCommonTableExpressions( + update.getWithItemsList(), + new Analysis(parsedSql, schema, placeholders) + ); + List columns = List.of(); String rowTable = null; if (returningClause != null) { - requireNoCommonTableExpressions(update.getWithItemsList()); - columns = resolveReturningColumns(returningClause, source); rowTable = resolveReturningRowTable(returningClause, source); } - Placeholders placeholders = toPlaceholders(parsedSql, schema); - resolveUpdateSetParameters(update.getUpdateSets(), source.table(), placeholders); if (update.getWhere() != null) { @@ -556,18 +777,21 @@ private QueryModel analyzeDelete(Query query, ParsedSql parsedSql, Delete delete Table table = delete.getTable(); Source source = toSource(table, schema); + Placeholders placeholders = toPlaceholders(parsedSql, schema); + + analyzeCommonTableExpressions( + delete.getWithItemsList(), + new Analysis(parsedSql, schema, placeholders) + ); + List columns = List.of(); String rowTable = null; if (returningClause != null) { - requireNoCommonTableExpressions(delete.getWithItemsList()); - columns = resolveReturningColumns(returningClause, source); rowTable = resolveReturningRowTable(returningClause, source); } - Placeholders placeholders = toPlaceholders(parsedSql, schema); - if (delete.getWhere() != null) { resolveParameters(delete.getWhere(), List.of(source), placeholders); } @@ -692,7 +916,7 @@ private List resolveReturningItem(Expression expression, Alias alia ); } - return resolveAllColumns(source); + return toQueryColumns(resolveAllColumns(source)); } net.sf.jsqlparser.schema.Column returnedColumn = directColumn(expression); @@ -739,8 +963,6 @@ private void requireSupportedReturningInsert(Insert insert) { "RETURNING requires an INSERT with a single VALUES row." ); } - - requireNoCommonTableExpressions(insert.getWithItemsList()); } /** @@ -798,14 +1020,6 @@ private void requireSupportedDeleteSources(Delete delete) { } } - private void requireNoCommonTableExpressions(List withItems) { - if (withItems != null && !withItems.isEmpty()) { - throw new UnsupportedOperationException( - "Common table expressions are not supported in a returning write." - ); - } - } - /** * Resolves the {@code INSERT} parameters by pairing the explicit column * list with the single values row, which is also their textual order. A @@ -937,7 +1151,7 @@ private void resolveConflictParameters( if (action.getWhereExpression() != null) { resolveParameters( action.getWhereExpression(), - List.of(source, new Source(EXCLUDED_QUALIFIER, source.table(), false)), + List.of(source, new Source(EXCLUDED_QUALIFIER, source.table(), false, false)), placeholders ); } @@ -1273,86 +1487,181 @@ private void requireContiguousIndexes(List parameters) { } } - 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. + * Resolves one query source: a subquery in the clause, a source that names + * a common table expression in scope, which reads that expression rather + * than the schema table of the same name as PostgreSQL resolves it, or a + * table of the schema. A {@code LATERAL} source states a source this subset + * does not model at all, and is classified first. */ - private Table requireTableSource( + private Source resolveSource( FromItem fromItem, String unsupportedSourceRejection, - List commonTableExpressions + Analysis analysis, + List commonTableExpressions, + boolean leftJoined ) { - requireModeledSourceKind(fromItem); + requireNoLateralSource(fromItem); + + if (fromItem instanceof ParenthesedSelect subquery) { + return toSubquerySource(subquery, analysis, commonTableExpressions, leftJoined); + } if (!(fromItem instanceof Table table)) { throw new UnsupportedOperationException(unsupportedSourceRejection); } - requireNoCommonTableExpressionSource(table, commonTableExpressions); + Source commonTableExpression = findCommonTableExpression(table, commonTableExpressions); - return table; + if (commonTableExpression != null) { + return toDerivedSource( + table.getAlias() == null + ? commonTableExpression.name() + : table.getAlias().getUnquotedName(), + commonTableExpression.table().columns(), + leftJoined + ); + } + + return toSource(table, analysis.schema(), leftJoined); } /** - * 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. + * Rejects a {@code LATERAL} source, which reads the sources written before + * it, so it is never reported as a join kind that could be rewritten. */ - private void requireModeledSourceKind(FromItem fromItem) { + private void requireNoLateralSource(FromItem fromItem) { if (fromItem instanceof LateralSubSelect) { throw new UnsupportedOperationException(LATERAL_REJECTION); } + } + + /** + * Resolves a subquery source into the derived source its own + * {@code SELECT} exposes. The body sees only the common table expressions + * in scope and no source of the query around it, as PostgreSQL scopes a + * subquery source that is not {@code LATERAL}. + * + *

The alias is the only name the subquery's columns can be read through, + * so it is required, and a column list on it would rename those columns by + * position, which this subset does not model. + */ + private Source toSubquerySource( + ParenthesedSelect subquery, + Analysis analysis, + List commonTableExpressions, + boolean leftJoined + ) { + Alias alias = subquery.getAlias(); - if (fromItem instanceof ParenthesedSelect) { - throw new UnsupportedOperationException(FROM_SUBQUERY_REJECTION); + if (alias == null) { + throw new UnsupportedOperationException(UNALIASED_FROM_SUBQUERY_REJECTION); + } + + String name = alias.getUnquotedName(); + + if (alias.getAliasColumns() != null && !alias.getAliasColumns().isEmpty()) { + throw new UnsupportedOperationException( + DERIVED_SOURCE_COLUMN_LIST_REJECTION.formatted(name) + ); } + + return toDerivedSource( + name, + analyzeNestedSelect(subquery.getSelect(), analysis, commonTableExpressions).columns(), + leftJoined + ); } /** - * 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. + * The common table expression in scope one source names, and {@code null} + * when the source names none. Only an unqualified name can name one, and + * the innermost declaration of that name, compared ignoring case, is the + * one the source reads. */ - private void requireNoCommonTableExpressionSource(Table table, List commonTableExpressions) { + private Source findCommonTableExpression(Table table, List commonTableExpressions) { if (table.getSchemaName() != null) { - return; + return null; } String name = table.getUnquotedName(); - boolean commonTableExpression = commonTableExpressions.stream() - .anyMatch(declared -> declared.equalsIgnoreCase(name)); + for (int index = commonTableExpressions.size() - 1; index >= 0; index--) { + Source commonTableExpression = commonTableExpressions.get(index); + + if (commonTableExpression.name().equalsIgnoreCase(name)) { + return commonTableExpression; + } + } + + return null; + } + + /** + * One derived source — a common table expression or a subquery source — + * under the name the query exposes it as, whose columns are the result + * columns of its own {@code SELECT}. A left join of the source makes every + * one of them nullable, as it does for a table source. + */ + private Source toDerivedSource( + String name, + List columns, + boolean leftJoined + ) { + requireDistinctDerivedColumnNames(name, columns); + + return new Source( + name, + new dev.sqlcj.schema.Table(name, columns, List.of()), + true, + leftJoined + ); + } - if (commonTableExpression) { - throw new UnsupportedOperationException(CTE_SOURCE_REJECTION.formatted(name)); + /** + * Requires the columns of a derived source to expose distinct names, + * compared ignoring case as a column reference resolves them, because a + * repeated name makes every reference to it ambiguous. + */ + private void requireDistinctDerivedColumnNames( + String name, + List columns + ) { + for (int index = 0; index < columns.size(); index++) { + String columnName = columns.get(index).name(); + + boolean repeated = columns.subList(0, index).stream() + .anyMatch(column -> column.name().equalsIgnoreCase(columnName)); + + if (repeated) { + throw new UnsupportedOperationException( + DERIVED_SOURCE_COLUMN_NAME_REJECTION.formatted(name, columnName) + ); + } } } /** - * Resolves the ordered query sources from the base table followed by every - * joined table, rejecting an exposed name that repeats. + * Resolves the ordered query sources from the first source followed by + * every joined source, rejecting an exposed name that repeats. */ private List resolveSources( PlainSelect plainSelect, - Table table, - List commonTableExpressions, - Schema schema + Analysis analysis, + List commonTableExpressions ) { List sources = new ArrayList<>(); - addSource(sources, table, schema, false); + addSource( + sources, + resolveSource( + plainSelect.getFromItem(), + "Only table sources are supported.", + analysis, + commonTableExpressions, + false + ) + ); List joins = plainSelect.getJoins(); @@ -1361,12 +1670,7 @@ private List resolveSources( } for (Join join : joins) { - addSource( - sources, - requireSupportedJoin(join, commonTableExpressions), - schema, - join.isLeft() - ); + addSource(sources, requireSupportedJoin(join, analysis, commonTableExpressions)); resolveJoinConditionColumns(requireJoinCondition(join), sources); } @@ -1374,9 +1678,7 @@ private List resolveSources( return List.copyOf(sources); } - private Source addSource(List sources, Table table, Schema schema, boolean leftJoined) { - Source source = toSource(table, schema, leftJoined); - + private void addSource(List sources, Source source) { boolean duplicate = sources.stream() .anyMatch(existing -> existing.name().equalsIgnoreCase(source.name())); @@ -1387,11 +1689,9 @@ private Source addSource(List sources, Table table, Schema schema, boole } sources.add(source); - - return source; } - /** A base or write source, which always contributes a row of its own. */ + /** A write source, which always contributes a row of its own. */ private Source toSource(Table table, Schema schema) { return toSource(table, schema, false); } @@ -1402,6 +1702,7 @@ private Source toSource(Table table, Schema schema, boolean leftJoined) { ? table.getUnquotedName() : table.getAlias().getUnquotedName(), findSourceTable(schema, table), + false, leftJoined ); } @@ -1425,17 +1726,21 @@ private dev.sqlcj.schema.Table findSourceTable(Schema schema, Table table) { /** * Accepts only a bare {@code JOIN} or explicit {@code INNER JOIN}, or a * {@code LEFT JOIN} in its bare or {@code LEFT OUTER JOIN} spelling, of one - * table source. {@link Join#isInnerJoin()} and {@link Join#isLeft()} also - * report shapes that this subset excludes, so every excluded modifier is - * rejected explicitly. + * table or derived 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, List commonTableExpressions) { - requireModeledSourceKind(join.getRightItem()); + *

A {@code LATERAL} 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 Source requireSupportedJoin( + Join join, + Analysis analysis, + List commonTableExpressions + ) { + requireNoLateralSource(join.getRightItem()); requireSupportedJoinKind(join); if (!isSupportedInnerJoin(join) && !isSupportedLeftJoin(join)) { @@ -1444,10 +1749,12 @@ private Table requireSupportedJoin(Join join, List commonTableExpression ); } - return requireTableSource( + return resolveSource( join.getRightItem(), "Only table join sources are supported.", - commonTableExpressions + analysis, + commonTableExpressions, + join.isLeft() ); } @@ -2392,13 +2699,12 @@ private Placeholders toPlaceholders(ParsedSql parsedSql, Schema schema) { *

A subscripted reference projects an element rather than the column, so * it is rejected as any other computed item is, and a cast states its type. */ - private List resolveColumns( + private List resolveColumns( PlainSelect plainSelect, - ParsedSql parsedSql, - List sources, - Schema schema + Analysis analysis, + List sources ) { - List columns = new ArrayList<>(); + List columns = new ArrayList<>(); for (SelectItem selectItem : plainSelect.getSelectItems()) { Expression expression = selectItem.getExpression(); @@ -2431,12 +2737,14 @@ private List resolveColumns( dev.sqlcj.schema.Column schemaColumn = resolved.column(); columns.add( - new QueryColumn( + new dev.sqlcj.schema.Column( selectedColumnName(selectItem.getAlias(), schemaColumn), requireSupportedType(schemaColumn), schemaColumn.nullable() || resolved.source().leftJoined(), + null, schemaColumn.enumType(), - schemaColumn.array() + schemaColumn.array(), + schemaColumn.blankPadded() ) ); @@ -2450,13 +2758,13 @@ private List resolveColumns( } if (expression instanceof CastExpression cast && cast.getColDataType() != null) { - columns.add(castColumn(cast, selectItem.getAlias(), schema)); + columns.add(castColumn(cast, selectItem.getAlias(), analysis.schema())); continue; } if (expression instanceof JdbcParameter || expression instanceof JdbcNamedParameter) { - throw untypedPlaceholder(occurrenceOf(parsedSql, expression)); + throw untypedPlaceholder(occurrenceOf(analysis.parsedSql(), expression)); } throw new UnsupportedOperationException( @@ -2498,14 +2806,14 @@ private boolean isScalarCount(Function function) { * {@code count(*)} returns {@code bigint}, and it is non-null because a * count is always a number, {@code 0} when no row matches. */ - private QueryColumn scalarCountColumn(Alias alias) { + private dev.sqlcj.schema.Column scalarCountColumn(Alias alias) { if (alias == null) { throw new UnsupportedOperationException( "COUNT(*) requires a result alias, such as COUNT(*) AS total." ); } - return new QueryColumn(alias.getUnquotedName(), ColumnType.BIGINT, false); + return new dev.sqlcj.schema.Column(alias.getUnquotedName(), ColumnType.BIGINT, false); } /** @@ -2517,7 +2825,7 @@ private QueryColumn scalarCountColumn(Alias alias) { *

The operand itself reaches the database as written, as a non-binding * write value does, so PostgreSQL rather than sqlcj checks it. */ - private QueryColumn castColumn(CastExpression cast, Alias alias, Schema schema) { + private dev.sqlcj.schema.Column castColumn(CastExpression cast, Alias alias, Schema schema) { if (alias == null) { throw new UnsupportedOperationException( "A cast projection requires a result alias, such as SUM(amount)::numeric AS total." @@ -2538,7 +2846,15 @@ private QueryColumn castColumn(CastExpression cast, Alias alias, Schema schema) ); } - return new QueryColumn(name, type.type(), true, type.enumType(), type.array()); + return new dev.sqlcj.schema.Column( + name, + type.type(), + true, + null, + type.enumType(), + type.array(), + type.blankPadded() + ); } /** @@ -2547,6 +2863,9 @@ private QueryColumn castColumn(CastExpression cast, Alias alias, Schema schema) * {@code qualifier.*}, and {@code null} for every other projection. The * name is the schema's own spelling of the table, so a query-side alias or * spelling never splits one row identity. + * + *

A derived source names no table, so a read over one returns a result + * specific to its own query even when the source is named like a table. */ private String resolveSelectRowTable(PlainSelect plainSelect, List sources) { List> selectItems = plainSelect.getSelectItems(); @@ -2556,10 +2875,28 @@ private String resolveSelectRowTable(PlainSelect plainSelect, List sourc } return selectItems.get(0).getExpression() instanceof AllColumns - ? sources.get(0).table().name() + ? sources.get(0).rowTable() : null; } + /** + * The analyzed result columns of one query, which carry the facts code + * generation reads from a selected column. + */ + private List toQueryColumns(List columns) { + return columns.stream() + .map( + column -> new QueryColumn( + column.name(), + column.type(), + column.nullable(), + column.enumType(), + column.array() + ) + ) + .toList(); + } + /** * Names a selected direct column after its explicit alias when the * projection declares one, so the alias reaches Java naming. The column's @@ -2662,15 +2999,22 @@ private dev.sqlcj.schema.Table findTable(Schema schema, String tableName) { ); } - private List resolveAllColumns(Source source) { + /** + * The columns one source exposes in its own order, each nullable when the + * source is left-joined, which is the expansion of a wildcard over that + * source. + */ + private List resolveAllColumns(Source source) { return source.table().columns().stream() .map( - column -> new QueryColumn( + column -> new dev.sqlcj.schema.Column( column.name(), requireSupportedType(column), column.nullable() || source.leftJoined(), + null, column.enumType(), - column.array() + column.array(), + column.blankPadded() ) ) .toList(); 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 652db18..6608486 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -3979,9 +3979,9 @@ void shouldResolveCastPlaceholderOfSubquery(String sql, String name) { } /** - * A {@code WITH} body is carried unanalyzed, so its cast placeholder is - * typed by that cast alone and binds before the analyzed clauses that - * follow it. + * A {@code WITH} body is analyzed by its own clause rules, so its cast + * placeholder is typed by that cast, named after the column it is compared + * with, and binds before the clauses that follow it. */ @ParameterizedTest @CsvSource( @@ -4003,7 +4003,7 @@ void shouldResolveCastPlaceholderOfWithBody(String sql, QueryType type) { assertEquals( List.of( - new QueryParameter(1, "param1", ColumnType.DECIMAL), + new QueryParameter(1, "total", ColumnType.DECIMAL), new QueryParameter(2, "id", ColumnType.BIGINT) ), model.parameters() @@ -4628,8 +4628,6 @@ void shouldRejectUnknownReturningColumn() { + "|RETURNING requires an INSERT with a single VALUES row.", "INSERT INTO users (id) VALUES ($1), ($2) RETURNING id" + "|INSERT requires a single VALUES row.", - "WITH known AS (SELECT id FROM users) INSERT INTO users (id) VALUES ($1) RETURNING id" - + "|Common table expressions are not supported in a returning write.", "UPDATE users SET name = $2 FROM profiles WHERE users.id = $1 RETURNING id" + "|UPDATE ... FROM and joined updates are not supported.", "DELETE FROM users USING profiles WHERE users.id = $1 RETURNING id" @@ -6637,4 +6635,545 @@ void shouldResolveAPublicQualifiedEnumCastType() { .getMessage() ); } + + /** + * A source that names a common table expression reads that expression, so + * its result columns are the body's own columns and an uncast placeholder + * of the body is typed and named from the column it is compared with. + */ + @Test + void shouldResolveCommonTableExpressionSource() { + String sql = """ + WITH recent AS (SELECT user_id, total + FROM orders + WHERE total = $1) + SELECT recent.user_id, recent.total + FROM recent + """; + + QueryModel model = analyzer.analyze( + new Query("ListRecentOrders", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals("recent", model.table()); + assertNull(model.rowTable()); + + assertEquals( + List.of( + new QueryColumn("user_id", ColumnType.BIGINT, false), + new QueryColumn("total", ColumnType.DECIMAL, true) + ), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "total", ColumnType.DECIMAL)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + } + + /** + * A common table expression sees the ones written before it in its own + * list, so a chain of expressions resolves its columns through each link, + * and an aliased source exposes the chain's last link under its alias. + */ + @Test + void shouldResolveChainedCommonTableExpressions() { + String sql = """ + WITH matching AS (SELECT id, user_id, total + FROM orders + WHERE total = $1), + totals AS (SELECT user_id, total AS amount + FROM matching) + SELECT t.user_id, t.amount + FROM totals t + """; + + QueryModel model = analyzer.analyze( + new Query("ListOrderTotals", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals("t", model.table()); + + assertEquals( + List.of( + new QueryColumn("user_id", ColumnType.BIGINT, false), + new QueryColumn("amount", ColumnType.DECIMAL, true) + ), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "total", ColumnType.DECIMAL)), + model.parameters() + ); + } + + /** + * A common table expression named like a schema table shadows that table + * for the sources written after it, while its own body reads the table, as + * PostgreSQL scopes it. + */ + @Test + void shouldReadTheSchemaTableInsideACommonTableExpressionNamedLikeIt() { + String sql = """ + WITH orders AS (SELECT id, total + FROM orders + WHERE total = $1) + SELECT orders.id + FROM orders + """; + + QueryModel model = analyzer.analyze( + new Query("ListOrderIds", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "total", ColumnType.DECIMAL)), + model.parameters() + ); + } + + /** + * A subquery source analyzes its own {@code WITH} list, so a common table + * expression declared inside it is a source of that body alone. + */ + @Test + void shouldResolveCommonTableExpressionDeclaredInsideASubquerySource() { + String sql = """ + SELECT s.total + FROM (WITH matching AS (SELECT total + FROM orders + WHERE total = $1) + SELECT total + FROM matching) s + """; + + QueryModel model = analyzer.analyze( + new Query("ListTotals", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals("s", model.table()); + + assertEquals( + List.of(new QueryColumn("total", ColumnType.DECIMAL, true)), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "total", ColumnType.DECIMAL)), + model.parameters() + ); + } + + /** + * A subquery may be joined like a table, and its columns are read through + * its alias. + */ + @Test + void shouldResolveSubquerySourceOfAJoin() { + String sql = """ + SELECT u.name, s.total + FROM users u + JOIN (SELECT user_id, total + FROM orders + WHERE total = $1) s ON s.user_id = u.id + """; + + QueryModel model = analyzer.analyze( + new Query("ListUserOrders", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals("users", model.table()); + + assertEquals( + List.of( + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("total", ColumnType.DECIMAL, true) + ), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "total", ColumnType.DECIMAL)), + model.parameters() + ); + } + + /** + * A left join of a derived source reads every one of its columns as + * nullable, including the ones its body declares {@code NOT NULL}, both + * selected by name and expanded by a wildcard. + */ + @Test + void shouldReadColumnsOfALeftJoinedDerivedSourceAsNullable() { + String sql = """ + WITH totals AS (SELECT user_id, total + FROM orders) + SELECT u.id, totals.user_id, totals.* + FROM users u + LEFT JOIN totals ON totals.user_id = u.id + """; + + QueryModel model = analyzer.analyze( + new Query("ListUserTotals", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("user_id", ColumnType.BIGINT, true), + new QueryColumn("user_id", ColumnType.BIGINT, true), + new QueryColumn("total", ColumnType.DECIMAL, true) + ), + model.columns() + ); + } + + /** + * A derived source exposes an aliased {@code COUNT(*)} and an aliased cast + * as columns of its own, so a reader of the source takes their types + * without restating the cast. + */ + @Test + void shouldResolveCountAndCastColumnsThroughADerivedSource() { + String sql = """ + WITH stats AS (SELECT user_id, + COUNT(*) AS order_count, + SUM(total)::numeric AS amount + FROM orders + GROUP BY user_id) + SELECT stats.user_id, stats.order_count, stats.amount + FROM stats + """; + + QueryModel model = analyzer.analyze( + new Query("ListOrderStats", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryColumn("user_id", ColumnType.BIGINT, false), + new QueryColumn("order_count", ColumnType.BIGINT, false), + new QueryColumn("amount", ColumnType.DECIMAL, true) + ), + model.columns() + ); + } + + /** + * A derived source returns no shared table row, so {@code SELECT *} over a + * common table expression or a subquery returns a result specific to its + * own query, even when the source is named like a schema table. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "WITH users AS (SELECT id, name FROM users) SELECT * FROM users|users", + "SELECT * FROM (SELECT id, name FROM users) s|s" + } + ) + void shouldResolveQuerySpecificResultForAllColumnsOfADerivedSource(String sql, String table) { + QueryModel model = analyzer.analyze( + new Query("ListUsers", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals(table, model.table()); + assertNull(model.rowTable()); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + model.columns() + ); + } + + /** + * Every placeholder binds in the textual order the parser reported, + * whatever order the analyzer resolves the scopes in, so a common table + * expression body, a subquery source, and the outer query bind in the order + * the query writes them. + */ + @Test + void shouldBindDerivedSourceParametersInTextualOrder() { + String sql = """ + WITH recent AS (SELECT user_id + FROM orders + WHERE total = $3) + SELECT s.user_id + FROM (SELECT user_id + FROM recent + WHERE user_id = $2) s + WHERE s.user_id = $1 + """; + + QueryModel model = analyzer.analyze( + new Query("ListRecentUsers", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "user_id", ColumnType.BIGINT), + new QueryParameter(2, "user_id", ColumnType.BIGINT), + new QueryParameter(3, "total", ColumnType.DECIMAL) + ), + model.parameters() + ); + + assertEquals(List.of(3, 2, 1), model.bindingParameterIndexes()); + } + + /** + * A returning write may declare a common table expression, whose body is + * analyzed by its own clause rules while the returned columns stay the + * write target's. + */ + @Test + void shouldAnalyzeReturningWriteWithACommonTableExpression() { + String sql = """ + WITH known AS (SELECT id + FROM users + WHERE name = $1) + INSERT INTO users (id) + VALUES ($2) + RETURNING id + """; + + QueryModel model = analyzer.analyze( + new Query("CreateUser", QueryType.ONE, sql), + parser.parse(sql), + schema + ); + + assertEquals("users", model.table()); + assertNull(model.rowTable()); + + assertEquals( + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + model.columns() + ); + + assertEquals( + List.of( + new QueryParameter(1, "name", ColumnType.VARCHAR), + new QueryParameter(2, "id", ColumnType.BIGINT) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2), model.bindingParameterIndexes()); + } + + /** + * The common table expressions of a write without {@code RETURNING} are + * analyzed as well, so an uncast placeholder of a body is typed from the + * column it is compared with. + */ + @Test + void shouldAnalyzeCommonTableExpressionOfAWriteWithoutReturning() { + String sql = """ + WITH recent AS (SELECT user_id + FROM orders + WHERE total = $1) + DELETE FROM users + WHERE id = $2 + """; + + QueryModel model = analyzer.analyze( + new Query("DeleteUser", QueryType.EXEC, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryParameter(1, "total", ColumnType.DECIMAL), + new QueryParameter(2, "id", ColumnType.BIGINT) + ), + model.parameters() + ); + + assertEquals(List.of(1, 2), model.bindingParameterIndexes()); + } + + /** + * A common table expression no source reads is analyzed too, so its body's + * placeholders are typed, and a subquery that reads it in a predicate + * keeps today's handling. + */ + @Test + void shouldAnalyzeACommonTableExpressionReadOnlyInsideAPredicateSubquery() { + String sql = """ + WITH recent AS (SELECT user_id + FROM orders + WHERE total = $1) + SELECT id + FROM users + WHERE id IN (SELECT user_id FROM recent) + """; + + QueryModel model = analyzer.analyze( + new Query("ListRecentUsers", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals("users", model.table()); + + assertEquals( + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "total", ColumnType.DECIMAL)), + model.parameters() + ); + } + + /** + * A {@code MATERIALIZED} and a {@code NOT MATERIALIZED} clause state how + * PostgreSQL evaluates the body, not what it returns, so both analyze like + * a plain one. + */ + @ParameterizedTest + @ValueSource(strings = { "MATERIALIZED ", "NOT MATERIALIZED ", "" }) + void shouldAcceptMaterializedCommonTableExpression(String materialized) { + String sql = "WITH recent AS %s(SELECT id FROM orders WHERE total = $1) SELECT id FROM recent" + .formatted(materialized); + + QueryModel model = analyzer.analyze( + new Query("ListRecentOrders", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of(new QueryColumn("id", ColumnType.BIGINT, false)), + model.columns() + ); + + assertEquals( + List.of(new QueryParameter(1, "total", ColumnType.DECIMAL)), + model.parameters() + ); + } + + /** + * A subquery source that is not {@code LATERAL} sees no source of the query + * around it, as PostgreSQL scopes it, so a reference to one is an unknown + * qualifier. + */ + @Test + void shouldRejectAnOuterSourceReferenceInsideASubquerySource() { + String sql = "SELECT s.name FROM users u JOIN (SELECT u.name FROM orders) s ON TRUE"; + + Query query = new Query("ListUserOrders", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> analyzer.analyze(query, parsedSql, joinSchema) + ); + + assertEquals("Unknown source qualifier in query: u", exception.getMessage()); + } + + /** + * The derived-source forms outside this subset are rejected with their own + * diagnostic: a recursive and a data-modifying {@code WITH} item, a + * subquery source without an alias, a column list on a {@code WITH} name or + * on a subquery alias, a body that is not a plain {@code SELECT}, which is + * classified inside its own parentheses, a {@code LATERAL} source, which is + * classified first, a doubly parenthesized source, which states a source + * kind of its own, and a derived source whose columns repeat a name. + */ + @ParameterizedTest + @CsvSource( + delimiter = '|', + quoteCharacter = '"', + value = { + "MANY|WITH RECURSIVE recent AS (SELECT id FROM users) SELECT id FROM recent" + + "|WITH RECURSIVE is not supported.", + "MANY|WITH moved AS (UPDATE users SET name = $1 WHERE id = $2 RETURNING id)" + + " SELECT id FROM moved" + + "|A data-modifying statement in WITH is not supported;" + + " run it as a query of its own.", + "EXEC|WITH removed AS (DELETE FROM users WHERE id = $1 RETURNING id)" + + " DELETE FROM users WHERE id = $2" + + "|A data-modifying statement in WITH is not supported;" + + " run it as a query of its own.", + "EXEC|WITH added AS (INSERT INTO users (id) VALUES ($1) RETURNING id)" + + " UPDATE users SET name = $2 WHERE id = $3" + + "|A data-modifying statement in WITH is not supported;" + + " run it as a query of its own.", + "MANY|WITH rows AS (VALUES (1)) SELECT * FROM rows" + + "|Only SELECT, INSERT, UPDATE, and DELETE statements are supported.", + "MANY|WITH combined AS (SELECT id FROM users UNION SELECT id FROM profiles)" + + " SELECT id FROM combined" + + "|UNION, INTERSECT, and EXCEPT are not supported; write one query per SELECT.", + "MANY|SELECT s.id FROM (SELECT id FROM users UNION SELECT id FROM profiles) s" + + "|UNION, INTERSECT, and EXCEPT are not supported; write one query per SELECT.", + "MANY|WITH recent (n) AS (SELECT id FROM users) SELECT n FROM recent" + + "|A column list on recent is not supported;" + + " alias the columns in its SELECT instead.", + "MANY|SELECT s.n FROM (SELECT id FROM users) AS s(n)" + + "|A column list on s is not supported; alias the columns in its SELECT instead.", + "MANY|SELECT id FROM (SELECT id FROM users)" + + "|A subquery in FROM requires an alias, such as (SELECT ...) AS recent.", + "MANY|SELECT u.id FROM users u JOIN (SELECT user_id FROM orders) ON TRUE" + + "|A subquery in FROM requires an alias, such as (SELECT ...) AS recent.", + "MANY|SELECT s.id FROM LATERAL (SELECT id FROM users) s" + + "|LATERAL is not supported.", + "MANY|SELECT s.id FROM ((SELECT id FROM users)) s" + + "|Only table sources are supported.", + "MANY|WITH pairs AS (SELECT u.id, p.id FROM users u JOIN profiles p" + + " ON p.user_id = u.id) SELECT * FROM pairs" + + "|Derived source pairs has more than one column named id;" + + " give each a distinct alias.", + "MANY|SELECT s.id FROM (SELECT id, name AS ID FROM users) s" + + "|Derived source s has more than one column named ID;" + + " give each a distinct alias." + } + ) + void shouldRejectExcludedDerivedSourceForm(QueryType type, String sql, String message) { + Query query = new Query("ReadUsers", type, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, joinSchema) + ); + + assertEquals(message, exception.getMessage()); + } } diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java index a9a6ecf..8da8321 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -3222,6 +3222,96 @@ INSERT INTO customer_orders (id, customer_id, quantity, status) } } + /** + * Covers derived sources end to end: a report over a left-joined common + * table expression and a read over a subquery source are generated, + * compiled, and executed against PostgreSQL, so the columns their bodies + * state are read through the generated result records, and an unmatched + * row reads the common table expression's column as {@code null}. + */ + @Test + void shouldExecuteGeneratedDerivedSourcesAgainstPostgres() throws Exception { + execute(CONSTRAINT_SCHEMA); + + Path classesDirectory = generateAndCompile( + CONSTRAINT_SCHEMA, + """ + -- name: ListCustomerOrderTotals :many + WITH totals AS (SELECT customer_id, SUM(quantity)::int AS ordered + FROM customer_orders + WHERE quantity >= :minQuantity + GROUP BY customer_id) + SELECT c.name, totals.ordered + FROM customers c + LEFT JOIN totals ON totals.customer_id = c.id + ORDER BY c.name; + + -- name: ListRecentCustomerOrders :many + SELECT recent.id, recent.quantity + FROM (SELECT id, quantity + FROM customer_orders + WHERE customer_id = :customerId + ORDER BY id DESC + LIMIT :pageSize) recent + ORDER BY recent.id; + """ + ); + + execute("INSERT INTO customers (id, name) VALUES (1, 'Alice'), (2, 'Bob')"); + + execute( + """ + INSERT INTO customer_orders (id, customer_id, quantity) + VALUES (10, 1, 3), (20, 1, 4), (30, 1, 1) + """ + ); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method totals = repository.getClass().getMethod( + "listCustomerOrderTotals", + Integer.class + ); + + List reports = (List) totals.invoke(repository, 2); + + assertEquals(2, reports.size()); + + assertEquals( + List.of("name", "ordered"), + recordComponentNames(reports.get(0)) + ); + + assertEquals("Alice", component(reports.get(0), "name")); + assertEquals(7, component(reports.get(0), "ordered")); + + assertEquals("Bob", component(reports.get(1), "name")); + assertNull(component(reports.get(1), "ordered")); + + Method recent = repository.getClass().getMethod( + "listRecentCustomerOrders", + Long.class, + Integer.class + ); + + List orders = (List) recent.invoke(repository, 1L, 2); + + assertEquals(2, orders.size()); + + assertEquals( + List.of("id", "quantity"), + recordComponentNames(orders.get(0)) + ); + + assertEquals(20L, component(orders.get(0), "id")); + assertEquals(4, component(orders.get(0), "quantity")); + + assertEquals(30L, component(orders.get(1), "id")); + assertEquals(1, component(orders.get(1), "quantity")); + } + } + /** * Covers a returning insert: the database-generated serial values and the * target-table order of {@code RETURNING *} are read through the generated diff --git a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/ReferenceServiceIntegrationTest.java b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/ReferenceServiceIntegrationTest.java index 283d3e7..4d23411 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/ReferenceServiceIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/ReferenceServiceIntegrationTest.java @@ -143,16 +143,6 @@ class ReferenceServiceIntegrationTest { "ListProjectsWithLatestTask", "LATERAL is not supported." ), - new UnsupportedQuery( - "cte_source.sql", - "ListOpenTaskTitles", - "A common table expression used as a source is not supported: open_tasks" - ), - new UnsupportedQuery( - "from_subquery.sql", - "ListRecentTaskTitles", - "A subquery in FROM is not supported." - ), new UnsupportedQuery( "multi_row_values.sql", "CreateTwoProjects", 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 a17c71f..09b1863 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/SqlcjCompilerIntegrationTest.java @@ -2437,18 +2437,25 @@ private static Stream rejectedQueries() { ), Arguments.of( ":many", - "SELECT s.id FROM (SELECT id FROM users) s", - "A subquery in FROM is not supported." + "SELECT s.id FROM (SELECT id FROM users) AS s(n)", + "A column list on s is not supported; alias the columns in its SELECT instead." ), 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" + "SELECT id FROM (SELECT id FROM users)", + "A subquery in FROM requires an alias, such as (SELECT ...) AS 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" + "WITH RECURSIVE recent AS (SELECT id FROM users) SELECT id FROM recent", + "WITH RECURSIVE is not supported." + ), + Arguments.of( + ":many", + "WITH moved AS (DELETE FROM users WHERE id = $1 RETURNING id)" + + " SELECT id FROM moved", + "A data-modifying statement in WITH is not supported;" + + " run it as a query of its own." ), Arguments.of( ":exec", @@ -3858,6 +3865,64 @@ WHERE EXISTS ( ); } + /** + * A common table expression and a subquery source generate compilable Java + * whose result components carry the types their bodies state, with the + * columns of a left-joined derived source nullable. + */ + @Test + void shouldGenerateCompilableJavaForDerivedSources() throws IOException { + generateAndCompile( + JOIN_SCHEMA, + """ + -- name: ListUserOrderTotals :many + WITH totals AS (SELECT user_id, SUM(total)::numeric AS amount + FROM orders + WHERE total = $1 + GROUP BY user_id) + SELECT u.name, totals.amount + FROM users u + LEFT JOIN totals ON totals.user_id = u.id; + + -- name: ListRecentOrders :many + SELECT recent.id, recent.total + FROM (SELECT id, total + FROM orders + WHERE user_id = $1 + ORDER BY id DESC + LIMIT $2) recent; + """ + ); + + String source = Files.readString( + tempDir.resolve("generated/generated/UsersRepository.java") + ); + + assertTrue( + source.contains( + "public List listUserOrderTotals(BigDecimal total)" + ), + source + ); + + assertEquals( + List.of("String name", "BigDecimal amount"), + recordComponents(source, "ListUserOrderTotalsResult") + ); + + assertTrue( + source.contains( + "public List listRecentOrders(Long userId, Integer limit)" + ), + source + ); + + assertEquals( + List.of("Long id", "BigDecimal total"), + recordComponents(source, "ListRecentOrdersResult") + ); + } + /** * A list predicate generates one {@code List} method parameter of the * compared column's element type, bound as one server array, in compilable diff --git a/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/cte_source.sql b/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/cte_source.sql deleted file mode 100644 index f85a6da..0000000 --- a/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/cte_source.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Outside the supported set: a common table expression is not a source. --- name: ListOpenTaskTitles :many -WITH open_tasks AS (SELECT id, title - FROM tasks - WHERE status = 'open') -SELECT title -FROM open_tasks; diff --git a/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/from_subquery.sql b/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/from_subquery.sql deleted file mode 100644 index 8288821..0000000 --- a/sqlcj-cli/src/test/resources/reference-service/unsupported/queries/from_subquery.sql +++ /dev/null @@ -1,7 +0,0 @@ --- Outside the supported set: sqlcj models no subquery source. --- name: ListRecentTaskTitles :many -SELECT recent.title -FROM (SELECT t.title - FROM tasks t - ORDER BY t.id DESC - LIMIT 10) recent;