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/19 12:44:15 UTC

(datafusion-sqlparser-rs) 01/01: PostgreSQL: Parse custom typed string literals

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 0bd18e1d5e39da17f7cb34e38259b6dc0c84ec1e
Author: LucaCappelletti94 <ca...@gmail.com>
AuthorDate: Mon Oct 19 14:39:34 2026 +0200

    PostgreSQL: Parse custom typed string literals
---
 src/dialect/mod.rs          |   5 ++
 src/dialect/postgresql.rs   | 162 ++++++++++++++++++++++++++++++++++++++++++++
 src/parser/mod.rs           |  68 ++++++++++++-------
 tests/sqlparser_common.rs   |   4 +-
 tests/sqlparser_postgres.rs |  48 +++++++++++++
 5 files changed, 262 insertions(+), 25 deletions(-)

diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs
index 69bbe355c..caf8ce20a 100644
--- a/src/dialect/mod.rs
+++ b/src/dialect/mod.rs
@@ -1990,6 +1990,11 @@ pub trait Dialect: Debug + Any {
         false
     }
 
+    /// Whether a custom type name may introduce a typed string.
+    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 e49417cc4..8ae5768e0 100644
--- a/src/dialect/postgresql.rs
+++ b/src/dialect/postgresql.rs
@@ -49,6 +49,157 @@ const RESERVED_EXCLUSIONS_FOR_TABLE_ALIAS: &[Keyword] = &[
     Keyword::VIEW,
 ];
 
+const RESERVED_TYPE_NAME_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,
+];
+
+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,
+];
+
+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))]
@@ -367,6 +518,17 @@ impl Dialect for PostgreSqlDialect {
         true
     }
 
+    /// See <https://www.postgresql.org/docs/current/sql-keywords-appendix.html>.
+    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_TYPE_NAME_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 db54296fc..7f0de92cf 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -1665,17 +1665,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,
         }
     }
 
@@ -1759,6 +1768,20 @@ impl<'a> Parser<'a> {
         }
     }
 
+    fn peek_custom_typed_string(&self, first_keyword: Keyword, name: &ObjectName) -> bool {
+        let qualified = name.0.len() > 1;
+        matches!(
+            self.peek_token_ref().token,
+            Token::SingleQuotedString(_)
+                | Token::EscapedStringLiteral(_)
+                | Token::UnicodeStringLiteral(_)
+                | Token::DollarQuotedString(_)
+        ) && self
+            .dialect
+            .is_custom_type_typed_string(first_keyword, qualified)
+            && (qualified || self.geometric_type_kind(first_keyword).is_none())
+    }
+
     /// Parse an expression prefix.
     pub fn parse_prefix(&mut self) -> Result<Expr, ParserError> {
         // allow the dialect to override prefix parsing
@@ -1812,26 +1835,23 @@ 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.
-                DataType::Custom(ref name, ref modifiers)
+                // Custom names need dialect consent, else `NOT 'a'` would read as a typed string.
+                DataType::Custom(name, 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()),
+                        data_type: DataType::Custom(name, modifiers),
                         value: parser.parse_value()?,
                         uses_odbc_syntax: false,
                     }))
diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs
index 4d5198953..69a8713a9 100644
--- a/tests/sqlparser_common.rs
+++ b/tests/sqlparser_common.rs
@@ -8503,7 +8503,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 1e0028867..d3146c0b8 100644
--- a/tests/sqlparser_postgres.rs
+++ b/tests/sqlparser_postgres.rs
@@ -10202,3 +10202,51 @@ fn parse_string_literal_alias_rejected() {
     }
     pg().verified_stmt(r#"SELECT a AS "x" FROM t AS "y""#);
 }
+
+#[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