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

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


The following commit(s) were added to refs/heads/master by this push:
     new e27dc9791d30 [SPARK-58012][SQL][CONNECT] Support @-syntax timestamp 
time travel on table names
e27dc9791d30 is described below

commit e27dc9791d30190fdbe73376f57cbdc3969d7f31
Author: sotikoug83 <[email protected]>
AuthorDate: Thu Jul 9 20:12:03 2026 -0700

    [SPARK-58012][SQL][CONNECT] Support @-syntax timestamp time travel on table 
names
    
    ### What changes were proposed in this pull request?
    
    Add the `` timestamp time travel shorthand on table names so that
    ```
    SELECT * FROM name<TS>
    ```
    and
    ```
    spark.read.format("format").table("name<ts>")
    ```
    parse into the same plan as the existing `TIMESTAMP AS OF` and 
`option("timestampAsOf")` clauses. Support for SQL, the DataFrame reader, and 
Spark Connect.
    
    ### Why are the changes needed?
    
    Spark has SQL `AS OF` and the `timestampAsOf` 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. 
Additionally, different from `TIMESTAMP AS OF <TS>` (which allows for arbitrary 
expressions in `<TS>`), the `<ts>` timestamp only accepts the fixed 17-digit 
`yyyyMMddHHmmssSSS` Delta convention.
    
    ### 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 #56847 from sotikoug83/SPARK-57421-at-syntax-timestamp.
    
    Authored-by: sotikoug83 <[email protected]>
    Signed-off-by: Gengliang Wang <[email protected]>
---
 .../src/main/resources/error/error-conditions.json |  6 ++
 .../spark/sql/catalyst/parser/SqlBaseLexer.g4      |  1 +
 .../spark/sql/catalyst/parser/SqlBaseParser.g4     |  6 +-
 .../spark/sql/errors/QueryParsingErrors.scala      | 10 ++++
 .../spark/sql/catalyst/parser/AstBuilder.scala     | 48 ++++++++++++---
 .../sql/catalyst/parser/ParserInterface.scala      |  2 +-
 .../sql/catalyst/parser/TemporalIdentifier.scala   | 14 ++++-
 .../org/apache/spark/sql/internal/SQLConf.scala    |  7 ++-
 .../sql/catalyst/parser/PlanParserSuite.scala      | 70 ++++++++++++++++++++--
 .../connect/planner/SparkConnectPlannerSuite.scala | 19 ++++++
 .../spark/sql/connector/DataSourceV2SQLSuite.scala | 31 ++++++++++
 .../streaming/test/DataStreamTableAPISuite.scala   |  6 ++
 12 files changed, 200 insertions(+), 20 deletions(-)

diff --git a/common/utils/src/main/resources/error/error-conditions.json 
b/common/utils/src/main/resources/error/error-conditions.json
index b09909f8f9aa..4c5cf5fe5d69 100644
--- a/common/utils/src/main/resources/error/error-conditions.json
+++ b/common/utils/src/main/resources/error/error-conditions.json
@@ -5116,6 +5116,12 @@
     },
     "sqlState" : "42K0E"
   },
