From 3b5cdce9ce045e9aafbadbe682af296a93219e70 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Sat, 5 Jul 2025 15:28:20 -0700 Subject: [PATCH 01/12] DuckDB, Postgres, SQLite: NOT NULL and NOTNULL expressions --- src/ast/mod.rs | 12 ++++ src/ast/spans.rs | 1 + src/dialect/duckdb.rs | 8 +++ src/dialect/mod.rs | 16 +++++ src/dialect/postgresql.rs | 4 ++ src/dialect/sqlite.rs | 8 +++ src/keywords.rs | 1 + src/parser/mod.rs | 10 +++ tests/sqlparser_common.rs | 132 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 192 insertions(+) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 75e88f8a8..9b9b82590 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -778,6 +778,12 @@ pub enum Expr { IsNull(Box), /// `IS NOT NULL` operator IsNotNull(Box), + /// `NOTNULL` or `NOT NULL` operator + NotNull { + expr: Box, + /// true if `NOTNULL`, false if `NOT NULL` + one_word: bool, + }, /// `IS UNKNOWN` operator IsUnknown(Box), /// `IS NOT UNKNOWN` operator @@ -1452,6 +1458,12 @@ impl fmt::Display for Expr { Expr::IsNotFalse(ast) => write!(f, "{ast} IS NOT FALSE"), Expr::IsNull(ast) => write!(f, "{ast} IS NULL"), Expr::IsNotNull(ast) => write!(f, "{ast} IS NOT NULL"), + Expr::NotNull { expr, one_word } => write!( + f, + "{} {}", + expr, + if *one_word { "NOTNULL" } else { "NOT NULL" } + ), Expr::IsUnknown(ast) => write!(f, "{ast} IS UNKNOWN"), Expr::IsNotUnknown(ast) => write!(f, "{ast} IS NOT UNKNOWN"), Expr::InList { diff --git a/src/ast/spans.rs b/src/ast/spans.rs index a1b2e4e0d..bf160f515 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1437,6 +1437,7 @@ impl Spanned for Expr { Expr::IsNotTrue(expr) => expr.span(), Expr::IsNull(expr) => expr.span(), Expr::IsNotNull(expr) => expr.span(), + Expr::NotNull { expr, .. } => expr.span(), Expr::IsUnknown(expr) => expr.span(), Expr::IsNotUnknown(expr) => expr.span(), Expr::IsDistinctFrom(lhs, rhs) => lhs.span().union(&rhs.span()), diff --git a/src/dialect/duckdb.rs b/src/dialect/duckdb.rs index 3366c6705..15b914a42 100644 --- a/src/dialect/duckdb.rs +++ b/src/dialect/duckdb.rs @@ -94,4 +94,12 @@ impl Dialect for DuckDbDialect { fn supports_order_by_all(&self) -> bool { true } + + fn supports_not_null(&self) -> bool { + true + } + + fn supports_notnull(&self) -> bool { + true + } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 8f9dd617f..44e94e0b3 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -650,8 +650,14 @@ pub trait Dialect: Debug + Any { Token::Word(w) if w.keyword == Keyword::MATCH => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::SIMILAR => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::MEMBER => Ok(p!(Like)), + Token::Word(w) if w.keyword == Keyword::NULL && self.supports_not_null() => { + Ok(p!(Is)) + } _ => Ok(self.prec_unknown()), }, + Token::Word(w) if w.keyword == Keyword::NOTNULL && self.supports_notnull() => { + Ok(p!(Is)) + } Token::Word(w) if w.keyword == Keyword::IS => Ok(p!(Is)), Token::Word(w) if w.keyword == Keyword::IN => Ok(p!(Between)), Token::Word(w) if w.keyword == Keyword::BETWEEN => Ok(p!(Between)), @@ -1089,6 +1095,16 @@ pub trait Dialect: Debug + Any { ) -> bool { false } + + /// Returns true if the dialect supports `NOTNULL` in expressions. + fn supports_notnull(&self) -> bool { + false + } + + /// Returns true if the dialect supports `NOT NULL` in expressions. + fn supports_not_null(&self) -> bool { + false + } } /// This represents the operators for which precedence must be defined diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index b2d4014cb..ba9de5848 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -262,4 +262,8 @@ impl Dialect for PostgreSqlDialect { fn supports_alter_column_type_using(&self) -> bool { true } + + fn supports_notnull(&self) -> bool { + true + } } diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs index 847e0d135..6db342891 100644 --- a/src/dialect/sqlite.rs +++ b/src/dialect/sqlite.rs @@ -110,4 +110,12 @@ impl Dialect for SQLiteDialect { fn supports_dollar_placeholder(&self) -> bool { true } + + fn supports_not_null(&self) -> bool { + true + } + + fn supports_notnull(&self) -> bool { + true + } } diff --git a/src/keywords.rs b/src/keywords.rs index 738651504..028f0f46d 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -608,6 +608,7 @@ define_keywords!( NOT, NOTHING, NOTIFY, + NOTNULL, NOWAIT, NO_WRITE_TO_BINLOG, NTH_VALUE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b00cd16d7..35de3b4a3 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3562,6 +3562,7 @@ impl<'a> Parser<'a> { let negated = self.parse_keyword(Keyword::NOT); let regexp = self.parse_keyword(Keyword::REGEXP); let rlike = self.parse_keyword(Keyword::RLIKE); + let null = self.parse_keyword(Keyword::NULL); if regexp || rlike { Ok(Expr::RLike { negated, @@ -3571,6 +3572,11 @@ impl<'a> Parser<'a> { ), regexp, }) + } else if dialect.supports_not_null() && negated && null { + Ok(Expr::NotNull { + expr: Box::new(expr), + one_word: false, + }) } else if self.parse_keyword(Keyword::IN) { self.parse_in(expr, negated) } else if self.parse_keyword(Keyword::BETWEEN) { @@ -3608,6 +3614,10 @@ impl<'a> Parser<'a> { self.expected("IN or BETWEEN after NOT", self.peek_token()) } } + Keyword::NOTNULL if dialect.supports_notnull() => Ok(Expr::NotNull { + expr: Box::new(expr), + one_word: true, + }), Keyword::MEMBER => { if self.parse_keyword(Keyword::OF) { self.expect_token(&Token::LParen)?; diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index a00405aaf..14fa02775 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -15988,3 +15988,135 @@ fn parse_create_procedure_with_parameter_modes() { _ => unreachable!(), } } + +#[test] +fn parse_not_null_unsupported() { + // Only DuckDB and SQLite support `x NOT NULL` as an expression + // All other dialects fail to parse. + let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOT NULL FROM t"#; + let dialects = all_dialects_except(|d| d.supports_not_null()); + let res = dialects.parse_sql_statements(sql); + assert_eq!( + ParserError::ParserError("Expected: end of statement, found: NULL".to_string()), + res.unwrap_err() + ); +} + +#[test] +fn parse_not_null_supported() { + // DuckDB and SQLite support `x NOT NULL` as an expression + let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOT NULL FROM t"#; + let dialects = all_dialects_where(|d| d.supports_not_null()); + let stmt = dialects.one_statement_parses_to(sql, sql); + match stmt { + Statement::Query(qry) => match *qry.body { + SetExpr::Select(select) => { + assert_eq!(select.projection.len(), 1); + match select.projection.first().unwrap() { + UnnamedExpr(expr) => { + let fake_span = Span { + start: Location { line: 0, column: 0 }, + end: Location { line: 0, column: 0 }, + }; + assert_eq!( + *expr, + Expr::NotNull { + expr: Box::new(Identifier(Ident { + value: "x".to_string(), + quote_style: None, + span: fake_span, + })), + one_word: false, + }, + ); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + }, + _ => unreachable!(), + } +} + +#[test] +fn parse_notnull_unsupported() { + // Only Postgres, DuckDB, and SQLite support `x NOTNULL` as an expression + // All other dialects consider `x NOTNULL` like `x AS NOTNULL` and thus + // consider `NOTNULL` an alias for x. + let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOTNULL FROM t"#; + let canonical = r#"WITH t AS (SELECT NULL AS x) SELECT x AS NOTNULL FROM t"#; + let dialects = all_dialects_except(|d| d.supports_notnull()); + let stmt = dialects.one_statement_parses_to(sql, canonical); + match stmt { + Statement::Query(qry) => match *qry.body { + SetExpr::Select(select) => { + assert_eq!(select.projection.len(), 1); + match select.projection.first().unwrap() { + SelectItem::ExprWithAlias { expr, alias } => { + let fake_span = Span { + start: Location { line: 0, column: 0 }, + end: Location { line: 0, column: 0 }, + }; + assert_eq!( + *expr, + Identifier(Ident { + value: "x".to_string(), + quote_style: None, + span: fake_span, + }) + ); + assert_eq!( + *alias, + Ident { + value: "NOTNULL".to_string(), + quote_style: None, + span: fake_span, + } + ); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + }, + _ => unreachable!(), + } +} + +#[test] +fn parse_notnull_supported() { + // DuckDB and SQLite support `x NOT NULL` as an expression + let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOTNULL FROM t"#; + let dialects = all_dialects_where(|d| d.supports_notnull()); + let stmt = dialects.one_statement_parses_to(sql, ""); + match stmt { + Statement::Query(qry) => match *qry.body { + SetExpr::Select(select) => { + assert_eq!(select.projection.len(), 1); + match select.projection.first().unwrap() { + UnnamedExpr(expr) => { + let fake_span = Span { + start: Location { line: 0, column: 0 }, + end: Location { line: 0, column: 0 }, + }; + assert_eq!( + *expr, + Expr::NotNull { + expr: Box::new(Identifier(Ident { + value: "x".to_string(), + quote_style: None, + span: fake_span, + })), + one_word: true, + }, + ); + } + _ => unreachable!(), + } + } + _ => unreachable!(), + }, + _ => unreachable!(), + } +} From fe516808ddaec63516a363c24ddac04364c2b874 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Mon, 7 Jul 2025 13:51:17 -0700 Subject: [PATCH 02/12] fixup: change to supports_is_not_null_alias(IsNotNullAlias) --- src/ast/mod.rs | 8 ++++---- src/dialect/duckdb.rs | 15 ++++++++------- src/dialect/mod.rs | 32 ++++++++++++++++++++++---------- src/dialect/postgresql.rs | 11 ++++++++--- src/dialect/sqlite.rs | 15 ++++++++------- src/parser/mod.rs | 15 +++++++++------ tests/sqlparser_common.rs | 18 ++++++++++-------- 7 files changed, 69 insertions(+), 45 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 9b9b82590..32e6ce312 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -781,8 +781,8 @@ pub enum Expr { /// `NOTNULL` or `NOT NULL` operator NotNull { expr: Box, - /// true if `NOTNULL`, false if `NOT NULL` - one_word: bool, + /// true if `NOT NULL`, false if `NOTNULL` + with_space: bool, }, /// `IS UNKNOWN` operator IsUnknown(Box), @@ -1458,11 +1458,11 @@ impl fmt::Display for Expr { Expr::IsNotFalse(ast) => write!(f, "{ast} IS NOT FALSE"), Expr::IsNull(ast) => write!(f, "{ast} IS NULL"), Expr::IsNotNull(ast) => write!(f, "{ast} IS NOT NULL"), - Expr::NotNull { expr, one_word } => write!( + Expr::NotNull { expr, with_space } => write!( f, "{} {}", expr, - if *one_word { "NOTNULL" } else { "NOT NULL" } + if *with_space { "NOT NULL" } else { "NOTNULL" } ), Expr::IsUnknown(ast) => write!(f, "{ast} IS UNKNOWN"), Expr::IsNotUnknown(ast) => write!(f, "{ast} IS NOT UNKNOWN"), diff --git a/src/dialect/duckdb.rs b/src/dialect/duckdb.rs index 15b914a42..ea069bc67 100644 --- a/src/dialect/duckdb.rs +++ b/src/dialect/duckdb.rs @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -use crate::dialect::Dialect; +use crate::dialect::{Dialect, IsNotNullAlias}; /// A [`Dialect`] for [DuckDB](https://duckdb.org/) #[derive(Debug, Default)] @@ -95,11 +95,12 @@ impl Dialect for DuckDbDialect { true } - fn supports_not_null(&self) -> bool { - true - } - - fn supports_notnull(&self) -> bool { - true + /// DuckDB supports `NOT NULL` and `NOTNULL` as aliases + /// for `IS NOT NULL`, see DuckDB Comparisons + fn supports_is_not_null_alias(&self, alias: IsNotNullAlias) -> bool { + match alias { + IsNotNullAlias::NotNull => true, + IsNotNullAlias::NotSpaceNull => true, + } } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 44e94e0b3..23cd7fe04 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -55,6 +55,7 @@ use crate::keywords::Keyword; use crate::parser::{Parser, ParserError}; use crate::tokenizer::Token; +use crate::dialect::IsNotNullAlias::{NotNull, NotSpaceNull}; #[cfg(not(feature = "std"))] use alloc::boxed::Box; @@ -650,12 +651,17 @@ pub trait Dialect: Debug + Any { Token::Word(w) if w.keyword == Keyword::MATCH => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::SIMILAR => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::MEMBER => Ok(p!(Like)), - Token::Word(w) if w.keyword == Keyword::NULL && self.supports_not_null() => { + Token::Word(w) + if w.keyword == Keyword::NULL + && self.supports_is_not_null_alias(NotSpaceNull) => + { Ok(p!(Is)) } _ => Ok(self.prec_unknown()), }, - Token::Word(w) if w.keyword == Keyword::NOTNULL && self.supports_notnull() => { + Token::Word(w) + if w.keyword == Keyword::NOTNULL && self.supports_is_not_null_alias(NotNull) => + { Ok(p!(Is)) } Token::Word(w) if w.keyword == Keyword::IS => Ok(p!(Is)), @@ -1096,14 +1102,13 @@ pub trait Dialect: Debug + Any { false } - /// Returns true if the dialect supports `NOTNULL` in expressions. - fn supports_notnull(&self) -> bool { - false - } - - /// Returns true if the dialect supports `NOT NULL` in expressions. - fn supports_not_null(&self) -> bool { - false + /// Returns true if the dialect supports the passed in alias. + /// See [IsNotNullAlias]. + fn supports_is_not_null_alias(&self, alias: IsNotNullAlias) -> bool { + match alias { + NotNull => false, + NotSpaceNull => false, + } } } @@ -1131,6 +1136,13 @@ pub enum Precedence { Or, } +/// Possible aliases for `IS NOT NULL` supported +/// by some non-standard dialects. +pub enum IsNotNullAlias { + NotNull, + NotSpaceNull, +} + impl dyn Dialect { #[inline] pub fn is(&self) -> bool { diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index ba9de5848..b3d2c0e2b 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -28,7 +28,7 @@ // limitations under the License. use log::debug; -use crate::dialect::{Dialect, Precedence}; +use crate::dialect::{Dialect, IsNotNullAlias, Precedence}; use crate::keywords::Keyword; use crate::parser::{Parser, ParserError}; use crate::tokenizer::Token; @@ -263,7 +263,12 @@ impl Dialect for PostgreSqlDialect { true } - fn supports_notnull(&self) -> bool { - true + /// Postgres supports `NOTNULL` as an alias for `IS NOT NULL` + /// but does not support `NOT NULL`. See: + fn supports_is_not_null_alias(&self, alias: IsNotNullAlias) -> bool { + match alias { + IsNotNullAlias::NotNull => true, + IsNotNullAlias::NotSpaceNull => false, + } } } diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs index 6db342891..b03f7be02 100644 --- a/src/dialect/sqlite.rs +++ b/src/dialect/sqlite.rs @@ -20,7 +20,7 @@ use alloc::boxed::Box; use crate::ast::BinaryOperator; use crate::ast::{Expr, Statement}; -use crate::dialect::Dialect; +use crate::dialect::{Dialect, IsNotNullAlias}; use crate::keywords::Keyword; use crate::parser::{Parser, ParserError}; @@ -111,11 +111,12 @@ impl Dialect for SQLiteDialect { true } - fn supports_not_null(&self) -> bool { - true - } - - fn supports_notnull(&self) -> bool { - true + /// SQLite supports ``NOT NULL` and `NOTNULL` as + /// aliases for `IS NOT NULL`, see: + fn supports_is_not_null_alias(&self, alias: IsNotNullAlias) -> bool { + match alias { + IsNotNullAlias::NotNull => true, + IsNotNullAlias::NotSpaceNull => true, + } } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 35de3b4a3..8bbaefbdb 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -35,6 +35,7 @@ use IsOptional::*; use crate::ast::helpers::stmt_create_table::{CreateTableBuilder, CreateTableConfiguration}; use crate::ast::Statement::CreatePolicy; use crate::ast::*; +use crate::dialect::IsNotNullAlias::{NotNull, NotSpaceNull}; use crate::dialect::*; use crate::keywords::{Keyword, ALL_KEYWORDS}; use crate::tokenizer::*; @@ -3572,10 +3573,10 @@ impl<'a> Parser<'a> { ), regexp, }) - } else if dialect.supports_not_null() && negated && null { + } else if dialect.supports_is_not_null_alias(NotSpaceNull) && negated && null { Ok(Expr::NotNull { expr: Box::new(expr), - one_word: false, + with_space: true, }) } else if self.parse_keyword(Keyword::IN) { self.parse_in(expr, negated) @@ -3614,10 +3615,12 @@ impl<'a> Parser<'a> { self.expected("IN or BETWEEN after NOT", self.peek_token()) } } - Keyword::NOTNULL if dialect.supports_notnull() => Ok(Expr::NotNull { - expr: Box::new(expr), - one_word: true, - }), + Keyword::NOTNULL if dialect.supports_is_not_null_alias(NotNull) => { + Ok(Expr::NotNull { + expr: Box::new(expr), + with_space: false, + }) + } Keyword::MEMBER => { if self.parse_keyword(Keyword::OF) { self.expect_token(&Token::LParen)?; diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 14fa02775..9fb1cf2d9 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -32,8 +32,8 @@ use sqlparser::ast::TableFactor::{Pivot, Unpivot}; use sqlparser::ast::*; use sqlparser::dialect::{ AnsiDialect, BigQueryDialect, ClickHouseDialect, DatabricksDialect, Dialect, DuckDbDialect, - GenericDialect, HiveDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, RedshiftSqlDialect, - SQLiteDialect, SnowflakeDialect, + GenericDialect, HiveDialect, IsNotNullAlias, MsSqlDialect, MySqlDialect, PostgreSqlDialect, + RedshiftSqlDialect, SQLiteDialect, SnowflakeDialect, }; use sqlparser::keywords::{Keyword, ALL_KEYWORDS}; use sqlparser::parser::{Parser, ParserError, ParserOptions}; @@ -15994,7 +15994,8 @@ fn parse_not_null_unsupported() { // Only DuckDB and SQLite support `x NOT NULL` as an expression // All other dialects fail to parse. let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOT NULL FROM t"#; - let dialects = all_dialects_except(|d| d.supports_not_null()); + let dialects = + all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); let res = dialects.parse_sql_statements(sql); assert_eq!( ParserError::ParserError("Expected: end of statement, found: NULL".to_string()), @@ -16006,7 +16007,8 @@ fn parse_not_null_unsupported() { fn parse_not_null_supported() { // DuckDB and SQLite support `x NOT NULL` as an expression let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOT NULL FROM t"#; - let dialects = all_dialects_where(|d| d.supports_not_null()); + let dialects = + all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); let stmt = dialects.one_statement_parses_to(sql, sql); match stmt { Statement::Query(qry) => match *qry.body { @@ -16026,7 +16028,7 @@ fn parse_not_null_supported() { quote_style: None, span: fake_span, })), - one_word: false, + with_space: true, }, ); } @@ -16046,7 +16048,7 @@ fn parse_notnull_unsupported() { // consider `NOTNULL` an alias for x. let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOTNULL FROM t"#; let canonical = r#"WITH t AS (SELECT NULL AS x) SELECT x AS NOTNULL FROM t"#; - let dialects = all_dialects_except(|d| d.supports_notnull()); + let dialects = all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); let stmt = dialects.one_statement_parses_to(sql, canonical); match stmt { Statement::Query(qry) => match *qry.body { @@ -16088,7 +16090,7 @@ fn parse_notnull_unsupported() { fn parse_notnull_supported() { // DuckDB and SQLite support `x NOT NULL` as an expression let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOTNULL FROM t"#; - let dialects = all_dialects_where(|d| d.supports_notnull()); + let dialects = all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); let stmt = dialects.one_statement_parses_to(sql, ""); match stmt { Statement::Query(qry) => match *qry.body { @@ -16108,7 +16110,7 @@ fn parse_notnull_supported() { quote_style: None, span: fake_span, })), - one_word: true, + with_space: false, }, ); } From ab5c571a4b373826e8b366a4d3644cfc677f56de Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Mon, 7 Jul 2025 18:27:28 -0700 Subject: [PATCH 03/12] fixup: make `NOTNULL`/`NOT NULL` an alias for Expr::IsNotNull --- src/ast/mod.rs | 12 ------------ src/ast/spans.rs | 1 - src/parser/mod.rs | 10 ++-------- tests/sqlparser_common.rs | 36 ++++++++++++++++-------------------- 4 files changed, 18 insertions(+), 41 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 32e6ce312..75e88f8a8 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -778,12 +778,6 @@ pub enum Expr { IsNull(Box), /// `IS NOT NULL` operator IsNotNull(Box), - /// `NOTNULL` or `NOT NULL` operator - NotNull { - expr: Box, - /// true if `NOT NULL`, false if `NOTNULL` - with_space: bool, - }, /// `IS UNKNOWN` operator IsUnknown(Box), /// `IS NOT UNKNOWN` operator @@ -1458,12 +1452,6 @@ impl fmt::Display for Expr { Expr::IsNotFalse(ast) => write!(f, "{ast} IS NOT FALSE"), Expr::IsNull(ast) => write!(f, "{ast} IS NULL"), Expr::IsNotNull(ast) => write!(f, "{ast} IS NOT NULL"), - Expr::NotNull { expr, with_space } => write!( - f, - "{} {}", - expr, - if *with_space { "NOT NULL" } else { "NOTNULL" } - ), Expr::IsUnknown(ast) => write!(f, "{ast} IS UNKNOWN"), Expr::IsNotUnknown(ast) => write!(f, "{ast} IS NOT UNKNOWN"), Expr::InList { diff --git a/src/ast/spans.rs b/src/ast/spans.rs index bf160f515..a1b2e4e0d 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1437,7 +1437,6 @@ impl Spanned for Expr { Expr::IsNotTrue(expr) => expr.span(), Expr::IsNull(expr) => expr.span(), Expr::IsNotNull(expr) => expr.span(), - Expr::NotNull { expr, .. } => expr.span(), Expr::IsUnknown(expr) => expr.span(), Expr::IsNotUnknown(expr) => expr.span(), Expr::IsDistinctFrom(lhs, rhs) => lhs.span().union(&rhs.span()), diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 8bbaefbdb..53e39c6e6 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3574,10 +3574,7 @@ impl<'a> Parser<'a> { regexp, }) } else if dialect.supports_is_not_null_alias(NotSpaceNull) && negated && null { - Ok(Expr::NotNull { - expr: Box::new(expr), - with_space: true, - }) + Ok(Expr::IsNotNull(Box::new(expr))) } else if self.parse_keyword(Keyword::IN) { self.parse_in(expr, negated) } else if self.parse_keyword(Keyword::BETWEEN) { @@ -3616,10 +3613,7 @@ impl<'a> Parser<'a> { } } Keyword::NOTNULL if dialect.supports_is_not_null_alias(NotNull) => { - Ok(Expr::NotNull { - expr: Box::new(expr), - with_space: false, - }) + Ok(Expr::IsNotNull(Box::new(expr))) } Keyword::MEMBER => { if self.parse_keyword(Keyword::OF) { diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 9fb1cf2d9..9ed50b12d 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -16005,11 +16005,12 @@ fn parse_not_null_unsupported() { #[test] fn parse_not_null_supported() { - // DuckDB and SQLite support `x NOT NULL` as an expression + // DuckDB and SQLite support `x NOT NULL` as an alias for `x IS NOT NULL` let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOT NULL FROM t"#; + let canonical = r#"WITH t AS (SELECT NULL AS x) SELECT x IS NOT NULL FROM t"#; let dialects = all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); - let stmt = dialects.one_statement_parses_to(sql, sql); + let stmt = dialects.one_statement_parses_to(sql, canonical); match stmt { Statement::Query(qry) => match *qry.body { SetExpr::Select(select) => { @@ -16022,14 +16023,11 @@ fn parse_not_null_supported() { }; assert_eq!( *expr, - Expr::NotNull { - expr: Box::new(Identifier(Ident { - value: "x".to_string(), - quote_style: None, - span: fake_span, - })), - with_space: true, - }, + Expr::IsNotNull(Box::new(Identifier(Ident { + value: "x".to_string(), + quote_style: None, + span: fake_span, + })),), ); } _ => unreachable!(), @@ -16088,10 +16086,11 @@ fn parse_notnull_unsupported() { #[test] fn parse_notnull_supported() { - // DuckDB and SQLite support `x NOT NULL` as an expression + // Postgres, DuckDB and SQLite support `x NOTNULL` as an alias for `x IS NOT NULL` let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOTNULL FROM t"#; + let canonical = r#"WITH t AS (SELECT NULL AS x) SELECT x IS NOT NULL FROM t"#; let dialects = all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); - let stmt = dialects.one_statement_parses_to(sql, ""); + let stmt = dialects.one_statement_parses_to(sql, canonical); match stmt { Statement::Query(qry) => match *qry.body { SetExpr::Select(select) => { @@ -16104,14 +16103,11 @@ fn parse_notnull_supported() { }; assert_eq!( *expr, - Expr::NotNull { - expr: Box::new(Identifier(Ident { - value: "x".to_string(), - quote_style: None, - span: fake_span, - })), - with_space: false, - }, + Expr::IsNotNull(Box::new(Identifier(Ident { + value: "x".to_string(), + quote_style: None, + span: fake_span, + })),), ); } _ => unreachable!(), From 75ba8218e09829d440710ace6223d1939b268ace Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Tue, 8 Jul 2025 13:16:18 -0700 Subject: [PATCH 04/12] fixup: simplify tests --- tests/sqlparser_common.rs | 108 +++----------------------------------- 1 file changed, 6 insertions(+), 102 deletions(-) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 9ed50b12d..b2154d14f 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -15992,51 +15992,18 @@ fn parse_create_procedure_with_parameter_modes() { #[test] fn parse_not_null_unsupported() { // Only DuckDB and SQLite support `x NOT NULL` as an expression - // All other dialects fail to parse. - let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOT NULL FROM t"#; + // All other dialects fail to parse the `NOT NULL` portion let dialects = all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); - let res = dialects.parse_sql_statements(sql); - assert_eq!( - ParserError::ParserError("Expected: end of statement, found: NULL".to_string()), - res.unwrap_err() - ); + let _ = dialects.expr_parses_to("x NOT NULL", "x"); } #[test] fn parse_not_null_supported() { // DuckDB and SQLite support `x NOT NULL` as an alias for `x IS NOT NULL` - let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOT NULL FROM t"#; - let canonical = r#"WITH t AS (SELECT NULL AS x) SELECT x IS NOT NULL FROM t"#; let dialects = all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); - let stmt = dialects.one_statement_parses_to(sql, canonical); - match stmt { - Statement::Query(qry) => match *qry.body { - SetExpr::Select(select) => { - assert_eq!(select.projection.len(), 1); - match select.projection.first().unwrap() { - UnnamedExpr(expr) => { - let fake_span = Span { - start: Location { line: 0, column: 0 }, - end: Location { line: 0, column: 0 }, - }; - assert_eq!( - *expr, - Expr::IsNotNull(Box::new(Identifier(Ident { - value: "x".to_string(), - quote_style: None, - span: fake_span, - })),), - ); - } - _ => unreachable!(), - } - } - _ => unreachable!(), - }, - _ => unreachable!(), - } + let _ = dialects.expr_parses_to("x NOT NULL", "x IS NOT NULL"); } #[test] @@ -16044,77 +16011,14 @@ fn parse_notnull_unsupported() { // Only Postgres, DuckDB, and SQLite support `x NOTNULL` as an expression // All other dialects consider `x NOTNULL` like `x AS NOTNULL` and thus // consider `NOTNULL` an alias for x. - let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOTNULL FROM t"#; - let canonical = r#"WITH t AS (SELECT NULL AS x) SELECT x AS NOTNULL FROM t"#; let dialects = all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); - let stmt = dialects.one_statement_parses_to(sql, canonical); - match stmt { - Statement::Query(qry) => match *qry.body { - SetExpr::Select(select) => { - assert_eq!(select.projection.len(), 1); - match select.projection.first().unwrap() { - SelectItem::ExprWithAlias { expr, alias } => { - let fake_span = Span { - start: Location { line: 0, column: 0 }, - end: Location { line: 0, column: 0 }, - }; - assert_eq!( - *expr, - Identifier(Ident { - value: "x".to_string(), - quote_style: None, - span: fake_span, - }) - ); - assert_eq!( - *alias, - Ident { - value: "NOTNULL".to_string(), - quote_style: None, - span: fake_span, - } - ); - } - _ => unreachable!(), - } - } - _ => unreachable!(), - }, - _ => unreachable!(), - } + let _ = dialects + .verified_only_select_with_canonical("SELECT NULL NOTNULL", "SELECT NULL AS NOTNULL"); } #[test] fn parse_notnull_supported() { // Postgres, DuckDB and SQLite support `x NOTNULL` as an alias for `x IS NOT NULL` - let sql = r#"WITH t AS (SELECT NULL AS x) SELECT x NOTNULL FROM t"#; - let canonical = r#"WITH t AS (SELECT NULL AS x) SELECT x IS NOT NULL FROM t"#; let dialects = all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); - let stmt = dialects.one_statement_parses_to(sql, canonical); - match stmt { - Statement::Query(qry) => match *qry.body { - SetExpr::Select(select) => { - assert_eq!(select.projection.len(), 1); - match select.projection.first().unwrap() { - UnnamedExpr(expr) => { - let fake_span = Span { - start: Location { line: 0, column: 0 }, - end: Location { line: 0, column: 0 }, - }; - assert_eq!( - *expr, - Expr::IsNotNull(Box::new(Identifier(Ident { - value: "x".to_string(), - quote_style: None, - span: fake_span, - })),), - ); - } - _ => unreachable!(), - } - } - _ => unreachable!(), - }, - _ => unreachable!(), - } + let _ = dialects.expr_parses_to("x NOTNULL", "x IS NOT NULL"); } From 4009fa72ac263b68fafe431d357a088de20b4500 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Tue, 8 Jul 2025 13:31:59 -0700 Subject: [PATCH 05/12] fixup: add precedence tests as well --- tests/sqlparser_common.rs | 42 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index b2154d14f..c65f842a7 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -16006,6 +16006,27 @@ fn parse_not_null_supported() { let _ = dialects.expr_parses_to("x NOT NULL", "x IS NOT NULL"); } +#[test] +fn test_not_null_precedence() { + // For dialects which support it, `NOT NULL NOT NULL` should + // parse as `(NOT (NULL IS NOT NULL))` + let supported_dialects = + all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); + let unsuported_dialects = + all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); + + assert_matches!( + supported_dialects.expr_parses_to("NOT NULL NOT NULL", "NOT NULL IS NOT NULL"), + Expr::UnaryOp { + op: UnaryOperator::Not, + .. + } + ); + + // for unsupported dialects, parsing should stop at `NOT NULL` + unsuported_dialects.expr_parses_to("NOT NULL NOT NULL", "NOT NULL"); +} + #[test] fn parse_notnull_unsupported() { // Only Postgres, DuckDB, and SQLite support `x NOTNULL` as an expression @@ -16022,3 +16043,24 @@ fn parse_notnull_supported() { let dialects = all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); let _ = dialects.expr_parses_to("x NOTNULL", "x IS NOT NULL"); } + +#[test] +fn test_notnull_precedence() { + // For dialects which support it, `NOT NULL NOTNULL` should + // parse as `(NOT (NULL IS NOT NULL))` + let supported_dialects = + all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); + let unsuported_dialects = + all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); + + assert_matches!( + supported_dialects.expr_parses_to("NOT NULL NOTNULL", "NOT NULL IS NOT NULL"), + Expr::UnaryOp { + op: UnaryOperator::Not, + .. + } + ); + + // for unsupported dialects, parsing should stop at `NOT NULL` + unsuported_dialects.expr_parses_to("NOT NULL NOTNULL", "NOT NULL"); +} From ab6607bcc33138c97695d25270f2812bd3079197 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Thu, 10 Jul 2025 17:05:13 -0700 Subject: [PATCH 06/12] fixup: handle `expr NOT NULL` directly in parse_subexpr. --- src/dialect/duckdb.rs | 13 ++++------ src/dialect/mod.rs | 29 ++++------------------- src/dialect/postgresql.rs | 11 ++++----- src/dialect/sqlite.rs | 13 ++++------ src/parser/mod.rs | 21 ++++++++++++---- tests/sqlparser_common.rs | 50 +++++++++++++-------------------------- 6 files changed, 52 insertions(+), 85 deletions(-) diff --git a/src/dialect/duckdb.rs b/src/dialect/duckdb.rs index ea069bc67..246cda8f7 100644 --- a/src/dialect/duckdb.rs +++ b/src/dialect/duckdb.rs @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -use crate::dialect::{Dialect, IsNotNullAlias}; +use crate::dialect::Dialect; /// A [`Dialect`] for [DuckDB](https://duckdb.org/) #[derive(Debug, Default)] @@ -95,12 +95,9 @@ impl Dialect for DuckDbDialect { true } - /// DuckDB supports `NOT NULL` and `NOTNULL` as aliases - /// for `IS NOT NULL`, see DuckDB Comparisons - fn supports_is_not_null_alias(&self, alias: IsNotNullAlias) -> bool { - match alias { - IsNotNullAlias::NotNull => true, - IsNotNullAlias::NotSpaceNull => true, - } + /// DuckDB supports `NOTNULL` as an alias for `IS NOT NULL`, + /// see DuckDB Comparisons + fn supports_notnull_operator(&self) -> bool { + true } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 23cd7fe04..e37b07f2d 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -55,7 +55,6 @@ use crate::keywords::Keyword; use crate::parser::{Parser, ParserError}; use crate::tokenizer::Token; -use crate::dialect::IsNotNullAlias::{NotNull, NotSpaceNull}; #[cfg(not(feature = "std"))] use alloc::boxed::Box; @@ -651,17 +650,9 @@ pub trait Dialect: Debug + Any { Token::Word(w) if w.keyword == Keyword::MATCH => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::SIMILAR => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::MEMBER => Ok(p!(Like)), - Token::Word(w) - if w.keyword == Keyword::NULL - && self.supports_is_not_null_alias(NotSpaceNull) => - { - Ok(p!(Is)) - } _ => Ok(self.prec_unknown()), }, - Token::Word(w) - if w.keyword == Keyword::NOTNULL && self.supports_is_not_null_alias(NotNull) => - { + Token::Word(w) if w.keyword == Keyword::NOTNULL && self.supports_notnull_operator() => { Ok(p!(Is)) } Token::Word(w) if w.keyword == Keyword::IS => Ok(p!(Is)), @@ -1102,13 +1093,10 @@ pub trait Dialect: Debug + Any { false } - /// Returns true if the dialect supports the passed in alias. - /// See [IsNotNullAlias]. - fn supports_is_not_null_alias(&self, alias: IsNotNullAlias) -> bool { - match alias { - NotNull => false, - NotSpaceNull => false, - } + /// Returns true if the dialect supports the `x NOTNULL` + /// operator expression. + fn supports_notnull_operator(&self) -> bool { + false } } @@ -1136,13 +1124,6 @@ pub enum Precedence { Or, } -/// Possible aliases for `IS NOT NULL` supported -/// by some non-standard dialects. -pub enum IsNotNullAlias { - NotNull, - NotSpaceNull, -} - impl dyn Dialect { #[inline] pub fn is(&self) -> bool { diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index b3d2c0e2b..24bd60719 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -28,7 +28,7 @@ // limitations under the License. use log::debug; -use crate::dialect::{Dialect, IsNotNullAlias, Precedence}; +use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; use crate::parser::{Parser, ParserError}; use crate::tokenizer::Token; @@ -264,11 +264,8 @@ impl Dialect for PostgreSqlDialect { } /// Postgres supports `NOTNULL` as an alias for `IS NOT NULL` - /// but does not support `NOT NULL`. See: - fn supports_is_not_null_alias(&self, alias: IsNotNullAlias) -> bool { - match alias { - IsNotNullAlias::NotNull => true, - IsNotNullAlias::NotSpaceNull => false, - } + /// See: + fn supports_notnull_operator(&self) -> bool { + true } } diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs index b03f7be02..64a8d532f 100644 --- a/src/dialect/sqlite.rs +++ b/src/dialect/sqlite.rs @@ -20,7 +20,7 @@ use alloc::boxed::Box; use crate::ast::BinaryOperator; use crate::ast::{Expr, Statement}; -use crate::dialect::{Dialect, IsNotNullAlias}; +use crate::dialect::Dialect; use crate::keywords::Keyword; use crate::parser::{Parser, ParserError}; @@ -111,12 +111,9 @@ impl Dialect for SQLiteDialect { true } - /// SQLite supports ``NOT NULL` and `NOTNULL` as - /// aliases for `IS NOT NULL`, see: - fn supports_is_not_null_alias(&self, alias: IsNotNullAlias) -> bool { - match alias { - IsNotNullAlias::NotNull => true, - IsNotNullAlias::NotSpaceNull => true, - } + /// SQLite supports `NOTNULL` as aliases for `IS NOT NULL` + /// See: + fn supports_notnull_operator(&self) -> bool { + true } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 53e39c6e6..9a0568ea8 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -35,7 +35,6 @@ use IsOptional::*; use crate::ast::helpers::stmt_create_table::{CreateTableBuilder, CreateTableConfiguration}; use crate::ast::Statement::CreatePolicy; use crate::ast::*; -use crate::dialect::IsNotNullAlias::{NotNull, NotSpaceNull}; use crate::dialect::*; use crate::keywords::{Keyword, ALL_KEYWORDS}; use crate::tokenizer::*; @@ -1244,6 +1243,21 @@ impl<'a> Parser<'a> { expr = self.parse_infix(expr, next_precedence)?; } + + // Special case: if expr is an identifier or NULL, accept `expr NOT NULL` + // as an alias for `expr IS NOT NULL`. + if match &expr { + Expr::Identifier(_) if self.parse_keywords(&[Keyword::NOT, Keyword::NULL]) => true, + Expr::Value(v) + if v.value == Value::Null + && self.parse_keywords(&[Keyword::NOT, Keyword::NULL]) => + { + true + } + _ => false, + } { + return Ok(Expr::IsNotNull(Box::new(expr))); + } Ok(expr) } @@ -3563,7 +3577,6 @@ impl<'a> Parser<'a> { let negated = self.parse_keyword(Keyword::NOT); let regexp = self.parse_keyword(Keyword::REGEXP); let rlike = self.parse_keyword(Keyword::RLIKE); - let null = self.parse_keyword(Keyword::NULL); if regexp || rlike { Ok(Expr::RLike { negated, @@ -3573,8 +3586,6 @@ impl<'a> Parser<'a> { ), regexp, }) - } else if dialect.supports_is_not_null_alias(NotSpaceNull) && negated && null { - Ok(Expr::IsNotNull(Box::new(expr))) } else if self.parse_keyword(Keyword::IN) { self.parse_in(expr, negated) } else if self.parse_keyword(Keyword::BETWEEN) { @@ -3612,7 +3623,7 @@ impl<'a> Parser<'a> { self.expected("IN or BETWEEN after NOT", self.peek_token()) } } - Keyword::NOTNULL if dialect.supports_is_not_null_alias(NotNull) => { + Keyword::NOTNULL if dialect.supports_notnull_operator() => { Ok(Expr::IsNotNull(Box::new(expr))) } Keyword::MEMBER => { diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index c65f842a7..50e3044d7 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -32,8 +32,8 @@ use sqlparser::ast::TableFactor::{Pivot, Unpivot}; use sqlparser::ast::*; use sqlparser::dialect::{ AnsiDialect, BigQueryDialect, ClickHouseDialect, DatabricksDialect, Dialect, DuckDbDialect, - GenericDialect, HiveDialect, IsNotNullAlias, MsSqlDialect, MySqlDialect, PostgreSqlDialect, - RedshiftSqlDialect, SQLiteDialect, SnowflakeDialect, + GenericDialect, HiveDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, RedshiftSqlDialect, + SQLiteDialect, SnowflakeDialect, }; use sqlparser::keywords::{Keyword, ALL_KEYWORDS}; use sqlparser::parser::{Parser, ParserError, ParserOptions}; @@ -15989,42 +15989,28 @@ fn parse_create_procedure_with_parameter_modes() { } } -#[test] -fn parse_not_null_unsupported() { - // Only DuckDB and SQLite support `x NOT NULL` as an expression - // All other dialects fail to parse the `NOT NULL` portion - let dialects = - all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); - let _ = dialects.expr_parses_to("x NOT NULL", "x"); -} - #[test] fn parse_not_null_supported() { - // DuckDB and SQLite support `x NOT NULL` as an alias for `x IS NOT NULL` - let dialects = - all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); - let _ = dialects.expr_parses_to("x NOT NULL", "x IS NOT NULL"); + let _ = all_dialects().expr_parses_to("x NOT NULL", "x IS NOT NULL"); + let _ = all_dialects().expr_parses_to("NULL NOT NULL", "NULL IS NOT NULL"); } #[test] fn test_not_null_precedence() { - // For dialects which support it, `NOT NULL NOT NULL` should - // parse as `(NOT (NULL IS NOT NULL))` - let supported_dialects = - all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); - let unsuported_dialects = - all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotSpaceNull)); - assert_matches!( - supported_dialects.expr_parses_to("NOT NULL NOT NULL", "NOT NULL IS NOT NULL"), + all_dialects().expr_parses_to("NOT NULL NOT NULL", "NOT NULL IS NOT NULL"), + Expr::UnaryOp { + op: UnaryOperator::Not, + .. + } + ); + assert_matches!( + all_dialects().expr_parses_to("NOT x NOT NULL", "NOT x IS NOT NULL"), Expr::UnaryOp { op: UnaryOperator::Not, .. } ); - - // for unsupported dialects, parsing should stop at `NOT NULL` - unsuported_dialects.expr_parses_to("NOT NULL NOT NULL", "NOT NULL"); } #[test] @@ -16032,7 +16018,7 @@ fn parse_notnull_unsupported() { // Only Postgres, DuckDB, and SQLite support `x NOTNULL` as an expression // All other dialects consider `x NOTNULL` like `x AS NOTNULL` and thus // consider `NOTNULL` an alias for x. - let dialects = all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); + let dialects = all_dialects_except(|d| d.supports_notnull_operator()); let _ = dialects .verified_only_select_with_canonical("SELECT NULL NOTNULL", "SELECT NULL AS NOTNULL"); } @@ -16040,7 +16026,7 @@ fn parse_notnull_unsupported() { #[test] fn parse_notnull_supported() { // Postgres, DuckDB and SQLite support `x NOTNULL` as an alias for `x IS NOT NULL` - let dialects = all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); + let dialects = all_dialects_where(|d| d.supports_notnull_operator()); let _ = dialects.expr_parses_to("x NOTNULL", "x IS NOT NULL"); } @@ -16048,10 +16034,8 @@ fn parse_notnull_supported() { fn test_notnull_precedence() { // For dialects which support it, `NOT NULL NOTNULL` should // parse as `(NOT (NULL IS NOT NULL))` - let supported_dialects = - all_dialects_where(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); - let unsuported_dialects = - all_dialects_except(|d| d.supports_is_not_null_alias(IsNotNullAlias::NotNull)); + let supported_dialects = all_dialects_where(|d| d.supports_notnull_operator()); + let unsupported_dialects = all_dialects_except(|d| d.supports_notnull_operator()); assert_matches!( supported_dialects.expr_parses_to("NOT NULL NOTNULL", "NOT NULL IS NOT NULL"), @@ -16062,5 +16046,5 @@ fn test_notnull_precedence() { ); // for unsupported dialects, parsing should stop at `NOT NULL` - unsuported_dialects.expr_parses_to("NOT NULL NOTNULL", "NOT NULL"); + unsupported_dialects.expr_parses_to("NOT NULL NOTNULL", "NOT NULL"); } From 375fe8c67df9088e5bb1b4252bf28d0750f555e8 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Fri, 11 Jul 2025 11:56:52 -0700 Subject: [PATCH 07/12] fixup: Add ParserState::ColumnDefinition to avoid parsing NOT NULL as Expr::IsNotNull. --- src/dialect/mod.rs | 3 +++ src/parser/mod.rs | 46 +++++++++++++++++++++++++++------------------- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index e37b07f2d..5b52fa7a2 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -650,6 +650,9 @@ pub trait Dialect: Debug + Any { Token::Word(w) if w.keyword == Keyword::MATCH => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::SIMILAR => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::MEMBER => Ok(p!(Like)), + Token::Word(w) if w.keyword == Keyword::NULL && parser.in_normal_state() => { + Ok(p!(Is)) + } _ => Ok(self.prec_unknown()), }, Token::Word(w) if w.keyword == Keyword::NOTNULL && self.supports_notnull_operator() => { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 9a0568ea8..3b34f76cf 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -28,16 +28,16 @@ use helpers::attached_token::AttachedToken; use log::debug; -use recursion::RecursionCounter; -use IsLateral::*; -use IsOptional::*; - use crate::ast::helpers::stmt_create_table::{CreateTableBuilder, CreateTableConfiguration}; use crate::ast::Statement::CreatePolicy; use crate::ast::*; use crate::dialect::*; use crate::keywords::{Keyword, ALL_KEYWORDS}; use crate::tokenizer::*; +use recursion::RecursionCounter; +use sqlparser::parser::ParserState::ColumnDefinition; +use IsLateral::*; +use IsOptional::*; mod alter; @@ -271,6 +271,9 @@ enum ParserState { /// PRIOR expressions while still allowing prior as an identifier name /// in other contexts. ConnectBy, + /// The state when parsing column definitions. This state prohibits + /// NOT NULL as an alias for IS NOT NULL. + ColumnDefinition, } /// A SQL Parser @@ -1243,21 +1246,6 @@ impl<'a> Parser<'a> { expr = self.parse_infix(expr, next_precedence)?; } - - // Special case: if expr is an identifier or NULL, accept `expr NOT NULL` - // as an alias for `expr IS NOT NULL`. - if match &expr { - Expr::Identifier(_) if self.parse_keywords(&[Keyword::NOT, Keyword::NULL]) => true, - Expr::Value(v) - if v.value == Value::Null - && self.parse_keywords(&[Keyword::NOT, Keyword::NULL]) => - { - true - } - _ => false, - } { - return Ok(Expr::IsNotNull(Box::new(expr))); - } Ok(expr) } @@ -3577,6 +3565,11 @@ impl<'a> Parser<'a> { let negated = self.parse_keyword(Keyword::NOT); let regexp = self.parse_keyword(Keyword::REGEXP); let rlike = self.parse_keyword(Keyword::RLIKE); + let null = if self.in_normal_state() { + self.parse_keyword(Keyword::NULL) + } else { + false + }; if regexp || rlike { Ok(Expr::RLike { negated, @@ -3586,6 +3579,8 @@ impl<'a> Parser<'a> { ), regexp, }) + } else if negated && null { + Ok(Expr::IsNotNull(Box::new(expr))) } else if self.parse_keyword(Keyword::IN) { self.parse_in(expr, negated) } else if self.parse_keyword(Keyword::BETWEEN) { @@ -7734,6 +7729,15 @@ impl<'a> Parser<'a> { return option; } + self.with_state( + ColumnDefinition, + |parser| -> Result, ParserError> { + parser.parse_optional_column_option_inner() + }, + ) + } + + fn parse_optional_column_option_inner(&mut self) -> Result, ParserError> { if self.parse_keywords(&[Keyword::CHARACTER, Keyword::SET]) { Ok(Some(ColumnOption::CharacterSet( self.parse_object_name(false)?, @@ -16510,6 +16514,10 @@ impl<'a> Parser<'a> { Ok(None) } } + + pub fn in_normal_state(&self) -> bool { + matches!(self.state, ParserState::Normal) + } } fn maybe_prefixed_expr(expr: Expr, prefix: Option) -> Expr { From 45fff121900496ce7d7509cb6522eddb5a8a0d0f Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Fri, 11 Jul 2025 12:04:20 -0700 Subject: [PATCH 08/12] fixup: fmt --- src/dialect/duckdb.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dialect/duckdb.rs b/src/dialect/duckdb.rs index 7bb937b5d..aee7ee935 100644 --- a/src/dialect/duckdb.rs +++ b/src/dialect/duckdb.rs @@ -98,7 +98,7 @@ impl Dialect for DuckDbDialect { fn supports_select_wildcard_exclude(&self) -> bool { true } - + /// DuckDB supports `NOTNULL` as an alias for `IS NOT NULL`, /// see DuckDB Comparisons fn supports_notnull_operator(&self) -> bool { From bf25f42123a31455de3b2ad0c4102f6281d665ec Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Mon, 14 Jul 2025 12:27:30 -0700 Subject: [PATCH 09/12] Return to ParserState::Normal when parsing DEFAULT sub-expression. --- src/parser/mod.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index b5ce7c381..67433edbb 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7761,7 +7761,14 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::NULL) { Ok(Some(ColumnOption::Null)) } else if self.parse_keyword(Keyword::DEFAULT) { - Ok(Some(ColumnOption::Default(self.parse_expr()?))) + // When parsing the `DEFAULT` expr if it's enclosed in parentheses + // then we want to parse using Normal state so `NOT NULL` is allowed + if matches!(self.peek_token().token, Token::LParen) { + let expr: Expr = self.with_state(ParserState::Normal, |p| Ok(p.parse_prefix()?))?; + Ok(Some(ColumnOption::Default(expr))) + } else { + Ok(Some(ColumnOption::Default(self.parse_expr()?))) + } } else if dialect_of!(self is ClickHouseDialect| GenericDialect) && self.parse_keyword(Keyword::MATERIALIZED) { @@ -17268,4 +17275,11 @@ mod tests { assert!(Parser::parse_sql(&MySqlDialect {}, sql).is_err()); } + + #[test] + fn test_parse_not_null_in_column_default() { + let canonical = "CREATE TABLE foo (abc INT DEFAULT (42 IS NOT NULL) NOT NULL)"; + all_dialects().verified_stmt(canonical); + all_dialects().one_statement_parses_to("CREATE TABLE foo (abc INT DEFAULT (42 NOT NULL) NOT NULL)", canonical); + } } From bc454c60f5cab58e9d91ef7905a5c3bda3abedc8 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Mon, 14 Jul 2025 13:12:43 -0700 Subject: [PATCH 10/12] Handle NOT NULL in MATERIALIZED and CHECK options as well. --- src/parser/mod.rs | 36 ++++++++++++++++++++++------------- tests/sqlparser_clickhouse.rs | 9 +++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 67433edbb..4d3aea077 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -7746,6 +7746,18 @@ impl<'a> Parser<'a> { } fn parse_optional_column_option_inner(&mut self) -> Result, ParserError> { + /// In some cases, we need to revert to [ParserState::Normal] when parsing nested expressions + /// In those cases we use the following macro to parse instead of calling [parse_expr] directly. + macro_rules! parse_expr_normal { + ($option:expr) => { + if matches!(self.peek_token().token, Token::LParen) { + let expr: Expr = self.with_state(ParserState::Normal, |p| p.parse_prefix())?; + Ok(Some($option(expr))) + } else { + Ok(Some($option(self.parse_expr()?))) + } + }; + } if self.parse_keywords(&[Keyword::CHARACTER, Keyword::SET]) { Ok(Some(ColumnOption::CharacterSet( self.parse_object_name(false)?, @@ -7761,18 +7773,11 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::NULL) { Ok(Some(ColumnOption::Null)) } else if self.parse_keyword(Keyword::DEFAULT) { - // When parsing the `DEFAULT` expr if it's enclosed in parentheses - // then we want to parse using Normal state so `NOT NULL` is allowed - if matches!(self.peek_token().token, Token::LParen) { - let expr: Expr = self.with_state(ParserState::Normal, |p| Ok(p.parse_prefix()?))?; - Ok(Some(ColumnOption::Default(expr))) - } else { - Ok(Some(ColumnOption::Default(self.parse_expr()?))) - } + parse_expr_normal!(ColumnOption::Default) } else if dialect_of!(self is ClickHouseDialect| GenericDialect) && self.parse_keyword(Keyword::MATERIALIZED) { - Ok(Some(ColumnOption::Materialized(self.parse_expr()?))) + parse_expr_normal!(ColumnOption::Materialized) } else if dialect_of!(self is ClickHouseDialect| GenericDialect) && self.parse_keyword(Keyword::ALIAS) { @@ -7828,7 +7833,8 @@ impl<'a> Parser<'a> { })) } else if self.parse_keyword(Keyword::CHECK) { self.expect_token(&Token::LParen)?; - let expr = self.parse_expr()?; + // since `CHECK` requires parentheses, we can parse the inner expression in ParserState::Normal + let expr: Expr = self.with_state(ParserState::Normal, |p| p.parse_expr())?; self.expect_token(&Token::RParen)?; Ok(Some(ColumnOption::Check(expr))) } else if self.parse_keyword(Keyword::AUTO_INCREMENT) @@ -17277,9 +17283,13 @@ mod tests { } #[test] - fn test_parse_not_null_in_column_default() { - let canonical = "CREATE TABLE foo (abc INT DEFAULT (42 IS NOT NULL) NOT NULL)"; + fn test_parse_not_null_in_column_options() { + let canonical = + "CREATE TABLE foo (abc INT DEFAULT (42 IS NOT NULL) NOT NULL, CHECK (abc IS NOT NULL))"; all_dialects().verified_stmt(canonical); - all_dialects().one_statement_parses_to("CREATE TABLE foo (abc INT DEFAULT (42 NOT NULL) NOT NULL)", canonical); + all_dialects().one_statement_parses_to( + "CREATE TABLE foo (abc INT DEFAULT (42 NOT NULL) NOT NULL, CHECK (abc NOT NULL) )", + canonical, + ); } } diff --git a/tests/sqlparser_clickhouse.rs b/tests/sqlparser_clickhouse.rs index 9e5b6ce8a..66e2026c3 100644 --- a/tests/sqlparser_clickhouse.rs +++ b/tests/sqlparser_clickhouse.rs @@ -1705,6 +1705,15 @@ fn parse_table_sample() { clickhouse().verified_stmt("SELECT * FROM tbl SAMPLE 1 / 10 OFFSET 1 / 2"); } +#[test] +fn test_parse_not_null_in_column_options() { + // In addition to DEFAULT and CHECK ClickHouse also supports MATERIALIZED, all of which + // can contain `IS NOT NULL` and thus `NOT NULL` as an alias. + let canonical = "CREATE TABLE foo (abc INT DEFAULT (42 IS NOT NULL) NOT NULL, not_null BOOL MATERIALIZED (abc IS NOT NULL), CHECK (abc IS NOT NULL))"; + clickhouse().verified_stmt(canonical); + clickhouse().one_statement_parses_to("CREATE TABLE foo (abc INT DEFAULT (42 NOT NULL) NOT NULL, not_null BOOL MATERIALIZED (abc IS NOT NULL), CHECK (abc NOT NULL) )", canonical); +} + fn clickhouse() -> TestedDialects { TestedDialects::new(vec![Box::new(ClickHouseDialect {})]) } From 7371bffbb00532be495ffea4270fe849a563e7d6 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Thu, 17 Jul 2025 14:44:52 -0700 Subject: [PATCH 11/12] Update tests/sqlparser_clickhouse.rs Co-authored-by: Ifeanyi Ubah --- tests/sqlparser_clickhouse.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/sqlparser_clickhouse.rs b/tests/sqlparser_clickhouse.rs index 66e2026c3..7d34e270f 100644 --- a/tests/sqlparser_clickhouse.rs +++ b/tests/sqlparser_clickhouse.rs @@ -1711,7 +1711,7 @@ fn test_parse_not_null_in_column_options() { // can contain `IS NOT NULL` and thus `NOT NULL` as an alias. let canonical = "CREATE TABLE foo (abc INT DEFAULT (42 IS NOT NULL) NOT NULL, not_null BOOL MATERIALIZED (abc IS NOT NULL), CHECK (abc IS NOT NULL))"; clickhouse().verified_stmt(canonical); - clickhouse().one_statement_parses_to("CREATE TABLE foo (abc INT DEFAULT (42 NOT NULL) NOT NULL, not_null BOOL MATERIALIZED (abc IS NOT NULL), CHECK (abc NOT NULL) )", canonical); + clickhouse().one_statement_parses_to("CREATE TABLE foo (abc INT DEFAULT (42 NOT NULL) NOT NULL, not_null BOOL MATERIALIZED (abc NOT NULL), CHECK (abc NOT NULL) )", canonical); } fn clickhouse() -> TestedDialects { From 1466e2ab212744bc2270546369a04ad8ab2e893a Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Thu, 17 Jul 2025 15:42:27 -0700 Subject: [PATCH 12/12] Address feedback from PR: - use `concat!` for longer SQL strings - replace macro w/ `parse_column_option_expr()` - add SQL examples to docstrings - use `peek_token_ref()` instead of `matches!` - `in_normal_state` -> `pub(crate) in_column_definition_state` - revert fmt change that moved use statements - add `GENERATED` test and use [ParserState::Normal] for parsing inner expr. --- src/dialect/mod.rs | 4 +- src/parser/mod.rs | 84 ++++++++++++++++++++++++----------- tests/sqlparser_clickhouse.rs | 19 +++++++- 3 files changed, 79 insertions(+), 28 deletions(-) diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 68e237ca8..c78b00033 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -677,7 +677,9 @@ pub trait Dialect: Debug + Any { Token::Word(w) if w.keyword == Keyword::MATCH => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::SIMILAR => Ok(p!(Like)), Token::Word(w) if w.keyword == Keyword::MEMBER => Ok(p!(Like)), - Token::Word(w) if w.keyword == Keyword::NULL && parser.in_normal_state() => { + Token::Word(w) + if w.keyword == Keyword::NULL && !parser.in_column_definition_state() => + { Ok(p!(Is)) } _ => Ok(self.prec_unknown()), diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 4766db8c6..aaec6d3ac 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -28,16 +28,17 @@ use helpers::attached_token::AttachedToken; use log::debug; +use recursion::RecursionCounter; +use IsLateral::*; +use IsOptional::*; + use crate::ast::helpers::stmt_create_table::{CreateTableBuilder, CreateTableConfiguration}; use crate::ast::Statement::CreatePolicy; use crate::ast::*; use crate::dialect::*; use crate::keywords::{Keyword, ALL_KEYWORDS}; use crate::tokenizer::*; -use recursion::RecursionCounter; use sqlparser::parser::ParserState::ColumnDefinition; -use IsLateral::*; -use IsOptional::*; mod alter; @@ -276,7 +277,10 @@ enum ParserState { /// in other contexts. ConnectBy, /// The state when parsing column definitions. This state prohibits - /// NOT NULL as an alias for IS NOT NULL. + /// NOT NULL as an alias for IS NOT NULL. For example: + /// ```sql + /// CREATE TABLE foo (abc BIGINT NOT NULL); + /// ``` ColumnDefinition, } @@ -3573,7 +3577,7 @@ impl<'a> Parser<'a> { let negated = self.parse_keyword(Keyword::NOT); let regexp = self.parse_keyword(Keyword::REGEXP); let rlike = self.parse_keyword(Keyword::RLIKE); - let null = if self.in_normal_state() { + let null = if !self.in_column_definition_state() { self.parse_keyword(Keyword::NULL) } else { false @@ -7746,18 +7750,6 @@ impl<'a> Parser<'a> { } fn parse_optional_column_option_inner(&mut self) -> Result, ParserError> { - /// In some cases, we need to revert to [ParserState::Normal] when parsing nested expressions - /// In those cases we use the following macro to parse instead of calling [parse_expr] directly. - macro_rules! parse_expr_normal { - ($option:expr) => { - if matches!(self.peek_token().token, Token::LParen) { - let expr: Expr = self.with_state(ParserState::Normal, |p| p.parse_prefix())?; - Ok(Some($option(expr))) - } else { - Ok(Some($option(self.parse_expr()?))) - } - }; - } if self.parse_keywords(&[Keyword::CHARACTER, Keyword::SET]) { Ok(Some(ColumnOption::CharacterSet( self.parse_object_name(false)?, @@ -7773,11 +7765,15 @@ impl<'a> Parser<'a> { } else if self.parse_keyword(Keyword::NULL) { Ok(Some(ColumnOption::Null)) } else if self.parse_keyword(Keyword::DEFAULT) { - parse_expr_normal!(ColumnOption::Default) + Ok(Some(ColumnOption::Default( + self.parse_column_option_expr()?, + ))) } else if dialect_of!(self is ClickHouseDialect| GenericDialect) && self.parse_keyword(Keyword::MATERIALIZED) { - parse_expr_normal!(ColumnOption::Materialized) + Ok(Some(ColumnOption::Materialized( + self.parse_column_option_expr()?, + ))) } else if dialect_of!(self is ClickHouseDialect| GenericDialect) && self.parse_keyword(Keyword::ALIAS) { @@ -7926,6 +7922,31 @@ impl<'a> Parser<'a> { } } + /// When parsing some column option expressions we need to revert to [ParserState::Normal] since + /// `NOT NULL` is allowed as an alias for `IS NOT NULL`. + /// In those cases we use this helper instead of calling [parse_expr] directly. + /// + /// For example, consider these `CREATE TABLE` statements: + /// ```sql + /// CREATE TABLE foo (abc BOOL DEFAULT (42 NOT NULL) NOT NULL); + /// ``` + /// vs + /// ```sql + /// CREATE TABLE foo (abc BOOL NOT NULL); + /// ``` + /// + /// In the first we should parse the inner portion of `(42 NOT NULL)` as [Expr::IsNotNull], + /// whereas is both statements that trailing `NOT NULL` should only be parsed as a + /// [ColumnOption::NotNull]. + fn parse_column_option_expr(&mut self) -> Result { + if self.peek_token_ref().token == Token::LParen { + let expr: Expr = self.with_state(ParserState::Normal, |p| p.parse_prefix())?; + Ok(expr) + } else { + Ok(self.parse_expr()?) + } + } + pub(crate) fn parse_tag(&mut self) -> Result { let name = self.parse_object_name(false)?; self.expect_token(&Token::Eq)?; @@ -7970,7 +7991,7 @@ impl<'a> Parser<'a> { })) } else if self.parse_keywords(&[Keyword::ALWAYS, Keyword::AS]) { if self.expect_token(&Token::LParen).is_ok() { - let expr = self.parse_expr()?; + let expr: Expr = self.with_state(ParserState::Normal, |p| p.parse_expr())?; self.expect_token(&Token::RParen)?; let (gen_as, expr_mode) = if self.parse_keywords(&[Keyword::STORED]) { Ok(( @@ -16550,8 +16571,8 @@ impl<'a> Parser<'a> { } } - pub fn in_normal_state(&self) -> bool { - matches!(self.state, ParserState::Normal) + pub(crate) fn in_column_definition_state(&self) -> bool { + matches!(self.state, ColumnDefinition) } } @@ -17291,11 +17312,24 @@ mod tests { #[test] fn test_parse_not_null_in_column_options() { - let canonical = - "CREATE TABLE foo (abc INT DEFAULT (42 IS NOT NULL) NOT NULL, CHECK (abc IS NOT NULL))"; + let canonical = concat!( + "CREATE TABLE foo (", + "abc INT DEFAULT (42 IS NOT NULL) NOT NULL,", + " def INT,", + " def_null BOOL GENERATED ALWAYS AS (def IS NOT NULL) STORED,", + " CHECK (abc IS NOT NULL)", + ")" + ); all_dialects().verified_stmt(canonical); all_dialects().one_statement_parses_to( - "CREATE TABLE foo (abc INT DEFAULT (42 NOT NULL) NOT NULL, CHECK (abc NOT NULL) )", + concat!( + "CREATE TABLE foo (", + "abc INT DEFAULT (42 NOT NULL) NOT NULL,", + " def INT,", + " def_null BOOL GENERATED ALWAYS AS (def NOT NULL) STORED,", + " CHECK (abc NOT NULL)", + ")" + ), canonical, ); } diff --git a/tests/sqlparser_clickhouse.rs b/tests/sqlparser_clickhouse.rs index 7d34e270f..bc1431f9c 100644 --- a/tests/sqlparser_clickhouse.rs +++ b/tests/sqlparser_clickhouse.rs @@ -1709,9 +1709,24 @@ fn parse_table_sample() { fn test_parse_not_null_in_column_options() { // In addition to DEFAULT and CHECK ClickHouse also supports MATERIALIZED, all of which // can contain `IS NOT NULL` and thus `NOT NULL` as an alias. - let canonical = "CREATE TABLE foo (abc INT DEFAULT (42 IS NOT NULL) NOT NULL, not_null BOOL MATERIALIZED (abc IS NOT NULL), CHECK (abc IS NOT NULL))"; + let canonical = concat!( + "CREATE TABLE foo (", + "abc INT DEFAULT (42 IS NOT NULL) NOT NULL,", + " not_null BOOL MATERIALIZED (abc IS NOT NULL),", + " CHECK (abc IS NOT NULL)", + ")", + ); clickhouse().verified_stmt(canonical); - clickhouse().one_statement_parses_to("CREATE TABLE foo (abc INT DEFAULT (42 NOT NULL) NOT NULL, not_null BOOL MATERIALIZED (abc NOT NULL), CHECK (abc NOT NULL) )", canonical); + clickhouse().one_statement_parses_to( + concat!( + "CREATE TABLE foo (", + "abc INT DEFAULT (42 NOT NULL) NOT NULL,", + " not_null BOOL MATERIALIZED (abc NOT NULL),", + " CHECK (abc NOT NULL)", + ")", + ), + canonical, + ); } fn clickhouse() -> TestedDialects {