diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java index 2f2d120..e34034f 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaData.java @@ -10,7 +10,7 @@ import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -30,7 +30,8 @@ public class LibsqlResultSetMetaData implements ResultSetMetaData { private final LibsqlResultSet resultSet; private boolean tableContextResolved; - private TableInfo tableContext; + private SqlTableReference.Resolution columnOrigins; + private final Map tableInfoCache = new LinkedHashMap<>(); public LibsqlResultSetMetaData(List cols) { this(cols, List.of(), null, null, null); @@ -63,8 +64,7 @@ private Col getCol(int column) throws SQLException { @Override public boolean isAutoIncrement(int column) throws SQLException { - getCol(column); - TableInfo table = resolveTableContext(); + TableInfo table = tableForColumn(column); ColumnInfo info = table != null ? table.columns().get(columnName(column)) : null; return info != null && info.primaryKey() && isAutoIncrementColumn(table.createSql(), columnName(column)); } @@ -106,12 +106,14 @@ public boolean isCurrency(int column) throws SQLException { @Override public int isNullable(int column) throws SQLException { - getCol(column); - TableInfo table = resolveTableContext(); - ColumnInfo info = table != null ? table.columns().get(columnName(column)) : null; - if (info == null) { + TableInfo table = tableForColumn(column); + if (table == null) { return columnNullableUnknown; } + ColumnInfo info = table.columns().get(columnName(column)); + if (info == null) { + return columnNullable; + } return info.notNull() ? columnNoNulls : columnNullable; } @@ -193,12 +195,8 @@ public int getScale(int column) throws SQLException { @Override public String getTableName(int column) throws SQLException { - getCol(column); - TableInfo table = resolveTableContext(); - if (table == null || !table.columns().containsKey(columnName(column))) { - return ""; - } - return table.name(); + TableInfo table = tableForColumn(column); + return table != null ? table.name() : ""; } @Override @@ -423,19 +421,46 @@ private static SampleType storageType(Value value) { return SampleType.NULL; } - private TableInfo resolveTableContext() throws SQLException { + private TableInfo tableForColumn(int column) throws SQLException { + getCol(column); + SqlTableReference.TableRef reference = originTable(column); + return reference != null ? tableInfo(reference) : null; + } + + private SqlTableReference.TableRef originTable(int column) throws SQLException { + SqlTableReference.Resolution resolution = resolveColumnOrigins(); + return resolution != null ? resolution.table(column - 1) : null; + } + + private SqlTableReference.Resolution resolveColumnOrigins() throws SQLException { if (!tableContextResolved) { - TableInfo resolved = null; + SqlTableReference.Resolution resolved = null; if (connection != null && sql != null) { - SqlTableReference.TableRef reference = SqlTableReference.parse(sql); - if (reference != null) { - resolved = loadTableInfo(reference); - } - } - tableContext = resolved; + resolved = SqlTableReference.resolve(sql, reference -> { + TableInfo info = tableInfo(reference); + if (info == null) { + return null; + } + return new SqlTableReference.TableDefinition( + info.createSql(), new ArrayList<>(info.columns().keySet())); + }); + } + columnOrigins = resolved; tableContextResolved = true; } - return tableContext; + return columnOrigins; + } + + private TableInfo tableInfo(SqlTableReference.TableRef reference) throws SQLException { + String schema = reference.schema() != null ? reference.schema().toLowerCase(Locale.ROOT) : ""; + String table = reference.table() != null ? reference.table().toLowerCase(Locale.ROOT) : ""; + String key = schema + "." + table; + if (tableInfoCache.containsKey(key)) { + return tableInfoCache.get(key); + } + TableInfo info = loadTableInfo(reference); + tableInfoCache.put(key, info); + return info; } private TableInfo loadTableInfo(SqlTableReference.TableRef reference) throws SQLException { @@ -459,7 +484,8 @@ private TableInfo loadTableInfo(String schema, String table) throws SQLException String master = schema.equals("temp") ? "sqlite_temp_master" : "sqlite_master"; String query = "SELECT m.name, m.sql, p.name, p.\"notnull\", p.pk " + "FROM " + master + " m JOIN pragma_table_info(?, ?) p " - + "WHERE m.type IN ('table', 'view') AND m.name COLLATE NOCASE = ?"; + + "WHERE m.type IN ('table', 'view') AND m.name COLLATE NOCASE = ? " + + "ORDER BY p.cid"; StmtResult result = connection.executeStatement( new Stmt(query, List.of(Value.of(table), Value.of(schema), Value.of(table)))); return toTableInfo(result, table); @@ -473,7 +499,8 @@ private TableInfo loadAttachedTableInfo(String schema, String table) throws SQLE String quotedSchema = "\"" + actualSchema.replace("\"", "\"\"") + "\""; String query = "SELECT m.name, m.sql, p.name, p.\"notnull\", p.pk " + "FROM " + quotedSchema + ".sqlite_master m JOIN pragma_table_info(?, ?) p " - + "WHERE m.type IN ('table', 'view') AND m.name COLLATE NOCASE = ?"; + + "WHERE m.type IN ('table', 'view') AND m.name COLLATE NOCASE = ? " + + "ORDER BY p.cid"; StmtResult result = connection.executeStatement(new Stmt( query, List.of(Value.of(table), Value.of(actualSchema), Value.of(table)))); return toTableInfo(result, table); @@ -504,7 +531,7 @@ private static TableInfo toTableInfo(StmtResult result, String fallbackName) { } String tableName = null; String createSql = null; - Map columns = new HashMap<>(); + Map columns = new LinkedHashMap<>(); for (Row row : result.rows()) { List values = row.values(); if (values == null || values.size() < 5) { 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 fd36dc2..2fc01b2 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 @@ -1,15 +1,53 @@ package com.github.magnusp.libsql.jdbc; +import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.regex.Pattern; +/** + * Resolves the origin table of each result column in a SQLite statement. + * + *

Resolved shapes: + * + *

    + *
  • simple queries, including {@code SELECT *} and schema-qualified names + *
  • column aliases, including aliases over joined, subquery, and view columns + *
  • joins, including comma joins and explicit {@code JOIN} clauses + *
  • subqueries in the FROM clause, scalar subqueries, and common table expressions + *
  • the leftmost branch of a compound SELECT + *
  • {@code INSERT}, {@code UPDATE}, and {@code DELETE} with {@code RETURNING} + *
  • views, which resolve to the base table that supplies each column + *
+ * + *

Expressions, literals, and other shapes whose origin SQLite does not expose as a direct + * column reference do not resolve. Callers report the JDBC empty table name and unknown + * nullability for those columns. + */ final class SqlTableReference { record TableRef(String schema, String table) {} + interface Schema { + TableDefinition definition(TableRef table) throws SQLException; + } + + record TableDefinition(String sql, List columns) {} + + record Resolution(List labels, List tables) { + static final Resolution EMPTY = new Resolution(List.of(), List.of()); + + TableRef table(int index) { + return index >= 0 && index < tables.size() ? tables.get(index) : null; + } + } + private enum Type { WORD, IDENT, @@ -18,19 +56,67 @@ private enum Type { private record Token(Type type, String text, int depth) {} - private static final Pattern UNQUOTED_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_$]*"); + private record ColumnRef(List parts) { + String column() { + return parts.get(parts.size() - 1); + } - private static final Set REJECTED_KEYWORDS = - Set.of("WITH", "UNION", "INTERSECT", "EXCEPT", "JOIN"); + String qualifier() { + return parts.size() >= 2 ? parts.get(parts.size() - 2) : null; + } + } + + private record Source( + String qualifier, + List columns, + List tables, + boolean knownColumns, + TableRef baseTable) { - private static final Set FROM_END_KEYWORDS = Set.of( + TableRef origin(String column) { + for (int i = 0; i < columns.size(); i++) { + if (columns.get(i).equalsIgnoreCase(column)) { + return tables.get(i); + } + } + return null; + } + + boolean hasColumn(String column) { + for (String candidate : columns) { + if (candidate.equalsIgnoreCase(column)) { + return true; + } + } + return false; + } + + boolean matchesQualifier(String qualifier) { + return this.qualifier != null && this.qualifier.equalsIgnoreCase(qualifier); + } + } + + private record SourceList(List sources, int next) {} + + private static final Pattern UNQUOTED_IDENTIFIER = Pattern.compile("[A-Za-z_][A-Za-z0-9_$]*"); + + private static final Set FROM_END = Set.of( "WHERE", "GROUP", "HAVING", "ORDER", "LIMIT", "OFFSET", "WINDOW", "RETURNING", - "UNION", "INTERSECT", "EXCEPT", "FROM", "JOIN", "CROSS", "INNER", "LEFT", "RIGHT", - "FULL", "NATURAL", "OUTER", "ON", "USING", "INDEXED", "NOT", "AS"); + "UNION", "INTERSECT", "EXCEPT"); + + private 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( + "AS", "ON", "USING", "INDEXED", "NOT", "COLLATE", "IS", "LIKE", "GLOB", "REGEXP", + "MATCH", "ESCAPE", "BETWEEN", "IN", "CASE", "WHEN", "THEN", "ELSE", "END", "CAST", + "EXISTS", "AND", "OR", "NULL"); private SqlTableReference() {} - static TableRef parse(String sql) { + static Resolution resolve(String sql, Schema schema) throws SQLException { if (sql == null || sql.isBlank()) { return null; } @@ -38,108 +124,905 @@ static TableRef parse(String sql) { if (tokens == null) { return null; } + return new Parser(tokens, schema, new HashSet<>()) + .parseStatement(0, tokens.size(), 0, new HashMap<>()); + } + + private static final class Parser { + + private final List tokens; + private final Schema schema; + private final Set resolvingViews; + + private Parser(List tokens, Schema schema, Set resolvingViews) { + this.tokens = tokens; + this.schema = schema; + this.resolvingViews = resolvingViews; + } - int fromIndex = -1; - int fromCount = 0; - for (int i = 0; i < tokens.size(); i++) { - Token token = tokens.get(i); - if (token.depth() != 0) { - if (fromIndex < 0 - && token.type() == Type.WORD - && (upper(token.text()).equals("SELECT") || upper(token.text()).equals("FROM"))) { + private Resolution parseStatement(int start, int end, int depth, Map scope) + throws SQLException { + int index = skipSymbols(start, end, depth, ";"); + while (end > index && isSymbolAt(end - 1, ";", depth)) { + end--; + } + if (index >= end) { + return null; + } + String keyword = wordAt(index, depth); + if (keyword == null) { + return null; + } + return switch (keyword) { + case "WITH" -> parseWith(index, end, depth, scope); + case "SELECT" -> parseSelect(index, end, depth, scope); + case "INSERT", "REPLACE", "UPDATE", "DELETE" -> parseReturning(index, end, depth, scope); + default -> null; + }; + } + + private Resolution parseWith(int start, int end, int depth, Map scope) + throws SQLException { + Map ctes = new HashMap<>(scope); + int index = start + 1; + if (isWordAt(index, depth, "RECURSIVE")) { + index++; + } + while (index < end) { + String name = identifier(index); + if (name == null) { return null; } - continue; + index++; + List columns = null; + if (isSymbolAt(index, "(", depth)) { + int close = matchingParen(index); + if (close < 0 || close >= end) { + return null; + } + columns = identifiers(index + 1, close, depth + 1); + index = close + 1; + } + if (!isWordAt(index, depth, "AS")) { + return null; + } + index++; + if (isWordAt(index, depth, "NOT")) { + index += 2; + } else if (isWordAt(index, depth, "MATERIALIZED")) { + index++; + } + if (!isSymbolAt(index, "(", depth)) { + return null; + } + int close = 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)); + } + index = close + 1; + if (isSymbolAt(index, ",", depth)) { + index++; + continue; + } + break; } - if (token.type() != Type.WORD) { - continue; + if (index >= end) { + return null; } - String keyword = upper(token.text()); - if (REJECTED_KEYWORDS.contains(keyword)) { + return parseStatement(index, end, depth, ctes); + } + + private Resolution parseSelect(int start, int end, int depth, Map scope) + throws SQLException { + int index = start + 1; + while (isWordAt(index, depth, "ALL") || isWordAt(index, depth, "DISTINCT")) { + index++; + } + int setOperator = indexOfAnyWord(index, end, depth, SET_OPERATORS); + int selectEnd = setOperator >= 0 ? setOperator : end; + int from = indexOfWord(index, selectEnd, depth, "FROM"); + int listEnd = from >= 0 ? from : selectEnd; + List sources = List.of(); + if (from >= 0) { + int fromEnd = indexOfAnyWord(from + 1, selectEnd, depth, FROM_END); + if (fromEnd < 0) { + fromEnd = selectEnd; + } + List parsed = parseSources(from + 1, fromEnd, depth, scope); + if (parsed == null || parsed.isEmpty()) { + return null; + } + sources = parsed; + } + return parseSelectItems(index, listEnd, depth, sources, scope); + } + + private List parseSources(int start, int end, int depth, Map scope) + throws SQLException { + List sources = new ArrayList<>(); + int index = start; + while (index < end) { + SourceList parsed = parseSource(index, end, depth, scope); + if (parsed == null) { + return null; + } + sources.addAll(parsed.sources()); + index = parsed.next(); + index = skipIndexModifiers(index, end, depth); + index = skipJoinCondition(index, end, depth); + if (isSymbolAt(index, ",", depth)) { + index++; + continue; + } + if (index < end && isJoinStart(index, end, depth)) { + index = skipToJoin(index, end, depth); + if (index < 0) { + break; + } + index++; + continue; + } + break; + } + return sources; + } + + private SourceList parseSource(int start, int end, int depth, Map scope) + throws SQLException { + int index = start; + if (isSymbolAt(index, "(", depth)) { + int close = matchingParen(index); + if (close < 0 || close >= end) { + return null; + } + int innerDepth = depth + 1; + String innerKeyword = wordAt(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 (isWordAt(index, depth, "AS")) { + index++; + alias = identifier(index); + if (alias == null) { + return null; + } + index++; + } else if (isAlias(index, depth)) { + alias = identifier(index); + index++; + } + List aliasColumns = null; + if (isSymbolAt(index, "(", depth)) { + int columnsEnd = matchingParen(index); + if (columnsEnd < 0 || columnsEnd >= end) { + return null; + } + aliasColumns = identifiers(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); + } + List grouped = parseSources(index + 1, close, innerDepth, scope); + if (grouped == null) { + return null; + } + index = close + 1; + String alias = null; + if (isWordAt(index, depth, "AS")) { + index++; + alias = identifier(index); + if (alias == null) { + return null; + } + index++; + } else if (isAlias(index, depth)) { + alias = identifier(index); + index++; + } + if (alias == null) { + return new SourceList(grouped, index); + } + List labels = new ArrayList<>(); + List tables = new ArrayList<>(); + for (Source source : grouped) { + if (source.knownColumns()) { + labels.addAll(source.columns()); + tables.addAll(source.tables()); + } + } + return new SourceList( + List.of(new Source(alias, labels, tables, true, null)), index); + } + + String first = identifier(index); + if (first == null) { return null; } - if (keyword.equals("FROM")) { - fromCount++; - if (fromIndex < 0) { - fromIndex = i; + index++; + String schemaName = null; + if (isSymbolAt(index, ".", depth)) { + index++; + String second = identifier(index); + if (second == null) { + return null; + } + index++; + if (isSymbolAt(index, ".", depth)) { + return null; } + schemaName = first; + first = second; } + String alias = null; + if (isWordAt(index, depth, "AS")) { + index++; + alias = identifier(index); + if (alias == null) { + return null; + } + index++; + } else if (isAlias(index, depth)) { + alias = identifier(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); } - if (fromCount != 1) { - return null; + + 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 = tokenize(definition.sql()); + if (viewTokens == null) { + return null; + } + int as = indexOfWordIn(viewTokens, 0, viewTokens.size(), 0, "AS"); + if (as < 0) { + return null; + } + List explicit = null; + for (int i = 0; i < as; i++) { + Token token = viewTokens.get(i); + if (token.depth() == 0 && isSymbol(token, "(")) { + int close = matchingParenIn(viewTokens, i); + if (close < 0) { + return null; + } + explicit = identifiersIn(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); + } + } + + private Resolution parseReturning(int start, int end, int depth, Map scope) + throws SQLException { + int returning = indexOfWord(start, end, depth, "RETURNING"); + if (returning < 0) { + return null; + } + TableRef target = targetTable(start, returning, depth); + if (target == null) { + return null; + } + TableDefinition definition = schema.definition(target); + Source source; + if (definition == null) { + source = new Source(target.table(), List.of(), List.of(), false, target); + } else if (isViewDefinition(definition.sql())) { + List columns = definition.columns(); + source = new Source( + target.table(), columns, Collections.nCopies(columns.size(), null), true, null); + } else { + List columns = definition.columns() != null ? definition.columns() : List.of(); + source = new Source( + target.table(), + columns, + Collections.nCopies(columns.size(), target), + true, + target); + } + return parseSelectItems(returning + 1, end, depth, List.of(source), scope); + } + + private TableRef targetTable(int start, int end, int depth) { + String keyword = wordAt(start, depth); + if (keyword == null) { + return null; + } + int index; + switch (keyword) { + case "INSERT", "REPLACE" -> { + index = indexOfWord(start, end, depth, "INTO"); + if (index < 0) { + return null; + } + index++; + } + case "UPDATE" -> { + index = start + 1; + if (isWordAt(index, depth, "OR")) { + index += 2; + } + } + case "DELETE" -> { + index = indexOfWord(start, end, depth, "FROM"); + if (index < 0) { + return null; + } + index++; + } + default -> { + return null; + } + } + String first = identifier(index); + if (first == null) { + return null; + } + index++; + String schemaName = null; + if (isSymbolAt(index, ".", depth)) { + index++; + String second = identifier(index); + if (second == null) { + return null; + } + schemaName = first; + first = second; + } + return new TableRef(schemaName, first); } - int cursor = fromIndex + 1; - if (cursor >= tokens.size()) { + private Resolution parseSelectItems( + int start, int end, int depth, List sources, Map scope) + throws SQLException { + List labels = new ArrayList<>(); + List tables = new ArrayList<>(); + int index = start; + while (index < end) { + int comma = indexOfSymbol(index, end, depth, ","); + int itemEnd = comma < 0 ? end : comma; + parseItem(index, itemEnd, depth, sources, scope, labels, tables); + if (comma < 0) { + break; + } + index = comma + 1; + } + return new Resolution(labels, tables); + } + + private void parseItem( + int start, + int end, + int depth, + List sources, + 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 = indexOfWord(start, end, depth, "AS"); + if (as >= 0 && as + 1 < end) { + String candidate = identifier(as + 1); + if (candidate != null) { + alias = candidate; + expressionEnd = as; + } + } else { + int implicit = implicitAlias(start, end, depth); + if (implicit >= 0) { + alias = identifier(implicit); + expressionEnd = implicit; + } + } + if (expressionEnd <= start) { + labels.add(alias != null ? alias : ""); + tables.add(null); + return; + } + + if (expressionEnd - start == 1 && isSymbol(tokens.get(start), "*")) { + for (Source source : sources) { + if (source.knownColumns()) { + labels.addAll(source.columns()); + tables.addAll(source.tables()); + } + } + return; + } + + ColumnRef qualifiedStar = qualifiedStar(start, expressionEnd); + if (qualifiedStar != null) { + Source source = + findSource(qualifiedStar.parts().get(qualifiedStar.parts().size() - 1), 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, sources)); + return; + } + + if (isSymbol(tokens.get(start), "(") && matchingParen(start) == expressionEnd - 1) { + int innerDepth = depth + 1; + String innerKeyword = wordAt(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, List 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) { + return null; + } + if (sources.size() == 1 && !sources.get(0).knownColumns()) { + return sources.get(0).baseTable(); + } return null; } - Token first = tokens.get(cursor); - String firstName = identifier(first); - if (firstName == null) { + private Source findSource(String qualifier, List sources) { + for (Source source : sources) { + if (source.matchesQualifier(qualifier)) { + return source; + } + } return null; } - cursor++; - String schema = null; - String table = firstName; - if (cursor < tokens.size() && isSymbol(tokens.get(cursor), ".")) { - cursor++; - if (cursor >= tokens.size()) { - return null; + private int implicitAlias(int start, int end, int depth) { + if (end - start < 2) { + return -1; } - Token tableToken = tokens.get(cursor); - String tableName = identifier(tableToken); - if (tableName == null) { - return null; + Token last = tokens.get(end - 1); + if (identifierOf(last) == null) { + return -1; + } + Token previous = tokens.get(end - 2); + if (isSymbol(previous, ".") || isSymbol(previous, "(") || isSymbol(previous, ",")) { + return -1; } - schema = firstName; - table = tableName; - cursor++; - if (cursor < tokens.size() && isSymbol(tokens.get(cursor), ".")) { + if (previous.type() == Type.SYMBOL) { + return -1; + } + if (previous.type() == Type.WORD && NOT_AN_ALIAS.contains(upper(previous.text()))) { + return -1; + } + return end - 1; + } + + private ColumnRef simpleColumn(int start, int end) { + List parts = new ArrayList<>(); + int index = start; + String first = identifier(index); + if (first == null) { return null; } + parts.add(first); + index++; + while (index < end) { + if (!isSymbol(tokens.get(index), ".")) { + return null; + } + index++; + if (index >= end) { + return null; + } + String part = identifier(index); + if (part == null) { + return null; + } + parts.add(part); + index++; + } + return parts.size() <= 3 ? new ColumnRef(parts) : null; } - if (cursor < tokens.size()) { - Token next = tokens.get(cursor); - if (isKeyword(next, "AS")) { - cursor++; - if (cursor >= tokens.size() || identifier(tokens.get(cursor)) == null) { + private ColumnRef qualifiedStar(int start, int end) { + if (end - start < 3 || !isSymbol(tokens.get(end - 1), "*")) { + return null; + } + List parts = new ArrayList<>(); + int index = start; + while (index < end - 1) { + String part = identifier(index); + if (part == null) { return null; } - cursor++; - } else if (next.type() != Type.SYMBOL && !isFromEnd(next)) { - if (identifier(next) == null) { + parts.add(part); + index++; + if (index == end - 1) { + break; + } + if (!isSymbol(tokens.get(index), ".")) { return null; } - cursor++; + index++; + } + if (index != end - 1 || !isSymbol(tokens.get(end - 2), ".")) { + return null; + } + if (parts.size() < 1 || parts.size() > 2) { + return null; } + return new ColumnRef(parts); } - if (cursor >= tokens.size() || isSymbol(tokens.get(cursor), ";") || isFromEnd(tokens.get(cursor))) { - return new TableRef(schema, table); + 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 (isWordAt(index, depth, "INDEXED")) { + index += 3; + continue; + } + if (isWordAt(index, depth, "NOT") && isWordAt(index + 1, depth, "INDEXED")) { + index += 3; + continue; + } + break; + } + return index; + } + + private int skipJoinCondition(int start, int end, int depth) { + if (!isWordAt(start, depth, "ON") && !isWordAt(start, depth, "USING")) { + return start; + } + int index = start + 1; + while (index < end) { + if (tokens.get(index).depth() == depth) { + if (isSymbol(tokens.get(index), ",") || isJoinStart(index, end, depth)) { + break; + } + } + index++; + } + return index; + } + + private boolean isJoinStart(int index, int end, int depth) { + if (index >= end) { + return false; + } + Token token = tokens.get(index); + return token.depth() == depth + && token.type() == Type.WORD + && JOIN_START.contains(upper(token.text())); + } + + private int skipToJoin(int start, int end, int depth) { + for (int index = start; index < end; index++) { + if (isWordAt(index, depth, "JOIN")) { + return index; + } + } + return -1; + } + + private boolean isAlias(int index, int depth) { + if (index >= tokens.size()) { + return false; + } + Token token = tokens.get(index); + if (token.depth() != depth || identifierOf(token) == null) { + return false; + } + if (token.type() == Type.WORD) { + String keyword = upper(token.text()); + return !FROM_END.contains(keyword) + && !JOIN_START.contains(keyword) + && !NOT_AN_ALIAS.contains(keyword); + } + return true; + } + + private List identifiers(int start, int end, int depth) { + List identifiers = new ArrayList<>(); + int index = start; + while (index < end) { + String identifier = identifier(index); + if (identifier == null || tokens.get(index).depth() != depth) { + return List.of(); + } + identifiers.add(identifier); + index++; + if (index < end) { + if (!isSymbol(tokens.get(index), ",")) { + return List.of(); + } + index++; + } + } + return identifiers; + } + + private int skipSymbols(int start, int end, int depth, String symbol) { + int index = start; + while (index < end && isSymbolAt(index, symbol, depth)) { + index++; + } + return index; + } + + private int indexOfWord(int start, int end, int depth, String word) { + for (int index = start; index < end; index++) { + if (isWordAt(index, depth, word)) { + return index; + } + } + return -1; + } + + private int indexOfAnyWord(int start, int end, int depth, Set words) { + for (int index = start; index < end; index++) { + Token token = tokens.get(index); + if (token.depth() == depth && token.type() == Type.WORD && words.contains(upper(token.text()))) { + return index; + } + } + return -1; + } + + private int indexOfSymbol(int start, int end, int depth, String symbol) { + for (int index = start; index < end; index++) { + if (isSymbolAt(index, symbol, depth)) { + return index; + } + } + return -1; + } + + private boolean isWordAt(int index, int depth, String word) { + if (index < 0 || index >= tokens.size()) { + return false; + } + Token token = tokens.get(index); + return token.depth() == depth && token.type() == Type.WORD && upper(token.text()).equals(word); + } + + private boolean isSymbolAt(int index, String symbol, int depth) { + if (index < 0 || index >= tokens.size()) { + return false; + } + Token token = tokens.get(index); + return token.depth() == depth && isSymbol(token, symbol); + } + + private String wordAt(int index, int depth) { + if (index < 0 || index >= tokens.size()) { + return null; + } + Token token = tokens.get(index); + return token.depth() == depth && token.type() == Type.WORD ? upper(token.text()) : null; + } + + private String identifier(int index) { + if (index < 0 || index >= tokens.size()) { + return null; + } + return identifierOf(tokens.get(index)); + } + + private int matchingParen(int open) { + return matchingParenIn(tokens, open); } - return null; } - private static boolean isFromEnd(Token token) { - return token.depth() == 0 && token.type() == Type.WORD - && FROM_END_KEYWORDS.contains(upper(token.text())); + private static int matchingParenIn(List tokens, int open) { + if (open < 0 || open >= tokens.size()) { + return -1; + } + int depth = tokens.get(open).depth(); + for (int index = open + 1; index < tokens.size(); index++) { + Token token = tokens.get(index); + if (token.depth() == depth && isSymbol(token, ")")) { + return index; + } + } + return -1; } - private static boolean isKeyword(Token token, String keyword) { - return token.depth() == 0 && token.type() == Type.WORD && upper(token.text()).equals(keyword); + private static int indexOfWordIn(List tokens, int start, int end, int depth, String word) { + for (int index = start; index < end; index++) { + Token token = tokens.get(index); + if (token.depth() == depth && token.type() == Type.WORD && upper(token.text()).equals(word)) { + return index; + } + } + return -1; } - private static boolean isSymbol(Token token, String symbol) { - return token.depth() == 0 && token.type() == Type.SYMBOL && token.text().equals(symbol); + private static List identifiersIn(List tokens, int start, int end, int depth) { + List identifiers = new ArrayList<>(); + int index = start; + while (index < end) { + Token token = tokens.get(index); + if (token.depth() != depth) { + return List.of(); + } + String identifier = identifierOf(token); + if (identifier == null) { + return List.of(); + } + identifiers.add(identifier); + index++; + if (index < end) { + Token separator = tokens.get(index); + if (separator.depth() != depth || !isSymbol(separator, ",")) { + return List.of(); + } + index++; + } + } + return identifiers; } - private static String identifier(Token token) { - if (token.depth() != 0) { - return null; + private static boolean isViewDefinition(String sql) { + if (sql == null) { + return false; + } + List tokens = tokenize(sql); + if (tokens == null) { + return false; + } + int index = 0; + if (!isWord(tokens, index, "CREATE")) { + return false; + } + index++; + if (isWord(tokens, index, "TEMP") || isWord(tokens, index, "TEMPORARY")) { + index++; } + return isWord(tokens, index, "VIEW"); + } + + private static boolean isWord(List tokens, int index, String word) { + if (index < 0 || index >= tokens.size()) { + return false; + } + Token token = tokens.get(index); + return token.depth() == 0 && token.type() == Type.WORD && upper(token.text()).equals(word); + } + + private static boolean isSymbol(Token token, String symbol) { + return token.type() == Type.SYMBOL && token.text().equals(symbol); + } + + private static String identifierOf(Token token) { if (token.type() == Type.IDENT) { return token.text(); } @@ -218,7 +1101,7 @@ private static List tokenize(String sql) { i++; continue; } - if (c == ',' || c == '.' || c == ';') { + if (c == ',' || c == '.' || c == ';' || c == '*') { tokens.add(new Token(Type.SYMBOL, String.valueOf(c), depth)); i++; continue; diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java index da7a111..ed86094 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java @@ -10,7 +10,11 @@ import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Types; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -322,18 +326,151 @@ private static void assertDeclaredTypes(ResultSetMetaData metadata) throws SQLEx } @Test - void leavesAmbiguousTableContextUnknown() throws SQLException { - LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( - List.of(new Col("id", "INTEGER")), - List.of(), - "SELECT * FROM a JOIN b ON a.id = b.id", - connectionReturning(new StmtResult(List.of(), List.of(), 0, null))); + void resolvesAliasTableNameAndNullability() throws SQLException { + LibsqlResultSetMetaData metadata = metadataForSql( + "SELECT a AS z FROM w", schemaConnection(), List.of(new Col("z", "INTEGER"))); - assertThat(metadata.getTableName(1)).isEmpty(); - assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullableUnknown); - assertThat(metadata.isAutoIncrement(1)).isFalse(); + assertThat(metadata.getTableName(1)).isEqualTo("w"); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullable); + } + + @Test + void resolvesJoinTableNamesAndNullability() throws SQLException { + LibsqlResultSetMetaData metadata = metadataForSql( + "SELECT w.a, z.a FROM w JOIN z ON w.a = z.a", + schemaConnection(), + List.of(new Col("a", "INTEGER"), new Col("a", "INTEGER"))); + + assertThat(metadata.getTableName(1)).isEqualTo("w"); + assertThat(metadata.getTableName(2)).isEqualTo("z"); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNoNulls); + assertThat(metadata.isNullable(2)).isEqualTo(ResultSetMetaData.columnNullable); + } + + @Test + void resolvesSubqueryTableNameAndNullability() throws SQLException { + LibsqlResultSetMetaData metadata = metadataForSql( + "SELECT q.a FROM (SELECT a FROM w) q", + schemaConnection(), + List.of(new Col("a", "INTEGER"))); + + assertThat(metadata.getTableName(1)).isEqualTo("w"); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNoNulls); + } + + @Test + void resolvesReturningTableNameAndNullability() throws SQLException { + LibsqlResultSetMetaData metadata = metadataForSql( + "INSERT INTO w(a, b) VALUES (1, 'x') RETURNING a, b", + schemaConnection(), + List.of(new Col("a", "INTEGER"), new Col("b", "TEXT"))); + + assertThat(metadata.getTableName(1)).isEqualTo("w"); + assertThat(metadata.getTableName(2)).isEqualTo("w"); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNoNulls); + assertThat(metadata.isNullable(2)).isEqualTo(ResultSetMetaData.columnNullable); + } + + @Test + void resolvesAliasedReturningTableNameAndNullability() throws SQLException { + LibsqlResultSetMetaData metadata = metadataForSql( + "INSERT INTO w(a, b) VALUES (1, 'x') RETURNING a AS aa", + schemaConnection(), + List.of(new Col("aa", "INTEGER"))); + + assertThat(metadata.getTableName(1)).isEqualTo("w"); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullable); } + @Test + void resolvesViewTableNameAndNullability() throws SQLException { + LibsqlResultSetMetaData metadata = metadataForSql( + "SELECT a, b FROM v", + schemaConnection(), + List.of(new Col("a", "INTEGER"), new Col("b", "TEXT"))); + + assertThat(metadata.getTableName(1)).isEqualTo("w"); + assertThat(metadata.getTableName(2)).isEqualTo("w"); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNoNulls); + assertThat(metadata.isNullable(2)).isEqualTo(ResultSetMetaData.columnNullable); + } + + @Test + void leavesUnsupportedShapesUnknown() throws SQLException { + LibsqlResultSetMetaData expression = metadataForSql( + "SELECT a + 1 FROM w", schemaConnection(), List.of(new Col("a + 1", "INTEGER"))); + LibsqlResultSetMetaData literal = + metadataForSql("SELECT 1 AS one", schemaConnection(), List.of(new Col("one", "INTEGER"))); + + assertThat(expression.getTableName(1)).isEmpty(); + assertThat(expression.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullableUnknown); + assertThat(expression.isAutoIncrement(1)).isFalse(); + assertThat(literal.getTableName(1)).isEmpty(); + assertThat(literal.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullableUnknown); + } + + private static LibsqlResultSetMetaData metadataForSql(String sql, LibsqlConnection connection, List cols) + throws SQLException { + return new LibsqlResultSetMetaData(cols, List.of(), sql, connection); + } + + private static LibsqlConnection schemaConnection() { + Map tables = new LinkedHashMap<>(); + tables.put( + "w", + new FakeTable( + "w", + "CREATE TABLE w (a INTEGER NOT NULL, b TEXT)", + List.of(new FakeColumn("a", true, false), new FakeColumn("b", false, false)))); + tables.put( + "z", + new FakeTable( + "z", + "CREATE TABLE z (a INTEGER)", + List.of(new FakeColumn("a", false, false)))); + tables.put( + "v", + new FakeTable( + "v", + "CREATE VIEW v AS SELECT a, b FROM w", + List.of(new FakeColumn("a", false, false), new FakeColumn("b", false, false)))); + return new LibsqlConnection(null, null) { + @Override + public StmtResult executeStatement(Stmt stmt) throws SQLException { + if (!stmt.sql().contains("pragma_table_info")) { + return new StmtResult(List.of(), List.of(), 0, null); + } + String table = argument(stmt, 0); + String schema = argument(stmt, 1); + FakeTable fake = tables.get(table != null ? table.toLowerCase(Locale.ROOT) : ""); + if (fake == null || !"main".equalsIgnoreCase(schema)) { + return new StmtResult(List.of(), List.of(), 0, null); + } + List rows = new ArrayList<>(); + for (FakeColumn column : fake.columns()) { + rows.add(new Row(List.of( + Value.of(fake.name()), + Value.of(fake.sql()), + Value.of(column.name()), + Value.of(column.notNull() ? 1L : 0L), + Value.of(column.primaryKey() ? 1L : 0L)))); + } + return new StmtResult(List.of(), rows, 0, null); + } + }; + } + + private static String argument(Stmt stmt, int index) { + if (stmt.args() == null || stmt.args().size() <= index) { + return null; + } + return stmt.args().get(index) instanceof Value.Text text ? text.value() : null; + } + + private record FakeTable(String name, String sql, List columns) {} + + private record FakeColumn(String name, boolean notNull, boolean primaryKey) {} + private static LibsqlResultSetMetaData metadataForTable(String createSql, List cols) throws SQLException { List tableRows = cols.stream() .map(col -> new Row(List.of( 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 ee4c45d..f7ded41 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 @@ -2,124 +2,189 @@ import org.junit.jupiter.api.Test; +import java.sql.SQLException; +import java.util.List; +import java.util.Locale; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; class SqlTableReferenceTest { + private static final Map DEFINITIONS = Map.of( + "w", + new SqlTableReference.TableDefinition("CREATE TABLE w (a INTEGER NOT NULL, b TEXT)", List.of("a", "b")), + "z", + new SqlTableReference.TableDefinition("CREATE TABLE z (a INTEGER)", List.of("a")), + "v", + new SqlTableReference.TableDefinition("CREATE VIEW v AS SELECT a, b FROM w", List.of("a", "b")), + "my table", + new SqlTableReference.TableDefinition("CREATE TABLE [my table] (c TEXT)", List.of("c"))); + + private static final SqlTableReference.Schema SCHEMA = reference -> { + if (reference.table() == null) { + return null; + } + return DEFINITIONS.get(reference.table().toLowerCase(Locale.ROOT)); + }; + @Test - void parsesSimpleTable() { - assertThat(SqlTableReference.parse("SELECT * FROM people")) - .isEqualTo(new SqlTableReference.TableRef(null, "people")); + void resolvesSimpleTable() throws SQLException { + assertThat(resolve("SELECT * FROM w").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w"), new SqlTableReference.TableRef(null, "w")); } @Test - void parsesSchemaQualifiedTable() { - assertThat(SqlTableReference.parse("SELECT * FROM main.people")) - .isEqualTo(new SqlTableReference.TableRef("main", "people")); + void resolvesSchemaQualifiedTable() throws SQLException { + assertThat(resolve("SELECT a FROM main.w").tables()) + .containsExactly(new SqlTableReference.TableRef("main", "w")); } @Test - void parsesTempSchema() { - assertThat(SqlTableReference.parse("SELECT * FROM temp.people")) - .isEqualTo(new SqlTableReference.TableRef("temp", "people")); + void resolvesQuotedAndBracketedNames() throws SQLException { + assertThat(resolve("SELECT c FROM \"my table\"").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "my table")); + assertThat(resolve("SELECT c FROM [my table]").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "my table")); } @Test - void parsesQuotedNamesWithSpaces() { - assertThat(SqlTableReference.parse("SELECT * FROM \"my schema\".\"my table\"")) - .isEqualTo(new SqlTableReference.TableRef("my schema", "my table")); + void resolvesAliasesWithAndWithoutAs() throws SQLException { + assertThat(resolve("SELECT a AS z FROM w").labels()).containsExactly("z"); + assertThat(resolve("SELECT a AS z FROM w").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w")); + assertThat(resolve("SELECT a z FROM w").labels()).containsExactly("z"); + assertThat(resolve("SELECT a z FROM w").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void parsesQuotedIdentifierKinds() { - assertThat(SqlTableReference.parse("SELECT * FROM `my table`")) - .isEqualTo(new SqlTableReference.TableRef(null, "my table")); - assertThat(SqlTableReference.parse("SELECT * FROM [my table]")) - .isEqualTo(new SqlTableReference.TableRef(null, "my table")); - assertThat(SqlTableReference.parse("SELECT * FROM \"my \"\"table\"\"\"")) - .isEqualTo(new SqlTableReference.TableRef(null, "my \"table\"")); + void ignoresStringLiteralsAndCommentsContainingFrom() throws SQLException { + assertThat(resolve("SELECT 'from elsewhere' AS s FROM w WHERE b = 'from x'").tables()) + .containsExactly((SqlTableReference.TableRef) null); + assertThat(resolve("SELECT a -- from noise\nFROM /* from noise */ w -- trailing from noise").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void parsesAliasesWithAndWithoutAs() { - assertThat(SqlTableReference.parse("SELECT * FROM people p WHERE p.id = 1")) - .isEqualTo(new SqlTableReference.TableRef(null, "people")); - assertThat(SqlTableReference.parse("SELECT * FROM people AS p")) - .isEqualTo(new SqlTableReference.TableRef(null, "people")); + void resolvesJoins() throws SQLException { + SqlTableReference.Resolution resolution = resolve("SELECT w.a, z.a FROM w JOIN z ON w.a = z.a"); + + assertThat(resolution.labels()).containsExactly("a", "a"); + assertThat(resolution.tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w"), new SqlTableReference.TableRef(null, "z")); } @Test - void ignoresStringLiteralsContainingFrom() { - assertThat(SqlTableReference.parse("SELECT 'from elsewhere' AS s FROM people WHERE name = 'from x'")) - .isEqualTo(new SqlTableReference.TableRef(null, "people")); + void resolvesCommaAndOuterJoins() throws SQLException { + assertThat(resolve("SELECT w.a, z.a FROM w, z").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w"), new SqlTableReference.TableRef(null, "z")); + assertThat(resolve("SELECT x.a, y.a FROM w x LEFT OUTER JOIN z y ON x.a = y.a").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w"), new SqlTableReference.TableRef(null, "z")); } @Test - void ignoresCommentsContainingFrom() { - assertThat(SqlTableReference.parse( - "SELECT * -- from noise\nFROM /* from noise */ people -- trailing from noise")) - .isEqualTo(new SqlTableReference.TableRef(null, "people")); + void resolvesStarExpansion() throws SQLException { + SqlTableReference.Resolution resolution = resolve("SELECT * FROM w JOIN z ON w.a = z.a"); + + assertThat(resolution.labels()).containsExactly("a", "b", "a"); + assertThat(resolution.tables()) + .containsExactly( + new SqlTableReference.TableRef(null, "w"), + new SqlTableReference.TableRef(null, "w"), + new SqlTableReference.TableRef(null, "z")); + assertThat(resolve("SELECT w.* FROM w JOIN z ON w.a = z.a").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w"), new SqlTableReference.TableRef(null, "w")); } @Test - void ignoresFromInNestedSubquery() { - assertThat(SqlTableReference.parse("SELECT * FROM people WHERE id IN (SELECT id FROM other)")) - .isEqualTo(new SqlTableReference.TableRef(null, "people")); + void resolvesSubqueryInFromClause() throws SQLException { + SqlTableReference.Resolution resolution = resolve("SELECT q.a FROM (SELECT a FROM w) q"); + + assertThat(resolution.labels()).containsExactly("a"); + assertThat(resolution.tables()).containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void rejectsJoin() { - assertThat(SqlTableReference.parse("SELECT * FROM a JOIN b ON a.id = b.id")).isNull(); + void resolvesScalarSubquery() throws SQLException { + SqlTableReference.Resolution resolution = resolve("SELECT (SELECT a FROM w) AS s"); + + assertThat(resolution.labels()).containsExactly("s"); + assertThat(resolution.tables()).containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void rejectsCommaSeparatedTables() { - assertThat(SqlTableReference.parse("SELECT * FROM a, b")).isNull(); + void resolvesCommonTableExpression() throws SQLException { + SqlTableReference.Resolution resolution = + resolve("WITH cte AS (SELECT a FROM w) SELECT cte.a FROM cte"); + + assertThat(resolution.labels()).containsExactly("a"); + assertThat(resolution.tables()).containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void rejectsCommonTableExpression() { - assertThat(SqlTableReference.parse("WITH cte AS (SELECT * FROM a) SELECT * FROM cte")).isNull(); + void resolvesLeftmostCompoundSelectBranch() throws SQLException { + SqlTableReference.Resolution resolution = resolve("SELECT a FROM w UNION SELECT a FROM z"); + + assertThat(resolution.labels()).containsExactly("a"); + assertThat(resolution.tables()).containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void rejectsCompoundSelect() { - assertThat(SqlTableReference.parse("SELECT * FROM a UNION SELECT * FROM b")).isNull(); - assertThat(SqlTableReference.parse("SELECT * FROM a INTERSECT SELECT * FROM b")).isNull(); - assertThat(SqlTableReference.parse("SELECT * FROM a EXCEPT SELECT * FROM b")).isNull(); + void resolvesInsertReturning() throws SQLException { + SqlTableReference.Resolution resolution = + resolve("INSERT INTO w(a, b) VALUES (1, 'x') RETURNING a AS aa, b"); + + assertThat(resolution.labels()).containsExactly("aa", "b"); + assertThat(resolution.tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w"), new SqlTableReference.TableRef(null, "w")); } @Test - void rejectsSubqueryInFromPosition() { - assertThat(SqlTableReference.parse("SELECT * FROM (SELECT * FROM a) AS x")).isNull(); + void resolvesUpdateAndDeleteReturning() throws SQLException { + assertThat(resolve("UPDATE w SET b = 'x' RETURNING a").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w")); + assertThat(resolve("DELETE FROM w RETURNING a").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void rejectsSubqueryInSelectList() { - assertThat(SqlTableReference.parse("SELECT (SELECT id FROM other) AS id FROM people")) - .isNull(); + void resolvesViewsToBaseTables() throws SQLException { + SqlTableReference.Resolution resolution = resolve("SELECT a, b FROM v"); + + assertThat(resolution.labels()).containsExactly("a", "b"); + assertThat(resolution.tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w"), new SqlTableReference.TableRef(null, "w")); + assertThat(resolve("SELECT b AS bb FROM v").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void rejectsSecondTableReferenceOrSecondFrom() { - assertThat(SqlTableReference.parse("SELECT * FROM a b c")).isNull(); - assertThat(SqlTableReference.parse("SELECT * FROM a FROM b")).isNull(); - assertThat(SqlTableReference.parse("SELECT * FROM a AS x, b")).isNull(); + void leavesExpressionsAndLiteralsUnresolved() throws SQLException { + SqlTableReference.Resolution expression = resolve("SELECT a + 1 FROM w"); + SqlTableReference.Resolution literal = resolve("SELECT 1 AS one"); + + assertThat(expression.labels()).containsExactly("a+1"); + assertThat(expression.tables()).containsExactly((SqlTableReference.TableRef) null); + assertThat(literal.labels()).containsExactly("one"); + assertThat(literal.tables()).containsExactly((SqlTableReference.TableRef) null); } @Test - void rejectsAmbiguousInput() { - assertThat(SqlTableReference.parse(null)).isNull(); - assertThat(SqlTableReference.parse("SELECT 1")).isNull(); - assertThat(SqlTableReference.parse("SELECT * FROM 'people'")).isNull(); - assertThat(SqlTableReference.parse("SELECT * FROM a.")).isNull(); - assertThat(SqlTableReference.parse("SELECT * FROM a.b.c")).isNull(); - assertThat(SqlTableReference.parse("SELECT * FROM a WHERE id IN (1, 2")).isNull(); + void acceptsTrailingSemicolon() throws SQLException { + assertThat(resolve("SELECT a FROM w;").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "w")); } @Test - void acceptsTrailingSemicolon() { - assertThat(SqlTableReference.parse("SELECT * FROM people;")) - .isEqualTo(new SqlTableReference.TableRef(null, "people")); + void rejectsUnparseableInput() throws SQLException { + assertThat(SqlTableReference.resolve(null, SCHEMA)).isNull(); + assertThat(SqlTableReference.resolve("", SCHEMA)).isNull(); + assertThat(SqlTableReference.resolve("SELECT * FROM 'w'", SCHEMA)).isNull(); + assertThat(SqlTableReference.resolve("SELECT * FROM w WHERE id IN (1, 2", SCHEMA)).isNull(); + } + + private static SqlTableReference.Resolution resolve(String sql) throws SQLException { + return SqlTableReference.resolve(sql, SCHEMA); } }