Skip to content
12 changes: 12 additions & 0 deletions src/ast/data_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,10 @@ pub enum DataType {
///
/// [ClickHouse]: https://clickhouse.com/docs/en/sql-reference/data-types/nested-data-structures/nested
Nested(Vec<ColumnDef>),
/// Structured object type, see [Snowflake].
///
/// [Snowflake]: https://docs.snowflake.com/en/sql-reference/data-types-structured#structured-object-types
Object(Vec<ColumnDef>),
/// Enum type.
Enum(Vec<EnumMember>, Option<u8>),
/// Set type.
Expand Down Expand Up @@ -800,6 +804,9 @@ impl fmt::Display for DataType {
MapBracketKind::Parentheses => {
write!(f, "Map({key_data_type}, {value_data_type})")
}
MapBracketKind::ParenthesesNotNull => {
write!(f, "MAP({key_data_type}, {value_data_type} NOT NULL)")
}
MapBracketKind::AngleBrackets => {
write!(f, "MAP<{key_data_type}, {value_data_type}>")
}
Expand All @@ -810,6 +817,9 @@ impl fmt::Display for DataType {
DataType::Nested(fields) => {
write!(f, "Nested({})", display_comma_separated(fields))
}
DataType::Object(fields) => {
write!(f, "OBJECT({})", display_comma_separated(fields))
}
DataType::Unspecified => Ok(()),
DataType::Trigger => write!(f, "TRIGGER"),
DataType::AnyType => write!(f, "ANY TYPE"),
Expand Down Expand Up @@ -927,6 +937,8 @@ pub enum StructBracketKind {
pub enum MapBracketKind {
/// Example: `Map(String, UInt16)`
Parentheses,
/// Example: `MAP(VARCHAR, NUMBER NOT NULL)`
ParenthesesNotNull,
/// Example: `MAP<STRING, INT>`
AngleBrackets,
}
Expand Down
4 changes: 4 additions & 0 deletions src/dialect/clickhouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ impl Dialect for ClickHouseDialect {
true
}

fn supports_map_typedef_with_parentheses(&self) -> bool {
true
}

// ClickHouse uses this for some FORMAT expressions in `INSERT` context, e.g. when inserting
// with FORMAT JSONEachRow a raw JSON key-value expression is valid and expected.
//
Expand Down
4 changes: 4 additions & 0 deletions src/dialect/generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ use crate::dialect::Dialect;
pub struct GenericDialect;

impl Dialect for GenericDialect {
fn supports_map_typedef_with_parentheses(&self) -> bool {
true
}

fn is_delimited_identifier_start(&self, ch: char) -> bool {
ch == '"' || ch == '`'
}
Expand Down
11 changes: 11 additions & 0 deletions src/dialect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,17 @@ pub trait Dialect: Debug + Any {
false
}

/// Returns true if this dialect supports the `MAP(key_type, value_type)` syntax.
fn supports_map_typedef_with_parentheses(&self) -> bool {
false
}

/// Returns true if this dialect supports `NOT NULL` on the value type in
/// a `MAP(key_type, value_type)` definition.
fn supports_map_value_not_null(&self) -> bool {
false
}

/// Returns true if this dialect supports extra parentheses around
/// lone table names or derived tables in the `FROM` clause.
///
Expand Down
8 changes: 8 additions & 0 deletions src/dialect/snowflake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ impl Dialect for SnowflakeDialect {
true
}

fn supports_map_typedef_with_parentheses(&self) -> bool {
true
}

fn supports_map_value_not_null(&self) -> bool {
true
}

/// See [doc](https://docs.snowflake.com/en/sql-reference/constructs/from)
fn supports_parens_around_table_factor(&self) -> bool {
true
Expand Down
63 changes: 57 additions & 6 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3792,7 +3792,7 @@ impl<'a> Parser<'a> {
})
}

/// Parse clickhouse [map]
/// Parse a parenthesized MAP type (ClickHouse or Snowflake).
///
/// Syntax
///
Expand All @@ -3801,15 +3801,19 @@ impl<'a> Parser<'a> {
/// ```
///
/// [map]: https://clickhouse.com/docs/en/sql-reference/data-types/map
fn parse_click_house_map_def(&mut self) -> Result<(DataType, DataType), ParserError> {
fn parse_parenthesized_map_type_def(
&mut self,
) -> Result<(DataType, DataType, bool), ParserError> {
self.expect_keyword_is(Keyword::MAP)?;
self.expect_token(&Token::LParen)?;
let key_data_type = self.parse_data_type()?;
self.expect_token(&Token::Comma)?;
let value_data_type = self.parse_data_type()?;
let value_not_null = self.dialect.supports_map_value_not_null()
&& self.parse_keywords(&[Keyword::NOT, Keyword::NULL]);
self.expect_token(&Token::RParen)?;

Ok((key_data_type, value_data_type))
Ok((key_data_type, value_data_type, value_not_null))
}

/// Parse clickhouse [tuple]
Expand Down Expand Up @@ -13199,6 +13203,18 @@ impl<'a> Parser<'a> {
))))
}
}
Keyword::OBJECT if self.peek_token_ref().token == Token::LParen => {
if let Some(fields) =
self.maybe_parse(|parser| parser.parse_structured_object_type_def())?
{
Ok(DataType::Object(fields))
} else {
self.prev_token();
let type_name = self.parse_object_name(false)?;
let modifiers = self.parse_optional_type_modifiers()?.unwrap_or_default();
Ok(DataType::Custom(type_name, modifiers))
}
}
Keyword::STRUCT if dialect_is!(dialect is DuckDbDialect) => {
self.prev_token();
let field_defs = self.parse_duckdb_struct_type_def()?;
Expand Down Expand Up @@ -13245,13 +13261,19 @@ impl<'a> Parser<'a> {
MapBracketKind::AngleBrackets,
))
}
Keyword::MAP if dialect_is!(dialect is ClickHouseDialect | GenericDialect) => {
Keyword::MAP if self.dialect.supports_map_typedef_with_parentheses() => {
self.prev_token();
let (key_data_type, value_data_type) = self.parse_click_house_map_def()?;
let (key_data_type, value_data_type, value_not_null) =
self.parse_parenthesized_map_type_def()?;
let bracket = if value_not_null {
MapBracketKind::ParenthesesNotNull
} else {
MapBracketKind::Parentheses
};
Ok(DataType::Map(
Box::new(key_data_type),
Box::new(value_data_type),
MapBracketKind::Parentheses,
bracket,
))
}
Keyword::NESTED if dialect_is!(dialect is ClickHouseDialect | GenericDialect) => {
Expand Down Expand Up @@ -14441,6 +14463,35 @@ impl<'a> Parser<'a> {
}
}

fn parse_structured_object_type_def(&mut self) -> Result<Vec<ColumnDef>, ParserError> {
self.expect_token(&Token::LParen)?;
if self.consume_token(&Token::RParen) {
return Ok(vec![]);
}
let fields = self.parse_comma_separated(|parser| {
if matches!(parser.peek_token_ref().token, Token::SingleQuotedString(_)) {
return parser.expected("an object field identifier", parser.peek_token());
}
let name = parser.parse_identifier()?;
let data_type = parser.parse_data_type()?;
let options = if parser.parse_keywords(&[Keyword::NOT, Keyword::NULL]) {
vec![ColumnOptionDef {
name: None,
option: ColumnOption::NotNull,
}]
} else {
vec![]
};
Ok(ColumnDef {
name,
data_type,
options,
})
})?;
self.expect_token(&Token::RParen)?;
Ok(fields)
}

/// Parse a parenthesized sub data type
fn parse_sub_type<F>(&mut self, parent_type: F) -> Result<DataType, ParserError>
where
Expand Down
66 changes: 66 additions & 0 deletions tests/sqlparser_clickhouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1956,6 +1956,72 @@ fn parse_alter_table_modify_order_by() {
}
}