+  "INVALID_TIME_TRAVEL_TIMESTAMP_FORMAT" : {
+    "message" : [
+      "The provided timestamp <timestamp> doesn't match the expected syntax 
<format>."
+    ],
+    "sqlState" : "22000"
+  },
   "INVALID_TYPED_LITERAL" : {
     "message" : [
       "The value of the typed literal <valueType> is invalid: <value>."
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 e15be3fb76ec..3e5c79613f19 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
@@ -595,6 +595,7 @@ OPERATOR_PIPE: '|>';
 HAT: '^';
 COLON: ':';
 DOUBLE_COLON: '::';
+AT_SIGN: '@';
 AT_VERSION: '@V';
 ARROW: '->';
 FAT_ARROW : '=>';
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 38d4460965fb..6b2237f15cc4 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
@@ -1276,12 +1276,14 @@ tableIdentifier
     ;
 
 temporalTableIdentifier
-    : id=multipartIdentifier AT_VERSION version
+    : id=multipartIdentifier AT_SIGN timestamp=INTEGER_VALUE
+    | id=multipartIdentifier AT_VERSION version
     | id=multipartIdentifier
     ;
 
 temporalTableIdentifierReference
-    : identifierReference AT_VERSION version
+    : identifierReference AT_SIGN timestamp=INTEGER_VALUE
+    | identifierReference AT_VERSION version
     | identifierReference
     ;
 
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 01ba078be729..f73b0457f87f 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,16 @@ private[sql] object QueryParsingErrors extends 
DataTypeErrorsBase {
       ctx)
   }
 
+  def invalidAtSyntaxTimestamp(
+      timestamp: String,
+      format: String,
+      ctx: ParserRuleContext): Throwable = {
+    new ParseException(
+      errorClass = "INVALID_TIME_TRAVEL_TIMESTAMP_FORMAT",
+      messageParameters = Map("timestamp" -> timestamp, "format" -> format),
+      ctx)
+  }
+
   def multipleTimeTravelSpec(ctx: ParserRuleContext): Throwable = {
     new ParseException(
       errorClass = "MULTIPLE_TIME_TRAVEL_SPEC",
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 8389e79331a9..032e032fa1a9 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
@@ -17,6 +17,7 @@
 
 package org.apache.spark.sql.catalyst.parser
 
+import java.time.{DateTimeException, LocalDateTime}
 import java.util.{List, Locale}
 import java.util.concurrent.TimeUnit
 
@@ -45,7 +46,7 @@ import org.apache.spark.sql.catalyst.plans.logical._
 import org.apache.spark.sql.catalyst.trees.{CurrentOrigin, Origin}
 import org.apache.spark.sql.catalyst.trees.TreePattern.PARAMETER
 import org.apache.spark.sql.catalyst.types.DataTypeUtils
-import org.apache.spark.sql.catalyst.util.{CharVarcharUtils, CollationFactory, 
DateTimeUtils, EvaluateUnresolvedInlineTable, IntervalUtils}
+import org.apache.spark.sql.catalyst.util.{CharVarcharUtils, CollationFactory, 
DateTimeConstants, DateTimeUtils, EvaluateUnresolvedInlineTable, IntervalUtils}
 import org.apache.spark.sql.catalyst.util.DateTimeUtils.{convertSpecialDate, 
convertSpecialTimestamp, convertSpecialTimestampNTZ, fractionalSecondsDigits, 
getZoneId, stringToDate, stringToTime, stringToTimestamp, 
stringToTimestampLTZNanos, stringToTimestampNTZNanos, 
stringToTimestampWithoutTimeZone}
 import org.apache.spark.sql.connector.catalog.{CatalogV2Util, 
ChangelogContext, PathElement, SupportsNamespaces, TableCatalog, 
TableWritePrivilege}
 import org.apache.spark.sql.connector.catalog.ChangelogRange.{TimestampRange, 
UnboundedRange, VersionRange}
@@ -2705,7 +2706,7 @@ class AstBuilder extends DataTypeAstBuilder
       relation: LogicalPlan,
       ttCtx: TemporalTableIdentifierReferenceContext,
       clause: TemporalClauseContext): LogicalPlan = {
-    val (atTimestamp, atVersion) = temporalSpec(ttCtx, ttCtx.version)
+    val (atTimestamp, atVersion) = temporalSpec(ttCtx, ttCtx.timestamp, 
ttCtx.version)
     val hasAtSpec = atTimestamp.isDefined || atVersion.isDefined
     if (hasAtSpec && clause != null) {
       withOrigin(clause) {
@@ -2719,24 +2720,57 @@ class AstBuilder extends DataTypeAstBuilder
 
   override def visitTemporalTableIdentifier(
       ctx: TemporalTableIdentifierContext): TemporalIdentifier = 
withOrigin(ctx) {
-    val (_, version) = temporalSpec(ctx, ctx.version)
-    TemporalIdentifier(visitMultipartIdentifier(ctx.id), version)
+    val (timestamp, version) = temporalSpec(ctx, ctx.timestamp, ctx.version)
+    TemporalIdentifier(visitMultipartIdentifier(ctx.id), timestamp, version)
   }
 
   /**
-   * Extract the optional '@v<version>' time travel suffix of a table 
identifier.
+   * Parse the digits of an '@' time travel timestamp (format 
yyyyMMddHHmmssSSS) to
+   * microseconds since epoch in the session time zone.
+   */
+  private def parseAtSyntaxTimestamp(text: String, ctx: ParserRuleContext): 
Long = {
+    val format = TemporalIdentifier.TimestampFormat
+    if (text.length != format.length) {
+      throw QueryParsingErrors.invalidAtSyntaxTimestamp(text, format, ctx)
+    }
+    try {
+      val localDateTime = LocalDateTime.of(
+        text.substring(0, 4).toInt,
+        text.substring(4, 6).toInt,
+        text.substring(6, 8).toInt,
+        text.substring(8, 10).toInt,
+        text.substring(10, 12).toInt,
+        text.substring(12, 14).toInt,
+        text.substring(14, 17).toInt * 
DateTimeConstants.NANOS_PER_MILLIS.toInt)
+      DateTimeUtils.instantToMicros(
+        localDateTime.atZone(getZoneId(conf.sessionLocalTimeZone)).toInstant)
+    } catch {
+      case _: DateTimeException =>
+        throw QueryParsingErrors.invalidAtSyntaxTimestamp(text, format, ctx)
+    }
+  }
+
+  /**
+   * Extract the optional '@' time travel suffix of a table identifier: 
'@<timestamp>'
+   * (format yyyyMMddHHmmssSSS) or '@v<version>'.
    */
   private def temporalSpec(
       ctx: ParserRuleContext,
+      timestampToken: Token,
       versionCtx: VersionContext): (Option[Expression], Option[String]) = {
-    if (versionCtx == null) {
+    if (timestampToken == null && 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))
+      if (timestampToken != null) {
+        val micros = parseAtSyntaxTimestamp(timestampToken.getText, ctx)
+        (Some(Literal(micros, TimestampType)), None)
+      } else {
+        (None, visitVersion(versionCtx))
+      }
     }
   }
 
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 4456303ffef5..4ca6fe3cbd91 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
@@ -76,7 +76,7 @@ trait ParserInterface extends DataTypeParserInterface {
   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)
+    TemporalIdentifier(parseMultipartIdentifier(sqlText), None, None)
   }
 
   /**
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
index 9be5c4016a7c..44bd7020155a 100644
--- 
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
@@ -18,19 +18,27 @@
 package org.apache.spark.sql.catalyst.parser
 
 import org.apache.spark.sql.catalyst.analysis.RelationTimeTravel
+import org.apache.spark.sql.catalyst.expressions.Expression
 import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
 
 /**
- * Result of parsing a table name that may carry an '@v<version>' time travel 
suffix.
+ * Result of parsing a table name that may carry an '@' time travel suffix: 
`name@v<version>`
+ * or `name@<yyyyMMddHHmmssSSS>`. At most one of `timestamp` and `version` is 
set.
  */
 case class TemporalIdentifier(
     nameParts: Seq[String],
+    timestamp: Option[Expression],
     version: Option[String]) {
 
-  def isTemporal: Boolean = version.isDefined
+  def isTemporal: Boolean = timestamp.isDefined || 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
+    if (isTemporal) RelationTimeTravel(plan, timestamp, version) else plan
   }
 }
+
+object TemporalIdentifier {
+  /** The fixed timestamp format accepted in the suffix. */
+  val TimestampFormat: String = "yyyyMMddHHmmssSSS"
+}
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 3315cdc72332..a3f0fd8ab43e 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
@@ -7186,8 +7186,11 @@ object SQLConf {
   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.")
+        "travel suffix: 'name@v123' reads version 123 of the table, and " +
+        "'name@20240101000000000' (format yyyyMMddHHmmssSSS, interpreted in 
the session " +
+        "time zone) reads the table as of that timestamp, to millisecond 
precision (use " +
+        "TIMESTAMP AS OF for finer granularity). When false, '@' in table 
names fails at " +
+        "parse time.")
       .version("4.3.0")
       .withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
       .booleanConf
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 e44ba2194a8e..e9f1a94f7010 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
@@ -26,11 +26,12 @@ import 
org.apache.spark.sql.catalyst.analysis.{AnalysisTest, RelationChanges, Re
 import org.apache.spark.sql.catalyst.expressions._
 import org.apache.spark.sql.catalyst.plans._
 import org.apache.spark.sql.catalyst.plans.logical._
-import org.apache.spark.sql.catalyst.util.{EvaluateUnresolvedInlineTable, 
IntervalUtils}
+import org.apache.spark.sql.catalyst.util.{DateTimeUtils, 
EvaluateUnresolvedInlineTable, IntervalUtils}
 import org.apache.spark.sql.connector.catalog.{ChangelogContext, 
ChangelogRange}
 import org.apache.spark.sql.internal.SQLConf
-import org.apache.spark.sql.types.{Decimal, DecimalType, IntegerType, 
LongType, StringType}
+import org.apache.spark.sql.types.{Decimal, DecimalType, IntegerType, 
LongType, StringType, TimestampType}
 import org.apache.spark.sql.util.CaseInsensitiveStringMap
+import org.apache.spark.unsafe.types.UTF8String
 
 /**
  * Parser test cases for rules defined in [[CatalystSqlParser]] / 
[[AstBuilder]].
@@ -2203,6 +2204,26 @@ class PlanParserSuite extends AnalysisTest {
     assertEqual("SELECT * FROM a.b.c @v123456789", versionPlan("123456789"))
     assertEqual("SELECT * FROM a.b.c@v'Snapshot123456789'", 
versionPlan("Snapshot123456789"))
 
+    val micros = DateTimeUtils.stringToTimestampAnsi(
+      UTF8String.fromString("2019-01-29 00:37:58"),
+      DateTimeUtils.getZoneId(SQLConf.get.sessionLocalTimeZone))
+    assertEqual("SELECT * FROM a.b.c@20190129003758000",
+      Project(Seq(UnresolvedStar(None)),
+        RelationTimeTravel(
+          UnresolvedRelation(Seq("a", "b", "c")),
+          Some(Literal(micros, TimestampType)),
+          None)))
+
+    val microsWithMillis = DateTimeUtils.stringToTimestampAnsi(
+      UTF8String.fromString("2019-01-29 00:37:58.123"),
+      DateTimeUtils.getZoneId(SQLConf.get.sessionLocalTimeZone))
+    assertEqual("SELECT * FROM a.b.c@20190129003758123",
+      Project(Seq(UnresolvedStar(None)),
+        RelationTimeTravel(
+          UnresolvedRelation(Seq("a", "b", "c")),
+          Some(Literal(microsWithMillis, TimestampType)),
+          None)))
+
     assertEqual("SELECT * FROM `t@v1`",
       Project(Seq(UnresolvedStar(None)), UnresolvedRelation(Seq("t@v1"))))
 
@@ -2223,6 +2244,40 @@ class PlanParserSuite extends AnalysisTest {
       condition = "MULTIPLE_TIME_TRAVEL_SPEC",
       parameters = Map.empty,
       context = ExpectedContext(fragment = "TIMESTAMP AS OF '2019-01-29'", 
start = 19, stop = 46))
+    checkError(
+      exception = parseException("SELECT * FROM t@20190129003758000 VERSION AS 
OF 2"),
+      condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+      parameters = Map.empty,
+      context = ExpectedContext(fragment = "VERSION AS OF 2", start = 34, stop 
= 48))
+    checkError(
+      exception = parseException("SELECT * FROM t@20190129003758000 TIMESTAMP 
AS OF '2019-01-29'"),
+      condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+      parameters = Map.empty,
+      context = ExpectedContext(fragment = "TIMESTAMP AS OF '2019-01-29'", 
start = 34, stop = 61))
+
+    checkError(
+      exception = parseException("SELECT * FROM t@123"),
+      condition = "INVALID_TIME_TRAVEL_TIMESTAMP_FORMAT",
+      parameters = Map("timestamp" -> "123", "format" -> "yyyyMMddHHmmssSSS"),
+      context = ExpectedContext(fragment = "t@123", start = 14, stop = 18))
+
+    checkError(
+      exception = parseException("SELECT * FROM t@20191301000000000"),
+      condition = "INVALID_TIME_TRAVEL_TIMESTAMP_FORMAT",
+      parameters = Map("timestamp" -> "20191301000000000", "format" -> 
"yyyyMMddHHmmssSSS"),
+      context = ExpectedContext(fragment = "t@20191301000000000", start = 14, 
stop = 32))
+
+    // Wrong-length timestamps are rejected.
+    checkError(
+      exception = parseException("SELECT * FROM t@20190129003758"),
+      condition = "INVALID_TIME_TRAVEL_TIMESTAMP_FORMAT",
+      parameters = Map("timestamp" -> "20190129003758", "format" -> 
"yyyyMMddHHmmssSSS"),
+      context = ExpectedContext(fragment = "t@20190129003758", start = 14, 
stop = 29))
+    checkError(
+      exception = parseException("SELECT * FROM t@20190129"),
+      condition = "INVALID_TIME_TRAVEL_TIMESTAMP_FORMAT",
+      parameters = Map("timestamp" -> "20190129", "format" -> 
"yyyyMMddHHmmssSSS"),
+      context = ExpectedContext(fragment = "t@20190129", start = 14, stop = 
23))
 
     assert(intercept[ParseException] {
       parsePlan("INSERT INTO t@v1 VALUES (1)")
@@ -2239,11 +2294,16 @@ class PlanParserSuite extends AnalysisTest {
 
   test("parseTemporalTableIdentifier") {
     assert(parseTemporalTableIdentifier("a.b") ===
-      TemporalIdentifier(Seq("a", "b"), None))
+      TemporalIdentifier(Seq("a", "b"), None, None))
     assert(parseTemporalTableIdentifier("a.b@v5") ===
-      TemporalIdentifier(Seq("a", "b"), Some("5")))
+      TemporalIdentifier(Seq("a", "b"), None, Some("5")))
     assert(parseTemporalTableIdentifier("`t@v1`") ===
-      TemporalIdentifier(Seq("t@v1"), None))
+      TemporalIdentifier(Seq("t@v1"), None, None))
+    val micros = DateTimeUtils.stringToTimestampAnsi(
+      UTF8String.fromString("2019-01-29 00:37:58"),
+      DateTimeUtils.getZoneId(SQLConf.get.sessionLocalTimeZone))
+    assert(parseTemporalTableIdentifier("t@20190129003758000") ===
+      TemporalIdentifier(Seq("t"), Some(Literal(micros, TimestampType)), None))
     Seq("a.b@x", "a@foo", "a@", "a@v").foreach { s =>
       intercept[ParseException](parseTemporalTableIdentifier(s))
     }
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 22f7cabc8c18..991b3f2a6c05 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
@@ -160,6 +160,7 @@ class SparkConnectPlannerSuite extends SparkFunSuite with 
SparkConnectPlanTest {
   }
 
   test("Read with at syntax time travel") {
+    // Version time travel
     val read = proto.Read
       .newBuilder()
       
.setNamedTable(proto.Read.NamedTable.newBuilder.setUnparsedIdentifier("name@v1").build())
@@ -173,12 +174,30 @@ class SparkConnectPlannerSuite extends SparkFunSuite with 
SparkConnectPlanTest {
       case other => fail(s"Expected RelationTimeTravel but got: $other")
     }
 
+    // Timestamp time travel
+    val tsRead = read.toBuilder
+      .setNamedTable(
+        
proto.Read.NamedTable.newBuilder.setUnparsedIdentifier("name@20190129003758000").build())
+      .build()
+    transform(proto.Relation.newBuilder.setRead(tsRead).build()) match {
+      case RelationTimeTravel(relation: UnresolvedRelation, timestamp, 
version) =>
+        assert(relation.multipartIdentifier === Seq("name"))
+        assert(timestamp.isDefined)
+        assert(version.isEmpty)
+      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")
+    val streamingTsRead = tsRead.toBuilder.setIsStreaming(true).build()
+    val tsErr = intercept[AnalysisException] {
+      transform(proto.Relation.newBuilder.setRead(streamingTsRead).build())
+    }
+    assert(tsErr.getCondition === "UNSUPPORTED_FEATURE.TIME_TRAVEL")
 
     // A non-time-travel '@' suffix is a parse error.
     val badRead = read.toBuilder
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 7ce2945898c3..4f855e7e453d 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
@@ -4003,6 +4003,37 @@ class DataSourceV2SQLSuiteV1Filter
       }
     }
 
+    val ts1 = DateTimeUtils.stringToTimestampAnsi(
+      UTF8String.fromString("2019-01-29 00:37:58"),
+      DateTimeUtils.getZoneId(SQLConf.get.sessionLocalTimeZone))
+    val t3 = s"testcat.t$ts1"
+    withTable(t3) {
+      sql(s"CREATE TABLE $t3 (id int) USING foo")
+      sql(s"INSERT INTO $t3 VALUES (5), (6)")
+
+      checkAnswer(sql("SELECT * FROM t@20190129003758000"), Seq(Row(5), 
Row(6)))
+      checkAnswer(spark.read.table("t@20190129003758000"), Seq(Row(5), Row(6)))
+
+      checkError(
+        exception = intercept[AnalysisException] {
+          spark.read.option("versionAsOf", 
"1").table("t@20190129003758000").collect()
+        },
+        condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+        parameters = Map.empty)
+      checkError(
+        exception = intercept[AnalysisException] {
+          spark.read.option("timestampAsOf", 
"2019-01-29").table("t@20190129003758000").collect()
+        },
+        condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+        parameters = Map.empty)
+      checkError(
+        exception = intercept[AnalysisException] {
+          sql("SELECT * FROM t@20190129003758000 WITH ('timestampAsOf' = 
'2019-01-29')")
+        },
+        condition = "MULTIPLE_TIME_TRAVEL_SPEC",
+        parameters = Map.empty)
+    }
+
     intercept[ParseException](sql("SELECT * FROM t@foo"))
     intercept[ParseException](spark.read.table("t@foo"))
     withTable("testcat.`weird@v1`") {
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 2ba374e3d674..da41abe76bd9 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
@@ -91,6 +91,12 @@ class DataStreamTableAPISuite extends StreamTest with 
BeforeAndAfter {
       },
       condition = "UNSUPPORTED_FEATURE.TIME_TRAVEL",
       parameters = Map("relationId" -> "`t`"))
+    checkError(
+      exception = intercept[AnalysisException] {
+        spark.readStream.table("t@20190129003758000")
+      },
+      condition = "UNSUPPORTED_FEATURE.TIME_TRAVEL",
+      parameters = Map("relationId" -> "`t`"))
   }
 
   test("read: stream table API with temp view") {


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

Reply via email to