This is an automated email from the ASF dual-hosted git repository.

gengliangwang pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/branch-4.x by this push:
     new 4159c905e229 [SPARK-57421][SQL] Support @-syntax version time travel 
on table names
4159c905e229 is described below

commit 4159c905e22999609f0032f749c481db6a443360
Author: sotikoug83 <[email protected]>
AuthorDate: Mon Jul 6 09:06:06 2026 -0700

    [SPARK-57421][SQL] Support @-syntax version time travel on table names
    
    ### What changes were proposed in this pull request?
    
    Add the `` version time travel shorthand on table names so that
    ```
    SELECT * FROM namevN
    ```
    and
    ```
    spark.read.format("format").table("namevN")
    ```
    parse into the same plan as the existing `VERSION AS OF` and 
`option("versionAsOf")` clauses. Support for SQL, the DataFrame reader, and 
Spark Connect.
    
    ### Why are the changes needed?
    
    Spark has SQL `VERSION AS OF` and the `versionAsOf` reader options but not 
the compact `` suffix. Resolving it at parse time also simplifies the time 
travel entry points pipeline.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, new syntax on table names gated by a `conf` 
(`spark.sql.timeTravel.atSyntax.enabled`, default **true**) which allows time 
travel. No longer gives a parse error when enabled and the syntax is used.
    
    ### How was this patch tested?
    
    New tests in `PlanParserSuite`, `SparkConnectPlannerSuite`, 
`DataSourceV2SQLSuite`, and `DataStreamTableAPISuite`.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    With help from Claude :)
    
    Closes #56481 from sotikoug83/SPARK-57421-at-syntax-time-travel.
    
    Authored-by: sotikoug83 <[email protected]>
    Signed-off-by: Gengliang Wang <[email protected]>
    (cherry picked from commit 3e247620d058da9c421ea51e032ad7ed39952a8f)
    Signed-off-by: Gengliang Wang <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  7 +-
 .../spark/sql/catalyst/parser/SqlBaseLexer.g4      |  1 +
 .../spark/sql/catalyst/parser/SqlBaseParser.g4     | 16 +++-
 .../spark/sql/errors/QueryParsingErrors.scala      | 14 ++++
 .../sql/catalyst/parser/AbstractSqlParser.scala    | 10 +++
 .../spark/sql/catalyst/parser/AstBuilder.scala     | 54 +++++++++++++-
 .../sql/catalyst/parser/ParserInterface.scala      | 10 +++
 .../sql/catalyst/parser/TemporalIdentifier.scala   | 36 +++++++++
 .../org/apache/spark/sql/internal/SQLConf.scala    | 10 +++
 .../sql/catalyst/parser/PlanParserSuite.scala      | 56 ++++++++++++++
 .../sql/connect/planner/SparkConnectPlanner.scala  | 11 ++-
 .../connect/planner/SparkConnectPlannerSuite.scala | 42 ++++++++++-
 .../apache/spark/sql/classic/DataFrameReader.scala |  9 ++-
 .../spark/sql/classic/DataStreamReader.scala       |  9 ++-
 .../analyzer-results/pipe-operators.sql.out        |  4 +-
 .../sql-tests/results/pipe-operators.sql.out       |  4 +-
 .../spark/sql/connector/DataSourceV2SQLSuite.scala | 86 ++++++++++++++++++++++
 .../streaming/test/DataStreamTableAPISuite.scala   |  9 +++
 18 files changed, 370 insertions(+), 18 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index 8b9292286aab..03bffb930e73 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -5647,7 +5647,7 @@
   },
   "MULTIPLE_TIME_TRAVEL_SPEC" : {
     "message" : [
-      "Cannot specify time travel in both the time travel clause and options."
+      "Cannot specify time travel in more than one of: the '@' suffix in the 
table name, the time travel clause, and the read options."
     ],
     "sqlState" : "42K0E"
   },
@@ -8574,6 +8574,11 @@
           "Time travel on the relation: <relationId>."
         ]
       },
