This is an automated email from the ASF dual-hosted git repository.

iffyio pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new a73577c2 Add support for `NOT NULL` and `NOTNULL` expressions (#1927)
a73577c2 is described below

commit a73577c29fc86b365f14545a0734e619b439dbf4
Author: Ryan Schneider <[email protected]>
AuthorDate: Mon Jul 21 03:58:20 2025 -0700

    Add support for `NOT NULL` and `NOTNULL` expressions (#1927)
    
    Co-authored-by: Ifeanyi Ubah <[email protected]>
---
 src/dialect/duckdb.rs         |   6 +++
 src/dialect/mod.rs            |  14 ++++++
 src/dialect/postgresql.rs     |   6 +++
 src/dialect/sqlite.rs         |   6 +++
 src/keywords.rs               |   1 +
 src/parser/mod.rs             | 104 ++++++++++++++++++++++++++++++++++++++----
 tests/sqlparser_clickhouse.rs |  24 ++++++++++
 tests/sqlparser_common.rs     |  47 +++++++++++++++++++
 8 files changed, 200 insertions(+), 8 deletions(-)

diff --git a/src/dialect/duckdb.rs b/src/dialect/duckdb.rs
index fa18463a..aee7ee93 100644
--- a/src/dialect/duckdb.rs
+++ b/src/dialect/duckdb.rs
@@ -98,4 +98,10 @@ 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 
<https://duckdb.org/docs/stable/sql/expressions/comparison_operators#between-and-is-not-null>
+    fn supports_notnull_operator(&self) -> bool {
+        true
+    }
 }
diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs
index c79b279d..c78b0003 100644
--- a/src/dialect/mod.rs
+++ b/src/dialect/mod.rs
@@ -677,8 +677,16 @@ 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_column_definition_state() =>
+                {
+                    Ok(p!(Is))
+                }
                 _ => Ok(self.prec_unknown()),
             },
+            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)),
             Token::Word(w) if w.keyword == Keyword::IN => Ok(p!(Between)),
             Token::Word(w) if w.keyword == Keyword::BETWEEN => Ok(p!(Between)),
@@ -1122,6 +1130,12 @@ pub trait Dialect: Debug + Any {
     ) -> bool {
         false
     }
+
+    /// Returns true if the dialect supports the `x NOTNULL`
+    /// operator expression.
+    fn supports_notnull_operator(&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 9cea252c..b12abaaf 100644
--- a/src/dialect/postgresql.rs
+++ b/src/dialect/postgresql.rs
@@ -263,4 +263,10 @@ impl Dialect for PostgreSqlDialect {
     fn supports_alter_column_type_using(&self) -> bool {
         true
     }
+
+    /// Postgres supports `NOTNULL` as an alias for `IS NOT NULL`
+    /// See: <https://www.postgresql.org/docs/17/functions-comparison.html>
+    fn supports_notnull_operator(&self) -> bool {
+        true
+    }
 }
diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs
index 847e0d13..64a8d532 100644
--- a/src/dialect/sqlite.rs
+++ b/src/dialect/sqlite.rs
@@ -110,4 +110,10 @@ impl Dialect for SQLiteDialect {
     fn supports_dollar_placeholder(&self) -> bool {
         true
     }
+
+    /// SQLite supports `NOTNULL` as aliases for `IS NOT NULL`
+    /// See: <https://sqlite.org/syntax/expr.html>
+    fn supports_notnull_operator(&self) -> bool {
+        true
+    }
 }
diff --git a/src/keywords.rs b/src/keywords.rs
index 9e689a6d..7781939b 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 3bb91311..19acd4b5 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -38,6 +38,7 @@ use crate::ast::*;
 use crate::dialect::*;
 use crate::keywords::{Keyword, ALL_KEYWORDS};
 use crate::tokenizer::*;
+use sqlparser::parser::ParserState::ColumnDefinition;
 
 mod alter;
 
@@ -275,6 +276,12 @@ 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.  For example:
+    /// ```sql
+    /// CREATE TABLE foo (abc BIGINT NOT NULL);
+    /// ```
+    ColumnDefinition,
 }
 
 /// A SQL Parser
