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