+      "TIME_TRAVEL_AT_SYNTAX" : {
+        "message" : [
+          "Time travel using the '@' suffix in table names. Set <config> to 
true to enable it."
+        ]
+      },
       "TOO_MANY_TYPE_ARGUMENTS_FOR_UDF_CLASS" : {
         "message" : [
           "UDF class with <num> type arguments."
diff --git 
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4 
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4
index 0e940ee5b4b0..f4a9aa1d5471 100644
--- 
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4
+++ 
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseLexer.g4
@@ -591,6 +591,7 @@ OPERATOR_PIPE: '|>';
 HAT: '^';
 COLON: ':';
 DOUBLE_COLON: '::';
+AT_VERSION: '@V';
 ARROW: '->';
 FAT_ARROW : '=>';
 HENT_START: '/*+';
diff --git 
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4 
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
index 2466cf62272a..7d75aaebd546 100644
--- 
a/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
+++ 
b/sql/api/src/main/antlr4/org/apache/spark/sql/catalyst/parser/SqlBaseParser.g4
@@ -201,6 +201,10 @@ singleTableIdentifier
     : tableIdentifier EOF
     ;
 
+singleTemporalTableIdentifier
+    : temporalTableIdentifier EOF
+    ;
+
 singleMultipartIdentifier
     : multipartIdentifier EOF
     ;
@@ -1133,7 +1137,7 @@ relationPrimary
     : streamRelationPrimary                                 #streamRelation
     | identifierReference changesClause
       optionsClause? tableAlias                             #changelogTableName
-    | identifierReference temporalClause?
+    | temporalTableIdentifierReference temporalClause?
       optionsClause? sample? watermarkClause? tableAlias    #tableName
     | LEFT_PAREN query RIGHT_PAREN sample? watermarkClause?
       tableAlias                                            #aliasedQuery
@@ -1239,6 +1243,16 @@ tableIdentifier
     : (db=errorCapturingIdentifier DOT)? table=errorCapturingIdentifier
     ;
 
+temporalTableIdentifier
+    : id=multipartIdentifier AT_VERSION version
+    | id=multipartIdentifier
+    ;
+
+temporalTableIdentifierReference
+    : identifierReference AT_VERSION version
+    | identifierReference
+    ;
+
 functionIdentifier
     : (db=errorCapturingIdentifier DOT)? function=errorCapturingIdentifier
     ;
diff --git 
a/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala 
b/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
index 558bda49a302..01ba078be729 100644
--- 
a/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
+++ 
b/sql/api/src/main/scala/org/apache/spark/sql/errors/QueryParsingErrors.scala
@@ -878,6 +878,20 @@ private[sql] object QueryParsingErrors extends 
DataTypeErrorsBase {
       ctx)
   }
 
+  def multipleTimeTravelSpec(ctx: ParserRuleContext): Throwable = {
+    new ParseException(
+      errorClass = "MULTIPLE_TIME_TRAVEL_SPEC",
+      messageParameters = Map.empty,
+      ctx)
+  }
+
+  def timeTravelAtSyntaxDisabled(configKey: String, ctx: ParserRuleContext): 
Throwable = {
+    new ParseException(
+      errorClass = "UNSUPPORTED_FEATURE.TIME_TRAVEL_AT_SYNTAX",
+      messageParameters = Map("config" -> toSQLConf(configKey)),
+      ctx)
+  }
+
   def invalidNameForDropTempFunc(name: Seq[String], ctx: ParserRuleContext): 
Throwable = {
     new ParseException(
       errorClass = "INVALID_SQL_SYNTAX.MULTI_PART_NAME",
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AbstractSqlParser.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AbstractSqlParser.scala
index 29bf924f244e..e6d53ecc1e25 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AbstractSqlParser.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AbstractSqlParser.scala
@@ -72,6 +72,16 @@ abstract class AbstractSqlParser extends AbstractParser with 
ParserInterface {
     }
   }
 
+  /** Creates a TemporalIdentifier for a given SQL string */
+  override def parseTemporalTableIdentifier(sqlText: String): 
TemporalIdentifier = {
+    parse(sqlText) { parser =>
+      val ctx = parser.singleTemporalTableIdentifier()
+      withErrorHandling(ctx, Some(sqlText)) {
+        astBuilder.visitSingleTemporalTableIdentifier(ctx)
+      }
+    }
+  }
+
   /** Creates LogicalPlan for a given SQL string of query. */
   override def parseQuery(sqlText: String): LogicalPlan =
     parse(sqlText) { parser =>
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
index aa3d68198e2d..996d72c10da9 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/AstBuilder.scala
@@ -708,6 +708,11 @@ class AstBuilder extends DataTypeAstBuilder
     visitMultipartIdentifier(ctx.multipartIdentifier)
   }
 
+  override def visitSingleTemporalTableIdentifier(
+      ctx: SingleTemporalTableIdentifierContext): TemporalIdentifier = 
withOrigin(ctx) {
+    visitTemporalTableIdentifier(ctx.temporalTableIdentifier)
+  }
+
   override def visitSinglePathElementList(
       ctx: SinglePathElementListContext): Seq[PathElement] = withOrigin(ctx) {
     ctx.pathElement().asScala.map(visitPathElement).toSeq
@@ -2620,13 +2625,56 @@ class AstBuilder extends DataTypeAstBuilder
    * Create an aliased table reference. This is typically used in FROM clauses.
    */
   override def visitTableName(ctx: TableNameContext): LogicalPlan = 
withOrigin(ctx) {
-    val relation = createUnresolvedRelation(ctx.identifierReference, 
Option(ctx.optionsClause))
-    val table = mayApplyAliasPlan(
-      ctx.tableAlias, relation.optionalMap(ctx.temporalClause)(withTimeTravel))
+    val ttCtx = ctx.temporalTableIdentifierReference
+    val relation = createUnresolvedRelation(ttCtx.identifierReference, 
Option(ctx.optionsClause))
+    val withTimeTravelSpec = withTableTimeTravel(relation, ttCtx, 
ctx.temporalClause)
+    val table = mayApplyAliasPlan(ctx.tableAlias, withTimeTravelSpec)
     val sample = table.optionalMap(ctx.sample)(withSample)
     sample.optionalMap(ctx.watermarkClause)(withWatermark)
   }
 
+  /**
+   * Applies the table-name '@' time travel suffix and/or the `AS OF` clause 
to `relation`.
+   */
+  private def withTableTimeTravel(
+      relation: LogicalPlan,
+      ttCtx: TemporalTableIdentifierReferenceContext,
+      clause: TemporalClauseContext): LogicalPlan = {
+    val (atTimestamp, atVersion) = temporalSpec(ttCtx, ttCtx.version)
+    val hasAtSpec = atTimestamp.isDefined || atVersion.isDefined
+    if (hasAtSpec && clause != null) {
+      withOrigin(clause) {
+        throw QueryParsingErrors.multipleTimeTravelSpec(clause)
+      }
+    }
+    val withAtSpec =
+      if (hasAtSpec) RelationTimeTravel(relation, atTimestamp, atVersion) else 
relation
+    withAtSpec.optionalMap(clause)(withTimeTravel)
+  }
+
+  override def visitTemporalTableIdentifier(
+      ctx: TemporalTableIdentifierContext): TemporalIdentifier = 
withOrigin(ctx) {
+    val (_, version) = temporalSpec(ctx, ctx.version)
+    TemporalIdentifier(visitMultipartIdentifier(ctx.id), version)
+  }
+
+  /**
+   * Extract the optional '@v<version>' time travel suffix of a table 
identifier.
+   */
+  private def temporalSpec(
+      ctx: ParserRuleContext,
+      versionCtx: VersionContext): (Option[Expression], Option[String]) = {
+    if (versionCtx == null) {
+      (None, None)
+    } else {
+      if (!conf.getConf(SQLConf.TIME_TRAVEL_AT_SYNTAX_ENABLED)) {
+        throw QueryParsingErrors.timeTravelAtSyntaxDisabled(
+          SQLConf.TIME_TRAVEL_AT_SYNTAX_ENABLED.key, ctx)
+      }
+      (None, visitVersion(versionCtx))
+    }
+  }
+
   override def visitVersion(ctx: VersionContext): Option[String] = {
     if (ctx != null) {
       if (ctx.INTEGER_VALUE() != null) {
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/ParserInterface.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/ParserInterface.scala
index bcbc7039f53c..193d68e9a444 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/ParserInterface.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/ParserInterface.scala
@@ -69,6 +69,16 @@ trait ParserInterface extends DataTypeParserInterface {
   @throws[ParseException]("Text cannot be parsed to a multi-part identifier")
   def parseMultipartIdentifier(sqlText: String): Seq[String]
 
+  /**
+   * Parse a string to a [[TemporalIdentifier]].
+   */
+  @throws[ParseException]("Text cannot be parsed to a temporal table 
identifier")
+  def parseTemporalTableIdentifier(sqlText: String): TemporalIdentifier = {
+    // Default implementation does not recognize the time travel suffix. 
Concrete
+    // implementations can override this to support it.
+    TemporalIdentifier(parseMultipartIdentifier(sqlText), None)
+  }
+
   /**
    * Parse a query string to a [[LogicalPlan]].
    */
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/TemporalIdentifier.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/TemporalIdentifier.scala
new file mode 100644
index 000000000000..9be5c4016a7c
--- /dev/null
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/parser/TemporalIdentifier.scala
@@ -0,0 +1,36 @@
+/*
+ * 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.spark.sql.catalyst.parser
+
+import org.apache.spark.sql.catalyst.analysis.RelationTimeTravel
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+
+/**
+ * Result of parsing a table name that may carry an '@v<version>' time travel 
suffix.
+ */
+case class TemporalIdentifier(
+    nameParts: Seq[String],
+    version: Option[String]) {
+
+  def isTemporal: Boolean = version.isDefined
+
+  /** Wraps `plan` in a [[RelationTimeTravel]] if a time travel suffix was 
specified. */
+  def wrapTimeTravel(plan: LogicalPlan): LogicalPlan = {
+    if (isTemporal) RelationTimeTravel(plan, None, version) else plan
+  }
+}
diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 1ca873d7da1a..86a19e45a045 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -7136,6 +7136,16 @@ object SQLConf {
       .stringConf
       .createWithDefault("versionAsOf")
 
+  val TIME_TRAVEL_AT_SYNTAX_ENABLED =
+    buildConf("spark.sql.timeTravel.atSyntax.enabled")
+      .doc("When true, a table name in a query or in table-reading APIs can 
carry a time " +
+        "travel suffix: 'name@v123' reads version 123 of the table. When 
false, '@' in " +
+        "table names fails at parse time.")
+      .version("4.3.0")
+      .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
+      .booleanConf
+      .createWithDefault(true)
+
   val OPERATOR_PIPE_SYNTAX_ENABLED =
     buildConf("spark.sql.operatorPipeSyntaxEnabled")
       .doc("If true, enable operator pipe syntax for Apache Spark SQL. This 
uses the operator " +
diff --git 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/PlanParserSuite.scala
 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/PlanParserSuite.scala
index e20071b3de62..712da9a9508a 100644
--- 
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/PlanParserSuite.scala
+++ 
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/parser/PlanParserSuite.scala
@@ -2193,6 +2193,62 @@ class PlanParserSuite extends AnalysisTest {
         stop = 38))
   }
 
+  test("at syntax time travel") {
+    def versionPlan(version: String): LogicalPlan = {
+      Project(Seq(UnresolvedStar(None)),
+        RelationTimeTravel(UnresolvedRelation(Seq("a", "b", "c")), None, 
Some(version)))
+    }
+    assertEqual("SELECT * FROM a.b.c@v123456789", versionPlan("123456789"))
+    assertEqual("SELECT * FROM a.b.c@V123456789", versionPlan("123456789"))
+    assertEqual("SELECT * FROM a.b.c @v123456789", versionPlan("123456789"))
+    assertEqual("SELECT * FROM a.b.c@v'Snapshot123456789'", 
versionPlan("Snapshot123456789"))
+
+    assertEqual("SELECT * FROM `t@v1`",
+      Project(Seq(UnresolvedStar(None)), UnresolvedRelation(Seq("t@v1"))))
+
+    // A non-time-travel '@' suffix is always a parse error.
+    Seq("SELECT * FROM a@foo", "SELECT * FROM a@", "SELECT * FROM 
a@v").foreach { q =>
+      assert(intercept[ParseException](parsePlan(q)).getCondition == 
"PARSE_SYNTAX_ERROR",
+        s"expected PARSE_SYNTAX_ERROR for: $q")
+    }
+
+    // The '@' suffix conflicts with an AS OF clause.
+    checkError(
+      exception = parseException("SELECT * FROM t@v1 VERSION AS OF 2"),
+      condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+      parameters = Map.empty,
+      context = ExpectedContext(fragment = "VERSION AS OF 2", start = 19, stop 
= 33))
+    checkError(
+      exception = parseException("SELECT * FROM t@v1 TIMESTAMP AS OF 
'2019-01-29'"),
+      condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+      parameters = Map.empty,
+      context = ExpectedContext(fragment = "TIMESTAMP AS OF '2019-01-29'", 
start = 19, stop = 46))
+
+    assert(intercept[ParseException] {
+      parsePlan("INSERT INTO t@v1 VALUES (1)")
+    }.getCondition == "PARSE_SYNTAX_ERROR")
+
+    withSQLConf(SQLConf.TIME_TRAVEL_AT_SYNTAX_ENABLED.key -> "false") {
+      checkError(
+        exception = parseException("SELECT * FROM t@v1"),
+        condition = "UNSUPPORTED_FEATURE.TIME_TRAVEL_AT_SYNTAX",
+        parameters = Map("config" -> 
"\"spark.sql.timeTravel.atSyntax.enabled\""),
+        context = ExpectedContext(fragment = "t@v1", start = 14, stop = 17))
+    }
+  }
+
+  test("parseTemporalTableIdentifier") {
+    assert(parseTemporalTableIdentifier("a.b") ===
+      TemporalIdentifier(Seq("a", "b"), None))
+    assert(parseTemporalTableIdentifier("a.b@v5") ===
+      TemporalIdentifier(Seq("a", "b"), Some("5")))
+    assert(parseTemporalTableIdentifier("`t@v1`") ===
+      TemporalIdentifier(Seq("t@v1"), None))
+    Seq("a.b@x", "a@foo", "a@", "a@v").foreach { s =>
+      intercept[ParseException](parseTemporalTableIdentifier(s))
+    }
+  }
+
   test("CHANGES clause - version range") {
     def changesFromVersion(
         startVersion: String,
diff --git 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
index 94e7938b7c72..1922eb14bda7 100644
--- 
a/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
+++ 
b/sql/connect/server/src/main/scala/org/apache/spark/sql/connect/planner/SparkConnectPlanner.scala
@@ -1660,10 +1660,17 @@ class SparkConnectPlanner(
 
     rel.getReadTypeCase match {
       case proto.Read.ReadTypeCase.NAMED_TABLE =>
-        UnresolvedRelation(
-          
parser.parseMultipartIdentifier(rel.getNamedTable.getUnparsedIdentifier),
+        val temporalIdent =
+          
parser.parseTemporalTableIdentifier(rel.getNamedTable.getUnparsedIdentifier)
+        if (temporalIdent.isTemporal && rel.getIsStreaming) {
+          throw QueryCompilationErrors.timeTravelUnsupportedError(
+            QueryCompilationErrors.toSQLId(temporalIdent.nameParts))
+        }
+        val relation = UnresolvedRelation(
+          temporalIdent.nameParts,
           new CaseInsensitiveStringMap(rel.getNamedTable.getOptionsMap),
           isStreaming = rel.getIsStreaming)
+        temporalIdent.wrapTimeTravel(relation)
 
       case proto.Read.ReadTypeCase.DATA_SOURCE if !rel.getIsStreaming =>
         val reader = session.read
diff --git 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/planner/SparkConnectPlannerSuite.scala
 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/planner/SparkConnectPlannerSuite.scala
index 89ccaea93a04..22f7cabc8c18 100644
--- 
a/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/planner/SparkConnectPlannerSuite.scala
+++ 
b/sql/connect/server/src/test/scala/org/apache/spark/sql/connect/planner/SparkConnectPlannerSuite.scala
@@ -26,7 +26,7 @@ import org.apache.spark.connect.proto
 import org.apache.spark.connect.proto.Expression.{Alias, ExpressionString, 
UnresolvedStar}
 import org.apache.spark.sql.{AnalysisException, Row}
 import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.analysis.{UnresolvedAlias, 
UnresolvedFunction, UnresolvedRelation}
+import org.apache.spark.sql.catalyst.analysis.{RelationTimeTravel, 
UnresolvedAlias, UnresolvedFunction, UnresolvedRelation}
 import org.apache.spark.sql.catalyst.expressions.{AttributeReference, 
UnsafeProjection}
 import org.apache.spark.sql.catalyst.plans.logical
 import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, LogicalPlan}
@@ -159,6 +159,46 @@ class SparkConnectPlannerSuite extends SparkFunSuite with 
SparkConnectPlanTest {
     assert(res.nodeName == "UnresolvedRelation")
   }
 
+  test("Read with at syntax time travel") {
+    val read = proto.Read
+      .newBuilder()
+      
.setNamedTable(proto.Read.NamedTable.newBuilder.setUnparsedIdentifier("name@v1").build())
+      .build()
+    val res = transform(proto.Relation.newBuilder.setRead(read).build())
+    res match {
+      case RelationTimeTravel(relation: UnresolvedRelation, timestamp, 
version) =>
+        assert(relation.multipartIdentifier === Seq("name"))
+        assert(timestamp.isEmpty)
+        assert(version === Some("1"))
+      case other => fail(s"Expected RelationTimeTravel but got: $other")
+    }
+
+    // Streaming reads do not support time travel.
+    val streamingRead = read.toBuilder.setIsStreaming(true).build()
+    val e = intercept[AnalysisException] {
+      transform(proto.Relation.newBuilder.setRead(streamingRead).build())
+    }
+    assert(e.getCondition === "UNSUPPORTED_FEATURE.TIME_TRAVEL")
+
+    // A non-time-travel '@' suffix is a parse error.
+    val badRead = read.toBuilder
+      
.setNamedTable(proto.Read.NamedTable.newBuilder.setUnparsedIdentifier("name@foo").build())
+      .build()
+    val pe = intercept[AnalysisException] {
+      transform(proto.Relation.newBuilder.setRead(badRead).build())
+    }
+    assert(pe.getCondition === "PARSE_SYNTAX_ERROR")
+
+    // A backticked '@' name stays a literal table name.
+    val quotedRead = read.toBuilder
+      
.setNamedTable(proto.Read.NamedTable.newBuilder.setUnparsedIdentifier("`name@v1`").build())
+      .build()
+    transform(proto.Relation.newBuilder.setRead(quotedRead).build()) match {
+      case u: UnresolvedRelation => assert(u.multipartIdentifier === 
Seq("name@v1"))
+      case other => fail(s"Expected a literal UnresolvedRelation but got: 
$other")
+    }
+  }
+
   test("Simple Table with options") {
     val read = proto.Read.newBuilder().build()
     // Invalid read without Table name.
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameReader.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameReader.scala
index 5ba6cb204075..f791a932b3a8 100644
--- a/sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameReader.scala
+++ b/sql/core/src/main/scala/org/apache/spark/sql/classic/DataFrameReader.scala
@@ -322,10 +322,11 @@ class DataFrameReader private[sql](sparkSession: 
SparkSession)
   /** @inheritdoc */
   def table(tableName: String): DataFrame = {
     assertNoSpecifiedSchema("table")
-    val multipartIdentifier =
-      sparkSession.sessionState.sqlParser.parseMultipartIdentifier(tableName)
-    Dataset.ofRows(sparkSession, UnresolvedRelation(multipartIdentifier,
-      new CaseInsensitiveStringMap(extraOptions.toMap.asJava)))
+    val temporalIdent =
+      
sparkSession.sessionState.sqlParser.parseTemporalTableIdentifier(tableName)
+    val relation = UnresolvedRelation(temporalIdent.nameParts,
+      new CaseInsensitiveStringMap(extraOptions.toMap.asJava))
+    Dataset.ofRows(sparkSession, temporalIdent.wrapTimeTravel(relation))
   }
 
   /** @inheritdoc */
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamReader.scala 
b/sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamReader.scala
index eb3120cac05a..b359554e527c 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamReader.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/classic/DataStreamReader.scala
@@ -102,9 +102,14 @@ final class DataStreamReader private[sql](sparkSession: 
SparkSession)
   /** @inheritdoc */
   def table(tableName: String): DataFrame = {
     require(tableName != null, "The table name can't be null")
-    val identifier = 
sparkSession.sessionState.sqlParser.parseMultipartIdentifier(tableName)
+    val temporalIdent =
+      
sparkSession.sessionState.sqlParser.parseTemporalTableIdentifier(tableName)
+    if (temporalIdent.isTemporal) {
+      throw QueryCompilationErrors.timeTravelUnsupportedError(
+        QueryCompilationErrors.toSQLId(temporalIdent.nameParts))
+    }
     val unresolved = UnresolvedRelation(
-      identifier,
+      temporalIdent.nameParts,
       new CaseInsensitiveStringMap(extraOptions.toMap.asJava),
       isStreaming = true)
     val plan = NamedStreamingRelation.withUserProvidedName(unresolved, 
userProvidedSourceName)
diff --git 
a/sql/core/src/test/resources/sql-tests/analyzer-results/pipe-operators.sql.out 
b/sql/core/src/test/resources/sql-tests/analyzer-results/pipe-operators.sql.out
index e9d5f79315a6..d136341fa32b 100644
--- 
a/sql/core/src/test/resources/sql-tests/analyzer-results/pipe-operators.sql.out
+++ 
b/sql/core/src/test/resources/sql-tests/analyzer-results/pipe-operators.sql.out
@@ -1346,8 +1346,8 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "'@'",
-    "hint" : ""
+    "error" : "'@v'",
+    "hint" : ": extra input '@v'"
   }
 }
 
diff --git 
a/sql/core/src/test/resources/sql-tests/results/pipe-operators.sql.out 
b/sql/core/src/test/resources/sql-tests/results/pipe-operators.sql.out
index aa0a377a95a4..fabe74b49158 100644
--- a/sql/core/src/test/resources/sql-tests/results/pipe-operators.sql.out
+++ b/sql/core/src/test/resources/sql-tests/results/pipe-operators.sql.out
@@ -1279,8 +1279,8 @@ org.apache.spark.sql.catalyst.parser.ParseException
   "errorClass" : "PARSE_SYNTAX_ERROR",
   "sqlState" : "42601",
   "messageParameters" : {
-    "error" : "'@'",
-    "hint" : ""
+    "error" : "'@v'",
+    "hint" : ": extra input '@v'"
   }
 }
 
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
index 3c5d1626ca1f..7ce2945898c3 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/connector/DataSourceV2SQLSuite.scala
@@ -3941,6 +3941,92 @@ class DataSourceV2SQLSuiteV1Filter
     }
   }
 
+  test("time travel with at syntax") {
+    sql("use testcat")
+    val t1 = "testcat.tSnapshot123456789"
+    val t2 = "testcat.t2345678910"
+    withTable(t1, t2) {
+      sql(s"CREATE TABLE $t1 (id int) USING foo")
+      sql(s"CREATE TABLE $t2 (id int) USING foo")
+      sql(s"INSERT INTO $t1 VALUES (1), (2)")
+      sql(s"INSERT INTO $t2 VALUES (3), (4)")
+
+      checkAnswer(sql("SELECT * FROM t@v2345678910"), Seq(Row(3), Row(4)))
+      checkAnswer(sql("SELECT * FROM t@V2345678910"), Seq(Row(3), Row(4)))
+      checkAnswer(sql("SELECT * FROM t@v'Snapshot123456789'"), Seq(Row(1), 
Row(2)))
+      checkAnswer(spark.read.table("t@v2345678910"), Seq(Row(3), Row(4)))
+      checkAnswer(spark.table("t@v2345678910"), Seq(Row(3), Row(4)))
+
+      checkError(
+        exception = intercept[ParseException] {
+          sql("SELECT * FROM t@v2345678910 VERSION AS OF 1")
+        },
+        condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+        parameters = Map.empty,
+        context = ExpectedContext(fragment = "VERSION AS OF 1", start = 28, 
stop = 42))
+      checkError(
+        exception = intercept[ParseException] {
+          sql("SELECT * FROM t@v2345678910 TIMESTAMP AS OF '2019-01-29'")
+        },
+        condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+        parameters = Map.empty,
+        context = ExpectedContext(
+          fragment = "TIMESTAMP AS OF '2019-01-29'", start = 28, stop = 55))
+      checkError(
+        exception = intercept[AnalysisException] {
+          spark.read.option("versionAsOf", 
"2345678910").table("t@v2345678910").collect()
+        },
+        condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+        parameters = Map.empty)
+      checkError(
+        exception = intercept[AnalysisException] {
+          spark.read.option("timestampAsOf", 
"2019-01-29").table("t@v2345678910").collect()
+        },
+        condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+        parameters = Map.empty)
+      checkError(
+        exception = intercept[AnalysisException] {
+          sql("SELECT * FROM t@v2345678910 WITH ('timestampAsOf' = 
'2019-01-29')")
+        },
+        condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+        parameters = Map.empty)
+
+      withSQLConf(SQLConf.TIME_TRAVEL_AT_SYNTAX_ENABLED.key -> "false") {
+        checkError(
+          exception = intercept[ParseException] {
+            sql("SELECT * FROM t@v2345678910")
+          },
+          condition = "UNSUPPORTED_FEATURE.TIME_TRAVEL_AT_SYNTAX",
+          parameters = Map("config" -> 
"\"spark.sql.timeTravel.atSyntax.enabled\""),
+          context = ExpectedContext(fragment = "t@v2345678910", start = 14, 
stop = 26))
+        intercept[ParseException](spark.read.table("t@v2345678910"))
+      }
+    }
+
+    intercept[ParseException](sql("SELECT * FROM t@foo"))
+    intercept[ParseException](spark.read.table("t@foo"))
+    withTable("testcat.`weird@v1`") {
+      sql("CREATE TABLE testcat.`weird@v1` (id int) USING foo")
+      sql("INSERT INTO testcat.`weird@v1` VALUES (42)")
+      checkAnswer(sql("SELECT * FROM `weird@v1`"), Row(42))
+      checkAnswer(spark.read.table("`weird@v1`"), Row(42))
+    }
+
+    withTempView("v") {
+      spark.range(1).createOrReplaceTempView("v")
+      checkError(
+        exception = analysisException("SELECT * FROM v@v1"),
+        condition = "UNSUPPORTED_FEATURE.TIME_TRAVEL",
+        sqlState = None,
+        parameters = Map("relationId" -> "`v`"))
+    }
+    checkError(
+      exception = analysisException("WITH x AS (SELECT 1) SELECT * FROM x@v1"),
+      condition = "UNSUPPORTED_FEATURE.TIME_TRAVEL",
+      sqlState = None,
+      parameters = Map("relationId" -> "`x`"))
+  }
+
   test("SPARK-37827: put build-in properties into V1Table.properties to adapt 
v2 command") {
     val t = "tbl"
     withTable(t) {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/test/DataStreamTableAPISuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/test/DataStreamTableAPISuite.scala
index e2c74533e7f3..2ba374e3d674 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/test/DataStreamTableAPISuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/test/DataStreamTableAPISuite.scala
@@ -84,6 +84,15 @@ class DataStreamTableAPISuite extends StreamTest with 
BeforeAndAfter {
     checkErrorTableNotFound(e, "`non_exist_table`")
   }
 
+  test("read: time travel @-syntax is unsupported for streaming") {
+    checkError(
+      exception = intercept[AnalysisException] {
+        spark.readStream.table("t@v1")
+      },
+      condition = "UNSUPPORTED_FEATURE.TIME_TRAVEL",
+      parameters = Map("relationId" -> "`t`"))
+  }
+
   test("read: stream table API with temp view") {
     val tblName = "my_table"
     val stream = MemoryStream[Int]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to