Skip to content

Add support for generated virtual columns with expression #1051

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Nov 22, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions src/ast/ddl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -641,20 +641,26 @@ impl fmt::Display for ColumnOption {
generation_expr,
} => match generated_as {
GeneratedAs::Always => {
write!(f, "GENERATED ALWAYS AS IDENTITY")?;
if sequence_options.is_some() {
let so = sequence_options.as_ref().unwrap();
if !so.is_empty() {
write!(f, " (")?;
}
for sequence_option in so {
write!(f, "{sequence_option}")?;
}
if !so.is_empty() {
write!(f, " )")?;
if let Some(expr) = generation_expr {
// Like SQLite, MySQL - expr evaluated on read
write!(f, "GENERATED ALWAYS AS ({expr}) VIRTUAL")
} else {
// Like Postgres - generated from sequence
write!(f, "GENERATED ALWAYS AS IDENTITY")?;
if sequence_options.is_some() {
let so = sequence_options.as_ref().unwrap();
if !so.is_empty() {
write!(f, " (")?;
}
for sequence_option in so {
write!(f, "{sequence_option}")?;
}
if !so.is_empty() {
write!(f, " )")?;
}
}
Ok(())
}
Ok(())
}
GeneratedAs::ByDefault => {
write!(f, "GENERATED BY DEFAULT AS IDENTITY")?;
Expand Down Expand Up @@ -682,7 +688,7 @@ impl fmt::Display for ColumnOption {
}

/// `GeneratedAs`s are modifiers that follow a column option in a `generated`.
/// 'ExpStored' is PostgreSQL specific
/// 'ExpStored' is used for a column generated from an expression and stored.
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "visitor", derive(Visit, VisitMut))]
Expand Down
13 changes: 11 additions & 2 deletions src/parser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4252,9 +4252,18 @@ impl<'a> Parser<'a> {
if self.expect_token(&Token::LParen).is_ok() {
let expr = self.parse_expr()?;
self.expect_token(&Token::RParen)?;
let _ = self.parse_keywords(&[Keyword::STORED]);
let gen_as = if self.parse_keywords(&[Keyword::STORED]) {
Ok(GeneratedAs::ExpStored)
} else if dialect_of!(self is PostgreSqlDialect) {
// Postgres' AS IDENTITY branches are above, this one needs STORED
self.expected("STORED", self.peek_token())
} else {
let _ = self.parse_keywords(&[Keyword::VIRTUAL]);
Ok(GeneratedAs::Always)
}?;

Ok(Some(ColumnOption::Generated {
generated_as: GeneratedAs::ExpStored,
generated_as: gen_as,
sequence_options: None,
generation_expr: Some(expr),
}))
Expand Down
11 changes: 11 additions & 0 deletions tests/sqlparser_mysql.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,17 @@ fn parse_create_table_comment_character_set() {
}
}

#[test]
fn parse_create_table_gencol() {
let sql_default = "CREATE TABLE t1 (a INT, b INT GENERATED ALWAYS AS (a * 2))";
let sql_virt = "CREATE TABLE t1 (a INT, b INT GENERATED ALWAYS AS (a * 2) VIRTUAL)";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about adding additional support to the AST so you can recover the original SQL --- as implemented now this change will not permit people to know when the original query had VIRTUAL or not

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Virtual is the default, so adding the marker doesn't make a logical difference (at least in the databases I found that support this). Elsewhere in the tests, it looks like you don't preserve differences like this - e.g. SHOW COLUMNS vs SHOW FIELDS in the MySQL tests. But if you'd like to preserve this one, I'm happy to do that. 🙂

If so, I would probably introduce an enum called something like GenExpressionQualifier (though that's an ugly name...), which could be None, Virtual or Stored. The Stored option would then be redundant with GeneratedAs::ExpStored.

(For my next PR, I'm planning to make the GENERATED ALWAYS keywords optional - as they are in MySQL and SQLite. Let me know if you want the presence or absence of those to be preserved too.)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Elsewhere in the tests, it looks like you don't preserve differences like this - e.g. SHOW COLUMNS vs SHOW FIELDS in the MySQL tests. But if you'd like to preserve this one, I'm happy to do that. 🙂

Yes, it is true that this crate is not always consistent. However, for newly added code let's try and preserve the ability to round trip the AST (though I see there is no note describing that detail on https://github.com/sqlparser-rs/sqlparser-rs -- I will try and add a note when I have time)

Let me know if you want the presence or absence of those to be preserved too.)

yes, please

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, the latest commit (6d69a43) preserves the distinction between VIRTUAL, STORED and no qualifier. As I mentioned, that does mean there are two fields which both record if STORED was used. That's not so tidy, but it preserves compatibility for any code that's checking for GeneratedAs::ExpStored.

I've called the new field & enum generation_expr_mode & GeneratedExpressionMode, which are both quite a mouthful. Let me know if you have a better name for this. 🙂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filed #1052 to document this property

mysql_and_generic().verified_stmt(sql_virt);
mysql_and_generic().one_statement_parses_to(sql_default, sql_virt);

let sql_stored = "CREATE TABLE t1 (a INT, b INT GENERATED ALWAYS AS (a * 2) STORED)";
mysql_and_generic().verified_stmt(sql_stored);
}

#[test]
fn parse_quote_identifiers() {
let sql = "CREATE TABLE `PRIMARY` (`BEGIN` INT PRIMARY KEY)";
Expand Down
12 changes: 11 additions & 1 deletion tests/sqlparser_sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,17 @@ fn parse_create_sqlite_quote() {
}
}

#[test]
fn parse_create_table_gencol() {
let sql_default = "CREATE TABLE t1 (a INT, b INT GENERATED ALWAYS AS (a * 2))";
let sql_virt = "CREATE TABLE t1 (a INT, b INT GENERATED ALWAYS AS (a * 2) VIRTUAL)";
sqlite_and_generic().verified_stmt(sql_virt);
sqlite_and_generic().one_statement_parses_to(sql_default, sql_virt);

let sql_stored = "CREATE TABLE t1 (a INT, b INT GENERATED ALWAYS AS (a * 2) STORED)";
sqlite_and_generic().verified_stmt(sql_stored);
}

#[test]
fn test_placeholder() {
// In postgres, this would be the absolute value operator '@' applied to the column 'xxx'
Expand Down Expand Up @@ -435,7 +446,6 @@ fn sqlite_with_options(options: ParserOptions) -> TestedDialects {

fn sqlite_and_generic() -> TestedDialects {
TestedDialects {
// we don't have a separate SQLite dialect, so test only the generic dialect for now
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 nice drive by cleanup

dialects: vec![Box::new(SQLiteDialect {}), Box::new(GenericDialect {})],
options: None,
}
Expand Down