@@ -3578,6 +3585,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_column_definition_state() {
+                        self.parse_keyword(Keyword::NULL)
+                    } else {
+                        false
+                    };
                     if regexp || rlike {
                         Ok(Expr::RLike {
                             negated,
@@ -3587,6 +3599,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) {
@@ -3624,6 +3638,9 @@ impl<'a> Parser<'a> {
                         self.expected("IN or BETWEEN after NOT", 
self.peek_token())
                     }
                 }
+                Keyword::NOTNULL if dialect.supports_notnull_operator() => {
+                    Ok(Expr::IsNotNull(Box::new(expr)))
+                }
                 Keyword::MEMBER => {
                     if self.parse_keyword(Keyword::OF) {
                         self.expect_token(&Token::LParen)?;
@@ -7742,6 +7759,15 @@ impl<'a> Parser<'a> {
             return option;
         }
 
+        self.with_state(
+            ColumnDefinition,
+            |parser| -> Result<Option<ColumnOption>, ParserError> {
+                parser.parse_optional_column_option_inner()
+            },
+        )
+    }
+
+    fn parse_optional_column_option_inner(&mut self) -> 
Result<Option<ColumnOption>, ParserError> {
         if self.parse_keywords(&[Keyword::CHARACTER, Keyword::SET]) {
             Ok(Some(ColumnOption::CharacterSet(
                 self.parse_object_name(false)?,
@@ -7757,15 +7783,19 @@ 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()?)))
+            Ok(Some(ColumnOption::Default(
+                self.parse_column_option_expr()?,
+            )))
         } else if dialect_of!(self is ClickHouseDialect| GenericDialect)
             && self.parse_keyword(Keyword::MATERIALIZED)
         {
-            Ok(Some(ColumnOption::Materialized(self.parse_expr()?)))
+            Ok(Some(ColumnOption::Materialized(
+                self.parse_column_option_expr()?,
+            )))
         } else if dialect_of!(self is ClickHouseDialect| GenericDialect)
             && self.parse_keyword(Keyword::ALIAS)
         {
-            Ok(Some(ColumnOption::Alias(self.parse_expr()?)))
+            Ok(Some(ColumnOption::Alias(self.parse_column_option_expr()?)))
         } else if dialect_of!(self is ClickHouseDialect| GenericDialect)
             && self.parse_keyword(Keyword::EPHEMERAL)
         {
@@ -7774,7 +7804,9 @@ impl<'a> Parser<'a> {
             if matches!(self.peek_token().token, Token::Comma | Token::RParen) 
{
                 Ok(Some(ColumnOption::Ephemeral(None)))
             } else {
-                Ok(Some(ColumnOption::Ephemeral(Some(self.parse_expr()?))))
+                Ok(Some(ColumnOption::Ephemeral(Some(
+                    self.parse_column_option_expr()?,
+                ))))
             }
         } else if self.parse_keywords(&[Keyword::PRIMARY, Keyword::KEY]) {
             let characteristics = self.parse_constraint_characteristics()?;
@@ -7817,7 +7849,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)
@@ -7851,7 +7884,7 @@ impl<'a> Parser<'a> {
         } else if self.parse_keywords(&[Keyword::ON, Keyword::UPDATE])
             && dialect_of!(self is MySqlDialect | GenericDialect)
         {
-            let expr = self.parse_expr()?;
+            let expr = self.parse_column_option_expr()?;
             Ok(Some(ColumnOption::OnUpdate(expr)))
         } else if self.parse_keyword(Keyword::GENERATED) {
             self.parse_optional_column_option_generated()
@@ -7869,7 +7902,9 @@ impl<'a> Parser<'a> {
         } else if self.parse_keyword(Keyword::SRID)
             && dialect_of!(self is MySqlDialect | GenericDialect)
         {
-            Ok(Some(ColumnOption::Srid(Box::new(self.parse_expr()?))))
+            Ok(Some(ColumnOption::Srid(Box::new(
+                self.parse_column_option_expr()?,
+            ))))
         } else if self.parse_keyword(Keyword::IDENTITY)
             && dialect_of!(self is MsSqlDialect | GenericDialect)
         {
@@ -7909,6 +7944,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 
[Parser::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<Expr, ParserError> {
+        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<Tag, ParserError> {
         let name = self.parse_object_name(false)?;
         self.expect_token(&Token::Eq)?;
@@ -7953,7 +8013,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((
@@ -16532,6 +16592,10 @@ impl<'a> Parser<'a> {
             Ok(None)
         }
     }
+
+    pub(crate) fn in_column_definition_state(&self) -> bool {
+        matches!(self.state, ColumnDefinition)
+    }
 }
 
 fn maybe_prefixed_expr(expr: Expr, prefix: Option<Ident>) -> Expr {
@@ -17267,4 +17331,28 @@ mod tests {
 
         assert!(Parser::parse_sql(&MySqlDialect {}, sql).is_err());
     }
+
+    #[test]
+    fn test_parse_not_null_in_column_options() {
+        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(
+            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 9e5b6ce8..bc1431f9 100644
--- a/tests/sqlparser_clickhouse.rs
+++ b/tests/sqlparser_clickhouse.rs
@@ -1705,6 +1705,30 @@ 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 = 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(
+        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 {
     TestedDialects::new(vec![Box::new(ClickHouseDialect {})])
 }
diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs
index 4183c553..4995b941 100644
--- a/tests/sqlparser_common.rs
+++ b/tests/sqlparser_common.rs
@@ -16040,6 +16040,27 @@ fn parse_create_procedure_with_parameter_modes() {
     }
 }
 
+#[test]
+fn parse_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");
+
+    assert_matches!(
+        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,
+            ..
+        }
+    );
+}
+
 #[test]
 fn test_select_exclude() {
     let dialects = all_dialects_where(|d| 
d.supports_select_wildcard_exclude());
@@ -16183,3 +16204,29 @@ fn test_identifier_unicode_start() {
     ]);
     let _ = dialects.verified_stmt(sql);
 }
+
+#[test]
+fn parse_notnull() {
+    // Some dialects support `x NOTNULL` as an expression while others consider
+    // `x NOTNULL` like `x AS NOTNULL` and thus consider `NOTNULL` an alias 
for x.
+    let notnull_unsupported_dialects = all_dialects_except(|d| 
d.supports_notnull_operator());
+    let _ = notnull_unsupported_dialects
+        .verified_only_select_with_canonical("SELECT NULL NOTNULL", "SELECT 
NULL AS NOTNULL");
+
+    // Supported dialects consider `x NOTNULL` as an alias for `x IS NOT NULL`
+    let notnull_supported_dialects = all_dialects_where(|d| 
d.supports_notnull_operator());
+    let _ = notnull_supported_dialects.expr_parses_to("x NOTNULL", "x IS NOT 
NULL");
+
+    // For dialects which support it, `NOT NULL NOTNULL` should
+    // parse as `(NOT (NULL IS NOT NULL))`
+    assert_matches!(
+        notnull_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`
+    notnull_unsupported_dialects.expr_parses_to("NOT NULL NOTNULL", "NOT 
NULL");
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to