From c2bdc75197a3edf71515a9267f3c9c2ccbb654ab Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 3 Oct 2026 08:35:31 +0200 Subject: [PATCH 1/5] Parse SQLite's `IS` and `IS NOT` operators --- src/ast/operator.rs | 6 +++ src/dialect/mod.rs | 5 +++ src/dialect/sqlite.rs | 4 ++ src/parser/mod.rs | 49 ++++++++++++++++++---- tests/sqlparser_common.rs | 3 +- tests/sqlparser_sqlite.rs | 87 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 9 deletions(-) diff --git a/src/ast/operator.rs b/src/ast/operator.rs index 669470cdaa..f2c8454f34 100644 --- a/src/ast/operator.rs +++ b/src/ast/operator.rs @@ -332,6 +332,10 @@ pub enum BinaryOperator { /// ':=' Assignment Operator /// See Assignment, + /// `IS` operator + Is, + /// `IS NOT` operator + IsNot, } impl fmt::Display for BinaryOperator { @@ -409,6 +413,8 @@ impl fmt::Display for BinaryOperator { BinaryOperator::At => f.write_str("@"), BinaryOperator::TildeEq => f.write_str("~="), BinaryOperator::Assignment => f.write_str(":="), + BinaryOperator::Is => f.write_str("IS"), + BinaryOperator::IsNot => f.write_str("IS NOT"), } } } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 3ce71eb0be..a76d51bbce 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1693,6 +1693,11 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports binary `IS` and `IS NOT` operators. + fn supports_is_operator(&self) -> bool { + false + } + /// Returns true if this dialect allows an optional `SIGNED` suffix after integer data types. /// /// Example: diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs index 41b0e8be16..e2429638cf 100644 --- a/src/dialect/sqlite.rs +++ b/src/dialect/sqlite.rs @@ -129,6 +129,10 @@ impl Dialect for SQLiteDialect { true } + fn supports_is_operator(&self) -> bool { + true + } + fn supports_comma_separated_trim(&self) -> bool { true } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 276b252ed2..6c065560f4 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4188,6 +4188,31 @@ impl<'a> Parser<'a> { self.parse_is_json_predicate(expr, false) } else if self.parse_keywords(&[Keyword::NOT, Keyword::JSON]) { self.parse_is_json_predicate(expr, true) + } else if self.dialect.supports_is_operator() { + if let Some((form, negated)) = + self.maybe_parse(|parser| parser.parse_unicode_is_normalized_suffix())? + { + Ok(Expr::IsNormalized { + expr: Box::new(expr), + form, + negated, + }) + } else { + let op = if self.parse_keyword(Keyword::NOT) { + BinaryOperator::IsNot + } else { + BinaryOperator::Is + }; + // SQLite parses `IS` at the same left-associative level as the + // comparison operators, see + Ok(Expr::BinaryOp { + left: Box::new(expr), + op, + right: Box::new( + self.parse_subexpr(self.dialect.prec_value(Precedence::Eq))?, + ), + }) + } } else if let Ok(is_normalized) = self.parse_unicode_is_normalized(expr) { Ok(is_normalized) } else { @@ -12936,8 +12961,19 @@ impl<'a> Parser<'a> { /// Parse a literal unicode normalization clause pub fn parse_unicode_is_normalized(&mut self, expr: Expr) -> Result { - let neg = self.parse_keyword(Keyword::NOT); - let normalized_form = self.maybe_parse(|parser| { + let (form, negated) = self.parse_unicode_is_normalized_suffix()?; + Ok(Expr::IsNormalized { + expr: Box::new(expr), + form, + negated, + }) + } + + fn parse_unicode_is_normalized_suffix( + &mut self, + ) -> Result<(Option, bool), ParserError> { + let negated = self.parse_keyword(Keyword::NOT); + let form = self.maybe_parse(|parser| { match parser.parse_one_of_keywords(&[ Keyword::NFC, Keyword::NFD, @@ -12952,13 +12988,10 @@ impl<'a> Parser<'a> { } })?; if self.parse_keyword(Keyword::NORMALIZED) { - return Ok(Expr::IsNormalized { - expr: Box::new(expr), - form: normalized_form, - negated: neg, - }); + Ok((form, negated)) + } else { + self.expected_ref("unicode normalization form", self.peek_token_ref()) } - self.expected_ref("unicode normalization form", self.peek_token_ref()) } /// Parse parenthesized enum members, used with `ENUM(...)` type definitions. diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 87fb61dc17..5252890c63 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -11296,7 +11296,8 @@ fn parse_is_boolean() { ("SELECT s, s IS TRIM(' NFKC ') FROM foo", "TRIM"), ] { assert_eq!( - parse_sql_statements(sql), + all_dialects_except(|dialect| dialect.supports_is_operator()) + .parse_sql_statements(sql), Err(ParserError::ParserError(format!( "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS, found: {found}" ))), diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 1752e976cb..34492945fd 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1026,6 +1026,93 @@ fn test_non_bmp_identifiers() { ); } +#[test] +fn sqlite_is_operators() { + for (sql, op) in [ + ("a IS b", BinaryOperator::Is), + ("a IS NOT b", BinaryOperator::IsNot), + ] { + assert_eq!( + sqlite().verified_expr(sql), + Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("a"))), + op, + right: Box::new(Expr::Identifier(Ident::new("b"))), + } + ); + } + + assert_eq!( + sqlite().verified_expr("a IS b + c"), + Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("a"))), + op: BinaryOperator::Is, + right: Box::new(Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("b"))), + op: BinaryOperator::Plus, + right: Box::new(Expr::Identifier(Ident::new("c"))), + }), + } + ); + + assert_eq!( + sqlite().verified_expr("a IS b AND c IS NOT d"), + Expr::BinaryOp { + left: Box::new(Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("a"))), + op: BinaryOperator::Is, + right: Box::new(Expr::Identifier(Ident::new("b"))), + }), + op: BinaryOperator::And, + right: Box::new(Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("c"))), + op: BinaryOperator::IsNot, + right: Box::new(Expr::Identifier(Ident::new("d"))), + }), + } + ); + + assert_eq!( + sqlite().verified_expr("a IS b = c"), + Expr::BinaryOp { + left: Box::new(Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("a"))), + op: BinaryOperator::Is, + right: Box::new(Expr::Identifier(Ident::new("b"))), + }), + op: BinaryOperator::Eq, + right: Box::new(Expr::Identifier(Ident::new("c"))), + } + ); + + match sqlite().verified_expr("a IS b IN (1)") { + Expr::InList { + expr, + list, + negated, + } => { + assert!(!negated); + assert_eq!( + *expr, + Expr::BinaryOp { + left: Box::new(Expr::Identifier(Ident::new("a"))), + op: BinaryOperator::Is, + right: Box::new(Expr::Identifier(Ident::new("b"))), + } + ); + assert_eq!(list.len(), 1); + assert!(matches!( + list[0], + Expr::Value(ValueWithSpan { + value: Value::Number(_, _), + .. + }) + )); + } + other => panic!("expected InList on top, got {other:?}"), + } +} + #[test] fn parse_create_table_string_column_names() { sqlite().verified_stmt("CREATE TABLE t ('a')"); From 466780ae36a53fa4bb2f4f7bb6c63416d3e8188c Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 16 Oct 2026 23:51:50 +0200 Subject: [PATCH 2/5] Track the new InList.global field in the SQLite IS tests --- tests/sqlparser_sqlite.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 34492945fd..522117e13c 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1090,8 +1090,10 @@ fn sqlite_is_operators() { expr, list, negated, + global, } => { assert!(!negated); + assert!(!global); assert_eq!( *expr, Expr::BinaryOp { From 207a975b3d0768ca85e995b8f732bdf050f26b09 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 12:51:23 +0200 Subject: [PATCH 3/5] Cover missing `IS` operands and simplify normalization backtracking --- src/parser/mod.rs | 4 ++-- tests/sqlparser_sqlite.rs | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6c065560f4..8a24d93907 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4189,8 +4189,8 @@ impl<'a> Parser<'a> { } else if self.parse_keywords(&[Keyword::NOT, Keyword::JSON]) { self.parse_is_json_predicate(expr, true) } else if self.dialect.supports_is_operator() { - if let Some((form, negated)) = - self.maybe_parse(|parser| parser.parse_unicode_is_normalized_suffix())? + if let Ok((form, negated)) = + self.try_parse(|parser| parser.parse_unicode_is_normalized_suffix()) { Ok(Expr::IsNormalized { expr: Box::new(expr), diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 522117e13c..542337bc7d 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1113,6 +1113,16 @@ fn sqlite_is_operators() { } other => panic!("expected InList on top, got {other:?}"), } + + // A dangling right-hand side is rejected + for sql in ["SELECT a IS", "SELECT a IS NOT"] { + assert_eq!( + sqlite().parse_sql_statements(sql), + Err(ParserError::ParserError( + "Expected: an expression, found: EOF".to_string() + )) + ); + } } #[test] From 06b4fa7707283361a6c70cc733866947fd7bfdfe Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 17:55:57 +0200 Subject: [PATCH 4/5] Clarify SQLite `IS` precedence --- src/parser/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 8a24d93907..646874106c 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4203,8 +4203,7 @@ impl<'a> Parser<'a> { } else { BinaryOperator::Is }; - // SQLite parses `IS` at the same left-associative level as the - // comparison operators, see + // SQLite gives `IS` the same precedence as `=` (https://www.sqlite.org/lang_expr.html). Ok(Expr::BinaryOp { left: Box::new(expr), op, From 30f6228abfa7ea1dd153d08b29af077bf9bd40f7 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Wed, 21 Oct 2026 09:34:12 +0200 Subject: [PATCH 5/5] Adapt SQLite `IS` assertion to the unified `IN` AST --- tests/sqlparser_sqlite.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index 542337bc7d..7306028f72 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1086,9 +1086,9 @@ fn sqlite_is_operators() { ); match sqlite().verified_expr("a IS b IN (1)") { - Expr::InList { + Expr::In { expr, - list, + target: InTarget::List(list), negated, global, } => {