From d03be3336ab6b6c02e754d7ebe1c01bb831f2d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Fri, 25 Sep 2026 20:31:59 +0300 Subject: [PATCH 1/2] feat(analysis): accept LEFT JOIN with nullable joined columns --- .../dev/sqlcj/analysis/QueryAnalyzer.java | 102 +++++--- .../dev/sqlcj/analysis/QueryAnalyzerTest.java | 228 ++++++++++++++++++ .../compiler/PostgresIntegrationTest.java | 54 +++++ 3 files changed, 353 insertions(+), 31 deletions(-) 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 f71c39f..40e4e47 100644 --- a/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java +++ b/sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java @@ -67,9 +67,11 @@ public final class QueryAnalyzer { /** * One query source and the name it exposes to column references, which is - * its alias when present and otherwise its table name. + * 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. */ - private record Source(String name, dev.sqlcj.schema.Table table) { + private record Source(String name, dev.sqlcj.schema.Table table, boolean leftJoined) { } /** One column reference resolved against the ordered query sources. */ @@ -602,7 +604,7 @@ private Table getTable(PlainSelect plainSelect) { private List resolveSources(PlainSelect plainSelect, Table table, Schema schema) { List sources = new ArrayList<>(); - addSource(sources, table, schema); + addSource(sources, table, schema, false); List joins = plainSelect.getJoins(); @@ -611,7 +613,12 @@ private List resolveSources(PlainSelect plainSelect, Table table, Schema } for (Join join : joins) { - Source joined = addSource(sources, requireSupportedJoin(join), schema); + Source joined = addSource( + sources, + requireSupportedJoin(join), + schema, + join.isLeft() + ); requireJoinCondition(join, sources, joined); } @@ -619,8 +626,8 @@ private List resolveSources(PlainSelect plainSelect, Table table, Schema return List.copyOf(sources); } - private Source addSource(List sources, Table table, Schema schema) { - Source source = toSource(table, schema); + private Source addSource(List sources, Table table, Schema schema, boolean leftJoined) { + Source source = toSource(table, schema, leftJoined); boolean duplicate = sources.stream() .anyMatch(existing -> existing.name().equalsIgnoreCase(source.name())); @@ -636,29 +643,72 @@ private Source addSource(List sources, Table table, Schema schema) { return source; } + /** A base or write source, which always contributes a row of its own. */ private Source toSource(Table table, Schema schema) { + return toSource(table, schema, false); + } + + private Source toSource(Table table, Schema schema, boolean leftJoined) { return new Source( table.getAlias() == null ? table.getUnquotedName() : table.getAlias().getUnquotedName(), - findTable(schema, table.getUnquotedName()) + findTable(schema, table.getUnquotedName()), + leftJoined ); } /** - * Accepts only a bare {@code JOIN} or explicit {@code INNER JOIN} of one - * table source. {@link Join#isInnerJoin()} also reports shapes that this - * subset excludes, so every excluded modifier is rejected explicitly. + * 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. */ private Table requireSupportedJoin(Join join) { - boolean supported = join.isInnerJoin() - && !join.isSimple() + if (!isSupportedInnerJoin(join) && !isSupportedLeftJoin(join)) { + throw new UnsupportedOperationException( + "Only unmodified INNER JOIN and LEFT JOIN clauses are supported." + ); + } + + if (!(join.getRightItem() instanceof Table table)) { + throw new UnsupportedOperationException( + "Only table join sources are supported." + ); + } + + return table; + } + + private boolean isSupportedInnerJoin(Join join) { + return join.isInnerJoin() && !join.isOuter() && !join.isLeft() && !join.isRight() && !join.isFull() && !join.isCross() && !join.isNatural() + && hasNoOtherJoinModifier(join); + } + + /** + * Reports a {@code LEFT JOIN}, whose {@code LEFT OUTER JOIN} spelling also + * sets the {@code OUTER} keyword. Every other qualifier, including the bare + * {@code OUTER JOIN} that sets no side, is excluded. + */ + private boolean isSupportedLeftJoin(Join join) { + return join.isLeft() + && !join.isInner() + && !join.isRight() + && !join.isFull() + && !join.isCross() + && !join.isNatural() + && hasNoOtherJoinModifier(join); + } + + private boolean hasNoOtherJoinModifier(Join join) { + return !join.isSimple() && !join.isSemi() && !join.isApply() && !join.isStraight() @@ -666,20 +716,6 @@ private Table requireSupportedJoin(Join join) { && !join.isWindowJoin() && join.getJoinHint() == null && join.getUsingColumns().isEmpty(); - - if (!supported) { - throw new UnsupportedOperationException( - "Only unmodified INNER JOIN clauses are supported." - ); - } - - if (!(join.getRightItem() instanceof Table table)) { - throw new UnsupportedOperationException( - "Only table join sources are supported." - ); - } - - return table; } /** @@ -1198,7 +1234,9 @@ private void requireIndexedParameter(Expression expression) { /** * Resolves the selected columns in declared order, expanding {@code *} * across the query sources in their declared order and - * {@code qualifier.*} across one source, each in schema column order. + * {@code qualifier.*} across one source, each in schema column order. A + * column of a left-joined source is nullable even when its schema + * declaration is not, because an unmatched row reads it as {@code NULL}. */ private List resolveColumns(PlainSelect plainSelect, List sources) { List columns = new ArrayList<>(); @@ -1228,13 +1266,14 @@ private List resolveColumns(PlainSelect plainSelect, List s } if (expression instanceof net.sf.jsqlparser.schema.Column column) { - dev.sqlcj.schema.Column schemaColumn = resolveColumn(column, sources).column(); + ResolvedColumn resolved = resolveColumn(column, sources); + dev.sqlcj.schema.Column schemaColumn = resolved.column(); columns.add( new QueryColumn( selectedColumnName(selectItem.getAlias(), schemaColumn), schemaColumn.type(), - schemaColumn.nullable() + schemaColumn.nullable() || resolved.source().leftJoined() ) ); @@ -1330,7 +1369,8 @@ private String resolveSelectRowTable(PlainSelect plainSelect, List sourc /** * Names a selected direct column after its explicit alias when the * projection declares one, so the alias reaches Java naming. The column's - * type and nullability still come from the schema column. + * type still comes from the schema column, and its nullability from the + * schema column and its source. */ private String selectedColumnName(Alias alias, dev.sqlcj.schema.Column schemaColumn) { return alias == null @@ -1418,7 +1458,7 @@ private List resolveAllColumns(Source source) { column -> new QueryColumn( column.name(), column.type(), - column.nullable() + column.nullable() || source.leftJoined() ) ) .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 72e55f2..8d38f29 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/analysis/QueryAnalyzerTest.java @@ -1736,6 +1736,166 @@ void shouldAnalyzeExplicitInnerJoinWithMultipleSources() { ); } + /** + * A left-joined source contributes no row when the join finds no match, so + * every column projected from it is nullable even when its schema + * declaration is not. Both accepted spellings are analyzed identically. + */ + @ParameterizedTest + @ValueSource(strings = { "LEFT JOIN", "LEFT OUTER JOIN" }) + void shouldAnalyzeLeftJoinWithNullableJoinedColumns(String joinKeywords) { + String sql = """ + SELECT u.id, p.id AS profile_id, p.nickname + FROM users u + %s profiles p ON p.user_id = u.id + """.formatted(joinKeywords); + + QueryModel model = analyzer.analyze( + new Query("ListUserProfiles", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("profile_id", ColumnType.BIGINT, true), + new QueryColumn("nickname", ColumnType.VARCHAR, true) + ), + model.columns() + ); + } + + @Test + void shouldExpandAllColumnsOfLeftJoinedSourceAsNullable() { + String sql = """ + SELECT * + FROM users u + LEFT JOIN profiles p ON p.user_id = u.id + """; + + QueryModel model = analyzer.analyze( + new Query("ListUserProfiles", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("name", ColumnType.VARCHAR, true), + new QueryColumn("id", ColumnType.BIGINT, true), + new QueryColumn("user_id", ColumnType.BIGINT, true), + new QueryColumn("nickname", ColumnType.VARCHAR, true) + ), + model.columns() + ); + } + + @Test + void shouldExpandQualifiedAllColumnsOfLeftJoinedSourceAsNullable() { + String sql = """ + SELECT p.*, u.name + FROM users u + LEFT JOIN profiles p ON p.user_id = u.id + """; + + QueryModel model = analyzer.analyze( + new Query("ListProfiles", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, true), + new QueryColumn("user_id", ColumnType.BIGINT, true), + new QueryColumn("nickname", ColumnType.VARCHAR, true), + new QueryColumn("name", ColumnType.VARCHAR, true) + ), + model.columns() + ); + } + + /** + * Inner and left joins may be chained in either order, and only the + * left-joined sources become nullable. + */ + @Test + void shouldAnalyzeLeftJoinAfterInnerJoin() { + String sql = """ + SELECT p.id, p.nickname, o.id, o.total + FROM users u + JOIN profiles p ON p.user_id = u.id + LEFT JOIN orders o ON o.user_id = u.id + """; + + QueryModel model = analyzer.analyze( + new Query("ListUserOrders", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("nickname", ColumnType.VARCHAR, true), + new QueryColumn("id", ColumnType.BIGINT, true), + new QueryColumn("total", ColumnType.DECIMAL, true) + ), + model.columns() + ); + } + + @Test + void shouldAnalyzeInnerJoinAfterLeftJoin() { + String sql = """ + SELECT p.id, o.id, o.total + FROM users u + LEFT JOIN profiles p ON p.user_id = u.id + JOIN orders o ON o.user_id = u.id + """; + + QueryModel model = analyzer.analyze( + new Query("ListUserOrders", QueryType.MANY, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of( + new QueryColumn("id", ColumnType.BIGINT, true), + new QueryColumn("id", ColumnType.BIGINT, false), + new QueryColumn("total", ColumnType.DECIMAL, true) + ), + model.columns() + ); + } + + /** A left join changes no parameter and no binding order. */ + @Test + void shouldResolveLeftJoinedParametersInTextualBindingOrder() { + String sql = """ + SELECT u.id, p.nickname + FROM users u + LEFT JOIN profiles p ON p.user_id = u.id + WHERE u.id = $1 + """; + + QueryModel model = analyzer.analyze( + new Query("GetUserProfile", QueryType.ONE, sql), + parser.parse(sql), + joinSchema + ); + + assertEquals( + List.of(new QueryParameter(1, "id", ColumnType.BIGINT)), + model.parameters() + ); + + assertEquals(List.of(1), model.bindingParameterIndexes()); + } + @Test void shouldExpandAllColumnsAcrossJoinedSourcesInOrder() { String sql = """ @@ -2023,6 +2183,74 @@ 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()}. + */ + @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", + "LEFT JOIN profiles p USING (user_id)" + } + ) + void shouldRejectUnsupportedJoinModifier(String joinClause) { + String sql = """ + SELECT u.id + FROM users u + %s + """.formatted(joinClause); + + Query query = new Query("ListIds", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, joinSchema) + ); + + assertEquals( + "Only unmodified INNER JOIN and LEFT JOIN clauses are supported.", + exception.getMessage() + ); + } + + /** A left join keeps the inner join's {@code ON} equality requirement. */ + @ParameterizedTest + @ValueSource( + strings = { + "ON p.user_id = u.id AND p.nickname = u.name", + "ON p.user_id > u.id" + } + ) + void shouldRejectLeftJoinWithoutSingleQualifiedEquality(String onClause) { + String sql = """ + SELECT u.id + FROM users u + LEFT JOIN profiles p %s + """.formatted(onClause); + + Query query = new Query("ListIds", QueryType.MANY, sql); + ParsedSql parsedSql = parser.parse(sql); + + UnsupportedOperationException exception = assertThrows( + UnsupportedOperationException.class, + () -> analyzer.analyze(query, parsedSql, joinSchema) + ); + + assertEquals( + "A join requires exactly one ON equality.", + exception.getMessage() + ); + } + @Test void shouldRejectJoinWithoutSingleQualifiedEquality() { String sql = """ 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 d5d5d07..b366068 100644 --- a/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java +++ b/sqlcj-cli/src/test/java/dev/sqlcj/compiler/PostgresIntegrationTest.java @@ -855,6 +855,60 @@ void shouldExecuteGeneratedQueryForSnapshotWithIgnoredTableConstraints() throws } } + /** + * Covers a left join end to end: the read is generated, compiled, and + * executed against PostgreSQL, so a matched row carries the joined values + * while an unmatched row reads every component of the left-joined source as + * {@code null}, including its {@code NOT NULL} columns. + */ + @Test + void shouldExecuteGeneratedLeftJoinAgainstPostgres() throws Exception { + execute(CONSTRAINT_SCHEMA); + + Path classesDirectory = generateAndCompile( + CONSTRAINT_SCHEMA, + """ + -- name: ListCustomerOrders :many + SELECT c.id, c.name, o.id AS order_id, o.quantity + FROM customers c + LEFT JOIN customer_orders o ON o.customer_id = c.id + ORDER BY c.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)"); + + try (URLClassLoader classLoader = classLoader(classesDirectory)) { + Object repository = newRepository(classLoader); + + Method method = repository.getClass().getMethod("listCustomerOrders"); + + Object result = method.invoke(repository); + + assertInstanceOf(List.class, result); + + List rows = (List) result; + + assertEquals(2, rows.size()); + + assertEquals( + List.of("id", "name", "orderId", "quantity"), + recordComponentNames(rows.getFirst()) + ); + + assertEquals(1L, component(rows.get(0), "id")); + assertEquals("Alice", component(rows.get(0), "name")); + assertEquals(10L, component(rows.get(0), "orderId")); + assertEquals(3, component(rows.get(0), "quantity")); + + assertEquals(2L, component(rows.get(1), "id")); + assertEquals("Bob", component(rows.get(1), "name")); + assertNull(component(rows.get(1), "orderId")); + assertNull(component(rows.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 From 8febcc1fa32c0d0562a1d7d5ce037426678af93b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer=20=C3=87engel?= Date: Fri, 25 Sep 2026 20:32:17 +0300 Subject: [PATCH 2/2] docs: document LEFT JOIN and its nullable joined columns --- docs/postgresql.md | 4 +++- docs/queries.md | 38 +++++++++++++++++++++++++++++--------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/docs/postgresql.md b/docs/postgresql.md index 2b83740..8f08999 100644 --- a/docs/postgresql.md +++ b/docs/postgresql.md @@ -155,7 +155,9 @@ input and is not carried into the model at all. Every generated method parameter and every generated result component uses a reference type, so each of them can represent SQL `NULL`. A result component may -be `null` exactly when its column is modeled nullable by the schema snapshot. +be `null` exactly when its column is modeled nullable by the schema snapshot or +is read from a left-joined source, whose columns are all nullable because an +unmatched row supplies no value for them. Row absence is a different thing from a null component, and the two are never mixed: diff --git a/docs/queries.md b/docs/queries.md index 0d853eb..2f7afe8 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -78,12 +78,17 @@ configuration entry. ### Sources - The `FROM` item must be a table of that schema, optionally with an alias. -- A table may be joined with `JOIN` or `INNER JOIN`. A comma-separated source - list and every other join modifier — `LEFT`, `RIGHT`, `FULL`, `OUTER`, - `CROSS`, `NATURAL`, `SEMI`, `APPLY`, `STRAIGHT`, `GLOBAL`, a join hint, and - `USING (...)` — are rejected. +- 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. - Each join requires exactly one `ON` equality between a qualified column of the - joined source and a qualified column of a source introduced earlier. + joined source and a qualified column of a source introduced earlier. A left + join uses the same rule as an inner join. +- Every column read from a left-joined source is nullable, even when the schema + declares it `NOT NULL`, because an unmatched row reads it as `NULL`. The base + source and every inner-joined source keep their schema nullability. - An alias replaces the table name as the exposed source name, so a qualified reference to an aliased table must use the alias and not the table name. - Two sources may not expose the same name. @@ -103,9 +108,10 @@ configuration entry. count below — such as another function call, an arithmetic expression, or a literal — is rejected. - An explicit alias names the result column, so `SELECT id AS author_id` - generates the record component `authorId` while the component's type and - nullability still come from the column. sqlcj itself resolves an alias only in - the projection; every other clause reaches the database as written. + generates the record component `authorId` while the component's type still + comes from the column and its nullability from the column and its source. + sqlcj itself resolves an alias only in the projection; every other clause + reaches the database as written. A read may count its matching rows with an aliased `COUNT(*)` as its only projection: @@ -473,7 +479,7 @@ name, and its header line. `COUNT(DISTINCT column)`, `COUNT(t.*)`, `pg_catalog.count(*)`, 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 join, a join predicate that is not one qualified + is not a plain inner or left join, a join predicate that is not one qualified equality, and a set operation such as `UNION`. - A placeholder in a location sqlcj does not analyze, including `ORDER BY $1`, a computed `LIKE` pattern such as `'%' || $1 || '%'`, a placeholder as the @@ -626,6 +632,20 @@ Reads: `PostgresIntegrationTest.shouldExecuteGeneratedScalarCountAgainstPostgres` compiles a `:one` count into a result record with one `Long` component and executes it against PostgreSQL 16 for a matching and a non-matching pattern. +- `QueryAnalyzerTest.shouldAnalyzeLeftJoinWithNullableJoinedColumns`, + `QueryAnalyzerTest.shouldExpandAllColumnsOfLeftJoinedSourceAsNullable`, + `QueryAnalyzerTest.shouldExpandQualifiedAllColumnsOfLeftJoinedSourceAsNullable`, + `QueryAnalyzerTest.shouldAnalyzeLeftJoinAfterInnerJoin`, + `QueryAnalyzerTest.shouldAnalyzeInnerJoinAfterLeftJoin`, + `QueryAnalyzerTest.shouldResolveLeftJoinedParametersInTextualBindingOrder`, + `QueryAnalyzerTest.shouldRejectUnsupportedJoinModifier`, and + `QueryAnalyzerTest.shouldRejectLeftJoinWithoutSingleQualifiedEquality` cover + both left-join spellings, the nullable columns of a left-joined source, the + chained join orders, the unchanged parameters, and the join kinds and `ON` + shapes that stay rejected, and + `PostgresIntegrationTest.shouldExecuteGeneratedLeftJoinAgainstPostgres` + executes a left join against PostgreSQL 16 whose unmatched row reads the + joined columns as `null`. - `QueryAnalyzerTest.shouldResolveRowTableForFullRowSelect`, `QueryAnalyzerTest.shouldNotResolveRowTableForQuerySpecificResult`, `QueryAnalyzerTest.shouldNotResolveRowTableForJoinedWildcard`, and