#[test]
fn parse_object_type_parameter() {
for dialects in [
clickhouse(),
TestedDialects::new(vec![Box::new(GenericDialect {})]),
] {
let statements = dialects
.parse_sql_statements("CREATE TABLE t (o Object('json'))")
.unwrap();
let [Statement::CreateTable(CreateTable { columns, .. })] = statements.as_slice() else {
unreachable!();
};

assert_eq!(
columns[0].data_type,
DataType::Custom(
ObjectName::from(vec![Ident::new("Object")]),
vec!["json".to_string()]
)
);

let formatted = statements[0].to_string();
assert_eq!(
dialects.parse_sql_statements(&formatted).unwrap(),
statements
);
}

clickhouse_and_generic().verified_stmt("CREATE TABLE t (o OBJECT())");
clickhouse_and_generic().verified_stmt("CREATE TABLE t (o OBJECT(city VARCHAR NOT NULL))");

for (sql, modifiers) in [
("CREATE TABLE t (o OBJECT(10))", vec!["10"]),
("CREATE TABLE t (o OBJECT(foo))", vec!["foo"]),
("CREATE TABLE t (o OBJECT(foo, bar))", vec!["foo", "bar"]),
] {
for dialects in [
clickhouse(),
TestedDialects::new(vec![Box::new(GenericDialect {})]),
] {
let statements = dialects.parse_sql_statements(sql).unwrap();
let [Statement::CreateTable(CreateTable { columns, .. })] = statements.as_slice()
else {
unreachable!();
};

assert_eq!(
columns[0].data_type,
DataType::Custom(
ObjectName::from(vec![Ident::new("OBJECT")]),
modifiers
.iter()
.map(|modifier| (*modifier).to_owned())
.collect(),
)
);

let formatted = statements[0].to_string();
assert_eq!(
dialects.parse_sql_statements(&formatted).unwrap(),
statements
);
}
}
}

