Skip to content

refactor(jdbc): split SqlTableReference parser into focused units - #225

Merged
magnusp merged 2 commits into
mainfrom
refactor/i206-sql-table-reference-split
Oct 24, 2026
Merged

magnusp merged 2 commits into
mainfrom
refactor/i206-sql-table-reference-split

Conversation

@magnusp

@magnusp magnusp commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Fixes #206

Splits the 789-line SqlTableReference.Parser inner class into focused units with no parse-rule, call-order, or error-type changes. SqlTableReference remains the public entry point; the split commit does not touch tests, pom.xml, or SqlTokenizer. A follow-up commit adds characterization tests for the AS-only RETURNING target-alias rule.

Unit split

File Lines Role
SqlTableReference.java 417 (was 998) Entry point, statement dispatch, WITH, SELECT, RETURNING, targetTable, shared records, result builder
SqlCursor.java 136 Token list + depth, bounds guards, isWordAt/isSymbolAt/identifierAt/wordAt, indexOf*, matchingParen, identifiers
FromClauseParser.java 309 Sources, joins, INDEXED, USING, star list, parseSource
SelectItemParser.java 256 Select items, aliases, star expansion, expression labels, column resolution
TableBinding.java 166 CTE, view, and definition to Source construction, including the view cycle guard

ResolutionBuilder replaces the parallel labels/tables lists in the select list and the star list. Every mutation goes through add(label, table) or addAll(labels, tables), which rejects a size mismatch; the star list uses the same builder, so USING/NATURAL deletion removes a pair.

Preserved invariants

  • Schema.definition is still called once per source occurrence, in source order; SQLException propagates. Verified by a differential probe that logs every call (below).
  • View cycle guard: key is schema + "." + lower(table), add before recursion, remove in finally.
  • CREATE VIEW is still tokenized twice (isViewDefinition plus resolveView), which keeps the explicit column list behaviour.
  • Token depth convention unchanged: ( carries pre-increment depth, ) post-decrement. SqlCursor compares token depth exactly.
  • resolve(sql, schema, columnCount) width guard is unchanged and still follows the cache-warming resolve(sql, schema) call.
  • parseSelect still takes only the leftmost set-operator branch; the FROM search still stops at FROM_END.
  • targetTable is still AS-only and still accepts a third dotted part; quoted identifiers still qualify as aliases. The AS-only rule is pinned by the follow-up tests below.
  • No test, dependency, or build-file change: git diff --name-only HEAD~1 lists only the five parser files.

Proof: normalized diff

Each moved method was compared with its old body after whitespace collapsing and these mechanical substitutions: cursor rebinding (SqlTokenizer.nav(tokens, …, depth, …) -> cursor.nav(…) or cursor.at(depth).nav(…), tokens.get(i) -> cursor.token(i)), new Parser(...) -> parser.onTokens(...), SourceList(List.of(new Source(...)), index) -> a TableBinding call, and labels.add/tables.add -> ResolutionBuilder.add. 31 methods/fragments were compared; findSource and expressionLabel are identical after normalization, and every remaining hunk is one of those substitutions or delegations. Representative hunks per unit:

SqlCursor

