904 lines
38 KiB
Rust
904 lines
38 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::docs::keys::{column_key, fold_identifier, table_key};
|
|
use crate::docs::{ColumnNote, NoteSource, SchemaSnapshot, SnapshotWarning, TableGroup};
|
|
use crate::models::connection::DatabaseType;
|
|
|
|
/// The on-disk notes file. This IS the store — not a cache of anything.
|
|
/// It is meant to be committed to a repository and reviewed in pull
|
|
/// requests, so it must stay small, readable, and stable in key order.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct AnnotationFile {
|
|
pub format_version: u32,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub project: Option<ProjectAnnotation>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub groups: Vec<GroupAnnotation>,
|
|
/// Keyed by `schema.table` (or bare `table` on schema-less engines).
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub tables: BTreeMap<String, TableAnnotation>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct ProjectAnnotation {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub name: Option<String>,
|
|
/// Markdown. Becomes the documentation landing page.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct GroupAnnotation {
|
|
/// Stable slug, referenced by `TableAnnotation::group`.
|
|
pub id: String,
|
|
pub name: String,
|
|
/// 0..=359. Lightness and chroma are theme-controlled, so any hue is
|
|
/// legible on both light and dark grounds by construction.
|
|
pub hue: u16,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub note: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct TableAnnotation {
|
|
/// References `GroupAnnotation::id`.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub group: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub note: Option<String>,
|
|
/// Keyed by bare column name.
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub columns: BTreeMap<String, ColumnAnnotation>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
|
pub struct ColumnAnnotation {
|
|
pub note: String,
|
|
}
|
|
|
|
/// The only format version this build understands.
|
|
pub const ANNOTATION_FORMAT_VERSION: u32 = 1;
|
|
|
|
/// Load the notes file.
|
|
///
|
|
/// An ABSENT file returns `Ok(None)` — that is the normal first-run and
|
|
/// first-CI-run state. A MALFORMED file is a hard error: someone's prose is
|
|
/// in there, and rendering apparently-complete documentation while silently
|
|
/// discarding it is worse than failing.
|
|
pub fn load_annotations(path: &Path) -> Result<Option<AnnotationFile>, String> {
|
|
let contents = match std::fs::read_to_string(path) {
|
|
Ok(contents) => contents,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(error) => return Err(format!("Failed to read notes file {}: {error}", path.display())),
|
|
};
|
|
|
|
// Read the version BEFORE full deserialization. `deny_unknown_fields`
|
|
// would otherwise reject a future-format file with a confusing
|
|
// "unknown field" error, never reaching the version check — defeating
|
|
// the entire purpose of having a version field.
|
|
let probe: serde_json::Value = serde_json::from_str(&contents)
|
|
.map_err(|error| format!("Failed to parse notes file {}: {error}", path.display()))?;
|
|
|
|
match probe.get("formatVersion").and_then(serde_json::Value::as_u64) {
|
|
Some(version) if version == u64::from(ANNOTATION_FORMAT_VERSION) => {}
|
|
Some(version) => {
|
|
return Err(format!(
|
|
"Notes file {} has formatVersion {version}, but this build understands {}.",
|
|
path.display(),
|
|
ANNOTATION_FORMAT_VERSION
|
|
))
|
|
}
|
|
None => return Err(format!("Notes file {} is missing formatVersion.", path.display())),
|
|
}
|
|
|
|
let parsed: AnnotationFile = serde_json::from_value(probe)
|
|
.map_err(|error| format!("Failed to parse notes file {}: {error}", path.display()))?;
|
|
|
|
Ok(Some(parsed))
|
|
}
|
|
|
|
/// A unique sibling temp path for an atomic save.
|
|
///
|
|
/// MUST be unique per call: a temp name derived from the target alone is
|
|
/// shared by every concurrent writer, so two saves interleave their bytes into
|
|
/// one file and the last rename publishes the mixture. MUST also be a sibling
|
|
/// — rename is only atomic within a filesystem.
|
|
///
|
|
/// The base name is truncated so the whole component stays within the 255-byte
|
|
/// limit most filesystems enforce on a single path component. The wrapper
|
|
/// costs 14 bytes, so a long-but-valid target name would otherwise make every
|
|
/// save fail with ENAMETOOLONG — a regression against the previous 4-byte
|
|
/// `.tmp` suffix. Truncating the base is safe because uniqueness comes from
|
|
/// the uuid, not from the name.
|
|
fn temp_save_path(path: &Path) -> PathBuf {
|
|
/// Most filesystems cap one path component at 255 bytes.
|
|
const MAX_COMPONENT: usize = 255;
|
|
/// `.` + `.` + 8 hex + `.tmp`
|
|
const WRAPPER: usize = 14;
|
|
|
|
let name = path.file_name().map(|value| value.to_string_lossy().into_owned()).unwrap_or_default();
|
|
let budget = MAX_COMPONENT - WRAPPER;
|
|
|
|
// Truncate on a char boundary — slicing a String by bytes can split a
|
|
// multi-byte character and panic.
|
|
let mut used = 0usize;
|
|
let trimmed: String = name
|
|
.chars()
|
|
.take_while(|character| {
|
|
used += character.len_utf8();
|
|
used <= budget
|
|
})
|
|
.collect();
|
|
|
|
let unique = uuid::Uuid::new_v4().simple().to_string();
|
|
path.with_file_name(format!(".{trimmed}.{}.tmp", &unique[..8]))
|
|
}
|
|
|
|
/// Write the notes file atomically.
|
|
///
|
|
/// A partial write destroys prose a human typed, and `load_annotations`
|
|
/// errors loudly on malformed JSON — so a torn write becomes "your notes file
|
|
/// is corrupt" the next time the viewer opens. Write a sibling temp file,
|
|
/// flush it to disk, then rename: rename within a directory is atomic on
|
|
/// every platform DBX targets.
|
|
pub fn save_annotations(path: &Path, annotations: &AnnotationFile) -> Result<(), String> {
|
|
let json =
|
|
serde_json::to_string_pretty(annotations).map_err(|error| format!("Failed to serialize notes: {error}"))?;
|
|
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
|
|
}
|
|
|
|
let temp = temp_save_path(path);
|
|
{
|
|
let mut file =
|
|
std::fs::File::create(&temp).map_err(|error| format!("Failed to create {}: {error}", temp.display()))?;
|
|
file.write_all(json.as_bytes()).map_err(|error| {
|
|
let _ = std::fs::remove_file(&temp);
|
|
format!("Failed to write {}: {error}", temp.display())
|
|
})?;
|
|
file.sync_all().map_err(|error| {
|
|
let _ = std::fs::remove_file(&temp);
|
|
format!("Failed to flush {}: {error}", temp.display())
|
|
})?;
|
|
}
|
|
|
|
std::fs::rename(&temp, path).map_err(|error| {
|
|
let _ = std::fs::remove_file(&temp);
|
|
format!("Failed to replace {}: {error}", path.display())
|
|
})
|
|
}
|
|
|
|
/// Where a connection's notes file lives.
|
|
///
|
|
/// An explicit `docs_notes_path` wins — that is the entire point of the field.
|
|
/// Pointing it at a file inside a repository is what lets schema documentation
|
|
/// be reviewed in pull requests. Otherwise the file lives under the app data
|
|
/// directory keyed by connection id, so the feature works with no setup.
|
|
///
|
|
/// Takes the two fields it needs rather than a whole `ConnectionConfig`:
|
|
/// that struct has no `Default` and ~60 fields, so passing it would force
|
|
/// every test to build a literal full of values the function never reads.
|
|
/// `data_dir` is a parameter because `dbx-core` cannot reach the caller's
|
|
/// data directory on its own.
|
|
pub fn resolve_notes_path(connection_id: &str, docs_notes_path: Option<&str>, data_dir: &Path) -> PathBuf {
|
|
if let Some(path) = docs_notes_path.map(str::trim).filter(|value| !value.is_empty()) {
|
|
return PathBuf::from(path);
|
|
}
|
|
data_dir.join("docs-notes").join(format!("{connection_id}.json"))
|
|
}
|
|
|
|
/// Merge a notes file into a collected snapshot.
|
|
///
|
|
/// Precedence is `local ?? database_comment`. When a local note shadows a
|
|
/// database comment the comment is kept in `shadowed_note`, so a later
|
|
/// `COMMENT ON` improvement stays visible rather than being silently hidden.
|
|
pub fn apply_annotations(snapshot: &mut SchemaSnapshot, annotations: &AnnotationFile, db_type: DatabaseType) {
|
|
if let Some(project) = annotations.project.as_ref() {
|
|
if let Some(name) = project.name.as_deref().filter(|value| !value.trim().is_empty()) {
|
|
snapshot.project.name = name.to_string();
|
|
}
|
|
if project.note.is_some() {
|
|
snapshot.project.note = project.note.clone();
|
|
}
|
|
}
|
|
|
|
let known_groups: std::collections::HashSet<&str> =
|
|
annotations.groups.iter().map(|group| group.id.as_str()).collect();
|
|
|
|
let mut seen_group_ids = std::collections::HashSet::new();
|
|
snapshot.groups = annotations
|
|
.groups
|
|
.iter()
|
|
// A duplicate id would emit two TableGroup blocks with the same
|
|
// name, which is invalid DBML. First occurrence wins.
|
|
.filter(|group| seen_group_ids.insert(group.id.as_str()))
|
|
.map(|group| TableGroup {
|
|
id: group.id.clone(),
|
|
name: group.name.clone(),
|
|
hue: group.hue,
|
|
note: group.note.clone(),
|
|
})
|
|
.collect();
|
|
|
|
for table in &mut snapshot.tables {
|
|
let key = table_key(db_type, table.schema.as_deref(), &table.name);
|
|
let Some(annotation) = annotations.tables.get(&key) else { continue };
|
|
|
|
if let Some(note) = annotation.note.as_deref().filter(|value| !value.trim().is_empty()) {
|
|
// Preserve whatever the database said before overwriting it.
|
|
if matches!(table.note_source, NoteSource::Database) {
|
|
table.shadowed_note = table.note.clone();
|
|
}
|
|
table.note = Some(note.to_string());
|
|
table.note_source = NoteSource::Local;
|
|
}
|
|
|
|
// A group reference that names no defined group is dropped rather
|
|
// than assigned — a dangling id would render an empty group header.
|
|
table.group_id = annotation.group.as_deref().filter(|id| known_groups.contains(id)).map(ToOwned::to_owned);
|
|
|
|
for column in &table.columns {
|
|
let column_fold = fold_identifier(db_type, &column.name);
|
|
let annotated = annotation.columns.iter().find(|(name, _)| fold_identifier(db_type, name) == column_fold);
|
|
|
|
let Some((_, column_annotation)) = annotated else { continue };
|
|
if column_annotation.note.trim().is_empty() {
|
|
continue;
|
|
}
|
|
|
|
table.column_notes.insert(
|
|
column.name.clone(),
|
|
ColumnNote {
|
|
note: column_annotation.note.clone(),
|
|
source: NoteSource::Local,
|
|
shadowed: column.comment.clone().filter(|value| !value.trim().is_empty()),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
let orphans = detect_orphans(snapshot, annotations, db_type);
|
|
if !orphans.is_empty() {
|
|
snapshot.warnings.push(SnapshotWarning::OrphanedNotes { count: orphans.len() });
|
|
}
|
|
}
|
|
|
|
/// Annotation keys whose target no longer exists in the collected schema.
|
|
///
|
|
/// Returns fully-qualified keys, sorted, so the caller can list them for a
|
|
/// human to re-map. This function NEVER mutates the notes file — user prose
|
|
/// is only ever removed by an explicit human action.
|
|
///
|
|
/// Suggestions for where a renamed target went are deliberately absent:
|
|
/// producing them requires the OLD schema to diff against, and the notes
|
|
/// file stores prose only. That becomes possible once snapshot history
|
|
/// exists (see the spec's deferred versioning seam).
|
|
pub fn detect_orphans(snapshot: &SchemaSnapshot, annotations: &AnnotationFile, db_type: DatabaseType) -> Vec<String> {
|
|
use std::collections::HashSet;
|
|
|
|
let live_tables: HashSet<String> =
|
|
snapshot.tables.iter().map(|table| table_key(db_type, table.schema.as_deref(), &table.name)).collect();
|
|
|
|
let live_columns: HashSet<String> = snapshot
|
|
.tables
|
|
.iter()
|
|
.flat_map(|table| {
|
|
table
|
|
.columns
|
|
.iter()
|
|
.map(move |column| column_key(db_type, table.schema.as_deref(), &table.name, &column.name))
|
|
})
|
|
.collect();
|
|
|
|
let mut orphans = Vec::new();
|
|
|
|
for (key, annotation) in &annotations.tables {
|
|
let folded_table = fold_key(db_type, key);
|
|
if !live_tables.contains(&folded_table) {
|
|
orphans.push(folded_table);
|
|
continue;
|
|
}
|
|
for column in annotation.columns.keys() {
|
|
let folded_column = format!("{folded_table}.{}", fold_identifier(db_type, column));
|
|
if !live_columns.contains(&folded_column) {
|
|
orphans.push(folded_column);
|
|
}
|
|
}
|
|
}
|
|
|
|
orphans.sort();
|
|
orphans
|
|
}
|
|
|
|
/// Fold an already-dotted key (e.g. `Core.Orders`) segment by segment.
|
|
fn fold_key(db_type: DatabaseType, key: &str) -> String {
|
|
key.split('.').map(|segment| fold_identifier(db_type, segment)).collect::<Vec<_>>().join(".")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const SAMPLE: &str = r##"{
|
|
"formatVersion": 1,
|
|
"project": { "name": "Ecommerce", "note": "# Overview" },
|
|
"groups": [
|
|
{ "id": "order-management", "name": "Order Management", "hue": 28, "note": "Checkout to handoff." }
|
|
],
|
|
"tables": {
|
|
"core.orders": {
|
|
"group": "order-management",
|
|
"note": "One row per checkout.",
|
|
"columns": { "status": { "note": "State machine." } }
|
|
}
|
|
}
|
|
}"##;
|
|
|
|
#[test]
|
|
fn parses_a_complete_notes_file() {
|
|
let parsed: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
assert_eq!(parsed.format_version, 1);
|
|
assert_eq!(parsed.project.as_ref().unwrap().name.as_deref(), Some("Ecommerce"));
|
|
assert_eq!(parsed.groups.len(), 1);
|
|
assert_eq!(parsed.groups[0].hue, 28);
|
|
assert_eq!(parsed.tables.len(), 1);
|
|
|
|
let orders = parsed.tables.get("core.orders").expect("orders");
|
|
assert_eq!(orders.group.as_deref(), Some("order-management"));
|
|
assert_eq!(orders.note.as_deref(), Some("One row per checkout."));
|
|
assert_eq!(orders.columns.get("status").unwrap().note, "State machine.");
|
|
}
|
|
|
|
#[test]
|
|
fn a_minimal_file_needs_only_the_format_version() {
|
|
let parsed: AnnotationFile = serde_json::from_str(r#"{"formatVersion": 1}"#).expect("parse");
|
|
assert!(parsed.tables.is_empty());
|
|
assert!(parsed.groups.is_empty());
|
|
assert!(parsed.project.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips_through_json() {
|
|
let parsed: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
let written = serde_json::to_string(&parsed).expect("serialize");
|
|
let reparsed: AnnotationFile = serde_json::from_str(&written).expect("reparse");
|
|
|
|
// Every field in SAMPLE must survive a write/read cycle. Asserting
|
|
// only a count here would pass against a model that silently drops
|
|
// fields on write via a wrong skip_serializing_if predicate.
|
|
assert_eq!(reparsed.format_version, 1);
|
|
|
|
let project = reparsed.project.as_ref().expect("project survived");
|
|
assert_eq!(project.name.as_deref(), Some("Ecommerce"));
|
|
assert_eq!(project.note.as_deref(), Some("# Overview"));
|
|
|
|
assert_eq!(reparsed.groups.len(), 1);
|
|
let group = &reparsed.groups[0];
|
|
assert_eq!(group.id, "order-management");
|
|
assert_eq!(group.name, "Order Management");
|
|
assert_eq!(group.hue, 28);
|
|
assert_eq!(group.note.as_deref(), Some("Checkout to handoff."));
|
|
|
|
assert_eq!(reparsed.tables.len(), 1);
|
|
let orders = reparsed.tables.get("core.orders").expect("orders survived");
|
|
assert_eq!(orders.group.as_deref(), Some("order-management"));
|
|
assert_eq!(orders.note.as_deref(), Some("One row per checkout."));
|
|
assert_eq!(orders.columns.len(), 1);
|
|
assert_eq!(orders.columns.get("status").expect("column survived").note, "State machine.");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_a_file_with_an_unknown_top_level_field() {
|
|
// Typos in a hand-edited file must not be silently ignored — a
|
|
// misspelled "tabels" key would otherwise discard every note in it.
|
|
let result: Result<AnnotationFile, _> = serde_json::from_str(r#"{"formatVersion": 1, "tabels": {}}"#);
|
|
assert!(result.is_err(), "unknown fields must be rejected");
|
|
}
|
|
|
|
fn temp_notes(contents: &str) -> std::path::PathBuf {
|
|
let path = std::env::temp_dir().join(format!("dbx-notes-test-{}.json", uuid::Uuid::new_v4()));
|
|
std::fs::write(&path, contents).expect("write temp notes file");
|
|
path
|
|
}
|
|
|
|
fn temp_case_dir(label: &str) -> std::path::PathBuf {
|
|
let dir = std::env::temp_dir().join(format!("dbx-notes-{label}-{}", uuid::Uuid::new_v4().simple()));
|
|
std::fs::create_dir_all(&dir).expect("create temp dir");
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn save_then_load_round_trips() {
|
|
let dir = temp_case_dir("round-trip");
|
|
let path = dir.join("notes.json");
|
|
let file = AnnotationFile {
|
|
format_version: 1,
|
|
project: Some(ProjectAnnotation { name: Some("P".into()), note: Some("hello".into()) }),
|
|
groups: vec![GroupAnnotation { id: "g".into(), name: "G".into(), hue: 200, note: None }],
|
|
tables: BTreeMap::from([(
|
|
"public.t".to_string(),
|
|
TableAnnotation { group: Some("g".into()), note: Some("n".into()), columns: BTreeMap::new() },
|
|
)]),
|
|
};
|
|
|
|
save_annotations(&path, &file).expect("save");
|
|
let loaded = load_annotations(&path).expect("load").expect("present");
|
|
|
|
assert_eq!(loaded.format_version, 1);
|
|
assert_eq!(loaded.groups[0].hue, 200);
|
|
assert_eq!(loaded.tables["public.t"].note.as_deref(), Some("n"));
|
|
assert_eq!(loaded.project.and_then(|p| p.note).as_deref(), Some("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn save_creates_missing_parent_directories() {
|
|
let dir = temp_case_dir("nested");
|
|
let path = dir.join("nested").join("deeper").join("notes.json");
|
|
let file = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
|
|
save_annotations(&path, &file).expect("save into a new directory");
|
|
assert!(path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn save_leaves_no_temp_file_behind() {
|
|
// A stray notes.json.tmp would be picked up by nothing, but it means
|
|
// the rename did not happen and the write was not atomic.
|
|
let dir = temp_case_dir("no-temp-left");
|
|
let path = dir.join("notes.json");
|
|
let file = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
|
|
save_annotations(&path, &file).expect("save");
|
|
|
|
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
|
.expect("read_dir")
|
|
.filter_map(Result::ok)
|
|
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
|
.filter(|name| name.ends_with(".tmp"))
|
|
.collect();
|
|
assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn a_save_replaces_the_file_by_rename_rather_than_writing_in_place() {
|
|
// The whole point of temp-and-rename. A reader either sees the old
|
|
// file or the new one, never a half-written mix — load_annotations
|
|
// errors loudly on malformed JSON, so a torn write reads as "your
|
|
// notes file is corrupt".
|
|
//
|
|
// Writing in place keeps the inode; renaming over the target changes
|
|
// it. That is the difference, and it is observable without having to
|
|
// make a write fail halfway.
|
|
use std::os::unix::fs::MetadataExt;
|
|
|
|
let dir = temp_case_dir("atomic-replace");
|
|
let path = dir.join("notes.json");
|
|
let first = AnnotationFile {
|
|
format_version: 1,
|
|
project: None,
|
|
groups: Vec::new(),
|
|
tables: BTreeMap::from([(
|
|
"public.a".to_string(),
|
|
TableAnnotation { group: None, note: Some("first".into()), columns: BTreeMap::new() },
|
|
)]),
|
|
};
|
|
save_annotations(&path, &first).expect("first save");
|
|
let before = std::fs::metadata(&path).expect("metadata").ino();
|
|
|
|
let second = AnnotationFile {
|
|
format_version: 1,
|
|
project: None,
|
|
groups: Vec::new(),
|
|
tables: BTreeMap::from([(
|
|
"public.b".to_string(),
|
|
TableAnnotation { group: None, note: Some("second".into()), columns: BTreeMap::new() },
|
|
)]),
|
|
};
|
|
save_annotations(&path, &second).expect("second save");
|
|
let after = std::fs::metadata(&path).expect("metadata").ino();
|
|
|
|
assert_ne!(before, after, "an atomic save replaces the file by rename, never writes in place");
|
|
let loaded = load_annotations(&path).expect("load").expect("present");
|
|
assert_eq!(loaded.tables["public.b"].note.as_deref(), Some("second"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_explicit_notes_path_wins_over_the_default() {
|
|
let resolved = resolve_notes_path("conn-1", Some("/tmp/team/schema-notes.json"), std::path::Path::new("/data"));
|
|
assert_eq!(resolved, std::path::PathBuf::from("/tmp/team/schema-notes.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_default_notes_path_is_keyed_by_connection_id() {
|
|
let resolved = resolve_notes_path("conn-1", None, std::path::Path::new("/data"));
|
|
assert_eq!(resolved, std::path::PathBuf::from("/data/docs-notes/conn-1.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_blank_notes_path_falls_back_to_the_default() {
|
|
// An empty string in the config is a cleared field, not a path to a
|
|
// file named "". Treating it as explicit would resolve to garbage.
|
|
let resolved = resolve_notes_path("conn-1", Some(" "), std::path::Path::new("/data"));
|
|
assert_eq!(resolved, std::path::PathBuf::from("/data/docs-notes/conn-1.json"));
|
|
}
|
|
|
|
#[test]
|
|
fn each_save_uses_a_distinct_temp_path() {
|
|
// A temp name derived from the target alone is shared by every
|
|
// concurrent writer, so two saves interleave into one file and the
|
|
// last rename publishes the mixture.
|
|
let target = std::path::Path::new("/data/docs-notes/conn-1.json");
|
|
let first = temp_save_path(target);
|
|
let second = temp_save_path(target);
|
|
assert_ne!(first, second);
|
|
}
|
|
|
|
#[test]
|
|
fn the_temp_path_is_a_sibling_of_the_target() {
|
|
// rename is only atomic within one filesystem. A temp file in /tmp
|
|
// could be on a different mount, making the rename a copy.
|
|
let target = std::path::Path::new("/data/docs-notes/conn-1.json");
|
|
assert_eq!(temp_save_path(target).parent(), target.parent());
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_write_leaves_no_temp_file_in_the_target_directory() {
|
|
// docs_notes_path is meant to point into a user's repository, so
|
|
// debris from a failed save shows up in their git status.
|
|
let dir = temp_case_dir("failed-write-debris");
|
|
let path = dir.join("notes.json");
|
|
// A directory at the target makes the rename fail after the temp file
|
|
// has been written.
|
|
std::fs::create_dir(&path).expect("block the target path");
|
|
let file = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
|
|
|
|
let result = save_annotations(&path, &file);
|
|
assert!(result.is_err(), "save must fail when the target is a directory");
|
|
|
|
let leftovers: Vec<_> = std::fs::read_dir(&dir)
|
|
.expect("read_dir")
|
|
.filter_map(Result::ok)
|
|
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
|
.filter(|name| name.ends_with(".tmp"))
|
|
.collect();
|
|
assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_target_name_still_yields_a_usable_temp_component() {
|
|
// The wrapper costs 14 bytes. Without a budget, a 245-byte name makes
|
|
// a 259-byte component and every save fails with ENAMETOOLONG — a
|
|
// regression against the old 4-byte `.tmp` suffix.
|
|
let name = format!("{}.json", "a".repeat(240));
|
|
let target = std::path::Path::new("/data").join(&name);
|
|
let temp = temp_save_path(&target);
|
|
let component = temp.file_name().expect("file name").to_string_lossy().len();
|
|
assert!(component <= 255, "temp component is {component} bytes: {temp:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_multibyte_target_name_is_truncated_on_a_char_boundary() {
|
|
// Slicing a String by bytes can split a multi-byte character and
|
|
// panic. This name is 3 bytes per character.
|
|
let name = format!("{}.json", "é".repeat(200));
|
|
let target = std::path::Path::new("/data").join(&name);
|
|
let temp = temp_save_path(&target); // must not panic
|
|
let component = temp.file_name().expect("file name").to_string_lossy().len();
|
|
assert!(component <= 255, "temp component is {component} bytes");
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_target_name_still_saves_and_loads() {
|
|
// End to end: the budget is only useful if the save actually works.
|
|
let dir = temp_case_dir("long-name");
|
|
let path = dir.join(format!("{}.json", "a".repeat(200)));
|
|
let file = AnnotationFile { format_version: 1, project: None, groups: Vec::new(), tables: BTreeMap::new() };
|
|
save_annotations(&path, &file).expect("save with a long name");
|
|
assert!(load_annotations(&path).expect("load").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn an_absent_file_is_not_an_error() {
|
|
let missing = std::path::Path::new("/nonexistent/dbx-notes-does-not-exist.json");
|
|
assert!(matches!(load_annotations(missing), Ok(None)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_valid_file_loads() {
|
|
let path = temp_notes(SAMPLE);
|
|
let loaded = load_annotations(&path);
|
|
let _ = std::fs::remove_file(&path);
|
|
let loaded = loaded.expect("load").expect("some");
|
|
assert_eq!(loaded.tables.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_malformed_file_is_a_hard_error_naming_the_path() {
|
|
let path = temp_notes("{ this is not json");
|
|
let error = load_annotations(&path);
|
|
let _ = std::fs::remove_file(&path);
|
|
let error = error.expect_err("must fail");
|
|
assert!(error.contains(&path.display().to_string()), "error must name the file: {error}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unsupported_format_version_is_rejected() {
|
|
let path = temp_notes(r#"{"formatVersion": 99}"#);
|
|
let error = load_annotations(&path);
|
|
let _ = std::fs::remove_file(&path);
|
|
let error = error.expect_err("must fail");
|
|
assert!(error.contains("99"), "error must name the version: {error}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_future_format_version_reports_the_version_not_an_unknown_field() {
|
|
// deny_unknown_fields must NOT pre-empt the version check — a v1
|
|
// build reading a v2 file has to say so, or the version field is
|
|
// useless exactly when it is needed.
|
|
let path = temp_notes(r#"{"formatVersion": 2, "someNewField": {"a": 1}}"#);
|
|
let error = load_annotations(&path);
|
|
let _ = std::fs::remove_file(&path);
|
|
let error = error.expect_err("must fail");
|
|
|
|
assert!(error.contains("formatVersion 2"), "should name the version, got: {error}");
|
|
assert!(!error.contains("unknown field"), "should not surface a raw serde error, got: {error}");
|
|
}
|
|
|
|
use crate::docs::{ColumnNote, DocTable, NoteSource, ProjectMeta, SchemaSnapshot, TableKind};
|
|
use crate::models::connection::DatabaseType;
|
|
|
|
fn snapshot_with(tables: Vec<DocTable>) -> SchemaSnapshot {
|
|
SchemaSnapshot {
|
|
format_version: 1,
|
|
project: ProjectMeta {
|
|
name: "conn".to_string(),
|
|
database_type: "postgres".to_string(),
|
|
database: None,
|
|
schemas: vec!["core".to_string()],
|
|
generated_at: String::new(),
|
|
note: None,
|
|
},
|
|
tables,
|
|
relationships: vec![],
|
|
groups: vec![],
|
|
enums: vec![],
|
|
warnings: vec![],
|
|
}
|
|
}
|
|
|
|
fn table_named(schema: &str, name: &str, comment: Option<&str>) -> DocTable {
|
|
DocTable {
|
|
schema: Some(schema.to_string()),
|
|
name: name.to_string(),
|
|
kind: TableKind::Table,
|
|
columns: vec![],
|
|
indexes: vec![],
|
|
foreign_keys: vec![],
|
|
group_id: None,
|
|
note: comment.map(ToOwned::to_owned),
|
|
note_source: if comment.is_some() { NoteSource::Database } else { NoteSource::None },
|
|
shadowed_note: None,
|
|
column_notes: BTreeMap::new(),
|
|
estimated_rows: None,
|
|
view_definition: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_local_note_shadows_the_database_comment_and_preserves_it() {
|
|
let mut snapshot = snapshot_with(vec![table_named("core", "orders", Some("Old DB comment."))]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
let table = &snapshot.tables[0];
|
|
assert_eq!(table.note.as_deref(), Some("One row per checkout."));
|
|
assert_eq!(table.note_source, NoteSource::Local);
|
|
assert_eq!(table.shadowed_note.as_deref(), Some("Old DB comment."));
|
|
}
|
|
|
|
#[test]
|
|
fn a_database_comment_survives_when_there_is_no_local_note() {
|
|
let mut snapshot = snapshot_with(vec![table_named("core", "users", Some("From the database."))]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
let table = &snapshot.tables[0];
|
|
assert_eq!(table.note.as_deref(), Some("From the database."));
|
|
assert_eq!(table.note_source, NoteSource::Database);
|
|
assert_eq!(table.shadowed_note, None);
|
|
}
|
|
|
|
#[test]
|
|
fn keys_match_case_insensitively_on_postgres() {
|
|
// The notes file says "core.orders"; the live schema reports "Core"/"Orders".
|
|
let mut snapshot = snapshot_with(vec![table_named("Core", "Orders", None)]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
assert_eq!(snapshot.tables[0].note.as_deref(), Some("One row per checkout."));
|
|
}
|
|
|
|
#[test]
|
|
fn column_notes_are_applied_and_marked_local() {
|
|
let mut table = table_named("core", "orders", None);
|
|
table.columns.push(crate::types::ColumnInfo {
|
|
name: "status".to_string(),
|
|
data_type: "text".to_string(),
|
|
comment: Some("Database lifecycle state.".to_string()),
|
|
..Default::default()
|
|
});
|
|
table.column_notes.insert(
|
|
"status".to_string(),
|
|
ColumnNote { note: "Database lifecycle state.".to_string(), source: NoteSource::Database, shadowed: None },
|
|
);
|
|
let mut snapshot = snapshot_with(vec![table]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
let note = snapshot.tables[0].column_notes.get("status").expect("column note");
|
|
assert_eq!(note.note, "State machine.");
|
|
assert_eq!(note.source, NoteSource::Local);
|
|
assert_eq!(note.shadowed.as_deref(), Some("Database lifecycle state."));
|
|
}
|
|
|
|
#[test]
|
|
fn database_column_note_survives_without_a_local_override() {
|
|
let mut table = table_named("core", "users", None);
|
|
table.columns.push(crate::types::ColumnInfo {
|
|
name: "email".to_string(),
|
|
data_type: "text".to_string(),
|
|
comment: Some("Primary contact address.".to_string()),
|
|
..Default::default()
|
|
});
|
|
table.column_notes.insert(
|
|
"email".to_string(),
|
|
ColumnNote { note: "Primary contact address.".to_string(), source: NoteSource::Database, shadowed: None },
|
|
);
|
|
let mut snapshot = snapshot_with(vec![table]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
let note = snapshot.tables[0].column_notes.get("email").expect("database column note");
|
|
assert_eq!(note.note, "Primary contact address.");
|
|
assert_eq!(note.source, NoteSource::Database);
|
|
assert_eq!(note.shadowed, None);
|
|
}
|
|
|
|
#[test]
|
|
fn the_project_note_and_name_are_applied() {
|
|
let mut snapshot = snapshot_with(vec![]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
assert_eq!(snapshot.project.name, "Ecommerce");
|
|
assert_eq!(snapshot.project.note.as_deref(), Some("# Overview"));
|
|
}
|
|
|
|
#[test]
|
|
fn groups_are_copied_and_membership_is_assigned() {
|
|
let mut snapshot = snapshot_with(vec![table_named("core", "orders", None)]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
assert_eq!(snapshot.groups.len(), 1);
|
|
assert_eq!(snapshot.groups[0].id, "order-management");
|
|
assert_eq!(snapshot.groups[0].hue, 28);
|
|
assert_eq!(snapshot.tables[0].group_id.as_deref(), Some("order-management"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_table_referencing_an_undefined_group_is_left_ungrouped() {
|
|
let mut snapshot = snapshot_with(vec![table_named("core", "orders", None)]);
|
|
let mut annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
annotations.groups.clear();
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
assert_eq!(snapshot.tables[0].group_id, None, "a dangling group reference must not be assigned");
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_group_ids_collapse_to_one_entry() {
|
|
// Two TableGroup blocks sharing an id is invalid DBML.
|
|
let mut snapshot = snapshot_with(vec![table_named("core", "orders", None)]);
|
|
let mut annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
let mut dup = annotations.groups[0].clone();
|
|
dup.name = "Duplicate".to_string();
|
|
annotations.groups.push(dup);
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
assert_eq!(snapshot.groups.len(), 1, "duplicate ids must collapse");
|
|
assert_eq!(snapshot.groups[0].name, "Order Management", "first occurrence wins");
|
|
}
|
|
|
|
use crate::docs::SnapshotWarning;
|
|
|
|
#[test]
|
|
fn a_note_for_a_missing_table_is_reported_as_orphaned() {
|
|
// The notes file describes core.orders; the schema no longer has it.
|
|
let mut snapshot = snapshot_with(vec![table_named("core", "customers", None)]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
let orphans = detect_orphans(&snapshot, &annotations, DatabaseType::Postgres);
|
|
assert_eq!(orphans, vec!["core.orders".to_string()]);
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
let orphan_warnings: Vec<&SnapshotWarning> = snapshot
|
|
.warnings
|
|
.iter()
|
|
.filter(|warning| matches!(warning, SnapshotWarning::OrphanedNotes { .. }))
|
|
.collect();
|
|
assert_eq!(orphan_warnings.len(), 1);
|
|
match orphan_warnings[0] {
|
|
SnapshotWarning::OrphanedNotes { count } => assert_eq!(*count, 1),
|
|
other => panic!("unexpected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_note_for_a_missing_column_is_reported_as_orphaned() {
|
|
// The table exists but no longer has the annotated column.
|
|
let mut table = table_named("core", "orders", None);
|
|
table.columns.push(crate::types::ColumnInfo {
|
|
name: "id".to_string(),
|
|
data_type: "integer".to_string(),
|
|
..Default::default()
|
|
});
|
|
let snapshot = snapshot_with(vec![table]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
let orphans = detect_orphans(&snapshot, &annotations, DatabaseType::Postgres);
|
|
assert_eq!(orphans, vec!["core.orders.status".to_string()]);
|
|
}
|
|
|
|
#[test]
|
|
fn nothing_is_orphaned_when_everything_matches() {
|
|
let mut table = table_named("core", "orders", None);
|
|
table.columns.push(crate::types::ColumnInfo {
|
|
name: "status".to_string(),
|
|
data_type: "text".to_string(),
|
|
..Default::default()
|
|
});
|
|
let mut snapshot = snapshot_with(vec![table]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
|
|
assert!(detect_orphans(&snapshot, &annotations, DatabaseType::Postgres).is_empty());
|
|
|
|
apply_annotations(&mut snapshot, &annotations, DatabaseType::Postgres);
|
|
assert!(
|
|
!snapshot.warnings.iter().any(|w| matches!(w, SnapshotWarning::OrphanedNotes { .. })),
|
|
"no orphan warning when everything matches"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn orphan_detection_never_removes_anything_from_the_file() {
|
|
let snapshot = snapshot_with(vec![]);
|
|
let annotations: AnnotationFile = serde_json::from_str(SAMPLE).expect("parse");
|
|
let before = annotations.tables.len();
|
|
|
|
let _ = detect_orphans(&snapshot, &annotations, DatabaseType::Postgres);
|
|
|
|
assert_eq!(annotations.tables.len(), before, "detection must not mutate the file");
|
|
}
|
|
}
|