41b7e9a049
One-time, mechanical reformat — no functional changes. The tree was not rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock `cargo fmt` defaults so a `cargo fmt --check` CI gate can follow. Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline), clippy clean. A .git-blame-ignore-revs entry follows so `git blame` skips this commit.
1121 lines
38 KiB
Rust
1121 lines
38 KiB
Rust
//! `project.yaml` writer (hand-rolled, ADR-0015 §3) and
|
|
//! reader (`serde_norway`, ADR-0015 §7).
|
|
//!
|
|
//! The schema YAML uses a small, fixed set of structures —
|
|
//! tables, columns, relationships — and the values it carries
|
|
//! are all known-safe (identifiers from the DSL, types from
|
|
//! the fixed `Type` enum, action names from `ReferentialAction`).
|
|
//! Hand-rolling the writer avoids pulling a YAML serializer
|
|
//! dep just for the write path; the read path uses
|
|
//! `serde_norway` because we need to handle whatever the user
|
|
//! (or a future migrator, or a hand-edit) puts in there.
|
|
//
|
|
// `pub(crate)` items in this private submodule are
|
|
// re-exported from `persistence::mod.rs`; that path is what
|
|
// the db worker uses. Clippy's `redundant_pub_crate` lint
|
|
// flags this pattern, but it's load-bearing here.
|
|
#![allow(clippy::redundant_pub_crate)]
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
use serde::Deserialize;
|
|
|
|
use crate::dsl::action::ReferentialAction;
|
|
use crate::dsl::types::Type;
|
|
use crate::mode::Mode;
|
|
|
|
use super::{
|
|
ColumnSchema, IndexSchema, RelationshipSchema, SchemaSnapshot, TableCheck, TableSchema,
|
|
};
|
|
|
|
/// Serialize a `SchemaSnapshot` to a `project.yaml` body.
|
|
#[must_use]
|
|
pub(super) fn serialize_schema(schema: &SchemaSnapshot) -> String {
|
|
let mut out = String::new();
|
|
let _ = writeln!(out, "version: 1");
|
|
let _ = writeln!(out, "project:");
|
|
let _ = writeln!(out, " created_at: {}", quote_if_needed(&schema.created_at));
|
|
// ADR-0015 mode-restore amendment (issue #14): the input mode
|
|
// lives alongside `created_at` as project-level metadata, not
|
|
// schema. `rebuild` ignores it; restore-on-open reads it.
|
|
let _ = writeln!(out, " mode: {}", schema.mode.keyword());
|
|
|
|
if schema.tables.is_empty() {
|
|
let _ = writeln!(out, "tables: []");
|
|
} else {
|
|
let _ = writeln!(out, "tables:");
|
|
for table in &schema.tables {
|
|
write_table(&mut out, table);
|
|
}
|
|
}
|
|
|
|
if schema.relationships.is_empty() {
|
|
let _ = writeln!(out, "relationships: []");
|
|
} else {
|
|
let _ = writeln!(out, "relationships:");
|
|
for rel in &schema.relationships {
|
|
write_relationship(&mut out, rel);
|
|
}
|
|
}
|
|
|
|
if schema.indexes.is_empty() {
|
|
let _ = writeln!(out, "indexes: []");
|
|
} else {
|
|
let _ = writeln!(out, "indexes:");
|
|
for index in &schema.indexes {
|
|
write_index(&mut out, index);
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
fn write_index(out: &mut String, index: &IndexSchema) {
|
|
let _ = writeln!(out, " - name: {}", quote_if_needed(&index.name));
|
|
let _ = writeln!(out, " table: {}", quote_if_needed(&index.table));
|
|
write!(out, " columns: [").unwrap();
|
|
for (i, col) in index.columns.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push_str(", ");
|
|
}
|
|
out.push_str("e_if_needed(col));
|
|
}
|
|
let _ = writeln!(out, "]");
|
|
// Emit `unique` only when true (ADR-0035 §4d), matching the
|
|
// column-`unique` convention — keeps pre-unique-index project files
|
|
// byte-stable on a no-op round-trip.
|
|
if index.unique {
|
|
let _ = writeln!(out, " unique: true");
|
|
}
|
|
}
|
|
|
|
fn write_table(out: &mut String, table: &TableSchema) {
|
|
let _ = writeln!(out, " - name: {}", quote_if_needed(&table.name));
|
|
write!(out, " primary_key: [").unwrap();
|
|
for (i, key) in table.primary_key.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push_str(", ");
|
|
}
|
|
out.push_str("e_if_needed(key));
|
|
}
|
|
let _ = writeln!(out, "]");
|
|
let _ = writeln!(out, " columns:");
|
|
for col in &table.columns {
|
|
write_column(out, col);
|
|
}
|
|
// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2) —
|
|
// emitted only when present so unconstrained tables stay compact.
|
|
if !table.unique_constraints.is_empty() {
|
|
let _ = writeln!(out, " unique_constraints:");
|
|
for cols in &table.unique_constraints {
|
|
write!(out, " - [").unwrap();
|
|
for (i, c) in cols.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push_str(", ");
|
|
}
|
|
out.push_str("e_if_needed(c));
|
|
}
|
|
let _ = writeln!(out, "]");
|
|
}
|
|
}
|
|
// Table-level CHECK constraints as raw SQL text (ADR-0035 §4a.3) —
|
|
// double-quoted (an expression like `a < b` is not a bare scalar)
|
|
// and emitted only when present. An unnamed CHECK is a bare string
|
|
// (back-compatible); a named CHECK (ADR-0035 §4g) is an `{expr,
|
|
// name}` mapping so the name round-trips through a rebuild.
|
|
if !table.check_constraints.is_empty() {
|
|
let _ = writeln!(out, " check_constraints:");
|
|
for check in &table.check_constraints {
|
|
match &check.name {
|
|
None => {
|
|
let _ = writeln!(out, " - {}", yaml_string(&check.expr));
|
|
}
|
|
Some(name) => {
|
|
let _ = writeln!(out, " - expr: {}", yaml_string(&check.expr));
|
|
let _ = writeln!(out, " name: {}", yaml_string(name));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Always render `s` as a double-quoted YAML string — used
|
|
/// for a column's `default` SQL literal, which must round-trip
|
|
/// as a string even when it looks numeric (ADR-0029).
|
|
fn yaml_string(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len() + 2);
|
|
out.push('"');
|
|
for c in s.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\n' => out.push_str("\\n"),
|
|
_ => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
fn write_column(out: &mut String, col: &ColumnSchema) {
|
|
let mut line = format!(
|
|
" - {{ name: {}, type: {}",
|
|
quote_if_needed(&col.name),
|
|
col.user_type.keyword(),
|
|
);
|
|
// ADR-0018 / ADR-0029 constraint flags — emitted only when
|
|
// set, so an unconstrained column stays a compact two-field
|
|
// entry and older readers stay forward-compatible.
|
|
if col.unique {
|
|
line.push_str(", unique: true");
|
|
}
|
|
if col.not_null {
|
|
line.push_str(", not_null: true");
|
|
}
|
|
if let Some(default) = &col.default {
|
|
line.push_str(", default: ");
|
|
line.push_str(&yaml_string(default));
|
|
}
|
|
if let Some(check) = &col.check {
|
|
line.push_str(", check: ");
|
|
line.push_str(&yaml_string(check));
|
|
}
|
|
line.push_str(" }");
|
|
let _ = writeln!(out, "{line}");
|
|
}
|
|
|
|
fn write_relationship(out: &mut String, rel: &RelationshipSchema) {
|
|
let _ = writeln!(out, " - name: {}", quote_if_needed(&rel.name));
|
|
let _ = writeln!(
|
|
out,
|
|
" parent: {{ table: {}, columns: [{}] }}",
|
|
quote_if_needed(&rel.parent_table),
|
|
write_col_list(&rel.parent_columns),
|
|
);
|
|
let _ = writeln!(
|
|
out,
|
|
" child: {{ table: {}, columns: [{}] }}",
|
|
quote_if_needed(&rel.child_table),
|
|
write_col_list(&rel.child_columns),
|
|
);
|
|
let _ = writeln!(out, " on_delete: {}", action_keyword(rel.on_delete));
|
|
let _ = writeln!(out, " on_update: {}", action_keyword(rel.on_update));
|
|
}
|
|
|
|
/// Format a column list for an inline yaml flow sequence — `a, b`
|
|
/// (the caller wraps in `[…]`), each element quoted if needed.
|
|
/// Matches the `primary_key: [...]` / index `columns: [...]` house
|
|
/// style (ADR-0043 D5). One element for a single-column endpoint.
|
|
fn write_col_list(cols: &[String]) -> String {
|
|
cols.iter()
|
|
.map(|c| quote_if_needed(c))
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
const fn action_keyword(action: ReferentialAction) -> &'static str {
|
|
match action {
|
|
ReferentialAction::NoAction => "no_action",
|
|
ReferentialAction::Restrict => "restrict",
|
|
ReferentialAction::SetNull => "set_null",
|
|
ReferentialAction::Cascade => "cascade",
|
|
}
|
|
}
|
|
|
|
/// Quote a string for safe inclusion as a YAML scalar.
|
|
///
|
|
/// We're conservative: anything not made of safe characters
|
|
/// (alphanumerics, `_`, `-`, `:` for ISO timestamps, `.`)
|
|
/// gets double-quoted with `"` and `\` escaped. Common
|
|
/// identifiers from the DSL (which restricts to alnum + `_`)
|
|
/// pass through unquoted, which keeps the YAML pleasantly
|
|
/// readable.
|
|
fn quote_if_needed(s: &str) -> String {
|
|
if needs_quoting(s) {
|
|
let mut out = String::with_capacity(s.len() + 2);
|
|
out.push('"');
|
|
for c in s.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\n' => out.push_str("\\n"),
|
|
_ => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
} else {
|
|
s.to_string()
|
|
}
|
|
}
|
|
|
|
fn needs_quoting(s: &str) -> bool {
|
|
if s.is_empty() {
|
|
return true;
|
|
}
|
|
// YAML reserves several leading characters and the empty
|
|
// string. Be defensive on anything outside the safe set.
|
|
let first = s.chars().next().unwrap();
|
|
if !is_safe_yaml_char(first) || first == '-' {
|
|
return true;
|
|
}
|
|
// Scalar text that looks like a YAML keyword needs quoting
|
|
// even if every character is safe.
|
|
if matches!(
|
|
s,
|
|
"true" | "false" | "null" | "~" | "yes" | "no" | "on" | "off"
|
|
) {
|
|
return true;
|
|
}
|
|
s.chars().any(|c| !is_safe_yaml_char(c))
|
|
}
|
|
|
|
const fn is_safe_yaml_char(c: char) -> bool {
|
|
c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')
|
|
}
|
|
|
|
/// Parse a `project.yaml` body into a `SchemaSnapshot`.
|
|
///
|
|
/// The wire types below mirror the format `serialize_schema`
|
|
/// emits. Anything outside that shape produces a structured
|
|
/// error — callers (the rebuild path) translate those into a
|
|
/// fatal banner per ADR-0015 §8.
|
|
pub(crate) fn parse_schema(body: &str) -> Result<SchemaSnapshot, YamlError> {
|
|
let raw: RawProject =
|
|
serde_norway::from_str(body).map_err(|e| YamlError::Syntax(e.to_string()))?;
|
|
if raw.version != 1 {
|
|
return Err(YamlError::UnsupportedVersion(raw.version));
|
|
}
|
|
let mut tables: Vec<TableSchema> = Vec::with_capacity(raw.tables.len());
|
|
for t in raw.tables {
|
|
let mut columns: Vec<ColumnSchema> = Vec::with_capacity(t.columns.len());
|
|
for c in t.columns {
|
|
let user_type = c
|
|
.user_type
|
|
.parse::<Type>()
|
|
.map_err(|_| YamlError::UnknownType {
|
|
table: t.name.clone(),
|
|
column: c.name.clone(),
|
|
raw: c.user_type.clone(),
|
|
})?;
|
|
columns.push(ColumnSchema {
|
|
name: c.name,
|
|
user_type,
|
|
unique: c.unique,
|
|
not_null: c.not_null,
|
|
default: c.default,
|
|
check: c.check,
|
|
});
|
|
}
|
|
tables.push(TableSchema {
|
|
name: t.name,
|
|
primary_key: t.primary_key,
|
|
columns,
|
|
unique_constraints: t.unique_constraints,
|
|
check_constraints: t
|
|
.check_constraints
|
|
.into_iter()
|
|
.map(TableCheck::from)
|
|
.collect(),
|
|
});
|
|
}
|
|
let mut relationships: Vec<RelationshipSchema> = Vec::with_capacity(raw.relationships.len());
|
|
for r in raw.relationships {
|
|
let on_delete = parse_action(&r.on_delete)
|
|
.ok_or_else(|| YamlError::UnknownAction(r.on_delete.clone()))?;
|
|
let on_update = parse_action(&r.on_update)
|
|
.ok_or_else(|| YamlError::UnknownAction(r.on_update.clone()))?;
|
|
relationships.push(RelationshipSchema {
|
|
name: r.name,
|
|
parent_table: r.parent.table,
|
|
parent_columns: r.parent.columns,
|
|
child_table: r.child.table,
|
|
child_columns: r.child.columns,
|
|
on_delete,
|
|
on_update,
|
|
});
|
|
}
|
|
let indexes: Vec<IndexSchema> = raw
|
|
.indexes
|
|
.into_iter()
|
|
.map(|i| IndexSchema {
|
|
name: i.name,
|
|
table: i.table,
|
|
columns: i.columns,
|
|
unique: i.unique,
|
|
})
|
|
.collect();
|
|
Ok(SchemaSnapshot {
|
|
created_at: raw.project.created_at,
|
|
mode: raw
|
|
.project
|
|
.mode
|
|
.as_deref()
|
|
.and_then(Mode::from_keyword)
|
|
.unwrap_or_default(),
|
|
tables,
|
|
relationships,
|
|
indexes,
|
|
})
|
|
}
|
|
|
|
/// Read just the stored input mode from a `project.yaml` body,
|
|
/// for restore-on-open (ADR-0015 mode-restore amendment, issue
|
|
/// #14). Returns `None` when the file has no `mode:` field (a
|
|
/// pre-#14 project, or a hand-written one) — distinct from an
|
|
/// explicit `mode: simple` — so the caller can tell "no stored
|
|
/// preference" from a deliberate choice. An unrecognised value
|
|
/// is also `None` (fall back to the default rather than reject
|
|
/// the whole file over a UI hint). Tolerant of an otherwise
|
|
/// unparseable body for the same reason.
|
|
#[must_use]
|
|
pub(super) fn parse_stored_mode(body: &str) -> Option<Mode> {
|
|
let raw: RawProject = serde_norway::from_str(body).ok()?;
|
|
raw.project.mode.as_deref().and_then(Mode::from_keyword)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(crate) enum YamlError {
|
|
Syntax(String),
|
|
UnsupportedVersion(u32),
|
|
UnknownType {
|
|
table: String,
|
|
column: String,
|
|
raw: String,
|
|
},
|
|
UnknownAction(String),
|
|
}
|
|
|
|
impl std::fmt::Display for YamlError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Syntax(msg) => f.write_str(&crate::t!("persistence.yaml.syntax", detail = msg,)),
|
|
Self::UnsupportedVersion(v) => f.write_str(&crate::t!(
|
|
"persistence.yaml.unsupported_version",
|
|
version = v,
|
|
)),
|
|
Self::UnknownType { table, column, raw } => f.write_str(&crate::t!(
|
|
"persistence.yaml.unknown_type",
|
|
table = table,
|
|
column = column,
|
|
raw = raw,
|
|
)),
|
|
Self::UnknownAction(raw) => {
|
|
f.write_str(&crate::t!("persistence.yaml.unknown_action", raw = raw,))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for YamlError {}
|
|
|
|
fn parse_action(s: &str) -> Option<ReferentialAction> {
|
|
match s {
|
|
"no_action" => Some(ReferentialAction::NoAction),
|
|
"restrict" => Some(ReferentialAction::Restrict),
|
|
"set_null" => Some(ReferentialAction::SetNull),
|
|
"cascade" => Some(ReferentialAction::Cascade),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawProject {
|
|
version: u32,
|
|
project: RawProjectMeta,
|
|
#[serde(default)]
|
|
tables: Vec<RawTable>,
|
|
#[serde(default)]
|
|
relationships: Vec<RawRelationship>,
|
|
/// Optional: project files written before ADR-0025 carry no
|
|
/// `indexes:` field and default to an empty list.
|
|
#[serde(default)]
|
|
indexes: Vec<RawIndex>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawProjectMeta {
|
|
created_at: String,
|
|
/// Optional: pre-#14 project files carry no `mode:` field and
|
|
/// default to the app's startup mode. Stored as a raw string
|
|
/// so an unrecognised value degrades to the default rather
|
|
/// than failing the parse (ADR-0015 mode-restore amendment).
|
|
#[serde(default)]
|
|
mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawTable {
|
|
name: String,
|
|
primary_key: Vec<String>,
|
|
columns: Vec<RawColumn>,
|
|
/// Composite (multi-column) UNIQUE constraints (ADR-0035 §4a.2).
|
|
/// Optional on read — older project files omit it.
|
|
#[serde(default)]
|
|
unique_constraints: Vec<Vec<String>>,
|
|
/// Table-level CHECK constraints (ADR-0035 §4a.3, named in §4g).
|
|
/// Optional on read — older project files omit it. Each entry is a
|
|
/// bare string (unnamed) or an `{expr, name}` mapping (named).
|
|
#[serde(default)]
|
|
check_constraints: Vec<RawTableCheck>,
|
|
}
|
|
|
|
/// A table-CHECK as read from `project.yaml`: a bare string (unnamed —
|
|
/// the pre-4g form, back-compatible) or an `{expr, name}` mapping (a
|
|
/// named CHECK, ADR-0035 §4g). `#[serde(untagged)]` tries the string
|
|
/// form first, then the mapping.
|
|
#[derive(Deserialize)]
|
|
#[serde(untagged)]
|
|
enum RawTableCheck {
|
|
Bare(String),
|
|
Named {
|
|
expr: String,
|
|
#[serde(default)]
|
|
name: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl From<RawTableCheck> for TableCheck {
|
|
fn from(raw: RawTableCheck) -> Self {
|
|
match raw {
|
|
RawTableCheck::Bare(expr) => Self { name: None, expr },
|
|
RawTableCheck::Named { expr, name } => Self { name, expr },
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawColumn {
|
|
name: String,
|
|
#[serde(rename = "type")]
|
|
user_type: String,
|
|
/// Optional flag introduced in ADR-0018 for single-column
|
|
/// UNIQUE constraints. Older project files without this
|
|
/// field default to `false`.
|
|
#[serde(default)]
|
|
unique: bool,
|
|
/// `NOT NULL` flag (ADR-0029); absent in older files.
|
|
#[serde(default)]
|
|
not_null: bool,
|
|
/// `DEFAULT` SQL literal (ADR-0029); absent in older files.
|
|
#[serde(default)]
|
|
default: Option<String>,
|
|
/// `CHECK` SQL (ADR-0029); absent in older files.
|
|
#[serde(default)]
|
|
check: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawRelationship {
|
|
name: String,
|
|
parent: RawEndpoint,
|
|
child: RawEndpoint,
|
|
on_delete: String,
|
|
on_update: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawEndpoint {
|
|
table: String,
|
|
/// FK endpoint column list (ADR-0043): `columns: [a, b]`, one
|
|
/// element for a single-column endpoint — matching the
|
|
/// `primary_key` / index `columns` house style.
|
|
columns: Vec<String>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RawIndex {
|
|
name: String,
|
|
table: String,
|
|
columns: Vec<String>,
|
|
/// `UNIQUE` index flag (ADR-0035 §4d). Optional on read — project
|
|
/// files written before unique indexes existed omit it and default
|
|
/// to `false`.
|
|
#[serde(default)]
|
|
unique: bool,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::dsl::types::Type;
|
|
|
|
fn snapshot() -> SchemaSnapshot {
|
|
SchemaSnapshot {
|
|
created_at: "2026-05-07T14:30:12Z".to_string(),
|
|
mode: Mode::Simple,
|
|
tables: vec![
|
|
TableSchema {
|
|
name: "Customers".to_string(),
|
|
primary_key: vec!["id".to_string()],
|
|
columns: vec![
|
|
ColumnSchema {
|
|
name: "id".to_string(),
|
|
user_type: Type::Serial,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "Name".to_string(),
|
|
user_type: Type::Text,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
],
|
|
unique_constraints: Vec::new(),
|
|
check_constraints: Vec::new(),
|
|
},
|
|
TableSchema {
|
|
name: "Orders".to_string(),
|
|
primary_key: vec!["id".to_string()],
|
|
columns: vec![
|
|
ColumnSchema {
|
|
name: "id".to_string(),
|
|
user_type: Type::Serial,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "CustId".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
],
|
|
unique_constraints: Vec::new(),
|
|
check_constraints: Vec::new(),
|
|
},
|
|
],
|
|
relationships: vec![RelationshipSchema {
|
|
name: "Customers_id_to_Orders_CustId".to_string(),
|
|
parent_table: "Customers".to_string(),
|
|
parent_columns: vec!["id".to_string()],
|
|
child_table: "Orders".to_string(),
|
|
child_columns: vec!["CustId".to_string()],
|
|
on_delete: ReferentialAction::Cascade,
|
|
on_update: ReferentialAction::NoAction,
|
|
}],
|
|
indexes: vec![IndexSchema {
|
|
name: "Orders_CustId_idx".to_string(),
|
|
table: "Orders".to_string(),
|
|
columns: vec!["CustId".to_string()],
|
|
unique: false,
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn writes_expected_yaml_for_full_schema() {
|
|
let body = serialize_schema(&snapshot());
|
|
// Spot-check structural lines rather than asserting on
|
|
// the whole blob — easier to read in failure output.
|
|
assert!(body.contains("version: 1"));
|
|
assert!(body.contains("created_at: 2026-05-07T14:30:12Z"));
|
|
assert!(body.contains("- name: Customers"));
|
|
assert!(body.contains("primary_key: [id]"));
|
|
assert!(body.contains("{ name: id, type: serial }"));
|
|
assert!(body.contains("{ name: Name, type: text }"));
|
|
assert!(body.contains("- name: Customers_id_to_Orders_CustId"));
|
|
assert!(body.contains("parent: { table: Customers, columns: [id] }"));
|
|
assert!(body.contains("child: { table: Orders, columns: [CustId] }"));
|
|
assert!(body.contains("on_delete: cascade"));
|
|
assert!(body.contains("on_update: no_action"));
|
|
assert!(body.contains("- name: Orders_CustId_idx"));
|
|
assert!(body.contains("table: Orders"));
|
|
assert!(body.contains("columns: [CustId]"));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_lists_use_inline_brackets() {
|
|
let body = serialize_schema(&SchemaSnapshot {
|
|
created_at: "2026-05-07T14:30:12Z".to_string(),
|
|
mode: Mode::Simple,
|
|
tables: vec![],
|
|
relationships: vec![],
|
|
indexes: vec![],
|
|
});
|
|
assert!(body.contains("tables: []"));
|
|
assert!(body.contains("relationships: []"));
|
|
assert!(body.contains("indexes: []"));
|
|
}
|
|
|
|
#[test]
|
|
fn quotes_yaml_keywords_used_as_identifiers() {
|
|
let body = serialize_schema(&SchemaSnapshot {
|
|
created_at: "2026-05-07T14:30:12Z".to_string(),
|
|
mode: Mode::Simple,
|
|
tables: vec![TableSchema {
|
|
name: "true".to_string(), // reserved keyword
|
|
primary_key: vec!["id".to_string()],
|
|
columns: vec![ColumnSchema {
|
|
name: "yes".to_string(),
|
|
user_type: Type::Bool,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
}],
|
|
unique_constraints: Vec::new(),
|
|
check_constraints: Vec::new(),
|
|
}],
|
|
relationships: vec![],
|
|
indexes: vec![],
|
|
});
|
|
assert!(body.contains("- name: \"true\""));
|
|
assert!(body.contains("{ name: \"yes\", type: bool }"));
|
|
}
|
|
|
|
#[test]
|
|
fn quotes_strings_with_unsafe_characters() {
|
|
assert_eq!(quote_if_needed("My Project"), "\"My Project\"");
|
|
assert_eq!(quote_if_needed("with\"quote"), "\"with\\\"quote\"");
|
|
}
|
|
|
|
#[test]
|
|
fn write_then_read_round_trips() {
|
|
let original = snapshot();
|
|
let body = serialize_schema(&original);
|
|
let parsed = parse_schema(&body).expect("parse schema");
|
|
assert_eq!(parsed, original);
|
|
}
|
|
|
|
#[test]
|
|
fn unique_index_round_trips_through_yaml() {
|
|
// ADR-0035 §4d: a UNIQUE index's uniqueness survives a serialize
|
|
// → parse cycle. A plain index emits no `unique` line; a unique
|
|
// index emits `unique: true`.
|
|
let snap = SchemaSnapshot {
|
|
created_at: "2026-05-25T00:00:00Z".to_string(),
|
|
mode: Mode::Simple,
|
|
tables: vec![TableSchema {
|
|
name: "Customers".to_string(),
|
|
primary_key: vec!["id".to_string()],
|
|
columns: vec![
|
|
ColumnSchema {
|
|
name: "id".to_string(),
|
|
user_type: Type::Serial,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "Email".to_string(),
|
|
user_type: Type::Text,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
],
|
|
unique_constraints: Vec::new(),
|
|
check_constraints: Vec::new(),
|
|
}],
|
|
relationships: Vec::new(),
|
|
indexes: vec![
|
|
IndexSchema {
|
|
name: "Customers_Email_uidx".to_string(),
|
|
table: "Customers".to_string(),
|
|
columns: vec!["Email".to_string()],
|
|
unique: true,
|
|
},
|
|
IndexSchema {
|
|
name: "Customers_id_idx".to_string(),
|
|
table: "Customers".to_string(),
|
|
columns: vec!["id".to_string()],
|
|
unique: false,
|
|
},
|
|
],
|
|
};
|
|
let body = serialize_schema(&snap);
|
|
// The unique index emits the flag; the plain one does not.
|
|
assert!(body.contains("unique: true"), "yaml:\n{body}");
|
|
assert_eq!(
|
|
body.matches("unique: true").count(),
|
|
1,
|
|
"only the unique index carries the flag:\n{body}"
|
|
);
|
|
let parsed = parse_schema(&body).expect("parse schema");
|
|
assert_eq!(parsed, snap);
|
|
}
|
|
|
|
#[test]
|
|
fn index_without_unique_field_defaults_to_false() {
|
|
// Older project files (written before unique indexes) omit the
|
|
// `unique` field; the `#[serde(default)]` makes it `false`.
|
|
let body = "\
|
|
version: 1
|
|
project:
|
|
created_at: 2026-05-25T00:00:00Z
|
|
tables:
|
|
- name: Customers
|
|
primary_key: [id]
|
|
columns:
|
|
- { name: id, type: serial }
|
|
relationships: []
|
|
indexes:
|
|
- name: Customers_id_idx
|
|
table: Customers
|
|
columns: [id]
|
|
";
|
|
let parsed = parse_schema(body).expect("parse schema");
|
|
assert_eq!(parsed.indexes.len(), 1);
|
|
assert!(!parsed.indexes[0].unique);
|
|
}
|
|
|
|
#[test]
|
|
fn column_constraints_round_trip_through_yaml() {
|
|
// NOT NULL / UNIQUE / DEFAULT survive a serialize →
|
|
// parse cycle (ADR-0029 §7).
|
|
let snap = SchemaSnapshot {
|
|
created_at: "2026-05-19T00:00:00Z".to_string(),
|
|
mode: Mode::Simple,
|
|
tables: vec![TableSchema {
|
|
name: "Books".to_string(),
|
|
primary_key: vec!["isbn".to_string()],
|
|
columns: vec![
|
|
ColumnSchema {
|
|
name: "isbn".to_string(),
|
|
user_type: Type::Text,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "title".to_string(),
|
|
user_type: Type::Text,
|
|
unique: true,
|
|
not_null: true,
|
|
default: Some("'untitled'".to_string()),
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "stock".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: Some("0".to_string()),
|
|
check: Some("\"stock\" >= 0".to_string()),
|
|
},
|
|
],
|
|
unique_constraints: Vec::new(),
|
|
check_constraints: Vec::new(),
|
|
}],
|
|
relationships: vec![],
|
|
indexes: vec![],
|
|
};
|
|
let body = serialize_schema(&snap);
|
|
let parsed = parse_schema(&body).expect("parse schema");
|
|
assert_eq!(parsed, snap, "constraints survive the yaml round-trip");
|
|
}
|
|
|
|
#[test]
|
|
fn table_level_constraints_round_trip_through_yaml() {
|
|
// Composite UNIQUE and table-level CHECK (raw SQL text) survive
|
|
// a serialize → parse cycle in declaration order (ADR-0035
|
|
// §4a.2 / §4a.3).
|
|
let snap = SchemaSnapshot {
|
|
created_at: "2026-05-25T00:00:00Z".to_string(),
|
|
mode: Mode::Simple,
|
|
tables: vec![TableSchema {
|
|
name: "T".to_string(),
|
|
primary_key: vec![],
|
|
columns: vec![
|
|
ColumnSchema {
|
|
name: "a".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "b".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "c".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
],
|
|
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
|
|
check_constraints: vec![TableCheck::unnamed("a < b"), TableCheck::unnamed("b < c")],
|
|
}],
|
|
relationships: vec![],
|
|
indexes: vec![],
|
|
};
|
|
let body = serialize_schema(&snap);
|
|
let parsed = parse_schema(&body).expect("parse schema");
|
|
assert_eq!(
|
|
parsed, snap,
|
|
"table-level UNIQUE + CHECK survive the yaml round-trip in order"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn named_check_constraints_round_trip_through_yaml() {
|
|
// ADR-0035 §4g: a *named* table-CHECK serializes to the `{expr,
|
|
// name}` mapping form and round-trips, mixed with an unnamed one.
|
|
let snap = SchemaSnapshot {
|
|
created_at: "2026-05-25T00:00:00Z".to_string(),
|
|
mode: Mode::Simple,
|
|
tables: vec![TableSchema {
|
|
name: "T".to_string(),
|
|
primary_key: vec!["id".to_string()],
|
|
columns: vec![
|
|
ColumnSchema {
|
|
name: "id".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "qty".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
],
|
|
unique_constraints: vec![],
|
|
check_constraints: vec![
|
|
TableCheck {
|
|
name: Some("qty_positive".to_string()),
|
|
expr: "qty >= 0".to_string(),
|
|
},
|
|
TableCheck::unnamed("qty < 1000"),
|
|
],
|
|
}],
|
|
relationships: vec![],
|
|
indexes: vec![],
|
|
};
|
|
let body = serialize_schema(&snap);
|
|
let parsed = parse_schema(&body).expect("parse schema");
|
|
assert_eq!(
|
|
parsed, snap,
|
|
"named + unnamed table-CHECKs survive the yaml round-trip"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn old_format_bare_string_check_constraints_still_parse() {
|
|
// Back-compat: a project file written before §4g (bare-string
|
|
// check_constraints) parses with name = None.
|
|
let body = "\
|
|
version: 1
|
|
project:
|
|
created_at: \"2026-05-25T00:00:00Z\"
|
|
tables:
|
|
- name: T
|
|
primary_key: [id]
|
|
columns:
|
|
- { name: id, type: int }
|
|
- { name: qty, type: int }
|
|
check_constraints:
|
|
- \"qty >= 0\"
|
|
relationships: []
|
|
indexes: []
|
|
";
|
|
let parsed = parse_schema(body).expect("parse old-format schema");
|
|
assert_eq!(
|
|
parsed.tables[0].check_constraints,
|
|
vec![TableCheck::unnamed("qty >= 0")],
|
|
"a bare-string CHECK parses as an unnamed TableCheck"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn check_constraints_optional_on_read() {
|
|
// A project file written before table-level CHECK existed (no
|
|
// `check_constraints:` key) parses with an empty list.
|
|
let body = "\
|
|
version: 1
|
|
project:
|
|
created_at: 2026-05-25T00:00:00Z
|
|
tables:
|
|
- name: T
|
|
primary_key: [id]
|
|
columns:
|
|
- { name: id, type: int }
|
|
relationships: []
|
|
";
|
|
let parsed = parse_schema(body).expect("parse");
|
|
assert!(parsed.tables[0].check_constraints.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_minimal_yaml_with_no_tables() {
|
|
let body = "\
|
|
version: 1
|
|
project:
|
|
created_at: 2026-05-07T14:30:12Z
|
|
tables: []
|
|
relationships: []
|
|
";
|
|
let parsed = parse_schema(body).expect("parse minimal");
|
|
assert_eq!(parsed.tables.len(), 0);
|
|
assert_eq!(parsed.relationships.len(), 0);
|
|
// A project file with no `indexes:` field (written
|
|
// before ADR-0025) parses with an empty index list.
|
|
assert_eq!(parsed.indexes.len(), 0);
|
|
assert_eq!(parsed.created_at, "2026-05-07T14:30:12Z");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_version() {
|
|
let body = "version: 9\nproject:\n created_at: x\ntables: []\nrelationships: []\n";
|
|
match parse_schema(body) {
|
|
Err(YamlError::UnsupportedVersion(9)) => {}
|
|
other => panic!("expected UnsupportedVersion(9), got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_column_type() {
|
|
let body = "\
|
|
version: 1
|
|
project:
|
|
created_at: x
|
|
tables:
|
|
- name: T
|
|
primary_key: [id]
|
|
columns:
|
|
- { name: id, type: bogus }
|
|
relationships: []
|
|
";
|
|
match parse_schema(body) {
|
|
Err(YamlError::UnknownType { raw, .. }) => assert_eq!(raw, "bogus"),
|
|
other => panic!("expected UnknownType, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_action() {
|
|
let body = "\
|
|
version: 1
|
|
project:
|
|
created_at: x
|
|
tables: []
|
|
relationships:
|
|
- name: R
|
|
parent: { table: A, columns: [id] }
|
|
child: { table: B, columns: [aid] }
|
|
on_delete: blow_up
|
|
on_update: no_action
|
|
";
|
|
match parse_schema(body) {
|
|
Err(YamlError::UnknownAction(s)) => assert_eq!(s, "blow_up"),
|
|
other => panic!("expected UnknownAction, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn preserves_compound_primary_key_order() {
|
|
let body = serialize_schema(&SchemaSnapshot {
|
|
created_at: "2026-05-07T14:30:12Z".to_string(),
|
|
mode: Mode::Simple,
|
|
tables: vec![TableSchema {
|
|
name: "Items".to_string(),
|
|
primary_key: vec!["a".to_string(), "b".to_string()],
|
|
columns: vec![
|
|
ColumnSchema {
|
|
name: "a".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
ColumnSchema {
|
|
name: "b".to_string(),
|
|
user_type: Type::Int,
|
|
unique: false,
|
|
not_null: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
],
|
|
unique_constraints: Vec::new(),
|
|
check_constraints: Vec::new(),
|
|
}],
|
|
relationships: vec![],
|
|
indexes: vec![],
|
|
});
|
|
assert!(body.contains("primary_key: [a, b]"));
|
|
}
|
|
|
|
// ---- ADR-0015 mode-restore amendment (issue #14) ----
|
|
|
|
#[test]
|
|
fn mode_round_trips_through_serialize_and_parse() {
|
|
for mode in [Mode::Simple, Mode::Advanced] {
|
|
let snap = SchemaSnapshot {
|
|
created_at: "2026-05-07T14:30:12Z".to_string(),
|
|
mode,
|
|
tables: vec![],
|
|
relationships: vec![],
|
|
indexes: vec![],
|
|
};
|
|
let body = serialize_schema(&snap);
|
|
assert!(
|
|
body.contains(&format!("mode: {}", mode.keyword())),
|
|
"serialized body carries the mode keyword: {body}"
|
|
);
|
|
let parsed = parse_schema(&body).expect("round-trips");
|
|
assert_eq!(parsed.mode, mode);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parse_schema_defaults_mode_to_simple_when_field_absent() {
|
|
// A pre-#14 project file carries no `mode:` field; it must
|
|
// parse with the default mode, not fail.
|
|
let body = "version: 1\nproject:\n created_at: x\ntables: []\nrelationships: []\n";
|
|
let parsed = parse_schema(body).expect("legacy file parses");
|
|
assert_eq!(parsed.mode, Mode::Simple);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_stored_mode_distinguishes_absent_from_explicit() {
|
|
// `None` (no stored preference) must be distinct from an
|
|
// explicit `simple`, so restore-on-open precedence can tell
|
|
// "fall back to default" from "the user chose simple".
|
|
let absent = "version: 1\nproject:\n created_at: x\ntables: []\n";
|
|
assert_eq!(parse_stored_mode(absent), None);
|
|
|
|
let explicit_simple = "version: 1\nproject:\n created_at: x\n mode: simple\ntables: []\n";
|
|
assert_eq!(parse_stored_mode(explicit_simple), Some(Mode::Simple));
|
|
|
|
let advanced = "version: 1\nproject:\n created_at: x\n mode: advanced\ntables: []\n";
|
|
assert_eq!(parse_stored_mode(advanced), Some(Mode::Advanced));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_stored_mode_falls_back_to_none_on_unknown_value() {
|
|
// An unrecognised mode keyword degrades to "no preference"
|
|
// rather than rejecting the whole file over a UI hint.
|
|
let body = "version: 1\nproject:\n created_at: x\n mode: expert\ntables: []\n";
|
|
assert_eq!(parse_stored_mode(body), None);
|
|
}
|
|
}
|