Join analysis (Semantic IR)¶
Older SQL — especially Oracle — writes joins in the FROM clause as a comma-separated
table list, with the join condition buried in WHERE:
1 2 3 | |
Read literally, that is a cartesian product with a filter. Semantically it is an
inner join. If you are migrating such SQL to ANSI JOIN ... ON syntax, building
lineage, or auditing join conditions, you need the second reading — and you need it
to survive Oracle's (+) outer-join marker too.
Join analysis gives you that reading. It classifies each join boundary in a
query as INNER, LEFT, RIGHT, IMPLICIT_CROSS and so on, and tells you which
predicates are join conditions versus plain row filters.
Availability and stability
Join analysis lives in gudusoft.gsqlparser.ir.semantic, which is separate from
the parse tree. TGSqlParser.parse() and the TSelectSqlStatement AST are
unchanged by it — a comma join still appears in the AST as a comma join. If you
inspect only the AST you will see no difference.
This package does not currently carry the same backward-compatibility guarantee as
gudusoft.gsqlparser, .nodes, .stmt and .util. Check the
release notes when upgrading.
Quick start¶
Join analysis runs on a parsed statement, not on SQL text. Parse first, then build the semantic program:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | |
Output:
1 2 | |
The comma join was classified INNER, and the WHERE predicate moved out of the
filter list and onto the join as a condition. syntax=COMMA records that this
was written as an implicit join, so you can still tell it apart from an ANSI
JOIN ... ON that produced the same classification.
The object model¶
| Step | Call | Gives you |
|---|---|---|
| 1 | SemanticIRBuilder.build(stmt, provider) |
SemanticProgram |
| 2 | program.getStatements() |
List<StatementGraph> — one per query block |
| 3 | graph.getJoinAnalysisFacts() |
JoinAnalysisFacts, or null |
| 4 | facts.getJoinGraph().getJoins() |
List<JoinEntity> — one per join boundary |
| 5 | facts.getFilterPredicates() |
List<Predicate> — predicates that are not join conditions |
Always null-check step 3 and call facts.isEmpty(): a single-table query has no
join boundaries.
Name binding¶
SemanticIRBuilder.build needs a NameBindingProvider. Use the no-argument
Resolver2NameBindingProvider() when you have no catalog metadata — this is the
normal case for analyzing SQL text on its own, and it is what every example on this
page uses.
JoinEntity fields¶
| Accessor | Meaning |
|---|---|
getOrder() |
Position of this boundary, 0-based, left to right |
getJoinType() |
SemanticJoinType: INNER, LEFT, RIGHT, FULL, CROSS, NATURAL, IMPLICIT_CROSS, SEMI, ANTI_SEMI, UNSUPPORTED |
getSourceSyntax() |
JoinSourceSyntax: EXPLICIT, COMMA, LATERAL, SEMI — how it was written |
getConditions() |
List<Predicate> — the join conditions |
getConditionText() |
Source text of the ON clause. See the caveat below |
getLeftEndpoint() / getRightEndpoint() |
The two sides of the boundary |
getUsingColumns() |
Column names from a USING (...) clause |
isNatural() / isLateral() |
Markers for NATURAL and LATERAL joins |
getSourceSpan() |
Position in the original SQL |
getJoinType() and getSourceSyntax() are independent: a comma join promoted to
INNER reports type=INNER, syntax=COMMA.
getConditionText() is only populated for syntax=EXPLICIT
An implicit join has no literal ON text in the source, so getConditionText()
returns null for syntax=COMMA even when getConditions() is fully populated:
1 2 | |
Use getConditions() — it is populated in both cases. If you need to emit an ANSI
ON clause, build it from the Predicate list rather than from getConditionText().
Predicate carries a PredicateKind: EQUI, NON_EQUI, RANGE, NULL_CHECK,
CALL, or COMPLEX.
Rule 1 — comma joins are promoted to INNER¶
When a top-level WHERE conjunct references relations on both sides of a comma
boundary, that conjunct becomes a join condition and the boundary is classified
INNER. This is not Oracle-specific — it applies to any dialect that allows a
comma-separated FROM list, PostgreSQL included.
| SQL | Result |
|---|---|
FROM a, b WHERE a.x = b.y |
INNER, condition (a.x = b.y) EQUI |
FROM a, b WHERE a.x > b.lo AND a.x < b.hi |
INNER, two NON_EQUI conditions |
FROM e, sg WHERE e.salary BETWEEN sg.lowest AND sg.highest |
INNER, one RANGE condition |
A band join through BETWEEN is recognized even though its bounds collapse into a
single COMPLEX operand:
1 2 3 | |
1 2 | |
Only the linking predicate is promoted¶
Predicates that touch just one side stay in getFilterPredicates():
1 | |
1 2 3 | |
A true cartesian product stays IMPLICIT_CROSS¶
Nothing is invented. Without a linking predicate the boundary keeps its literal meaning:
1 2 3 | |
IMPLICIT_CROSS therefore means "written as a comma join, and genuinely
unconstrained" — distinct from CROSS, which is an explicit CROSS JOIN.
Each boundary is classified independently¶
With three tables there are two boundaries, and a predicate is attached at the
boundary where its last relation becomes available. A predicate spanning a and
c cannot be attached at the a/b boundary, because c is not in scope yet:
1 | |
1 2 3 | |
Link both boundaries and both are promoted:
1 | |
1 2 3 4 | |
Rule 2 — Oracle (+) becomes LEFT or RIGHT¶
Oracle marks the null-producing side of an outer join with (+). Join analysis
detects the marker and classifies the boundary accordingly.
The direction is the opposite of where the marker sits
The (+) marks the side that may produce nulls, so the other side is the
preserved one — and the join is named after the preserved side.
| SQL | Marked side | Result |
|---|---|---|
WHERE a.x = b.y(+) |
right (b) |
LEFT — a is preserved |
WHERE a.x(+) = b.y |
left (a) |
RIGHT — b is preserved |
Both are equivalent to a LEFT JOIN b and a RIGHT JOIN b respectively.
1 | |
1 2 | |
The marker is found even when the column is parenthesized — WHERE a.x = (b.y(+))
gives the same LEFT result.
Unmarked predicates stay filters on an outer join¶
This matches Oracle's own semantics: on an outer join, only (+)-marked predicates
are join conditions. An unmarked two-sided predicate is a WHERE filter, and it
changes the result — it is applied after the outer join:
1 | |
1 2 3 | |
Rule 3 — invalid (+) degrades to IMPLICIT_CROSS¶
Some (+) usages are rejected by Oracle itself. Rather than failing the whole
analysis, join analysis degrades the boundary to IMPLICIT_CROSS and returns
the predicate as a filter, so nothing is silently dropped:
| SQL | Why it degrades |
|---|---|
WHERE a.x(+) = b.y(+) |
(+) on both sides — Oracle raises ORA-01468 |
WHERE a.x(+) = b.y AND a.z = b.w(+) |
Conflicting directions on one boundary |
WHERE a.x BETWEEN b.lo(+) AND b.hi |
Marker cannot be classified |
WHERE a.x(+) = 5 |
Single-sided — no second relation to join to |
1 | |
1 2 | |
A degraded result is indistinguishable from a real cross join
In the output above, nothing records that a (+) marker was seen and discarded.
The same shape is produced by WHERE a.x = b.y, which is valid SQL with a
completely different meaning.
If your SQL may contain invalid (+) usage, validate it before analysis rather
than trying to detect the degradation afterwards. Emitting a diagnostic on this
path is tracked as
issue #709.
Known limitations¶
| Limitation | Effect | Tracking |
|---|---|---|
Comma-separated FROM inside EXISTS/IN subquery bodies and set-operation branches is not supported |
SemanticIRBuilder.build() throws SemanticIRBuildException |
#707 |
An unsupported inner block aborts the whole build() call |
The outer block's join graph is lost too — there is currently no partial-result mode | #708 |
Invalid (+) usage produces no diagnostic |
See the warning above | #709 |
getConditionText() is null for syntax=COMMA |
Use getConditions() instead |
#711 |
Rewriting the affected subquery to use explicit JOIN ... ON is the current
workaround for the first two.
Reference: outcome by input¶
Every row below is produced by the current release. Vendor is Oracle unless noted.
| SQL fragment | getJoinType() |
getSourceSyntax() |
Conditions | Filters |
|---|---|---|---|---|
FROM a, b |
IMPLICIT_CROSS |
COMMA |
— | — |
FROM a, b WHERE a.x = 10 |
IMPLICIT_CROSS |
COMMA |
— | (a.x = LITERAL) EQUI |
FROM a, b WHERE a.x = b.y |
INNER |
COMMA |
(a.x = b.y) EQUI |
— |
FROM a, b WHERE a.x = b.y AND a.z = 5 |
INNER |
COMMA |
(a.x = b.y) EQUI |
(a.z = LITERAL) EQUI |
FROM a, b WHERE a.x > b.lo AND a.x < b.hi |
INNER |
COMMA |
two NON_EQUI |
— |
FROM e, sg WHERE e.salary BETWEEN sg.lowest AND sg.highest |
INNER |
COMMA |
one RANGE |
— |
FROM a, b WHERE a.x = b.y(+) |
LEFT |
COMMA |
(a.x = b.y) EQUI |
— |
FROM a, b WHERE a.x(+) = b.y |
RIGHT |
COMMA |
(a.x = b.y) EQUI |
— |
FROM a, b WHERE a.x = (b.y(+)) |
LEFT |
COMMA |
(a.x = b.y) EQUI |
— |
FROM a, b WHERE a.id = b.id(+) AND a.type = b.type |
LEFT |
COMMA |
(a.id = b.id) EQUI |
(a.type = b.type) EQUI |
FROM a, b WHERE a.x(+) = b.y(+) |
IMPLICIT_CROSS |
COMMA |
— | (a.x = b.y) EQUI |
FROM a, b WHERE a.x(+) = b.y AND a.z = b.w(+) |
IMPLICIT_CROSS |
COMMA |
— | two EQUI |
FROM a, b WHERE a.x BETWEEN b.lo(+) AND b.hi |
IMPLICIT_CROSS |
COMMA |
— | one RANGE |
FROM a LEFT JOIN b ON a.x = b.y |
LEFT |
EXPLICIT |
(a.x = b.y) EQUI |
— |
See also¶
- Release notes — implicit-join support landed in 4.1.5.4
- API documentation