From 0cf9537286b6031d89185add95bee2b93ea249f1 Mon Sep 17 00:00:00 2001 From: Ben Herzberg Date: Mon, 14 Sep 2026 16:15:35 +0300 Subject: [PATCH] Postgres: Support IS DOCUMENT predicate --- src/ast/mod.rs | 14 ++++++++++++++ src/ast/spans.rs | 1 + src/parser/mod.rs | 35 ++++++++++++++++++++++++++++++++++- tests/sqlparser_common.rs | 12 ++++++++++++ 4 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 20058b83ab..e05e50095d 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -944,6 +944,13 @@ pub enum Expr { IsUnknown(Box), /// `IS NOT UNKNOWN` operator IsNotUnknown(Box), + /// ` IS [NOT] DOCUMENT` + IsDocument { + /// Expression being tested. + expr: Box, + /// `true` when `NOT` is present. + negated: bool, + }, /// `IS DISTINCT FROM` operator IsDistinctFrom(Box, Box), /// `IS NOT DISTINCT FROM` operator @@ -1764,6 +1771,13 @@ 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::IsDocument { expr, negated } => { + write!(f, "{expr} IS ")?; + if *negated { + write!(f, "NOT ")?; + } + write!(f, "DOCUMENT") + } Expr::IsJson { expr, kind, diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 7acbd7d0b4..7848660c5e 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -1497,6 +1497,7 @@ impl Spanned for Expr { Expr::IsNotNull(expr) => expr.span(), Expr::IsUnknown(expr) => expr.span(), Expr::IsNotUnknown(expr) => expr.span(), + Expr::IsDocument { expr, negated: _ } => expr.span(), Expr::IsJson { expr, kind: _, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 15f135fffa..d7ab119d09 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4091,6 +4091,15 @@ impl<'a> Parser<'a> { Ok(Expr::IsUnknown(Box::new(expr))) } else if self.parse_keywords(&[Keyword::NOT, Keyword::UNKNOWN]) { Ok(Expr::IsNotUnknown(Box::new(expr))) + } else if let Some(negated) = if dialect.supports_xml_expressions() { + self.parse_is_document_predicate() + } else { + None + } { + Ok(Expr::IsDocument { + expr: Box::new(expr), + negated, + }) } else if self.parse_keywords(&[Keyword::DISTINCT, Keyword::FROM]) { // The right operand binds no more loosely than `IS` // itself, so that e.g. `a IS DISTINCT FROM b AND c` @@ -4109,7 +4118,7 @@ impl<'a> Parser<'a> { Ok(is_normalized) } else { self.expected_ref( - "[NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS", + "[NOT] NULL | TRUE | FALSE | DISTINCT | [NOT] DOCUMENT | [NOT] JSON [VALUE | SCALAR | ARRAY | OBJECT] [WITH | WITHOUT UNIQUE [KEYS]] | [form] NORMALIZED FROM after IS", self.peek_token_ref(), ) } @@ -4752,6 +4761,16 @@ impl<'a> Parser<'a> { } } + fn parse_unquoted_word(&mut self, expected: &str) -> bool { + match &self.peek_token_ref().token { + Token::Word(w) if w.quote_style.is_none() && w.value.eq_ignore_ascii_case(expected) => { + self.advance_token(); + true + } + _ => false, + } + } + #[must_use] /// Check if the current token is the expected keyword without consuming it. /// @@ -12742,6 +12761,20 @@ impl<'a> Parser<'a> { }) } + fn parse_is_document_predicate(&mut self) -> Option { + if self.parse_unquoted_word("document") { + return Some(false); + } + + let start_index = self.index; + if self.parse_keyword(Keyword::NOT) && self.parse_unquoted_word("document") { + Some(true) + } else { + self.index = start_index; + None + } + } + /// Parse a literal unicode normalization clause pub fn parse_unicode_is_normalized(&mut self, expr: Expr) -> Result { let neg = self.parse_keyword(Keyword::NOT); diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 2de6062b28..dc3c79ee34 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -19837,6 +19837,18 @@ fn parse_xmlparse() { .is_err()); } +#[test] +fn parse_xml_is_document_predicate() { + let dialects = all_dialects_where(|d| d.supports_xml_expressions()); + dialects.one_statement_parses_to("SELECT '' IS document", "SELECT '' IS DOCUMENT"); + dialects.verified_stmt("SELECT '' IS NOT DOCUMENT"); + + let others = all_dialects_except(|d| d.supports_xml_expressions()); + assert!(others + .parse_sql_statements("SELECT '' IS DOCUMENT") + .is_err()); +} + /// Regression test for the 2^N parse-time blowup in `parse_compound_expr` on /// inputs like `IF a0.a1...aN.#`. The parse is run on a worker thread and the /// main thread asserts that it reports back within a generous timeout. Post-fix