use std::{
collections::HashSet,
collections::hash_map::DefaultHasher,
fs,
hash::{Hash, Hasher},
io,
path::{Path, PathBuf},
};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationFile {
pub path: PathBuf,
pub id: u64,
pub name: String,
pub checksum: String,
pub up: Vec<Statement>,
pub down: Vec<Statement>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Statement {
Sql {
sql: String,
},
Backfill {
sql: String,
},
CreateTable {
name: String,
items: Vec<TableItem>,
},
AlterTable {
name: String,
actions: Vec<AlterAction>,
},
DropTable {
name: String,
},
RenameTable {
from: String,
to: String,
},
CreateEnum {
name: String,
values: Vec<String>,
},
AlterEnum {
name: String,
actions: Vec<AlterEnumAction>,
},
DropEnum {
name: String,
},
RenameEnum {
from: String,
to: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TableItem {
Column(Column),
Index {
name: Option<String>,
columns: Vec<String>,
},
Unique(Vec<String>),
Primary(Vec<String>),
ConstraintUnique {
name: String,
columns: Vec<String>,
},
Timestamps,
SoftDeletes,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Column {
pub name: String,
pub ty: ColumnType,
pub nullable: Nullability,
pub primary: bool,
pub unique: bool,
pub index: bool,
pub default: Option<String>,
pub reference: Option<Reference>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ColumnType {
Implicit,
Id,
String,
Text,
Integer,
BigInt,
Boolean,
Date,
Time,
DateTime,
Timestamp,
TimestampTz,
Json,
Uuid,
RememberToken,
Varchar(u32),
Decimal(u32, u32),
Float,
Double,
Custom(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Nullability {
Implicit,
Nullable,
NotNull,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Reference {
pub table: String,
pub column: Option<String>,
pub on_delete: Option<ReferenceAction>,
pub on_update: Option<ReferenceAction>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReferenceAction {
Cascade,
Restrict,
SetNull,
NoAction,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AlterAction {
AddColumn(Column),
DropColumn(String),
RenameColumn {
from: String,
to: String,
},
AddIndex {
name: Option<String>,
columns: Vec<String>,
},
DropIndex(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AlterEnumAction {
AddValue(String),
}
#[derive(Debug, Error)]
pub enum Error {
#[error("failed to read migration directory `{path}`")]
MigrationDirectory {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("migration io error: {0}")]
Io(#[from] io::Error),
#[error("invalid migration filename `{0}`")]
InvalidMigrationFilename(PathBuf),
#[error("invalid migration file `{path}`: {message}")]
InvalidMigrationFile { path: PathBuf, message: String },
#[error("duplicate migration version `{0}`")]
DuplicateVersion(u64),
}
pub fn default_migration_dir() -> PathBuf {
PathBuf::from("migrations")
}
pub fn load_migrations(dir: &Path) -> Result<Vec<MigrationFile>, Error> {
let mut lift_migrations = Vec::new();
let mut sql_migrations = std::collections::BTreeMap::<(u64, String), SqlMigrationParts>::new();
let read_dir = fs::read_dir(dir).map_err(|source| Error::MigrationDirectory {
path: dir.to_path_buf(),
source,
})?;
for entry in read_dir {
let entry = entry.map_err(Error::Io)?;
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if file_name.ends_with(".lift") {
lift_migrations.push(parse_migration_file(&path)?);
continue;
}
if file_name.ends_with(".sql") {
let ParsedSqlFilename { id, name, kind } = parse_sql_filename(file_name, &path)?;
let source = fs::read_to_string(&path).map_err(Error::Io)?;
let parts = sql_migrations.entry((id, name)).or_default();
match kind {
SqlFileKind::Up => {
if parts.up.replace((path.clone(), source)).is_some() {
return Err(Error::InvalidMigrationFile {
path,
message: "duplicate forward sql migration".to_owned(),
});
}
}
SqlFileKind::Down => {
if parts.down.replace((path.clone(), source)).is_some() {
return Err(Error::InvalidMigrationFile {
path,
message: "duplicate rollback sql migration".to_owned(),
});
}
}
}
}
}
let mut migrations = lift_migrations;
for ((id, name), parts) in sql_migrations {
let (up_path, up_source) = parts.up.ok_or_else(|| Error::InvalidMigrationFile {
path: dir.to_path_buf(),
message: format!("missing forward sql migration for `{id}_{name}`"),
})?;
let mut checksum_source = String::with_capacity(
up_source.len()
+ parts
.down
.as_ref()
.map(|(_, down_source)| down_source.len() + "\n-- down --\n".len())
.unwrap_or(0),
);
checksum_source.push_str(&up_source);
if let Some((_, down_source)) = &parts.down {
checksum_source.push_str("\n-- down --\n");
checksum_source.push_str(down_source);
}
let file_name = up_path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| Error::InvalidMigrationFilename(up_path.clone()))?;
migrations.push(MigrationFile {
path: up_path.clone(),
id,
name,
checksum: checksum(file_name, &checksum_source),
up: vec![Statement::Sql { sql: up_source }],
down: parts
.down
.map(|(_, sql)| vec![Statement::Sql { sql }])
.unwrap_or_default(),
});
}
migrations.sort_by_key(|migration| migration.id);
for pair in migrations.windows(2) {
if pair[0].id == pair[1].id {
return Err(Error::DuplicateVersion(pair[0].id));
}
}
Ok(migrations)
}
#[derive(Default)]
struct SqlMigrationParts {
up: Option<(PathBuf, String)>,
down: Option<(PathBuf, String)>,
}
struct ParsedSqlFilename {
id: u64,
name: String,
kind: SqlFileKind,
}
enum SqlFileKind {
Up,
Down,
}
pub fn parse_migration_file(path: &Path) -> Result<MigrationFile, Error> {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| Error::InvalidMigrationFilename(path.to_path_buf()))?;
let (id, name) = parse_filename(file_name, path)?;
let source = fs::read_to_string(path).map_err(Error::Io)?;
parse_source(path.to_path_buf(), id, name, file_name, &source)
}
pub fn parse_source(
path: PathBuf,
id: u64,
name: String,
file_name: &str,
source: &str,
) -> Result<MigrationFile, Error> {
let checksum = checksum(file_name, source);
let lines = normalized_lines(source);
let (up_lines, down_lines) = split_top_level(&lines, &path)?;
let up = parse_statements(&up_lines, &path)?;
validate_statements(&up, &path, "forward")?;
let down = parse_statements(&down_lines, &path)?;
validate_statements(&down, &path, "down")?;
Ok(MigrationFile {
path,
id,
name,
checksum,
up,
down,
})
}
fn validate_statements(
statements: &[Statement],
path: &Path,
direction: &str,
) -> Result<(), Error> {
let mut tables = HashSet::new();
let mut enums = HashSet::new();
for statement in statements {
match statement {
Statement::CreateTable { name, .. } => {
if !tables.insert(name.as_str()) {
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!(
"duplicate `create {name}` in {direction} migration statements"
),
});
}
}
Statement::CreateEnum { name, .. } => {
if !enums.insert(name.as_str()) {
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!(
"duplicate `create enum {name}` in {direction} migration statements"
),
});
}
}
_ => {}
}
}
Ok(())
}
fn parse_filename(file_name: &str, path: &Path) -> Result<(u64, String), Error> {
let stem = file_name
.strip_suffix(".lift")
.ok_or_else(|| Error::InvalidMigrationFilename(path.to_path_buf()))?;
let (id, name) = stem
.split_once('_')
.ok_or_else(|| Error::InvalidMigrationFilename(path.to_path_buf()))?;
let id = id
.parse::<u64>()
.map_err(|_| Error::InvalidMigrationFilename(path.to_path_buf()))?;
if name.is_empty() {
return Err(Error::InvalidMigrationFilename(path.to_path_buf()));
}
Ok((id, name.to_owned()))
}
fn parse_sql_filename(file_name: &str, path: &Path) -> Result<ParsedSqlFilename, Error> {
let (stem, kind) = if let Some(stem) = file_name.strip_suffix(".down.sql") {
(stem, SqlFileKind::Down)
} else if let Some(stem) = file_name.strip_suffix(".sql") {
(stem, SqlFileKind::Up)
} else {
return Err(Error::InvalidMigrationFilename(path.to_path_buf()));
};
let (id, name) = stem
.split_once('_')
.ok_or_else(|| Error::InvalidMigrationFilename(path.to_path_buf()))?;
let id = id
.parse::<u64>()
.map_err(|_| Error::InvalidMigrationFilename(path.to_path_buf()))?;
if name.is_empty() {
return Err(Error::InvalidMigrationFilename(path.to_path_buf()));
}
Ok(ParsedSqlFilename {
id,
name: name.to_owned(),
kind,
})
}
fn checksum(file_name: &str, source: &str) -> String {
let mut hasher = DefaultHasher::new();
file_name.hash(&mut hasher);
source.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn normalized_lines(source: &str) -> Vec<String> {
let mut lines = Vec::new();
let mut in_sql = false;
let mut in_backfill = false;
for raw_line in source.lines() {
if in_sql {
let trimmed = raw_line.trim();
if trimmed == r#"""""# {
lines.push(r#"""""#.to_owned());
in_sql = false;
} else {
lines.push(raw_line.to_owned());
}
continue;
}
if in_backfill {
let trimmed = raw_line.trim();
lines.push(raw_line.to_owned());
if trimmed == "}" {
in_backfill = false;
}
continue;
}
let line = normalize_line(strip_line_comment(raw_line).trim());
if line.is_empty() {
continue;
}
if line == r#"sql """"# {
in_sql = true;
} else if line == "backfill {" {
in_backfill = true;
}
lines.push(line);
}
lines
}
fn normalize_line(line: &str) -> String {
let mut normalized = line.trim();
loop {
let trimmed = normalized.trim_end();
if let Some(stripped) = trimmed.strip_suffix(';') {
normalized = stripped;
continue;
}
return trimmed.to_owned();
}
}
fn strip_line_comment(line: &str) -> &str {
let mut in_string = false;
let mut prev = '\0';
for (index, ch) in line.char_indices() {
match ch {
'"' if prev != '\\' => in_string = !in_string,
'/' if !in_string && prev == '/' => return &line[..index - 1],
_ => {}
}
prev = ch;
}
line
}
fn split_top_level(lines: &[String], path: &Path) -> Result<(Vec<String>, Vec<String>), Error> {
let mut up = Vec::new();
let mut down = Vec::new();
let mut index = 0usize;
let mut saw_down = false;
while index < lines.len() {
let line = &lines[index];
if line == "down {" {
if saw_down {
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: "multiple `down` blocks are not allowed".to_owned(),
});
}
let (body, next) = collect_body(lines, index + 1, path)?;
down = body;
saw_down = true;
index = next;
continue;
}
if saw_down {
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: "statements after `down` are not allowed".to_owned(),
});
}
up.push(line.clone());
index += 1;
}
Ok((up, down))
}
fn parse_statements(lines: &[String], path: &Path) -> Result<Vec<Statement>, Error> {
let mut index = 0usize;
let mut statements = Vec::new();
while index < lines.len() {
let line = &lines[index];
if line == r#"sql """"# {
let (sql, next) = collect_sql_block(lines, index + 1, path)?;
statements.push(Statement::Sql { sql });
index = next;
continue;
}
if line == "backfill {" {
let (sql, next) = collect_backfill_block(lines, index + 1, path)?;
if sql.trim().is_empty() {
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: "empty `backfill` blocks are not allowed".to_owned(),
});
}
statements.push(Statement::Backfill { sql });
index = next;
continue;
}
if let Some(rest) = line.strip_prefix("create ") {
if let Some(enum_rest) = rest.strip_prefix("enum ") {
statements.push(parse_create_enum(enum_rest, path)?);
index += 1;
continue;
}
if let Some(name) = rest.strip_suffix(" {") {
let name = parse_statement_identifier(name, path, "create")?;
let (body, next) = collect_body(lines, index + 1, path)?;
statements.push(Statement::CreateTable {
name,
items: parse_table_items(&body, path)?,
});
index = next;
continue;
}
}
if let Some(rest) = line.strip_prefix("alter ") {
if let Some(name) = rest
.strip_prefix("enum ")
.and_then(|value| value.strip_suffix(" {"))
{
let (body, next) = collect_body(lines, index + 1, path)?;
statements.push(Statement::AlterEnum {
name: name.to_owned(),
actions: parse_alter_enum_actions(&body, path)?,
});
index = next;
continue;
}
if let Some(name) = rest.strip_suffix(" {") {
let name = parse_statement_identifier(name, path, "alter")?;
let (body, next) = collect_body(lines, index + 1, path)?;
statements.push(Statement::AlterTable {
name,
actions: parse_alter_actions(&body, path)?,
});
index = next;
continue;
}
}
if let Some(rest) = line.strip_prefix("drop ") {
if let Some(name) = rest.strip_prefix("enum ") {
statements.push(Statement::DropEnum {
name: name.to_owned(),
});
index += 1;
continue;
}
let name = parse_statement_identifier(rest, path, "drop")?;
statements.push(Statement::DropTable { name });
index += 1;
continue;
}
if let Some(rest) = line.strip_prefix("rename ") {
if let Some(enum_rest) = rest.strip_prefix("enum ") {
let (from, to) = parse_rename(enum_rest, path, "enum")?;
statements.push(Statement::RenameEnum { from, to });
index += 1;
continue;
}
let (from, to) = parse_rename(rest, path, "table")?;
statements.push(Statement::RenameTable { from, to });
index += 1;
continue;
}
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("unsupported migration statement `{line}`"),
});
}
Ok(statements)
}
fn parse_statement_identifier(value: &str, path: &Path, statement: &str) -> Result<String, Error> {
let name = value.trim();
if is_identifier(name) {
return Ok(name.to_owned());
}
Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid `{statement}` statement `{value}`"),
})
}
fn parse_create_enum(rest: &str, path: &Path) -> Result<Statement, Error> {
let (name, raw_values) = rest
.split_once('{')
.and_then(|(name, values)| values.strip_suffix('}').map(|values| (name.trim(), values)))
.ok_or_else(|| Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid enum declaration `create enum {rest}`"),
})?;
let values = raw_values
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
if name.is_empty() || values.is_empty() {
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid enum declaration `create enum {rest}`"),
});
}
Ok(Statement::CreateEnum {
name: name.to_owned(),
values,
})
}
fn parse_alter_enum_actions(lines: &[String], path: &Path) -> Result<Vec<AlterEnumAction>, Error> {
let mut actions = Vec::new();
for line in lines {
if let Some(value) = line.strip_prefix("add value ") {
actions.push(AlterEnumAction::AddValue(value.trim().to_owned()));
continue;
}
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("unsupported alter enum action `{line}`"),
});
}
Ok(actions)
}
fn parse_rename(rest: &str, path: &Path, kind: &str) -> Result<(String, String), Error> {
let (from, to) = rest
.split_once(" to ")
.ok_or_else(|| Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid rename {kind} statement `{rest}`"),
})?;
let from = from.trim();
let to = to.trim();
if !is_identifier(from) || !is_identifier(to) {
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid rename {kind} statement `{rest}`"),
});
}
Ok((from.to_owned(), to.to_owned()))
}
fn collect_body(
lines: &[String],
mut index: usize,
path: &Path,
) -> Result<(Vec<String>, usize), Error> {
let mut body = Vec::new();
let mut depth = 1usize;
while index < lines.len() {
let line = &lines[index];
let trimmed = line.trim();
if trimmed == r#"sql """"# {
body.push(line.clone());
index += 1;
while index < lines.len() {
let sql_line = &lines[index];
body.push(sql_line.clone());
index += 1;
if sql_line.trim() == r#"""""# {
break;
}
}
continue;
}
if trimmed.ends_with('{') {
depth += 1;
body.push(line.clone());
index += 1;
continue;
}
if trimmed == "}" {
depth -= 1;
if depth == 0 {
return Ok((body, index + 1));
}
body.push(line.clone());
index += 1;
continue;
}
body.push(line.clone());
index += 1;
}
Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: "unterminated statement block".to_owned(),
})
}
fn collect_sql_block(
lines: &[String],
mut index: usize,
path: &Path,
) -> Result<(String, usize), Error> {
let mut sql_lines = Vec::new();
while index < lines.len() {
let line = &lines[index];
if line.trim() == r#"""""# {
return Ok((sql_lines.join("\n"), index + 1));
}
sql_lines.push(line.clone());
index += 1;
}
Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: "unterminated sql block".to_owned(),
})
}
fn collect_backfill_block(
lines: &[String],
mut index: usize,
path: &Path,
) -> Result<(String, usize), Error> {
let mut sql_lines = Vec::new();
while index < lines.len() {
let line = &lines[index];
if line.trim() == "}" {
return Ok((sql_lines.join("\n"), index + 1));
}
sql_lines.push(line.clone());
index += 1;
}
Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: "unterminated `backfill` block".to_owned(),
})
}
fn parse_table_items(lines: &[String], path: &Path) -> Result<Vec<TableItem>, Error> {
let mut items = Vec::new();
for line in lines {
if line == "timestamps" {
items.push(TableItem::Timestamps);
continue;
}
if line == "soft_deletes" {
items.push(TableItem::SoftDeletes);
continue;
}
if let Some(columns) = line.strip_prefix("index ") {
items.push(parse_index(columns, path)?);
continue;
}
if let Some(columns) = line.strip_prefix("unique ") {
items.push(TableItem::Unique(parse_column_list(columns, path)?));
continue;
}
if let Some(columns) = line.strip_prefix("primary ") {
items.push(TableItem::Primary(parse_column_list(columns, path)?));
continue;
}
if let Some(rest) = line.strip_prefix("constraint ") {
items.push(parse_constraint(rest, path)?);
continue;
}
items.push(TableItem::Column(parse_column(line, path)?));
}
Ok(items)
}
fn parse_index(value: &str, path: &Path) -> Result<TableItem, Error> {
let tokens = tokenize(value);
match tokens.as_slice() {
[columns] => Ok(TableItem::Index {
name: None,
columns: parse_column_list(columns, path)?,
}),
[name, columns] => Ok(TableItem::Index {
name: Some(name.clone()),
columns: parse_column_list(columns, path)?,
}),
_ => Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid index declaration `index {value}`"),
}),
}
}
fn parse_constraint(value: &str, path: &Path) -> Result<TableItem, Error> {
let tokens = tokenize(value);
match tokens.as_slice() {
[name, kind, columns] if kind == "unique" => Ok(TableItem::ConstraintUnique {
name: name.clone(),
columns: parse_column_list(columns, path)?,
}),
_ => Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid constraint declaration `constraint {value}`"),
}),
}
}
fn parse_alter_actions(lines: &[String], path: &Path) -> Result<Vec<AlterAction>, Error> {
let mut actions = Vec::new();
for line in lines {
if let Some(rest) = line.strip_prefix("add column ") {
actions.push(AlterAction::AddColumn(parse_column(rest, path)?));
continue;
}
if let Some(rest) = line.strip_prefix("drop column ") {
actions.push(AlterAction::DropColumn(rest.trim().to_owned()));
continue;
}
if let Some(rest) = line.strip_prefix("rename column ") {
let (from, to) = parse_rename(rest, path, "column")?;
actions.push(AlterAction::RenameColumn { from, to });
continue;
}
if let Some(rest) = line.strip_prefix("add index ") {
match parse_index(rest, path)? {
TableItem::Index { name, columns } => {
actions.push(AlterAction::AddIndex { name, columns });
}
_ => unreachable!(),
}
continue;
}
if let Some(rest) = line.strip_prefix("drop index ") {
actions.push(AlterAction::DropIndex(rest.trim().to_owned()));
continue;
}
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("unsupported alter action `{line}`"),
});
}
Ok(actions)
}
fn parse_column(line: &str, path: &Path) -> Result<Column, Error> {
let tokens = tokenize(line);
if tokens.is_empty() {
return Err(invalid_column(path, line, "missing column name"));
}
if tokens.len() == 1 {
let name = tokens[0].clone();
let ty = infer_implicit_column_type(&name)
.ok_or_else(|| invalid_column(path, line, "missing column type"))?;
return Ok(Column {
name,
ty,
nullable: Nullability::Implicit,
primary: false,
unique: false,
index: false,
default: None,
reference: None,
});
}
if tokens.len() < 2 {
return Err(invalid_column(path, line, "missing column type"));
}
let name = tokens[0].clone();
let mut index = 1usize;
let mut ty = ColumnType::Implicit;
let mut primary = false;
let mut unique = false;
let mut nullable = Nullability::Implicit;
let mut default = None;
let mut reference = None;
let indexed = false;
let mut no_index = false;
match tokens[index].as_str() {
"primary" => {
primary = true;
index += 1;
}
"ref" => {
let target = tokens.get(index + 1).ok_or_else(|| {
invalid_column(path, line, "missing reference target after `ref`")
})?;
reference = Some(parse_reference_target(target));
index += 2;
}
raw => {
ty = parse_type(raw, path, line)?;
index += 1;
if let Some("ref") = tokens.get(index).map(String::as_str) {
let target = tokens.get(index + 1).ok_or_else(|| {
invalid_column(path, line, "missing reference target after `ref`")
})?;
reference = Some(parse_reference_target(target));
index += 2;
}
}
}
while index < tokens.len() {
match tokens[index].as_str() {
"primary" => {
primary = true;
index += 1;
}
"unique" => {
unique = true;
index += 1;
}
"nullable" => {
nullable = Nullability::Nullable;
index += 1;
}
"no" if tokens.get(index + 1).map(String::as_str) == Some("index") => {
no_index = true;
index += 2;
}
"default" => {
let value = tokens
.get(index + 1)
.ok_or_else(|| invalid_column(path, line, "missing default value"))?;
default = Some(value.clone());
index += 2;
}
"on" if tokens.get(index + 1).map(String::as_str) == Some("delete") => {
let reference_value = reference
.as_mut()
.ok_or_else(|| invalid_column(path, line, "`on delete` requires `ref`"))?;
let (action, consumed) =
parse_reference_action_tokens(&tokens[index + 2..], path, line)?;
reference_value.on_delete = Some(action);
index += 2 + consumed;
}
"on" if tokens.get(index + 1).map(String::as_str) == Some("update") => {
let reference_value = reference
.as_mut()
.ok_or_else(|| invalid_column(path, line, "`on update` requires `ref`"))?;
let (action, consumed) =
parse_reference_action_tokens(&tokens[index + 2..], path, line)?;
reference_value.on_update = Some(action);
index += 2 + consumed;
}
other => {
return Err(invalid_column(
path,
line,
&format!("unsupported column modifier `{other}`"),
));
}
}
}
Ok(Column {
name,
ty,
nullable,
primary,
unique,
index: if no_index {
false
} else {
indexed || reference.is_some()
},
default,
reference,
})
}
fn infer_implicit_column_type(name: &str) -> Option<ColumnType> {
match name {
"id" => Some(ColumnType::Id),
"remember_token" => Some(ColumnType::RememberToken),
_ => None,
}
}
fn parse_type(value: &str, path: &Path, line: &str) -> Result<ColumnType, Error> {
let value = value.trim();
Ok(match value {
"id" => ColumnType::Id,
"string" => ColumnType::String,
"text" => ColumnType::Text,
"int" | "integer" => ColumnType::Integer,
"bigint" => ColumnType::BigInt,
"bool" | "boolean" => ColumnType::Boolean,
"date" => ColumnType::Date,
"time" => ColumnType::Time,
"datetime" => ColumnType::DateTime,
"timestamp" => ColumnType::Timestamp,
"timestamptz" => ColumnType::TimestampTz,
"json" | "jsonb" => ColumnType::Json,
"uuid" | "ulid" => ColumnType::Uuid,
"remember_token" => ColumnType::RememberToken,
"float" => ColumnType::Float,
"double" => ColumnType::Double,
raw if raw.starts_with("string(") && raw.ends_with(')') => {
let inner = &raw["string(".len()..raw.len() - 1];
let length = inner
.trim()
.parse::<u32>()
.map_err(|_| invalid_column(path, line, "invalid string length"))?;
ColumnType::Varchar(length)
}
raw if raw.starts_with("varchar(") && raw.ends_with(')') => {
let inner = &raw["varchar(".len()..raw.len() - 1];
let length = inner
.trim()
.parse::<u32>()
.map_err(|_| invalid_column(path, line, "invalid varchar length"))?;
ColumnType::Varchar(length)
}
raw if raw.starts_with("decimal(") && raw.ends_with(')') => {
let inner = &raw["decimal(".len()..raw.len() - 1];
let mut parts = inner.split(',').map(str::trim);
let precision = parts
.next()
.ok_or_else(|| invalid_column(path, line, "invalid decimal precision"))?
.parse::<u32>()
.map_err(|_| invalid_column(path, line, "invalid decimal precision"))?;
let scale = parts
.next()
.ok_or_else(|| invalid_column(path, line, "invalid decimal scale"))?
.parse::<u32>()
.map_err(|_| invalid_column(path, line, "invalid decimal scale"))?;
ColumnType::Decimal(precision, scale)
}
raw if is_identifier(raw) => ColumnType::Custom(raw.to_owned()),
other => {
return Err(invalid_column(
path,
line,
&format!("unsupported column type `{other}`"),
));
}
})
}
fn parse_reference_target(target: &str) -> Reference {
let (table, column) = target
.split_once('.')
.map_or((target, None), |(table, column)| {
(table, Some(column.to_owned()))
});
Reference {
table: table.to_owned(),
column,
on_delete: None,
on_update: None,
}
}
fn parse_reference_action_tokens(
tokens: &[String],
path: &Path,
line: &str,
) -> Result<(ReferenceAction, usize), Error> {
match tokens {
[value, ..] if value == "cascade" => Ok((ReferenceAction::Cascade, 1)),
[value, ..] if value == "restrict" => Ok((ReferenceAction::Restrict, 1)),
[first, second, ..] if first == "set" && second == "null" => {
Ok((ReferenceAction::SetNull, 2))
}
[first, second, ..] if first == "no" && second == "action" => {
Ok((ReferenceAction::NoAction, 2))
}
[value, ..] if value == "set_null" => Ok((ReferenceAction::SetNull, 1)),
[value, ..] if value == "no_action" => Ok((ReferenceAction::NoAction, 1)),
[other, ..] => Err(invalid_column(
path,
line,
&format!("unsupported reference action `{other}`"),
)),
[] => Err(invalid_column(path, line, "missing reference action")),
}
}
fn parse_column_list(value: &str, path: &Path) -> Result<Vec<String>, Error> {
let inner = value
.trim()
.strip_prefix('(')
.and_then(|value| value.strip_suffix(')'))
.ok_or_else(|| Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid column list `{value}`"),
})?;
let columns = inner
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.collect::<Vec<_>>();
if columns.is_empty() {
return Err(Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid column list `{value}`"),
});
}
Ok(columns)
}
fn invalid_column(path: &Path, line: &str, message: &str) -> Error {
Error::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("{message} in `{line}`"),
}
}
fn tokenize(line: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut quote = false;
let mut paren_depth = 0usize;
let mut prev = '\0';
for ch in line.chars() {
match ch {
'"' if prev != '\\' => {
quote = !quote;
current.push(ch);
}
'(' if !quote => {
paren_depth += 1;
current.push(ch);
}
')' if !quote => {
paren_depth = paren_depth.saturating_sub(1);
current.push(ch);
}
c if c.is_whitespace() && !quote && paren_depth == 0 => {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
prev = ch;
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn is_identifier(value: &str) -> bool {
let mut chars = value.chars();
match chars.next() {
Some(ch) if ch.is_ascii_alphabetic() || ch == '_' => {}
_ => return false,
}
chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
#[cfg(test)]
mod tests {
use super::*;
fn path(name: &str) -> PathBuf {
PathBuf::from(name)
}
#[test]
fn parses_filename_derived_lift_migration() {
let file = parse_source(
path("202604210001_create_users.lift"),
202604210001,
"create_users".to_owned(),
"202604210001_create_users.lift",
r#"
create users {
id primary
email text unique
tenant_id uuid
index (tenant_id)
unique (tenant_id, email)
}
down {
drop users
}
"#,
)
.unwrap();
assert_eq!(file.up.len(), 1);
assert_eq!(file.down.len(), 1);
match &file.up[0] {
Statement::CreateTable { name, items } => {
assert_eq!(name, "users");
assert!(matches!(items[0], TableItem::Column(_)));
assert!(matches!(items[3], TableItem::Index { .. }));
assert!(matches!(items[4], TableItem::Unique(_)));
}
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn parses_alter_and_refs() {
let file = parse_source(
path("202604210002_create_posts.lift"),
202604210002,
"create_posts".to_owned(),
"202604210002_create_posts.lift",
r#"
create posts {
id primary
author_id ref users on delete cascade
title text
index (author_id)
}
alter posts {
add column excerpt text nullable
rename column title to headline
add index (headline)
drop index posts_legacy_idx
}
"#,
)
.unwrap();
match &file.up[0] {
Statement::CreateTable { items, .. } => match &items[1] {
TableItem::Column(column) => {
assert!(column.reference.is_some());
assert_eq!(
column.reference.as_ref().unwrap().on_delete,
Some(ReferenceAction::Cascade)
);
}
other => panic!("unexpected table item: {other:?}"),
},
other => panic!("unexpected statement: {other:?}"),
}
match &file.up[1] {
Statement::AlterTable { actions, .. } => {
assert!(matches!(actions[0], AlterAction::AddColumn(_)));
assert!(matches!(actions[1], AlterAction::RenameColumn { .. }));
assert!(matches!(actions[2], AlterAction::AddIndex { .. }));
assert!(matches!(actions[3], AlterAction::DropIndex(_)));
}
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn parses_set_null_and_named_index() {
let file = parse_source(
path("202604210004_categories.lift"),
202604210004,
"categories".to_owned(),
"202604210004_categories.lift",
r#"
create products {
id primary
category_id ref categories nullable on delete set null
index products_category_idx (category_id)
}
"#,
)
.unwrap();
match &file.up[0] {
Statement::CreateTable { items, .. } => {
match &items[1] {
TableItem::Column(column) => {
assert_eq!(column.nullable, Nullability::Nullable);
assert_eq!(
column.reference.as_ref().unwrap().on_delete,
Some(ReferenceAction::SetNull)
);
}
other => panic!("unexpected table item: {other:?}"),
}
match &items[2] {
TableItem::Index { name, columns } => {
assert_eq!(name.as_deref(), Some("products_category_idx"));
assert_eq!(columns, &vec!["category_id".to_owned()]);
}
other => panic!("unexpected table item: {other:?}"),
}
}
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn parses_relation_no_index_override() {
let file = parse_source(
path("202604210011_posts.lift"),
202604210011,
"posts".to_owned(),
"202604210011_posts.lift",
r#"
create posts {
id primary
author_id ref users no index
}
"#,
)
.unwrap();
match &file.up[0] {
Statement::CreateTable { items, .. } => match &items[1] {
TableItem::Column(column) => {
assert!(column.reference.is_some());
assert!(!column.index);
}
other => panic!("unexpected table item: {other:?}"),
},
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn infers_remember_token_without_duplicate_type() {
let file = parse_source(
path("202604210010_users.lift"),
202604210010,
"users".to_owned(),
"202604210010_users.lift",
r#"
create users {
id primary
remember_token
}
"#,
)
.unwrap();
match &file.up[0] {
Statement::CreateTable { items, .. } => match &items[1] {
TableItem::Column(column) => {
assert_eq!(column.name, "remember_token");
assert_eq!(column.ty, ColumnType::RememberToken);
}
other => panic!("unexpected table item: {other:?}"),
},
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn infers_id_without_duplicate_primary_helper() {
let file = parse_source(
path("202604210013_users.lift"),
202604210013,
"users".to_owned(),
"202604210013_users.lift",
r#"
create users {
id
name string
}
"#,
)
.unwrap();
match &file.up[0] {
Statement::CreateTable { items, .. } => match &items[0] {
TableItem::Column(column) => {
assert_eq!(column.name, "id");
assert_eq!(column.ty, ColumnType::Id);
}
other => panic!("unexpected table item: {other:?}"),
},
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn parses_enum_and_composite_primary_syntax() {
let file = parse_source(
path("202604210003_statuses.lift"),
202604210003,
"statuses".to_owned(),
"202604210003_statuses.lift",
r#"
create enum post_status { draft, published, archived }
rename enum post_status to article_status
create memberships {
user_id uuid
org_id uuid
primary (user_id, org_id)
constraint memberships_user_org_unique unique (user_id, org_id)
}
"#,
)
.unwrap();
assert!(matches!(file.up[0], Statement::CreateEnum { .. }));
assert!(matches!(file.up[1], Statement::RenameEnum { .. }));
match &file.up[2] {
Statement::CreateTable { items, .. } => {
assert!(matches!(items[2], TableItem::Primary(_)));
assert!(matches!(items[3], TableItem::ConstraintUnique { .. }));
}
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn parses_inline_sql_blocks() {
let file = parse_source(
path("202604210012_enable_feature.lift"),
202604210012,
"enable_feature".to_owned(),
"202604210012_enable_feature.lift",
r#"
sql """
create table logs (
id integer primary key,
payload text not null
);
"""
down {
sql """
drop table logs;
"""
}
"#,
)
.unwrap();
assert_eq!(
file.up,
vec![Statement::Sql {
sql: "create table logs (\n id integer primary key,\n payload text not null\n);"
.to_owned()
}]
);
assert_eq!(
file.down,
vec![Statement::Sql {
sql: "drop table logs;".to_owned()
}]
);
}
#[test]
fn parses_backfill_blocks_in_up_and_down() {
let file = parse_source(
path("202604210018_backfill_users.lift"),
202604210018,
"backfill_users".to_owned(),
"202604210018_backfill_users.lift",
r#"
alter users {
add column normalized_email text nullable
}
backfill {
update users
set normalized_email = lower(email)
where normalized_email is null;
}
down {
backfill {
update users
set normalized_email = null;
}
}
"#,
)
.unwrap();
assert!(matches!(file.up[0], Statement::AlterTable { .. }));
assert_eq!(
file.up[1],
Statement::Backfill {
sql: " update users\n set normalized_email = lower(email)\n where normalized_email is null;"
.to_owned()
}
);
assert_eq!(
file.down[0],
Statement::Backfill {
sql: " update users\n set normalized_email = null;".to_owned()
}
);
}
#[test]
fn rejects_empty_backfill_blocks() {
let err = parse_source(
path("202604210019_empty_backfill.lift"),
202604210019,
"empty_backfill".to_owned(),
"202604210019_empty_backfill.lift",
r#"
backfill {
}
"#,
)
.err()
.unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
}
#[test]
fn accepts_optional_down_block() {
let file = parse_source(
path("202604210005_add_timezone.lift"),
202604210005,
"add_timezone".to_owned(),
"202604210005_add_timezone.lift",
r#"
alter users {
add column timezone text default "UTC"
}
"#,
)
.unwrap();
assert_eq!(file.down, Vec::<Statement>::new());
assert!(matches!(file.up[0], Statement::AlterTable { .. }));
}
#[test]
fn rejects_multiple_down_blocks() {
let err = parse_source(
path("202604210006_bad.lift"),
202604210006,
"bad".to_owned(),
"202604210006_bad.lift",
r#"
drop logs
down {
create logs {
id primary
}
}
down {
drop logs
}
"#,
)
.err()
.unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
}
#[test]
fn rejects_bracket_column_lists() {
let err = parse_source(
path("202604210007_bad_index.lift"),
202604210007,
"bad_index".to_owned(),
"202604210007_bad_index.lift",
r#"
create users {
id primary
index [email]
}
"#,
)
.err()
.unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
}
#[test]
fn rejects_arrow_rename_syntax() {
let err = parse_source(
path("202604210008_bad_rename.lift"),
202604210008,
"bad_rename".to_owned(),
"202604210008_bad_rename.lift",
r#"
rename enum post_status -> article_status
"#,
)
.err()
.unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
}
#[test]
fn rejects_explicit_not_null_modifier() {
let err = parse_source(
path("202604210012_bad_not_null.lift"),
202604210012,
"bad_not_null".to_owned(),
"202604210012_bad_not_null.lift",
r#"
create users {
email text not null
}
"#,
)
.err()
.unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
}
#[test]
fn rejects_duplicate_create_table_in_same_direction() {
let err = parse_source(
path("202604210014_duplicate_users.lift"),
202604210014,
"duplicate_users".to_owned(),
"202604210014_duplicate_users.lift",
r#"
create users {
id
}
create users {
}
"#,
)
.err()
.unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
}
#[test]
fn rejects_duplicate_create_enum_in_same_direction() {
let err = parse_source(
path("202604210015_duplicate_status.lift"),
202604210015,
"duplicate_status".to_owned(),
"202604210015_duplicate_status.lift",
r#"
create enum post_status { draft, published }
create enum post_status { archived }
"#,
)
.err()
.unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
}
#[test]
fn rejects_legacy_table_keyword_syntax() {
let err = parse_source(
path("202604210016_legacy_table_keyword.lift"),
202604210016,
"legacy_table_keyword".to_owned(),
"202604210016_legacy_table_keyword.lift",
r#"
create table users {
id
}
"#,
)
.err()
.unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
}
#[test]
fn tokenizes_decimal_and_quoted_defaults() {
let file = parse_source(
path("202604210009_prices.lift"),
202604210009,
"prices".to_owned(),
"202604210009_prices.lift",
r#"
create prices {
amount decimal(10, 2)
timezone text default "UTC"
}
"#,
)
.unwrap();
match &file.up[0] {
Statement::CreateTable { items, .. } => {
match &items[0] {
TableItem::Column(column) => {
assert_eq!(column.ty, ColumnType::Decimal(10, 2));
}
other => panic!("unexpected table item: {other:?}"),
}
match &items[1] {
TableItem::Column(column) => {
assert_eq!(column.default.as_deref(), Some("\"UTC\""));
}
other => panic!("unexpected table item: {other:?}"),
}
}
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn parses_string_type_variants() {
let file = parse_source(
path("202604210017_strings.lift"),
202604210017,
"strings".to_owned(),
"202604210017_strings.lift",
r#"
create users {
name string
handle string(128)
bio text
}
"#,
)
.unwrap();
match &file.up[0] {
Statement::CreateTable { items, .. } => {
match &items[0] {
TableItem::Column(column) => {
assert_eq!(column.ty, ColumnType::String);
}
other => panic!("unexpected table item: {other:?}"),
}
match &items[1] {
TableItem::Column(column) => {
assert_eq!(column.ty, ColumnType::Varchar(128));
}
other => panic!("unexpected table item: {other:?}"),
}
match &items[2] {
TableItem::Column(column) => {
assert_eq!(column.ty, ColumnType::Text);
}
other => panic!("unexpected table item: {other:?}"),
}
}
other => panic!("unexpected statement: {other:?}"),
}
}
#[test]
fn loads_sql_migration_pair_as_one_unit() {
let mut dir = std::env::temp_dir();
dir.push(format!(
"lift-sql-pair-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("20260422_enable_pgcrypto.sql"),
"create table features (id integer primary key);",
)
.unwrap();
std::fs::write(
dir.join("20260422_enable_pgcrypto.down.sql"),
"drop table features;",
)
.unwrap();
let migrations = load_migrations(&dir).unwrap();
assert_eq!(migrations.len(), 1);
assert_eq!(migrations[0].id, 20260422);
assert_eq!(migrations[0].name, "enable_pgcrypto");
assert!(matches!(migrations[0].up[0], Statement::Sql { .. }));
assert!(matches!(migrations[0].down[0], Statement::Sql { .. }));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn loads_forward_only_sql_migration() {
let mut dir = std::env::temp_dir();
dir.push(format!(
"lift-sql-forward-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("20260423_create_flags.sql"),
"create table flags (id integer primary key);",
)
.unwrap();
let migrations = load_migrations(&dir).unwrap();
assert_eq!(migrations.len(), 1);
assert!(migrations[0].down.is_empty());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn rejects_sql_down_without_forward_pair() {
let mut dir = std::env::temp_dir();
dir.push(format!(
"lift-sql-down-only-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("20260424_create_logs.down.sql"),
"drop table logs;",
)
.unwrap();
let err = load_migrations(&dir).err().unwrap();
assert!(matches!(err, Error::InvalidMigrationFile { .. }));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn accepts_optional_semicolons_on_lift_statements() {
let file = parse_source(
path("202604210013_semicolons.lift"),
202604210013,
"semicolons".to_owned(),
"202604210013_semicolons.lift",
r#"
create users {
id primary;
email text unique;
profile_id ref profiles;
index (profile_id);
};
alter users {
add column timezone text default "UTC";
rename column email to login;
};
rename users to accounts;
down {
drop accounts;
};
"#,
)
.unwrap();
assert!(matches!(file.up[0], Statement::CreateTable { .. }));
assert!(matches!(file.up[1], Statement::AlterTable { .. }));
assert!(matches!(file.up[2], Statement::RenameTable { .. }));
assert!(matches!(file.down[0], Statement::DropTable { .. }));
}
}