use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::def::TableDef;
use super::def::validate_object_name;
use super::error::SchemaError;
use super::properties::IndexMode;
use crate::qbe::QueryDef;
pub const SCHEMA_VERSION: u32 = 1;
const FIRST_VERSION: u32 = 1;
type Migration = fn(&mut serde_json::Value);
const MIGRATIONS: &[Migration] = &[];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SchemaDocument {
pub schema_version: u32,
pub database_name: String,
pub tables: Vec<TableDef>,
#[serde(default)]
pub queries: Vec<QueryDef>,
#[serde(default)]
pub forms: Vec<crate::form::FormDef>,
#[serde(default)]
pub reports: Vec<crate::report::ReportDef>,
#[serde(default)]
pub views: Vec<crate::view::ViewDef>,
}
impl SchemaDocument {
pub fn new(database_name: impl Into<String>) -> Self {
Self {
schema_version: SCHEMA_VERSION,
database_name: database_name.into(),
tables: Vec::new(),
queries: Vec::new(),
forms: Vec::new(),
reports: Vec::new(),
views: Vec::new(),
}
}
pub fn with_tables(mut self, tables: Vec<TableDef>) -> Self {
self.tables = tables;
self
}
pub fn validate(&self) -> Result<(), SchemaError> {
validate_object_name(&self.database_name)?;
let mut seen: Vec<String> = Vec::with_capacity(self.tables.len());
for table in &self.tables {
table.validate()?;
let key = table.name.to_lowercase();
if seen.contains(&key) {
return Err(SchemaError::DuplicateName {
name: table.name.clone(),
});
}
seen.push(key);
}
let mut view_names: Vec<String> = Vec::with_capacity(self.views.len());
for view in &self.views {
validate_object_name(&view.name)?;
let key = view.name.to_lowercase();
if seen.contains(&key) || view_names.contains(&key) {
return Err(SchemaError::DuplicateName {
name: view.name.clone(),
});
}
view_names.push(key);
}
Ok(())
}
pub fn to_json(&self) -> String {
serde_json::to_string_pretty(self).expect("SchemaDocument serialization is infallible")
}
pub fn from_json(json: &str) -> Result<Self, SchemaError> {
let mut value: serde_json::Value =
serde_json::from_str(json).map_err(|e| SchemaError::Parse {
detail: e.to_string(),
})?;
let found = value
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| SchemaError::Parse {
detail: "missing or non-numeric schema_version field".to_owned(),
})?;
migrate_with_ladder(MIGRATIONS, SCHEMA_VERSION, &mut value, found)?;
serde_json::from_value(value).map_err(|e| SchemaError::Parse {
detail: e.to_string(),
})
}
pub fn sidecar_path(database: &Path) -> PathBuf {
let mut path = database.to_path_buf();
path.set_extension("datacraft");
path
}
pub fn apply_table_properties(&self, tables: &mut [TableDef]) -> Result<(), SchemaError> {
for source in &self.tables {
let Some(target) = tables
.iter_mut()
.find(|table| table.name.eq_ignore_ascii_case(&source.name))
else {
return Err(SchemaError::InvalidProperty {
detail: format!(
"the sidecar table {:?} is not in the database — properties were not applied",
source.name
),
});
};
for source_field in &source.fields {
let Some(target_field) = target
.fields
.iter_mut()
.find(|field| field.name.eq_ignore_ascii_case(&source_field.name))
else {
return Err(SchemaError::InvalidProperty {
detail: format!(
"the sidecar field {:?}.{:?} is not in the database — properties were not applied",
source.name, source_field.name
),
});
};
let physical_indexed = target_field.properties.indexed;
target_field.properties = source_field.properties.clone();
if physical_indexed != IndexMode::None {
target_field.properties.indexed = physical_indexed;
}
}
target.validate()?;
}
Ok(())
}
pub fn merge_field_properties(target: &mut [TableDef], source: &[TableDef]) {
for source_table in source {
let Some(target_table) = target
.iter_mut()
.find(|table| table.name.eq_ignore_ascii_case(&source_table.name))
else {
continue;
};
for source_field in &source_table.fields {
if let Some(target_field) = target_table
.fields
.iter_mut()
.find(|field| field.name.eq_ignore_ascii_case(&source_field.name))
{
let physical_indexed = target_field.properties.indexed;
target_field.properties = source_field.properties.clone();
if physical_indexed != IndexMode::None {
target_field.properties.indexed = physical_indexed;
}
}
}
}
}
pub fn save(&self, path: &Path) -> Result<(), SchemaError> {
crate::atomic_write::write_replacing(path, self.to_json().as_bytes())?;
Ok(())
}
pub fn load(path: &Path) -> Result<Self, SchemaError> {
let json = std::fs::read_to_string(path)?;
Self::from_json(&json)
}
}
fn migrate_with_ladder(
ladder: &[Migration],
target: u32,
value: &mut serde_json::Value,
found: u64,
) -> Result<(), SchemaError> {
let supported = u64::from(target);
if found < u64::from(FIRST_VERSION) || found > supported {
return Err(SchemaError::UnsupportedVersion { found, supported });
}
let mut version = found as u32;
while version < target {
let step = ladder
.get((version - FIRST_VERSION) as usize)
.copied()
.ok_or(SchemaError::UnsupportedVersion {
found: u64::from(version),
supported,
})?;
step(value);
version += 1;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ladder_is_identity_for_v1_and_strict_around_it() {
let mut value = serde_json::json!({ "schema_version": 1 });
migrate_with_ladder(MIGRATIONS, SCHEMA_VERSION, &mut value, 1).unwrap();
assert_eq!(value["schema_version"], 1);
for found in [0u64, 2, 99, u64::from(u32::MAX) + 1] {
let mut value = serde_json::json!({ "schema_version": found });
let err = migrate_with_ladder(MIGRATIONS, SCHEMA_VERSION, &mut value, found)
.expect_err("ladder must reject versions without a step");
assert!(
err.to_string().contains(&found.to_string()),
"error must name the found version {found}: {err}"
);
}
}
fn bump(value: &mut serde_json::Value) {
let current = value
.get("schema_version")
.and_then(serde_json::Value::as_u64);
if let Some(v) = current {
value["schema_version"] = serde_json::json!(v + 1);
}
}
#[test]
fn ladder_applies_steps_in_order_up_to_the_target() {
let ladder: &[Migration] = &[bump, bump];
let mut value = serde_json::json!({ "schema_version": 1 });
migrate_with_ladder(ladder, 3, &mut value, 1).unwrap();
assert_eq!(value["schema_version"], 3);
}
#[test]
fn ladder_refuses_to_walk_past_its_last_step() {
let ladder: &[Migration] = &[bump];
let mut value = serde_json::json!({ "schema_version": 1 });
let err = migrate_with_ladder(ladder, 3, &mut value, 1)
.expect_err("missing step must be refused");
let msg = err.to_string();
assert!(msg.contains('2'), "must name the stuck version: {msg}");
assert!(msg.contains("3"), "must name the supported target: {msg}");
}
}