From a3ef8d4012ab64f358413c56fb359c946f744f27 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 23 Oct 2026 19:55:22 +0800 Subject: [PATCH] [fix](parser) Reject unquoted suffixes attached to quoted table names ### What problem does this PR solve? Problem Summary: SELECT * FROM `t`$snapshots treats $snapshots as an implicit alias and silently reads the base table. Reject a dollar-prefixed unquoted suffix immediately adjacent to a quoted table name, with guidance to quote the complete name or separate an intended alias. Preserve valid system-table identifiers and explicit, whitespace-separated, or comment-separated aliases. ### Release note Queries that attach an unquoted dollar suffix directly to a quoted table name now fail explicitly instead of silently scanning the base table. ### Check List (For Author) - Test: All 255 standalone parser unit tests and FE Checkstyle passed; verified the six added regression SQL statements with the parser CLI. Cluster execution of the new regression suite is pending. - Behavior changed: Yes, reject ambiguous adjacent suffixes. - Does this need documentation: No. --- .../org/apache/doris/nereids/DorisParser.g4 | 17 +++- .../sqlparser/QuotedTableSuffixTest.java | 90 +++++++++++++++++++ .../test_quoted_table_suffix.groovy | 33 +++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 fe/fe-sql-parser/src/test/java/org/apache/doris/sqlparser/QuotedTableSuffixTest.java create mode 100644 regression-test/suites/nereids_p0/test_quoted_table_suffix.groovy diff --git a/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 b/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 index e73ef5d7b20f89..33d0115ebfcdb8 100644 --- a/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 +++ b/fe/fe-sql-parser/src/main/antlr4/org/apache/doris/nereids/DorisParser.g4 @@ -49,6 +49,20 @@ options { tokenVocab = DorisLexer; } ctx.getParent().getText(), ctx); } + private void validateQuotedTableSuffix(Token tableEnd, TableAliasContext alias) { + Token aliasStart = alias.getStart(); + // A suffix attached to a quoted table name otherwise becomes a base-table scan alias. + // Keep separated or explicit dollar-prefixed aliases valid. + if (tableEnd.getType() == BACKQUOTED_IDENTIFIER + && aliasStart.getType() == IDENTIFIER + && aliasStart.getText().startsWith("$") + && tableEnd.getStopIndex() + 1 == aliasStart.getStartIndex()) { + throw new org.apache.doris.nereids.exceptions.ParseException( + "A '$' suffix cannot be attached outside a quoted table name. " + + "Quote the full table name or separate an alias with AS or whitespace.", alias); + } + } + private boolean isQueryOrganizationStart() { int tokenType = _input.LA(1); return tokenType == ORDER || tokenType == LIMIT; @@ -1721,7 +1735,8 @@ optScanParams relationPrimary : multipartIdentifier optScanParams? materializedViewName? tableSnapshot? specifiedPartition? - tabletList? tableAlias sample? relationHint? lateralView* #tableName + tabletList? tableAlias {validateQuotedTableSuffix($multipartIdentifier.stop, $tableAlias.ctx);} + sample? relationHint? lateralView* #tableName | LEFT_PAREN query RIGHT_PAREN tableAlias lateralView* #aliasedQuery | tvfName=identifier LEFT_PAREN (properties=propertyItemList)? diff --git a/fe/fe-sql-parser/src/test/java/org/apache/doris/sqlparser/QuotedTableSuffixTest.java b/fe/fe-sql-parser/src/test/java/org/apache/doris/sqlparser/QuotedTableSuffixTest.java new file mode 100644 index 00000000000000..15acd3cfaedffa --- /dev/null +++ b/fe/fe-sql-parser/src/test/java/org/apache/doris/sqlparser/QuotedTableSuffixTest.java @@ -0,0 +1,90 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.apache.doris.sqlparser; + +import org.apache.doris.nereids.DorisParser; +import org.apache.doris.nereids.DorisParserBaseVisitor; +import org.apache.doris.nereids.exceptions.ParseException; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +class QuotedTableSuffixTest { + private final DorisSqlParser parser = new DorisSqlParser(); + + @Test + void rejectsUnquotedSuffixAttachedToQuotedTable() { + for (String table : List.of("`catalog`.`db`.`t`", "catalog.db.`t`", "`t`", "`t``name`", "`่กจ`")) { + for (String suffix : List.of("$snapshots", "$history", "$partitions", "$custom")) { + String sql = "SELECT * FROM " + table + suffix; + Assertions.assertThrows(ParseException.class, () -> parser.parseStatement(sql), sql); + } + } + } + + @Test + void rejectsAttachedSuffixWithoutBuildingParseTree() { + DorisParser generated = parser.newParser(parser.newLexer("SELECT * FROM `t`$snapshots")); + generated.setBuildParseTree(false); + Assertions.assertThrows(ParseException.class, generated::singleStatement); + } + + @Test + void preservesCompleteSystemTableIdentifiers() { + for (String table : List.of("catalog.db.t$snapshots", "`catalog`.`db`.`t$snapshots`")) { + DorisParser.TableNameContext relation = parseTable("SELECT * FROM " + table); + Assertions.assertEquals("catalog.db.t$snapshots", relation.multipartIdentifier().getText()); + Assertions.assertNull(relation.tableAlias().strictIdentifier()); + } + } + + @Test + void preservesExplicitAndSeparatedDollarAliases() { + for (String separator : List.of(" ", "\t", "\n", "/**/", "-- alias\n", " AS ", "AS ")) { + String sql = "SELECT $snapshots.id FROM `t`" + separator + "$snapshots"; + DorisParser.TableNameContext relation = parseTable(sql); + Assertions.assertEquals("t", relation.multipartIdentifier().getText()); + Assertions.assertEquals("$snapshots", relation.tableAlias().strictIdentifier().getText()); + } + } + + @Test + void preservesQuotedAndOrdinaryAliases() { + for (String alias : List.of(" `$snapshots`", "ordinary", " `ordinary`")) { + DorisParser.TableNameContext relation = parseTable("SELECT * FROM `t`" + alias); + Assertions.assertEquals("t", relation.multipartIdentifier().getText()); + Assertions.assertEquals(alias.trim().replace("`", ""), relation.tableAlias().strictIdentifier().getText()); + } + } + + private DorisParser.TableNameContext parseTable(String sql) { + List relations = new ArrayList<>(); + new DorisParserBaseVisitor() { + @Override + public Void visitTableName(DorisParser.TableNameContext ctx) { + relations.add(ctx); + return super.visitTableName(ctx); + } + }.visit(parser.parseStatement(sql)); + Assertions.assertEquals(1, relations.size()); + return relations.get(0); + } +} diff --git a/regression-test/suites/nereids_p0/test_quoted_table_suffix.groovy b/regression-test/suites/nereids_p0/test_quoted_table_suffix.groovy new file mode 100644 index 00000000000000..ebbf3fa8f6d0bb --- /dev/null +++ b/regression-test/suites/nereids_p0/test_quoted_table_suffix.groovy @@ -0,0 +1,33 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +suite("test_quoted_table_suffix") { + // Reject the ambiguous suffix before catalog lookup so it cannot silently scan the base table. + [ + 'SELECT * FROM `catalog`.`db`.`t`$snapshots', + 'SELECT * FROM catalog.db.`t`$snapshots', + 'SELECT * FROM `t`$history', + 'SELECT * FROM `t``name`$partitions', + 'SELECT * FROM (SELECT * FROM `t`$snapshots) nested', + 'SELECT * FROM base JOIN `t`$snapshots ON base.id = $snapshots.id' + ].each { query -> + test { + sql query + exception "A '\$' suffix cannot be attached outside a quoted table name" + } + } +}