Repository navigation
fix(storage-cassandra): qualify migration statements with their keyspace instead of USE #638
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,6 +30,9 @@ | |
| //! 2. Use the next sequential version number (e.g., V003 after V002) | ||
| //! 3. Add the migration to the `CATALOG_MIGRATIONS` array below | ||
| //! 4. Migrations are embedded at compile time via `include_str!()` | ||
| //! 5. Name tables without a keyspace. The runner writes the keyspace it | ||
| //! migrates into each `CREATE TABLE`, `CREATE INDEX`, `ALTER TABLE` and | ||
| //! `INSERT INTO` statement, and rejects any other statement form. | ||
|
|
||
| use cdrs_tokio::cluster::TcpConnectionManager; | ||
| use cdrs_tokio::cluster::session::Session; | ||
|
|
@@ -87,19 +90,13 @@ pub async fn run_catalog_migrations( | |
| ) -> OpResult<()> { | ||
| println!("--- Running catalog migrations..."); | ||
|
|
||
| // Set keyspace context for all subsequent queries | ||
| session | ||
| .query(format!("USE {keyspace}")) | ||
| .await | ||
| .map_err(|e| OpError::Internal(format!("Failed to USE keyspace: {e}")))?; | ||
|
|
||
| for (filename, sql) in CATALOG_MIGRATIONS { | ||
| if is_migration_applied(session, keyspace, filename).await? { | ||
| println!(" {filename} — already applied, skipping."); | ||
| continue; | ||
| } | ||
| println!(" Applying {filename}..."); | ||
| execute_migration(session, sql).await?; | ||
| execute_migration(session, keyspace, filename, sql).await?; | ||
| record_migration(session, keyspace, filename).await?; | ||
| } | ||
| println!(" Migrations applied."); | ||
|
|
@@ -110,19 +107,13 @@ pub async fn run_catalog_migrations( | |
| pub async fn run_data_migrations(session: &Arc<CassandraSession>, keyspace: &str) -> OpResult<()> { | ||
| println!("--- Running data migrations..."); | ||
|
|
||
| // Set keyspace context for all subsequent queries | ||
| session | ||
| .query(format!("USE {keyspace}")) | ||
| .await | ||
| .map_err(|e| OpError::Internal(format!("Failed to USE keyspace: {e}")))?; | ||
|
|
||
| for (filename, sql) in DATA_MIGRATIONS { | ||
| if is_migration_applied(session, keyspace, filename).await? { | ||
| println!(" {filename} — already applied, skipping."); | ||
| continue; | ||
| } | ||
| println!(" Applying {filename}..."); | ||
| execute_migration(session, sql).await?; | ||
| execute_migration(session, keyspace, filename, sql).await?; | ||
| record_migration(session, keyspace, filename).await?; | ||
| } | ||
| println!(" Data migrations applied."); | ||
|
|
@@ -200,22 +191,137 @@ async fn is_migration_applied( | |
| Ok(applied) | ||
| } | ||
|
|
||
| /// Execute a migration by splitting on semicolons and running each statement. | ||
| async fn execute_migration(session: &Arc<CassandraSession>, sql: &str) -> OpResult<()> { | ||
| for statement in sql.split(';').filter(|s| !s.trim().is_empty()) { | ||
| let stmt = statement.trim(); | ||
| if stmt.is_empty() { | ||
| continue; | ||
| } | ||
|
|
||
| /// Execute a migration by splitting on semicolons and running each statement | ||
| /// with the table it names written as `keyspace.table`. | ||
| /// | ||
| /// The runners never select a keyspace with `USE` (#632). cdrs-tokio runs a | ||
| /// `USE` on one pooled connection and copies it to the others from a | ||
| /// background task, so a statement relying on it can run in the keyspace the | ||
| /// connection it lands on had selected before. | ||
| async fn execute_migration( | ||
| session: &Arc<CassandraSession>, | ||
| keyspace: &str, | ||
| filename: &str, | ||
| sql: &str, | ||
| ) -> OpResult<()> { | ||
| for statement in migration_statements(keyspace, filename, sql)? { | ||
| session | ||
| .query(stmt) | ||
| .query(statement) | ||
| .await | ||
| .map_err(|e| OpError::Internal(format!("Migration failed: {e}")))?; | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// The statements of migration file `filename`, split on semicolons, each | ||
| /// qualified with `keyspace` by [`qualify_statement`]. If one cannot be | ||
| /// qualified, the file fails before any of its statements runs. | ||
| #[doc(hidden)] | ||
| pub fn migration_statements(keyspace: &str, filename: &str, sql: &str) -> OpResult<Vec<String>> { | ||
| sql.split(';') | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similarly, ';' in string literals can cause weird failures. I'm not sure how much this type of issue can really be excluded without basically writing our own SQL/CQL parser (meh) ... at some point it might be worth considering another way of specifying system schemas that is easier to parse. |
||
| .map(str::trim) | ||
| .filter(|statement| !statement.is_empty()) | ||
| .map(|statement| qualify_statement(keyspace, filename, statement)) | ||
| .collect() | ||
| } | ||
|
|
||
| /// `statement` with the table it names written as `keyspace.table`. | ||
| /// | ||
| /// Recognises `CREATE TABLE [IF NOT EXISTS]`, | ||
| /// `CREATE INDEX [IF NOT EXISTS] [name] ON`, `ALTER TABLE [IF EXISTS]` and | ||
| /// `INSERT INTO`, each naming an unquoted table without a keyspace. Any other | ||
| /// statement is an error naming `filename`, so no statement runs in whatever | ||
| /// keyspace the session has selected. | ||
| fn qualify_statement(keyspace: &str, filename: &str, statement: &str) -> OpResult<String> { | ||
| let body = skip_comments(statement); | ||
| if body.is_empty() { | ||
| return Err(OpError::Internal(format!( | ||
| "Migration file {filename} has a fragment between semicolons that holds only a \ | ||
| comment (a semicolon inside a comment, or a comment after the last statement): {}", | ||
| statement.lines().next().unwrap_or_default() | ||
| ))); | ||
| } | ||
| let at = table_name_offset(body).ok_or_else(|| { | ||
| OpError::Internal(format!( | ||
| "Migration file {filename} has a statement that names no table the runner can \ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Strictly speaking, this may not be true. The issue isn't with the statement per se; it's that the runner doesn't no how to handle the statement. Something like this would be more truthy and point at where the fix is needed: |
||
| qualify with a keyspace: {}", | ||
| body.lines().next().unwrap_or_default() | ||
| )) | ||
| })?; | ||
| let at = statement.len() - body.len() + at; | ||
| Ok(format!( | ||
| "{}{keyspace}.{}", | ||
| &statement[..at], | ||
| &statement[at..] | ||
| )) | ||
| } | ||
|
|
||
| /// `statement` from its first character outside whitespace and `--` or `//` | ||
| /// line comments. | ||
| fn skip_comments(mut statement: &str) -> &str { | ||
| loop { | ||
| statement = statement.trim_start(); | ||
| if !statement.starts_with("--") && !statement.starts_with("//") { | ||
| return statement; | ||
| } | ||
| statement = statement.split_once('\n').map_or("", |(_, rest)| rest); | ||
| } | ||
| } | ||
|
|
||
| /// The byte offset in `body` of the table name, for the statement forms | ||
| /// [`qualify_statement`] recognises. | ||
| fn table_name_offset(body: &str) -> Option<usize> { | ||
| fn is(word: Option<(usize, &str)>, keyword: &str) -> bool { | ||
| word.is_some_and(|(_, word)| word.eq_ignore_ascii_case(keyword)) | ||
| } | ||
| let mut words = words(body).peekable(); | ||
| let (first, second) = (words.next(), words.next()); | ||
| if is(first, "CREATE") && (is(second, "TABLE") || is(second, "INDEX")) { | ||
| if words | ||
| .next_if(|&(_, w)| w.eq_ignore_ascii_case("IF")) | ||
| .is_some() | ||
| && !(is(words.next(), "NOT") && is(words.next(), "EXISTS")) | ||
| { | ||
| return None; | ||
| } | ||
| // CREATE INDEX [name] ON table: the index name is optional. | ||
| if is(second, "INDEX") && !is(words.next(), "ON") && !is(words.next(), "ON") { | ||
| return None; | ||
| } | ||
| } else if is(first, "ALTER") && is(second, "TABLE") { | ||
| if words | ||
| .next_if(|&(_, w)| w.eq_ignore_ascii_case("IF")) | ||
| .is_some() | ||
| && !is(words.next(), "EXISTS") | ||
| { | ||
| return None; | ||
| } | ||
| } else if !(is(first, "INSERT") && is(second, "INTO")) { | ||
| return None; | ||
| } | ||
| let (at, table) = words.next()?; | ||
| let mut chars = table.chars(); | ||
| let unquoted = chars.next().is_some_and(|c| c.is_ascii_alphabetic()) | ||
| && chars.all(|c| c.is_ascii_alphanumeric() || c == '_'); | ||
| unquoted.then_some(at) | ||
| } | ||
|
|
||
| /// The words of `body` with their byte offsets, a word being a run of | ||
| /// characters ended by whitespace or an opening parenthesis. | ||
| fn words(body: &str) -> impl Iterator<Item = (usize, &str)> { | ||
| let mut at = 0; | ||
| std::iter::from_fn(move || { | ||
| let rest = &body[at..]; | ||
| let start = at + (rest.len() - rest.trim_start().len()); | ||
| let word = &body[start..]; | ||
| let end = word | ||
| .find(|c: char| c.is_whitespace() || c == '(') | ||
| .unwrap_or(word.len()); | ||
| at = start + end; | ||
| (end > 0).then(|| (start, &word[..end])) | ||
| }) | ||
| } | ||
|
|
||
| /// Record a migration in the `schema_history` table. | ||
| async fn record_migration( | ||
| session: &Arc<CassandraSession>, | ||
|
|
@@ -306,4 +412,144 @@ mod tests { | |
| ); | ||
| assert!(super::migration_version("005_x.cql").is_err()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn qualifies_each_statement_form_with_the_keyspace() { | ||
| for (statement, qualified) in [ | ||
| ( | ||
| "CREATE TABLE IF NOT EXISTS settings (\n key text PRIMARY KEY\n)", | ||
| "CREATE TABLE IF NOT EXISTS ks_a.settings (\n key text PRIMARY KEY\n)", | ||
| ), | ||
| ( | ||
| "create table accounts(account_id text PRIMARY KEY)", | ||
| "create table ks_a.accounts(account_id text PRIMARY KEY)", | ||
| ), | ||
| ( | ||
| "CREATE INDEX IF NOT EXISTS ON tables (table_id)", | ||
| "CREATE INDEX IF NOT EXISTS ON ks_a.tables (table_id)", | ||
| ), | ||
| ( | ||
| "CREATE INDEX ON tables (table_id)", | ||
| "CREATE INDEX ON ks_a.tables (table_id)", | ||
| ), | ||
| ( | ||
| "CREATE INDEX idx ON tables (table_id)", | ||
| "CREATE INDEX idx ON ks_a.tables (table_id)", | ||
| ), | ||
| ( | ||
| "CREATE INDEX IF NOT EXISTS idx_txn_started_at ON transaction_ledger (started_at)", | ||
| "CREATE INDEX IF NOT EXISTS idx_txn_started_at ON ks_a.transaction_ledger (started_at)", | ||
| ), | ||
| ( | ||
| "ALTER TABLE tables ADD IF NOT EXISTS ttl_backfill_cursor text", | ||
| "ALTER TABLE ks_a.tables ADD IF NOT EXISTS ttl_backfill_cursor text", | ||
| ), | ||
| ( | ||
| "ALTER TABLE IF EXISTS tables ADD IF NOT EXISTS c text", | ||
| "ALTER TABLE IF EXISTS ks_a.tables ADD IF NOT EXISTS c text", | ||
| ), | ||
| ( | ||
| "alter table if exists tables with comment = 'x'", | ||
| "alter table if exists ks_a.tables with comment = 'x'", | ||
| ), | ||
| ( | ||
| "-- Seed settings\n// two comments\nINSERT INTO settings (key) VALUES ('a')", | ||
| "-- Seed settings\n// two comments\nINSERT INTO ks_a.settings (key) VALUES ('a')", | ||
| ), | ||
| ] { | ||
| let got = | ||
| super::qualify_statement("ks_a", "V999__test.cql", statement).expect(statement); | ||
| assert_eq!(got, qualified); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn rejects_statements_it_cannot_qualify() { | ||
| for statement in [ | ||
| "UPDATE settings SET value = 'x' WHERE key = 'y'", | ||
| "DROP TABLE settings", | ||
| "CREATE TABLE other.settings (key text PRIMARY KEY)", | ||
| "CREATE TABLE \"Settings\" (key text PRIMARY KEY)", | ||
| "CREATE TABLE IF EXISTS settings (key text PRIMARY KEY)", | ||
| "CREATE INDEX IF EXISTS idx ON tables (table_id)", | ||
| "ALTER TABLE IF NOT EXISTS tables ADD c text", | ||
| "CREATE INDEX idx settings (table_id)", | ||
| "-- a comment and no statement", | ||
| ] { | ||
| assert!( | ||
| super::qualify_statement("ks_a", "V999__test.cql", statement).is_err(), | ||
| "{statement}" | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /// A semicolon inside a comment, or a comment after a file's last | ||
| /// statement, leaves a fragment that holds only a comment. | ||
| #[test] | ||
| fn a_fragment_of_only_a_comment_fails_naming_the_file() { | ||
| let sql = "CREATE TABLE t (k int PRIMARY KEY);\n-- a note after the last statement\n"; | ||
| let Err(super::OpError::Internal(message)) = | ||
| super::migration_statements("ks_a", "V999__note.cql", sql) | ||
| else { | ||
| panic!("a fragment that holds only a comment was accepted"); | ||
| }; | ||
| assert!( | ||
| message.contains("V999__note.cql") | ||
| && message.contains("holds only a comment") | ||
| && message.ends_with("-- a note after the last statement"), | ||
| "{message}" | ||
| ); | ||
| } | ||
|
|
||
| /// Every statement of every embedded migration file, as the runner sends | ||
| /// it, is the file's statement with the keyspace written in once, in front | ||
| /// of its table: after the statement's leading comments, as a word of its | ||
| /// own after `ON` in a `CREATE INDEX`, after `INTO` in an `INSERT INTO`, | ||
| /// and after `TABLE` or `EXISTS` otherwise, and not in front of the `IF` | ||
| /// that opens an `IF EXISTS` or `IF NOT EXISTS` clause. | ||
| #[test] | ||
| fn every_migration_statement_names_the_keyspace() { | ||
| let files = super::CATALOG_MIGRATIONS | ||
| .iter() | ||
| .chain(super::DATA_MIGRATIONS); | ||
| for (filename, sql) in files { | ||
| let sent = super::migration_statements("ks_a", filename, sql).expect(filename); | ||
| let written: Vec<&str> = sql | ||
| .split(';') | ||
| .map(str::trim) | ||
| .filter(|s| !s.is_empty()) | ||
| .collect(); | ||
| assert_eq!(sent.len(), written.len(), "{filename}"); | ||
| for (sent, written) in sent.iter().zip(written) { | ||
| assert_eq!(sent.matches("ks_a.").count(), 1, "{filename}: {sent}"); | ||
| assert_eq!(sent.replacen("ks_a.", "", 1), written, "{filename}"); | ||
| let (before, after) = super::skip_comments(sent) | ||
| .split_once("ks_a.") | ||
| .unwrap_or_else(|| panic!("{filename}: keyspace in a leading comment: {sent}")); | ||
| let keyword = if before.ends_with(char::is_whitespace) { | ||
| before.split_whitespace().next_back().unwrap_or_default() | ||
| } else { | ||
| "" | ||
| }; | ||
| let table = after | ||
| .split(|c: char| c.is_whitespace() || c == '(') | ||
| .next() | ||
| .unwrap_or_default(); | ||
| let mut words = super::skip_comments(written) | ||
| .split_whitespace() | ||
| .map(str::to_ascii_uppercase); | ||
| let expected: &[&str] = match (words.next().as_deref(), words.next().as_deref()) { | ||
| (Some("CREATE"), Some("INDEX")) => &["ON"], | ||
| (Some("INSERT"), Some("INTO")) => &["INTO"], | ||
| _ => &["TABLE", "EXISTS"], | ||
| }; | ||
| assert!( | ||
| expected.iter().any(|k| keyword.eq_ignore_ascii_case(k)) | ||
| && !table.is_empty() | ||
| && !table.eq_ignore_ascii_case("IF"), | ||
| "{filename}: keyspace not in front of the table: {sent}" | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Existing issue, but this still leaves the possibility for unintended behavior with SQL/CQL in comments. E.g.
CREATE TABLE IF NOT EXISTS b (k int PRIMARY KEY) -- was: ; INSERT INTO settings (key, value) VALUES ('catalog_version', '9.9.9') ;
Will result in two statements when only one is intended. It'd be better to strip end-of-line comments, etc. throughout before brute force parsing by splitting on ';'.