use std::{
collections::{HashSet, hash_map::DefaultHasher},
fs,
hash::{Hash, Hasher},
path::{Path, PathBuf},
};
use crate::MigrationError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) enum Nullability {
Implicit,
Nullable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) 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(crate) enum ReferenceAction {
Cascade,
Restrict,
SetNull,
NoAction,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) 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(crate) enum AlterEnumAction {
AddValue(String),
}
pub(crate) use self::{Column as Field, ColumnType as FieldType, Statement as MigrationOp};
pub(crate) fn default_migration_dir() -> PathBuf {
PathBuf::from("migrations")
}
pub(crate) fn load_migrations(dir: &Path) -> Result<Vec<MigrationFile>, MigrationError> {
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| MigrationError::MigrationDirectory {
path: dir.to_path_buf(),
source,
})?;
for entry in read_dir {
let entry = entry.map_err(MigrationError::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(MigrationError::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(MigrationError::InvalidMigrationFile {
path,
message: "duplicate forward sql migration".to_owned(),
});
}
}
SqlFileKind::Down => {
if parts.down.replace((path.clone(), source)).is_some() {
return Err(MigrationError::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(|| MigrationError::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(|| MigrationError::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(MigrationError::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,
}
type StatementLines = Vec<String>;
type SplitLines = (StatementLines, StatementLines);
enum SqlFileKind {
Up,
Down,
}
fn parse_migration_file(path: &Path) -> Result<MigrationFile, MigrationError> {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| MigrationError::InvalidMigrationFilename(path.to_path_buf()))?;
let (id, name) = parse_filename(file_name, path)?;
let source = fs::read_to_string(path).map_err(MigrationError::Io)?;
parse_source(path.to_path_buf(), id, name, file_name, &source)
}
fn parse_source(
path: PathBuf,
id: u64,
name: String,
file_name: &str,
source: &str,
) -> Result<MigrationFile, MigrationError> {
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<(), MigrationError> {
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(MigrationError::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(MigrationError::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), MigrationError> {
let stem = file_name
.strip_suffix(".lift")
.ok_or_else(|| MigrationError::InvalidMigrationFilename(path.to_path_buf()))?;
let (id, name) = stem
.split_once('_')
.ok_or_else(|| MigrationError::InvalidMigrationFilename(path.to_path_buf()))?;
let id = id
.parse::<u64>()
.map_err(|_| MigrationError::InvalidMigrationFilename(path.to_path_buf()))?;
if name.is_empty() {
return Err(MigrationError::InvalidMigrationFilename(path.to_path_buf()));
}
Ok((id, name.to_owned()))
}
fn parse_sql_filename(file_name: &str, path: &Path) -> Result<ParsedSqlFilename, MigrationError> {
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(MigrationError::InvalidMigrationFilename(path.to_path_buf()));
};
let (id, name) = stem
.split_once('_')
.ok_or_else(|| MigrationError::InvalidMigrationFilename(path.to_path_buf()))?;
let id = id
.parse::<u64>()
.map_err(|_| MigrationError::InvalidMigrationFilename(path.to_path_buf()))?;
if name.is_empty() {
return Err(MigrationError::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<SplitLines, MigrationError> {
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(MigrationError::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(MigrationError::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>, MigrationError> {
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(MigrationError::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(MigrationError::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, MigrationError> {
let name = value.trim();
if is_identifier(name) {
return Ok(name.to_owned());
}
Err(MigrationError::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid `{statement}` statement `{value}`"),
})
}
fn parse_create_enum(rest: &str, path: &Path) -> Result<Statement, MigrationError> {
let (name, raw_values) = rest
.split_once('{')
.and_then(|(name, values)| values.strip_suffix('}').map(|values| (name.trim(), values)))
.ok_or_else(|| MigrationError::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(MigrationError::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>, MigrationError> {
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(MigrationError::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), MigrationError> {
let (from, to) =
rest.split_once(" to ")
.ok_or_else(|| MigrationError::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(MigrationError::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), MigrationError> {
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(MigrationError::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), MigrationError> {
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(MigrationError::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), MigrationError> {
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(MigrationError::InvalidMigrationFile {
path: path.to_path_buf(),
message: "unterminated `backfill` block".to_owned(),
})
}
fn parse_table_items(lines: &[String], path: &Path) -> Result<Vec<TableItem>, MigrationError> {
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, MigrationError> {
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(MigrationError::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid index declaration `index {value}`"),
}),
}
}
fn parse_constraint(value: &str, path: &Path) -> Result<TableItem, MigrationError> {
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(MigrationError::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid constraint declaration `constraint {value}`"),
}),
}
}
fn parse_alter_actions(lines: &[String], path: &Path) -> Result<Vec<AlterAction>, MigrationError> {
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(MigrationError::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("unsupported alter action `{line}`"),
});
}
Ok(actions)
}
fn parse_column(line: &str, path: &Path) -> Result<Column, MigrationError> {
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,
});
}
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, MigrationError> {
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), MigrationError> {
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>, MigrationError> {
let inner = value
.trim()
.strip_prefix('(')
.and_then(|value| value.strip_suffix(')'))
.ok_or_else(|| MigrationError::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(MigrationError::InvalidMigrationFile {
path: path.to_path_buf(),
message: format!("invalid column list `{value}`"),
});
}
Ok(columns)
}
fn invalid_column(path: &Path, line: &str, message: &str) -> MigrationError {
MigrationError::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 == '_')
}