This is an automated email from the ASF dual-hosted git repository.
iffyio pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-sqlparser-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 2ed2cbe2 Snowflake: Add support for `CREATE USER` (#1950)
2ed2cbe2 is described below
commit 2ed2cbe291150d4a28f2a4f20f9c95ceead57b60
Author: Yoav Cohen <[email protected]>
AuthorDate: Wed Jul 23 18:52:08 2025 +0300
Snowflake: Add support for `CREATE USER` (#1950)
---
src/ast/helpers/key_value_options.rs | 28 +++++----
src/ast/mod.rs | 50 ++++++++++++++++
src/ast/spans.rs | 1 +
src/dialect/snowflake.rs | 109 ++++++++++++-----------------------
src/parser/mod.rs | 107 +++++++++++++++++++++++++++++++++-
tests/sqlparser_common.rs | 71 +++++++++++++++++++++++
6 files changed, 281 insertions(+), 85 deletions(-)
diff --git a/src/ast/helpers/key_value_options.rs
b/src/ast/helpers/key_value_options.rs
index 796bfd5e..7f1bb0fd 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<KeyValueOption>,
+ 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 1798223f..e1646349 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 <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,50 @@ impl fmt::Display for MemberOf {
}
}
+/// Creates a user
+///
+/// Syntax:
+/// ```sql
+/// CREATE [OR REPLACE] USER [IF NOT EXISTS] <name> [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))]
+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 3e82905e..4deedca0 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 baf99b84..9786aba0 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<Statement, Pars
set,
session_params: KeyValueOptions {
options: session_options,
+ delimiter: KeyValueOptionsDelimiter::Space,
},
})
}
@@ -777,19 +780,19 @@ pub fn parse_create_stage(
// [ directoryTableParams ]
if parser.parse_keyword(Keyword::DIRECTORY) {
parser.expect_token(&Token::Eq)?;
- directory_table_params = parse_parentheses_options(parser)?;
+ directory_table_params = parser.parse_key_value_options(true, &[])?;
}
// [ 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, &[])?;
}
// [ copy_options ]
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, &[])?;
}
// [ comment ]
@@ -806,12 +809,15 @@ pub fn parse_create_stage(
stage_params,
directory_table_params: KeyValueOptions {
options: directory_table_params,
+ delimiter: KeyValueOptionsDelimiter::Space,
},
file_format: KeyValueOptions {
options: file_format,
+ delimiter: KeyValueOptionsDelimiter::Space,
},
copy_options: KeyValueOptions {
options: copy_options,
+ delimiter: KeyValueOptionsDelimiter::Space,
},
comment,
})
@@ -879,10 +885,16 @@ pub fn parse_copy_into(parser: &mut Parser) ->
Result<Statement, ParserError> {
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<Statement, ParserError> {
// 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<Statement, ParserError> {
// 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 <location>` the copy options do not have a
shared key
// like in `COPY INTO <table>`
- 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<Statement, ParserError> {
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<StageParamsObject,
ParserError> {
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<StageParamsObject, ParserEr
if parser.parse_keyword(Keyword::CREDENTIALS) {
parser.expect_token(&Token::Eq)?;
credentials = KeyValueOptions {
- options: parse_parentheses_options(parser)?,
+ options: parser.parse_key_value_options(true, &[])?,
+ delimiter: KeyValueOptionsDelimiter::Space,
};
}
@@ -1149,7 +1170,8 @@ fn parse_stage_params(parser: &mut Parser) ->
Result<StageParamsObject, ParserEr
if parser.parse_keyword(Keyword::ENCRYPTION) {
parser.expect_token(&Token::Eq)?;
encryption = KeyValueOptions {
- options: parse_parentheses_options(parser)?,
+ options: parser.parse_key_value_options(true, &[])?,
+ delimiter: KeyValueOptionsDelimiter::Space,
};
}
@@ -1183,7 +1205,7 @@ fn parse_session_options(
Token::Word(key) => {
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 = '<notification_integration_name>' ] )
-///
-fn parse_parentheses_options(parser: &mut Parser) ->
Result<Vec<KeyValueOption>, ParserError> {
- let mut options: Vec<KeyValueOption> = 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<KeyValueOption,
ParserError> {
- 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 8d5a55da..5ac19d13 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> {
}
}
+ fn parse_create_user(&mut self, or_replace: bool) -> Result<Statement,
ParserError> {
+ 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,78 @@ 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<Vec<KeyValueOption>, ParserError> {
+ let mut options: Vec<KeyValueOption> = 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<KeyValueOption, ParserError> {
+ self.expect_token(&Token::Eq)?;
+ match self.next_token().token {
+ Token::SingleQuotedString(value) => Ok(KeyValueOption {
+ option_name: key.value,
+ 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::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()),
+ }
+ }
}
fn maybe_prefixed_expr(expr: Expr, prefix: Option<Ident>) -> Expr {
diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs
index 5d8284a4..5ae12ff4 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::*;
@@ -16256,3 +16258,72 @@ 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() {
+ 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'");
+ 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')");
+ 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!(),
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]