Skip to content

Commit b68914e

Browse files
committed
feat(analysis): accept any boolean expression as a join ON condition
A join's ON condition is no longer limited to one qualified equality. Composite-key joins, filtered joins, comparisons other than equality and ON TRUE compile. Every column of the condition outside a nested SELECT resolves against the sources introduced up to and including the joined source. Its placeholders are typed by the WHERE rules and bind in textual order. A join without ON, or with two ON conditions, is rejected with "A join requires one ON condition." Left-joined columns stay nullable whatever the condition filters.
1 parent 991efbe commit b68914e

5 files changed

Lines changed: 417 additions & 63 deletions

File tree

‎docs/queries.md‎

Lines changed: 40 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,17 @@ configuration entry.
9191
comma-separated source list and every other join modifier — `RIGHT`, `FULL`, a
9292
bare `OUTER`, `CROSS`, `NATURAL`, `SEMI`, `APPLY`, `STRAIGHT`, `GLOBAL`, a
9393
join hint, and `USING (...)` — are rejected.
94-
- Each join requires exactly one `ON` equality between a qualified column of the
95-
joined source and a qualified column of a source introduced earlier. A left
96-
join uses the same rule as an inner join.
94+
- Each join requires exactly one `ON` condition, which may be any boolean
95+
expression, including `ON TRUE`. Its column references, qualified or not,
96+
resolve against the sources introduced up to and including the joined source,
97+
so a reference to a source joined later is rejected, and its placeholders
98+
follow the same rules as a `WHERE` predicate. A left join uses the same rule
99+
as an inner join.
97100
- Every column read from a left-joined source is nullable, even when the schema
98-
declares it `NOT NULL`, because an unmatched row reads it as `NULL`. The base
99-
source and every inner-joined source keep their schema nullability.
101+
declares it `NOT NULL`, because an unmatched row reads it as `NULL`. The join
102+
kind alone decides this, so a left join whose `ON` condition filters its
103+
joined rows reads them as nullable too. The base source and every
104+
inner-joined source keep their schema nullability.
100105
- An alias replaces the table name as the exposed source name, so a qualified
101106
reference to an aliased table must use the alias and not the table name.
102107
- Two sources may not expose the same name.
@@ -514,11 +519,11 @@ string literal, a quoted identifier, or a comment is not a parameter.
514519
assigned to, or inserted into.
515520
- A placeholder written as the direct operand of `::type` or
516521
`CAST(... AS type)` is typed by that cast instead, wherever it appears in an
517-
accepted statement: a projection, `WHERE`, `GROUP BY`, `HAVING`, `ORDER BY`,
518-
`LIMIT`, `OFFSET`, `FETCH`, an `INSERT` value, an `UPDATE` or
519-
`DO UPDATE` assignment, a `WITH` body, and a subquery at any depth, such as
520-
`EXISTS`, `IN (SELECT ...)`, or a scalar subquery. The cast type
521-
may be any type a column may declare and sqlcj maps, including a declared
522+
accepted statement: a projection, a join `ON` condition, `WHERE`, `GROUP BY`,
523+
`HAVING`, `ORDER BY`, `LIMIT`, `OFFSET`, `FETCH`, an `INSERT` value, an
524+
`UPDATE` or `DO UPDATE` assignment, a `WITH` body, and a subquery at any
525+
depth, such as `EXISTS`, `IN (SELECT ...)`, or a scalar subquery. The cast
526+
type may be any type a column may declare and sqlcj maps, including a declared
522527
enum name, written unquoted and matched case-insensitively, and a
523528
one-dimensional array, which binds a `List`. A cast type sqlcj does not map,
524529
such as `INTERVAL` or a multi-dimensional `INT[][]`, is rejected naming the
@@ -814,8 +819,8 @@ name, and its header line.
814819
`lower(name) AS n`, and the `FILTER` and `OVER` forms keep the
815820
unsupported-expression rejection.
816821
- A `FROM` item that is not a table, a comma-separated source list, a join that
817-
is not a plain inner or left join, a join predicate that is not one qualified
818-
equality, and a set operation such as `UNION`.
822+
is not a plain inner or left join, a join that does not carry exactly one
823+
`ON` condition, and a set operation such as `UNION`.
819824
- An uncast placeholder in a location sqlcj does not analyze, including
820825
`ORDER BY $1`, a projection such as `SELECT $1 AS x`, a grouping list, a
821826
`HAVING` predicate, a subquery, a
@@ -828,9 +833,8 @@ name, and its header line.
828833
`LIMIT 5, $1`, a placeholder inside a write value such as
829834
`COALESCE($2, bio)`, and a `FETCH FIRST $1 ROWS ONLY` clause, so dynamic `IN`
830835
expansion is unavailable. A cast states the type of such a placeholder in
831-
every one of those locations except a join condition, where any placeholder
832-
stays rejected, and it does not accept a placeholder that is not the direct
833-
operand of its cast, such as `(:x)::int`.
836+
every one of those locations, and it does not accept a placeholder that is not
837+
the direct operand of its cast, such as `(:x)::int`.
834838
- A cast type sqlcj does not map, such as `$1::interval` or a
835839
multi-dimensional `$1::int[][]`, which is rejected naming the placeholder and
836840
the declared type, or the result column and the declared type when the cast
@@ -1084,14 +1088,30 @@ Reads:
10841088
`QueryAnalyzerTest.shouldAnalyzeLeftJoinAfterInnerJoin`,
10851089
`QueryAnalyzerTest.shouldAnalyzeInnerJoinAfterLeftJoin`,
10861090
`QueryAnalyzerTest.shouldResolveLeftJoinedParametersInTextualBindingOrder`,
1087-
`QueryAnalyzerTest.shouldRejectUnsupportedJoinModifier`, and
1088-
`QueryAnalyzerTest.shouldRejectLeftJoinWithoutSingleQualifiedEquality` cover
1089-
both left-join spellings, the nullable columns of a left-joined source, the
1090-
chained join orders, the unchanged parameters, and the join kinds and `ON`
1091-
shapes that stay rejected, and
1091+
and `QueryAnalyzerTest.shouldRejectUnsupportedJoinModifier` cover both
1092+
left-join spellings, the nullable columns of a left-joined source, the
1093+
chained join orders, the unchanged parameters, and the join kinds that stay
1094+
rejected, and
10921095
`PostgresIntegrationTest.shouldExecuteGeneratedLeftJoinAgainstPostgres`
10931096
executes a left join against PostgreSQL 16 whose unmatched row reads the
10941097
joined columns as `null`.
1098+
- `QueryAnalyzerTest.shouldAnalyzeJoinConditionOfAnyBooleanExpression`,
1099+
`QueryAnalyzerTest.shouldReadFilteredLeftJoinedColumnsAsNullable`,
1100+
`QueryAnalyzerTest.shouldResolveJoinConditionParameterInTextualBindingOrder`,
1101+
`QueryAnalyzerTest.shouldResolveNamedJoinConditionParameter`,
1102+
`QueryAnalyzerTest.shouldTypeCastJoinConditionPlaceholderFromItsCast`,
1103+
`QueryAnalyzerTest.shouldRejectJoinConditionReferenceToLaterSource`,
1104+
`QueryAnalyzerTest.shouldRejectUnknownJoinConditionColumn`, and
1105+
`QueryAnalyzerTest.shouldRejectJoinWithoutOneOnCondition` cover a composite
1106+
key, a comparison other than equality, a condition over one source alone, an
1107+
unqualified reference, `ON TRUE`, the nullability of a filtered left join,
1108+
the uncast, named, and cast placeholder forms and their binding order, the
1109+
join scope, and the joins that carry no single `ON` condition;
1110+
`SqlcjCompilerIntegrationTest.shouldGenerateCompilableJavaForJoinConditions`
1111+
compiles a repository over them, and
1112+
`PostgresIntegrationTest.shouldExecuteGeneratedFilteredLeftJoinAgainstPostgres`
1113+
executes a filtered left join against PostgreSQL 16 whose filtered-out and
1114+
unmatched rows read the joined components as `null`.
10951115
- `QueryAnalyzerTest.shouldResolveRowTableForFullRowSelect`,
10961116
`QueryAnalyzerTest.shouldNotResolveRowTableForQuerySpecificResult`,
10971117
`QueryAnalyzerTest.shouldNotResolveRowTableForJoinedWildcard`, and

