diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/FromClauseParser.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/FromClauseParser.java new file mode 100644 index 0000000..95db2bf --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/FromClauseParser.java @@ -0,0 +1,309 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.jdbc.SqlTableReference.Resolution; +import com.github.magnusp.libsql.jdbc.SqlTableReference.ResolutionBuilder; +import com.github.magnusp.libsql.jdbc.SqlTableReference.Source; +import com.github.magnusp.libsql.jdbc.SqlTableReference.StatementParser; +import com.github.magnusp.libsql.jdbc.SqlTableReference.TableRef; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Parses a FROM clause: its sources, joins, index hints, and {@code USING} columns, and the star + * list that {@code *} expands to. The star list drops the right-side duplicate of a {@code USING} + * or {@code NATURAL} join column, as SQLite does. + */ +final class FromClauseParser { + + record SourceList(List sources, int next) {} + + record FromClause( + List sources, ResolutionBuilder star, List mergedColumns) { + + static FromClause of(List sources) { + ResolutionBuilder star = new ResolutionBuilder(); + for (Source source : sources) { + appendStarColumns(source, false, star); + } + return new FromClause(sources, star, List.of()); + } + } + + private final SqlCursor cursor; + private final TableBinding binding; + private final StatementParser parser; + private final Map scope; + + FromClauseParser( + SqlCursor cursor, + TableBinding binding, + StatementParser parser, + Map scope) { + this.cursor = cursor; + this.binding = binding; + this.parser = parser; + this.scope = scope; + } + + FromClause parseSources(int start, int end) throws SQLException { + List sources = new ArrayList<>(); + ResolutionBuilder star = new ResolutionBuilder(); + List mergedColumns = new ArrayList<>(); + boolean natural = false; + int index = start; + while (index < end) { + SourceList parsed = parseSource(index, end); + if (parsed == null) { + return null; + } + int starStart = star.size(); + for (Source source : parsed.sources()) { + sources.add(source); + appendStarColumns(source, natural, star); + } + index = parsed.next(); + index = skipIndexModifiers(index, end); + if (cursor.isWordAt(index, "USING")) { + List merged = usingColumns(index, end); + star.dropLabels(merged, starStart); + for (String column : merged) { + if (!ResolutionBuilder.containsIgnoreCase(mergedColumns, column)) { + mergedColumns.add(column); + } + } + } + index = skipJoinCondition(index, end); + if (cursor.isSymbolAt(index, ",")) { + natural = false; + index++; + continue; + } + if (index < end && isJoinStart(index, end)) { + natural = cursor.isWordAt(index, "NATURAL"); + index = skipToJoin(index, end); + if (index < 0) { + break; + } + index++; + continue; + } + break; + } + return new FromClause(sources, star, mergedColumns); + } + + private SourceList parseSource(int start, int end) throws SQLException { + int index = start; + if (cursor.isSymbolAt(index, "(")) { + int close = cursor.matchingParen(index); + if (close < 0 || close >= end) { + return null; + } + int innerDepth = cursor.depth() + 1; + String innerKeyword = cursor.at(innerDepth).wordAt(index + 1); + if ("SELECT".equals(innerKeyword) || "WITH".equals(innerKeyword)) { + Resolution inner = parser.parseStatement(index + 1, close, innerDepth, scope); + index = close + 1; + String alias = null; + if (cursor.isWordAt(index, "AS")) { + index++; + alias = cursor.identifierAt(index); + if (alias == null) { + return null; + } + index++; + } else if (isAlias(index)) { + alias = cursor.identifierAt(index); + index++; + } + List aliasColumns = null; + if (cursor.isSymbolAt(index, "(")) { + int columnsEnd = cursor.matchingParen(index); + if (columnsEnd < 0 || columnsEnd >= end) { + return null; + } + aliasColumns = cursor.at(innerDepth).identifiers(index + 1, columnsEnd); + index = columnsEnd + 1; + } + if (inner == null) { + inner = Resolution.EMPTY; + } + List labels = aliasColumns != null && aliasColumns.size() == inner.labels().size() + ? aliasColumns + : inner.labels(); + return new SourceList( + List.of(new Source(alias, labels, inner.tables(), true, null)), index); + } + FromClause grouped = new FromClauseParser(cursor.at(innerDepth), binding, parser, scope) + .parseSources(index + 1, close); + if (grouped == null) { + return null; + } + index = close + 1; + String alias = null; + if (cursor.isWordAt(index, "AS")) { + index++; + alias = cursor.identifierAt(index); + if (alias == null) { + return null; + } + index++; + } else if (isAlias(index)) { + alias = cursor.identifierAt(index); + index++; + } + if (alias == null) { + return new SourceList(grouped.sources(), index); + } + return new SourceList( + List.of(new Source( + alias, grouped.star().labels(), grouped.star().tables(), true, null)), + index); + } + + String first = cursor.identifierAt(index); + if (first == null) { + return null; + } + index++; + String schemaName = null; + if (cursor.isSymbolAt(index, ".")) { + index++; + String second = cursor.identifierAt(index); + if (second == null) { + return null; + } + index++; + if (cursor.isSymbolAt(index, ".")) { + return null; + } + schemaName = first; + first = second; + } + String alias = null; + if (cursor.isWordAt(index, "AS")) { + index++; + alias = cursor.identifierAt(index); + if (alias == null) { + return null; + } + index++; + } else if (isAlias(index)) { + alias = cursor.identifierAt(index); + index++; + } + SqlTableReference.TableRef reference = new TableRef(schemaName, first); + String qualifier = alias != null ? alias : first; + if (schemaName == null) { + Source cte = scope.get(first.toLowerCase(Locale.ROOT)); + if (cte != null) { + return new SourceList(List.of(binding.cteReference(cte, qualifier)), index); + } + } + return new SourceList(List.of(binding.source(reference, qualifier, parser, scope)), index); + } + + private int skipIndexModifiers(int start, int end) { + int index = start; + while (index < end) { + if (cursor.isWordAt(index, "INDEXED")) { + index += 3; + continue; + } + if (cursor.isWordAt(index, "NOT") && cursor.isWordAt(index + 1, "INDEXED")) { + index += 2; + continue; + } + break; + } + return index; + } + + private int skipJoinCondition(int start, int end) { + if (!cursor.isWordAt(start, "ON") && !cursor.isWordAt(start, "USING")) { + return start; + } + int index = start + 1; + while (index < end) { + if (cursor.token(index).depth() == cursor.depth()) { + if (cursor.token(index).isSymbol(",") || isJoinStart(index, end)) { + break; + } + } + index++; + } + return index; + } + + private boolean isJoinStart(int index, int end) { + if (index >= end) { + return false; + } + SqlTokenizer.Token token = cursor.token(index); + if (token.depth() != cursor.depth() || token.type() != SqlTokenizer.Type.WORD) { + return false; + } + String keyword = SqlTokenizer.upper(token.text()); + if (!SqlTableReference.JOIN_START.contains(keyword)) { + return false; + } + if ("LEFT".equals(keyword) || "RIGHT".equals(keyword)) { + return cursor.isWordAt(index + 1, "JOIN") || cursor.isWordAt(index + 1, "OUTER"); + } + return true; + } + + private int skipToJoin(int start, int end) { + for (int index = start; index < end; index++) { + if (cursor.isWordAt(index, "JOIN")) { + return index; + } + } + return -1; + } + + private boolean isAlias(int index) { + if (index >= cursor.size()) { + return false; + } + SqlTokenizer.Token token = cursor.token(index); + if (token.depth() != cursor.depth() || token.identifier() == null) { + return false; + } + if (token.type() == SqlTokenizer.Type.WORD) { + String keyword = SqlTokenizer.upper(token.text()); + return !SqlTableReference.FROM_END.contains(keyword) + && !SqlTableReference.JOIN_START.contains(keyword) + && !SqlTableReference.NOT_AN_ALIAS.contains(keyword); + } + return true; + } + + private List usingColumns(int start, int end) { + if (!cursor.isSymbolAt(start + 1, "(")) { + return List.of(); + } + int close = cursor.matchingParen(start + 1); + if (close < 0 || close > end) { + return List.of(); + } + return cursor.at(cursor.depth() + 1).identifiers(start + 2, close); + } + + private static void appendStarColumns(Source source, boolean merged, ResolutionBuilder star) { + if (!source.knownColumns()) { + return; + } + for (int i = 0; i < source.columns().size(); i++) { + String label = source.columns().get(i); + if (merged && star.containsLabel(label)) { + continue; + } + star.add(label, source.tables().get(i)); + } + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SelectItemParser.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SelectItemParser.java new file mode 100644 index 0000000..47a41da --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SelectItemParser.java @@ -0,0 +1,256 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.jdbc.SqlTableReference.ColumnRef; +import com.github.magnusp.libsql.jdbc.SqlTableReference.Resolution; +import com.github.magnusp.libsql.jdbc.SqlTableReference.ResolutionBuilder; +import com.github.magnusp.libsql.jdbc.SqlTableReference.Source; +import com.github.magnusp.libsql.jdbc.SqlTableReference.StatementParser; +import com.github.magnusp.libsql.jdbc.SqlTableReference.TableRef; + +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Parses the select list of a statement: item boundaries, explicit and implicit aliases, {@code *} + * expansion, simple column references, scalar subqueries, and fallback expression labels. + */ +final class SelectItemParser { + + private final SqlCursor cursor; + private final StatementParser parser; + private final FromClauseParser.FromClause from; + private final Map scope; + + SelectItemParser( + SqlCursor cursor, + StatementParser parser, + FromClauseParser.FromClause from, + Map scope) { + this.cursor = cursor; + this.parser = parser; + this.from = from; + this.scope = scope; + } + + Resolution parseItems(int start, int end) throws SQLException { + ResolutionBuilder items = new ResolutionBuilder(); + int index = start; + while (index < end) { + int comma = cursor.indexOfSymbol(index, end, ","); + int itemEnd = comma < 0 ? end : comma; + parseItem(index, itemEnd, items); + if (comma < 0) { + break; + } + index = comma + 1; + } + return items.build(); + } + + private void parseItem(int start, int end, ResolutionBuilder items) throws SQLException { + if (start >= end) { + items.add("", null); + return; + } + String alias = null; + int expressionEnd = end; + int as = cursor.indexOfWord(start, end, "AS"); + if (as >= 0 && as + 1 < end) { + String candidate = cursor.identifierAt(as + 1); + if (candidate != null) { + alias = candidate; + expressionEnd = as; + } + } else { + int implicit = implicitAlias(start, end); + if (implicit >= 0) { + alias = cursor.identifierAt(implicit); + expressionEnd = implicit; + } + } + if (expressionEnd <= start) { + items.add(alias != null ? alias : "", null); + return; + } + + if (expressionEnd - start == 1 && cursor.token(start).isSymbol("*")) { + items.addAll(from.star()); + return; + } + + ColumnRef qualifiedStar = qualifiedStar(start, expressionEnd); + if (qualifiedStar != null) { + Source source = findSource( + qualifiedStar.parts().get(qualifiedStar.parts().size() - 1), from.sources()); + if (source != null && source.knownColumns()) { + items.addAll(source.columns(), source.tables()); + } + return; + } + + ColumnRef column = simpleColumn(start, expressionEnd); + if (column != null) { + items.add(alias != null ? alias : column.column(), resolveColumn(column, from)); + return; + } + + 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 = parser.parseStatement(start + 1, expressionEnd - 1, innerDepth, scope); + if (inner != null && inner.tables().size() == 1) { + items.add(alias != null ? alias : inner.labels().get(0), inner.tables().get(0)); + return; + } + } + } + + items.add(alias != null ? alias : expressionLabel(start, expressionEnd), null); + } + + private TableRef resolveColumn(ColumnRef column, FromClauseParser.FromClause from) { + List sources = from.sources(); + String qualifier = column.qualifier(); + if (qualifier != null) { + Source source = findSource(qualifier, sources); + if (source == null) { + return null; + } + TableRef origin = source.origin(column.column()); + if (origin != null) { + return origin; + } + return source.knownColumns() ? null : source.baseTable(); + } + + TableRef match = null; + int matches = 0; + for (Source source : sources) { + if (source.hasColumn(column.column())) { + matches++; + match = source.origin(column.column()); + } + } + if (matches == 1) { + return match; + } + if (matches > 1) { + if (ResolutionBuilder.containsIgnoreCase(from.mergedColumns(), column.column())) { + for (Source source : sources) { + if (source.hasColumn(column.column())) { + return source.origin(column.column()); + } + } + } + return null; + } + if (sources.size() == 1 && !sources.get(0).knownColumns()) { + return sources.get(0).baseTable(); + } + return null; + } + + private Source findSource(String qualifier, List sources) { + for (Source source : sources) { + if (source.matchesQualifier(qualifier)) { + return source; + } + } + return null; + } + + private int implicitAlias(int start, int end) { + if (end - start < 2) { + return -1; + } + SqlTokenizer.Token last = cursor.token(end - 1); + if (last.identifier() == null) { + return -1; + } + if (last.type() == SqlTokenizer.Type.WORD + && SqlTableReference.NOT_AN_ALIAS.contains(SqlTokenizer.upper(last.text()))) { + return -1; + } + SqlTokenizer.Token previous = cursor.token(end - 2); + if (previous.isSymbol(".") || previous.isSymbol("(") || previous.isSymbol(",")) { + return -1; + } + if (previous.type() == SqlTokenizer.Type.SYMBOL && !previous.isSymbol(")")) { + return -1; + } + if (previous.type() == SqlTokenizer.Type.WORD + && SqlTableReference.NOT_AN_ALIAS.contains(SqlTokenizer.upper(previous.text()))) { + return -1; + } + return end - 1; + } + + private ColumnRef simpleColumn(int start, int end) { + List parts = new ArrayList<>(); + int index = start; + String first = cursor.identifierAt(index); + if (first == null) { + return null; + } + parts.add(first); + index++; + while (index < end) { + if (!cursor.token(index).isSymbol(".")) { + return null; + } + index++; + if (index >= end) { + return null; + } + String part = cursor.identifierAt(index); + if (part == null) { + return null; + } + parts.add(part); + index++; + } + return parts.size() <= 3 ? new ColumnRef(parts) : null; + } + + private ColumnRef qualifiedStar(int start, int end) { + if (end - start < 3 || !cursor.token(end - 1).isSymbol("*")) { + return null; + } + List parts = new ArrayList<>(); + int index = start; + while (index < end - 1) { + String part = cursor.identifierAt(index); + if (part == null) { + return null; + } + parts.add(part); + index++; + if (index == end - 1) { + break; + } + if (!cursor.token(index).isSymbol(".")) { + return null; + } + index++; + } + if (index != end - 1 || !cursor.token(end - 2).isSymbol(".")) { + return null; + } + if (parts.size() < 1 || parts.size() > 2) { + return null; + } + return new ColumnRef(parts); + } + + private String expressionLabel(int start, int end) { + StringBuilder label = new StringBuilder(); + for (int i = start; i < end; i++) { + label.append(cursor.token(i).text()); + } + return label.toString(); + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlCursor.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlCursor.java new file mode 100644 index 0000000..d8cb1ae --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlCursor.java @@ -0,0 +1,136 @@ +package com.github.magnusp.libsql.jdbc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** + * A read-only position over a token list at one parenthesis depth. + * + *

The cursor owns the bounds guards and the depth comparisons that the parser otherwise repeats + * at every call site. Depth follows the tokenizer convention: an opening parenthesis carries the + * depth outside the group, and the closing parenthesis carries the depth inside it. + */ +final class SqlCursor { + + private final List tokens; + private final int depth; + + SqlCursor(List tokens, int depth) { + this.tokens = tokens; + this.depth = depth; + } + + SqlCursor at(int depth) { + return depth == this.depth ? this : new SqlCursor(tokens, depth); + } + + int depth() { + return depth; + } + + int size() { + return tokens.size(); + } + + SqlTokenizer.Token token(int index) { + return tokens.get(index); + } + + boolean isWordAt(int index, String word) { + if (index < 0 || index >= tokens.size()) { + return false; + } + SqlTokenizer.Token token = tokens.get(index); + return token.depth() == depth && token.isWord(word); + } + + boolean isSymbolAt(int index, String symbol) { + if (index < 0 || index >= tokens.size()) { + return false; + } + SqlTokenizer.Token token = tokens.get(index); + return token.depth() == depth && token.isSymbol(symbol); + } + + String wordAt(int index) { + if (index < 0 || index >= tokens.size()) { + return null; + } + SqlTokenizer.Token token = tokens.get(index); + return token.depth() == depth && token.type() == SqlTokenizer.Type.WORD + ? SqlTokenizer.upper(token.text()) + : null; + } + + String identifierAt(int index) { + if (index < 0 || index >= tokens.size()) { + return null; + } + return tokens.get(index).identifier(); + } + + int indexOfWord(int start, int end, String word) { + for (int index = start; index < end; index++) { + if (isWordAt(index, word)) { + return index; + } + } + return -1; + } + + int indexOfAnyWord(int start, int end, Set words) { + for (int index = start; index < end; index++) { + SqlTokenizer.Token token = tokens.get(index); + if (token.depth() == depth + && token.type() == SqlTokenizer.Type.WORD + && words.contains(SqlTokenizer.upper(token.text()))) { + return index; + } + } + return -1; + } + + int indexOfSymbol(int start, int end, String symbol) { + for (int index = start; index < end; index++) { + if (isSymbolAt(index, symbol)) { + return index; + } + } + return -1; + } + + int matchingParen(int open) { + if (open < 0 || open >= tokens.size()) { + return -1; + } + int openDepth = tokens.get(open).depth(); + for (int index = open + 1; index < tokens.size(); index++) { + SqlTokenizer.Token token = tokens.get(index); + if (token.depth() == openDepth && token.isSymbol(")")) { + return index; + } + } + return -1; + } + + List identifiers(int start, int end) { + List identifiers = new ArrayList<>(); + int index = start; + while (index < end) { + String identifier = identifierAt(index); + if (identifier == null || tokens.get(index).depth() != depth) { + return List.of(); + } + identifiers.add(identifier); + index++; + if (index < end) { + if (!isSymbolAt(index, ",")) { + return List.of(); + } + index++; + } + } + return identifiers; + } +} diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlTableReference.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlTableReference.java index 9c78534..c3964a7 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlTableReference.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/SqlTableReference.java @@ -49,7 +49,18 @@ TableRef table(int index) { } } - private record ColumnRef(List parts) { + /** + * Parses a statement from a token range. A view definition re-enters statement parsing through + * {@link #onTokens}, which keeps the same schema and cycle guard. + */ + interface StatementParser { + Resolution parseStatement(int start, int end, int depth, Map scope) + throws SQLException; + + StatementParser onTokens(List tokens); + } + + record ColumnRef(List parts) { String column() { return parts.get(parts.size() - 1); } @@ -59,7 +70,7 @@ String qualifier() { } } - private record Source( + record Source( String qualifier, List columns, List tables, @@ -89,38 +100,81 @@ boolean matchesQualifier(String qualifier) { } } - private record SourceList(List sources, int next) {} - /** - * The sources of a FROM clause together with the column list {@code *} expands to. The star - * list drops the right-side duplicate of a USING or NATURAL join column, as SQLite does. + * Collects result columns so that every label is paired with the table that supplies it. All + * additions go through one method, which keeps the two lists the same length by construction. */ - private record FromClause( - List sources, - List starLabels, - List starTables, - List mergedColumns) { - - static FromClause of(List sources) { - List labels = new ArrayList<>(); - List tables = new ArrayList<>(); - for (Source source : sources) { - appendStarColumns(source, false, labels, tables); + static final class ResolutionBuilder { + + private final List labels = new ArrayList<>(); + private final List tables = new ArrayList<>(); + + void add(String label, TableRef table) { + labels.add(label); + tables.add(table); + } + + void addAll(List labels, List tables) { + if (labels.size() != tables.size()) { + throw new IllegalArgumentException("labels and tables must have the same size"); + } + this.labels.addAll(labels); + this.tables.addAll(tables); + } + + void addAll(ResolutionBuilder other) { + addAll(other.labels, other.tables); + } + + int size() { + return labels.size(); + } + + boolean containsLabel(String label) { + return containsIgnoreCase(labels, label); + } + + void dropLabels(List columns, int from) { + for (int i = labels.size() - 1; i >= from; i--) { + if (containsIgnoreCase(columns, labels.get(i))) { + labels.remove(i); + tables.remove(i); + } + } + } + + List labels() { + return Collections.unmodifiableList(new ArrayList<>(labels)); + } + + List tables() { + return Collections.unmodifiableList(new ArrayList<>(tables)); + } + + Resolution build() { + return new Resolution(labels(), tables()); + } + + static boolean containsIgnoreCase(List values, String value) { + for (String candidate : values) { + if (candidate.equalsIgnoreCase(value)) { + return true; + } } - return new FromClause(sources, labels, tables, List.of()); + return false; } } - private static final Set FROM_END = Set.of( + static final Set FROM_END = Set.of( "WHERE", "GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", "WINDOW", "RETURNING", "UNION", "INTERSECT", "EXCEPT"); - private static final Set JOIN_START = + static final Set JOIN_START = Set.of("JOIN", "INNER", "LEFT", "RIGHT", "FULL", "CROSS", "NATURAL", "OUTER"); private static final Set SET_OPERATORS = Set.of("UNION", "INTERSECT", "EXCEPT"); - private static final Set NOT_AN_ALIAS = Set.of( + static final Set NOT_AN_ALIAS = Set.of( "AS", "ON", "USING", "INDEXED", "NOT", "COLLATE", "IS", "ISNULL", "NOTNULL", "LIKE", "GLOB", "REGEXP", "MATCH", "ESCAPE", "BETWEEN", "IN", "CASE", "WHEN", "THEN", "ELSE", "END", "CAST", "EXISTS", "AND", "OR", "NULL"); @@ -152,31 +206,40 @@ static Resolution resolve(String sql, Schema schema, int columnCount) throws SQL return resolution; } - private static final class Parser { + private static final class Parser implements StatementParser { - private final List tokens; + private final SqlCursor cursor; private final Schema schema; private final Set resolvingViews; + private final TableBinding binding; private Parser(List tokens, Schema schema, Set resolvingViews) { - this.tokens = tokens; + this.cursor = new SqlCursor(tokens, 0); this.schema = schema; this.resolvingViews = resolvingViews; + this.binding = new TableBinding(schema, resolvingViews); + } + + @Override + public StatementParser onTokens(List tokens) { + return new Parser(tokens, schema, resolvingViews); } - private Resolution parseStatement(int start, int end, int depth, Map scope) + @Override + public Resolution parseStatement(int start, int end, int depth, Map scope) throws SQLException { + SqlCursor cursor = this.cursor.at(depth); int index = start; - while (index < end && SqlTokenizer.isSymbolAt(tokens, index, ";", depth)) { + while (index < end && cursor.isSymbolAt(index, ";")) { index++; } - while (end > index && SqlTokenizer.isSymbolAt(tokens, end - 1, ";", depth)) { + while (end > index && cursor.isSymbolAt(end - 1, ";")) { end--; } if (index >= end) { return null; } - String keyword = SqlTokenizer.wordAt(tokens, index, depth); + String keyword = cursor.wordAt(index); if (keyword == null) { return null; } @@ -190,67 +253,51 @@ private Resolution parseStatement(int start, int end, int depth, Map scope) throws SQLException { + SqlCursor cursor = this.cursor.at(depth); Map ctes = new HashMap<>(scope); int index = start + 1; - if (SqlTokenizer.isWordAt(tokens, index, depth, "RECURSIVE")) { + if (cursor.isWordAt(index, "RECURSIVE")) { index++; } while (index < end) { - String name = SqlTokenizer.identifierAt(tokens, index); + String name = cursor.identifierAt(index); if (name == null) { return null; } index++; List columns = null; - if (SqlTokenizer.isSymbolAt(tokens, index, "(", depth)) { - int close = SqlTokenizer.matchingParen(tokens, index); + if (cursor.isSymbolAt(index, "(")) { + int close = cursor.matchingParen(index); if (close < 0 || close >= end) { return null; } - columns = SqlTokenizer.identifiers(tokens, index + 1, close, depth + 1); + columns = cursor.at(depth + 1).identifiers(index + 1, close); index = close + 1; } - if (!SqlTokenizer.isWordAt(tokens, index, depth, "AS")) { + if (!cursor.isWordAt(index, "AS")) { return null; } index++; - if (SqlTokenizer.isWordAt(tokens, index, depth, "NOT")) { + if (cursor.isWordAt(index, "NOT")) { index += 2; - } else if (SqlTokenizer.isWordAt(tokens, index, depth, "MATERIALIZED")) { + } else if (cursor.isWordAt(index, "MATERIALIZED")) { index++; } - if (!SqlTokenizer.isSymbolAt(tokens, index, "(", depth)) { + if (!cursor.isSymbolAt(index, "(")) { return null; } - int close = SqlTokenizer.matchingParen(tokens, index); + int close = cursor.matchingParen(index); if (close < 0 || close >= end) { return null; } Resolution resolution = parseStatement(index + 1, close, depth + 1, ctes); if (resolution != null) { - ctes.put( - name.toLowerCase(Locale.ROOT), - new Source( - name, - columns != null && columns.size() == resolution.labels().size() - ? columns - : resolution.labels(), - resolution.tables(), - true, - null)); + ctes.put(name.toLowerCase(Locale.ROOT), binding.cte(name, columns, resolution)); } else { - List opaque = columns != null ? columns : List.of(); - ctes.put( - name.toLowerCase(Locale.ROOT), - new Source( - name, - opaque, - Collections.nCopies(opaque.size(), null), - columns != null, - null)); + ctes.put(name.toLowerCase(Locale.ROOT), binding.opaqueCte(name, columns)); } index = close + 1; - if (SqlTokenizer.isSymbolAt(tokens, index, ",", depth)) { + if (cursor.isSymbolAt(index, ",")) { index++; continue; } @@ -264,270 +311,35 @@ private Resolution parseWith(int start, int end, int depth, Map private Resolution parseSelect(int start, int end, int depth, Map scope) throws SQLException { + SqlCursor cursor = this.cursor.at(depth); int index = start + 1; - while (SqlTokenizer.isWordAt(tokens, index, depth, "ALL") - || SqlTokenizer.isWordAt(tokens, index, depth, "DISTINCT")) { + while (cursor.isWordAt(index, "ALL") || cursor.isWordAt(index, "DISTINCT")) { index++; } - int setOperator = SqlTokenizer.indexOfAnyWord(tokens, index, end, depth, SET_OPERATORS); + int setOperator = cursor.indexOfAnyWord(index, end, SET_OPERATORS); int selectEnd = setOperator >= 0 ? setOperator : end; - int fromIndex = SqlTokenizer.indexOfWord(tokens, index, selectEnd, depth, "FROM"); + int fromIndex = cursor.indexOfWord(index, selectEnd, "FROM"); int listEnd = fromIndex >= 0 ? fromIndex : selectEnd; - FromClause from = FromClause.of(List.of()); + FromClauseParser.FromClause from = FromClauseParser.FromClause.of(List.of()); if (fromIndex >= 0) { - int fromEnd = SqlTokenizer.indexOfAnyWord(tokens, fromIndex + 1, selectEnd, depth, FROM_END); + int fromEnd = cursor.indexOfAnyWord(fromIndex + 1, selectEnd, FROM_END); if (fromEnd < 0) { fromEnd = selectEnd; } - FromClause parsed = parseSources(fromIndex + 1, fromEnd, depth, scope); + FromClauseParser.FromClause parsed = new FromClauseParser(cursor, binding, this, scope) + .parseSources(fromIndex + 1, fromEnd); if (parsed == null || parsed.sources().isEmpty()) { return null; } from = parsed; } - return parseSelectItems(index, listEnd, depth, from, scope); - } - - private FromClause parseSources(int start, int end, int depth, Map scope) - throws SQLException { - List sources = new ArrayList<>(); - List starLabels = new ArrayList<>(); - List starTables = new ArrayList<>(); - List mergedColumns = new ArrayList<>(); - boolean natural = false; - int index = start; - while (index < end) { - SourceList parsed = parseSource(index, end, depth, scope); - if (parsed == null) { - return null; - } - int starStart = starLabels.size(); - for (Source source : parsed.sources()) { - sources.add(source); - appendStarColumns(source, natural, starLabels, starTables); - } - index = parsed.next(); - index = skipIndexModifiers(index, end, depth); - if (SqlTokenizer.isWordAt(tokens, index, depth, "USING")) { - List merged = usingColumns(index, end, depth); - dropStarColumns(merged, starStart, starLabels, starTables); - for (String column : merged) { - if (!containsIgnoreCase(mergedColumns, column)) { - mergedColumns.add(column); - } - } - } - index = skipJoinCondition(index, end, depth); - if (SqlTokenizer.isSymbolAt(tokens, index, ",", depth)) { - natural = false; - index++; - continue; - } - if (index < end && isJoinStart(index, end, depth)) { - natural = SqlTokenizer.isWordAt(tokens, index, depth, "NATURAL"); - index = skipToJoin(index, end, depth); - if (index < 0) { - break; - } - index++; - continue; - } - break; - } - return new FromClause(sources, starLabels, starTables, mergedColumns); - } - - private SourceList parseSource(int start, int end, int depth, Map scope) - throws SQLException { - int index = start; - if (SqlTokenizer.isSymbolAt(tokens, index, "(", depth)) { - int close = SqlTokenizer.matchingParen(tokens, index); - if (close < 0 || close >= end) { - return null; - } - int innerDepth = depth + 1; - String innerKeyword = SqlTokenizer.wordAt(tokens, index + 1, innerDepth); - if ("SELECT".equals(innerKeyword) || "WITH".equals(innerKeyword)) { - Resolution inner = parseStatement(index + 1, close, innerDepth, scope); - index = close + 1; - String alias = null; - if (SqlTokenizer.isWordAt(tokens, index, depth, "AS")) { - index++; - alias = SqlTokenizer.identifierAt(tokens, index); - if (alias == null) { - return null; - } - index++; - } else if (isAlias(index, depth)) { - alias = SqlTokenizer.identifierAt(tokens, index); - index++; - } - List aliasColumns = null; - if (SqlTokenizer.isSymbolAt(tokens, index, "(", depth)) { - int columnsEnd = SqlTokenizer.matchingParen(tokens, index); - if (columnsEnd < 0 || columnsEnd >= end) { - return null; - } - aliasColumns = SqlTokenizer.identifiers(tokens, index + 1, columnsEnd, depth + 1); - index = columnsEnd + 1; - } - if (inner == null) { - inner = Resolution.EMPTY; - } - List labels = aliasColumns != null && aliasColumns.size() == inner.labels().size() - ? aliasColumns - : inner.labels(); - return new SourceList( - List.of(new Source(alias, labels, inner.tables(), true, null)), index); - } - FromClause grouped = parseSources(index + 1, close, innerDepth, scope); - if (grouped == null) { - return null; - } - index = close + 1; - String alias = null; - if (SqlTokenizer.isWordAt(tokens, index, depth, "AS")) { - index++; - alias = SqlTokenizer.identifierAt(tokens, index); - if (alias == null) { - return null; - } - index++; - } else if (isAlias(index, depth)) { - alias = SqlTokenizer.identifierAt(tokens, index); - index++; - } - if (alias == null) { - return new SourceList(grouped.sources(), index); - } - return new SourceList( - List.of(new Source(alias, grouped.starLabels(), grouped.starTables(), true, null)), - index); - } - - String first = SqlTokenizer.identifierAt(tokens, index); - if (first == null) { - return null; - } - index++; - String schemaName = null; - if (SqlTokenizer.isSymbolAt(tokens, index, ".", depth)) { - index++; - String second = SqlTokenizer.identifierAt(tokens, index); - if (second == null) { - return null; - } - index++; - if (SqlTokenizer.isSymbolAt(tokens, index, ".", depth)) { - return null; - } - schemaName = first; - first = second; - } - String alias = null; - if (SqlTokenizer.isWordAt(tokens, index, depth, "AS")) { - index++; - alias = SqlTokenizer.identifierAt(tokens, index); - if (alias == null) { - return null; - } - index++; - } else if (isAlias(index, depth)) { - alias = SqlTokenizer.identifierAt(tokens, index); - index++; - } - TableRef reference = new TableRef(schemaName, first); - String qualifier = alias != null ? alias : first; - if (schemaName == null) { - Source cte = scope.get(first.toLowerCase(Locale.ROOT)); - if (cte != null) { - return new SourceList( - List.of(new Source( - qualifier, - cte.columns(), - cte.tables(), - cte.knownColumns(), - cte.baseTable())), - index); - } - } - TableDefinition definition = schema.definition(reference); - if (definition == null) { - return new SourceList( - List.of(new Source(qualifier, List.of(), List.of(), false, reference)), index); - } - if (isViewDefinition(definition.sql())) { - Resolution view = resolveView(reference, definition, depth, 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 SourceList( - List.of(new Source(qualifier, view.labels(), view.tables(), true, null)), index); - } - List 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); - } - - private Resolution resolveView( - TableRef reference, TableDefinition definition, int depth, Map scope) - throws SQLException { - String key = (reference.schema() != null ? reference.schema() + "." : "") - + reference.table().toLowerCase(Locale.ROOT); - if (!resolvingViews.add(key)) { - return null; - } - try { - List viewTokens = SqlTokenizer.tokenize(definition.sql()); - if (viewTokens == null) { - return null; - } - int as = SqlTokenizer.indexOfWord(viewTokens, 0, viewTokens.size(), 0, "AS"); - if (as < 0) { - return null; - } - List explicit = null; - for (int i = 0; i < as; i++) { - SqlTokenizer.Token token = viewTokens.get(i); - if (token.depth() == 0 && token.isSymbol("(")) { - int close = SqlTokenizer.matchingParen(viewTokens, i); - if (close < 0) { - return null; - } - explicit = SqlTokenizer.identifiers(viewTokens, i + 1, close, 1); - break; - } - } - Resolution inner = new Parser(viewTokens, schema, resolvingViews) - .parseStatement(as + 1, viewTokens.size(), 0, new HashMap<>(scope)); - if (inner == null) { - return null; - } - if (explicit != null && explicit.size() == inner.labels().size()) { - return new Resolution(explicit, inner.tables()); - } - return inner; - } finally { - resolvingViews.remove(key); - } + return new SelectItemParser(cursor, this, from, scope).parseItems(index, listEnd); } private Resolution parseReturning(int start, int end, int depth, Map scope) throws SQLException { - int returning = SqlTokenizer.indexOfWord(tokens, start, end, depth, "RETURNING"); + SqlCursor cursor = this.cursor.at(depth); + int returning = cursor.indexOfWord(start, end, "RETURNING"); if (returning < 0) { return null; } @@ -536,35 +348,25 @@ private Resolution parseReturning(int start, int end, int depth, Map columns = definition.columns(); - source = new Source( - qualifier, columns, Collections.nCopies(columns.size(), null), true, null); - } else { - List columns = definition.columns() != null ? definition.columns() : List.of(); - source = new Source( - qualifier, - columns, - Collections.nCopies(columns.size(), target.table()), - true, - target.table()); - } - return parseSelectItems(returning + 1, end, depth, FromClause.of(List.of(source)), scope); + Source source = binding.returningSource(target.table(), qualifier); + return new SelectItemParser( + cursor, + this, + FromClauseParser.FromClause.of(List.of(source)), + scope) + .parseItems(returning + 1, end); } private Target targetTable(int start, int end, int depth) { - String keyword = SqlTokenizer.wordAt(tokens, start, depth); + SqlCursor cursor = this.cursor.at(depth); + String keyword = cursor.wordAt(start); if (keyword == null) { return null; } int index; switch (keyword) { case "INSERT", "REPLACE" -> { - index = SqlTokenizer.indexOfWord(tokens, start, end, depth, "INTO"); + index = cursor.indexOfWord(start, end, "INTO"); if (index < 0) { return null; } @@ -572,12 +374,12 @@ private Target targetTable(int start, int end, int depth) { } case "UPDATE" -> { index = start + 1; - if (SqlTokenizer.isWordAt(tokens, index, depth, "OR")) { + if (cursor.isWordAt(index, "OR")) { index += 2; } } case "DELETE" -> { - index = SqlTokenizer.indexOfWord(tokens, start, end, depth, "FROM"); + index = cursor.indexOfWord(start, end, "FROM"); if (index < 0) { return null; } @@ -587,15 +389,15 @@ private Target targetTable(int start, int end, int depth) { return null; } } - String first = SqlTokenizer.identifierAt(tokens, index); + String first = cursor.identifierAt(index); if (first == null) { return null; } index++; String schemaName = null; - if (SqlTokenizer.isSymbolAt(tokens, index, ".", depth)) { + if (cursor.isSymbolAt(index, ".")) { index++; - String second = SqlTokenizer.identifierAt(tokens, index); + String second = cursor.identifierAt(index); if (second == null) { return null; } @@ -603,396 +405,13 @@ private Target targetTable(int start, int end, int depth) { first = second; } String alias = null; - if (SqlTokenizer.isWordAt(tokens, index, depth, "AS")) { - alias = SqlTokenizer.identifierAt(tokens, index + 1); + if (cursor.isWordAt(index, "AS")) { + alias = cursor.identifierAt(index + 1); if (alias == null) { return null; } } return new Target(new TableRef(schemaName, first), alias); } - - private Resolution parseSelectItems( - int start, int end, int depth, FromClause from, Map scope) - throws SQLException { - List labels = new ArrayList<>(); - List tables = new ArrayList<>(); - int index = start; - while (index < end) { - int comma = SqlTokenizer.indexOfSymbol(tokens, index, end, depth, ","); - int itemEnd = comma < 0 ? end : comma; - parseItem(index, itemEnd, depth, from, scope, labels, tables); - if (comma < 0) { - break; - } - index = comma + 1; - } - return new Resolution(labels, tables); - } - - private void parseItem( - int start, - int end, - int depth, - FromClause from, - Map scope, - List labels, - List tables) - throws SQLException { - if (start >= end) { - labels.add(""); - tables.add(null); - return; - } - String alias = null; - int expressionEnd = end; - int as = SqlTokenizer.indexOfWord(tokens, start, end, depth, "AS"); - if (as >= 0 && as + 1 < end) { - String candidate = SqlTokenizer.identifierAt(tokens, as + 1); - if (candidate != null) { - alias = candidate; - expressionEnd = as; - } - } else { - int implicit = implicitAlias(start, end, depth); - if (implicit >= 0) { - alias = SqlTokenizer.identifierAt(tokens, implicit); - expressionEnd = implicit; - } - } - if (expressionEnd <= start) { - labels.add(alias != null ? alias : ""); - tables.add(null); - return; - } - - if (expressionEnd - start == 1 && tokens.get(start).isSymbol("*")) { - labels.addAll(from.starLabels()); - tables.addAll(from.starTables()); - return; - } - - ColumnRef qualifiedStar = qualifiedStar(start, expressionEnd); - if (qualifiedStar != null) { - 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()); - } - return; - } - - ColumnRef column = simpleColumn(start, expressionEnd); - if (column != null) { - labels.add(alias != null ? alias : column.column()); - tables.add(resolveColumn(column, from)); - return; - } - - if (tokens.get(start).isSymbol("(") - && SqlTokenizer.matchingParen(tokens, start) == expressionEnd - 1) { - int innerDepth = depth + 1; - String innerKeyword = SqlTokenizer.wordAt(tokens, start + 1, innerDepth); - if ("SELECT".equals(innerKeyword) || "WITH".equals(innerKeyword)) { - Resolution inner = 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)); - return; - } - } - } - - labels.add(alias != null ? alias : expressionLabel(start, expressionEnd)); - tables.add(null); - } - - private TableRef resolveColumn(ColumnRef column, FromClause from) { - List sources = from.sources(); - String qualifier = column.qualifier(); - if (qualifier != null) { - Source source = findSource(qualifier, sources); - if (source == null) { - return null; - } - TableRef origin = source.origin(column.column()); - if (origin != null) { - return origin; - } - return source.knownColumns() ? null : source.baseTable(); - } - - TableRef match = null; - int matches = 0; - for (Source source : sources) { - if (source.hasColumn(column.column())) { - matches++; - match = source.origin(column.column()); - } - } - if (matches == 1) { - return match; - } - if (matches > 1) { - if (containsIgnoreCase(from.mergedColumns(), column.column())) { - for (Source source : sources) { - if (source.hasColumn(column.column())) { - return source.origin(column.column()); - } - } - } - return null; - } - if (sources.size() == 1 && !sources.get(0).knownColumns()) { - return sources.get(0).baseTable(); - } - return null; - } - - private Source findSource(String qualifier, List sources) { - for (Source source : sources) { - if (source.matchesQualifier(qualifier)) { - return source; - } - } - return null; - } - - private int implicitAlias(int start, int end, int depth) { - if (end - start < 2) { - return -1; - } - SqlTokenizer.Token last = tokens.get(end - 1); - if (last.identifier() == null) { - return -1; - } - if (last.type() == SqlTokenizer.Type.WORD - && NOT_AN_ALIAS.contains(SqlTokenizer.upper(last.text()))) { - return -1; - } - SqlTokenizer.Token previous = tokens.get(end - 2); - if (previous.isSymbol(".") || previous.isSymbol("(") || previous.isSymbol(",")) { - return -1; - } - if (previous.type() == SqlTokenizer.Type.SYMBOL && !previous.isSymbol(")")) { - return -1; - } - if (previous.type() == SqlTokenizer.Type.WORD - && NOT_AN_ALIAS.contains(SqlTokenizer.upper(previous.text()))) { - return -1; - } - return end - 1; - } - - private ColumnRef simpleColumn(int start, int end) { - List parts = new ArrayList<>(); - int index = start; - String first = SqlTokenizer.identifierAt(tokens, index); - if (first == null) { - return null; - } - parts.add(first); - index++; - while (index < end) { - if (!tokens.get(index).isSymbol(".")) { - return null; - } - index++; - if (index >= end) { - return null; - } - String part = SqlTokenizer.identifierAt(tokens, index); - if (part == null) { - return null; - } - parts.add(part); - index++; - } - return parts.size() <= 3 ? new ColumnRef(parts) : null; - } - - private ColumnRef qualifiedStar(int start, int end) { - if (end - start < 3 || !tokens.get(end - 1).isSymbol("*")) { - return null; - } - List parts = new ArrayList<>(); - int index = start; - while (index < end - 1) { - String part = SqlTokenizer.identifierAt(tokens, index); - if (part == null) { - return null; - } - parts.add(part); - index++; - if (index == end - 1) { - break; - } - if (!tokens.get(index).isSymbol(".")) { - return null; - } - index++; - } - if (index != end - 1 || !tokens.get(end - 2).isSymbol(".")) { - return null; - } - if (parts.size() < 1 || parts.size() > 2) { - return null; - } - return new ColumnRef(parts); - } - - private String expressionLabel(int start, int end) { - StringBuilder label = new StringBuilder(); - for (int i = start; i < end; i++) { - label.append(tokens.get(i).text()); - } - return label.toString(); - } - - private int skipIndexModifiers(int start, int end, int depth) { - int index = start; - while (index < end) { - if (SqlTokenizer.isWordAt(tokens, index, depth, "INDEXED")) { - index += 3; - continue; - } - if (SqlTokenizer.isWordAt(tokens, index, depth, "NOT") - && SqlTokenizer.isWordAt(tokens, index + 1, depth, "INDEXED")) { - index += 2; - continue; - } - break; - } - return index; - } - - private int skipJoinCondition(int start, int end, int depth) { - if (!SqlTokenizer.isWordAt(tokens, start, depth, "ON") - && !SqlTokenizer.isWordAt(tokens, start, depth, "USING")) { - return start; - } - int index = start + 1; - while (index < end) { - if (tokens.get(index).depth() == depth) { - if (tokens.get(index).isSymbol(",") || isJoinStart(index, end, depth)) { - break; - } - } - index++; - } - return index; - } - - private boolean isJoinStart(int index, int end, int depth) { - if (index >= end) { - return false; - } - SqlTokenizer.Token token = tokens.get(index); - if (token.depth() != depth || token.type() != SqlTokenizer.Type.WORD) { - return false; - } - String keyword = SqlTokenizer.upper(token.text()); - if (!JOIN_START.contains(keyword)) { - return false; - } - if ("LEFT".equals(keyword) || "RIGHT".equals(keyword)) { - return SqlTokenizer.isWordAt(tokens, index + 1, depth, "JOIN") - || SqlTokenizer.isWordAt(tokens, index + 1, depth, "OUTER"); - } - return true; - } - - private int skipToJoin(int start, int end, int depth) { - for (int index = start; index < end; index++) { - if (SqlTokenizer.isWordAt(tokens, index, depth, "JOIN")) { - return index; - } - } - return -1; - } - - private boolean isAlias(int index, int depth) { - if (index >= tokens.size()) { - return false; - } - SqlTokenizer.Token token = tokens.get(index); - if (token.depth() != depth || token.identifier() == null) { - return false; - } - if (token.type() == SqlTokenizer.Type.WORD) { - String keyword = SqlTokenizer.upper(token.text()); - return !FROM_END.contains(keyword) - && !JOIN_START.contains(keyword) - && !NOT_AN_ALIAS.contains(keyword); - } - return true; - } - - private List usingColumns(int start, int end, int depth) { - if (!SqlTokenizer.isSymbolAt(tokens, start + 1, "(", depth)) { - return List.of(); - } - int close = SqlTokenizer.matchingParen(tokens, start + 1); - if (close < 0 || close > end) { - return List.of(); - } - return SqlTokenizer.identifiers(tokens, start + 2, close, depth + 1); - } - - } - - private static boolean isViewDefinition(String sql) { - if (sql == null) { - return false; - } - List tokens = SqlTokenizer.tokenize(sql); - if (tokens == null) { - return false; - } - int index = 0; - if (!SqlTokenizer.isWordAt(tokens, index, 0, "CREATE")) { - return false; - } - index++; - if (SqlTokenizer.isWordAt(tokens, index, 0, "TEMP") - || SqlTokenizer.isWordAt(tokens, index, 0, "TEMPORARY")) { - index++; - } - return SqlTokenizer.isWordAt(tokens, index, 0, "VIEW"); - } - - private static void appendStarColumns( - Source source, boolean merged, List labels, List tables) { - if (!source.knownColumns()) { - return; - } - for (int i = 0; i < source.columns().size(); i++) { - String label = source.columns().get(i); - if (merged && containsIgnoreCase(labels, label)) { - continue; - } - labels.add(label); - tables.add(source.tables().get(i)); - } - } - - private static void dropStarColumns( - List columns, int from, List labels, List tables) { - for (int i = labels.size() - 1; i >= from; i--) { - if (containsIgnoreCase(columns, labels.get(i))) { - labels.remove(i); - tables.remove(i); - } - } - } - - private static boolean containsIgnoreCase(List values, String value) { - for (String candidate : values) { - if (candidate.equalsIgnoreCase(value)) { - return true; - } - } - return false; } } diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/TableBinding.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/TableBinding.java new file mode 100644 index 0000000..2c1ec74 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/TableBinding.java @@ -0,0 +1,166 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.jdbc.SqlTableReference.Resolution; +import com.github.magnusp.libsql.jdbc.SqlTableReference.Schema; +import com.github.magnusp.libsql.jdbc.SqlTableReference.Source; +import com.github.magnusp.libsql.jdbc.SqlTableReference.StatementParser; +import com.github.magnusp.libsql.jdbc.SqlTableReference.TableDefinition; +import com.github.magnusp.libsql.jdbc.SqlTableReference.TableRef; + +import java.sql.SQLException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Builds the parser's {@code Source} view of a table, common table expression, or view. + * + *

A schema definition is read here, once per source occurrence, so the metadata cache warms + * during parsing and a definition failure propagates to the caller. A view re-enters statement + * parsing with the same schema and a shared cycle guard. + */ +final class TableBinding { + + private final Schema schema; + private final Set resolvingViews; + + TableBinding(Schema schema, Set resolvingViews) { + this.schema = schema; + this.resolvingViews = resolvingViews; + } + + Source cte(String name, List columns, Resolution resolution) { + return new Source( + name, + columns != null && columns.size() == resolution.labels().size() + ? columns + : resolution.labels(), + resolution.tables(), + true, + null); + } + + Source opaqueCte(String name, List columns) { + List opaque = columns != null ? columns : List.of(); + return new Source( + name, opaque, Collections.nCopies(opaque.size(), null), columns != null, null); + } + + Source cteReference(Source cte, String qualifier) { + return new Source( + qualifier, cte.columns(), cte.tables(), cte.knownColumns(), cte.baseTable()); + } + + Source source( + TableRef reference, + String qualifier, + StatementParser parser, + Map scope) + throws SQLException { + TableDefinition definition = schema.definition(reference); + if (definition == null) { + return new Source(qualifier, List.of(), List.of(), false, reference); + } + if (isViewDefinition(definition.sql())) { + Resolution view = resolveView(reference, definition, parser, scope); + if (view == null) { + return new Source( + qualifier, + definition.columns(), + Collections.nCopies(definition.columns().size(), null), + true, + null); + } + return new Source(qualifier, view.labels(), view.tables(), true, null); + } + List columns = definition.columns() != null ? definition.columns() : List.of(); + return new Source( + qualifier, columns, Collections.nCopies(columns.size(), reference), true, reference); + } + + Source returningSource(TableRef reference, String qualifier) throws SQLException { + TableDefinition definition = schema.definition(reference); + if (definition == null) { + return new Source(qualifier, List.of(), List.of(), false, reference); + } + if (isViewDefinition(definition.sql())) { + List columns = definition.columns(); + return new Source( + qualifier, columns, Collections.nCopies(columns.size(), null), true, null); + } + List columns = definition.columns() != null ? definition.columns() : List.of(); + return new Source( + qualifier, columns, Collections.nCopies(columns.size(), reference), true, reference); + } + + private Resolution resolveView( + TableRef reference, + TableDefinition definition, + StatementParser parser, + Map scope) + throws SQLException { + String key = (reference.schema() != null ? reference.schema() + "." : "") + + reference.table().toLowerCase(Locale.ROOT); + if (!resolvingViews.add(key)) { + return null; + } + try { + List viewTokens = SqlTokenizer.tokenize(definition.sql()); + if (viewTokens == null) { + return null; + } + SqlCursor viewCursor = new SqlCursor(viewTokens, 0); + int as = viewCursor.indexOfWord(0, viewTokens.size(), "AS"); + if (as < 0) { + return null; + } + List explicit = null; + for (int i = 0; i < as; i++) { + SqlTokenizer.Token token = viewCursor.token(i); + if (token.depth() == 0 && token.isSymbol("(")) { + int close = viewCursor.matchingParen(i); + if (close < 0) { + return null; + } + explicit = viewCursor.at(1).identifiers(i + 1, close); + break; + } + } + Resolution inner = parser + .onTokens(viewTokens) + .parseStatement(as + 1, viewTokens.size(), 0, new HashMap<>(scope)); + if (inner == null) { + return null; + } + if (explicit != null && explicit.size() == inner.labels().size()) { + return new Resolution(explicit, inner.tables()); + } + return inner; + } finally { + resolvingViews.remove(key); + } + } + + private static boolean isViewDefinition(String sql) { + if (sql == null) { + return false; + } + List tokens = SqlTokenizer.tokenize(sql); + if (tokens == null) { + return false; + } + SqlCursor cursor = new SqlCursor(tokens, 0); + int index = 0; + if (!cursor.isWordAt(index, "CREATE")) { + return false; + } + index++; + if (cursor.isWordAt(index, "TEMP") || cursor.isWordAt(index, "TEMPORARY")) { + index++; + } + return cursor.isWordAt(index, "VIEW"); + } +} diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/SqlTableReferenceTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/SqlTableReferenceTest.java index 723a820..f840ac9 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/SqlTableReferenceTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/SqlTableReferenceTest.java @@ -354,6 +354,47 @@ void resolvesReturningColumnsThroughTheTargetAlias() throws SQLException { new SqlTableReference.TableRef(null, "a")); } + @Test + void resolvesReturningThroughTheTableNameWhenTheTargetHasNoAlias() throws SQLException { + SqlTableReference.Resolution resolution = resolve("DELETE FROM w RETURNING w.a"); + + assertThat(resolution.labels()).containsExactly("a"); + assertThat(resolution.tables()).containsExactly(new SqlTableReference.TableRef(null, "w")); + // The table reference is the nullability path: w.a maps back to the NOT NULL column of w. + assertThat(resolution.table(0)).isEqualTo(new SqlTableReference.TableRef(null, "w")); + } + + @Test + void doesNotBindAnImplicitUpdateAliasToTheReturningSource() throws SQLException { + SqlTableReference.Resolution implicit = resolve("UPDATE w x RETURNING x.a"); + SqlTableReference.Resolution byTableName = resolve("UPDATE w x RETURNING w.a"); + + assertThat(implicit.labels()).containsExactly("a"); + assertThat(implicit.tables()).containsExactly((SqlTableReference.TableRef) null); + assertThat(implicit.table(0)).isNull(); + assertThat(byTableName.tables()).containsExactly(new SqlTableReference.TableRef(null, "w")); + } + + @Test + void doesNotBindAnImplicitInsertAliasToTheReturningSource() throws SQLException { + SqlTableReference.Resolution implicit = resolve("INSERT INTO w t SELECT 1 RETURNING t.a"); + SqlTableReference.Resolution byTableName = resolve("INSERT INTO w t SELECT 1 RETURNING w.a"); + + assertThat(implicit.labels()).containsExactly("a"); + assertThat(implicit.tables()).containsExactly((SqlTableReference.TableRef) null); + assertThat(implicit.table(0)).isNull(); + assertThat(byTableName.tables()).containsExactly(new SqlTableReference.TableRef(null, "w")); + } + + @Test + void resolvesReturningThroughAnExplicitAsAlias() throws SQLException { + SqlTableReference.Resolution resolution = resolve("UPDATE w AS x RETURNING x.a"); + + assertThat(resolution.labels()).containsExactly("a"); + assertThat(resolution.tables()).containsExactly(new SqlTableReference.TableRef(null, "w")); + assertThat(resolution.table(0)).isEqualTo(new SqlTableReference.TableRef(null, "w")); + } + @Test void acceptsTrailingSemicolon() throws SQLException { assertThat(resolve("SELECT a FROM w;").tables())