You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by lu...@apache.org on 2026/10/03 07:19:19 UTC

(datafusion-sqlparser-rs) branch upstream/postgres-generic-typed-literal created (now 90e41abd)

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

LucaCappelletti94 pushed a change to branch upstream/postgres-generic-typed-literal
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git


      at 90e41abd PostgreSQL: Parse typed string literals of user-defined types

This branch includes the following new commits:

     new 90e41abd PostgreSQL: Parse typed string literals of user-defined types

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org


(datafusion-sqlparser-rs) 01/01: PostgreSQL: Parse typed string literals of user-defined types

Posted by lu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

LucaCappelletti94 pushed a commit to branch upstream/postgres-generic-typed-literal
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git

commit 90e41abd930773d8d925adaac45eb2c12c79f197
Author: LucaCappelletti94 <ca...@gmail.com>
AuthorDate: Sat Oct 3 09:19:10 2026 +0200

    PostgreSQL: Parse typed string literals of user-defined types
---
 src/dialect/mod.rs          |   6 ++
 src/dialect/postgresql.rs   | 164 ++++++++++++++++++++++++++++++++++++++++++++
 src/parser/mod.rs           |  66 ++++++++++++------
 tests/sqlparser_common.rs   |   4 +-
 tests/sqlparser_postgres.rs |  48 +++++++++++++
 5 files changed, 265 insertions(+), 23 deletions(-)

diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs
index 2beb5655..f9f7cc40 100644
--- a/src/dialect/mod.rs
+++ b/src/dialect/mod.rs
@@ -1928,6 +1928,12 @@ pub trait Dialect: Debug + Any {
         false
     }
 
