use std::{
collections::{HashMap, HashSet},
path::Path,
};
use serde_json::Value;
use yaml_rust2::{Yaml, YamlLoader};
use crate::{names::pluralize, output::CliError};
pub const FIXTURES: &str = "db/fixtures";
const DEFAULTS: &str = "DEFAULTS";
pub fn to_sql(project_root: &Path, dir: &str) -> Result<String, CliError> {
Ok(load(project_root, dir)?.sql)
}
pub struct Fixtures {
pub tables: Vec<String>,
pub sql: String,
}
pub fn load(project_root: &Path, dir: &str) -> Result<Fixtures, CliError> {
let path = project_root.join(dir);
if !path.is_dir() {
return Ok(Fixtures { tables: Vec::new(), sql: String::new() });
}
let mut files: Vec<(String, String)> = Vec::new();
for entry in std::fs::read_dir(&path)? {
let name = entry?.file_name().to_string_lossy().into_owned();
if let Some((table, "yml" | "yaml" | "json")) = name.rsplit_once('.') {
files.push((name.clone(), table.to_owned()));
}
}
files.sort();
let mut tables = Vec::new();
for (name, table) in files {
let file = format!("{dir}/{name}");
let source = std::fs::read_to_string(path.join(&name))?;
tables.push(parse(&file, table, &source)?);
}
let sql = if tables.is_empty() { String::new() } else { render(&tables, &foreign_keys(project_root)?)? };
Ok(Fixtures { tables: tables.into_iter().map(|table| table.name).collect(), sql })
}
pub fn identify(label: &str) -> u32 {
crc32(label.as_bytes()) % ((1 << 30) - 1)
}
fn crc32(bytes: &[u8]) -> u32 {
let mut crc = !0u32;
for &byte in bytes {
crc ^= u32::from(byte);
for _ in 0..8 {
crc = if crc & 1 == 1 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
}
}
!crc
}
struct Table {
name: String,
file: String,
rows: Vec<Row>,
}
struct Row {
label: String,
id: String,
columns: Vec<(String, Yaml)>,
}
fn parse(file: &str, table: String, source: &str) -> Result<Table, CliError> {
let docs = YamlLoader::load_from_str(source).map_err(|err| {
CliError::new(format!("{file}: invalid YAML: {err}"))
.hint("a fixture file maps labels to rows of `column: value` (YAML or JSON)")
})?;
let mut rows = Vec::new();
let top = match docs.into_iter().next() {
None | Some(Yaml::Null) => return Ok(Table { name: table, file: file.to_owned(), rows }),
Some(Yaml::Hash(top)) => top,
Some(_) => {
return Err(CliError::new(format!("{file}: expected a mapping of labels to rows"))
.hint("write one `label:` per row, its columns indented below it"));
}
};
for (label, value) in top {
let Yaml::String(label) = label else {
return Err(CliError::new(format!("{file}: labels must be strings, found {label:?}"))
.hint("quote the label, e.g. `\"1\":`"));
};
if label == DEFAULTS {
continue;
}
let at = |err: CliError| CliError { message: format!("{file}: `{label}`: {}", err.message), hint: err.hint };
let mut id = identify(&label).to_string();
let mut columns = Vec::new();
for (column, value) in row_columns(value).map_err(at)? {
let value = match value {
Yaml::String(text) => Yaml::String(text.replace("$LABEL", &label)),
other => other,
};
if column == "id" {
id = sql_value(&value).map_err(at)?;
} else {
columns.push((column, value));
}
}
rows.push(Row { label, id, columns });
}
Ok(Table { name: table, file: file.to_owned(), rows })
}
fn row_columns(row: Yaml) -> Result<Vec<(String, Yaml)>, CliError> {
let hash = match row {
Yaml::Null => return Ok(Vec::new()),
Yaml::Hash(hash) => hash,
_ => {
return Err(
CliError::new("expected a mapping of `column: value`").hint("indent the row's columns below its label")
);
}
};
let mut own = Vec::new();
let mut merged = Vec::new();
for (column, value) in hash {
let Yaml::String(column) = column else {
return Err(
CliError::new(format!("column names must be strings, found {column:?}")).hint("quote the column name")
);
};
if column != "<<" {
own.push((column, value));
continue;
}
let sources = match value {
Yaml::Array(sources) => sources,
source => vec![source],
};
for source in sources {
if !matches!(source, Yaml::Hash(_)) {
return Err(CliError::new("`<<` merges a mapping or a list of mappings")
.hint("merge an anchored row, e.g. `<<: *defaults`"));
}
merged.extend(row_columns(source)?);
}
}
for (column, value) in merged {
if !own.iter().any(|(name, _)| *name == column) {
own.push((column, value));
}
}
Ok(own)
}
fn foreign_keys(project_root: &Path) -> Result<HashSet<String>, CliError> {
let mut keys = HashSet::new();
let Ok(entries) = std::fs::read_dir(project_root.join("migrations")) else {
return Ok(keys);
};
for entry in entries {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "sql") {
let sql = std::fs::read_to_string(&path)?;
let words = sql.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'));
keys.extend(words.filter(|word| word.ends_with("_id")).map(str::to_owned));
}
}
Ok(keys)
}
fn render(tables: &[Table], foreign_keys: &HashSet<String>) -> Result<String, CliError> {
let mut labels: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
for table in tables {
for row in &table.rows {
labels.entry(&row.label).or_default().push((&table.name, &row.id));
}
}
let mut sql = String::from("PRAGMA defer_foreign_keys = on;\n");
for table in tables {
sql.push_str(&format!("DELETE FROM {};\n", ident(&table.name)));
}
for table in tables {
for row in &table.rows {
let at = |message: String| format!("{}: `{}`: {message}", table.file, row.label);
let mut columns = vec![ident("id")];
let mut values = vec![row.id.clone()];
for (column, value) in &row.columns {
let key = format!("{column}_id");
if !foreign_keys.contains(&key) {
columns.push(ident(column));
values.push(sql_value(value).map_err(|err| CliError { message: at(err.message), ..err })?);
continue;
}
let Yaml::String(target) = value else {
return Err(CliError::new(at(format!("`{column}` names a fixture label (`{key}` is a column)")))
.hint(format!("write `{column}: <label>`, or set `{key}` directly")));
};
let found = labels.get(target.as_str()).map(Vec::as_slice).unwrap_or_default();
let plural = pluralize(column);
let id = match found.iter().find(|(name, _)| *name == plural) {
Some((_, id)) => id,
None => match found {
[(_, id)] => id,
[] => {
return Err(CliError::new(at(format!("no fixture labelled `{target}` for `{column}`")))
.hint(format!("add a `{target}:` row to a fixture file, or set `{key}` to an id")));
}
_ => {
return Err(CliError::new(at(format!(
"`{target}` labels rows of several tables, none of them `{plural}`"
)))
.hint(format!("set `{key}` to the id instead")));
}
},
};
columns.push(ident(&key));
values.push((*id).to_owned());
}
sql.push_str(&format!(
"INSERT INTO {} ({}) VALUES ({});\n",
ident(&table.name),
columns.join(", "),
values.join(", ")
));
}
}
Ok(sql)
}
pub(crate) fn ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn quote(text: &str) -> String {
format!("'{}'", text.replace('\'', "''"))
}
fn sql_value(value: &Yaml) -> Result<String, CliError> {
Ok(match value {
Yaml::String(text) => quote(text),
Yaml::Integer(number) => number.to_string(),
Yaml::Real(number) => number.clone(),
Yaml::Boolean(flag) => u8::from(*flag).to_string(),
Yaml::Null => "NULL".to_owned(),
Yaml::Array(_) | Yaml::Hash(_) => quote(&json(value)?.to_string()),
Yaml::Alias(_) | Yaml::BadValue => return Err(invalid()),
})
}
fn json(value: &Yaml) -> Result<Value, CliError> {
Ok(match value {
Yaml::String(text) => Value::String(text.clone()),
Yaml::Integer(number) => Value::from(*number),
Yaml::Real(number) => number.parse::<f64>().map(Value::from).unwrap_or_else(|_| Value::String(number.clone())),
Yaml::Boolean(flag) => Value::Bool(*flag),
Yaml::Null => Value::Null,
Yaml::Array(items) => Value::Array(items.iter().map(json).collect::<Result<_, _>>()?),
Yaml::Hash(hash) => {
let mut object = serde_json::Map::new();
for (key, item) in hash {
let Yaml::String(key) = key else {
return Err(
CliError::new(format!("JSON keys must be strings, found {key:?}")).hint("quote the key")
);
};
object.insert(key.clone(), json(item)?);
}
Value::Object(object)
}
Yaml::Alias(_) | Yaml::BadValue => return Err(invalid()),
})
}
fn invalid() -> CliError {
CliError::new("invalid value").hint("check the value's `!!tag`, or quote it to store it as text")
}
pub fn to_yaml(table: &str, rows: &[&[(String, Value)]], header: &str) -> String {
let mut out = format!("# {header}\n");
for (index, row) in rows.iter().enumerate() {
let id = row.iter().find(|(column, _)| column == "id").map(|(_, id)| id);
let label = match id {
Some(Value::String(id)) => format!("{table}_{id}"),
Some(id @ Value::Number(_)) => format!("{table}_{id}"),
_ => format!("{table}_{}", index + 1),
};
out.push_str(&format!("{}:\n", key(&label)));
for (column, value) in row.iter() {
out.push_str(&format!(" {}: {value}\n", key(column)));
}
}
out
}
fn key(name: &str) -> String {
let plain = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !["true", "false", "null"].contains(&name.to_ascii_lowercase().as_str());
if plain { name.to_owned() } else { Value::from(name).to_string() }
}
#[cfg(test)]
#[path = "../tests/fixtures.rs"]
mod tests;