mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-14 14:38:35 +09:00
707 lines
22 KiB
Rust
707 lines
22 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::paths::DEV_CASSANDRA_DIR;
|
|
use anyhow::{Context, Result, bail};
|
|
use regex::Regex;
|
|
use scylla::DeserializeRow;
|
|
use scylla::client::session::Session;
|
|
use scylla::client::session_builder::SessionBuilder;
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::env;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::LazyLock;
|
|
use std::time::{Duration, Instant};
|
|
use tokio::time::sleep;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct CassandraConfig {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub keyspace: String,
|
|
pub local_dc: String,
|
|
pub username: Option<String>,
|
|
pub password: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ExistingTable {
|
|
pub columns: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct ExistingSchema {
|
|
pub keyspace_exists: bool,
|
|
pub types: BTreeMap<String, BTreeMap<String, String>>,
|
|
pub tables: BTreeMap<String, ExistingTable>,
|
|
pub indexes: BTreeSet<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SchemaDiff {
|
|
pub statements: Vec<String>,
|
|
pub warnings: Vec<String>,
|
|
pub errors: Vec<String>,
|
|
pub target_sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
pub struct Field {
|
|
pub name: String,
|
|
#[serde(rename = "type")]
|
|
pub field_type: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
pub struct UserType {
|
|
pub name: String,
|
|
pub fields: Vec<Field>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
pub struct Table {
|
|
pub name: String,
|
|
pub columns: Vec<Field>,
|
|
pub primary_key: String,
|
|
pub options: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
pub struct Index {
|
|
pub name: String,
|
|
pub table: String,
|
|
pub expression: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
|
pub struct TargetSchema {
|
|
pub user_types: Vec<UserType>,
|
|
pub tables: Vec<Table>,
|
|
pub indexes: Vec<Index>,
|
|
}
|
|
|
|
static TARGET_SCHEMA: LazyLock<TargetSchema> = LazyLock::new(|| {
|
|
serde_json::from_str(include_str!("../cassandra_target_schema.json"))
|
|
.expect("embedded Cassandra target schema JSON is valid")
|
|
});
|
|
|
|
static TYPE_TOKEN_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"[a-zA-Z_][a-zA-Z0-9_]*").expect("valid type token regex"));
|
|
static WHITESPACE_RE: LazyLock<Regex> =
|
|
LazyLock::new(|| Regex::new(r"\s+").expect("valid whitespace regex"));
|
|
|
|
pub fn target_schema() -> &'static TargetSchema {
|
|
&TARGET_SCHEMA
|
|
}
|
|
|
|
pub fn config_from_env() -> Result<CassandraConfig> {
|
|
let hosts = env::var("FLUXER_CASSANDRA_HOSTS").unwrap_or_else(|_| "cassandra".to_owned());
|
|
let host = hosts
|
|
.split_once(',')
|
|
.map(|(first, _)| first)
|
|
.unwrap_or(&hosts)
|
|
.trim();
|
|
Ok(CassandraConfig {
|
|
host: if host.is_empty() {
|
|
"cassandra".to_owned()
|
|
} else {
|
|
host.to_owned()
|
|
},
|
|
port: env::var("FLUXER_CASSANDRA_PORT")
|
|
.unwrap_or_else(|_| "9042".to_owned())
|
|
.parse()
|
|
.context("invalid FLUXER_CASSANDRA_PORT")?,
|
|
keyspace: env::var("FLUXER_CASSANDRA_KEYSPACE").unwrap_or_else(|_| "fluxer".to_owned()),
|
|
local_dc: env::var("FLUXER_CASSANDRA_LOCAL_DC")
|
|
.unwrap_or_else(|_| "datacenter1".to_owned()),
|
|
username: env::var("FLUXER_CASSANDRA_USERNAME")
|
|
.ok()
|
|
.filter(|value| !value.is_empty()),
|
|
password: env::var("FLUXER_CASSANDRA_PASSWORD")
|
|
.ok()
|
|
.filter(|value| !value.is_empty()),
|
|
})
|
|
}
|
|
|
|
async fn connect(config: &CassandraConfig) -> Result<Session> {
|
|
let contact = format!("{}:{}", config.host, config.port);
|
|
let mut builder = SessionBuilder::new().known_node(contact);
|
|
if let Some(username) = &config.username {
|
|
builder = builder.user(username, config.password.as_deref().unwrap_or_default());
|
|
}
|
|
Ok(builder.build().await?)
|
|
}
|
|
|
|
pub async fn wait_for_cassandra(config: &CassandraConfig, timeout_secs: u64) -> Result<()> {
|
|
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
|
|
let mut last_error = None;
|
|
while Instant::now() < deadline {
|
|
match connect(config).await {
|
|
Ok(session) => match session
|
|
.query_unpaged("SELECT release_version FROM system.local", ())
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
println!("Cassandra is reachable at {}:{}", config.host, config.port);
|
|
return Ok(());
|
|
}
|
|
Err(error) => last_error = Some(error.to_string()),
|
|
},
|
|
Err(error) => last_error = Some(error.to_string()),
|
|
}
|
|
sleep(Duration::from_secs(3)).await;
|
|
}
|
|
bail!(
|
|
"Timed out waiting for Cassandra at {}:{}: {}",
|
|
config.host,
|
|
config.port,
|
|
last_error.unwrap_or_else(|| "unknown error".to_owned())
|
|
);
|
|
}
|
|
|
|
pub fn normalize_type(value: &str) -> String {
|
|
let mut normalized = WHITESPACE_RE.replace_all(value, "").to_ascii_lowercase();
|
|
let user_type_names = target_schema()
|
|
.user_types
|
|
.iter()
|
|
.map(|user_type| user_type.name.as_str())
|
|
.collect::<Vec<_>>();
|
|
let mut previous = String::new();
|
|
while previous != normalized {
|
|
previous = normalized.clone();
|
|
for name in &user_type_names {
|
|
normalized = normalized.replace(&format!("frozen<{name}>"), name);
|
|
}
|
|
}
|
|
normalized
|
|
}
|
|
|
|
fn normalize_identifier(value: &str) -> String {
|
|
value.trim_matches('"').to_ascii_lowercase()
|
|
}
|
|
|
|
#[derive(Debug, DeserializeRow)]
|
|
#[allow(dead_code)]
|
|
struct KeyspaceRow {
|
|
keyspace_name: String,
|
|
}
|
|
|
|
#[derive(Debug, DeserializeRow)]
|
|
struct TypeRow {
|
|
type_name: String,
|
|
field_names: Option<Vec<String>>,
|
|
field_types: Option<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Debug, DeserializeRow)]
|
|
struct ColumnRow {
|
|
table_name: String,
|
|
column_name: String,
|
|
#[scylla(rename = "type")]
|
|
column_type: String,
|
|
}
|
|
|
|
#[derive(Debug, DeserializeRow)]
|
|
struct IndexRow {
|
|
index_name: Option<String>,
|
|
}
|
|
|
|
pub async fn fetch_existing_schema(session: &Session, keyspace: &str) -> Result<ExistingSchema> {
|
|
let keyspace_rows = session
|
|
.query_unpaged(
|
|
"SELECT keyspace_name FROM system_schema.keyspaces WHERE keyspace_name = ?",
|
|
(keyspace,),
|
|
)
|
|
.await?
|
|
.into_rows_result()?
|
|
.rows::<KeyspaceRow>()?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
|
|
let type_rows = session
|
|
.query_unpaged(
|
|
"SELECT type_name, field_names, field_types FROM system_schema.types WHERE keyspace_name = ?",
|
|
(keyspace,),
|
|
)
|
|
.await?
|
|
.into_rows_result()?
|
|
.rows::<TypeRow>()?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
let mut types = BTreeMap::new();
|
|
for row in type_rows {
|
|
let mut fields = BTreeMap::new();
|
|
for (name, field_type) in row
|
|
.field_names
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.zip(row.field_types.unwrap_or_default())
|
|
{
|
|
fields.insert(normalize_identifier(&name), field_type);
|
|
}
|
|
types.insert(normalize_identifier(&row.type_name), fields);
|
|
}
|
|
|
|
let column_rows = session
|
|
.query_unpaged(
|
|
"SELECT table_name, column_name, type FROM system_schema.columns WHERE keyspace_name = ?",
|
|
(keyspace,),
|
|
)
|
|
.await?
|
|
.into_rows_result()?
|
|
.rows::<ColumnRow>()?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
let mut tables: BTreeMap<String, ExistingTable> = BTreeMap::new();
|
|
for row in column_rows {
|
|
tables
|
|
.entry(normalize_identifier(&row.table_name))
|
|
.or_default()
|
|
.columns
|
|
.insert(normalize_identifier(&row.column_name), row.column_type);
|
|
}
|
|
|
|
let index_rows = session
|
|
.query_unpaged(
|
|
"SELECT index_name FROM system_schema.indexes WHERE keyspace_name = ?",
|
|
(keyspace,),
|
|
)
|
|
.await?
|
|
.into_rows_result()?
|
|
.rows::<IndexRow>()?
|
|
.collect::<std::result::Result<Vec<_>, _>>()?;
|
|
let indexes = index_rows
|
|
.into_iter()
|
|
.filter_map(|row| row.index_name)
|
|
.map(|name| normalize_identifier(&name))
|
|
.collect();
|
|
|
|
Ok(ExistingSchema {
|
|
keyspace_exists: !keyspace_rows.is_empty(),
|
|
types,
|
|
tables,
|
|
indexes,
|
|
})
|
|
}
|
|
|
|
pub fn render_user_type(user_type: &UserType, keyspace: &str) -> String {
|
|
let fields = user_type
|
|
.fields
|
|
.iter()
|
|
.map(|field| format!("{} {}", field.name, field.field_type))
|
|
.collect::<Vec<_>>()
|
|
.join(",\n ");
|
|
format!(
|
|
"CREATE TYPE IF NOT EXISTS {keyspace}.{} (\n {fields}\n)",
|
|
user_type.name
|
|
)
|
|
}
|
|
|
|
pub fn render_table(table: &Table, keyspace: &str) -> String {
|
|
let mut fields = table
|
|
.columns
|
|
.iter()
|
|
.map(|field| format!("{} {}", field.name, field.field_type))
|
|
.collect::<Vec<_>>();
|
|
fields.push(format!("PRIMARY KEY {}", table.primary_key));
|
|
let body = fields.join(",\n ");
|
|
let mut statement = format!(
|
|
"CREATE TABLE IF NOT EXISTS {keyspace}.{} (\n {body}\n)",
|
|
table.name
|
|
);
|
|
if !table.options.is_empty() {
|
|
statement.push_str(" WITH ");
|
|
statement.push_str(&table.options);
|
|
}
|
|
statement
|
|
}
|
|
|
|
pub fn render_index(index: &Index, keyspace: &str) -> String {
|
|
format!(
|
|
"CREATE INDEX IF NOT EXISTS {} ON {keyspace}.{} ({})",
|
|
index.name, index.table, index.expression
|
|
)
|
|
}
|
|
|
|
pub fn render_target_schema(keyspace: &str) -> String {
|
|
let schema = target_schema();
|
|
let mut statements = Vec::new();
|
|
for user_type in sorted_user_types() {
|
|
statements.push(statement_with_semicolon(&render_user_type(
|
|
&user_type, keyspace,
|
|
)));
|
|
}
|
|
let mut tables = schema.tables.clone();
|
|
tables.sort_by(|a, b| a.name.cmp(&b.name));
|
|
for table in tables {
|
|
statements.push(statement_with_semicolon(&render_table(&table, keyspace)));
|
|
}
|
|
let mut indexes = schema.indexes.clone();
|
|
indexes.sort_by(|a, b| a.name.cmp(&b.name));
|
|
for index in indexes {
|
|
statements.push(statement_with_semicolon(&render_index(&index, keyspace)));
|
|
}
|
|
format!("{}\n", statements.join("\n\n"))
|
|
}
|
|
|
|
pub fn target_schema_sha256(keyspace: &str) -> String {
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(render_target_schema(keyspace).as_bytes());
|
|
bytes_to_lower_hex(&hasher.finalize())
|
|
}
|
|
|
|
fn bytes_to_lower_hex(bytes: &[u8]) -> String {
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
let mut out = String::with_capacity(bytes.len() * 2);
|
|
for byte in bytes {
|
|
out.push(HEX[(byte >> 4) as usize] as char);
|
|
out.push(HEX[(byte & 0x0f) as usize] as char);
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn sorted_user_types() -> Vec<UserType> {
|
|
let schema = target_schema();
|
|
let by_name = schema
|
|
.user_types
|
|
.iter()
|
|
.map(|user_type| (user_type.name.clone(), user_type.clone()))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let mut done = BTreeSet::new();
|
|
let mut result = Vec::new();
|
|
let mut remaining = by_name.clone();
|
|
while !remaining.is_empty() {
|
|
let mut progressed = false;
|
|
for (name, user_type) in remaining.clone() {
|
|
let deps = type_dependencies(&user_type, &by_name.keys().cloned().collect());
|
|
if deps.is_subset(&done) {
|
|
result.push(user_type);
|
|
done.insert(name.clone());
|
|
remaining.remove(&name);
|
|
progressed = true;
|
|
}
|
|
}
|
|
if !progressed {
|
|
result.extend(remaining.into_values());
|
|
break;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn type_dependencies(user_type: &UserType, user_type_names: &BTreeSet<String>) -> BTreeSet<String> {
|
|
let mut deps = BTreeSet::new();
|
|
for field in &user_type.fields {
|
|
for token in TYPE_TOKEN_RE.find_iter(&field.field_type) {
|
|
let token = token.as_str().to_ascii_lowercase();
|
|
if user_type_names.contains(&token) && token != user_type.name {
|
|
deps.insert(token);
|
|
}
|
|
}
|
|
}
|
|
deps
|
|
}
|
|
|
|
pub fn create_diff(existing: &ExistingSchema, keyspace: &str) -> SchemaDiff {
|
|
let schema = target_schema();
|
|
let mut statements = Vec::new();
|
|
let mut warnings = Vec::new();
|
|
let mut errors = Vec::new();
|
|
if !existing.keyspace_exists {
|
|
statements.push(format!(
|
|
"CREATE KEYSPACE IF NOT EXISTS {keyspace} WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': 1}}"
|
|
));
|
|
}
|
|
for user_type in sorted_user_types() {
|
|
let Some(current_fields) = existing.types.get(&user_type.name) else {
|
|
statements.push(render_user_type(&user_type, keyspace));
|
|
continue;
|
|
};
|
|
for field in &user_type.fields {
|
|
match current_fields.get(&field.name) {
|
|
None => statements.push(format!(
|
|
"ALTER TYPE {keyspace}.{} ADD IF NOT EXISTS {} {}",
|
|
user_type.name, field.name, field.field_type
|
|
)),
|
|
Some(current_type)
|
|
if normalize_type(current_type) != normalize_type(&field.field_type) =>
|
|
{
|
|
errors.push(format!(
|
|
"type {}.{} has {current_type:?}, expected {:?}",
|
|
user_type.name, field.name, field.field_type
|
|
));
|
|
}
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
}
|
|
let mut tables = schema.tables.clone();
|
|
tables.sort_by(|a, b| a.name.cmp(&b.name));
|
|
for table in tables {
|
|
let Some(current_table) = existing.tables.get(&table.name) else {
|
|
statements.push(render_table(&table, keyspace));
|
|
continue;
|
|
};
|
|
for field in &table.columns {
|
|
match current_table.columns.get(&field.name) {
|
|
None => statements.push(format!(
|
|
"ALTER TABLE {keyspace}.{} ADD IF NOT EXISTS {} {}",
|
|
table.name, field.name, field.field_type
|
|
)),
|
|
Some(current_type)
|
|
if normalize_type(current_type) != normalize_type(&field.field_type) =>
|
|
{
|
|
errors.push(format!(
|
|
"table {}.{} has {current_type:?}, expected {:?}",
|
|
table.name, field.name, field.field_type
|
|
));
|
|
}
|
|
Some(_) => {}
|
|
}
|
|
}
|
|
}
|
|
let mut indexes = schema.indexes.clone();
|
|
indexes.sort_by(|a, b| a.name.cmp(&b.name));
|
|
for index in indexes {
|
|
if !existing.indexes.contains(&index.name.to_ascii_lowercase()) {
|
|
statements.push(render_index(&index, keyspace));
|
|
}
|
|
}
|
|
let target_tables = schema
|
|
.tables
|
|
.iter()
|
|
.map(|table| table.name.clone())
|
|
.collect::<BTreeSet<_>>();
|
|
for table_name in existing.tables.keys() {
|
|
if !target_tables.contains(table_name) && !table_name.starts_with("system_") {
|
|
warnings.push(format!(
|
|
"extra Cassandra table exists outside target schema: {table_name}"
|
|
));
|
|
}
|
|
}
|
|
SchemaDiff {
|
|
statements,
|
|
warnings,
|
|
errors,
|
|
target_sha256: target_schema_sha256(keyspace),
|
|
}
|
|
}
|
|
|
|
fn statement_with_semicolon(statement: &str) -> String {
|
|
format!("{};", statement.trim().trim_end_matches(';'))
|
|
}
|
|
|
|
pub fn render_diff(diff: &SchemaDiff) -> String {
|
|
let mut lines = vec![
|
|
"-- Generated by fluxer-dev cassandra diff".to_owned(),
|
|
format!("-- Target schema sha256: {}", diff.target_sha256),
|
|
String::new(),
|
|
];
|
|
if !diff.errors.is_empty() {
|
|
lines.push("-- Diff contains validation errors and should not be applied:".to_owned());
|
|
lines.extend(diff.errors.iter().map(|error| format!("-- {error}")));
|
|
lines.push(String::new());
|
|
}
|
|
if !diff.warnings.is_empty() {
|
|
lines.push("-- Warnings:".to_owned());
|
|
lines.extend(
|
|
diff.warnings
|
|
.iter()
|
|
.map(|warning| format!("-- {warning}")),
|
|
);
|
|
lines.push(String::new());
|
|
}
|
|
if diff.statements.is_empty() {
|
|
lines.push("-- No schema changes required.".to_owned());
|
|
} else {
|
|
lines.extend(
|
|
diff.statements
|
|
.iter()
|
|
.map(|statement| statement_with_semicolon(statement)),
|
|
);
|
|
}
|
|
format!("{}\n", lines.join("\n"))
|
|
}
|
|
|
|
pub fn write_diff_file(diff: &SchemaDiff, path: Option<&Path>) -> Result<PathBuf> {
|
|
std::fs::create_dir_all(DEV_CASSANDRA_DIR.as_path())?;
|
|
let output = path
|
|
.map(Path::to_path_buf)
|
|
.unwrap_or_else(|| DEV_CASSANDRA_DIR.join("last-diff.cql"));
|
|
std::fs::write(&output, render_diff(diff))?;
|
|
std::fs::write(
|
|
DEV_CASSANDRA_DIR.join("target-schema.sha256"),
|
|
format!("{}\n", diff.target_sha256),
|
|
)?;
|
|
Ok(output)
|
|
}
|
|
|
|
pub async fn compute_diff(config: Option<CassandraConfig>) -> Result<SchemaDiff> {
|
|
let config = config.unwrap_or(config_from_env()?);
|
|
wait_for_cassandra(&config, 180).await?;
|
|
let session = connect(&config).await?;
|
|
create_live_diff(&session, &config.keyspace).await
|
|
}
|
|
|
|
async fn create_live_diff(session: &Session, keyspace: &str) -> Result<SchemaDiff> {
|
|
Ok(create_diff(
|
|
&fetch_existing_schema(session, keyspace).await?,
|
|
keyspace,
|
|
))
|
|
}
|
|
|
|
pub async fn apply_schema(config: Option<CassandraConfig>) -> Result<SchemaDiff> {
|
|
let config = config.unwrap_or(config_from_env()?);
|
|
wait_for_cassandra(&config, 180).await?;
|
|
let session = connect(&config).await?;
|
|
let diff = create_live_diff(&session, &config.keyspace).await?;
|
|
let diff_path = write_diff_file(&diff, None)?;
|
|
if !diff.errors.is_empty() {
|
|
bail!(
|
|
"Cassandra schema has validation errors; wrote {}",
|
|
diff_path.display()
|
|
);
|
|
}
|
|
for warning in &diff.warnings {
|
|
println!("Cassandra schema warning: {warning}");
|
|
}
|
|
if diff.statements.is_empty() {
|
|
println!(
|
|
"Cassandra schema is already up to date; wrote {}",
|
|
diff_path.display()
|
|
);
|
|
} else {
|
|
println!(
|
|
"Applying {} Cassandra schema statement(s); wrote {}",
|
|
diff.statements.len(),
|
|
diff_path.display()
|
|
);
|
|
for statement in &diff.statements {
|
|
session.query_unpaged(statement.as_str(), ()).await?;
|
|
}
|
|
}
|
|
verify_schema_with_session(&session, &config.keyspace).await?;
|
|
Ok(diff)
|
|
}
|
|
|
|
pub async fn verify_schema(
|
|
config: Option<CassandraConfig>,
|
|
keyspace: Option<String>,
|
|
) -> Result<()> {
|
|
let config = config.unwrap_or(config_from_env()?);
|
|
let keyspace = keyspace.unwrap_or_else(|| config.keyspace.clone());
|
|
wait_for_cassandra(&config, 180).await?;
|
|
let session = connect(&config).await?;
|
|
verify_schema_with_session(&session, &keyspace).await
|
|
}
|
|
|
|
async fn verify_schema_with_session(session: &Session, keyspace: &str) -> Result<()> {
|
|
let existing = fetch_existing_schema(session, keyspace).await?;
|
|
let diff = create_diff(&existing, keyspace);
|
|
let unapplied = diff.statements.clone();
|
|
if !diff.errors.is_empty() || !unapplied.is_empty() {
|
|
write_diff_file(&diff, None)?;
|
|
let mut problems = diff.errors;
|
|
problems.extend(
|
|
unapplied
|
|
.into_iter()
|
|
.take(10)
|
|
.map(|statement| format!("still needs: {statement}")),
|
|
);
|
|
bail!(
|
|
"Cassandra schema verification failed:\n{}",
|
|
problems
|
|
.into_iter()
|
|
.map(|item| format!(" - {item}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
);
|
|
}
|
|
println!(
|
|
"Cassandra schema verified: {} types, {} tables, {} indexes, target {}",
|
|
target_schema().user_types.len(),
|
|
target_schema().tables.len(),
|
|
target_schema().indexes.len(),
|
|
&diff.target_sha256[..12],
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn normalizes_frozen_user_type_wrappers() {
|
|
assert_eq!(normalize_type(" frozen<message_embed> "), "message_embed");
|
|
assert_eq!(
|
|
normalize_type("LIST< frozen<message_embed> >"),
|
|
"list<message_embed>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn renders_target_schema_with_semicolons() {
|
|
let rendered = render_target_schema("fluxer");
|
|
assert!(rendered.starts_with("CREATE TYPE IF NOT EXISTS fluxer."));
|
|
assert!(rendered.contains("CREATE TABLE IF NOT EXISTS fluxer.messages"));
|
|
assert!(rendered.ends_with(";\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn diff_creates_keyspace_and_missing_schema() {
|
|
let diff = create_diff(&ExistingSchema::default(), "fluxer");
|
|
assert!(diff.statements[0].starts_with("CREATE KEYSPACE IF NOT EXISTS fluxer"));
|
|
assert!(
|
|
diff.statements
|
|
.iter()
|
|
.any(|statement| statement.starts_with("CREATE TYPE IF NOT EXISTS fluxer."))
|
|
);
|
|
assert!(
|
|
diff.statements
|
|
.iter()
|
|
.any(|statement| statement.starts_with("CREATE TABLE IF NOT EXISTS fluxer."))
|
|
);
|
|
assert!(diff.errors.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn diff_reports_type_mismatches_and_extra_tables() {
|
|
let mut existing = ExistingSchema {
|
|
keyspace_exists: true,
|
|
..ExistingSchema::default()
|
|
};
|
|
existing.types.insert(
|
|
"message_attachment".to_owned(),
|
|
BTreeMap::from([("attachment_id".to_owned(), "text".to_owned())]),
|
|
);
|
|
existing
|
|
.tables
|
|
.insert("unexpected".to_owned(), ExistingTable::default());
|
|
let diff = create_diff(&existing, "fluxer");
|
|
assert!(
|
|
diff.errors
|
|
.iter()
|
|
.any(|error| error.contains("type message_attachment.attachment_id"))
|
|
);
|
|
assert!(
|
|
diff.warnings
|
|
.iter()
|
|
.any(|warning| warning.contains("unexpected"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_diff_includes_warnings_errors_and_sha() {
|
|
let diff = SchemaDiff {
|
|
statements: vec!["SELECT 1".to_owned()],
|
|
warnings: vec!["warn".to_owned()],
|
|
errors: vec!["err".to_owned()],
|
|
target_sha256: "abc".to_owned(),
|
|
};
|
|
let rendered = render_diff(&diff);
|
|
assert!(rendered.contains("-- Target schema sha256: abc"));
|
|
assert!(rendered.contains("-- warn"));
|
|
assert!(rendered.contains("-- err"));
|
|
assert!(rendered.ends_with("SELECT 1;\n"));
|
|
}
|
|
}
|