From 4bcaa9b4df0631bbdd9e486622b1ddf55e5e7038 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 2 Oct 2026 18:14:20 +0200 Subject: [PATCH 1/7] SQLite: Support multi-word type names --- src/ast/data_type.rs | 13 +++- src/dialect/mod.rs | 6 ++ src/dialect/sqlite.rs | 4 ++ src/keywords.rs | 1 + src/parser/mod.rs | 142 +++++++++++++++++++++++++++++++++++++- tests/sqlparser_common.rs | 3 +- tests/sqlparser_sqlite.rs | 52 ++++++++++++++ 7 files changed, 217 insertions(+), 4 deletions(-) diff --git a/src/ast/data_type.rs b/src/ast/data_type.rs index acb1b2ab9c..5c43f9e2dc 100644 --- a/src/ast/data_type.rs +++ b/src/ast/data_type.rs @@ -25,7 +25,9 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; -use crate::ast::{display_comma_separated, Expr, Ident, ObjectName, StructField, UnionField}; +use crate::ast::{ + display_comma_separated, display_separated, Expr, Ident, ObjectName, StructField, UnionField, +}; use super::{value::escape_single_quote_string, ColumnDef}; @@ -466,6 +468,8 @@ pub enum DataType { VarBit(Option), /// Custom type with a modifier list, e.g. `GEOMETRY(POINT, 4326)`. Custom(ObjectName, Vec), + /// Multi-word type name whose parts are space-separated, as in SQLite's `typename` grammar. + CustomMultiWord(Vec, Vec), /// Arrays. Array(ArrayElemTypeDef), /// Map, see [ClickHouse], [Hive]. @@ -770,6 +774,13 @@ impl fmt::Display for DataType { write!(f, "{ty}({})", display_comma_separated(modifiers)) } } + DataType::CustomMultiWord(parts, modifiers) => { + write!(f, "{}", display_separated(parts, " "))?; + if !modifiers.is_empty() { + write!(f, "({})", display_comma_separated(modifiers))?; + } + Ok(()) + } DataType::Enum(vals, bits) => { match bits { Some(bits) => write!(f, "ENUM{bits}"), diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index c867b76f61..29220af1b2 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -586,6 +586,12 @@ pub trait Dialect: Debug + Any { false } + /// Returns true if type names may consist of multiple space-separated identifiers + /// or string literals, as in SQLite's `typename` grammar. + fn supports_multiword_type_names(&self) -> bool { + false + } + /// Returns true if the dialects supports specifying null treatment /// as part of a window function's parameter list as opposed /// to after the parameter list. diff --git a/src/dialect/sqlite.rs b/src/dialect/sqlite.rs index 44dcc9d267..5054427d17 100644 --- a/src/dialect/sqlite.rs +++ b/src/dialect/sqlite.rs @@ -171,6 +171,10 @@ impl Dialect for SQLiteDialect { true } + fn supports_multiword_type_names(&self) -> bool { + true + } + fn supports_double_eq_assignment(&self) -> bool { true } diff --git a/src/keywords.rs b/src/keywords.rs index 8a6c980cab..86b8fdab69 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -566,6 +566,7 @@ define_keywords!( INVOKER, IO, IS, + ISNULL, ISODOW, ISOLATION, ISOWEEK, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 3efb22a349..33de7e0352 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1870,7 +1870,7 @@ impl<'a> Parser<'a> { uses_odbc_syntax: false, })) } - DataType::Custom(..) => parser_err!("dummy", loc), + DataType::Custom(..) | DataType::CustomMultiWord(..) => parser_err!("dummy", loc), // MySQL supports using the `BINARY` keyword as a cast to binary type. DataType::Binary(..) if self.dialect.supports_binary_kw_as_cast() => { Ok(Expr::Cast { @@ -9994,7 +9994,9 @@ impl<'a> Parser<'a> { | Keyword::GENERATED | Keyword::AS ), - _ => true, // e.g. comma immediately after column name + // A single-quoted string is a valid type name in SQLite's ids grammar. + Token::SingleQuotedString(_) => false, + _ => true, } } else { false @@ -13679,6 +13681,10 @@ impl<'a> Parser<'a> { } } Keyword::SIGNED => { + if self.dialect.supports_multiword_type_names() { + self.prev_token(); + return self.parse_multiword_type_name(); + } if self.parse_keyword(Keyword::INTEGER) { Ok(DataType::SignedInteger) } else { @@ -13686,6 +13692,10 @@ impl<'a> Parser<'a> { } } Keyword::UNSIGNED => { + if self.dialect.supports_multiword_type_names() { + self.prev_token(); + return self.parse_multiword_type_name(); + } if self.parse_keyword(Keyword::INTEGER) { Ok(DataType::UnsignedInteger) } else { @@ -13700,6 +13710,9 @@ impl<'a> Parser<'a> { } _ => { self.prev_token(); + if self.dialect.supports_multiword_type_names() { + return self.parse_multiword_type_name(); + } let type_name = self.parse_object_name(false)?; if let Some(modifiers) = self.parse_optional_type_modifiers()? { Ok(DataType::Custom(type_name, modifiers)) @@ -13708,6 +13721,10 @@ impl<'a> Parser<'a> { } } }, + Token::SingleQuotedString(_) if self.dialect.supports_multiword_type_names() => { + self.prev_token(); + return self.parse_multiword_type_name(); + } _ => self.expected_at("a data type name", next_token_index), }?; @@ -14890,6 +14907,127 @@ impl<'a> Parser<'a> { } } + /// Parse a space-separated multi-word typename per SQLite's `typename` grammar. + fn parse_multiword_type_name( + &mut self, + ) -> Result<(DataType, MatchedTrailingBracket), ParserError> { + let mut parts: Vec = vec![]; + let first = self.next_token(); + match first.token { + Token::Word(w) => parts.push(w.into_ident(first.span)), + Token::SingleQuotedString(s) => parts.push(Ident::with_quote('\'', s)), + _ => return self.expected("a data type name", first), + } + loop { + let can_extend = { + let peeked = self.peek_token_ref(); + match &peeked.token { + Token::Word(w) if w.keyword == Keyword::GENERATED => { + // `GENERATED` starts a constraint only when followed + // by `ALWAYS`, otherwise it is a type name word. + !matches!( + self.peek_nth_token(1).token, + Token::Word(w) if w.keyword == Keyword::ALWAYS + ) + } + Token::Word(w) => { + // Fully-reserved keywords end the type name, see + // + !matches!( + w.keyword, + Keyword::ADD + | Keyword::ALL + | Keyword::ALTER + | Keyword::AND + | Keyword::AS + | Keyword::AUTOINCREMENT + | Keyword::BETWEEN + | Keyword::CASE + | Keyword::CHECK + | Keyword::COLLATE + | Keyword::COMMIT + | Keyword::CONSTRAINT + | Keyword::CREATE + | Keyword::CROSS + | Keyword::DEFAULT + | Keyword::DELETE + | Keyword::DEFERRABLE + | Keyword::DISTINCT + | Keyword::DROP + | Keyword::ELSE + | Keyword::ESCAPE + | Keyword::EXCEPT + | Keyword::EXISTS + | Keyword::FOREIGN + | Keyword::FROM + | Keyword::FULL + | Keyword::GROUP + | Keyword::HAVING + | Keyword::IN + | Keyword::INDEX + | Keyword::INDEXED + | Keyword::INNER + | Keyword::INSERT + | Keyword::INTERSECT + | Keyword::INTO + | Keyword::IS + | Keyword::ISNULL + | Keyword::JOIN + | Keyword::LEFT + | Keyword::LIMIT + | Keyword::NATURAL + | Keyword::NOT + | Keyword::NULL + | Keyword::NOTHING + | Keyword::NOTNULL + | Keyword::ON + | Keyword::OR + | Keyword::ORDER + | Keyword::OUTER + | Keyword::PRIMARY + | Keyword::REFERENCES + | Keyword::RETURNING + | Keyword::RIGHT + | Keyword::SELECT + | Keyword::SET + | Keyword::TABLE + | Keyword::THEN + | Keyword::TO + | Keyword::TRANSACTION + | Keyword::UNIQUE + | Keyword::UNION + | Keyword::UPDATE + | Keyword::USING + | Keyword::VALUES + | Keyword::WHEN + | Keyword::WHERE + ) + } + Token::SingleQuotedString(_) => true, + _ => false, + } + }; + if !can_extend { + break; + } + let next = self.next_token(); + match next.token { + Token::Word(w) => parts.push(w.into_ident(next.span)), + Token::SingleQuotedString(s) => parts.push(Ident::with_quote('\'', s)), + _ => break, + } + } + let modifiers = self.parse_optional_type_modifiers()?.unwrap_or_default(); + if parts.len() == 1 { + Ok(( + DataType::Custom(ObjectName::from(vec![parts.remove(0)]), modifiers), + false.into(), + )) + } else { + Ok((DataType::CustomMultiWord(parts, modifiers), false.into())) + } + } + fn parse_structured_object_type_def(&mut self) -> Result, ParserError> { self.expect_token(&Token::LParen)?; if self.consume_token(&Token::RParen) { diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index f71869e820..5d5243a51d 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -14192,7 +14192,8 @@ fn test_xmltable() { ); // Example from DB2 docs without explicit PASSING clause: https://www.ibm.com/docs/en/db2/12.1.0?topic=xquery-simple-column-name-passing-xmlexists-xmlquery-xmltable - all_dialects().verified_only_select( + // Multi-word type names would read `PATH` as part of the column type + all_dialects_where(|d| !d.supports_multiword_type_names()).verified_only_select( "SELECT X.* FROM T1, XMLTABLE('$CUSTLIST/customers/customerinfo' COLUMNS \"Cid\" BIGINT PATH '@Cid', \"Info\" XML PATH 'document{.}', \"History\" XML PATH 'NULL') AS X" ); diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index ad80e95a93..c0033e555b 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1026,6 +1026,58 @@ fn test_non_bmp_identifiers() { ); } +#[test] +fn test_multiword_type_names() { + // A SQLite type name is one or more names or strings + sqlite().verified_stmt("CREATE TABLE t (a UNSIGNED BIG INT)"); + sqlite().verified_stmt("CREATE TABLE t (a foo bar baz)"); + sqlite().verified_stmt("CREATE TABLE t (a 'text')"); + sqlite().verified_stmt("SELECT CAST(a AS foo bar) FROM t"); + sqlite().verified_stmt("ALTER TABLE t ADD b foo bar"); + // Keywords without a dedicated parser arm also form multi-word type names + sqlite().verified_stmt("CREATE TABLE t (a NATIVE CHARACTER(70))"); + sqlite().verified_stmt("CREATE TABLE t (a VARYING CHARACTER(10))"); + sqlite().verified_stmt("SELECT CAST(x AS VARYING CHARACTER) FROM t"); + // Column constraints still terminate the type name + sqlite().verified_stmt("CREATE TABLE t (a foo bar NOT NULL)"); + sqlite().verified_stmt("CREATE TABLE t (a foo bar DEFAULT 0)"); + // Standard constraint keywords are not consumed as type words + sqlite().verified_stmt("CREATE TABLE t (a INT PRIMARY KEY)"); + sqlite().verified_stmt("CREATE TABLE t (a TEXT COLLATE NOCASE)"); + sqlite().verified_stmt("CREATE TABLE t (a INT GENERATED ALWAYS AS (1))"); + sqlite().verified_stmt("CREATE TABLE t (a INT AS (1))"); + // GENERATED is a type word unless ALWAYS follows + sqlite().verified_stmt("CREATE TABLE t (a foo GENERATED, b INT)"); + sqlite().verified_stmt("CREATE TABLE t (a foo GENERATED ALWAYS AS (1))"); + // PATH is a valid type word, not a terminator + sqlite().verified_stmt("CREATE TABLE t (a foo bar PATH)"); + sqlite().verified_stmt("CREATE TABLE t (a foo PATH bar)"); + // fully-reserved keywords end the type name + assert!(sqlite() + .parse_sql_statements("CREATE TABLE t (a foo AND bar)") + .is_err()); + assert!(sqlite() + .parse_sql_statements("CREATE TABLE t (a foo bar ON)") + .is_err()); + assert!(sqlite() + .parse_sql_statements("CREATE TABLE t (a foo bar SELECT)") + .is_err()); + assert!(sqlite() + .parse_sql_statements("CREATE TABLE t (a foo ISNULL, b INT)") + .is_err()); + assert!(sqlite() + .parse_sql_statements("CREATE TABLE t (a foo INDEXED, b INT)") + .is_err()); +} + +#[test] +fn test_multiword_type_names_rejected_by_other_dialects() { + // GenericDialect does not support multi-word type names + assert!(TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("CREATE TABLE t (a foo bar)") + .is_err()); +} + #[test] fn parse_create_table_string_column_names() { sqlite().verified_stmt("CREATE TABLE t ('a')"); From c69ac5587ca83bfeba4ab221bfc02533c8fa4afb Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 16 Oct 2026 23:29:28 +0200 Subject: [PATCH 2/7] Adapt tests to the new deny lints and multiword type names --- tests/sqlparser_postgres.rs | 6 +++-- tests/sqlparser_sqlite.rs | 52 ++++++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index aa9252125b..c169a8f622 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -672,8 +672,10 @@ fn parse_exclude_constraint() { // Dialects that do not opt in via `supports_exclude_constraint` must // refuse to parse `EXCLUDE` constraints. assert_eq!( - all_dialects_where(|d| !d.supports_exclude_constraint()) - .parse_sql_statements("CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =))"), + all_dialects_where(|d| { + !d.supports_exclude_constraint() && !d.supports_multiword_type_names() + }) + .parse_sql_statements("CREATE TABLE t (col INT, EXCLUDE USING gist (col WITH =))"), Err(ParserError::ParserError( "Expected: ',' or ')' after column definition, found: gist".to_string() )) diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index c0033e555b..b1b702f3a5 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1053,29 +1053,45 @@ fn test_multiword_type_names() { sqlite().verified_stmt("CREATE TABLE t (a foo bar PATH)"); sqlite().verified_stmt("CREATE TABLE t (a foo PATH bar)"); // fully-reserved keywords end the type name - assert!(sqlite() - .parse_sql_statements("CREATE TABLE t (a foo AND bar)") - .is_err()); - assert!(sqlite() - .parse_sql_statements("CREATE TABLE t (a foo bar ON)") - .is_err()); - assert!(sqlite() - .parse_sql_statements("CREATE TABLE t (a foo bar SELECT)") - .is_err()); - assert!(sqlite() - .parse_sql_statements("CREATE TABLE t (a foo ISNULL, b INT)") - .is_err()); - assert!(sqlite() - .parse_sql_statements("CREATE TABLE t (a foo INDEXED, b INT)") - .is_err()); + for (sql, err) in [ + ( + "CREATE TABLE t (a foo AND bar)", + "Expected: ',' or ')' after column definition, found: AND", + ), + ( + "CREATE TABLE t (a foo bar ON)", + "Expected: ',' or ')' after column definition, found: ON", + ), + ( + "CREATE TABLE t (a foo bar SELECT)", + "Expected: ',' or ')' after column definition, found: SELECT", + ), + ( + "CREATE TABLE t (a foo ISNULL, b INT)", + "Expected: ',' or ')' after column definition, found: ISNULL", + ), + ( + "CREATE TABLE t (a foo INDEXED, b INT)", + "Expected: ',' or ')' after column definition, found: INDEXED", + ), + ] { + assert_eq!( + sqlite().parse_sql_statements(sql), + Err(ParserError::ParserError(err.to_string())) + ); + } } #[test] fn test_multiword_type_names_rejected_by_other_dialects() { // GenericDialect does not support multi-word type names - assert!(TestedDialects::new(vec![Box::new(GenericDialect {})]) - .parse_sql_statements("CREATE TABLE t (a foo bar)") - .is_err()); + assert_eq!( + TestedDialects::new(vec![Box::new(GenericDialect {})]) + .parse_sql_statements("CREATE TABLE t (a foo bar)"), + Err(ParserError::ParserError( + "Expected: ',' or ')' after column definition, found: bar".to_string() + )) + ); } #[test] From 1837075cfc7fc389e3b8244489ef91cf9e7ecf51 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Tue, 20 Oct 2026 13:13:30 +0200 Subject: [PATCH 3/7] Parse multiword type continuations without redundant validation --- src/parser/mod.rs | 61 +++++++++++++++++++-------------------- tests/sqlparser_sqlite.rs | 28 ++++++++++++++++++ 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 33de7e0352..e5ce7f8e37 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -13682,8 +13682,8 @@ impl<'a> Parser<'a> { } Keyword::SIGNED => { if self.dialect.supports_multiword_type_names() { - self.prev_token(); - return self.parse_multiword_type_name(); + let first = w.clone().into_ident(next_token.span); + return self.parse_multiword_type_name(first); } if self.parse_keyword(Keyword::INTEGER) { Ok(DataType::SignedInteger) @@ -13693,8 +13693,8 @@ impl<'a> Parser<'a> { } Keyword::UNSIGNED => { if self.dialect.supports_multiword_type_names() { - self.prev_token(); - return self.parse_multiword_type_name(); + let first = w.clone().into_ident(next_token.span); + return self.parse_multiword_type_name(first); } if self.parse_keyword(Keyword::INTEGER) { Ok(DataType::UnsignedInteger) @@ -13709,10 +13709,11 @@ impl<'a> Parser<'a> { Ok(DataType::TsQuery) } _ => { - self.prev_token(); if self.dialect.supports_multiword_type_names() { - return self.parse_multiword_type_name(); + let first = w.clone().into_ident(next_token.span); + return self.parse_multiword_type_name(first); } + self.prev_token(); let type_name = self.parse_object_name(false)?; if let Some(modifiers) = self.parse_optional_type_modifiers()? { Ok(DataType::Custom(type_name, modifiers)) @@ -13721,9 +13722,9 @@ impl<'a> Parser<'a> { } } }, - Token::SingleQuotedString(_) if self.dialect.supports_multiword_type_names() => { - self.prev_token(); - return self.parse_multiword_type_name(); + Token::SingleQuotedString(value) if self.dialect.supports_multiword_type_names() => { + let first = Ident::with_quote('\'', value.clone()); + return self.parse_multiword_type_name(first); } _ => self.expected_at("a data type name", next_token_index), }?; @@ -14910,30 +14911,27 @@ impl<'a> Parser<'a> { /// Parse a space-separated multi-word typename per SQLite's `typename` grammar. fn parse_multiword_type_name( &mut self, + first: Ident, ) -> Result<(DataType, MatchedTrailingBracket), ParserError> { - let mut parts: Vec = vec![]; - let first = self.next_token(); - match first.token { - Token::Word(w) => parts.push(w.into_ident(first.span)), - Token::SingleQuotedString(s) => parts.push(Ident::with_quote('\'', s)), - _ => return self.expected("a data type name", first), - } + let mut parts = Vec::with_capacity(4); + parts.push(first); loop { - let can_extend = { + let next_part = { let peeked = self.peek_token_ref(); match &peeked.token { Token::Word(w) if w.keyword == Keyword::GENERATED => { // `GENERATED` starts a constraint only when followed // by `ALWAYS`, otherwise it is a type name word. - !matches!( - self.peek_nth_token(1).token, + (!matches!( + &self.peek_nth_token_ref(1).token, Token::Word(w) if w.keyword == Keyword::ALWAYS - ) + )) + .then(|| w.clone().into_ident(peeked.span)) } Token::Word(w) => { // Fully-reserved keywords end the type name, see // - !matches!( + (!matches!( w.keyword, Keyword::ADD | Keyword::ALL @@ -15001,21 +14999,20 @@ impl<'a> Parser<'a> { | Keyword::VALUES | Keyword::WHEN | Keyword::WHERE - ) + )) + .then(|| w.clone().into_ident(peeked.span)) + } + Token::SingleQuotedString(value) => { + Some(Ident::with_quote('\'', value.clone())) } - Token::SingleQuotedString(_) => true, - _ => false, + _ => None, } }; - if !can_extend { + let Some(next_part) = next_part else { break; - } - let next = self.next_token(); - match next.token { - Token::Word(w) => parts.push(w.into_ident(next.span)), - Token::SingleQuotedString(s) => parts.push(Ident::with_quote('\'', s)), - _ => break, - } + }; + self.advance_token(); + parts.push(next_part); } let modifiers = self.parse_optional_type_modifiers()?.unwrap_or_default(); if parts.len() == 1 { diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index b1b702f3a5..d73777cc6a 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1082,6 +1082,34 @@ fn test_multiword_type_names() { } } +#[test] +fn display_multiword_type_name_propagates_write_errors() { + use core::fmt::{self, Write}; + + struct RejectingWriter(char); + + impl Write for RejectingWriter { + fn write_str(&mut self, value: &str) -> fmt::Result { + if value.contains(self.0) { + Err(fmt::Error) + } else { + Ok(()) + } + } + } + + let data_type = DataType::CustomMultiWord( + vec![Ident::new("signed"), Ident::new("int")], + vec![TypeModifier::Number("1".to_string(), false)], + ); + // A writer that rejects the type name fails before the modifier list + let mut writer = RejectingWriter('s'); + assert_eq!(write!(writer, "{data_type}"), Err(fmt::Error)); + // A writer that only rejects the parenthesized list fails on the modifiers + let mut writer = RejectingWriter('('); + assert_eq!(write!(writer, "{data_type}"), Err(fmt::Error)); +} + #[test] fn test_multiword_type_names_rejected_by_other_dialects() { // GenericDialect does not support multi-word type names From 15fd27e2f6e2618a0a15152703a38505a0fa163c Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 00:24:40 +0200 Subject: [PATCH 4/7] Place regression tests beside related syntax cases --- tests/sqlparser_sqlite.rs | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index d73777cc6a..efe51e12e4 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1026,6 +1026,23 @@ fn test_non_bmp_identifiers() { ); } +#[test] +fn parse_create_table_string_column_names() { + sqlite().verified_stmt("CREATE TABLE t ('a')"); + sqlite().verified_stmt(r#"CREATE TABLE '""' ('id' INT UNSIGNED NOT NULL)"#); + sqlite().verified_stmt( + r#"CREATE TABLE '""' ('id' INT UNSIGNED NOT NULL, 'name' TEXT NOT NULL, 'zip' INT UNSIGNED NULL)"#, + ); + // Generic dialect does not support this + assert_eq!( + sqlparser::parser::Parser::parse_sql(&GenericDialect {}, "CREATE TABLE t ('a')"), + Err(ParserError::ParserError( + "Expected: column name or constraint definition, found: 'a' at Line: 1, Column: 17" + .to_string() + )) + ); +} + #[test] fn test_multiword_type_names() { // A SQLite type name is one or more names or strings @@ -1122,23 +1139,6 @@ fn test_multiword_type_names_rejected_by_other_dialects() { ); } -#[test] -fn parse_create_table_string_column_names() { - sqlite().verified_stmt("CREATE TABLE t ('a')"); - sqlite().verified_stmt(r#"CREATE TABLE '""' ('id' INT UNSIGNED NOT NULL)"#); - sqlite().verified_stmt( - r#"CREATE TABLE '""' ('id' INT UNSIGNED NOT NULL, 'name' TEXT NOT NULL, 'zip' INT UNSIGNED NULL)"#, - ); - // Generic dialect does not support this - assert_eq!( - sqlparser::parser::Parser::parse_sql(&GenericDialect {}, "CREATE TABLE t ('a')"), - Err(ParserError::ParserError( - "Expected: column name or constraint definition, found: 'a' at Line: 1, Column: 17" - .to_string() - )) - ); -} - #[test] fn test_cast_empty_type() { // SQLite allows CAST(expr AS) with an empty type name (typetoken can be empty) From a79280a553ce42bd3aa2752aa732c4d17205ad5f Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 13:43:04 +0200 Subject: [PATCH 5/7] Add fuzz seeds for sqlite multiword type names --- fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql diff --git a/fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql b/fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql new file mode 100644 index 0000000000..7f958f4b3d --- /dev/null +++ b/fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql @@ -0,0 +1,3 @@ +CREATE TABLE t (a UNSIGNED BIG INT, b NATIVE CHARACTER(70), c VARYING CHARACTER(10), d 'text', e foo bar NOT NULL DEFAULT 0, f foo GENERATED, g foo GENERATED ALWAYS AS (1), h foo PATH bar); +SELECT CAST(a AS foo bar), CAST(b AS VARYING CHARACTER) FROM t; +ALTER TABLE t ADD i foo bar; From cf2e132a5c7d32fe087331bec46d1f8b36f4c645 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 14:37:37 +0200 Subject: [PATCH 6/7] Handle SQLite multiword types and untyped NULL constraints --- .../sqlite_2556_multiword_type_names.sql | 3 + src/dialect/mod.rs | 3 +- src/parser/mod.rs | 222 ++++++++++-------- tests/sqlparser_common.rs | 44 ++-- tests/sqlparser_mysql.rs | 6 +- tests/sqlparser_sqlite.rs | 126 ++++++++++ 6 files changed, 284 insertions(+), 120 deletions(-) diff --git a/fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql b/fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql index 7f958f4b3d..1f663d7336 100644 --- a/fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql +++ b/fuzz/fuzz_seeds/sqlite_2556_multiword_type_names.sql @@ -1,3 +1,6 @@ CREATE TABLE t (a UNSIGNED BIG INT, b NATIVE CHARACTER(70), c VARYING CHARACTER(10), d 'text', e foo bar NOT NULL DEFAULT 0, f foo GENERATED, g foo GENERATED ALWAYS AS (1), h foo PATH bar); SELECT CAST(a AS foo bar), CAST(b AS VARYING CHARACTER) FROM t; ALTER TABLE t ADD i foo bar; +CREATE TABLE u (a NULL, b INT FOO, c TEXT FOO PRIMARY KEY, d ENUM FOO, e TIMESTAMP WITH FOO); +SELECT CAST(1 AS INT FOO), CAST(1 AS ENUM FOO), CAST(1 AS TIMESTAMP WITH FOO); +ALTER TABLE u ADD f NULL; diff --git a/src/dialect/mod.rs b/src/dialect/mod.rs index 29220af1b2..95a61d2f5e 100644 --- a/src/dialect/mod.rs +++ b/src/dialect/mod.rs @@ -586,8 +586,7 @@ pub trait Dialect: Debug + Any { false } - /// Returns true if type names may consist of multiple space-separated identifiers - /// or string literals, as in SQLite's `typename` grammar. + /// Whether type names accept space-separated words or strings, including built-in type keywords. fn supports_multiword_type_names(&self) -> bool { false } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e5ce7f8e37..740689fb06 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1854,7 +1854,11 @@ impl<'a> Parser<'a> { _ => None, }; let opt_expr = self.maybe_parse(|parser| { - match parser.parse_data_type()? { + let (data_type, trailing_bracket) = parser.parse_data_type_inner()?; + if trailing_bracket.0 { + return parser_err!("dummy", loc); + } + match data_type { DataType::Interval { .. } => parser.parse_interval(), // Custom names need dialect consent, else `NOT 'a'` would read as a typed string. DataType::Custom(name, modifiers) @@ -9986,6 +9990,7 @@ impl<'a> Parser<'a> { Keyword::CONSTRAINT | Keyword::PRIMARY | Keyword::NOT + | Keyword::NULL | Keyword::UNIQUE | Keyword::CHECK | Keyword::DEFAULT @@ -13224,10 +13229,41 @@ impl<'a> Parser<'a> { Ok(ty) } - #[cfg_attr(feature = "recursive-protection", recursive::recursive)] fn parse_data_type_helper( &mut self, ) -> Result<(DataType, MatchedTrailingBracket), ParserError> { + if !self.dialect.supports_multiword_type_names() { + return self.parse_data_type_inner(); + } + + let index = self.index; + let dedicated_error = match self.try_parse(|parser| parser.parse_data_type_inner()) { + Ok(data_type) => { + if self.peek_multiword_type_name_part().is_none() { + return Ok(data_type); + } + self.index = index; + None + } + Err(ParserError::RecursionLimitExceeded) => { + return Err(ParserError::RecursionLimitExceeded); + } + Err(error) => Some(error), + }; + + self.advance_token(); + let token = self.get_current_token(); + let first = match &token.token { + Token::Word(word) => word.clone().into_ident(token.span), + Token::SingleQuotedString(value) => Ident::with_quote('\'', value.clone()), + _ => return self.expected_ref("a data type name", token), + }; + self.parse_multiword_type_name(first) + .map_err(|error| dedicated_error.unwrap_or(error)) + } + + #[cfg_attr(feature = "recursive-protection", recursive::recursive)] + fn parse_data_type_inner(&mut self) -> Result<(DataType, MatchedTrailingBracket), ParserError> { let _guard = self.recursion_counter.try_decrease()?; let dialect = self.dialect; @@ -14908,6 +14944,88 @@ impl<'a> Parser<'a> { } } + fn peek_multiword_type_name_part(&self) -> Option<&TokenWithSpan> { + let peeked = self.peek_token_ref(); + let is_part = match &peeked.token { + Token::Word(w) if w.keyword == Keyword::GENERATED => !matches!( + &self.peek_nth_token_ref(1).token, + Token::Word(w) if w.keyword == Keyword::ALWAYS + ), + Token::Word(w) => !matches!( + w.keyword, + Keyword::ADD + | Keyword::ALL + | Keyword::ALTER + | Keyword::AND + | Keyword::AS + | Keyword::AUTOINCREMENT + | Keyword::BETWEEN + | Keyword::CASE + | Keyword::CHECK + | Keyword::COLLATE + | Keyword::COMMIT + | Keyword::CONSTRAINT + | Keyword::CREATE + | Keyword::CROSS + | Keyword::DEFAULT + | Keyword::DELETE + | Keyword::DEFERRABLE + | Keyword::DISTINCT + | Keyword::DROP + | Keyword::ELSE + | Keyword::ESCAPE + | Keyword::EXCEPT + | Keyword::EXISTS + | Keyword::FOREIGN + | Keyword::FROM + | Keyword::FULL + | Keyword::GROUP + | Keyword::HAVING + | Keyword::IN + | Keyword::INDEX + | Keyword::INDEXED + | Keyword::INNER + | Keyword::INSERT + | Keyword::INTERSECT + | Keyword::INTO + | Keyword::IS + | Keyword::ISNULL + | Keyword::JOIN + | Keyword::LEFT + | Keyword::LIMIT + | Keyword::NATURAL + | Keyword::NOT + | Keyword::NULL + | Keyword::NOTHING + | Keyword::NOTNULL + | Keyword::ON + | Keyword::OR + | Keyword::ORDER + | Keyword::OUTER + | Keyword::PRIMARY + | Keyword::REFERENCES + | Keyword::RETURNING + | Keyword::RIGHT + | Keyword::SELECT + | Keyword::SET + | Keyword::TABLE + | Keyword::THEN + | Keyword::TO + | Keyword::TRANSACTION + | Keyword::UNIQUE + | Keyword::UNION + | Keyword::UPDATE + | Keyword::USING + | Keyword::VALUES + | Keyword::WHEN + | Keyword::WHERE + ), + Token::SingleQuotedString(_) => true, + _ => false, + }; + is_part.then_some(peeked) + } + /// Parse a space-separated multi-word typename per SQLite's `typename` grammar. fn parse_multiword_type_name( &mut self, @@ -14915,101 +15033,11 @@ impl<'a> Parser<'a> { ) -> Result<(DataType, MatchedTrailingBracket), ParserError> { let mut parts = Vec::with_capacity(4); parts.push(first); - loop { - let next_part = { - let peeked = self.peek_token_ref(); - match &peeked.token { - Token::Word(w) if w.keyword == Keyword::GENERATED => { - // `GENERATED` starts a constraint only when followed - // by `ALWAYS`, otherwise it is a type name word. - (!matches!( - &self.peek_nth_token_ref(1).token, - Token::Word(w) if w.keyword == Keyword::ALWAYS - )) - .then(|| w.clone().into_ident(peeked.span)) - } - Token::Word(w) => { - // Fully-reserved keywords end the type name, see - // - (!matches!( - w.keyword, - Keyword::ADD - | Keyword::ALL - | Keyword::ALTER - | Keyword::AND - | Keyword::AS - | Keyword::AUTOINCREMENT - | Keyword::BETWEEN - | Keyword::CASE - | Keyword::CHECK - | Keyword::COLLATE - | Keyword::COMMIT - | Keyword::CONSTRAINT - | Keyword::CREATE - | Keyword::CROSS - | Keyword::DEFAULT - | Keyword::DELETE - | Keyword::DEFERRABLE - | Keyword::DISTINCT - | Keyword::DROP - | Keyword::ELSE - | Keyword::ESCAPE - | Keyword::EXCEPT - | Keyword::EXISTS - | Keyword::FOREIGN - | Keyword::FROM - | Keyword::FULL - | Keyword::GROUP - | Keyword::HAVING - | Keyword::IN - | Keyword::INDEX - | Keyword::INDEXED - | Keyword::INNER - | Keyword::INSERT - | Keyword::INTERSECT - | Keyword::INTO - | Keyword::IS - | Keyword::ISNULL - | Keyword::JOIN - | Keyword::LEFT - | Keyword::LIMIT - | Keyword::NATURAL - | Keyword::NOT - | Keyword::NULL - | Keyword::NOTHING - | Keyword::NOTNULL - | Keyword::ON - | Keyword::OR - | Keyword::ORDER - | Keyword::OUTER - | Keyword::PRIMARY - | Keyword::REFERENCES - | Keyword::RETURNING - | Keyword::RIGHT - | Keyword::SELECT - | Keyword::SET - | Keyword::TABLE - | Keyword::THEN - | Keyword::TO - | Keyword::TRANSACTION - | Keyword::UNIQUE - | Keyword::UNION - | Keyword::UPDATE - | Keyword::USING - | Keyword::VALUES - | Keyword::WHEN - | Keyword::WHERE - )) - .then(|| w.clone().into_ident(peeked.span)) - } - Token::SingleQuotedString(value) => { - Some(Ident::with_quote('\'', value.clone())) - } - _ => None, - } - }; - let Some(next_part) = next_part else { - break; + while let Some(peeked) = self.peek_multiword_type_name_part() { + let next_part = match &peeked.token { + Token::Word(word) => word.clone().into_ident(peeked.span), + Token::SingleQuotedString(value) => Ident::with_quote('\'', value.clone()), + _ => break, }; self.advance_token(); parts.push(next_part); diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 5d5243a51d..4abde5ccf3 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -5776,7 +5776,7 @@ fn parse_bad_constraint() { fn parse_sequence_options_in_any_order() { // The sequence options form an unordered list, so every permutation below // is accepted and round-trips in the order written. - let dialects = all_dialects(); + let dialects = all_dialects_where(|d| !d.supports_multiword_type_names()); for sql in [ "CREATE SEQUENCE sa START WITH 1 INCREMENT BY 1", "CREATE SEQUENCE sb INCREMENT BY 1 START WITH 1", @@ -5890,7 +5890,7 @@ fn parse_sequence_options_in_any_order() { #[test] fn sequence_options_reject_non_numeric_values() { - let dialects = all_dialects(); + let dialects = all_dialects_where(|d| !d.supports_multiword_type_names()); for (sql, expected) in [ ( "CREATE SEQUENCE s MINVALUE x", @@ -10009,7 +10009,8 @@ fn parse_set_role_as_variable() { #[test] fn parse_double_colon_cast_at_timezone() { let sql = "SELECT '2001-01-01T00:00:00.000Z'::TIMESTAMP AT TIME ZONE 'Europe/Brussels' FROM t"; - let select = verified_only_select(sql); + let select = + all_dialects_where(|d| !d.supports_multiword_type_names()).verified_only_select(sql); assert_eq!( &Expr::AtTimeZone { @@ -13332,10 +13333,10 @@ fn parse_execute_immediate() { fn parse_create_table_collate() { all_dialects().verified_stmt("CREATE TABLE tbl (foo INT, bar TEXT COLLATE \"de_DE\")"); // check ordering is preserved - all_dialects().verified_stmt( + all_dialects_where(|d| !d.supports_multiword_type_names()).verified_stmt( "CREATE TABLE tbl (foo INT, bar TEXT CHARACTER SET utf8mb4 COLLATE \"de_DE\")", ); - all_dialects().verified_stmt( + all_dialects_where(|d| !d.supports_multiword_type_names()).verified_stmt( "CREATE TABLE tbl (foo INT, bar TEXT COLLATE \"de_DE\" CHARACTER SET utf8mb4)", ); } @@ -13671,7 +13672,9 @@ fn test_parse_inline_comment() { "CREATE TABLE t0 (id INT COMMENT 'comment without equal') COMMENT = 'comment with equal'"; // Hive dialect doesn't support `=` in table comment, please refer: // [Hive](https://cwiki.apache.org/confluence/display/Hive/LanguageManual+DDL#LanguageManualDDL-CreateTable) - match all_dialects_except(|d| d.is::()).verified_stmt(sql) { + match all_dialects_except(|d| d.is::() || d.supports_multiword_type_names()) + .verified_stmt(sql) + { Statement::CreateTable(CreateTable { columns, table_options, @@ -14166,39 +14169,39 @@ fn test_group_by_grouping_sets_bare_columns() { #[test] fn test_xmltable() { - all_dialects() + let dialects = all_dialects_where(|d| !d.supports_multiword_type_names()); + dialects .verified_only_select("SELECT * FROM XMLTABLE('/root' PASSING data COLUMNS element TEXT)"); // Minimal meaningful working example: returns a single row with a single column named y containing the value z - all_dialects().verified_only_select( + dialects.verified_only_select( "SELECT y FROM XMLTABLE('/X' PASSING 'z' COLUMNS y TEXT)", ); // Test using subqueries - all_dialects().verified_only_select("SELECT y FROM XMLTABLE((SELECT '/X') PASSING (SELECT CAST('z' AS xml)) COLUMNS y TEXT PATH (SELECT 'y'))"); + dialects.verified_only_select("SELECT y FROM XMLTABLE((SELECT '/X') PASSING (SELECT CAST('z' AS xml)) COLUMNS y TEXT PATH (SELECT 'y'))"); // NOT NULL - all_dialects().verified_only_select( + dialects.verified_only_select( "SELECT y FROM XMLTABLE('/X' PASSING '' COLUMNS y TEXT NOT NULL)", ); - all_dialects().verified_only_select("SELECT * FROM XMLTABLE('/root/row' PASSING xmldata COLUMNS id INT PATH '@id', name TEXT PATH 'name/text()', value FLOAT PATH 'value')"); + dialects.verified_only_select("SELECT * FROM XMLTABLE('/root/row' PASSING xmldata COLUMNS id INT PATH '@id', name TEXT PATH 'name/text()', value FLOAT PATH 'value')"); - all_dialects().verified_only_select("SELECT * FROM XMLTABLE('//ROWS/ROW' PASSING data COLUMNS row_num FOR ORDINALITY, id INT PATH '@id', name TEXT PATH 'NAME' DEFAULT 'unnamed')"); + dialects.verified_only_select("SELECT * FROM XMLTABLE('//ROWS/ROW' PASSING data COLUMNS row_num FOR ORDINALITY, id INT PATH '@id', name TEXT PATH 'NAME' DEFAULT 'unnamed')"); // Example from https://www.postgresql.org/docs/15/functions-xml.html#FUNCTIONS-XML-PROCESSING - all_dialects().verified_only_select( + dialects.verified_only_select( "SELECT xmltable.* FROM xmldata, XMLTABLE('//ROWS/ROW' PASSING data COLUMNS id INT PATH '@id', ordinality FOR ORDINALITY, \"COUNTRY_NAME\" TEXT, country_id TEXT PATH 'COUNTRY_ID', size_sq_km FLOAT PATH 'SIZE[@unit = \"sq_km\"]', size_other TEXT PATH 'concat(SIZE[@unit!=\"sq_km\"], \" \", SIZE[@unit!=\"sq_km\"]/@unit)', premier_name TEXT PATH 'PREMIER_NAME' DEFAULT 'not specified')" ); // Example from DB2 docs without explicit PASSING clause: https://www.ibm.com/docs/en/db2/12.1.0?topic=xquery-simple-column-name-passing-xmlexists-xmlquery-xmltable - // Multi-word type names would read `PATH` as part of the column type - all_dialects_where(|d| !d.supports_multiword_type_names()).verified_only_select( + dialects.verified_only_select( "SELECT X.* FROM T1, XMLTABLE('$CUSTLIST/customers/customerinfo' COLUMNS \"Cid\" BIGINT PATH '@Cid', \"Info\" XML PATH 'document{.}', \"History\" XML PATH 'NULL') AS X" ); // Example from PostgreSQL with XMLNAMESPACES - all_dialects().verified_only_select( + dialects.verified_only_select( "SELECT xmltable.* FROM XMLTABLE(XMLNAMESPACES('http://example.com/myns' AS x, 'http://example.com/b' AS \"B\"), '/x:example/x:item' PASSING (SELECT data FROM xmldata) COLUMNS foo INT PATH '@foo', bar INT PATH '@B:bar')" ); } @@ -19908,8 +19911,9 @@ fn parse_adjacent_string_literal_concatenation() { #[test] fn parse_invisible_column() { + let dialects = all_dialects_where(|d| !d.supports_multiword_type_names()); let sql = r#"CREATE TABLE t (foo INT, bar INT INVISIBLE)"#; - let stmt = verified_stmt(sql); + let stmt = dialects.verified_stmt(sql); match stmt { Statement::CreateTable(CreateTable { columns, .. }) => { assert_eq!( @@ -19935,7 +19939,7 @@ fn parse_invisible_column() { } let sql = r#"ALTER TABLE t ADD COLUMN bar INT INVISIBLE"#; - let stmt = verified_stmt(sql); + let stmt = dialects.verified_stmt(sql); match stmt { Statement::AlterTable(alter_table) => { assert_eq!( @@ -20976,7 +20980,9 @@ fn parse_alter_table_column_position() { .unwrap_err() ); - let dialects = all_dialects_where(|d| !d.supports_alter_column_position()); + let dialects = all_dialects_where(|d| { + !d.supports_alter_column_position() && !d.supports_multiword_type_names() + }); for (sql, found) in [ ("ALTER TABLE tab ADD COLUMN c INT FIRST", "FIRST"), ("ALTER TABLE tab ADD COLUMN c INT AFTER b", "AFTER"), diff --git a/tests/sqlparser_mysql.rs b/tests/sqlparser_mysql.rs index 233a5403a5..1f61fab660 100644 --- a/tests/sqlparser_mysql.rs +++ b/tests/sqlparser_mysql.rs @@ -1953,8 +1953,10 @@ fn parse_zerofill() { )) ); assert_eq!( - all_dialects_except(|d| d.supports_data_type_zerofill_suffix()) - .parse_sql_statements("CREATE TABLE t (a INT ZEROFILL)"), + all_dialects_except(|d| { + d.supports_data_type_zerofill_suffix() || d.supports_multiword_type_names() + }) + .parse_sql_statements("CREATE TABLE t (a INT ZEROFILL)"), Err(ParserError::ParserError( "Expected: ',' or ')' after column definition, found: ZEROFILL".to_string() )) diff --git a/tests/sqlparser_sqlite.rs b/tests/sqlparser_sqlite.rs index efe51e12e4..0f64419943 100644 --- a/tests/sqlparser_sqlite.rs +++ b/tests/sqlparser_sqlite.rs @@ -1783,6 +1783,52 @@ fn parse_left_shift_and_bitwise_and_are_left_associative() { ); } +#[test] +fn sqlite_type_name_builtin_prefixes() { + for type_name in [ + "INT FOO", + "TEXT FOO", + "DOUBLE PRECISION FOO", + "INT FIRST", + "INT INVISIBLE", + ] { + let sql = format!("CREATE TABLE t (a {type_name})"); + let Statement::CreateTable(table) = sqlite().verified_stmt(&sql) else { + unreachable!() + }; + assert_eq!( + table.columns[0].data_type, + DataType::CustomMultiWord( + type_name.split_whitespace().map(Ident::new).collect(), + vec![] + ), + ); + sqlite().verified_stmt(&format!("SELECT CAST(1 AS {type_name})")); + sqlite().verified_stmt(&format!("ALTER TABLE t ADD a {type_name}")); + } + sqlite().verified_stmt("CREATE TABLE t (a INT FOO(10, 2))"); + let Statement::CreateTable(table) = + sqlite().verified_stmt("CREATE TABLE t (a INT COMMENT 'note')") + else { + unreachable!() + }; + assert_eq!( + table.columns[0], + ColumnDef { + name: Ident::new("a"), + data_type: DataType::CustomMultiWord( + vec![ + Ident::new("INT"), + Ident::new("COMMENT"), + Ident::with_quote('\'', "note"), + ], + vec![], + ), + options: vec![], + }, + ); +} + #[test] fn parse_right_shift_and_bitwise_and_are_left_associative() { assert_eq!( @@ -1882,3 +1928,83 @@ fn parse_bitwise_shift_precedence_boundaries() { assert_eq!(sqlite().verified_expr(sql), expected, "{sql}"); } } + +#[test] +fn sqlite_type_name_builtin_parse_errors() { + for type_name in ["ENUM FOO", "TIMESTAMP WITH FOO"] { + sqlite().verified_stmt(&format!("CREATE TABLE t (a {type_name})")); + sqlite().verified_stmt(&format!("SELECT CAST(1 AS {type_name})")); + sqlite().verified_stmt(&format!("ALTER TABLE t ADD a {type_name}")); + } +} + +#[test] +fn sqlite_type_name_builtin_prefix_constraint_boundaries() { + for (constraint, option) in [ + ("NOT NULL", ColumnOption::NotNull), + ("NULL", ColumnOption::Null), + ("DEFAULT 0", ColumnOption::Default(Expr::value(number("0")))), + ( + "COLLATE NOCASE", + ColumnOption::Collation(ObjectName::from(vec![Ident::new("NOCASE")])), + ), + ] { + let sql = format!("CREATE TABLE t (a TEXT FOO {constraint})"); + let Statement::CreateTable(table) = sqlite().verified_stmt(&sql) else { + unreachable!() + }; + assert_eq!( + table.columns[0].data_type, + DataType::CustomMultiWord(vec![Ident::new("TEXT"), Ident::new("FOO")], vec![]), + ); + assert_eq!( + table.columns[0].options, + vec![ColumnOptionDef { name: None, option }], + ); + } + sqlite().verified_stmt("CREATE TABLE t (a TEXT FOO PRIMARY KEY)"); + sqlite().verified_stmt("CREATE TABLE t (a INT FOO GENERATED ALWAYS AS (1), b INT)"); + sqlite().verified_stmt("CREATE TABLE t (a INT FOO GENERATED, b INT)"); +} + +#[test] +fn sqlite_type_name_untyped_null_constraint() { + let Statement::CreateTable(table) = sqlite().verified_stmt("CREATE TABLE t (a NULL, b INT)") + else { + unreachable!() + }; + assert_eq!(table.columns[0].data_type, DataType::Unspecified); + assert_eq!( + table.columns[0].options, + vec![ColumnOptionDef { + name: None, + option: ColumnOption::Null, + }], + ); + let AlterTableOperation::AddColumn { column_def, .. } = + alter_table_op_with_name(sqlite().verified_stmt("ALTER TABLE t ADD a NULL"), "t") + else { + unreachable!() + }; + assert_eq!(column_def, table.columns[0]); + sqlite().verified_stmt(r#"CREATE TABLE t (a "NULL" foo)"#); +} + +#[test] +fn sqlite_type_name_rejects_words_after_null_constraint() { + for (sql, error) in [ + ( + "CREATE TABLE t (a NULL foo)", + "Expected: ',' or ')' after column definition, found: foo", + ), + ( + "ALTER TABLE t ADD a NULL foo", + "Expected: end of statement, found: foo", + ), + ] { + assert_eq!( + sqlite().parse_sql_statements(sql), + Err(ParserError::ParserError(error.to_string())), + ); + } +} From 68b01b4f5b10442dade23c80fc1347f26425be01 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Sat, 24 Oct 2026 16:08:27 +0200 Subject: [PATCH 7/7] Preserve built-in type errors in multiword fallback --- src/parser/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 740689fb06..230a98da4c 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -13258,6 +13258,11 @@ impl<'a> Parser<'a> { Token::SingleQuotedString(value) => Ident::with_quote('\'', value.clone()), _ => return self.expected_ref("a data type name", token), }; + if self.peek_multiword_type_name_part().is_none() { + if let Some(error) = dedicated_error { + return Err(error); + } + } self.parse_multiword_type_name(first) .map_err(|error| dedicated_error.unwrap_or(error)) }