Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/postgresql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 29 additions & 9 deletions docs/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
102 changes: 71 additions & 31 deletions sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -602,7 +604,7 @@ private Table getTable(PlainSelect plainSelect) {
private List<Source> resolveSources(PlainSelect plainSelect, Table table, Schema schema) {
List<Source> sources = new ArrayList<>();

addSource(sources, table, schema);
addSource(sources, table, schema, false);

List<Join> joins = plainSelect.getJoins();

Expand All @@ -611,16 +613,21 @@ private List<Source> 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);
}

return List.copyOf(sources);
}

private Source addSource(List<Source> sources, Table table, Schema schema) {
Source source = toSource(table, schema);
private Source addSource(List<Source> sources, Table table, Schema schema, boolean leftJoined) {
Source source = toSource(table, schema, leftJoined);

boolean duplicate = sources.stream()
.anyMatch(existing -> existing.name().equalsIgnoreCase(source.name()));
Expand All @@ -636,50 +643,79 @@ private Source addSource(List<Source> 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()
&& !join.isGlobal()
&& !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;
}

/**
Expand Down Expand Up @@ -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<QueryColumn> resolveColumns(PlainSelect plainSelect, List<Source> sources) {
List<QueryColumn> columns = new ArrayList<>();
Expand Down Expand Up @@ -1228,13 +1266,14 @@ private List<QueryColumn> resolveColumns(PlainSelect plainSelect, List<Source> 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()
)
);

Expand Down Expand Up @@ -1330,7 +1369,8 @@ private String resolveSelectRowTable(PlainSelect plainSelect, List<Source> 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
Expand Down Expand Up @@ -1418,7 +1458,7 @@ private List<QueryColumn> resolveAllColumns(Source source) {
column -> new QueryColumn(
column.name(),
column.type(),
column.nullable()
column.nullable() || source.leftJoined()
)
)
.toList();
Expand Down
Loading
Loading