@@ -1956,6 +1956,7 @@ class TimeTravelConfig(NamedTuple):
19561956 timestamp_type : Optional [str ] = None
19571957 stream : Optional [str ] = None
19581958 version : Optional [int ] = None
1959+ version_tag : Optional [str ] = None
19591960
19601961 @staticmethod
19611962 def validate_and_normalize_params (
@@ -1966,6 +1967,7 @@ def validate_and_normalize_params(
19661967 timestamp_type : Optional [Union [str , "TimestampTimeZone" ]] = None ,
19671968 stream : Optional [str ] = None ,
19681969 version : Optional [int ] = None ,
1970+ version_tag : Optional [str ] = None ,
19691971 ) -> Optional ["TimeTravelConfig" ]:
19701972 """
19711973 Validates and normalizes time travel parameters.
@@ -1988,7 +1990,8 @@ def validate_and_normalize_params(
19881990 ValueError: If parameters are invalid.
19891991 """
19901992 time_travel_arg_count = sum (
1991- arg is not None for arg in (statement , offset , timestamp , stream , version )
1993+ arg is not None
1994+ for arg in (statement , offset , timestamp , stream , version , version_tag )
19921995 )
19931996
19941997 # Validate mode
@@ -2023,10 +2026,32 @@ def validate_and_normalize_params(
20232026 f"'version' must be an int Iceberg snapshot id, got { type (version ).__name__ } ."
20242027 )
20252028
2029+ # version_tag (Iceberg tag name, mapped to Snowflake's
2030+ # ``AT(VERSION_TAG => '<name>')`` grammar) only works with 'at' mode —
2031+ # Iceberg tag reads are positional (bound to a specific snapshot),
2032+ # not range-of-time, so ``BEFORE`` has no meaning.
2033+ if version_tag is not None and time_travel_mode .lower () != "at" :
2034+ raise ValueError (
2035+ "Iceberg version_tag time travel can only be used with "
2036+ "time_travel_mode='at', not 'before'."
2037+ )
2038+
2039+ # Validate version_tag type — Iceberg tag names are strings. Empty
2040+ # strings are invalid.
2041+ if version_tag is not None :
2042+ if not isinstance (version_tag , str ):
2043+ raise ValueError (
2044+ f"'version_tag' must be a string Iceberg tag name, "
2045+ f"got { type (version_tag ).__name__ } ."
2046+ )
2047+ if not version_tag :
2048+ raise ValueError ("'version_tag' must be a non-empty Iceberg tag name." )
2049+
20262050 # Validate exactly one parameter is provided
20272051 if time_travel_arg_count != 1 :
20282052 raise ValueError (
2029- "Exactly one of 'statement', 'offset', 'timestamp', 'stream', or 'version' must be provided."
2053+ "Exactly one of 'statement', 'offset', 'timestamp', 'stream', "
2054+ "'version', or 'version_tag' must be provided."
20302055 )
20312056
20322057 # Normalize timestamp
@@ -2061,6 +2086,7 @@ def validate_and_normalize_params(
20612086 timestamp_type = timestamp_type ,
20622087 stream = stream ,
20632088 version = version ,
2089+ version_tag = version_tag ,
20642090 )
20652091
20662092 def generate_sql_clause (self ) -> str :
@@ -2069,19 +2095,41 @@ def generate_sql_clause(self) -> str:
20692095 Args:
20702096 config: Time travel configuration.
20712097 Returns:
2072- SQL clause like " AT (TIMESTAMP => TO_TIMESTAMP_NTZ('...'))" or
2073- " AT (VERSION => 1234567890)" for Iceberg snapshot id time travel.
2098+ SQL clause like " AT (TIMESTAMP => TO_TIMESTAMP_NTZ('...'))",
2099+ " AT (VERSION => 1234567890)" for Iceberg snapshot id time travel,
2100+ or " AT (VERSION_TAG => 'release_v1')" for Iceberg tag time
2101+ travel.
2102+
2103+ Note on escaping: string-valued parameters (``statement``,
2104+ ``stream``, ``version_tag``, ``timestamp``) are embedded inside
2105+ single-quoted SQL literals via the existing ``str_to_sql``
2106+ helper in ``analyzer.datatype_mapper`` so embedded ``'``, ``\\ ``
2107+ and newline characters are properly escaped. This keeps the
2108+ emission consistent with the rest of Snowpark Python's SQL
2109+ generation and avoids both broken SQL and injection surface
2110+ (e.g. ``x'); DROP TABLE foo; --``). Numeric parameters
2111+ (``offset``, ``version``) are not quoted and don't need
2112+ escaping.
2113+
2114+ ``str_to_sql`` is imported lazily here because
2115+ ``analyzer.datatype_mapper`` imports from this module at top
2116+ level — same pattern used elsewhere in this file for analyzer
2117+ cross-references.
20742118 """
2119+ from snowflake .snowpark ._internal .analyzer .datatype_mapper import str_to_sql
2120+
20752121 clause = f" { self .time_travel_mode .upper ()} "
20762122
20772123 if self .statement is not None :
2078- clause += f"(STATEMENT => ' { self .statement } ' )"
2124+ clause += f"(STATEMENT => { str_to_sql ( self .statement ) } )"
20792125 elif self .offset is not None :
20802126 clause += f"(OFFSET => { self .offset } )"
20812127 elif self .stream is not None :
2082- clause += f"(STREAM => ' { self .stream } ' )"
2128+ clause += f"(STREAM => { str_to_sql ( self .stream ) } )"
20832129 elif self .version is not None :
20842130 clause += f"(VERSION => { self .version } )"
2131+ elif self .version_tag is not None :
2132+ clause += f"(VERSION_TAG => { str_to_sql (self .version_tag )} )"
20852133 elif self .timestamp is not None :
20862134 if self .timestamp_type is not None :
20872135 timestamp_type = self .timestamp_type .upper ()
@@ -2093,9 +2141,9 @@ def generate_sql_clause(self) -> str:
20932141 func_name = "TO_TIMESTAMP_TZ"
20942142 else :
20952143 func_name = "TO_TIMESTAMP"
2096- clause += f"(TIMESTAMP => { func_name } (' { self .timestamp } ' ))"
2144+ clause += f"(TIMESTAMP => { func_name } ({ str_to_sql ( self .timestamp ) } ))"
20972145 else :
2098- clause += f"(TIMESTAMP => ' { self .timestamp } ' )"
2146+ clause += f"(TIMESTAMP => { str_to_sql ( self .timestamp ) } )"
20992147
21002148 return clause
21012149
0 commit comments