From a39cadea70b8b9d55ab729c321b972bdfea4ebac Mon Sep 17 00:00:00 2001 From: Yoav Cohen Date: Wed, 16 Jul 2025 23:52:17 +0300 Subject: [PATCH 1/2] Snowflake: CREATE USER --- src/ast/helpers/key_value_options.rs | 28 ++++--- src/ast/mod.rs | 42 ++++++++++ src/ast/spans.rs | 1 + src/dialect/snowflake.rs | 109 +++++++++----------------- src/parser/mod.rs | 112 ++++++++++++++++++++++++++- tests/sqlparser_common.rs | 13 ++++ 6 files changed, 220 insertions(+), 85 deletions(-) diff --git a/src/ast/helpers/key_value_options.rs b/src/ast/helpers/key_value_options.rs index 796bfd5e3..7f1bb0fdb 100644 --- a/src/ast/helpers/key_value_options.rs +++ b/src/ast/helpers/key_value_options.rs @@ -31,11 +31,22 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "visitor")] use sqlparser_derive::{Visit, VisitMut}; +use crate::ast::display_separated; + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] pub struct KeyValueOptions { pub options: Vec, + pub delimiter: KeyValueOptionsDelimiter, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum KeyValueOptionsDelimiter { + Space, + Comma, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -59,18 +70,11 @@ pub struct KeyValueOption { impl fmt::Display for KeyValueOptions { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - if !self.options.is_empty() { - let mut first = false; - for option in &self.options { - if !first { - first = true; - } else { - f.write_str(" ")?; - } - write!(f, "{option}")?; - } - } - Ok(()) + let sep = match self.delimiter { + KeyValueOptionsDelimiter::Space => " ", + KeyValueOptionsDelimiter::Comma => ", ", + }; + write!(f, "{}", display_separated(&self.options, sep)) } } diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 1798223f3..d2174acd4 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -4355,6 +4355,11 @@ pub enum Statement { /// /// See [ReturnStatement] Return(ReturnStatement), + /// ```sql + /// CREATE [OR REPLACE] USER [IF NOT EXISTS] + /// ``` + /// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-user) + CreateUser(CreateUser), } /// ```sql @@ -6193,6 +6198,7 @@ impl fmt::Display for Statement { Statement::Return(r) => write!(f, "{r}"), Statement::List(command) => write!(f, "LIST {command}"), Statement::Remove(command) => write!(f, "REMOVE {command}"), + Statement::CreateUser(s) => write!(f, "{s}"), } } } @@ -10125,6 +10131,42 @@ impl fmt::Display for MemberOf { } } +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub struct CreateUser { + pub or_replace: bool, + pub if_not_exists: bool, + pub name: Ident, + pub options: KeyValueOptions, + pub with_tags: bool, + pub tags: KeyValueOptions, +} + +impl fmt::Display for CreateUser { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "CREATE")?; + if self.or_replace { + write!(f, " OR REPLACE")?; + } + write!(f, " USER")?; + if self.if_not_exists { + write!(f, " IF NOT EXISTS")?; + } + write!(f, " {}", self.name)?; + if !self.options.options.is_empty() { + write!(f, " {}", self.options)?; + } + if !self.tags.options.is_empty() { + if self.with_tags { + write!(f, " WITH")?; + } + write!(f, " TAG ({})", self.tags)?; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use crate::tokenizer::Location; diff --git a/src/ast/spans.rs b/src/ast/spans.rs index 3e82905e1..4deedca04 100644 --- a/src/ast/spans.rs +++ b/src/ast/spans.rs @@ -531,6 +531,7 @@ impl Spanned for Statement { Statement::Print { .. } => Span::empty(), Statement::Return { .. } => Span::empty(), Statement::List(..) | Statement::Remove(..) => Span::empty(), + Statement::CreateUser(..) => Span::empty(), } } } diff --git a/src/dialect/snowflake.rs b/src/dialect/snowflake.rs index baf99b84b..9786aba0d 100644 --- a/src/dialect/snowflake.rs +++ b/src/dialect/snowflake.rs @@ -17,7 +17,9 @@ #[cfg(not(feature = "std"))] use crate::alloc::string::ToString; -use crate::ast::helpers::key_value_options::{KeyValueOption, KeyValueOptionType, KeyValueOptions}; +use crate::ast::helpers::key_value_options::{ + KeyValueOption, KeyValueOptionType, KeyValueOptions, KeyValueOptionsDelimiter, +}; use crate::ast::helpers::stmt_create_table::CreateTableBuilder; use crate::ast::helpers::stmt_data_loading::{ FileStagingCommand, StageLoadSelectItem, StageLoadSelectItemKind, StageParamsObject, @@ -31,7 +33,7 @@ use crate::ast::{ use crate::dialect::{Dialect, Precedence}; use crate::keywords::Keyword; use crate::parser::{IsOptional, Parser, ParserError}; -use crate::tokenizer::{Token, Word}; +use crate::tokenizer::Token; #[cfg(not(feature = "std"))] use alloc::boxed::Box; #[cfg(not(feature = "std"))] @@ -516,6 +518,7 @@ fn parse_alter_session(parser: &mut Parser, set: bool) -> Result Result { let mut from_stage = None; let mut stage_params = StageParamsObject { url: None, - encryption: KeyValueOptions { options: vec![] }, + encryption: KeyValueOptions { + options: vec![], + delimiter: KeyValueOptionsDelimiter::Space, + }, endpoint: None, storage_integration: None, - credentials: KeyValueOptions { options: vec![] }, + credentials: KeyValueOptions { + options: vec![], + delimiter: KeyValueOptionsDelimiter::Space, + }, }; let mut from_query = None; let mut partition = None; @@ -944,7 +956,7 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { // FILE_FORMAT if parser.parse_keyword(Keyword::FILE_FORMAT) { parser.expect_token(&Token::Eq)?; - file_format = parse_parentheses_options(parser)?; + file_format = parser.parse_key_value_options(true, &[])?; // PARTITION BY } else if parser.parse_keywords(&[Keyword::PARTITION, Keyword::BY]) { partition = Some(Box::new(parser.parse_expr()?)) @@ -982,14 +994,14 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { // COPY OPTIONS } else if parser.parse_keyword(Keyword::COPY_OPTIONS) { parser.expect_token(&Token::Eq)?; - copy_options = parse_parentheses_options(parser)?; + copy_options = parser.parse_key_value_options(true, &[])?; } else { match parser.next_token().token { Token::SemiColon | Token::EOF => break, Token::Comma => continue, // In `COPY INTO ` the copy options do not have a shared key // like in `COPY INTO ` - Token::Word(key) => copy_options.push(parse_option(parser, key)?), + Token::Word(key) => copy_options.push(parser.parse_key_value_option(key)?), _ => return parser.expected("another copy option, ; or EOF'", parser.peek_token()), } } @@ -1008,9 +1020,11 @@ pub fn parse_copy_into(parser: &mut Parser) -> Result { pattern, file_format: KeyValueOptions { options: file_format, + delimiter: KeyValueOptionsDelimiter::Space, }, copy_options: KeyValueOptions { options: copy_options, + delimiter: KeyValueOptionsDelimiter::Space, }, validation_mode, partition, @@ -1110,8 +1124,14 @@ fn parse_select_item_for_data_load( fn parse_stage_params(parser: &mut Parser) -> Result { let (mut url, mut storage_integration, mut endpoint) = (None, None, None); - let mut encryption: KeyValueOptions = KeyValueOptions { options: vec![] }; - let mut credentials: KeyValueOptions = KeyValueOptions { options: vec![] }; + let mut encryption: KeyValueOptions = KeyValueOptions { + options: vec![], + delimiter: KeyValueOptionsDelimiter::Space, + }; + let mut credentials: KeyValueOptions = KeyValueOptions { + options: vec![], + delimiter: KeyValueOptionsDelimiter::Space, + }; // URL if parser.parse_keyword(Keyword::URL) { @@ -1141,7 +1161,8 @@ fn parse_stage_params(parser: &mut Parser) -> Result Result { parser.advance_token(); if set { - let option = parse_option(parser, key)?; + let option = parser.parse_key_value_option(key)?; options.push(option); } else { options.push(KeyValueOption { @@ -1207,63 +1229,6 @@ fn parse_session_options( } } -/// Parses options provided within parentheses like: -/// ( ENABLE = { TRUE | FALSE } -/// [ AUTO_REFRESH = { TRUE | FALSE } ] -/// [ REFRESH_ON_CREATE = { TRUE | FALSE } ] -/// [ NOTIFICATION_INTEGRATION = '' ] ) -/// -fn parse_parentheses_options(parser: &mut Parser) -> Result, ParserError> { - let mut options: Vec = Vec::new(); - parser.expect_token(&Token::LParen)?; - loop { - match parser.next_token().token { - Token::RParen => break, - Token::Comma => continue, - Token::Word(key) => options.push(parse_option(parser, key)?), - _ => return parser.expected("another option or ')'", parser.peek_token()), - }; - } - Ok(options) -} - -/// Parses a `KEY = VALUE` construct based on the specified key -fn parse_option(parser: &mut Parser, key: Word) -> Result { - parser.expect_token(&Token::Eq)?; - if parser.parse_keyword(Keyword::TRUE) { - Ok(KeyValueOption { - option_name: key.value, - option_type: KeyValueOptionType::BOOLEAN, - value: "TRUE".to_string(), - }) - } else if parser.parse_keyword(Keyword::FALSE) { - Ok(KeyValueOption { - option_name: key.value, - option_type: KeyValueOptionType::BOOLEAN, - value: "FALSE".to_string(), - }) - } else { - match parser.next_token().token { - Token::SingleQuotedString(value) => Ok(KeyValueOption { - option_name: key.value, - option_type: KeyValueOptionType::STRING, - value, - }), - Token::Word(word) => Ok(KeyValueOption { - option_name: key.value, - option_type: KeyValueOptionType::ENUM, - value: word.value, - }), - Token::Number(n, _) => Ok(KeyValueOption { - option_name: key.value, - option_type: KeyValueOptionType::NUMBER, - value: n, - }), - _ => parser.expected("expected option value", parser.peek_token()), - } - } -} - /// Parsing a property of identity or autoincrement column option /// Syntax: /// ```sql diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 8d5a55da0..61b825638 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -32,7 +32,12 @@ use recursion::RecursionCounter; use IsLateral::*; use IsOptional::*; -use crate::ast::helpers::stmt_create_table::{CreateTableBuilder, CreateTableConfiguration}; +use crate::ast::helpers::{ + key_value_options::{ + KeyValueOption, KeyValueOptionType, KeyValueOptions, KeyValueOptionsDelimiter, + }, + stmt_create_table::{CreateTableBuilder, CreateTableConfiguration}, +}; use crate::ast::Statement::CreatePolicy; use crate::ast::*; use crate::dialect::*; @@ -4680,6 +4685,8 @@ impl<'a> Parser<'a> { self.parse_create_macro(or_replace, temporary) } else if self.parse_keyword(Keyword::SECRET) { self.parse_create_secret(or_replace, temporary, persistent) + } else if self.parse_keyword(Keyword::USER) { + self.parse_create_user(or_replace) } else if or_replace { self.expected( "[EXTERNAL] TABLE or [MATERIALIZED] VIEW or FUNCTION after CREATE OR REPLACE", @@ -4714,6 +4721,32 @@ impl<'a> Parser<'a> { } } + pub fn parse_create_user(&mut self, or_replace: bool) -> Result { + let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); + let name = self.parse_identifier()?; + let options = self.parse_key_value_options(false, &[Keyword::WITH, Keyword::TAG])?; + let with_tags = self.parse_keyword(Keyword::WITH); + let tags = if self.parse_keyword(Keyword::TAG) { + self.parse_key_value_options(true, &[])? + } else { + vec![] + }; + Ok(Statement::CreateUser(CreateUser { + or_replace, + if_not_exists, + name, + options: KeyValueOptions { + options, + delimiter: KeyValueOptionsDelimiter::Space, + }, + with_tags, + tags: KeyValueOptions { + options: tags, + delimiter: KeyValueOptionsDelimiter::Comma, + }, + })) + } + /// See [DuckDB Docs](https://duckdb.org/docs/sql/statements/create_secret.html) for more details. pub fn parse_create_secret( &mut self, @@ -16612,6 +16645,83 @@ impl<'a> Parser<'a> { pub(crate) fn in_column_definition_state(&self) -> bool { matches!(self.state, ColumnDefinition) } + + /// Parses options provided in key-value format. + /// + /// * `parenthesized` - true if the options are enclosed in parenthesis + /// * `end_words` - a list of keywords that any of them indicates the end of the options section + pub(crate) fn parse_key_value_options( + &mut self, + parenthesized: bool, + end_words: &[Keyword], + ) -> Result, ParserError> { + let mut options: Vec = Vec::new(); + if parenthesized { + self.expect_token(&Token::LParen)?; + } + loop { + match self.next_token().token { + Token::RParen => { + if parenthesized { + break; + } else { + return self.expected(" another option or EOF", self.peek_token()); + } + } + Token::EOF => break, + Token::Comma => continue, + Token::Word(w) if !end_words.contains(&w.keyword) => { + options.push(self.parse_key_value_option(w)?) + } + Token::Word(w) if end_words.contains(&w.keyword) => { + self.prev_token(); + break; + } + _ => return self.expected("another option, EOF, Comma or ')'", self.peek_token()), + }; + } + Ok(options) + } + + // Parses a `KEY = VALUE` construct based on the specified key + pub(crate) fn parse_key_value_option( + &mut self, + key: Word, + ) -> Result { + self.expect_token(&Token::Eq)?; + if self.parse_keyword(Keyword::TRUE) { + Ok(KeyValueOption { + option_name: key.value, + option_type: KeyValueOptionType::BOOLEAN, + value: "TRUE".to_string(), + }) + } else if self.parse_keyword(Keyword::FALSE) { + Ok(KeyValueOption { + option_name: key.value, + option_type: KeyValueOptionType::BOOLEAN, + value: "FALSE".to_string(), + }) + } else { + match self.next_token().token { + Token::SingleQuotedString(value) => Ok(KeyValueOption { + option_name: key.value, + option_type: KeyValueOptionType::STRING, + value, + }), + Token::Word(word) => Ok(KeyValueOption { + option_name: key.value, + option_type: KeyValueOptionType::ENUM, + value: word.value, + }), + Token::Number(n, _) => Ok(KeyValueOption { + option_name: key.value, + option_type: KeyValueOptionType::NUMBER, + value: n, + }), + _ => self.expected("expected option value", self.peek_token()), + } + } + } } fn maybe_prefixed_expr(expr: Expr, prefix: Option) -> Expr { diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 5d8284a46..873daacfa 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -16256,3 +16256,16 @@ fn parse_notnull() { // for unsupported dialects, parsing should stop at `NOT NULL` notnull_unsupported_dialects.expr_parses_to("NOT NULL NOTNULL", "NOT NULL"); } + +#[test] +fn parse_create_user() { + verified_stmt("CREATE USER u1"); + verified_stmt("CREATE OR REPLACE USER u1"); + verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1"); + verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret'"); + verified_stmt( + "CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE", + ); + verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE TYPE=SERVICE TAG (t1='v1')"); + verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE TYPE=SERVICE WITH TAG (t1='v1', t2='v2')"); +} From f97efc2ae868cded87a2faac07274e3407ae0c78 Mon Sep 17 00:00:00 2001 From: Yoav Cohen Date: Mon, 21 Jul 2025 16:36:46 +0300 Subject: [PATCH 2/2] Code review feedback --- src/ast/mod.rs | 8 +++++ src/parser/mod.rs | 55 ++++++++++++++++------------------ tests/sqlparser_common.rs | 62 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 93 insertions(+), 32 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index d2174acd4..e16463495 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -10131,6 +10131,14 @@ impl fmt::Display for MemberOf { } } +/// Creates a user +/// +/// Syntax: +/// ```sql +/// CREATE [OR REPLACE] USER [IF NOT EXISTS] [OPTIONS] +/// ``` +/// +/// [Snowflake](https://docs.snowflake.com/en/sql-reference/sql/create-user) #[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 61b825638..5ac19d13c 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -4721,7 +4721,7 @@ impl<'a> Parser<'a> { } } - pub fn parse_create_user(&mut self, or_replace: bool) -> Result { + fn parse_create_user(&mut self, or_replace: bool) -> Result { let if_not_exists = self.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]); let name = self.parse_identifier()?; let options = self.parse_key_value_options(false, &[Keyword::WITH, Keyword::TAG])?; @@ -16683,43 +16683,38 @@ impl<'a> Parser<'a> { Ok(options) } - // Parses a `KEY = VALUE` construct based on the specified key + /// Parses a `KEY = VALUE` construct based on the specified key pub(crate) fn parse_key_value_option( &mut self, key: Word, ) -> Result { self.expect_token(&Token::Eq)?; - if self.parse_keyword(Keyword::TRUE) { - Ok(KeyValueOption { - option_name: key.value, - option_type: KeyValueOptionType::BOOLEAN, - value: "TRUE".to_string(), - }) - } else if self.parse_keyword(Keyword::FALSE) { - Ok(KeyValueOption { + match self.next_token().token { + Token::SingleQuotedString(value) => Ok(KeyValueOption { option_name: key.value, - option_type: KeyValueOptionType::BOOLEAN, - value: "FALSE".to_string(), - }) - } else { - match self.next_token().token { - Token::SingleQuotedString(value) => Ok(KeyValueOption { - option_name: key.value, - option_type: KeyValueOptionType::STRING, - value, - }), - Token::Word(word) => Ok(KeyValueOption { - option_name: key.value, - option_type: KeyValueOptionType::ENUM, - value: word.value, - }), - Token::Number(n, _) => Ok(KeyValueOption { + option_type: KeyValueOptionType::STRING, + value, + }), + Token::Word(word) + if word.keyword == Keyword::TRUE || word.keyword == Keyword::FALSE => + { + Ok(KeyValueOption { option_name: key.value, - option_type: KeyValueOptionType::NUMBER, - value: n, - }), - _ => self.expected("expected option value", self.peek_token()), + option_type: KeyValueOptionType::BOOLEAN, + value: word.value.to_uppercase(), + }) } + Token::Word(word) => Ok(KeyValueOption { + option_name: key.value, + option_type: KeyValueOptionType::ENUM, + value: word.value, + }), + Token::Number(n, _) => Ok(KeyValueOption { + option_name: key.value, + option_type: KeyValueOptionType::NUMBER, + value: n, + }), + _ => self.expected("expected option value", self.peek_token()), } } } diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 873daacfa..5ae12ff40 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -27,6 +27,8 @@ extern crate core; use helpers::attached_token::AttachedToken; use matches::assert_matches; +use sqlparser::ast::helpers::key_value_options::*; +use sqlparser::ast::helpers::key_value_options::{KeyValueOptions, KeyValueOptionsDelimiter}; use sqlparser::ast::SelectItem::UnnamedExpr; use sqlparser::ast::TableFactor::{Pivot, Unpivot}; use sqlparser::ast::*; @@ -16259,7 +16261,13 @@ fn parse_notnull() { #[test] fn parse_create_user() { - verified_stmt("CREATE USER u1"); + let create = verified_stmt("CREATE USER u1"); + match create { + Statement::CreateUser(stmt) => { + assert_eq!(stmt.name, Ident::new("u1")); + } + _ => unreachable!(), + } verified_stmt("CREATE OR REPLACE USER u1"); verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1"); verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret'"); @@ -16267,5 +16275,55 @@ fn parse_create_user() { "CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE", ); verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE TYPE=SERVICE TAG (t1='v1')"); - verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE TYPE=SERVICE WITH TAG (t1='v1', t2='v2')"); + let create = verified_stmt("CREATE OR REPLACE USER IF NOT EXISTS u1 PASSWORD='secret' MUST_CHANGE_PASSWORD=TRUE TYPE=SERVICE WITH TAG (t1='v1', t2='v2')"); + match create { + Statement::CreateUser(stmt) => { + assert_eq!(stmt.name, Ident::new("u1")); + assert_eq!(stmt.or_replace, true); + assert_eq!(stmt.if_not_exists, true); + assert_eq!( + stmt.options, + KeyValueOptions { + delimiter: KeyValueOptionsDelimiter::Space, + options: vec![ + KeyValueOption { + option_name: "PASSWORD".to_string(), + value: "secret".to_string(), + option_type: KeyValueOptionType::STRING + }, + KeyValueOption { + option_name: "MUST_CHANGE_PASSWORD".to_string(), + value: "TRUE".to_string(), + option_type: KeyValueOptionType::BOOLEAN + }, + KeyValueOption { + option_name: "TYPE".to_string(), + value: "SERVICE".to_string(), + option_type: KeyValueOptionType::ENUM + }, + ], + }, + ); + assert_eq!(stmt.with_tags, true); + assert_eq!( + stmt.tags, + KeyValueOptions { + delimiter: KeyValueOptionsDelimiter::Comma, + options: vec![ + KeyValueOption { + option_name: "t1".to_string(), + value: "v1".to_string(), + option_type: KeyValueOptionType::STRING + }, + KeyValueOption { + option_name: "t2".to_string(), + value: "v2".to_string(), + option_type: KeyValueOptionType::STRING + }, + ] + } + ); + } + _ => unreachable!(), + } }