fn clickhouse() -> TestedDialects {
TestedDialects::new(vec![Box::new(ClickHouseDialect {})])
}
Expand Down
71 changes: 71 additions & 0 deletions tests/sqlparser_snowflake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4954,6 +4954,77 @@ fn test_structured_array_type() {
);
}

#[test]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
#[test]
#[test]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the missing blank line before the test in 0d58c18.

fn test_structured_object_type() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're missing a test case for how the PR handles plain OBJECT as a type (as mentioned in my previous review comment)

@osipovartem osipovartem Sep 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added an explicit test_plain_object_type in 0d58c18. It verifies both Snowflake and Generic dialect parsing and asserts that plain OBJECT remains DataType::Custom("OBJECT", []), while OBJECT(...) uses the new structured representation. cargo test --all-targets and cargo clippy --all-targets --all-features -- -D warnings pass locally.

snowflake_and_generic().verified_stmt(
"SELECT payload::OBJECT(address OBJECT(city VARCHAR NOT NULL), zip NUMBER) FROM t",
);
snowflake().one_statement_parses_to(
"SELECT payload::OBJECT(tags ARRAY, labels MAP(VARCHAR, VARCHAR)) FROM t",
"SELECT payload::OBJECT(tags ARRAY, labels Map(VARCHAR, VARCHAR)) FROM t",
);
let select = snowflake().verified_only_select(
"SELECT payload::OBJECT(items ARRAY(NUMBER NOT NULL), meta MAP(VARCHAR, OBJECT(k NUMBER) NOT NULL)) FROM t",
);
let Expr::Cast { data_type, .. } = expr_from_projection(only(&select.projection)) else {
unreachable!();
};
let DataType::Object(fields) = data_type else {
unreachable!();
};
assert!(matches!(
&fields[0].data_type,
DataType::Array(ArrayElemTypeDef::ParenthesisNotNull(_))
));
let DataType::Map(_, value, MapBracketKind::ParenthesesNotNull) = &fields[1].data_type else {
unreachable!();
};
assert!(matches!(**value, DataType::Object(_)));

snowflake().one_statement_parses_to(
"SELECT payload::ARRAY(NUMBER) FROM t",
"SELECT payload::Array(NUMBER) FROM t",
);
snowflake().one_statement_parses_to(
"SELECT payload::MAP(VARCHAR, OBJECT(k NUMBER)) FROM t",
"SELECT payload::Map(VARCHAR, OBJECT(k NUMBER)) FROM t",
);
snowflake().verified_stmt("SELECT payload::ARRAY(NUMBER NOT NULL) FROM t");
snowflake().verified_stmt("SELECT payload::MAP(VARCHAR, NUMBER NOT NULL) FROM t");
snowflake_and_generic().verified_stmt("CREATE TABLE t (o OBJECT())");

let select = snowflake().verified_only_select(
"SELECT CAST(payload AS OBJECT(city VARCHAR, zip NUMBER NOT NULL)) FROM t",
);
let Expr::Cast { data_type, .. } = expr_from_projection(only(&select.projection)) else {
unreachable!();
};
let DataType::Object(fields) = data_type else {
unreachable!();
};
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].name, Ident::new("city"));
assert!(fields[0].options.is_empty());
assert_eq!(fields[1].name, Ident::new("zip"));
assert_eq!(fields[1].options.len(), 1);
assert_eq!(fields[1].options[0].option, ColumnOption::NotNull);

assert!(snowflake()
.parse_sql_statements("CREATE TABLE t (o OBJECT(city VARCHAR)")
.is_err());

let Statement::CreateTable(CreateTable { columns, .. }) =
snowflake_and_generic().verified_stmt("CREATE TABLE t (o OBJECT)")
else {
unreachable!();
};

assert_eq!(
columns[0].data_type,
DataType::Custom(ObjectName::from(vec![Ident::new("OBJECT")]), vec![])
);
}

#[test]
fn test_snowflake_stage_name_with_escaped_quotes() {
snowflake().verified_stmt("REMOVE @````");
Expand Down
Loading