diff --git a/src/ast/query.rs b/src/ast/query.rs index 296e4e8ca..15d0a31ec 100644 --- a/src/ast/query.rs +++ b/src/ast/query.rs @@ -2630,6 +2630,18 @@ pub enum TableVersion { /// Databricks supports this syntax. /// For example: `SELECT * FROM tbl VERSION AS OF 2` VersionAsOf(Expr), + /// When the table version is defined using `FOR TIMESTAMP AS OF`. + /// Trino reads Iceberg and Delta tables at a point in time this way. + /// For example: `SELECT * FROM tbl FOR TIMESTAMP AS OF TIMESTAMP '2026-01-01 00:00:00 UTC'` + /// + /// See + ForTimestampAsOf(Expr), + /// When the table version is defined using `FOR VERSION AS OF`. + /// Trino accepts a snapshot id or, for Iceberg, a branch or tag name. + /// For example: `SELECT * FROM tbl FOR VERSION AS OF 8954597067493422955` + /// + /// See + ForVersionAsOf(Expr), /// When the table version is defined using a function. /// For example: `SELECT * FROM tbl AT(TIMESTAMP => '2020-08-14 09:30:00')` Function(Expr), @@ -2658,6 +2670,8 @@ impl Display for TableVersion { TableVersion::ForSystemTimeAsOf(e) => write!(f, "FOR SYSTEM_TIME AS OF {e}")?, TableVersion::TimestampAsOf(e) => write!(f, "TIMESTAMP AS OF {e}")?, TableVersion::VersionAsOf(e) => write!(f, "VERSION AS OF {e}")?, + TableVersion::ForTimestampAsOf(e) => write!(f, "FOR TIMESTAMP AS OF {e}")?, + TableVersion::ForVersionAsOf(e) => write!(f, "FOR VERSION AS OF {e}")?, TableVersion::Function(func) => write!(f, "{func}")?, TableVersion::Changes { changes, at, end } => { write!(f, "{changes} {at}")?; diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index ff83a4da6..cd4f8eba2 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -31,6 +31,7 @@ mod snowflake; mod spark; mod sqlite; mod teradata; +mod trino; use core::any::{Any, TypeId}; use core::fmt::Debug; @@ -56,6 +57,7 @@ pub use self::snowflake::SnowflakeDialect; pub use self::spark::SparkSqlDialect; pub use self::sqlite::SQLiteDialect; pub use self::teradata::TeradataDialect; +pub use self::trino::TrinoDialect; /// Macro for streamlining the creation of derived `Dialect` objects. /// The generated struct includes `new()` and `default()` constructors. @@ -1412,6 +1414,17 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect spells table versions with a leading + /// `FOR`: `FOR TIMESTAMP AS OF ` and `FOR VERSION AS OF `, + /// as Trino does for Iceberg and Delta time travel. + /// + /// Only consulted when [`Self::supports_table_versioning`] is true. + /// + /// See + fn supports_for_table_version(&self) -> bool { + false + } + /// Returns true if this dialect supports the E'...' syntax for string literals /// /// Postgres: @@ -1927,6 +1940,7 @@ pub fn dialect_from_str(dialect_name: impl AsRef) -> Option Some(Box::new(SparkSqlDialect {})), "oracle" => Some(Box::new(OracleDialect {})), "teradata" => Some(Box::new(TeradataDialect {})), + "trino" => Some(Box::new(TrinoDialect {})), _ => None, } } @@ -1981,6 +1995,7 @@ mod tests { assert!(parse_dialect("DataBricks").is::()); assert!(parse_dialect("databricks").is::()); assert!(parse_dialect("teradata").is::()); + assert!(parse_dialect("trino").is::()); assert!(parse_dialect("Teradata").is::()); // error cases diff --git a/src/dialect/trino.rs b/src/dialect/trino.rs new file mode 100644 index 000000000..cbb0bb9aa --- /dev/null +++ b/src/dialect/trino.rs @@ -0,0 +1,115 @@ +// 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. + +use crate::dialect::Dialect; + +/// A [`Dialect`] for [Trino](https://trino.io/docs/current/language.html), +/// the distributed SQL engine that descends from Presto. +/// +/// Trino follows the SQL standard closely: identifiers are delimited with +/// double quotes only, aggregates take `FILTER (WHERE ...)`, higher-order +/// functions take `x -> ...` lambdas, and row pattern recognition, table +/// sampling, table versioning (time travel) and parenthesized `EXPLAIN` +/// options are all part of the grammar. +/// +/// See . +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TrinoDialect; + +impl Dialect for TrinoDialect { + /// Trino delimits identifiers with double quotes only; backquotes are a + /// syntax error, the most common mistake of users arriving from + /// MySQL, ClickHouse or Spark. + /// + /// See + fn is_delimited_identifier_start(&self, ch: char) -> bool { + ch == '"' + } + + fn is_identifier_start(&self, ch: char) -> bool { + ch.is_ascii_alphabetic() || ch == '_' + } + + fn is_identifier_part(&self, ch: char) -> bool { + ch.is_ascii_alphanumeric() || ch == '_' + } + + /// See + fn supports_filter_during_aggregation(&self) -> bool { + true + } + + /// `GROUP BY` accepts arbitrary expressions, `GROUPING SETS`, `CUBE` + /// and `ROLLUP`. + /// + /// See + fn supports_group_by_expr(&self) -> bool { + true + } + + /// See + fn supports_lambda_functions(&self) -> bool { + true + } + + /// See + fn supports_match_recognize(&self) -> bool { + true + } + + /// Trino reads Iceberg and Delta tables at a point in time with + /// `FOR TIMESTAMP AS OF` and `FOR VERSION AS OF`. + /// + /// See + fn supports_table_versioning(&self) -> bool { + true + } + + /// See + fn supports_for_table_version(&self) -> bool { + true + } + + /// `EXPLAIN (TYPE IO, FORMAT JSON) ...` and friends. + /// + /// See + fn supports_explain_with_utility_options(&self) -> bool { + true + } + + /// Named arguments to table functions use `=>`, e.g. + /// `TABLE(sequence(start => 1, stop => 10))`. + /// + /// See + fn supports_named_fn_args_with_rarrow_operator(&self) -> bool { + true + } + + /// See + fn supports_comment_on(&self) -> bool { + true + } + + /// `SHOW TABLES FROM schema LIKE '%x%'`: the source comes before the + /// pattern. + /// + /// See + fn supports_show_like_before_in(&self) -> bool { + false + } +} diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e7e5afa07..036a58432 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -17389,6 +17389,22 @@ impl<'a> Parser<'a> { { let expr = self.parse_expr()?; return Ok(Some(TableVersion::ForSystemTimeAsOf(expr))); + } else if self.dialect.supports_for_table_version() + && self.parse_keywords(&[ + Keyword::FOR, + Keyword::TIMESTAMP, + Keyword::AS, + Keyword::OF, + ]) + { + let expr = self.parse_expr()?; + return Ok(Some(TableVersion::ForTimestampAsOf(expr))); + } else if self.dialect.supports_for_table_version() + && self.parse_keywords(&[Keyword::FOR, Keyword::VERSION, Keyword::AS, Keyword::OF]) + { + // A snapshot id, or a branch/tag name on Iceberg. + let expr = self.parse_expr()?; + return Ok(Some(TableVersion::ForVersionAsOf(expr))); } else if self.peek_keyword(Keyword::CHANGES) { return self.parse_table_version_changes().map(Some); } else if self.peek_keyword(Keyword::AT) || self.peek_keyword(Keyword::BEFORE) { diff --git a/tests/sqlparser_trino.rs b/tests/sqlparser_trino.rs new file mode 100644 index 000000000..47ed038f0 --- /dev/null +++ b/tests/sqlparser_trino.rs @@ -0,0 +1,260 @@ +// 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. + +#![warn(clippy::all)] +//! Test SQL syntax specific to Trino. + +use sqlparser::ast::*; +use sqlparser::dialect::{DatabricksDialect, TrinoDialect}; +use sqlparser::parser::{Parser, ParserError}; +use test_utils::*; + +#[macro_use] +mod test_utils; + +fn trino() -> TestedDialects { + TestedDialects::new(vec![Box::new(TrinoDialect {})]) +} + +// -------------------------------- +// Identifiers +// -------------------------------- + +#[test] +fn double_quoted_identifiers() { + let select = trino().verified_only_select(r#"SELECT "order_id" FROM iceberg."demo"."orders""#); + match &select.projection[0] { + SelectItem::UnnamedExpr(Expr::Identifier(ident)) => { + assert_eq!(ident.value, "order_id"); + assert_eq!(ident.quote_style, Some('"')); + } + other => panic!("expected a quoted identifier, got {other:?}"), + } +} + +#[test] +fn backquoted_identifiers_are_rejected() { + // Trino has no backquote quoting; the parser must not accept it either, + // or a MySQL/ClickHouse habit would be reported far from its cause. + let err = trino() + .parse_sql_statements("SELECT * FROM `demo`.`orders`") + .unwrap_err(); + assert!(matches!( + err, + ParserError::ParserError(_) | ParserError::TokenizerError(_) + )); +} + +#[test] +fn hidden_columns_are_quoted_identifiers() { + trino().verified_stmt(r#"SELECT "$path" FROM iceberg.demo.orders"#); +} + +// -------------------------------- +// Aggregates, grouping and lambdas +// -------------------------------- + +#[test] +fn filter_during_aggregation() { + trino().verified_stmt("SELECT count(*) FILTER (WHERE status = 'DELIVERED') FROM orders"); +} + +#[test] +fn approx_percentile_and_array_agg_with_order() { + trino().verified_stmt("SELECT approx_percentile(total, 0.5) FROM orders"); + trino().verified_stmt("SELECT array_agg(status ORDER BY status) FROM orders"); +} + +#[test] +fn grouping_sets_cube_rollup() { + trino().verified_stmt( + "SELECT status, region, count(*) FROM orders GROUP BY GROUPING SETS ((status), (region), ())", + ); + trino().verified_stmt("SELECT status, count(*) FROM orders GROUP BY ROLLUP (status)"); + trino().verified_stmt("SELECT status, count(*) FROM orders GROUP BY CUBE (status, region)"); +} + +#[test] +fn lambda_functions() { + trino().verified_stmt("SELECT filter(ARRAY[1, 2, 3], x -> x > 1)"); + trino().verified_stmt("SELECT transform(ARRAY[1, 2], x -> x * 2)"); + trino().verified_stmt("SELECT reduce(ARRAY[1, 2, 3], 0, (s, x) -> s + x, s -> s)"); +} + +// -------------------------------- +// Relations +// -------------------------------- + +#[test] +fn unnest_with_ordinality() { + trino().verified_stmt("SELECT t.x, t.i FROM UNNEST(ARRAY[10, 20]) WITH ORDINALITY AS t (x, i)"); +} + +#[test] +fn cross_join_unnest() { + trino().verified_stmt("SELECT o.id, e FROM orders AS o CROSS JOIN UNNEST(o.items) AS t (e)"); +} + +#[test] +fn values_with_column_aliases() { + trino().verified_stmt("SELECT * FROM (VALUES (1, 'a'), (2, 'b')) AS t (id, name)"); +} + +#[test] +fn tablesample() { + trino().verified_stmt("SELECT count(*) FROM orders TABLESAMPLE BERNOULLI (50)"); + trino().verified_stmt("SELECT count(*) FROM orders AS o TABLESAMPLE SYSTEM (10)"); +} + +#[test] +fn table_functions_with_named_arguments() { + trino().verified_stmt("SELECT * FROM TABLE(sequence(start => 1, stop => 10))"); +} + +// -------------------------------- +// Expressions and literals +// -------------------------------- + +#[test] +fn typed_literals() { + trino().verified_stmt("SELECT DATE '2026-01-01'"); + trino().verified_stmt("SELECT TIMESTAMP '2026-01-01 00:00:00 UTC'"); + trino().verified_stmt("SELECT DECIMAL '1.5'"); +} + +#[test] +fn interval_literal() { + trino().verified_stmt( + "SELECT count(*) FROM orders WHERE order_date > current_date - INTERVAL '365' DAY", + ); +} + +#[test] +fn cast_and_try_cast() { + trino().verified_stmt("SELECT CAST(order_id AS VARCHAR) FROM orders"); + trino().verified_stmt("SELECT TRY_CAST(order_id AS VARCHAR) FROM orders"); +} + +#[test] +fn at_time_zone() { + trino().verified_stmt("SELECT created_at AT TIME ZONE 'UTC' FROM orders"); +} + +#[test] +fn is_distinct_from() { + trino().verified_stmt("SELECT count(*) FROM orders WHERE status IS DISTINCT FROM 'x'"); +} + +#[test] +fn json_path_functions() { + trino().verified_stmt(r#"SELECT JSON_VALUE('{"a":1}', 'lax $.a')"#); + trino().verified_stmt(r#"SELECT JSON_QUERY('{"a":[1,2]}', 'lax $.a')"#); +} + +#[test] +fn listagg_within_group() { + trino().verified_stmt("SELECT LISTAGG(status, ',') WITHIN GROUP (ORDER BY status) FROM orders"); +} + +#[test] +fn map_subscript() { + trino().verified_stmt("SELECT MAP(ARRAY['k'], ARRAY['v'])['k']"); +} + +#[test] +fn like_with_escape() { + trino().verified_stmt(r"SELECT count(*) FROM orders WHERE status LIKE 'a\_%' ESCAPE '\'"); +} + +// -------------------------------- +// Query shape +// -------------------------------- + +#[test] +fn fetch_first_rows_only() { + trino().verified_stmt("SELECT order_id FROM orders ORDER BY order_id FETCH FIRST 2 ROWS ONLY"); +} + +#[test] +fn with_cte() { + trino().verified_stmt( + "WITH x AS (SELECT status FROM orders) SELECT status, count(*) FROM x GROUP BY status", + ); +} + +#[test] +fn match_recognize() { + trino().verified_stmt( + "SELECT * FROM orders MATCH_RECOGNIZE(PARTITION BY customer_id ORDER BY order_date MEASURES A.order_date AS start_date ONE ROW PER MATCH PATTERN (A B+) DEFINE B AS B.total > PREV(B.total))", + ); +} + +// -------------------------------- +// Statements around queries +// -------------------------------- + +#[test] +fn explain_with_options() { + trino().verified_stmt("EXPLAIN (TYPE IO, FORMAT JSON) SELECT * FROM orders"); + trino().verified_stmt("EXPLAIN (TYPE VALIDATE) SELECT * FROM orders"); +} + +#[test] +fn show_and_describe() { + trino().verified_stmt("SHOW SCHEMAS FROM iceberg"); + trino().verified_stmt("SHOW TABLES FROM demo"); + trino().verified_stmt("SHOW COLUMNS FROM demo.orders"); + trino().verified_stmt("DESCRIBE demo.orders"); +} + +#[test] +fn comment_on() { + trino().verified_stmt("COMMENT ON TABLE demo.orders IS 'orders'"); + trino().verified_stmt("COMMENT ON COLUMN demo.orders.status IS 'lifecycle'"); +} + +// -------------------------------- +// Time travel +// -------------------------------- + +#[test] +fn for_timestamp_and_version_as_of() { + // Trino spells time travel with a leading FOR (Iceberg, Delta). + let select = trino().verified_only_select( + "SELECT 1 FROM t1 FOR TIMESTAMP AS OF TIMESTAMP '2026-01-01 00:00:00 UTC'", + ); + match &select.from[0].relation { + TableFactor::Table { version, .. } => { + assert!(matches!(version, Some(TableVersion::ForTimestampAsOf(_)))) + } + other => panic!("expected a table, got {other:?}"), + } + trino().verified_only_select("SELECT 1 FROM t1 FOR VERSION AS OF 8954597067493422955"); + let select = trino().verified_only_select("SELECT 1 FROM t1 FOR VERSION AS OF 'my-branch'"); + match &select.from[0].relation { + TableFactor::Table { version, .. } => { + assert!(matches!(version, Some(TableVersion::ForVersionAsOf(_)))) + } + other => panic!("expected a table, got {other:?}"), + } + // A dialect that versions tables without FOR keeps rejecting it. + assert!(Parser::parse_sql( + &DatabricksDialect {}, + "SELECT 1 FROM t1 FOR VERSION AS OF 1" + ) + .is_err()); +}