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 3275ff8..6beaf1f 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 @@ -34,6 +34,8 @@ final class SqlTableReference { record TableRef(String schema, String table) {} + record Target(TableRef table, String alias) {} + interface Schema { TableDefinition definition(TableRef table) throws SQLException; } @@ -51,6 +53,7 @@ TableRef table(int index) { private enum Type { WORD, IDENT, + LITERAL, SYMBOL } @@ -102,7 +105,11 @@ 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. */ - private record FromClause(List sources, List starLabels, List starTables) { + private record FromClause( + List sources, + List starLabels, + List starTables, + List mergedColumns) { static FromClause of(List sources) { List labels = new ArrayList<>(); @@ -110,7 +117,7 @@ static FromClause of(List sources) { for (Source source : sources) { appendStarColumns(source, false, labels, tables); } - return new FromClause(sources, labels, tables); + return new FromClause(sources, labels, tables, List.of()); } } @@ -126,9 +133,9 @@ static FromClause of(List sources) { 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"); + "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"); private SqlTableReference() {} @@ -240,6 +247,16 @@ private Resolution parseWith(int start, int end, int depth, Map resolution.tables(), true, null)); + } 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)); } index = close + 1; if (isSymbolAt(index, ",", depth)) { @@ -284,6 +301,7 @@ private FromClause parseSources(int start, int end, int depth, Map sources = new ArrayList<>(); List starLabels = new ArrayList<>(); List starTables = new ArrayList<>(); + List mergedColumns = new ArrayList<>(); boolean natural = false; int index = start; while (index < end) { @@ -299,7 +317,13 @@ private FromClause parseSources(int start, int end, int depth, Map 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 (isSymbolAt(index, ",", depth)) { @@ -318,7 +342,7 @@ private FromClause parseSources(int start, int end, int depth, Map scope) @@ -515,31 +539,32 @@ private Resolution parseReturning(int start, int end, int depth, Map columns = definition.columns(); source = new Source( - target.table(), columns, Collections.nCopies(columns.size(), null), true, null); + qualifier, columns, Collections.nCopies(columns.size(), null), true, null); } else { List columns = definition.columns() != null ? definition.columns() : List.of(); source = new Source( - target.table(), + qualifier, columns, - Collections.nCopies(columns.size(), target), + Collections.nCopies(columns.size(), target.table()), true, - target); + target.table()); } return parseSelectItems(returning + 1, end, depth, FromClause.of(List.of(source)), scope); } - private TableRef targetTable(int start, int end, int depth) { + private Target targetTable(int start, int end, int depth) { String keyword = wordAt(start, depth); if (keyword == null) { return null; @@ -585,7 +610,14 @@ private TableRef targetTable(int start, int end, int depth) { schemaName = first; first = second; } - return new TableRef(schemaName, first); + String alias = null; + if (isWordAt(index, depth, "AS")) { + alias = identifier(index + 1); + if (alias == null) { + return null; + } + } + return new Target(new TableRef(schemaName, first), alias); } private Resolution parseSelectItems( @@ -662,7 +694,7 @@ private void parseItem( ColumnRef column = simpleColumn(start, expressionEnd); if (column != null) { labels.add(alias != null ? alias : column.column()); - tables.add(resolveColumn(column, from.sources())); + tables.add(resolveColumn(column, from)); return; } @@ -683,7 +715,8 @@ private void parseItem( tables.add(null); } - private TableRef resolveColumn(ColumnRef column, List sources) { + private TableRef resolveColumn(ColumnRef column, FromClause from) { + List sources = from.sources(); String qualifier = column.qualifier(); if (qualifier != null) { Source source = findSource(qualifier, sources); @@ -709,6 +742,13 @@ private TableRef resolveColumn(ColumnRef column, List sources) { 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()) { @@ -734,11 +774,14 @@ private int implicitAlias(int start, int end, int depth) { if (identifierOf(last) == null) { return -1; } + if (last.type() == Type.WORD && NOT_AN_ALIAS.contains(upper(last.text()))) { + return -1; + } Token previous = tokens.get(end - 2); if (isSymbol(previous, ".") || isSymbol(previous, "(") || isSymbol(previous, ",")) { return -1; } - if (previous.type() == Type.SYMBOL) { + if (previous.type() == Type.SYMBOL && !isSymbol(previous, ")")) { return -1; } if (previous.type() == Type.WORD && NOT_AN_ALIAS.contains(upper(previous.text()))) { @@ -820,7 +863,7 @@ private int skipIndexModifiers(int start, int end, int depth) { continue; } if (isWordAt(index, depth, "NOT") && isWordAt(index + 1, depth, "INDEXED")) { - index += 3; + index += 2; continue; } break; @@ -849,9 +892,17 @@ private boolean isJoinStart(int index, int end, int depth) { return false; } Token token = tokens.get(index); - return token.depth() == depth - && token.type() == Type.WORD - && JOIN_START.contains(upper(token.text())); + if (token.depth() != depth || token.type() != Type.WORD) { + return false; + } + String keyword = upper(token.text()); + if (!JOIN_START.contains(keyword)) { + return false; + } + if ("LEFT".equals(keyword) || "RIGHT".equals(keyword)) { + return isWordAt(index + 1, depth, "JOIN") || isWordAt(index + 1, depth, "OUTER"); + } + return true; } private int skipToJoin(int start, int end, int depth) { @@ -1136,11 +1187,14 @@ private static List tokenize(String sql) { i = end + 2; continue; } - if (c == '\'') { - i = scanQuoted(sql, i, '\''); - if (i < 0) { + if (c == '\'' || ((c == 'x' || c == 'X') && i + 1 < sql.length() && sql.charAt(i + 1) == '\'')) { + int quote = c == '\'' ? i : i + 1; + int end = scanQuoted(sql, quote, '\''); + if (end < 0) { return null; } + tokens.add(new Token(Type.LITERAL, sql.substring(i, end), depth)); + i = end; continue; } if (c == '"' || c == '`') { 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 282d5b1..7608fb5 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 @@ -224,6 +224,80 @@ void leavesExpressionsAndLiteralsUnresolved() throws SQLException { assertThat(literal.tables()).containsExactly((SqlTableReference.TableRef) null); } + @Test + void emitsLiteralTokensForStringAndBlobLiterals() throws SQLException { + SqlTableReference.Resolution aliased = resolve("SELECT 'lit' z FROM c"); + + assertThat(aliased.labels()).containsExactly("z"); + assertThat(aliased.tables()).containsExactly((SqlTableReference.TableRef) null); + assertThat(resolve("SELECT x'41' AS b FROM a").tables()) + .containsExactly((SqlTableReference.TableRef) null); + + SqlTableReference.Resolution compared = resolve("SELECT x LIKE 'a%' FROM a"); + assertThat(compared.tables()).containsExactly((SqlTableReference.TableRef) null); + assertThat(compared.labels()).doesNotContain("LIKE", "a%"); + } + + @Test + void rejectsOperatorKeywordsAsImplicitAliases() throws SQLException { + SqlTableReference.Resolution isnull = resolve("SELECT x ISNULL FROM a"); + assertThat(isnull.labels()).doesNotContain("ISNULL"); + assertThat(isnull.tables()).containsExactly((SqlTableReference.TableRef) null); + + SqlTableReference.Resolution notnull = resolve("SELECT x NOTNULL FROM a"); + assertThat(notnull.labels()).doesNotContain("NOTNULL"); + assertThat(notnull.tables()).containsExactly((SqlTableReference.TableRef) null); + + SqlTableReference.Resolution caseExpression = resolve("SELECT CASE WHEN x = 1 THEN id ELSE 2 END FROM a"); + assertThat(caseExpression.labels()).doesNotContain("END"); + + SqlTableReference.Resolution counted = resolve("SELECT count(*) c FROM a"); + assertThat(counted.labels()).containsExactly("c"); + assertThat(counted.tables()).containsExactly((SqlTableReference.TableRef) null); + } + + @Test + void keepsTheJoinThatFollowsNotIndexed() throws SQLException { + SqlTableReference.Resolution resolution = + resolve("SELECT b.y FROM a NOT INDEXED JOIN b ON a.id = b.id"); + + assertThat(resolution.labels()).containsExactly("y"); + assertThat(resolution.tables()).containsExactly(new SqlTableReference.TableRef(null, "b")); + } + + @Test + void doesNotReadLeftOrRightCallsAsJoinStarts() throws SQLException { + assertThat(resolve("SELECT c.z FROM a JOIN b ON left(a.x, 2) = b.y, c").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "c")); + assertThat(resolve("SELECT c.z FROM a JOIN b ON right(a.x, 2) = b.y, c").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "c")); + } + + @Test + void registersUnresolvedCommonTableExpressionsAsOpaque() throws SQLException { + assertThat(resolve("WITH c AS (VALUES (1)) SELECT c.z FROM c").tables()) + .containsExactly((SqlTableReference.TableRef) null); + assertThat(resolve("WITH c(z) AS (VALUES (1)) SELECT z FROM c").tables()) + .containsExactly((SqlTableReference.TableRef) null); + } + + @Test + void resolvesUnqualifiedUsingColumnToTheLeftTable() throws SQLException { + assertThat(resolve("SELECT id FROM a JOIN b USING(id)").tables()) + .containsExactly(new SqlTableReference.TableRef(null, "a")); + } + + @Test + void resolvesReturningColumnsThroughTheTargetAlias() throws SQLException { + SqlTableReference.Resolution resolution = + resolve("INSERT INTO a AS t (id, x) VALUES (1, 'v') RETURNING t.id, t.x"); + + assertThat(resolution.tables()) + .containsExactly( + new SqlTableReference.TableRef(null, "a"), + new SqlTableReference.TableRef(null, "a")); + } + @Test void acceptsTrailingSemicolon() throws SQLException { assertThat(resolve("SELECT a FROM w;").tables())