From cdba47471f976f085561ecefbeec9b91c5afd893 Mon Sep 17 00:00:00 2001 From: Yoav Cohen Date: Wed, 23 Jul 2025 13:52:15 +0300 Subject: [PATCH 1/2] Redshift: Add support for IAM_ROLE and IGNOREHEADER COPY options --- src/ast/mod.rs | 20 +++++++++++++++++++- src/keywords.rs | 2 ++ src/parser/mod.rs | 15 +++++++++++++++ tests/sqlparser_common.rs | 22 ++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index a30e24239c..99fe01a433 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -8772,6 +8772,10 @@ pub enum CopyLegacyOption { Null(String), /// CSV ... Csv(Vec), + /// IAM_ROLE { DEFAULT | 'arn:aws:iam::AWS_ACCOUNT_ID:role/ROLE_NAME' } + IamRole(Option), + /// IGNOREHEADER \[ AS \] number_rows + IgnoreHeader(u64), } impl fmt::Display for CopyLegacyOption { @@ -8781,7 +8785,21 @@ impl fmt::Display for CopyLegacyOption { Binary => write!(f, "BINARY"), Delimiter(char) => write!(f, "DELIMITER '{char}'"), Null(string) => write!(f, "NULL '{}'", value::escape_single_quote_string(string)), - Csv(opts) => write!(f, "CSV {}", display_separated(opts, " ")), + Csv(opts) => { + write!(f, "CSV")?; + if !opts.is_empty() { + write!(f, " {}", display_separated(opts, " "))?; + } + Ok(()) + } + IamRole(role) => { + write!(f, "IAM_ROLE")?; + match role { + Some(role) => write!(f, " '{role}'"), + None => write!(f, " default"), + } + } + IgnoreHeader(num_rows) => write!(f, "IGNOREHEADER {num_rows}"), } } } diff --git a/src/keywords.rs b/src/keywords.rs index 659bc04399..1d94ae23a1 100644 --- a/src/keywords.rs +++ b/src/keywords.rs @@ -429,12 +429,14 @@ define_keywords!( HOUR, HOURS, HUGEINT, + IAM_ROLE, ICEBERG, ID, IDENTITY, IDENTITY_INSERT, IF, IGNORE, + IGNOREHEADER, ILIKE, IMMEDIATE, IMMUTABLE, diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5480112e13..0817bf9d47 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9548,6 +9548,8 @@ impl<'a> Parser<'a> { Keyword::DELIMITER, Keyword::NULL, Keyword::CSV, + Keyword::IAM_ROLE, + Keyword::IGNOREHEADER, ]) { Some(Keyword::BINARY) => CopyLegacyOption::Binary, Some(Keyword::DELIMITER) => { @@ -9567,6 +9569,19 @@ impl<'a> Parser<'a> { } opts }), + Some(Keyword::IAM_ROLE) => { + if self.parse_keyword(Keyword::DEFAULT) { + CopyLegacyOption::IamRole(None) + } else { + let role = self.parse_literal_string()?; + CopyLegacyOption::IamRole(Some(role)) + } + } + Some(Keyword::IGNOREHEADER) => { + let _ = self.parse_keyword(Keyword::AS); + let num_rows = self.parse_literal_uint()?; + CopyLegacyOption::IgnoreHeader(num_rows) + } _ => self.expected("option", self.peek_token())?, }; Ok(ret) diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 53b0d20365..67d7ca0275 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -16729,3 +16729,25 @@ fn parse_create_table_like() { _ => unreachable!(), } } + +#[test] +fn pares_copy_options() { + let copy = verified_stmt( + r#"COPY dst (c1, c2, c3) FROM 's3://redshift-downloads/tickit/category_pipe.txt' IAM_ROLE 'arn:aws:iam::123456789:role/role1' CSV IGNOREHEADER 1"#, + ); + match copy { + Statement::Copy { legacy_options, .. } => { + assert_eq!( + legacy_options, + vec![ + CopyLegacyOption::IamRole(Some( + "arn:aws:iam::123456789:role/role1".to_string() + )), + CopyLegacyOption::Csv(vec![]), + CopyLegacyOption::IgnoreHeader(1), + ] + ); + } + _ => unreachable!(), + } +} From 43b9f451027bd5736a77dd80c0f31f39260b7830 Mon Sep 17 00:00:00 2001 From: Yoav Cohen Date: Tue, 12 Aug 2025 12:58:38 +0200 Subject: [PATCH 2/2] Code review feedback --- src/ast/mod.rs | 34 +++++++++++++++++++++++++--------- src/parser/mod.rs | 18 ++++++++++-------- tests/sqlparser_common.rs | 22 ++++++++++++++++++++-- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 99fe01a433..8c026ce885 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -8772,8 +8772,8 @@ pub enum CopyLegacyOption { Null(String), /// CSV ... Csv(Vec), - /// IAM_ROLE { DEFAULT | 'arn:aws:iam::AWS_ACCOUNT_ID:role/ROLE_NAME' } - IamRole(Option), + /// IAM_ROLE { DEFAULT | 'arn:aws:iam::123456789:role/role1' } + IamRole(IamRoleKind), /// IGNOREHEADER \[ AS \] number_rows IgnoreHeader(u64), } @@ -8792,18 +8792,34 @@ impl fmt::Display for CopyLegacyOption { } Ok(()) } - IamRole(role) => { - write!(f, "IAM_ROLE")?; - match role { - Some(role) => write!(f, " '{role}'"), - None => write!(f, " default"), - } - } + IamRole(role) => write!(f, "IAM_ROLE {role}"), IgnoreHeader(num_rows) => write!(f, "IGNOREHEADER {num_rows}"), } } } +/// An `IAM_ROLE` option in the AWS ecosystem +/// +/// [Redshift COPY](https://docs.aws.amazon.com/redshift/latest/dg/copy-parameters-authorization.html#copy-iam-role) +#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))] +pub enum IamRoleKind { + /// Default role + Default, + /// Specific role ARN, for example: `arn:aws:iam::123456789:role/role1` + Arn(String), +} + +impl fmt::Display for IamRoleKind { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + IamRoleKind::Default => write!(f, "DEFAULT"), + IamRoleKind::Arn(arn) => write!(f, "'{arn}'"), + } + } +} + /// A `CSV` option in `COPY` statement before PostgreSQL version 9.0. /// /// diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 0817bf9d47..1afd571377 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -9569,14 +9569,7 @@ impl<'a> Parser<'a> { } opts }), - Some(Keyword::IAM_ROLE) => { - if self.parse_keyword(Keyword::DEFAULT) { - CopyLegacyOption::IamRole(None) - } else { - let role = self.parse_literal_string()?; - CopyLegacyOption::IamRole(Some(role)) - } - } + Some(Keyword::IAM_ROLE) => CopyLegacyOption::IamRole(self.parse_iam_role_kind()?), Some(Keyword::IGNOREHEADER) => { let _ = self.parse_keyword(Keyword::AS); let num_rows = self.parse_literal_uint()?; @@ -9587,6 +9580,15 @@ impl<'a> Parser<'a> { Ok(ret) } + fn parse_iam_role_kind(&mut self) -> Result { + if self.parse_keyword(Keyword::DEFAULT) { + Ok(IamRoleKind::Default) + } else { + let arn = self.parse_literal_string()?; + Ok(IamRoleKind::Arn(arn)) + } + } + fn parse_copy_legacy_csv_option(&mut self) -> Result { let ret = match self.parse_one_of_keywords(&[ Keyword::HEADER, diff --git a/tests/sqlparser_common.rs b/tests/sqlparser_common.rs index 67d7ca0275..3bf7fef99c 100644 --- a/tests/sqlparser_common.rs +++ b/tests/sqlparser_common.rs @@ -16731,7 +16731,7 @@ fn parse_create_table_like() { } #[test] -fn pares_copy_options() { +fn parse_copy_options() { let copy = verified_stmt( r#"COPY dst (c1, c2, c3) FROM 's3://redshift-downloads/tickit/category_pipe.txt' IAM_ROLE 'arn:aws:iam::123456789:role/role1' CSV IGNOREHEADER 1"#, ); @@ -16740,7 +16740,7 @@ fn pares_copy_options() { assert_eq!( legacy_options, vec![ - CopyLegacyOption::IamRole(Some( + CopyLegacyOption::IamRole(IamRoleKind::Arn( "arn:aws:iam::123456789:role/role1".to_string() )), CopyLegacyOption::Csv(vec![]), @@ -16750,4 +16750,22 @@ fn pares_copy_options() { } _ => unreachable!(), } + + let copy = one_statement_parses_to( + r#"COPY dst (c1, c2, c3) FROM 's3://redshift-downloads/tickit/category_pipe.txt' IAM_ROLE DEFAULT CSV IGNOREHEADER AS 1"#, + r#"COPY dst (c1, c2, c3) FROM 's3://redshift-downloads/tickit/category_pipe.txt' IAM_ROLE DEFAULT CSV IGNOREHEADER 1"#, + ); + match copy { + Statement::Copy { legacy_options, .. } => { + assert_eq!( + legacy_options, + vec![ + CopyLegacyOption::IamRole(IamRoleKind::Default), + CopyLegacyOption::Csv(vec![]), + CopyLegacyOption::IgnoreHeader(1), + ] + ); + } + _ => unreachable!(), + } }