diff --git a/src/ast/types.rs b/src/ast/types.rs index a5234af..8203a85 100644 --- a/src/ast/types.rs +++ b/src/ast/types.rs @@ -636,6 +636,8 @@ pub enum TypedFunction { }, /// `CURRENT_DATE` CurrentDate, + /// `CURRENT_TIME` — returns the current time of day (ANSI / PostgreSQL). + CurrentTime, /// `CURRENT_TIMESTAMP` / `NOW()` / `GETDATE()` CurrentTimestamp, /// `STR_TO_TIME(expr, format)` / `TO_TIMESTAMP` / `PARSE_DATETIME` @@ -885,7 +887,9 @@ impl TypedFunction { end.walk(visitor); } TypedFunction::DateTrunc { expr, .. } => expr.walk(visitor), - TypedFunction::CurrentDate | TypedFunction::CurrentTimestamp => {} + TypedFunction::CurrentDate + | TypedFunction::CurrentTime + | TypedFunction::CurrentTimestamp => {} TypedFunction::StrToTime { expr, format } | TypedFunction::TimeToStr { expr, format } => { expr.walk(visitor); @@ -1147,6 +1151,7 @@ impl TypedFunction { unit, }, TypedFunction::CurrentDate => TypedFunction::CurrentDate, + TypedFunction::CurrentTime => TypedFunction::CurrentTime, TypedFunction::CurrentTimestamp => TypedFunction::CurrentTimestamp, TypedFunction::StrToTime { expr, format } => TypedFunction::StrToTime { expr: Box::new(expr.transform(func)), diff --git a/src/dialects/mod.rs b/src/dialects/mod.rs index 76ca53c..8d91d22 100644 --- a/src/dialects/mod.rs +++ b/src/dialects/mod.rs @@ -261,10 +261,350 @@ fn is_hive_family(d: Dialect) -> bool { } /// Dialects in the T-SQL family. -fn is_tsql_family(d: Dialect) -> bool { +pub(crate) fn is_tsql_family(d: Dialect) -> bool { matches!(d, Dialect::Tsql | Dialect::Fabric) } +/// Returns `true` when `name` (case-insensitive) is a T-SQL reserved keyword +/// that must be quoted with square brackets when used as an identifier +/// (e.g. as a column alias, table alias, or column name). +/// +/// Sourced from Microsoft's documented T-SQL reserved words (current and +/// future). Covers both the ANSI/ODBC reserved set and SQL Server's +/// dialect-specific reservations. Not exhaustive for every contextual +/// keyword — focuses on words that, when emitted unquoted as aliases, will +/// cause MSSQL syntax error 156. +#[must_use] +pub(crate) fn is_tsql_reserved(name: &str) -> bool { + // Reserved word set — keep in sorted order for binary_search. + // Source: docs.microsoft.com "Reserved Keywords (Transact-SQL)" and + // "ODBC Reserved Keywords", plus ANSI/ISO future reserved words. + const RESERVED: &[&str] = &[ + "ABSOLUTE", + "ACTION", + "ADA", + "ADD", + "ALL", + "ALLOCATE", + "ALTER", + "AND", + "ANY", + "ARE", + "AS", + "ASC", + "ASSERTION", + "AT", + "AUTHORIZATION", + "AVG", + "BACKUP", + "BEGIN", + "BETWEEN", + "BIT", + "BIT_LENGTH", + "BOTH", + "BREAK", + "BROWSE", + "BULK", + "BY", + "CASCADE", + "CASCADED", + "CASE", + "CAST", + "CATALOG", + "CHAR", + "CHARACTER", + "CHARACTER_LENGTH", + "CHAR_LENGTH", + "CHECK", + "CHECKPOINT", + "CLOSE", + "CLUSTERED", + "COALESCE", + "COLLATE", + "COLLATION", + "COLUMN", + "COMMIT", + "COMPUTE", + "CONNECT", + "CONNECTION", + "CONSTRAINT", + "CONSTRAINTS", + "CONTAINS", + "CONTAINSTABLE", + "CONTINUE", + "CONVERT", + "CORRESPONDING", + "COUNT", + "CREATE", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "CURSOR", + "DATABASE", + "DATE", + "DBCC", + "DEALLOCATE", + "DEC", + "DECIMAL", + "DECLARE", + "DEFAULT", + "DEFERRABLE", + "DEFERRED", + "DELETE", + "DENY", + "DESC", + "DESCRIBE", + "DESCRIPTOR", + "DIAGNOSTICS", + "DISCONNECT", + "DISK", + "DISTINCT", + "DISTRIBUTED", + "DOMAIN", + "DOUBLE", + "DROP", + "DUMP", + "ELSE", + "END", + "ERRLVL", + "ESCAPE", + "EXCEPT", + "EXCEPTION", + "EXEC", + "EXECUTE", + "EXISTS", + "EXIT", + "EXTERNAL", + "EXTRACT", + "FETCH", + "FILE", + "FILLFACTOR", + "FLOAT", + "FOR", + "FOREIGN", + "FORTRAN", + "FOUND", + "FREETEXT", + "FREETEXTTABLE", + "FROM", + "FULL", + "FUNCTION", + "GET", + "GLOBAL", + "GO", + "GOTO", + "GRANT", + "GROUP", + "HAVING", + "HOLDLOCK", + "HOUR", + "IDENTITY", + "IDENTITYCOL", + "IDENTITY_INSERT", + "IF", + "IMMEDIATE", + "IN", + "INCLUDE", + "INDEX", + "INDICATOR", + "INITIALLY", + "INNER", + "INPUT", + "INSENSITIVE", + "INSERT", + "INT", + "INTEGER", + "INTERSECT", + "INTERVAL", + "INTO", + "IS", + "ISOLATION", + "JOIN", + "KEY", + "KILL", + "LANGUAGE", + "LAST", + "LEADING", + "LEFT", + "LEVEL", + "LIKE", + "LINENO", + "LOAD", + "LOCAL", + "LOWER", + "MATCH", + "MAX", + "MERGE", + "MIN", + "MINUTE", + "MODULE", + "MONTH", + "NAMES", + "NATIONAL", + "NATURAL", + "NCHAR", + "NEXT", + "NO", + "NOCHECK", + "NONCLUSTERED", + "NONE", + "NOT", + "NULL", + "NULLIF", + "NUMERIC", + "OCTET_LENGTH", + "OF", + "OFF", + "OFFSETS", + "ON", + "ONLY", + "OPEN", + "OPENDATASOURCE", + "OPENQUERY", + "OPENROWSET", + "OPENXML", + "OPTION", + "OR", + "ORDER", + "OUTER", + "OUTPUT", + "OVER", + "OVERLAPS", + "PAD", + "PARTIAL", + "PASCAL", + "PERCENT", + "PIVOT", + "PLAN", + "POSITION", + "PRECISION", + "PREPARE", + "PRESERVE", + "PRIMARY", + "PRINT", + "PRIOR", + "PRIVILEGES", + "PROC", + "PROCEDURE", + "PUBLIC", + "RAISERROR", + "READ", + "READTEXT", + "REAL", + "RECONFIGURE", + "REFERENCES", + "RELATIVE", + "REPLICATION", + "RESTORE", + "RESTRICT", + "RETURN", + "REVERT", + "REVOKE", + "RIGHT", + "ROLLBACK", + "ROWCOUNT", + "ROWGUIDCOL", + "ROWS", + "RULE", + "SAVE", + "SCHEMA", + "SCROLL", + "SECOND", + "SECTION", + "SECURITYAUDIT", + "SELECT", + "SEMANTICKEYPHRASETABLE", + "SEMANTICSIMILARITYDETAILSTABLE", + "SEMANTICSIMILARITYTABLE", + "SESSION", + "SESSION_USER", + "SET", + "SETUSER", + "SHUTDOWN", + "SIZE", + "SMALLINT", + "SOME", + "SPACE", + "SQL", + "SQLCA", + "SQLCODE", + "SQLERROR", + "SQLSTATE", + "SQLWARNING", + "STATISTICS", + "SUBSTRING", + "SUM", + "SYSTEM_USER", + "TABLE", + "TABLESAMPLE", + "TEMPORARY", + "TEXTSIZE", + "THEN", + "TIME", + "TIMESTAMP", + "TIMEZONE_HOUR", + "TIMEZONE_MINUTE", + "TO", + "TOP", + "TRAILING", + "TRAN", + "TRANSACTION", + "TRANSLATE", + "TRANSLATION", + "TRIGGER", + "TRIM", + "TRUE", + "TRUNCATE", + "TRY_CONVERT", + "TSEQUAL", + "UNION", + "UNIQUE", + "UNKNOWN", + "UNPIVOT", + "UPDATE", + "UPDATETEXT", + "UPPER", + "USAGE", + "USE", + "USER", + "USING", + "VALUE", + "VALUES", + "VARCHAR", + "VARYING", + "VIEW", + "WAITFOR", + "WHEN", + "WHENEVER", + "WHERE", + "WHILE", + "WITH", + "WITHIN GROUP", + "WORK", + "WRITE", + "WRITETEXT", + "YEAR", + "ZONE", + ]; + + // Cheap upper-case comparison without allocation for ASCII identifiers. + if name.is_empty() || name.len() > 32 { + return false; + } + let mut buf = [0u8; 32]; + for (i, b) in name.as_bytes().iter().enumerate() { + buf[i] = b.to_ascii_uppercase(); + } + let upper = match std::str::from_utf8(&buf[..name.len()]) { + Ok(s) => s, + Err(_) => return false, + }; + RESERVED.binary_search(&upper).is_ok() +} + /// Dialects that natively support ILIKE. pub(crate) fn supports_ilike_builtin(d: Dialect) -> bool { matches!( diff --git a/src/dialects/plugin.rs b/src/dialects/plugin.rs index fadf0ac..2ff8fa6 100644 --- a/src/dialects/plugin.rs +++ b/src/dialects/plugin.rs @@ -294,6 +294,7 @@ fn typed_function_canonical_name(func: &TypedFunction) -> &'static str { TypedFunction::DateTrunc { .. } => "DATE_TRUNC", TypedFunction::DateSub { .. } => "DATE_SUB", TypedFunction::CurrentDate => "CURRENT_DATE", + TypedFunction::CurrentTime => "CURRENT_TIME", TypedFunction::CurrentTimestamp => "NOW", TypedFunction::StrToTime { .. } => "STR_TO_TIME", TypedFunction::TimeToStr { .. } => "TIME_TO_STR", @@ -368,7 +369,9 @@ fn typed_function_canonical_name(func: &TypedFunction) -> &'static str { /// Extract the argument expressions from a TypedFunction (in positional order). fn typed_function_args(func: &TypedFunction) -> Vec { match func { - TypedFunction::CurrentDate | TypedFunction::CurrentTimestamp => vec![], + TypedFunction::CurrentDate + | TypedFunction::CurrentTime + | TypedFunction::CurrentTimestamp => vec![], TypedFunction::RowNumber | TypedFunction::Rank | TypedFunction::DenseRank => vec![], TypedFunction::Length { expr } | TypedFunction::Upper { expr } diff --git a/src/generator/sql_generator.rs b/src/generator/sql_generator.rs index 92341e3..82b9edb 100644 --- a/src/generator/sql_generator.rs +++ b/src/generator/sql_generator.rs @@ -99,6 +99,22 @@ impl Generator { self.write(s); } + /// Emit a column/table alias, automatically wrapping it in the target + /// dialect's canonical quoting style when the alias collides with a + /// reserved keyword for that dialect. Preserves any explicit quoting + /// the source already specified. + fn write_alias(&mut self, alias: &str, style: QuoteStyle) { + let effective = if !style.is_quoted() + && matches!(self.dialect, Some(d) if crate::dialects::is_tsql_family(d)) + && crate::dialects::is_tsql_reserved(alias) + { + QuoteStyle::Bracket + } else { + style + }; + self.write_quoted(alias, effective); + } + /// Write an identifier with the given quoting style. /// If a target dialect is set and the identifier is quoted, the quoting /// is transformed to the target dialect's canonical style. @@ -457,7 +473,7 @@ impl Generator { if let Some(alias) = alias { self.write(" "); self.write_keyword("AS "); - self.write_quoted(alias, *alias_quote_style); + self.write_alias(alias, *alias_quote_style); } } } @@ -479,7 +495,7 @@ impl Generator { if !self.omit_table_alias_as() { self.write_keyword("AS "); } - self.write_quoted(alias, *alias_quote_style); + self.write_alias(alias, *alias_quote_style); } } TableSource::TableFunction { @@ -497,7 +513,7 @@ impl Generator { if !self.omit_table_alias_as() { self.write_keyword("AS "); } - self.write_quoted(alias, *alias_quote_style); + self.write_alias(alias, *alias_quote_style); } } TableSource::Lateral { source } => { @@ -595,7 +611,7 @@ impl Generator { if let Some(alias) = &pv.alias { self.write(" "); self.write_keyword("AS "); - self.write_quoted(alias, pv.alias_quote_style); + self.write_alias(alias, pv.alias_quote_style); } } } @@ -620,7 +636,7 @@ impl Generator { if !self.omit_table_alias_as() { self.write_keyword("AS "); } - self.write_quoted(alias, table.alias_quote_style); + self.write_alias(alias, table.alias_quote_style); } } @@ -2461,6 +2477,15 @@ impl Generator { self.write_keyword("CURRENT_DATE"); } } + TypedFunction::CurrentTime => { + if is_tsql { + self.write_keyword("CAST(GETDATE() AS TIME)"); + } else if is_mysql || is_hive_family { + self.write_keyword("CURRENT_TIME()"); + } else { + self.write_keyword("CURRENT_TIME"); + } + } TypedFunction::CurrentTimestamp => { if is_tsql { self.write_keyword("GETDATE()"); diff --git a/src/optimizer/annotate_types.rs b/src/optimizer/annotate_types.rs index 47200a8..d059a8d 100644 --- a/src/optimizer/annotate_types.rs +++ b/src/optimizer/annotate_types.rs @@ -861,6 +861,7 @@ fn infer_typed_function_type(func: &TypedFunction, ann: &TypeAnnotations) -> Opt | TypedFunction::TsOrDsToDate { .. } => Some(DataType::Date), TypedFunction::DateDiff { .. } => Some(DataType::Int), TypedFunction::CurrentDate => Some(DataType::Date), + TypedFunction::CurrentTime => Some(DataType::Time { precision: None }), TypedFunction::CurrentTimestamp => Some(DataType::Timestamp { precision: None, with_tz: false, diff --git a/src/parser/sql_parser.rs b/src/parser/sql_parser.rs index f0502f8..5c13e21 100644 --- a/src/parser/sql_parser.rs +++ b/src/parser/sql_parser.rs @@ -2897,6 +2897,31 @@ impl Parser { }); } + // ── Bare niladic temporal keywords: CURRENT_TIME, CURRENT_DATE, + // CURRENT_TIMESTAMP, LOCALTIMESTAMP (no parens) ── + // ANSI SQL allows these without parentheses. Materialize them + // as typed functions so the generator can emit dialect-specific + // forms (e.g. TSQL requires CAST(GETDATE() AS TIME) rather than + // a bare CURRENT_TIME reserved word). + if name_qs == QuoteStyle::None && self.peek_type() != &TokenType::LParen { + let upper = name.to_ascii_uppercase(); + let typed = match upper.as_str() { + "CURRENT_DATE" => Some(TypedFunction::CurrentDate), + "CURRENT_TIME" => Some(TypedFunction::CurrentTime), + "CURRENT_TIMESTAMP" | "LOCALTIMESTAMP" => { + Some(TypedFunction::CurrentTimestamp) + } + _ => None, + }; + if let Some(tf) = typed { + return Ok(Expr::TypedFunction { + func: tf, + filter: None, + over: None, + }); + } + } + // Function call: name(...) if self.peek_type() == &TokenType::LParen { self.advance(); @@ -3191,6 +3216,7 @@ impl Parser { } } "CURRENT_DATE" => TypedFunction::CurrentDate, + "CURRENT_TIME" | "CURTIME" => TypedFunction::CurrentTime, "CURRENT_TIMESTAMP" | "NOW" | "GETDATE" | "SYSDATE" => TypedFunction::CurrentTimestamp, "STR_TO_TIME" | "STR_TO_DATE" | "TO_TIMESTAMP" | "PARSE_TIMESTAMP" | "PARSE_DATETIME" => { diff --git a/tests/test_tsql_reserved.rs b/tests/test_tsql_reserved.rs new file mode 100644 index 0000000..d53eb4b --- /dev/null +++ b/tests/test_tsql_reserved.rs @@ -0,0 +1,165 @@ +//! CR-009 regression tests. +//! +//! Covers two related issues when transpiling Postgres → TSQL: +//! +//! 1. Column aliases that collide with T-SQL reserved keywords must be +//! wrapped in square brackets, otherwise MSSQL rejects the query +//! with error 156. +//! 2. The ANSI niladic temporal expressions `CURRENT_TIME`, `CURRENT_DATE` +//! and `CURRENT_TIMESTAMP` (no parentheses) must be translated to +//! TSQL-compatible forms. + +use sqlglot_rust::{Dialect, transpile}; + +// ── Fix 1: reserved-keyword alias quoting ───────────────────────────────── + +#[test] +fn alias_reserved_keyword_quoted_tsql() { + let result = transpile( + "SELECT NOW() AS current_time", + Dialect::Postgres, + Dialect::Tsql, + ) + .unwrap(); + assert!( + result.contains("[current_time]"), + "Reserved keyword alias should be quoted: {result}" + ); + assert!(result.contains("GETDATE()")); +} + +#[test] +fn alias_reserved_keyword_time_quoted_tsql() { + let result = transpile( + "SELECT col AS time FROM t", + Dialect::Postgres, + Dialect::Tsql, + ) + .unwrap(); + assert!( + result.contains("[time]"), + "TIME alias must be bracket-quoted for TSQL: {result}" + ); +} + +#[test] +fn alias_reserved_keyword_user_quoted_tsql() { + let result = transpile( + "SELECT col AS user FROM t", + Dialect::Postgres, + Dialect::Tsql, + ) + .unwrap(); + assert!( + result.contains("[user]"), + "USER alias must be bracket-quoted for TSQL: {result}" + ); +} + +#[test] +fn non_reserved_alias_unquoted_tsql() { + let result = transpile("SELECT 1 AS my_alias", Dialect::Postgres, Dialect::Tsql).unwrap(); + assert!( + !result.contains("[my_alias]"), + "Non-reserved aliases must not be quoted: {result}" + ); + assert!(result.contains("AS my_alias")); +} + +#[test] +fn reserved_alias_not_quoted_for_postgres() { + // Same alias is fine for Postgres → no transformation. + let result = transpile( + "SELECT NOW() AS current_time", + Dialect::Postgres, + Dialect::Postgres, + ) + .unwrap(); + assert!( + !result.contains("[current_time]"), + "Postgres target must not bracket-quote aliases: {result}" + ); +} + +#[test] +fn reserved_table_alias_quoted_tsql() { + let result = transpile( + "SELECT t.x FROM tbl AS time WHERE t.x > 0", + Dialect::Postgres, + Dialect::Tsql, + ) + .unwrap(); + // The alias `time` on the table must be bracketed. + assert!( + result.contains("[time]"), + "Reserved table alias must be bracket-quoted: {result}" + ); +} + +// ── Fix 2: CURRENT_TIME / CURRENT_DATE / CURRENT_TIMESTAMP expressions ─── + +#[test] +fn current_time_expr_to_tsql() { + let result = transpile("SELECT CURRENT_TIME", Dialect::Postgres, Dialect::Tsql).unwrap(); + let upper = result.to_uppercase(); + assert!( + upper.contains("GETDATE") && upper.contains("TIME"), + "CURRENT_TIME should become CAST(GETDATE() AS TIME): {result}" + ); +} + +#[test] +fn current_time_aliased_to_tsql() { + let result = transpile( + "SELECT CURRENT_TIME AS ct", + Dialect::Postgres, + Dialect::Tsql, + ) + .unwrap(); + let upper = result.to_uppercase(); + assert!( + upper.contains("GETDATE"), + "Should translate CURRENT_TIME: {result}" + ); + assert!(upper.contains("AS CT"), "Alias preserved: {result}"); +} + +#[test] +fn current_date_expr_to_tsql() { + let result = transpile("SELECT CURRENT_DATE", Dialect::Postgres, Dialect::Tsql).unwrap(); + let upper = result.to_uppercase(); + assert!( + upper.contains("GETDATE") && upper.contains("DATE"), + "CURRENT_DATE should become CAST(GETDATE() AS DATE): {result}" + ); +} + +#[test] +fn current_timestamp_bare_expr_to_tsql() { + let result = transpile("SELECT CURRENT_TIMESTAMP", Dialect::Postgres, Dialect::Tsql).unwrap(); + let upper = result.to_uppercase(); + assert!( + upper.contains("GETDATE()"), + "Bare CURRENT_TIMESTAMP should become GETDATE(): {result}" + ); +} + +#[test] +fn current_time_preserved_for_postgres() { + let result = transpile("SELECT CURRENT_TIME", Dialect::Postgres, Dialect::Postgres).unwrap(); + let upper = result.to_uppercase(); + assert!( + upper.contains("CURRENT_TIME"), + "Postgres should keep CURRENT_TIME: {result}" + ); +} + +#[test] +fn current_time_mysql_becomes_function_call() { + let result = transpile("SELECT CURRENT_TIME", Dialect::Postgres, Dialect::Mysql).unwrap(); + let upper = result.to_uppercase(); + assert!( + upper.contains("CURRENT_TIME()"), + "MySQL should emit CURRENT_TIME(): {result}" + ); +}