From d6f51a13a84fb21b9c8ef2839a2b87935b9022a6 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Sat, 5 Jul 2025 15:28:20 -0700 Subject: [PATCH 1/5] 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 425e1fb60..273196e3d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -756,6 +756,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 @@ -1430,6 +1436,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 144de5923..bb0cd77ce 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 bc3c55554..13667687c 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)), @@ -1076,6 +1082,16 @@ pub trait Dialect: Debug + Any { fn supports_comma_separated_drop_column_list(&self) -> 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 839d36459..61418a947 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 ed9bb704d..1a5d61e79 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -15974,3 +15974,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 f022dfaa9a15a7b4b2ff2ecd2d2c8f2dd837e71f Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Mon, 7 Jul 2025 13:51:17 -0700 Subject: [PATCH 2/5] 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 273196e3d..610802a19 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -759,8 +759,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), @@ -1436,11 +1436,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 13667687c..a40b18905 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)), @@ -1083,14 +1089,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, + } } } @@ -1118,6 +1123,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 61418a947..5067822d6 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 1a5d61e79..2788f575f 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}; @@ -15980,7 +15980,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()), @@ -15992,7 +15993,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 { @@ -16012,7 +16014,7 @@ fn parse_not_null_supported() { quote_style: None, span: fake_span, })), - one_word: false, + with_space: true, }, ); } @@ -16032,7 +16034,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 { @@ -16074,7 +16076,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 { @@ -16094,7 +16096,7 @@ fn parse_notnull_supported() { quote_style: None, span: fake_span, })), - one_word: true, + with_space: false, }, ); } From 2bf08db93d994b056820f747f3a6335e3046738a Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Mon, 7 Jul 2025 18:27:28 -0700 Subject: [PATCH 3/5] 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 610802a19..425e1fb60 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -756,12 +756,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 @@ -1436,12 +1430,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 bb0cd77ce..144de5923 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 5067822d6..f9d08f3e8 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 2788f575f..2dc9202b0 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -15991,11 +15991,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) => { @@ -16008,14 +16009,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!(), @@ -16074,10 +16072,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) => { @@ -16090,14 +16089,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 431fca3682cfc78ee61a2493f85f2345a9faf6a2 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Tue, 8 Jul 2025 13:16:18 -0700 Subject: [PATCH 4/5] 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 2dc9202b0..0a91ff26b 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -15978,51 +15978,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] @@ -16030,77 +15997,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 637fb69dd2666b0c1999fbbb0ba9b38d751b9634 Mon Sep 17 00:00:00 2001 From: Ryan Schneider Date: Tue, 8 Jul 2025 13:31:59 -0700 Subject: [PATCH 5/5] 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 0a91ff26b..86372bdca 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -15992,6 +15992,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 @@ -16008,3 +16029,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"); +}