‎sqlcj-cli/src/main/java/dev/sqlcj/analysis/QueryAnalyzer.java‎

Lines changed: 60 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import net.sf.jsqlparser.expression.Alias;
1414
import net.sf.jsqlparser.expression.CastExpression;
1515
import net.sf.jsqlparser.expression.Expression;
16+
import net.sf.jsqlparser.expression.ExpressionVisitorAdapter;
1617
import net.sf.jsqlparser.expression.Function;
1718
import net.sf.jsqlparser.expression.JdbcNamedParameter;
1819
import net.sf.jsqlparser.expression.JdbcParameter;
@@ -1050,14 +1051,14 @@ private List<Source> resolveSources(PlainSelect plainSelect, Table table, Schema
10501051
}
10511052

10521053
for (Join join : joins) {
1053-
Source joined = addSource(
1054+
addSource(
10541055
sources,
10551056
requireSupportedJoin(join),
10561057
schema,
10571058
join.isLeft()
10581059
);
10591060

1060-
requireJoinCondition(join, sources, joined);
1061+
resolveJoinConditionColumns(requireJoinCondition(join), sources);
10611062
}
10621063

10631064
return List.copyOf(sources);
@@ -1172,49 +1173,56 @@ private boolean hasNoOtherJoinModifier(Join join) {
11721173
}
11731174

11741175
/**
1175-
* Requires one {@code ON} equality between a qualified column of the joined
1176-
* source and a qualified column of a source introduced earlier.
1176+
* The single {@code ON} condition of one join, which may be any boolean
1177+
* expression. The parser reports no condition for a join written without
1178+
* one and two conditions for the nested {@code JOIN a JOIN b ON ... ON ...}
1179+
* form, neither of which states one condition for the joined source.
11771180
*/
1178-
private void requireJoinCondition(Join join, List<Source> sources, Source joined) {
1181+
private Expression requireJoinCondition(Join join) {
11791182
Collection<Expression> onExpressions = join.getOnExpressions();
11801183

1181-
if (onExpressions.size() != 1 || !(onExpressions.iterator().next() instanceof EqualsTo equality)) {
1184+
if (onExpressions.size() != 1) {
11821185
throw new UnsupportedOperationException(
1183-
"A join requires exactly one ON equality."
1186+
"A join requires one ON condition."
11841187
);
11851188
}
11861189

1187-
Source left = resolveJoinConditionSource(equality.getLeftExpression(), sources);
1188-
Source right = resolveJoinConditionSource(equality.getRightExpression(), sources);
1189-
1190-
if ((left == joined) == (right == joined)) {
1191-
throw new UnsupportedOperationException(
1192-
"A join ON equality must compare "
1193-
+ joined.name()
1194-
+ " with an earlier source."
1195-
);
1196-
}
1190+
return onExpressions.iterator().next();
11971191
}
11981192

1199-
private Source resolveJoinConditionSource(Expression expression, List<Source> sources) {
1200-
if (!(expression instanceof net.sf.jsqlparser.schema.Column column) || qualifier(column) == null) {
1201-
throw new UnsupportedOperationException(
1202-
"A join ON equality requires qualified columns."
1203-
);
1204-
}
1193+
/**
1194+
* Resolves every column reference an {@code ON} condition makes against the
1195+
* sources introduced up to and including its own joined source, as
1196+
* PostgreSQL scopes a join condition, so a reference to a later source is
1197+
* an unknown qualifier. A nested {@code SELECT} is not analyzed, which the
1198+
* visitor's default traversal already leaves unentered.
1199+
*/
1200+
private void resolveJoinConditionColumns(Expression condition, List<Source> sources) {
1201+
condition.accept(
1202+
new ExpressionVisitorAdapter<Void>() {
1203+
1204+
@Override
1205+
public <S> Void visit(net.sf.jsqlparser.schema.Column column, S context) {
1206+
resolveColumn(column, sources);
12051207

1206-
return resolveColumn(column, sources).source();
1208+
return null;
1209+
}
1210+
}
1211+
);
12071212
}
12081213

12091214
/**
1210-
* Resolves the parameters the clauses of a read type, which are the
1211-
* {@code WHERE} parameters and the pagination parameters.
1215+
* Resolves the parameters the clauses of a read type, which are the join
1216+
* condition parameters, the {@code WHERE} parameters, and the pagination
1217+
* parameters.
12121218
*/
12131219
private void resolveBindingParameters(
12141220
PlainSelect plainSelect,
12151221
List<Source> sources,
12161222
Placeholders placeholders
12171223
) {
1224+
resolveJoinConditionParameters(plainSelect, sources, placeholders);
1225+
12181226
if (plainSelect.getWhere() != null) {
12191227
resolveParameters(
12201228
plainSelect.getWhere(),
@@ -1226,6 +1234,32 @@ private void resolveBindingParameters(
12261234
resolvePaginationParameters(plainSelect, placeholders);
12271235
}
12281236

1237+
/**
1238+
* Resolves the parameters of each join's {@code ON} condition by the
1239+
* {@code WHERE} predicate rules, over the sources introduced up to and
1240+
* including that join's own source. Binding order comes from the textual
1241+
* placeholder order the parser reported, not from this traversal.
1242+
*/
1243+
private void resolveJoinConditionParameters(
1244+
PlainSelect plainSelect,
1245+
List<Source> sources,
1246+
Placeholders placeholders
1247+
) {
1248+
List<Join> joins = plainSelect.getJoins();
1249+
1250+
if (joins == null) {
1251+
return;
1252+
}
1253+
1254+
for (int index = 0; index < joins.size(); index++) {
1255+
resolveParameters(
1256+
requireJoinCondition(joins.get(index)),
1257+
sources.subList(0, index + 2),
1258+
placeholders
1259+
);
1260+
}
1261+
}
1262+
12291263
/**
12301264
* Resolves the pagination parameters, which are the {@code LIMIT} row count
12311265
* and the {@code OFFSET} value. Either value binds one parameter named after

0 commit comments

Comments
 (0)