From 580eca18b9ff300695a4569267b83e217c1f61d2 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 27 Feb 2026 17:57:14 +0100 Subject: [PATCH 1/2] feat(parser): add IS [NOT] JSON predicate support Introduce AST and parser support for IS JSON predicates, including optional VALUE/SCALAR/ARRAY/OBJECT and WITH/WITHOUT UNIQUE [KEYS] modifiers. Gate parsing by dialect capability, enable Generic/ANSI/PostgreSQL/Oracle, update IS diagnostic hints accordingly, and include parser-side regression coverage. Also apply parser-only control-flow cleanups to keep strict clippy (-D warnings) green. --- src/ast/mod.rs | 76 ++++++++++++ src/ast/spans.rs | 6 + src/dialect/ansi.rs | 4 + src/dialect/generic.rs | 4 + src/dialect/mod.rs | 5 + src/dialect/oracle.rs | 4 + src/dialect/postgresql.rs | 4 + src/keywords.rs | 1 + src/parser/mod.rs | 255 ++++++++++++++++++++++++-------------- 9 files changed, 263 insertions(+), 96 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 97cc61935..b76118bfc 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -876,6 +876,17 @@ pub enum Expr { IsDistinctFrom(Box, Box), /// `IS NOT DISTINCT FROM` operator IsNotDistinctFrom(Box, Box), + /// ` IS [NOT] JSON [VALUE|SCALAR|ARRAY|OBJECT] [WITH|WITHOUT UNIQUE [KEYS]]` + IsJson { + /// Expression being tested. + expr: Box, + /// Optional JSON shape constraint. + kind: Option, + /// Optional duplicate-key handling constraint for JSON objects. + unique_keys: Option, + /// `true` when `NOT` is present. + negated: bool, + }, /// ` IS [ NOT ] [ form ] NORMALIZED` IsNormalized { /// Expression being tested. @@ -1685,6 +1696,25 @@ impl fmt::Display for Expr { Expr::IsNotNull(ast) => write!(f, "{ast} IS NOT NULL"), Expr::IsUnknown(ast) => write!(f, "{ast} IS UNKNOWN"), Expr::IsNotUnknown(ast) => write!(f, "{ast} IS NOT UNKNOWN"), + Expr::IsJson { + expr, + kind, + unique_keys, + negated, + } => { + write!(f, "{expr} IS ")?; + if *negated { + write!(f, "NOT ")?; + } + write!(f, "JSON")?; + if let Some(kind) = kind { + write!(f, " {kind}")?; + } + if let Some(unique_keys) = unique_keys { + write!(f, " {unique_keys}")?; + } + Ok(()) + } Expr::InList { expr, list, @@ -8107,6 +8137,52 @@ pub enum AnalyzeFormat { TREE, } +/// Optional type constraint for `IS JSON`. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum JsonPredicateType { + /// `VALUE` form. + Value, + /// `SCALAR` form. + Scalar, + /// `ARRAY` form. + Array, + /// `OBJECT` form. + Object, +} + +impl fmt::Display for JsonPredicateType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + JsonPredicateType::Value => write!(f, "VALUE"), + JsonPredicateType::Scalar => write!(f, "SCALAR"), + JsonPredicateType::Array => write!(f, "ARRAY"), + JsonPredicateType::Object => write!(f, "OBJECT"), + } + } +} + +/// Optional duplicate-key handling for `IS JSON`. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum JsonKeyUniqueness { + /// `WITH UNIQUE KEYS` form. + WithUniqueKeys, + /// `WITHOUT UNIQUE KEYS` form. + WithoutUniqueKeys, +} + +impl fmt::Display for JsonKeyUniqueness { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + JsonKeyUniqueness::WithUniqueKeys => write!(f, "WITH UNIQUE KEYS"), + JsonKeyUniqueness::WithoutUniqueKeys => write!(f, "WITHOUT UNIQUE KEYS"), + } + } +} + impl fmt::Display for AnalyzeFormat { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str(match self { diff --git a/src/ast/spans.rs b/src/ast/spans.rs index dd62c5ba1..c2b3fea7f 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1451,6 +1451,12 @@ impl Spanned for Expr { Expr::IsNotNull(expr) => expr.span(), Expr::IsUnknown(expr) => expr.span(), Expr::IsNotUnknown(expr) => expr.span(), + Expr::IsJson { + expr, + kind: _, + unique_keys: _, + negated: _, + } => expr.span(), Expr::IsDistinctFrom(lhs, rhs) => lhs.span().union(&rhs.span()), Expr::IsNotDistinctFrom(lhs, rhs) => lhs.span().union(&rhs.span()), Expr::InList { diff --git a/src/dialect/ansi.rs b/src/dialect/ansi.rs index 89c8a9ea2..811001685 100644 --- a/src/dialect/ansi.rs +++ b/src/dialect/ansi.rs @@ -39,4 +39,8 @@ impl Dialect for AnsiDialect { fn supports_nested_comments(&self) -> bool { true } + + fn supports_is_json_predicate(&self) -> bool { + true + } } diff --git a/src/dialect/generic.rs b/src/dialect/generic.rs index 1d5461fec..31c95f33a 100644 --- a/src/dialect/generic.rs +++ b/src/dialect/generic.rs @@ -285,6 +285,10 @@ impl Dialect for GenericDialect { true } + fn supports_is_json_predicate(&self) -> bool { + true + } + fn supports_comma_separated_trim(&self) -> bool { true } diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 8703e402c..8af4b416c 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -1399,6 +1399,11 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if the dialect supports the `IS [NOT] JSON` predicate. + fn supports_is_json_predicate(&self) -> bool { + false + } + /// Returns true if this dialect allows an optional `SIGNED` suffix after integer data types. /// /// Example: diff --git a/src/dialect/oracle.rs b/src/dialect/oracle.rs index dce0493d3..4bc6f4900 100644 --- a/src/dialect/oracle.rs +++ b/src/dialect/oracle.rs @@ -114,4 +114,8 @@ impl Dialect for OracleDialect { fn supports_insert_table_alias(&self) -> bool { true } + + fn supports_is_json_predicate(&self) -> bool { + true + } } diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs index b99a8b5c3..87a85becd 100644 --- a/src/dialect/postgresql.rs +++ b/src/dialect/postgresql.rs @@ -307,6 +307,10 @@ impl Dialect for PostgreSqlDialect { true } + fn supports_is_json_predicate(&self) -> bool { + true + } + fn supports_comma_separated_trim(&self) -> bool { true } diff --git a/src/keywords.rs b/src/keywords.rs index 80f679c07..166765e21 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -902,6 +902,7 @@ define_keywords!( SAFE_CAST, SAMPLE, SAVEPOINT, + SCALAR, SCHEMA, SCHEMAS, SCOPE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 75450f75d..a76bb9e74 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -506,12 +506,11 @@ impl<'a> Parser<'a> { match &self.peek_token_ref().token { Token::EOF => break, - // end of statement - Token::Word(word) => { - if expecting_statement_delimiter && word.keyword == Keyword::END { - break; - } + Token::Word(word) + if expecting_statement_delimiter && word.keyword == Keyword::END => + { + break; } _ => {} } @@ -1298,41 +1297,40 @@ impl<'a> Parser<'a> { let next_token = self.next_token(); match next_token.token { - t @ (Token::Word(_) | Token::SingleQuotedString(_)) => { - if self.peek_token_ref().token == Token::Period { - let mut id_parts: Vec = vec![match t { - Token::Word(w) => w.into_ident(next_token.span), - Token::SingleQuotedString(s) => Ident::with_quote('\'', s), - _ => { - return Err(ParserError::ParserError( - "Internal parser error: unexpected token type".to_string(), - )) + t @ (Token::Word(_) | Token::SingleQuotedString(_)) + if self.peek_token_ref().token == Token::Period => + { + let mut id_parts: Vec = vec![match t { + Token::Word(w) => w.into_ident(next_token.span), + Token::SingleQuotedString(s) => Ident::with_quote('\'', s), + _ => { + return Err(ParserError::ParserError( + "Internal parser error: unexpected token type".to_string(), + )) + } + }]; + + while self.consume_token(&Token::Period) { + let next_token = self.next_token(); + match next_token.token { + Token::Word(w) => id_parts.push(w.into_ident(next_token.span)), + Token::SingleQuotedString(s) => { + // SQLite has single-quoted identifiers + id_parts.push(Ident::with_quote('\'', s)) } - }]; - - while self.consume_token(&Token::Period) { - let next_token = self.next_token(); - match next_token.token { - Token::Word(w) => id_parts.push(w.into_ident(next_token.span)), - Token::SingleQuotedString(s) => { - // SQLite has single-quoted identifiers - id_parts.push(Ident::with_quote('\'', s)) - } - Token::Placeholder(s) => { - // Snowflake uses $1, $2, etc. for positional column references - // in staged data queries like: SELECT t.$1 FROM @stage t - id_parts.push(Ident::new(s)) - } - Token::Mul => { - return Ok(Expr::QualifiedWildcard( - ObjectName::from(id_parts), - AttachedToken(next_token), - )); - } - _ => { - return self - .expected("an identifier or a '*' after '.'", next_token); - } + Token::Placeholder(s) => { + // Snowflake uses $1, $2, etc. for positional column references + // in staged data queries like: SELECT t.$1 FROM @stage t + id_parts.push(Ident::new(s)) + } + Token::Mul => { + return Ok(Expr::QualifiedWildcard( + ObjectName::from(id_parts), + AttachedToken(next_token), + )); + } + _ => { + return self.expected("an identifier or a '*' after '.'", next_token); } } } @@ -3952,13 +3950,23 @@ impl<'a> Parser<'a> { { let expr2 = self.parse_expr()?; Ok(Expr::IsNotDistinctFrom(Box::new(expr), Box::new(expr2))) + } else if self.dialect.supports_is_json_predicate() + && self.parse_keyword(Keyword::JSON) + { + self.parse_is_json_predicate(expr, false) + } else if self.dialect.supports_is_json_predicate() + && self.parse_keywords(&[Keyword::NOT, Keyword::JSON]) + { + self.parse_is_json_predicate(expr, true) } else if let Ok(is_normalized) = self.parse_unicode_is_normalized(expr) { Ok(is_normalized) } else { - self.expected_ref( - "[NOT] NULL | TRUE | FALSE | DISTINCT | [form] NORMALIZED FROM after IS", - self.peek_token_ref(), - ) + let expected = if self.dialect.supports_is_json_predicate() { + "[NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS" + } else { + "[NOT] NULL | TRUE | FALSE | DISTINCT | [form] NORMALIZED FROM after IS" + }; + self.expected_ref(expected, self.peek_token_ref()) } } Keyword::AT => { @@ -4989,10 +4997,10 @@ impl<'a> Parser<'a> { loop { match &self.peek_nth_token_ref(0).token { Token::EOF => break, - Token::Word(w) => { - if w.quote_style.is_none() && terminal_keywords.contains(&w.keyword) { - break; - } + Token::Word(w) + if w.quote_style.is_none() && terminal_keywords.contains(&w.keyword) => + { + break; } _ => {} } @@ -8172,71 +8180,66 @@ impl<'a> Parser<'a> { Keyword::LINES, Keyword::NULL, ]) { - Some(Keyword::FIELDS) => { - if self.parse_keywords(&[Keyword::TERMINATED, Keyword::BY]) { + Some(Keyword::FIELDS) + if self.parse_keywords(&[Keyword::TERMINATED, Keyword::BY]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::FieldsTerminatedBy, + char: self.parse_identifier()?, + }); + + if self.parse_keywords(&[Keyword::ESCAPED, Keyword::BY]) { row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::FieldsTerminatedBy, + delimiter: HiveDelimiter::FieldsEscapedBy, char: self.parse_identifier()?, }); - - if self.parse_keywords(&[Keyword::ESCAPED, Keyword::BY]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::FieldsEscapedBy, - char: self.parse_identifier()?, - }); - } - } else { - break; } } - Some(Keyword::COLLECTION) => { + Some(Keyword::FIELDS) => break, + Some(Keyword::COLLECTION) if self.parse_keywords(&[ Keyword::ITEMS, Keyword::TERMINATED, Keyword::BY, - ]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::CollectionItemsTerminatedBy, - char: self.parse_identifier()?, - }); - } else { - break; - } + ]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::CollectionItemsTerminatedBy, + char: self.parse_identifier()?, + }); } - Some(Keyword::MAP) => { + Some(Keyword::COLLECTION) => break, + Some(Keyword::MAP) if self.parse_keywords(&[ Keyword::KEYS, Keyword::TERMINATED, Keyword::BY, - ]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::MapKeysTerminatedBy, - char: self.parse_identifier()?, - }); - } else { - break; - } + ]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::MapKeysTerminatedBy, + char: self.parse_identifier()?, + }); } - Some(Keyword::LINES) => { - if self.parse_keywords(&[Keyword::TERMINATED, Keyword::BY]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::LinesTerminatedBy, - char: self.parse_identifier()?, - }); - } else { - break; - } + Some(Keyword::MAP) => break, + Some(Keyword::LINES) + if self.parse_keywords(&[Keyword::TERMINATED, Keyword::BY]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::LinesTerminatedBy, + char: self.parse_identifier()?, + }); } - Some(Keyword::NULL) => { - if self.parse_keywords(&[Keyword::DEFINED, Keyword::AS]) { - row_delimiters.push(HiveRowDelimiter { - delimiter: HiveDelimiter::NullDefinedAs, - char: self.parse_identifier()?, - }); - } else { - break; - } + Some(Keyword::LINES) => break, + Some(Keyword::NULL) + if self.parse_keywords(&[Keyword::DEFINED, Keyword::AS]) => + { + row_delimiters.push(HiveRowDelimiter { + delimiter: HiveDelimiter::NullDefinedAs, + char: self.parse_identifier()?, + }); } + Some(Keyword::NULL) => break, _ => { break; } @@ -11710,6 +11713,43 @@ impl<'a> Parser<'a> { } } + /// Parse the `IS [NOT] JSON` predicate after `JSON` (and optional `NOT`) was consumed. + fn parse_is_json_predicate(&mut self, expr: Expr, negated: bool) -> Result { + let kind = match self.parse_one_of_keywords(&[ + Keyword::VALUE, + Keyword::SCALAR, + Keyword::ARRAY, + Keyword::OBJECT, + ]) { + Some(Keyword::VALUE) => Some(JsonPredicateType::Value), + Some(Keyword::SCALAR) => Some(JsonPredicateType::Scalar), + Some(Keyword::ARRAY) => Some(JsonPredicateType::Array), + Some(Keyword::OBJECT) => Some(JsonPredicateType::Object), + _ => None, + }; + + let unique_keys = match self.parse_one_of_keywords(&[Keyword::WITH, Keyword::WITHOUT]) { + Some(Keyword::WITH) => { + self.expect_keyword_is(Keyword::UNIQUE)?; + let _ = self.parse_keyword(Keyword::KEYS); + Some(JsonKeyUniqueness::WithUniqueKeys) + } + Some(Keyword::WITHOUT) => { + self.expect_keyword_is(Keyword::UNIQUE)?; + let _ = self.parse_keyword(Keyword::KEYS); + Some(JsonKeyUniqueness::WithoutUniqueKeys) + } + _ => None, + }; + + Ok(Expr::IsJson { + expr: Box::new(expr), + kind, + unique_keys, + negated, + }) + } + /// Parse a literal unicode normalization clause pub fn parse_unicode_is_normalized(&mut self, expr: Expr) -> Result { let neg = self.parse_keyword(Keyword::NOT); @@ -20484,12 +20524,35 @@ mod tests { assert_eq!( ast, Err(ParserError::ParserError( - "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [form] NORMALIZED FROM after IS, found: a at Line: 1, Column: 16" + "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS, found: a at Line: 1, Column: 16" .to_string() )) ); } + #[test] + fn test_is_predicate_error_hint_depends_on_dialect() { + let sql = "SELECT this is a syntax error"; + + let generic_err = Parser::parse_sql(&GenericDialect, sql).unwrap_err(); + let ParserError::ParserError(generic_msg) = generic_err else { + panic!("Expected ParserError::ParserError, got: {generic_err:?}"); + }; + assert!( + generic_msg.contains("[NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT]"), + "Expected Generic dialect to include JSON predicate hint, got: {generic_msg}" + ); + + let mysql_err = Parser::parse_sql(&MySqlDialect {}, sql).unwrap_err(); + let ParserError::ParserError(mysql_msg) = mysql_err else { + panic!("Expected ParserError::ParserError, got: {mysql_err:?}"); + }; + assert!( + !mysql_msg.contains("[NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT]"), + "Expected MySQL dialect to exclude JSON predicate hint, got: {mysql_msg}" + ); + } + #[test] fn test_nested_explain_error() { let sql = "EXPLAIN EXPLAIN SELECT 1"; From 3014f8cb6242affdbfa9a7f7cc5ed4129f51e714 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 27 Feb 2026 17:57:20 +0100 Subject: [PATCH 2/2] test(parser): expand IS JSON coverage across dialect and error paths Add integration coverage for supported IS JSON forms and unsupported-dialect failures for both IS JSON and IS NOT JSON. Tighten malformed-case assertions with token-specific diagnostics, including junk tails and duplicate WITH/WITHOUT UNIQUE KEYS clauses. --- tests/sqlparser_common.rs | 258 +++++++++++++++++++++++++++++++++++++- 1 file changed, 254 insertions(+), 4 deletions(-) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 7bf276407..ca9b3e2b3 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -10747,8 +10747,19 @@ fn parse_is_boolean() { verified_stmt("SELECT f FROM foo WHERE field IS UNKNOWN"); verified_stmt("SELECT f FROM foo WHERE field IS NOT UNKNOWN"); + let supported_dialects = all_dialects_where(|d| d.supports_is_json_predicate()); + let unsupported_dialects = all_dialects_where(|d| !d.supports_is_json_predicate()); + let sql = "SELECT f from foo where field is 0"; - let res = parse_sql_statements(sql); + let res = supported_dialects.parse_sql_statements(sql); + assert_eq!( + ParserError::ParserError( + "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS, found: 0" + .to_string() + ), + res.unwrap_err() + ); + let res = unsupported_dialects.parse_sql_statements(sql); assert_eq!( ParserError::ParserError( "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [form] NORMALIZED FROM after IS, found: 0" @@ -10758,7 +10769,15 @@ fn parse_is_boolean() { ); let sql = "SELECT s, s IS XYZ NORMALIZED FROM foo"; - let res = parse_sql_statements(sql); + let res = supported_dialects.parse_sql_statements(sql); + assert_eq!( + ParserError::ParserError( + "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS, found: XYZ" + .to_string() + ), + res.unwrap_err() + ); + let res = unsupported_dialects.parse_sql_statements(sql); assert_eq!( ParserError::ParserError( "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [form] NORMALIZED FROM after IS, found: XYZ" @@ -10768,7 +10787,15 @@ fn parse_is_boolean() { ); let sql = "SELECT s, s IS NFKC FROM foo"; - let res = parse_sql_statements(sql); + let res = supported_dialects.parse_sql_statements(sql); + assert_eq!( + ParserError::ParserError( + "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS, found: FROM" + .to_string() + ), + res.unwrap_err() + ); + let res = unsupported_dialects.parse_sql_statements(sql); assert_eq!( ParserError::ParserError( "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [form] NORMALIZED FROM after IS, found: FROM" @@ -10778,7 +10805,15 @@ fn parse_is_boolean() { ); let sql = "SELECT s, s IS TRIM(' NFKC ') FROM foo"; - let res = parse_sql_statements(sql); + let res = supported_dialects.parse_sql_statements(sql); + assert_eq!( + ParserError::ParserError( + "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS, found: TRIM" + .to_string() + ), + res.unwrap_err() + ); + let res = unsupported_dialects.parse_sql_statements(sql); assert_eq!( ParserError::ParserError( "Expected: [NOT] NULL | TRUE | FALSE | DISTINCT | [form] NORMALIZED FROM after IS, found: TRIM" @@ -10788,6 +10823,221 @@ fn parse_is_boolean() { ); } +#[test] +fn parse_is_json_predicate() { + use self::Expr::*; + + let supported_dialects = all_dialects_where(|d| d.supports_is_json_predicate()); + + let sql = "a IS JSON"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: None, + unique_keys: None, + negated: false, + }, + supported_dialects.verified_expr(sql) + ); + + let sql = "a IS NOT JSON"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: None, + unique_keys: None, + negated: true, + }, + supported_dialects.verified_expr(sql) + ); + + let sql = "a IS JSON VALUE"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: Some(JsonPredicateType::Value), + unique_keys: None, + negated: false, + }, + supported_dialects.verified_expr(sql) + ); + + let sql = "a IS JSON SCALAR"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: Some(JsonPredicateType::Scalar), + unique_keys: None, + negated: false, + }, + supported_dialects.verified_expr(sql) + ); + + let sql = "a IS JSON ARRAY"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: Some(JsonPredicateType::Array), + unique_keys: None, + negated: false, + }, + supported_dialects.verified_expr(sql) + ); + + let sql = "a IS JSON OBJECT"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: Some(JsonPredicateType::Object), + unique_keys: None, + negated: false, + }, + supported_dialects.verified_expr(sql) + ); + + let sql = "a IS JSON WITH UNIQUE KEYS"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: None, + unique_keys: Some(JsonKeyUniqueness::WithUniqueKeys), + negated: false, + }, + supported_dialects.verified_expr(sql) + ); + + let sql = "a IS JSON WITHOUT UNIQUE KEYS"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: None, + unique_keys: Some(JsonKeyUniqueness::WithoutUniqueKeys), + negated: false, + }, + supported_dialects.verified_expr(sql) + ); + + let sql = "a IS NOT JSON OBJECT WITHOUT UNIQUE KEYS"; + assert_eq!( + IsJson { + expr: Box::new(Identifier(Ident::new("a"))), + kind: Some(JsonPredicateType::Object), + unique_keys: Some(JsonKeyUniqueness::WithoutUniqueKeys), + negated: true, + }, + supported_dialects.verified_expr(sql) + ); + + supported_dialects.expr_parses_to("a IS JSON WITH UNIQUE", "a IS JSON WITH UNIQUE KEYS"); + supported_dialects.expr_parses_to("a IS JSON WITHOUT UNIQUE", "a IS JSON WITHOUT UNIQUE KEYS"); + + assert_matches!( + supported_dialects.verified_expr("NOT a IS JSON"), + Expr::UnaryOp { + op: UnaryOperator::Not, + expr + } if matches!(&*expr, Expr::IsJson { .. }) + ); +} + +#[test] +fn parse_is_json_predicate_unsupported_dialects() { + let unsupported_dialects = all_dialects_where(|d| !d.supports_is_json_predicate()); + assert!(!unsupported_dialects.dialects.is_empty()); + + for sql in ["SELECT a IS JSON FROM t", "SELECT a IS NOT JSON FROM t"] { + let err = unsupported_dialects.parse_sql_statements(sql).unwrap_err(); + let ParserError::ParserError(msg) = err else { + panic!("Expected ParserError::ParserError for `{sql}`, got: {err:?}"); + }; + assert!( + msg.contains("[NOT] NULL | TRUE | FALSE | DISTINCT | [form] NORMALIZED FROM after IS"), + "Unexpected error hint for unsupported dialects in `{sql}`: {msg}" + ); + assert!( + !msg.contains("[NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT]"), + "Unsupported dialects should not advertise JSON IS-predicate syntax in `{sql}`: {msg}" + ); + assert!( + msg.contains("found: JSON"), + "Expected parser to fail at JSON token for unsupported dialects in `{sql}`: {msg}" + ); + } +} + +#[test] +fn parse_is_json_predicate_negative() { + let supported_dialects = all_dialects_where(|d| d.supports_is_json_predicate()); + + let cases = [ + ( + "SELECT * FROM t WHERE a IS JSON WITH FROM", + &["Expected: UNIQUE", "found: FROM"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITH KEYS", + &["Expected: UNIQUE", "found: KEYS"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITHOUT FROM", + &["Expected: UNIQUE", "found: FROM"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITHOUT KEYS", + &["Expected: UNIQUE", "found: KEYS"][..], + ), + ( + "SELECT * FROM t WHERE a IS NOT JSON WITH FROM", + &["Expected: UNIQUE", "found: FROM"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON VALUE ARRAY", + &["Expected: end of statement", "found: ARRAY"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON OBJECT VALUE", + &["Expected: end of statement", "found: VALUE"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITH UNIQUE EXTRA", + &["Expected: end of statement", "found: EXTRA"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITH UNIQUE KEYS EXTRA", + &["Expected: end of statement", "found: EXTRA"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITHOUT UNIQUE EXTRA", + &["Expected: end of statement", "found: EXTRA"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITHOUT UNIQUE KEYS EXTRA", + &["Expected: end of statement", "found: EXTRA"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITH UNIQUE KEYS WITH UNIQUE KEYS", + &["Expected: end of statement", "found: WITH"][..], + ), + ( + "SELECT * FROM t WHERE a IS JSON WITHOUT UNIQUE KEYS WITHOUT UNIQUE KEYS", + &["Expected: end of statement", "found: WITHOUT"][..], + ), + ]; + + for (sql, expected_fragments) in cases { + let err = supported_dialects.parse_sql_statements(sql).unwrap_err(); + let ParserError::ParserError(msg) = err else { + panic!("Expected ParserError::ParserError for `{sql}`, got: {err:?}"); + }; + for fragment in expected_fragments { + assert!( + msg.contains(fragment), + "Expected parser diagnostic for `{sql}` to contain `{fragment}`, got: {msg}" + ); + } + } +} + #[test] fn parse_discard() { let sql = "DISCARD ALL";