+    /// Returns true if a custom type name starting with `first_keyword` may precede
+    /// a string literal to form a typed string, as in PostgreSQL's `mood 'happy'`.
+    fn is_custom_type_typed_string(&self, _first_keyword: Keyword, _qualified: bool) -> bool {
+        false
+    }
+
     /// Returns true if the dialect supports aliased function arguments,
     /// e.g. `XMLFOREST(a AS x)` in PostgreSQL.
     fn supports_aliased_function_args(&self) -> bool {
diff --git a/src/dialect/postgresql.rs b/src/dialect/postgresql.rs
index 3bec6ceb..f8e9455f 100644
--- a/src/dialect/postgresql.rs
+++ b/src/dialect/postgresql.rs
@@ -49,6 +49,160 @@ const RESERVED_EXCLUSIONS_FOR_TABLE_ALIAS: &[Keyword] = &[
     Keyword::VIEW,
 ];
 
+/// [PostgreSQL reserved keywords](https://www.postgresql.org/docs/current/sql-keywords-appendix.html) known to [`Keyword`].
+const RESERVED_KEYWORDS: &[Keyword] = &[
+    Keyword::ALL,
+    Keyword::ANALYZE,
+    Keyword::AND,
+    Keyword::ANY,
+    Keyword::ARRAY,
+    Keyword::AS,
+    Keyword::ASC,
+    Keyword::ASYMMETRIC,
+    Keyword::BOTH,
+    Keyword::CASE,
+    Keyword::CAST,
+    Keyword::CHECK,
+    Keyword::COLLATE,
+    Keyword::COLUMN,
+    Keyword::CONSTRAINT,
+    Keyword::CREATE,
+    Keyword::CURRENT_CATALOG,
+    Keyword::CURRENT_DATE,
+    Keyword::CURRENT_ROLE,
+    Keyword::CURRENT_TIME,
+    Keyword::CURRENT_TIMESTAMP,
+    Keyword::CURRENT_USER,
+    Keyword::DEFAULT,
+    Keyword::DEFERRABLE,
+    Keyword::DESC,
+    Keyword::DISTINCT,
+    Keyword::DO,
+    Keyword::ELSE,
+    Keyword::END,
+    Keyword::EXCEPT,
+    Keyword::FALSE,
+    Keyword::FETCH,
+    Keyword::FOR,
+    Keyword::FOREIGN,
+    Keyword::FROM,
+    Keyword::GRANT,
+    Keyword::GROUP,
+    Keyword::HAVING,
+    Keyword::IN,
+    Keyword::INITIALLY,
+    Keyword::INTERSECT,
+    Keyword::INTO,
+    Keyword::LATERAL,
+    Keyword::LEADING,
+    Keyword::LIMIT,
+    Keyword::LOCALTIME,
+    Keyword::LOCALTIMESTAMP,
+    Keyword::NOT,
+    Keyword::NULL,
+    Keyword::OFFSET,
+    Keyword::ON,
+    Keyword::ONLY,
+    Keyword::OR,
+    Keyword::ORDER,
+    Keyword::PLACING,
+    Keyword::PRIMARY,
+    Keyword::REFERENCES,
+    Keyword::RETURNING,
+    Keyword::SELECT,
+    Keyword::SESSION_USER,
+    Keyword::SOME,
+    Keyword::SYMMETRIC,
+    Keyword::SYSTEM_USER,
+    Keyword::TABLE,
+    Keyword::THEN,
+    Keyword::TO,
+    Keyword::TRAILING,
+    Keyword::TRUE,
+    Keyword::UNION,
+    Keyword::UNIQUE,
+    Keyword::USER,
+    Keyword::USING,
+    Keyword::VARIADIC,
+    Keyword::WHEN,
+    Keyword::WHERE,
+    Keyword::WINDOW,
+    Keyword::WITH,
+];
+
+/// PostgreSQL non-reserved keywords that cannot be function or type names, known to [`Keyword`].
+const COL_NAME_KEYWORDS: &[Keyword] = &[
+    Keyword::BETWEEN,
+    Keyword::BIGINT,
+    Keyword::BIT,
+    Keyword::BOOLEAN,
+    Keyword::CHAR,
+    Keyword::CHARACTER,
+    Keyword::COALESCE,
+    Keyword::DEC,
+    Keyword::DECIMAL,
+    Keyword::EXISTS,
+    Keyword::EXTRACT,
+    Keyword::FLOAT,
+    Keyword::GROUPING,
+    Keyword::INOUT,
+    Keyword::INT,
+    Keyword::INTEGER,
+    Keyword::INTERVAL,
+    Keyword::JSON,
+    Keyword::JSON_TABLE,
+    Keyword::LEAST,
+    Keyword::NATIONAL,
+    Keyword::NCHAR,
+    Keyword::NONE,
+    Keyword::NORMALIZE,
+    Keyword::NULLIF,
+    Keyword::NUMERIC,
+    Keyword::OUT,
+    Keyword::OVERLAY,
+    Keyword::POSITION,
+    Keyword::PRECISION,
+    Keyword::REAL,
+    Keyword::ROW,
+    Keyword::SETOF,
+    Keyword::SMALLINT,
+    Keyword::SUBSTRING,
+    Keyword::TIME,
+    Keyword::TIMESTAMP,
+    Keyword::TREAT,
+    Keyword::TRIM,
+    Keyword::VALUES,
+    Keyword::VARCHAR,
+    Keyword::XMLNAMESPACES,
+    Keyword::XMLTABLE,
+];
+
+/// PostgreSQL reserved keywords that can be function or type names, known to [`Keyword`].
+const TYPE_FUNC_NAME_KEYWORDS: &[Keyword] = &[
+    Keyword::AUTHORIZATION,
+    Keyword::BINARY,
+    Keyword::COLLATION,
+    Keyword::CONCURRENTLY,
+    Keyword::CROSS,
+    Keyword::CURRENT_SCHEMA,
+    Keyword::FREEZE,
+    Keyword::FULL,
+    Keyword::ILIKE,
+    Keyword::INNER,
+    Keyword::IS,
+    Keyword::JOIN,
+    Keyword::LEFT,
+    Keyword::LIKE,
+    Keyword::NATURAL,
+    Keyword::NOTNULL,
+    Keyword::OUTER,
+    Keyword::OVERLAPS,
+    Keyword::RIGHT,
+    Keyword::SIMILAR,
+    Keyword::TABLESAMPLE,
+    Keyword::VERBOSE,
+];
+
 /// A [`Dialect`] for [PostgreSQL](https://www.postgresql.org/)
 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -357,6 +511,16 @@ impl Dialect for PostgreSqlDialect {
         true
     }
 
+    /// See <https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS-GENERIC>
+    fn is_custom_type_typed_string(&self, first_keyword: Keyword, qualified: bool) -> bool {
+        let not_a_type_name = if qualified {
+            TYPE_FUNC_NAME_KEYWORDS
+        } else {
+            COL_NAME_KEYWORDS
+        };
+        !RESERVED_KEYWORDS.contains(&first_keyword) && !not_a_type_name.contains(&first_keyword)
+    }
+
     fn supports_aliased_function_args(&self) -> bool {
         true
     }
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index 1a46c6e5..ad710488 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -1659,17 +1659,26 @@ impl<'a> Parser<'a> {
             Keyword::LAMBDA if self.dialect.supports_lambda_keyword_syntax() => {
                 Ok(Some(self.parse_lambda_expr()?))
             }
-            _ if self.dialect.supports_geometric_types() => match w.keyword {
-                Keyword::CIRCLE => Ok(Some(self.parse_geometric_type(GeometricTypeKind::Circle)?)),
-                Keyword::BOX => Ok(Some(self.parse_geometric_type(GeometricTypeKind::GeometricBox)?)),
-                Keyword::PATH => Ok(Some(self.parse_geometric_type(GeometricTypeKind::GeometricPath)?)),
-                Keyword::LINE => Ok(Some(self.parse_geometric_type(GeometricTypeKind::Line)?)),
-                Keyword::LSEG => Ok(Some(self.parse_geometric_type(GeometricTypeKind::LineSegment)?)),
-                Keyword::POINT => Ok(Some(self.parse_geometric_type(GeometricTypeKind::Point)?)),
-                Keyword::POLYGON => Ok(Some(self.parse_geometric_type(GeometricTypeKind::Polygon)?)),
-                _ => Ok(None),
+            _ => match self.geometric_type_kind(w.keyword) {
+                Some(kind) => Ok(Some(self.parse_geometric_type(kind)?)),
+                None => Ok(None),
             },
-            _ => Ok(None),
+        }
+    }
+
+    fn geometric_type_kind(&self, keyword: Keyword) -> Option<GeometricTypeKind> {
+        if !self.dialect.supports_geometric_types() {
+            return None;
+        }
+        match keyword {
+            Keyword::CIRCLE => Some(GeometricTypeKind::Circle),
+            Keyword::BOX => Some(GeometricTypeKind::GeometricBox),
+            Keyword::PATH => Some(GeometricTypeKind::GeometricPath),
+            Keyword::LINE => Some(GeometricTypeKind::Line),
+            Keyword::LSEG => Some(GeometricTypeKind::LineSegment),
+            Keyword::POINT => Some(GeometricTypeKind::Point),
+            Keyword::POLYGON => Some(GeometricTypeKind::Polygon),
+            _ => None,
         }
     }
 
@@ -1753,6 +1762,22 @@ impl<'a> Parser<'a> {
         }
     }
 
