From 0bd18e1d5e39da17f7cb34e38259b6dc0c84ec1e Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Mon, 19 Oct 2026 14:39:34 +0200 Subject: [PATCH 1/2] 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 69bbe355c8..caf8ce20ab 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 e49417cc4d..8ae5768e0c 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 . + 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 db54296fce..7f0de92cff 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 { + 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 { // 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 4d5198953c..69a8713a9a 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 1e00288673..d3146c0b88 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()) + ); +} From 029855b7b4d95b671e6ab46316b56a24d99357a3 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 12:51:26 +0200 Subject: [PATCH 2/2] Cover geometric keyword expression fallback --- tests/sqlparser_postgres.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index d3146c0b88..0a3866d29f 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -10250,3 +10250,18 @@ fn parse_custom_type_typed_string() { ParserError::ParserError("Expected: end of statement, found: 5".to_string()) ); } + +#[test] +fn parse_geometric_keyword_as_column_name() { + pg().verified_stmt("SELECT circle FROM t"); + pg().verified_stmt("SELECT point FROM t"); +} + +#[test] +fn parse_geometric_typed_literal_error_propagates() { + assert_eq!( + pg().parse_sql_statements("SELECT circle( FROM t") + .unwrap_err(), + ParserError::ParserError("Expected: a value, found: (".to_string()) + ); +}