From f7518fc343bcae56943a4f8b56557fbd9f491138 Mon Sep 17 00:00:00 2001 From: Simon Sawert Date: Wed, 11 Sep 2024 19:09:41 +0200 Subject: [PATCH] feat: Add support for MSSQL table options (#1414) --- src/ast/mod.rs | 120 ++++++++++++++++- src/keywords.rs | 2 + src/parser/mod.rs | 107 +++++++++++++-- tests/sqlparser_bigquery.rs | 28 ++-- tests/sqlparser_common.rs | 56 ++++---- tests/sqlparser_mssql.rs | 255 ++++++++++++++++++++++++++++++++++++ tests/sqlparser_postgres.rs | 20 +-- 7 files changed, 523 insertions(+), 65 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d2bffd322..8fbdf6a7c 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -2103,6 +2103,15 @@ pub enum CreateTableOptions { /// e.g. `WITH (description = "123")` /// /// + /// + /// MSSQL supports more specific options that's not only key-value pairs. + /// + /// WITH ( + /// DISTRIBUTION = ROUND_ROBIN, + /// CLUSTERED INDEX (column_a DESC, column_b) + /// ) + /// + /// With(Vec), /// Options specified using the `OPTIONS` keyword. /// e.g. `OPTIONS(description = "123")` @@ -5749,14 +5758,119 @@ pub struct HiveFormat { #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] -pub struct SqlOption { +pub struct ClusteredIndex { pub name: Ident, - pub value: Expr, + pub asc: Option, +} + +impl fmt::Display for ClusteredIndex { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.name)?; + match self.asc { + Some(true) => write!(f, " ASC"), + Some(false) => write!(f, " DESC"), + _ => Ok(()), + } + } +} + +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum TableOptionsClustered { + ColumnstoreIndex, + ColumnstoreIndexOrder(Vec), + Index(Vec), +} + +impl fmt::Display for TableOptionsClustered { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + TableOptionsClustered::ColumnstoreIndex => { + write!(f, "CLUSTERED COLUMNSTORE INDEX") + } + TableOptionsClustered::ColumnstoreIndexOrder(values) => { + write!( + f, + "CLUSTERED COLUMNSTORE INDEX ORDER ({})", + display_comma_separated(values) + ) + } + TableOptionsClustered::Index(values) => { + write!(f, "CLUSTERED INDEX ({})", display_comma_separated(values)) + } + } + } +} + +/// Specifies which partition the boundary values on table partitioning belongs to. +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum PartitionRangeDirection { + Left, + Right, +} + +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum SqlOption { + /// Clustered represents the clustered version of table storage for MSSQL. + /// + /// + Clustered(TableOptionsClustered), + /// Single identifier options, e.g. `HEAP` for MSSQL. + /// + /// + Ident(Ident), + /// Any option that consists of a key value pair where the value is an expression. e.g. + /// + /// WITH(DISTRIBUTION = ROUND_ROBIN) + KeyValue { key: Ident, value: Expr }, + /// One or more table partitions and represents which partition the boundary values belong to, + /// e.g. + /// + /// PARTITION (id RANGE LEFT FOR VALUES (10, 20, 30, 40)) + /// + /// + Partition { + column_name: Ident, + range_direction: Option, + for_values: Vec, + }, } impl fmt::Display for SqlOption { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{} = {}", self.name, self.value) + match self { + SqlOption::Clustered(c) => write!(f, "{}", c), + SqlOption::Ident(ident) => { + write!(f, "{}", ident) + } + SqlOption::KeyValue { key: name, value } => { + write!(f, "{} = {}", name, value) + } + SqlOption::Partition { + column_name, + range_direction, + for_values, + } => { + let direction = match range_direction { + Some(PartitionRangeDirection::Left) => " LEFT", + Some(PartitionRangeDirection::Right) => " RIGHT", + None => "", + }; + + write!( + f, + "PARTITION ({} RANGE{} FOR VALUES ({}))", + column_name, + direction, + display_comma_separated(for_values) + ) + } + } } } diff --git a/src/keywords.rs b/src/keywords.rs index a968421b4..bb1c132fc 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -166,6 +166,7 @@ define_keywords!( COLLECTION, COLUMN, COLUMNS, + COLUMNSTORE, COMMENT, COMMIT, COMMITTED, @@ -355,6 +356,7 @@ define_keywords!( HASH, HAVING, HEADER, + HEAP, HIGH_PRIORITY, HISTORY, HIVEVAR, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index ae82b4d3a..88302bce6 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -6494,10 +6494,91 @@ impl<'a> Parser<'a> { } pub fn parse_sql_option(&mut self) -> Result { - let name = self.parse_identifier(false)?; - self.expect_token(&Token::Eq)?; - let value = self.parse_expr()?; - Ok(SqlOption { name, value }) + let is_mssql = dialect_of!(self is MsSqlDialect|GenericDialect); + + match self.peek_token().token { + Token::Word(w) if w.keyword == Keyword::HEAP && is_mssql => { + Ok(SqlOption::Ident(self.parse_identifier(false)?)) + } + Token::Word(w) if w.keyword == Keyword::PARTITION && is_mssql => { + self.parse_option_partition() + } + Token::Word(w) if w.keyword == Keyword::CLUSTERED && is_mssql => { + self.parse_option_clustered() + } + _ => { + let name = self.parse_identifier(false)?; + self.expect_token(&Token::Eq)?; + let value = self.parse_expr()?; + + Ok(SqlOption::KeyValue { key: name, value }) + } + } + } + + pub fn parse_option_clustered(&mut self) -> Result { + if self.parse_keywords(&[ + Keyword::CLUSTERED, + Keyword::COLUMNSTORE, + Keyword::INDEX, + Keyword::ORDER, + ]) { + Ok(SqlOption::Clustered( + TableOptionsClustered::ColumnstoreIndexOrder( + self.parse_parenthesized_column_list(IsOptional::Mandatory, false)?, + ), + )) + } else if self.parse_keywords(&[Keyword::CLUSTERED, Keyword::COLUMNSTORE, Keyword::INDEX]) { + Ok(SqlOption::Clustered( + TableOptionsClustered::ColumnstoreIndex, + )) + } else if self.parse_keywords(&[Keyword::CLUSTERED, Keyword::INDEX]) { + self.expect_token(&Token::LParen)?; + + let columns = self.parse_comma_separated(|p| { + let name = p.parse_identifier(false)?; + let asc = p.parse_asc_desc(); + + Ok(ClusteredIndex { name, asc }) + })?; + + self.expect_token(&Token::RParen)?; + + Ok(SqlOption::Clustered(TableOptionsClustered::Index(columns))) + } else { + Err(ParserError::ParserError( + "invalid CLUSTERED sequence".to_string(), + )) + } + } + + pub fn parse_option_partition(&mut self) -> Result { + self.expect_keyword(Keyword::PARTITION)?; + self.expect_token(&Token::LParen)?; + let column_name = self.parse_identifier(false)?; + + self.expect_keyword(Keyword::RANGE)?; + let range_direction = if self.parse_keyword(Keyword::LEFT) { + Some(PartitionRangeDirection::Left) + } else if self.parse_keyword(Keyword::RIGHT) { + Some(PartitionRangeDirection::Right) + } else { + None + }; + + self.expect_keywords(&[Keyword::FOR, Keyword::VALUES])?; + self.expect_token(&Token::LParen)?; + + let for_values = self.parse_comma_separated(Parser::parse_expr)?; + + self.expect_token(&Token::RParen)?; + self.expect_token(&Token::RParen)?; + + Ok(SqlOption::Partition { + column_name, + range_direction, + for_values, + }) } pub fn parse_partition(&mut self) -> Result { @@ -11043,17 +11124,23 @@ impl<'a> Parser<'a> { }) } - /// Parse an expression, optionally followed by ASC or DESC (used in ORDER BY) - pub fn parse_order_by_expr(&mut self) -> Result { - let expr = self.parse_expr()?; - - let asc = if self.parse_keyword(Keyword::ASC) { + /// Parse ASC or DESC, returns an Option with true if ASC, false of DESC or `None` if none of + /// them. + pub fn parse_asc_desc(&mut self) -> Option { + if self.parse_keyword(Keyword::ASC) { Some(true) } else if self.parse_keyword(Keyword::DESC) { Some(false) } else { None - }; + } + } + + /// Parse an expression, optionally followed by ASC or DESC (used in ORDER BY) + pub fn parse_order_by_expr(&mut self) -> Result { + let expr = self.parse_expr()?; + + let asc = self.parse_asc_desc(); let nulls_first = if self.parse_keywords(&[Keyword::NULLS, Keyword::FIRST]) { Some(true) diff --git a/tests/sqlparser_bigquery.rs b/tests/sqlparser_bigquery.rs index 4f84b376d..e051baa8b 100644 --- a/tests/sqlparser_bigquery.rs +++ b/tests/sqlparser_bigquery.rs @@ -267,8 +267,8 @@ fn parse_create_view_with_options() { ViewColumnDef { name: Ident::new("age"), data_type: None, - options: Some(vec![SqlOption { - name: Ident::new("description"), + options: Some(vec![SqlOption::KeyValue { + key: Ident::new("description"), value: Expr::Value(Value::DoubleQuotedString("field age".to_string())), }]) }, @@ -287,8 +287,8 @@ fn parse_create_view_with_options() { unreachable!() }; assert_eq!( - &SqlOption { - name: Ident::new("description"), + &SqlOption::KeyValue { + key: Ident::new("description"), value: Expr::Value(Value::DoubleQuotedString( "a view that expires in 2 days".to_string() )), @@ -414,8 +414,8 @@ fn parse_create_table_with_options() { }, ColumnOptionDef { name: None, - option: ColumnOption::Options(vec![SqlOption { - name: Ident::new("description"), + option: ColumnOption::Options(vec![SqlOption::KeyValue { + key: Ident::new("description"), value: Expr::Value(Value::DoubleQuotedString( "field x".to_string() )), @@ -429,8 +429,8 @@ fn parse_create_table_with_options() { collation: None, options: vec![ColumnOptionDef { name: None, - option: ColumnOption::Options(vec![SqlOption { - name: Ident::new("description"), + option: ColumnOption::Options(vec![SqlOption::KeyValue { + key: Ident::new("description"), value: Expr::Value(Value::DoubleQuotedString( "field y".to_string() )), @@ -448,12 +448,12 @@ fn parse_create_table_with_options() { Ident::new("age"), ])), Some(vec![ - SqlOption { - name: Ident::new("partition_expiration_days"), + SqlOption::KeyValue { + key: Ident::new("partition_expiration_days"), value: Expr::Value(number("1")), }, - SqlOption { - name: Ident::new("description"), + SqlOption::KeyValue { + key: Ident::new("description"), value: Expr::Value(Value::DoubleQuotedString( "table option description".to_string() )), @@ -2005,8 +2005,8 @@ fn test_bigquery_create_function() { function_body: Some(CreateFunctionBody::AsAfterOptions(Expr::Value(number( "42" )))), - options: Some(vec![SqlOption { - name: Ident::new("x"), + options: Some(vec![SqlOption::KeyValue { + key: Ident::new("x"), value: Expr::Value(Value::SingleQuotedString("y".into())), }]), behavior: None, diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 59bd95be4..d76dd573f 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -3638,12 +3638,12 @@ fn parse_create_table_with_options() { Statement::CreateTable(CreateTable { with_options, .. }) => { assert_eq!( vec![ - SqlOption { - name: "foo".into(), + SqlOption::KeyValue { + key: "foo".into(), value: Expr::Value(Value::SingleQuotedString("bar".into())), }, - SqlOption { - name: "a".into(), + SqlOption::KeyValue { + key: "a".into(), value: Expr::Value(number("123")), }, ], @@ -3871,8 +3871,8 @@ fn parse_alter_table() { AlterTableOperation::SetTblProperties { table_properties } => { assert_eq!( table_properties, - [SqlOption { - name: Ident { + [SqlOption::KeyValue { + key: Ident { value: "classification".to_string(), quote_style: Some('\'') }, @@ -3959,12 +3959,12 @@ fn parse_alter_view_with_options() { Statement::AlterView { with_options, .. } => { assert_eq!( vec![ - SqlOption { - name: "foo".into(), + SqlOption::KeyValue { + key: "foo".into(), value: Expr::Value(Value::SingleQuotedString("bar".into())), }, - SqlOption { - name: "a".into(), + SqlOption::KeyValue { + key: "a".into(), value: Expr::Value(number("123")), }, ], @@ -6730,12 +6730,12 @@ fn parse_create_view_with_options() { Statement::CreateView { options, .. } => { assert_eq!( CreateTableOptions::With(vec![ - SqlOption { - name: "foo".into(), + SqlOption::KeyValue { + key: "foo".into(), value: Expr::Value(Value::SingleQuotedString("bar".into())), }, - SqlOption { - name: "a".into(), + SqlOption::KeyValue { + key: "a".into(), value: Expr::Value(number("123")), }, ]), @@ -8828,12 +8828,12 @@ fn parse_cache_table() { table_name: ObjectName(vec![Ident::with_quote('\'', cache_table_name)]), has_as: false, options: vec![ - SqlOption { - name: Ident::with_quote('\'', "K1"), + SqlOption::KeyValue { + key: Ident::with_quote('\'', "K1"), value: Expr::Value(Value::SingleQuotedString("V1".into())), }, - SqlOption { - name: Ident::with_quote('\'', "K2"), + SqlOption::KeyValue { + key: Ident::with_quote('\'', "K2"), value: Expr::Value(number("0.88")), }, ], @@ -8853,12 +8853,12 @@ fn parse_cache_table() { table_name: ObjectName(vec![Ident::with_quote('\'', cache_table_name)]), has_as: false, options: vec![ - SqlOption { - name: Ident::with_quote('\'', "K1"), + SqlOption::KeyValue { + key: Ident::with_quote('\'', "K1"), value: Expr::Value(Value::SingleQuotedString("V1".into())), }, - SqlOption { - name: Ident::with_quote('\'', "K2"), + SqlOption::KeyValue { + key: Ident::with_quote('\'', "K2"), value: Expr::Value(number("0.88")), }, ], @@ -8878,12 +8878,12 @@ fn parse_cache_table() { table_name: ObjectName(vec![Ident::with_quote('\'', cache_table_name)]), has_as: true, options: vec![ - SqlOption { - name: Ident::with_quote('\'', "K1"), + SqlOption::KeyValue { + key: Ident::with_quote('\'', "K1"), value: Expr::Value(Value::SingleQuotedString("V1".into())), }, - SqlOption { - name: Ident::with_quote('\'', "K2"), + SqlOption::KeyValue { + key: Ident::with_quote('\'', "K2"), value: Expr::Value(number("0.88")), }, ], @@ -9697,8 +9697,8 @@ fn parse_unload() { value: "s3://...".to_string(), quote_style: Some('\'') }, - with: vec![SqlOption { - name: Ident { + with: vec![SqlOption::KeyValue { + key: Ident { value: "format".to_string(), quote_style: None }, diff --git a/tests/sqlparser_mssql.rs b/tests/sqlparser_mssql.rs index 5c2ec8763..0ab160f56 100644 --- a/tests/sqlparser_mssql.rs +++ b/tests/sqlparser_mssql.rs @@ -653,6 +653,261 @@ fn parse_use() { } } +#[test] +fn parse_create_table_with_valid_options() { + let options = [ + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (DISTRIBUTION = ROUND_ROBIN, PARTITION (column_a RANGE FOR VALUES (10, 11)))", + vec![ + SqlOption::KeyValue { + key: Ident { + value: "DISTRIBUTION".to_string(), + quote_style: None, + }, + value: Expr::Identifier(Ident { + value: "ROUND_ROBIN".to_string(), + quote_style: None, + }) + }, + SqlOption::Partition { + column_name: "column_a".into(), + range_direction: None, + for_values: vec![Expr::Value(test_utils::number("10")), Expr::Value(test_utils::number("11"))] , + }, + ], + ), + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (PARTITION (column_a RANGE LEFT FOR VALUES (10, 11)))", + vec![ + SqlOption::Partition { + column_name: "column_a".into(), + range_direction: Some(PartitionRangeDirection::Left), + for_values: vec![ + Expr::Value(test_utils::number("10")), + Expr::Value(test_utils::number("11")), + ], + } + ], + ), + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (CLUSTERED COLUMNSTORE INDEX)", + vec![SqlOption::Clustered(TableOptionsClustered::ColumnstoreIndex)], + ), + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (CLUSTERED COLUMNSTORE INDEX ORDER (column_a, column_b))", + vec![ + SqlOption::Clustered(TableOptionsClustered::ColumnstoreIndexOrder(vec![ + "column_a".into(), + "column_b".into(), + ])) + ], + ), + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (CLUSTERED INDEX (column_a ASC, column_b DESC, column_c))", + vec![ + SqlOption::Clustered(TableOptionsClustered::Index(vec![ + ClusteredIndex { + name: Ident { + value: "column_a".to_string(), + quote_style: None, + }, + asc: Some(true), + }, + ClusteredIndex { + name: Ident { + value: "column_b".to_string(), + quote_style: None, + }, + asc: Some(false), + }, + ClusteredIndex { + name: Ident { + value: "column_c".to_string(), + quote_style: None, + }, + asc: None, + }, + ])) + ], + ), + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (DISTRIBUTION = HASH(column_a, column_b), HEAP)", + vec![ + SqlOption::KeyValue { + key: Ident { + value: "DISTRIBUTION".to_string(), + quote_style: None, + }, + value: Expr::Function( + Function { + name: ObjectName( + vec![ + Ident { + value: "HASH".to_string(), + quote_style: None, + }, + ], + ), + parameters: FunctionArguments::None, + args: FunctionArguments::List( + FunctionArgumentList { + duplicate_treatment: None, + args: vec![ + FunctionArg::Unnamed( + FunctionArgExpr::Expr( + Expr::Identifier( + Ident { + value: "column_a".to_string(), + quote_style: None, + }, + ), + ), + ), + FunctionArg::Unnamed( + FunctionArgExpr::Expr( + Expr::Identifier( + Ident { + value: "column_b".to_string(), + quote_style: None, + }, + ), + ), + ), + ], + clauses: vec![], + }, + ), + filter: None, + null_treatment: None, + over: None, + within_group: vec![], + }, + ), + }, + SqlOption::Ident("HEAP".into()), + ], + ), + ]; + + for (sql, with_options) in options { + assert_eq!( + ms_and_generic().verified_stmt(sql), + Statement::CreateTable(CreateTable { + or_replace: false, + temporary: false, + external: false, + global: None, + if_not_exists: false, + transient: false, + volatile: false, + name: ObjectName(vec![Ident { + value: "mytable".to_string(), + quote_style: None, + },],), + columns: vec![ + ColumnDef { + name: Ident { + value: "column_a".to_string(), + quote_style: None, + }, + data_type: Int(None,), + collation: None, + options: vec![], + }, + ColumnDef { + name: Ident { + value: "column_b".to_string(), + quote_style: None, + }, + data_type: Int(None,), + collation: None, + options: vec![], + }, + ColumnDef { + name: Ident { + value: "column_c".to_string(), + quote_style: None, + }, + data_type: Int(None,), + collation: None, + options: vec![], + }, + ], + constraints: vec![], + hive_distribution: HiveDistributionStyle::NONE, + hive_formats: Some(HiveFormat { + row_format: None, + serde_properties: None, + storage: None, + location: None, + },), + table_properties: vec![], + with_options, + file_format: None, + location: None, + query: None, + without_rowid: false, + like: None, + clone: None, + engine: None, + comment: None, + auto_increment_offset: None, + default_charset: None, + collation: None, + on_commit: None, + on_cluster: None, + primary_key: None, + order_by: None, + partition_by: None, + cluster_by: None, + clustered_by: None, + options: None, + strict: false, + copy_grants: false, + enable_schema_evolution: None, + change_tracking: None, + data_retention_time_in_days: None, + max_data_extension_time_in_days: None, + default_ddl_collation: None, + with_aggregation_policy: None, + with_row_access_policy: None, + with_tags: None, + }) + ); + } +} + +#[test] +fn parse_create_table_with_invalid_options() { + let invalid_cases = vec![ + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (CLUSTERED COLUMNSTORE INDEX ORDER ())", + "Expected: identifier, found: )", + ), + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (CLUSTERED COLUMNSTORE)", + "invalid CLUSTERED sequence", + ), + ( + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (HEAP INDEX)", + "Expected: ), found: INDEX", + ), + ( + + "CREATE TABLE mytable (column_a INT, column_b INT, column_c INT) WITH (PARTITION (RANGE LEFT FOR VALUES (10, 11)))", + "Expected: RANGE, found: LEFT", + ), + ]; + + for (sql, expected_error) in invalid_cases { + let res = ms_and_generic().parse_sql_statements(sql); + assert_eq!( + format!("sql parser error: {expected_error}"), + res.unwrap_err().to_string() + ); + } +} + fn ms() -> TestedDialects { TestedDialects { dialects: vec![Box::new(MsSqlDialect {})], diff --git a/tests/sqlparser_postgres.rs b/tests/sqlparser_postgres.rs index 1ebb5d54c..ec1311f2c 100644 --- a/tests/sqlparser_postgres.rs +++ b/tests/sqlparser_postgres.rs @@ -461,16 +461,16 @@ fn parse_create_table_with_defaults() { assert_eq!( with_options, vec![ - SqlOption { - name: "fillfactor".into(), + SqlOption::KeyValue { + key: "fillfactor".into(), value: Expr::Value(number("20")) }, - SqlOption { - name: "user_catalog_table".into(), + SqlOption::KeyValue { + key: "user_catalog_table".into(), value: Expr::Value(Value::Boolean(true)) }, - SqlOption { - name: "autovacuum_vacuum_threshold".into(), + SqlOption::KeyValue { + key: "autovacuum_vacuum_threshold".into(), value: Expr::Value(number("100")) }, ] @@ -4482,12 +4482,12 @@ fn parse_create_table_with_options() { Statement::CreateTable(CreateTable { with_options, .. }) => { assert_eq!( vec![ - SqlOption { - name: "foo".into(), + SqlOption::KeyValue { + key: "foo".into(), value: Expr::Value(Value::SingleQuotedString("bar".into())), }, - SqlOption { - name: "a".into(), + SqlOption::KeyValue { + key: "a".into(), value: Expr::Value(number("123")), }, ],