+    /// Returns true if the custom type `name` starting with `first_keyword` and the
+    /// next token form a typed string.
+    fn peek_custom_typed_string(&self, first_keyword: Keyword, name: &ObjectName) -> bool {
+        let qualified = name.0.len() > 1;
+        self.dialect
+            .is_custom_type_typed_string(first_keyword, qualified)
+            && (qualified || self.geometric_type_kind(first_keyword).is_none())
+            && matches!(
+                self.peek_token_ref().token,
+                Token::SingleQuotedString(_)
+                    | Token::EscapedStringLiteral(_)
+                    | Token::UnicodeStringLiteral(_)
+                    | Token::DollarQuotedString(_)
+            )
+    }
+
     /// Parse an expression prefix.
     pub fn parse_prefix(&mut self) -> Result<Expr, ParserError> {
         // allow the dialect to override prefix parsing
@@ -1806,23 +1831,20 @@ impl<'a> Parser<'a> {
         // name is not followed by a string literal, but in fact in PostgreSQL it is a valid
         // expression that should parse as the column name "date".
         let loc = self.peek_token_ref().span.start;
+        let first_keyword = match &self.peek_token_ref().token {
+            Token::Word(w) => Some(w.keyword),
+            _ => None,
+        };
         let opt_expr = self.maybe_parse(|parser| {
             match parser.parse_data_type()? {
                 DataType::Interval { .. } => parser.parse_interval(),
-                // PostgreSQL allows almost any identifier to be used as custom data type name,
-                // and we support that in `parse_data_type()`. But unlike Postgres we don't
-                // have a list of globally reserved keywords (since they vary across dialects),
-                // so given `NOT 'a' LIKE 'b'`, we'd accept `NOT` as a possible custom data type
-                // name, resulting in `NOT 'a'` being recognized as a `TypedString` instead of
-                // an unary negation `NOT ('a' LIKE 'b')`. To solve this, we don't accept the
-                // `type 'string'` syntax for the custom data types at all ...
-                //
-                // ... with the exception of `xml '...'` on dialects that support XML
-                // expressions, which is a valid PostgreSQL typed string literal.
+                // Custom names need dialect consent, else `NOT 'a'` would read as a typed string.
                 DataType::Custom(ref name, ref modifiers)
                     if modifiers.is_empty()
-                        && Self::is_simple_unquoted_object_name(name, "xml")
-                        && parser.dialect.supports_xml_expressions() =>
+                        && ((Self::is_simple_unquoted_object_name(name, "xml")
+                            && parser.dialect.supports_xml_expressions())
+                            || first_keyword
+                                .is_some_and(|kw| parser.peek_custom_typed_string(kw, name))) =>
                 {
                     Ok(Expr::TypedString(TypedString {
                         data_type: DataType::Custom(name.clone(), modifiers.clone()),
diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs
index 8e08c657..217fbe18 100644
--- a/tests/sqlparser_common.rs
+++ b/tests/sqlparser_common.rs
@@ -8493,7 +8493,9 @@ fn parse_trim() {
 
     assert_eq!(
         ParserError::ParserError("Expected: ), found: 'xyz'".to_owned()),
-        parse_sql_statements("SELECT TRIM(FOO 'xyz' FROM 'xyzfooxyz')").unwrap_err()
+        all_dialects_where(|d| !d.is_custom_type_typed_string(Keyword::NoKeyword, false))
+            .parse_sql_statements("SELECT TRIM(FOO 'xyz' FROM 'xyzfooxyz')")
+            .unwrap_err()
     );
 
     // dialects that support comma-separated TRIM syntax
diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs
index fb998510..351f1cb8 100644
--- a/tests/sqlparser_postgres.rs
+++ b/tests/sqlparser_postgres.rs
@@ -10126,3 +10126,51 @@ fn parse_bitstring_literal_escaping() {
     pg_and_generic().verified_stmt("SELECT B''''");
     pg_and_generic().verified_stmt("SELECT B'it''s'");
 }
+
+#[test]
+fn parse_custom_type_typed_string() {
+    assert_eq!(
+        pg().verified_expr("public.mood 'happy'"),
+        Expr::TypedString(TypedString {
+            data_type: DataType::Custom(
+                ObjectName::from(vec!["public".into(), "mood".into()]),
+                vec![]
+            ),
+            value: Value::SingleQuotedString("happy".to_string()).with_empty_span(),
+            uses_odbc_syntax: false,
+        })
+    );
+    pg().verified_stmt("SELECT mood 'happy'");
+    pg().verified_stmt("SELECT count(*) FROM t WHERE m = mood 'sad'");
+    pg().verified_stmt("SELECT pg_typeof(mood 'happy')");
+    pg().verified_stmt(r#"SELECT "mood" 'happy'"#);
+    pg().verified_stmt("SELECT status 'on'");
+    pg().verified_stmt("SELECT left 'x'");
+    pg().verified_stmt("SELECT pg_catalog.coalesce 'x'");
+    pg().verified_stmt("SELECT mood E'happy'");
+    pg().verified_stmt("SELECT mood $$happy$$");
+
+    assert!(matches!(
+        pg().verified_expr("NOT 'a' LIKE 'b'"),
+        Expr::UnaryOp {
+            op: UnaryOperator::Not,
+            ..
+        }
+    ));
+    assert!(matches!(
+        pg().verified_expr("point '1,2'"),
+        Expr::TypedString(TypedString {
+            data_type: DataType::GeometricType(GeometricTypeKind::Point),
+            ..
+        })
+    ));
+    assert_eq!(
+        pg().parse_sql_statements("SELECT pg_typeof(coalesce 'x')")
+            .unwrap_err(),
+        ParserError::ParserError("Expected: ), found: 'x'".to_string())
+    );
+    assert_eq!(
+        pg().parse_sql_statements("SELECT mood 5").unwrap_err(),
+        ParserError::ParserError("Expected: end of statement, found: 5".to_string())
+    );
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org