use crate::migrator::{Migration, MigratorError};
use drizzle_types::Dialect;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectKind {
Table,
Index,
View,
Trigger,
Enum,
}
#[derive(Debug, Clone)]
pub struct CatalogObject {
pub kind: ObjectKind,
pub schema: Option<String>,
pub name: String,
pub sql: Option<String>,
pub members: Vec<String>,
pub unique: bool,
}
#[derive(Debug, Clone, Default)]
pub struct Catalog {
pub objects: Vec<CatalogObject>,
}
impl Catalog {
#[must_use]
pub const fn new() -> Self {
Self {
objects: Vec::new(),
}
}
pub fn push(&mut self, object: CatalogObject) {
self.objects.push(object);
}
#[must_use]
pub fn find(
&self,
kind: ObjectKind,
schema: Option<&str>,
name: &str,
) -> Option<&CatalogObject> {
let same_name =
|object: &&CatalogObject| object.kind == kind && object.name.eq_ignore_ascii_case(name);
if let Some(schema) = schema {
return self.objects.iter().find(|object| {
same_name(object)
&& object
.schema
.as_deref()
.is_some_and(|live| live.eq_ignore_ascii_case(schema))
});
}
if let Some(object) = self.objects.iter().find(|object| {
same_name(object)
&& object
.schema
.as_deref()
.is_none_or(|live| live.eq_ignore_ascii_case("public"))
}) {
return Some(object);
}
let mut matches = self.objects.iter().filter(same_name);
let first = matches.next()?;
matches.next().is_none().then_some(first)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StatementTarget {
Table {
schema: Option<String>,
name: String,
columns: Vec<String>,
},
Index {
schema: Option<String>,
name: String,
unique: bool,
columns: Vec<String>,
},
View {
schema: Option<String>,
name: String,
},
Enum {
schema: Option<String>,
name: String,
values: Vec<String>,
},
Unclassified,
}
impl StatementTarget {
#[must_use]
pub const fn is_classified(&self) -> bool {
!matches!(self, Self::Unclassified)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Disposition {
Skip {
reason: String,
},
Execute,
Unresolvable {
reason: String,
},
}
#[derive(Debug, Clone)]
pub struct Step {
pub index: usize,
pub sql: String,
pub disposition: Disposition,
}
#[derive(Debug, Clone)]
pub struct Plan {
pub tag: String,
pub steps: Vec<Step>,
}
impl Plan {
#[must_use]
pub fn unresolvable(&self) -> Vec<&Step> {
self.steps
.iter()
.filter(|step| matches!(step.disposition, Disposition::Unresolvable { .. }))
.collect()
}
#[must_use]
pub fn skipped_count(&self) -> usize {
self.steps
.iter()
.filter(|step| matches!(step.disposition, Disposition::Skip { .. }))
.count()
}
#[must_use]
pub fn is_resolvable(&self) -> bool {
self.unresolvable().is_empty()
}
pub fn into_executable(self, table_ident: &str) -> Result<Vec<String>, MigratorError> {
let blockers = self.unresolvable();
if !blockers.is_empty() {
let mut message = format!(
"cannot repair migration `{}`: {} statement(s) could not be reconciled against \
the live schema.\n\
Repair only skips CREATE TABLE / CREATE [UNIQUE] INDEX / CREATE VIEW / \
CREATE TYPE ... AS ENUM statements that introspection proves are already \
present; anything else inside the interrupted region must be resolved by hand.",
self.tag,
blockers.len()
);
for step in blockers {
let reason = match &step.disposition {
Disposition::Unresolvable { reason } => reason.as_str(),
_ => unreachable!("filtered to unresolvable"),
};
message.push_str(&format!(
"\n statement {}: {reason}\n {}",
step.index + 1,
single_line(&step.sql)
));
}
message.push_str(&format!(
"\nResolve those statements manually, then mark the migration complete:\n \
UPDATE {table_ident} SET \"applied_at\" = CURRENT_TIMESTAMP WHERE \"name\" = '{}';\n\
…or discard the marker and re-run from scratch:\n \
DELETE FROM {table_ident} WHERE \"name\" = '{}';",
self.tag.replace('\'', "''"),
self.tag.replace('\'', "''"),
));
return Err(MigratorError::UnrepairableMigration(message));
}
Ok(self
.steps
.into_iter()
.filter(|step| step.disposition == Disposition::Execute)
.map(|step| step.sql)
.collect())
}
}
#[must_use]
pub fn plan(dialect: Dialect, migration: &Migration, catalog: &Catalog) -> Plan {
let mut steps = Vec::new();
let mut in_applied_prefix = true;
for (index, sql) in migration.statements().iter().enumerate() {
if sql.trim().is_empty() {
continue;
}
let target = classify_statement(dialect, sql);
let disposition = match &target {
StatementTarget::Unclassified => {
if in_applied_prefix {
Disposition::Unresolvable {
reason: "not a provable CREATE statement, and it may already have run \
before the interruption"
.to_string(),
}
} else {
Disposition::Execute
}
}
_ => match reconcile(dialect, &target, sql, catalog) {
Reconciled::Present => Disposition::Skip {
reason: describe_present(&target),
},
Reconciled::Absent => {
in_applied_prefix = false;
Disposition::Execute
}
Reconciled::Conflict(reason) => Disposition::Unresolvable { reason },
},
};
steps.push(Step {
index,
sql: sql.clone(),
disposition,
});
}
Plan {
tag: migration.tag().to_string(),
steps,
}
}
enum Reconciled {
Present,
Absent,
Conflict(String),
}
fn reconcile(
dialect: Dialect,
target: &StatementTarget,
sql: &str,
catalog: &Catalog,
) -> Reconciled {
match target {
StatementTarget::Table {
schema,
name,
columns,
} => {
let Some(object) = catalog.find(ObjectKind::Table, schema.as_deref(), name) else {
return Reconciled::Absent;
};
if object_matches_sql(dialect, object, sql) || members_match(&object.members, columns) {
Reconciled::Present
} else {
Reconciled::Conflict(format!(
"table `{name}` already exists but its columns ({}) do not match the ones this \
statement creates ({})",
join_or_unknown(&object.members),
join_or_unknown(columns)
))
}
}
StatementTarget::Index {
schema,
name,
unique,
columns,
} => {
let Some(object) = catalog.find(ObjectKind::Index, schema.as_deref(), name) else {
return Reconciled::Absent;
};
let have_evidence = object.sql.is_some() || !object.members.is_empty();
if have_evidence && object.unique != *unique {
return Reconciled::Conflict(format!(
"index `{name}` already exists but its uniqueness differs (live: {}, \
statement: {})",
object.unique, unique
));
}
if object_matches_sql(dialect, object, sql) || members_match(&object.members, columns) {
Reconciled::Present
} else {
Reconciled::Conflict(format!(
"index `{name}` already exists but covers ({}) instead of ({})",
join_or_unknown(&object.members),
join_or_unknown(columns)
))
}
}
StatementTarget::View { schema, name } => {
let Some(object) = catalog.find(ObjectKind::View, schema.as_deref(), name) else {
return Reconciled::Absent;
};
if object_matches_sql(dialect, object, sql) {
Reconciled::Present
} else {
Reconciled::Conflict(format!(
"view `{name}` already exists but its definition differs from this statement"
))
}
}
StatementTarget::Enum {
schema,
name,
values,
} => {
let Some(object) = catalog.find(ObjectKind::Enum, schema.as_deref(), name) else {
return Reconciled::Absent;
};
if object.members == *values {
Reconciled::Present
} else {
Reconciled::Conflict(format!(
"enum type `{name}` already exists with labels ({}) instead of ({})",
join_or_unknown(&object.members),
join_or_unknown(values)
))
}
}
StatementTarget::Unclassified => Reconciled::Conflict("unclassified statement".to_string()),
}
}
fn object_matches_sql(dialect: Dialect, object: &CatalogObject, sql: &str) -> bool {
object.sql.as_deref().is_some_and(|live_sql| {
canonical_tokens(dialect, live_sql) == canonical_tokens(dialect, sql)
})
}
fn members_match(live: &[String], target: &[String]) -> bool {
!live.is_empty()
&& live.len() == target.len()
&& live
.iter()
.zip(target)
.all(|(a, b)| a.eq_ignore_ascii_case(b))
}
fn join_or_unknown(values: &[String]) -> String {
if values.is_empty() {
"unknown".to_string()
} else {
values.join(", ")
}
}
fn describe_present(target: &StatementTarget) -> String {
match target {
StatementTarget::Table { name, .. } => {
format!("table `{name}` already exists with a matching definition")
}
StatementTarget::Index { name, .. } => {
format!("index `{name}` already exists with a matching definition")
}
StatementTarget::View { name, .. } => {
format!("view `{name}` already exists with a matching definition")
}
StatementTarget::Enum { name, .. } => {
format!("enum type `{name}` already exists with matching labels")
}
StatementTarget::Unclassified => "already applied".to_string(),
}
}
fn single_line(sql: &str) -> String {
let collapsed = sql.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() > 160 {
let truncated: String = collapsed.chars().take(157).collect();
format!("{truncated}...")
} else {
collapsed
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Tok {
Word(String),
Quoted(String),
Str(String),
Punct(char),
}
impl Tok {
fn ident(&self) -> Option<&str> {
match self {
Self::Word(value) | Self::Quoted(value) => Some(value),
_ => None,
}
}
fn is_word(&self, keyword: &str) -> bool {
matches!(self, Self::Word(value) if value == keyword)
}
fn is_punct(&self, character: char) -> bool {
matches!(self, Self::Punct(value) if *value == character)
}
fn canonical(&self) -> String {
match self {
Self::Word(value) => value.clone(),
Self::Quoted(value) => value.to_lowercase(),
Self::Str(value) => format!("'{value}'"),
Self::Punct(value) => value.to_string(),
}
}
}
fn tokenize(sql: &str) -> Vec<Tok> {
let bytes: Vec<char> = sql.chars().collect();
let mut tokens = Vec::new();
let mut index = 0;
while index < bytes.len() {
let ch = bytes[index];
if ch.is_whitespace() {
index += 1;
continue;
}
if ch == '-' && bytes.get(index + 1) == Some(&'-') {
while index < bytes.len() && bytes[index] != '\n' {
index += 1;
}
continue;
}
if ch == '/' && bytes.get(index + 1) == Some(&'*') {
index += 2;
while index < bytes.len() {
if bytes[index] == '*' && bytes.get(index + 1) == Some(&'/') {
index += 2;
break;
}
index += 1;
}
continue;
}
if let Some((closing, is_string)) = match ch {
'"' => Some(('"', false)),
'`' => Some(('`', false)),
'[' => Some((']', false)),
'\'' => Some(('\'', true)),
_ => None,
} {
index += 1;
let mut value = String::new();
while index < bytes.len() {
if bytes[index] == closing {
if closing != ']' && bytes.get(index + 1) == Some(&closing) {
value.push(closing);
index += 2;
continue;
}
index += 1;
break;
}
value.push(bytes[index]);
index += 1;
}
tokens.push(if is_string {
Tok::Str(value)
} else {
Tok::Quoted(value)
});
continue;
}
if ch.is_alphanumeric() || ch == '_' || ch == '$' {
let mut value = String::new();
while index < bytes.len()
&& (bytes[index].is_alphanumeric() || bytes[index] == '_' || bytes[index] == '$')
{
value.push(bytes[index]);
index += 1;
}
tokens.push(Tok::Word(value.to_lowercase()));
continue;
}
tokens.push(Tok::Punct(ch));
index += 1;
}
tokens
}
fn canonical_tokens(_dialect: Dialect, sql: &str) -> Vec<String> {
let tokens = tokenize(sql);
let mut out = Vec::with_capacity(tokens.len());
let mut index = 0;
while index < tokens.len() {
if tokens[index].is_word("if")
&& tokens.get(index + 1).is_some_and(|t| t.is_word("not"))
&& tokens.get(index + 2).is_some_and(|t| t.is_word("exists"))
{
index += 3;
continue;
}
if tokens[index].is_word("concurrently") {
index += 1;
continue;
}
if tokens[index].is_punct(';') && index + 1 == tokens.len() {
break;
}
out.push(tokens[index].canonical());
index += 1;
}
out
}
const CONSTRAINT_KEYWORDS: [&str; 8] = [
"constraint",
"primary",
"unique",
"foreign",
"check",
"exclude",
"like",
"period",
];
#[must_use]
pub fn classify_statement(dialect: Dialect, sql: &str) -> StatementTarget {
let tokens = tokenize(sql);
let mut index = 0;
if !tokens.first().is_some_and(|t| t.is_word("create")) {
return StatementTarget::Unclassified;
}
index += 1;
let mut unique = false;
loop {
match tokens.get(index) {
Some(t) if t.is_word("or") => {
index += 2;
}
Some(t) if t.is_word("unique") => {
unique = true;
index += 1;
}
Some(t)
if t.is_word("temp")
|| t.is_word("temporary")
|| t.is_word("unlogged")
|| t.is_word("global")
|| t.is_word("local") =>
{
index += 1;
}
_ => break,
}
}
let Some(kind) = tokens.get(index).and_then(Tok::ident).map(str::to_string) else {
return StatementTarget::Unclassified;
};
index += 1;
let skip_noise = |tokens: &[Tok], mut index: usize| {
loop {
match tokens.get(index) {
Some(t) if t.is_word("concurrently") => index += 1,
Some(t)
if t.is_word("if")
&& tokens.get(index + 1).is_some_and(|t| t.is_word("not"))
&& tokens.get(index + 2).is_some_and(|t| t.is_word("exists")) =>
{
index += 3;
}
_ => return index,
}
}
};
match kind.as_str() {
"table" => {
index = skip_noise(&tokens, index);
let Some((schema, name, next)) = read_qualified_name(&tokens, index) else {
return StatementTarget::Unclassified;
};
let Some(columns) = read_table_columns(&tokens, next) else {
return StatementTarget::Unclassified;
};
StatementTarget::Table {
schema,
name,
columns,
}
}
"index" => {
index = skip_noise(&tokens, index);
if tokens.get(index).is_some_and(|t| t.is_word("on")) {
return StatementTarget::Unclassified;
}
let Some((schema, name, mut next)) = read_qualified_name(&tokens, index) else {
return StatementTarget::Unclassified;
};
if !tokens.get(next).is_some_and(|t| t.is_word("on")) {
return StatementTarget::Unclassified;
}
next += 1;
let Some((_, _, after_table)) = read_qualified_name(&tokens, next) else {
return StatementTarget::Unclassified;
};
let mut cursor = after_table;
while cursor < tokens.len() && !tokens[cursor].is_punct('(') {
cursor += 1;
}
let Some(columns) = read_paren_list_heads(&tokens, cursor, false) else {
return StatementTarget::Unclassified;
};
StatementTarget::Index {
schema,
name,
unique,
columns,
}
}
"view" | "materialized" => {
let mut cursor = index;
if kind == "materialized" {
if !tokens.get(cursor).is_some_and(|t| t.is_word("view")) {
return StatementTarget::Unclassified;
}
cursor += 1;
}
cursor = skip_noise(&tokens, cursor);
let Some((schema, name, _)) = read_qualified_name(&tokens, cursor) else {
return StatementTarget::Unclassified;
};
StatementTarget::View { schema, name }
}
"type" if dialect == Dialect::PostgreSQL => {
let Some((schema, name, mut next)) = read_qualified_name(&tokens, index) else {
return StatementTarget::Unclassified;
};
if !tokens.get(next).is_some_and(|t| t.is_word("as")) {
return StatementTarget::Unclassified;
}
next += 1;
if !tokens.get(next).is_some_and(|t| t.is_word("enum")) {
return StatementTarget::Unclassified;
}
next += 1;
let Some(values) = read_enum_values(&tokens, next) else {
return StatementTarget::Unclassified;
};
StatementTarget::Enum {
schema,
name,
values,
}
}
_ => StatementTarget::Unclassified,
}
}
fn read_qualified_name(tokens: &[Tok], index: usize) -> Option<(Option<String>, String, usize)> {
let first = tokens.get(index)?.ident()?.to_string();
if tokens.get(index + 1).is_some_and(|t| t.is_punct('.')) {
let second = tokens.get(index + 2)?.ident()?.to_string();
return Some((Some(first), second, index + 3));
}
Some((None, first, index + 1))
}
fn read_paren_list_heads(
tokens: &[Tok],
index: usize,
skip_constraints: bool,
) -> Option<Vec<String>> {
if !tokens.get(index)?.is_punct('(') {
return None;
}
let mut heads = Vec::new();
let mut depth = 0usize;
let mut entry_start = true;
let mut cursor = index;
while cursor < tokens.len() {
let token = &tokens[cursor];
if token.is_punct('(') {
depth += 1;
if depth == 1 {
entry_start = true;
cursor += 1;
continue;
}
} else if token.is_punct(')') {
depth -= 1;
if depth == 0 {
return Some(heads);
}
} else if depth == 1 && token.is_punct(',') {
entry_start = true;
cursor += 1;
continue;
}
if depth == 1 && entry_start {
entry_start = false;
if let Some(ident) = token.ident() {
let is_constraint = skip_constraints
&& matches!(token, Tok::Word(_))
&& CONSTRAINT_KEYWORDS.contains(&ident);
if !is_constraint {
heads.push(ident.to_string());
}
}
}
cursor += 1;
}
None
}
fn read_table_columns(tokens: &[Tok], index: usize) -> Option<Vec<String>> {
read_paren_list_heads(tokens, index, true)
}
fn read_enum_values(tokens: &[Tok], index: usize) -> Option<Vec<String>> {
if !tokens.get(index)?.is_punct('(') {
return None;
}
let mut values = Vec::new();
let mut cursor = index + 1;
while cursor < tokens.len() {
match &tokens[cursor] {
Tok::Str(value) => values.push(value.clone()),
Tok::Punct(')') => return Some(values),
Tok::Punct(',') => {}
_ => return None,
}
cursor += 1;
}
None
}
pub mod sqlite {
use super::{Catalog, CatalogObject, ObjectKind, StatementTarget, classify_statement};
use drizzle_types::Dialect;
pub const OBJECTS_QUERY: &str = "SELECT type, name, sql FROM sqlite_master \
WHERE type IN ('table', 'index', 'view', 'trigger') AND name NOT LIKE 'sqlite_%'";
#[must_use]
pub fn catalog(rows: &[(String, String, Option<String>)]) -> Catalog {
let mut catalog = Catalog::new();
for (kind, name, sql) in rows {
let kind = match kind.as_str() {
"table" => ObjectKind::Table,
"index" => ObjectKind::Index,
"view" => ObjectKind::View,
"trigger" => ObjectKind::Trigger,
_ => continue,
};
let parsed = sql
.as_deref()
.map(|sql| classify_statement(Dialect::SQLite, sql));
let members = match &parsed {
Some(
StatementTarget::Table { columns, .. } | StatementTarget::Index { columns, .. },
) => columns.clone(),
_ => Vec::new(),
};
let unique = matches!(parsed, Some(StatementTarget::Index { unique: true, .. }));
catalog.push(CatalogObject {
kind,
schema: None,
name: name.clone(),
sql: sql.clone(),
members,
unique,
});
}
catalog
}
}
pub mod postgres {
use super::{Catalog, CatalogObject, ObjectKind};
pub const TABLES_QUERY: &str = "SELECT n.nspname::text, c.relname::text \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE c.relkind IN ('r', 'p') \
AND n.nspname NOT IN ('pg_catalog', 'information_schema') \
AND n.nspname NOT LIKE 'pg\\_%'";
pub const COLUMNS_QUERY: &str = "SELECT n.nspname::text, c.relname::text, a.attname::text \
FROM pg_catalog.pg_attribute a \
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE c.relkind IN ('r', 'p') AND a.attnum > 0 AND NOT a.attisdropped \
AND n.nspname NOT IN ('pg_catalog', 'information_schema') \
AND n.nspname NOT LIKE 'pg\\_%' \
ORDER BY n.nspname, c.relname, a.attnum";
pub const VIEWS_QUERY: &str = "SELECT n.nspname::text, c.relname::text \
FROM pg_catalog.pg_class c \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE c.relkind IN ('v', 'm') \
AND n.nspname NOT IN ('pg_catalog', 'information_schema') \
AND n.nspname NOT LIKE 'pg\\_%'";
pub const INDEXES_QUERY: &str = "SELECT n.nspname::text, c.relname::text, i.indisunique \
FROM pg_catalog.pg_index i \
JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') \
AND n.nspname NOT LIKE 'pg\\_%'";
pub const INDEX_COLUMNS_QUERY: &str = "SELECT n.nspname::text, c.relname::text, a.attname::text \
FROM pg_catalog.pg_index i \
JOIN pg_catalog.pg_class c ON c.oid = i.indexrelid \
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace \
JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord) ON true \
JOIN pg_catalog.pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum \
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') \
AND n.nspname NOT LIKE 'pg\\_%' \
ORDER BY n.nspname, c.relname, k.ord";
pub const ENUMS_QUERY: &str = "SELECT n.nspname::text, t.typname::text, e.enumlabel::text \
FROM pg_catalog.pg_type t \
JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace \
JOIN pg_catalog.pg_enum e ON e.enumtypid = t.oid \
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') \
AND n.nspname NOT LIKE 'pg\\_%' \
ORDER BY n.nspname, t.typname, e.enumsortorder";
#[must_use]
pub fn catalog(
tables: &[(String, String)],
columns: &[(String, String, String)],
views: &[(String, String)],
indexes: &[(String, String, bool)],
index_columns: &[(String, String, String)],
enums: &[(String, String, String)],
) -> Catalog {
fn members_of(rows: &[(String, String, String)], schema: &str, owner: &str) -> Vec<String> {
rows.iter()
.filter(|(row_schema, row_owner, _)| row_schema == schema && row_owner == owner)
.map(|(_, _, member)| member.clone())
.collect()
}
let mut catalog = Catalog::new();
for (schema, name) in tables {
catalog.push(CatalogObject {
kind: ObjectKind::Table,
schema: Some(schema.clone()),
name: name.clone(),
sql: None,
members: members_of(columns, schema, name),
unique: false,
});
}
for (schema, name) in views {
catalog.push(CatalogObject {
kind: ObjectKind::View,
schema: Some(schema.clone()),
name: name.clone(),
sql: None,
members: Vec::new(),
unique: false,
});
}
for (schema, name, unique) in indexes {
catalog.push(CatalogObject {
kind: ObjectKind::Index,
schema: Some(schema.clone()),
name: name.clone(),
sql: None,
members: members_of(index_columns, schema, name),
unique: *unique,
});
}
let mut enum_names: Vec<(String, String)> = Vec::new();
for (schema, name, _) in enums {
let key = (schema.clone(), name.clone());
if !enum_names.contains(&key) {
enum_names.push(key);
}
}
for (schema, name) in enum_names {
let members = members_of(enums, &schema, &name);
catalog.push(CatalogObject {
kind: ObjectKind::Enum,
schema: Some(schema),
name,
sql: None,
members,
unique: false,
});
}
catalog
}
}
#[cfg(test)]
mod tests {
use super::{
Catalog, Disposition, ObjectKind, StatementTarget, classify_statement, plan, postgres,
sqlite,
};
use crate::migrator::Migration;
use drizzle_types::Dialect;
fn sqlite_table(name: &str, sql: &str) -> Catalog {
sqlite::catalog(&[("table".to_string(), name.to_string(), Some(sql.to_string()))])
}
fn migration(statements: &[&str]) -> Migration {
Migration::with_hash(
"20240101010101_test",
"hash",
1,
statements.iter().map(|s| (*s).to_string()).collect(),
)
}
#[test]
fn classifies_create_table_columns_without_constraints() {
let target = classify_statement(
Dialect::SQLite,
"CREATE TABLE `users` (\n\t`id` integer PRIMARY KEY NOT NULL,\n\t`email` text NOT NULL,\n\tFOREIGN KEY (`id`) REFERENCES `other`(`id`),\n\tCONSTRAINT `uq` UNIQUE(`email`)\n)",
);
assert_eq!(
target,
StatementTarget::Table {
schema: None,
name: "users".to_string(),
columns: vec!["id".to_string(), "email".to_string()],
}
);
}
#[test]
fn classifies_if_not_exists_and_schema_qualified_tables() {
let target = classify_statement(
Dialect::PostgreSQL,
"CREATE TABLE IF NOT EXISTS \"app\".\"users\" (\"id\" serial PRIMARY KEY, \"name\" text)",
);
assert_eq!(
target,
StatementTarget::Table {
schema: Some("app".to_string()),
name: "users".to_string(),
columns: vec!["id".to_string(), "name".to_string()],
}
);
}
#[test]
fn classifies_unique_and_concurrent_indexes() {
assert_eq!(
classify_statement(
Dialect::SQLite,
"CREATE UNIQUE INDEX `users_email_idx` ON `users` (`email`)"
),
StatementTarget::Index {
schema: None,
name: "users_email_idx".to_string(),
unique: true,
columns: vec!["email".to_string()],
}
);
assert_eq!(
classify_statement(
Dialect::PostgreSQL,
"CREATE INDEX CONCURRENTLY IF NOT EXISTS \"users_email_idx\" ON \"users\" USING btree (\"email\", \"name\")"
),
StatementTarget::Index {
schema: None,
name: "users_email_idx".to_string(),
unique: false,
columns: vec!["email".to_string(), "name".to_string()],
}
);
}
#[test]
fn classifies_postgres_enum_types() {
assert_eq!(
classify_statement(
Dialect::PostgreSQL,
"CREATE TYPE \"public\".\"status\" AS ENUM('active', 'archived')"
),
StatementTarget::Enum {
schema: Some("public".to_string()),
name: "status".to_string(),
values: vec!["active".to_string(), "archived".to_string()],
}
);
assert_eq!(
classify_statement(Dialect::SQLite, "CREATE TYPE status AS ENUM('a')"),
StatementTarget::Unclassified
);
}
#[test]
fn leaves_non_create_statements_unclassified() {
for sql in [
"ALTER TABLE `users` ADD COLUMN `age` integer",
"DROP TABLE `users`",
"INSERT INTO `users` (`id`) VALUES (1)",
"UPDATE `users` SET `id` = 2",
"CREATE TRIGGER t AFTER INSERT ON users BEGIN SELECT 1; END",
"PRAGMA foreign_keys=OFF",
] {
assert_eq!(
classify_statement(Dialect::SQLite, sql),
StatementTarget::Unclassified,
"unexpectedly classified: {sql}"
);
}
}
#[test]
fn skips_the_applied_prefix_and_executes_the_rest() {
let migration = migration(&[
"CREATE TABLE `a` (`id` integer PRIMARY KEY)",
"CREATE TABLE `b` (`id` integer PRIMARY KEY)",
]);
let catalog = sqlite_table("a", "CREATE TABLE `a` (`id` integer PRIMARY KEY)");
let plan = plan(Dialect::SQLite, &migration, &catalog);
assert_eq!(plan.skipped_count(), 1);
assert!(plan.is_resolvable());
assert!(matches!(
plan.steps[0].disposition,
Disposition::Skip { .. }
));
assert_eq!(plan.steps[1].disposition, Disposition::Execute);
let executable = plan
.into_executable("\"__drizzle_migrations\"")
.expect("resolvable");
assert_eq!(
executable,
vec!["CREATE TABLE `b` (`id` integer PRIMARY KEY)"]
);
}
#[test]
fn matches_tables_structurally_when_formatting_differs() {
let migration =
migration(&["CREATE TABLE `a` (\n `id` integer PRIMARY KEY,\n `name` text\n)"]);
let catalog = sqlite_table(
"a",
"CREATE TABLE \"a\" (\"id\" INTEGER PRIMARY KEY, \"name\" TEXT)",
);
let plan = plan(Dialect::SQLite, &migration, &catalog);
assert!(plan.is_resolvable());
assert_eq!(plan.skipped_count(), 1);
}
#[test]
fn refuses_when_an_existing_table_does_not_match() {
let migration = migration(&["CREATE TABLE `a` (`id` integer, `email` text)"]);
let catalog = sqlite_table("a", "CREATE TABLE `a` (`id` integer)");
let plan = plan(Dialect::SQLite, &migration, &catalog);
assert!(!plan.is_resolvable());
let error = plan
.into_executable("\"__drizzle_migrations\"")
.expect_err("must refuse");
assert!(error.to_string().contains("do not match"));
assert!(error.to_string().contains("UPDATE"));
}
#[test]
fn refuses_unprovable_statements_inside_the_applied_prefix() {
let migration = migration(&[
"ALTER TABLE `a` ADD COLUMN `age` integer",
"CREATE TABLE `b` (`id` integer)",
]);
let catalog = Catalog::new();
let plan = plan(Dialect::SQLite, &migration, &catalog);
assert!(!plan.is_resolvable());
assert!(matches!(
plan.steps[0].disposition,
Disposition::Unresolvable { .. }
));
}
#[test]
fn executes_unprovable_statements_after_a_proven_gap() {
let migration = migration(&[
"CREATE TABLE `a` (`id` integer)",
"CREATE TABLE `b` (`id` integer)",
"ALTER TABLE `b` ADD COLUMN `age` integer",
]);
let catalog = sqlite_table("a", "CREATE TABLE `a` (`id` integer)");
let plan = plan(Dialect::SQLite, &migration, &catalog);
assert!(plan.is_resolvable(), "{:?}", plan.steps);
let executable = plan.into_executable("\"t\"").expect("resolvable");
assert_eq!(executable.len(), 2);
}
#[test]
fn postgres_catalog_round_trips_through_the_planner() {
let catalog = postgres::catalog(
&[("public".to_string(), "users".to_string())],
&[
("public".to_string(), "users".to_string(), "id".to_string()),
(
"public".to_string(),
"users".to_string(),
"email".to_string(),
),
],
&[],
&[("public".to_string(), "users_email_idx".to_string(), true)],
&[(
"public".to_string(),
"users_email_idx".to_string(),
"email".to_string(),
)],
&[(
"public".to_string(),
"status".to_string(),
"active".to_string(),
)],
);
assert!(
catalog.find(ObjectKind::Table, None, "users").is_some(),
"unqualified lookup resolves to public"
);
let migration = migration(&[
"CREATE TABLE \"users\" (\"id\" serial PRIMARY KEY, \"email\" text)",
"CREATE UNIQUE INDEX CONCURRENTLY \"users_email_idx\" ON \"users\" (\"email\")",
"CREATE TYPE \"public\".\"status\" AS ENUM('active')",
"CREATE TABLE \"posts\" (\"id\" serial PRIMARY KEY)",
]);
let plan = plan(Dialect::PostgreSQL, &migration, &catalog);
assert!(plan.is_resolvable(), "{:?}", plan.steps);
assert_eq!(plan.skipped_count(), 3);
let executable = plan
.into_executable("\"drizzle\".\"t\"")
.expect("resolvable");
assert_eq!(executable.len(), 1);
assert!(executable[0].contains("posts"));
}
#[test]
fn postgres_enum_label_mismatch_is_unresolvable() {
let catalog = postgres::catalog(
&[],
&[],
&[],
&[],
&[],
&[(
"public".to_string(),
"status".to_string(),
"active".to_string(),
)],
);
let migration = migration(&["CREATE TYPE \"status\" AS ENUM('active', 'archived')"]);
let plan = plan(Dialect::PostgreSQL, &migration, &catalog);
assert!(!plan.is_resolvable());
}
#[test]
fn postgres_index_uniqueness_mismatch_is_unresolvable() {
let catalog = postgres::catalog(
&[],
&[],
&[],
&[("public".to_string(), "users_email_idx".to_string(), false)],
&[(
"public".to_string(),
"users_email_idx".to_string(),
"email".to_string(),
)],
&[],
);
let migration =
migration(&["CREATE UNIQUE INDEX \"users_email_idx\" ON \"users\" (\"email\")"]);
let plan = plan(Dialect::PostgreSQL, &migration, &catalog);
assert!(!plan.is_resolvable(), "{:?}", plan.steps);
let text = plan
.into_executable("\"t\"")
.expect_err("must refuse")
.to_string();
assert!(text.contains("uniqueness differs"), "{text}");
}
}