--- old/isWordAt
+++ SqlCursor/isWordAt
@@ -1,3 +1,3 @@
-static boolean isWordAt(List<Token> cursor, int index, int depth, String word) {
+boolean isWordAt(int index, String word) {
 if (index < 0 || index >= tokens.size()) {
 return false;

TableBinding (source fragment moved out of parseSource)

--- old/source
+++ TableBinding/source
@@ -1,14 +1,15 @@
+throws SQLException {
 TableDefinition definition = schema.definition(reference);
 if (definition == null) {
-return new SourceList( List.of(new Source(qualifier, List.of(), List.of(), false, reference)), index);
+return new Source(qualifier, List.of(), List.of(), false, reference);
 }
 if (isViewDefinition(definition.sql())) {
-Resolution view = resolveView(reference, definition, depth, scope);
+Resolution view = resolveView(reference, definition, parser, scope);
 if (view == null) {
-return new SourceList( List.of(new Source( qualifier, definition.columns(), Collections.nCopies(definition.columns().size(), null), true, null)), index);
+return new Source( qualifier, definition.columns(), Collections.nCopies(definition.columns().size(), null), true, null);
 }
-return new SourceList( List.of(new Source(qualifier, view.labels(), view.tables(), true, null)), index);
+return new Source(qualifier, view.labels(), view.tables(), true, null);
 }
 List<String> columns = definition.columns() != null ? definition.columns() : List.of();
-return new SourceList( List.of(new Source( qualifier, columns, Collections.nCopies(columns.size(), reference), true, reference)), index);
+return new Source( qualifier, columns, Collections.nCopies(columns.size(), reference), true, reference);
 }

FromClauseParser

--- old/parseSources
+++ FromClauseParser/parseSources
@@ -1,39 +1,38 @@
-throws SQLException {
+FromClause parseSources(int start, int end) throws SQLException {
 List<Source> sources = new ArrayList<>();
-List<String> starLabels = new ArrayList<>();
-List<TableRef> starTables = new ArrayList<>();
+ResolutionBuilder star = new ResolutionBuilder();
 List<String> mergedColumns = new ArrayList<>();
 boolean natural = false;
 int index = start;
 while (index < end) {
-SourceList parsed = parseSource(index, end, depth, scope);
+SourceList parsed = parseSource(index, end);
 if (parsed == null) {
 return null;
 }
-int starStart = starLabels.size();
+int starStart = star.size();
 for (Source source : parsed.sources()) {
 sources.add(source);
-appendStarColumns(source, natural, starLabels, starTables);
+appendStarColumns(source, natural, star);
 }
 index = parsed.next();
-index = skipIndexModifiers(index, end, depth);
-if (isWordAt(cursor, index, depth, "USING")) {
-List<String> merged = usingColumns(index, end, depth);
-dropStarColumns(merged, starStart, starLabels, starTables);
+index = skipIndexModifiers(index, end);
+if (cursor.isWordAt(index, "USING")) {
+List<String> merged = usingColumns(index, end);
+star.dropLabels(merged, starStart);
 for (String column : merged) {
-if (!containsIgnoreCase(mergedColumns, column)) {
+if (!ResolutionBuilder.containsIgnoreCase(mergedColumns, column)) {
 mergedColumns.add(column);
 }
 }
 }
-index = skipJoinCondition(index, end, depth);
-if (isSymbolAt(cursor, index, ",", depth)) {
+index = skipJoinCondition(index, end);
+if (cursor.isSymbolAt(index, ",")) {
 natural = false;
 index++;
 continue;
 }
-if (index < end && isJoinStart(index, end, depth)) {
-natural = isWordAt(cursor, index, depth, "NATURAL");
-index = skipToJoin(index, end, depth);
+if (index < end && isJoinStart(index, end)) {
+natural = cursor.isWordAt(index, "NATURAL");
+index = skipToJoin(index, end);
 if (index < 0) {
 break;
@@ -44,4 +43,4 @@
 break;
 }
-return new FromClause(sources, starLabels, starTables, mergedColumns);
+return new FromClause(sources, star, mergedColumns);
 }

SelectItemParser

--- old/parseItem
+++ SelectItemParser/parseItem
@@ -1,13 +1,12 @@
-throws SQLException {
+private void parseItem(int start, int end, ResolutionBuilder items) throws SQLException {
 if (start >= end) {
-labels.add("");
-tables.add(null);
+items.add("", null);
 return;
 }
 String alias = null;
 int expressionEnd = end;
-int as = indexOfWord(cursor, start, end, depth, "AS");
+int as = cursor.indexOfWord(start, end, "AS");
 if (as >= 0 && as + 1 < end) {
-String candidate = identifierAt(cursor, as + 1);
+String candidate = cursor.identifierAt(as + 1);
 if (candidate != null) {
 alias = candidate;
@@ -16,18 +15,16 @@
 }
 else {
-int implicit = implicitAlias(start, end, depth);
+int implicit = implicitAlias(start, end);
 if (implicit >= 0) {
-alias = identifierAt(cursor, implicit);
+alias = cursor.identifierAt(implicit);
 expressionEnd = implicit;
 }
 }
 if (expressionEnd <= start) {
-labels.add(alias != null ? alias : "");
-tables.add(null);
+items.add(alias != null ? alias : "", null);
 return;
 }
 if (expressionEnd - start == 1 && cursor.token(start).isSymbol("*")) {
-labels.addAll(from.starLabels());
-tables.addAll(from.starTables());
+items.addAll(from.star());
 return;
 }
@@ -36,6 +33,5 @@
 Source source = findSource( qualifiedStar.parts().get(qualifiedStar.parts().size() - 1), from.sources());
 if (source != null && source.knownColumns()) {
-labels.addAll(source.columns());
-tables.addAll(source.tables());
+items.addAll(source.columns(), source.tables());
 }
 return;
@@ -43,21 +39,18 @@
 ColumnRef column = simpleColumn(start, expressionEnd);
 if (column != null) {
-labels.add(alias != null ? alias : column.column());
-tables.add(resolveColumn(column, from));
+items.add(alias != null ? alias : column.column(), resolveColumn(column, from));
 return;
 }
-if (cursor.token(start).isSymbol("(") && matchingParen(cursor, start) == expressionEnd - 1) {
-int innerDepth = depth + 1;
-String innerKeyword = wordAt(cursor, start + 1, innerDepth);
+if (cursor.token(start).isSymbol("(") && cursor.matchingParen(start) == expressionEnd - 1) {
+int innerDepth = cursor.depth() + 1;
+String innerKeyword = cursor.at(innerDepth).wordAt(start + 1);
 if ("SELECT".equals(innerKeyword) || "WITH".equals(innerKeyword)) {
-Resolution inner = parseStatement(start + 1, expressionEnd - 1, innerDepth, scope);
+Resolution inner = parser.parseStatement(start + 1, expressionEnd - 1, innerDepth, scope);
 if (inner != null && inner.tables().size() == 1) {
-labels.add(alias != null ? alias : inner.labels().get(0));
-tables.add(inner.tables().get(0));
+items.add(alias != null ? alias : inner.labels().get(0), inner.tables().get(0));
 return;
 }
 }
 }
-labels.add(alias != null ? alias : expressionLabel(start, expressionEnd));
-tables.add(null);
+items.add(alias != null ? alias : expressionLabel(start, expressionEnd), null);
 }

Differential probe

A scratch probe (not committed) ran 109 statements against classes built from origin/main and from this branch, printing Resolution.labels(), Resolution.tables(), the exact Schema.definition call sequence, and any thrown SQLException. The corpus covers every SqlTableReferenceTest statement plus view chains, explicit view column lists, cyclic views, CTEs with and without column lists, USING/NATURAL, subqueries, compound selects, RETURNING forms, malformed input, and a definition that throws. Output was byte-identical (diff empty), including the definition call order and the propagated SQLException.

RETURNING target alias coverage

Commit test(jdbc): pin the AS-only RETURNING target alias rule adds four tests to SqlTableReferenceTest (no production change). They assert what a caller sees: column label, table reference, and the table reference used as the nullability path.

Statement Labels Tables Rule
DELETE FROM w RETURNING w.a a w No target alias, so the table name is the qualifier
UPDATE w x RETURNING x.a a null x is not bound; UPDATE w x RETURNING w.a still resolves to w
INSERT INTO w t SELECT 1 RETURNING t.a a null t is not bound; INSERT INTO w t SELECT 1 RETURNING w.a still resolves to w
UPDATE w AS x RETURNING x.a a w Explicit AS alias still binds (positive case)

Probes

Probe A (depth convention) — killed. In SqlCursor.isSymbolAt I changed token.depth() == depth to token.depth() == depth - 1, an off-by-one against the (/) convention. SqlTableReferenceTest: 34 run, 8 failures, 4 errors. Reverted and re-ran: 34/34 green.

Probe B (implicit alias in targetTable) — killed by the new tests. I added else { alias = cursor.identifierAt(index); } after the AS-only branch, so targetTable accepts an implicit alias. SqlTableReferenceTest: 38 run, 3 failures — exactly resolvesReturningThroughTheTableNameWhenTheTargetHasNoAlias, doesNotBindAnImplicitUpdateAliasToTheReturningSource, and doesNotBindAnImplicitInsertAliasToTheReturningSource. The explicit AS positive case and the other 34 tests in the class stayed green. The mutation was live: DELETE FROM w RETURNING w.a resolved to [null] with it and [w] without it. Reverted; git status clean; the full suite is green at 396.

Gates

Lane Result
./mvnw -pl libsql-jdbc -am test 396 tests, 0 failures, 0 errors, 0 skipped
./mvnw spotless:apply then ./mvnw spotless:check green
./mvnw -pl libsql-conformance -am verify 272 tests, 0 failures, 0 errors, 7 skipped

The first JDBC run in this session raced a concurrent conformance build on the same target/ directory and reported a spurious failure; the serial reruns above are the results of record.

@magnusp
magnusp merged commit 3324e92 into main Oct 24, 2026
6 checks passed
@magnusp
magnusp deleted the refactor/i206-sql-table-reference-split branch October 24, 2026 16:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Split SqlTableReference.Parser into focused units

1 participant