use std::borrow::Cow;
use std::fmt;
use rich::table::Table;
use rich::{Console, ConsoleOptions, Renderable, Segment, Style, Text, Tree};
use serde_json::Value;
mod diff;
pub mod json;
pub mod model;
pub mod sql;
mod timeline;
pub use model::{
Composition, Constraint, DataType, Field, FieldKind, ForeignKey, Literal, Schema, Unexpanded,
};
pub use timeline::SchemaTimeline;
pub const STYLES: &[(&str, &str)] = &[
("schema.name", "bold"),
("schema.required", "bold red"),
("schema.type", "cyan"),
("schema.constraint", "yellow"),
("schema.description", "dim"),
("schema.ref", "dim italic"),
("schema.branch", "magenta"),
("schema.added", "green"),
("schema.removed", "red"),
("schema.changed", "yellow"),
("schema.breaking", "bold red"),
];
fn theme_style(console: &Console, key: &str) -> Style {
if let Some(style) = console.theme().get(key) {
return style.clone();
}
STYLES
.iter()
.find(|(name, _)| *name == key)
.and_then(|(_, spec)| Style::parse(spec).ok())
.unwrap_or_default()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SchemaError(String);
impl fmt::Display for SchemaError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for SchemaError {}
pub fn parse(json: &str) -> Result<Value, SchemaError> {
let value: Value =
serde_json::from_str(json).map_err(|e| SchemaError(format!("not JSON: {e}")))?;
if !(value.is_object() || value.is_boolean()) {
return Err(SchemaError(
"a JSON Schema is an object (or true or false)".into(),
));
}
Ok(value)
}
const DEPTH: usize = 32;
pub const MAX_ENTRIES: usize = 10_000;
pub const MAX_CHANGES: usize = 10_000;
pub const MAX_COMPARISONS: usize = 100_000;
#[derive(Clone, Debug)]
enum Source {
Json(Value),
Model(Schema),
}
#[derive(Clone, Debug)]
#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
pub struct SchemaTree {
source: Source,
title: Option<String>,
max_depth: usize,
}
impl SchemaTree {
pub fn new(schema: Value) -> Self {
SchemaTree {
source: Source::Json(schema),
title: None,
max_depth: DEPTH,
}
}
pub fn from_json(json: &str) -> Result<Self, SchemaError> {
parse(json).map(SchemaTree::new)
}
pub fn from_model(schema: Schema) -> Self {
SchemaTree {
source: Source::Model(schema),
title: None,
max_depth: DEPTH,
}
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn max_depth(mut self, depth: usize) -> Self {
self.max_depth = depth;
self
}
pub fn schema(&self) -> Option<&Value> {
match &self.source {
Source::Json(value) => Some(value),
Source::Model(_) => None,
}
}
pub fn model(&self) -> Option<&Schema> {
match &self.source {
Source::Json(_) => None,
Source::Model(schema) => Some(schema),
}
}
pub fn root(&self) -> (Field, bool) {
match &self.source {
Source::Json(value) => json::to_field(value, self.title.as_deref(), self.max_depth),
Source::Model(schema) => {
let mut root = model_root(schema, self.title.as_deref());
let mut budget = MAX_ENTRIES - 1;
let cut = limit(&mut root, &mut budget);
(root, cut)
}
}
}
pub fn tree(&self, console: &Console) -> Tree {
let (root, truncated) = self.root();
let mut tree = draw(&root, 0, self.max_depth, console);
let truncated = truncated || self.model().is_some_and(Schema::is_truncated);
if truncated {
tree.add(Text::styled(
format!("… (the tree stops at {MAX_ENTRIES} entries)"),
theme_style(console, "schema.description"),
));
}
tree
}
}
fn model_root(schema: &Schema, title: Option<&str>) -> Field {
let name = title.or(schema.name()).unwrap_or("schema");
let plural = |n: usize, what: &str| format!("{n} {what}{}", if n == 1 { "" } else { "s" });
let label = match (schema.len(), schema.tables().len()) {
(0, tables) if tables > 0 => plural(tables, "table"),
(fields, 0) => plural(fields, "field"),
(fields, tables) => format!("{}, {}", plural(fields, "field"), plural(tables, "table")),
};
let mut root = diff::record(schema, name).with_native_type(label);
if let DataType::Struct(children) = root.data_type_mut() {
for table in schema.tables() {
let name = table.name().unwrap_or("table");
children.push(diff::record(table, name));
}
}
root
}
fn limit(field: &mut Field, budget: &mut usize) -> bool {
let mut cut = false;
let mut elide = false;
match field.data_type_mut() {
DataType::Struct(children) => {
let mut keep = 0;
for child in children.iter_mut() {
if *budget == 0 {
cut = true;
break;
}
*budget -= 1;
keep += 1;
cut |= limit(child, budget);
}
children.truncate(keep);
}
DataType::List(item) => {
if *budget == 0 {
elide = true;
} else {
*budget -= 1;
cut |= limit(item, budget);
}
}
DataType::Map { key, value } => {
if *budget < 2 {
elide = true;
} else {
*budget -= 2;
cut |= limit(key, budget);
cut |= limit(value, budget);
}
}
_ => {}
}
if elide {
let taken = std::mem::replace(field, Field::new("", DataType::Any));
*field = taken.elided(true);
return true;
}
cut
}
fn label(field: &Field, console: &Console) -> Text {
let constraint_text = |field: &Field| -> Vec<String> {
let mut shown: Vec<String> = field
.constraints()
.iter()
.map(ToString::to_string)
.filter(|text| !text.is_empty())
.collect();
shown.extend(
field
.metadata()
.iter()
.map(|(key, value)| format!("{key}={}", Literal::new(value.as_str()).short())),
);
shown
};
match field.kind() {
FieldKind::Group(_) => {
return Text::styled(field.name(), theme_style(console, "schema.branch"));
}
FieldKind::Condition => {
let mut label = Text::styled(field.name(), theme_style(console, "schema.branch"));
label.append(" ", None);
label.append(
&field.type_label(),
Some(theme_style(console, "schema.type").into()),
);
let shown = constraint_text(field);
if !shown.is_empty() {
label.append(" ", None);
label.append(
&shown.join(", "),
Some(theme_style(console, "schema.constraint").into()),
);
}
return label;
}
_ => {}
}
let mut label = Text::new("");
label.append(
field.name(),
Some(theme_style(console, "schema.name").into()),
);
if field.is_required() {
label.append(
" (required)",
Some(theme_style(console, "schema.required").into()),
);
}
let recursive = matches!(field.unexpanded(), Some(Unexpanded::Recursive(_)));
let unresolved = matches!(field.unexpanded(), Some(Unexpanded::Unresolved { .. }));
if !unresolved {
label.append(" ", None);
label.append(
&field.type_label(),
Some(theme_style(console, "schema.type").into()),
);
}
let shown = constraint_text(field);
if !shown.is_empty() && !recursive {
label.append(" ", None);
label.append(
&shown.join(", "),
Some(theme_style(console, "schema.constraint").into()),
);
}
if let Some(reference) = field.reference() {
label.append(
&format!(" → {reference}"),
Some(theme_style(console, "schema.ref").into()),
);
}
if let Some(unexpanded) = field.unexpanded() {
label.append(" ", None);
label.append(
&unexpanded.to_string(),
Some(theme_style(console, "schema.ref").into()),
);
}
let description = field
.description()
.and_then(|d| d.lines().map(str::trim).find(|l| !l.is_empty()));
if let Some(description) = description {
label.append(
&format!(" {description}"),
Some(theme_style(console, "schema.description").into()),
);
}
label
}
fn draw(field: &Field, depth: usize, max_depth: usize, console: &Console) -> Tree {
let mut tree = Tree::new(label(field, console));
if field.unexpanded().is_some() {
return tree;
}
let has_children = field.children().next().is_some();
if field.is_elided() || (has_children && depth >= max_depth) {
tree.add(Text::styled(
"…",
theme_style(console, "schema.description"),
));
return tree;
}
for child in field.children() {
let next = match child.kind() {
FieldKind::Group(_) => depth,
_ => depth + 1,
};
tree.add_tree(draw(child, next, max_depth, console));
}
tree
}
impl Renderable for SchemaTree {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
self.tree(console).rich_render(console, options)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
self.tree(console).measure(console, options)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ChangeKind {
Added,
Removed,
Changed,
}
impl ChangeKind {
pub fn marker(self) -> &'static str {
match self {
ChangeKind::Added => "+",
ChangeKind::Removed => "-",
ChangeKind::Changed => "~",
}
}
pub fn style_key(self) -> &'static str {
match self {
ChangeKind::Added => "schema.added",
ChangeKind::Removed => "schema.removed",
ChangeKind::Changed => "schema.changed",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Change {
pub kind: ChangeKind,
pub path: String,
pub detail: String,
pub breaking: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SchemaDiff {
changes: Vec<Change>,
truncated: bool,
old_name: String,
new_name: String,
}
impl SchemaDiff {
pub fn new(old: &Value, new: &Value) -> Self {
let mut differ = diff::Differ::new();
let a = json::JsonNode::new(json::Resolver { root: old }, Cow::Borrowed(old));
let b = json::JsonNode::new(json::Resolver { root: new }, Cow::Borrowed(new));
differ.compare(&a, &b, "", 0);
SchemaDiff::from_differ(differ)
}
pub fn models(old: &Schema, new: &Schema) -> Self {
let mut differ = diff::Differ::new();
diff::compare_schemas(&mut differ, old, new);
if old.is_truncated() || new.is_truncated() {
differ.truncated = true;
}
SchemaDiff::from_differ(differ)
}
fn from_differ(differ: diff::Differ) -> Self {
SchemaDiff {
changes: differ.changes,
truncated: differ.truncated,
old_name: "old".into(),
new_name: "new".into(),
}
}
#[must_use]
pub fn names(mut self, old: impl Into<String>, new: impl Into<String>) -> Self {
self.old_name = old.into();
self.new_name = new.into();
self
}
pub fn changes(&self) -> &[Change] {
&self.changes
}
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
pub fn is_truncated(&self) -> bool {
self.truncated
}
fn stopped(&self) -> String {
if self.truncated {
format!(
" (stopped after {MAX_CHANGES} changes or {MAX_COMPARISONS} comparisons: \
more may differ)"
)
} else {
String::new()
}
}
pub fn breaking(&self) -> usize {
self.changes.iter().filter(|c| c.breaking).count()
}
pub fn summary(&self) -> String {
if self.changes.is_empty() {
return format!(
"{} → {}: no changes{}",
self.old_name,
self.new_name,
self.stopped()
);
}
let count = self.changes.len();
let breaking = self.breaking();
format!(
"{} → {}: {count} change{}, {breaking} breaking{}",
self.old_name,
self.new_name,
if count == 1 { "" } else { "s" },
self.stopped()
)
}
}
impl Renderable for SchemaDiff {
fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
if self.changes.is_empty() {
let text = Text::new(format!(
"{} → {}: no changes{}",
self.old_name,
self.new_name,
self.stopped()
));
return text.rich_render(console, options);
}
let mut table = Table::new()
.caption(self.summary())
.caption_justify(rich::Justify::Left);
table.add_column("");
table.add_column("Where");
table.add_column("Change");
table.add_column("");
for change in &self.changes {
let style = theme_style(console, change.kind.style_key());
let breaking = if change.breaking {
Text::styled("breaking", theme_style(console, "schema.breaking"))
} else {
Text::new("")
};
table.add_row_text(vec![
Text::styled(change.kind.marker(), style.clone()),
Text::styled(change.path.clone(), theme_style(console, "schema.name")),
Text::styled(change.detail.clone(), style),
breaking,
]);
}
table.rich_render(console, options)
}
fn measure(&self, console: &Console, options: &ConsoleOptions) -> rich::measure::Measurement {
if self.changes.is_empty() {
return Text::new(format!(
"{} → {}: no changes{}",
self.old_name,
self.new_name,
self.stopped()
))
.measure(console, options);
}
let mut table = Table::new();
table.add_column("");
table.add_column("Where");
table.add_column("Change");
table.add_column("");
for change in &self.changes {
let breaking = if change.breaking { "breaking" } else { "" };
table.add_row(&[change.kind.marker(), &change.path, &change.detail, breaking]);
}
table.measure(console, options)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn render(r: &dyn Renderable, width: usize) -> String {
Console::builder()
.width(width)
.color_system(None)
.build()
.render_to_string(r)
}
#[test]
fn refs_resolve_and_cycles_stop() {
let schema = json!({
"$defs": {
"node": {
"type": "object",
"properties": {
"value": {"type": "string"},
"next": {"$ref": "#/$defs/node"}
}
}
},
"$ref": "#/$defs/node"
});
assert_eq!(
render(&SchemaTree::new(schema), 80),
"schema object → #/$defs/node\n\
├── value string\n\
└── next object → #/$defs/node (recursive)"
);
}
#[test]
fn branches_items_and_external_refs() {
let schema = json!({
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
"pay": {"oneOf": [{"title": "Card", "type": "object"}, {"type": "null"}]},
"other": {"$ref": "other.json#/x"},
"missing": {"$ref": "#/nope"}
}
});
assert_eq!(
render(&SchemaTree::new(schema), 80),
"schema object\n\
├── tags array unique items\n\
│ └── [items] string minLength=1\n\
├── pay one of\n\
│ ├── [1] Card object\n\
│ └── [2] null\n\
├── other → other.json#/x (another document)\n\
└── missing → #/nope (not found)"
);
}
#[test]
fn boolean_schemas_and_anchors() {
let schema = json!({
"properties": {
"anything": true,
"nothing": false,
"anchored": {"$ref": "#thing"}
},
"$defs": {"t": {"$anchor": "thing", "type": "integer"}}
});
let out = render(&SchemaTree::new(schema), 80);
assert!(out.contains("anything any"), "{out}");
assert!(out.contains("nothing never"), "{out}");
assert!(out.contains("anchored integer → #thing"), "{out}");
}
#[test]
fn diff_reports_each_kind() {
let old = json!({
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "integer"},
"kind": {"enum": ["a", "b"]},
"name": {"type": "string", "maxLength": 10},
"gone": {"type": "boolean"}
}
});
let new = json!({
"type": "object",
"required": ["id", "name"],
"properties": {
"id": {"type": ["integer", "null"]},
"kind": {"enum": ["a", "c"]},
"name": {"type": "string", "maxLength": 5},
"fresh": {"type": "number"}
}
});
let diff = SchemaDiff::new(&old, &new);
let lines: Vec<String> = diff
.changes()
.iter()
.map(|c| {
format!(
"{} {} {}{}",
c.kind.marker(),
c.path,
c.detail,
if c.breaking { " !" } else { "" }
)
})
.collect();
assert_eq!(
lines,
[
"~ name became required !",
"- gone property removed (boolean)",
"~ id type integer → integer | null",
"+ kind enum value \"c\" added",
"- kind enum value \"b\" removed !",
"~ name maxLength 10 → 5 !",
"+ fresh property added (number)",
]
);
assert_eq!(diff.breaking(), 3);
let out = render(&diff.names("v1", "v2"), 80);
assert!(out.contains("v1 → v2: 7 changes, 3 breaking"), "{out}");
assert!(out.contains("breaking"), "{out}");
}
#[test]
fn removing_a_property_breaks_only_what_now_refuses_it() {
let old = json!({"properties": {"gone": {"type": "string"}, "x_tag": {}}});
let removed = |new: Value| -> Vec<bool> {
SchemaDiff::new(&old, &new)
.changes()
.iter()
.filter(|c| c.kind == ChangeKind::Removed)
.map(|c| c.breaking)
.collect()
};
assert_eq!(removed(json!({})), [false, false]);
assert_eq!(
removed(json!({"additionalProperties": true})),
[false, false]
);
assert_eq!(removed(json!({"additionalProperties": {}})), [false, false]);
assert_eq!(
removed(json!({"additionalProperties": false})),
[true, true]
);
assert_eq!(
removed(json!({"additionalProperties": {"type": "integer"}})),
[true, true]
);
assert_eq!(
removed(json!({
"patternProperties": {"^x_": true},
"additionalProperties": false
})),
[true, false]
);
assert_eq!(
removed(json!({"patternProperties": {"^x_": {"type": "integer"}}})),
[false, true]
);
}
#[test]
fn keywords_beside_a_ref_are_compared() {
let schema = |max: u64| {
json!({
"$defs": {"s": {"type": "string"}},
"properties": {"name": {"$ref": "#/$defs/s", "maxLength": max}}
})
};
let diff = SchemaDiff::new(&schema(10), &schema(5));
let details: Vec<(&str, &str, bool)> = diff
.changes()
.iter()
.map(|c| (c.path.as_str(), c.detail.as_str(), c.breaking))
.collect();
assert_eq!(details, [("name", "maxLength 10 → 5", true)]);
let mut new = schema(10);
new["$defs"]["s"]["type"] = json!("integer");
assert_eq!(SchemaDiff::new(&schema(10), &new).breaking(), 1);
assert!(SchemaDiff::new(&schema(10), &schema(10)).is_empty());
}
#[test]
fn a_keyword_in_both_the_ref_and_its_target_keeps_both() {
let schema = |target: u64, sibling: u64| {
json!({
"$defs": {"s": {"type": "string", "maxLength": target}},
"properties": {"name": {"$ref": "#/$defs/s", "maxLength": sibling}}
})
};
let diff = SchemaDiff::new(&schema(5, 3), &schema(2, 3));
assert_eq!(diff.breaking(), 1, "{:?}", diff.changes());
let diff = SchemaDiff::new(&schema(5, 3), &schema(5, 1));
assert_eq!(diff.breaking(), 1, "{:?}", diff.changes());
assert!(SchemaDiff::new(&schema(5, 3), &schema(5, 3)).is_empty());
}
#[test]
fn recursive_refs_with_siblings_diff_without_looping() {
let schema = |extra: &str| {
json!({
"$defs": {"node": {"properties": {
"next": {"$ref": "#/$defs/node", "description": "the next one"},
extra: {"type": "string"}
}}},
"$ref": "#/$defs/node",
"title": "List"
})
};
let diff = SchemaDiff::new(&schema("a"), &schema("b"));
assert_eq!(diff.changes().len(), 2, "{:?}", diff.changes());
}
#[test]
fn recursive_schemas_diff_without_looping() {
let schema = |extra: &str| {
json!({
"$defs": {"node": {"properties": {
"next": {"$ref": "#/$defs/node"},
extra: {"type": "string"}
}}},
"$ref": "#/$defs/node"
})
};
let diff = SchemaDiff::new(&schema("a"), &schema("b"));
assert_eq!(diff.changes().len(), 2, "{:?}", diff.changes());
assert!(SchemaDiff::new(&schema("a"), &schema("a")).is_empty());
assert_eq!(
render(&SchemaDiff::new(&schema("a"), &schema("a")), 40),
"old → new: no changes"
);
}
#[test]
fn parse_refuses_non_schemas() {
assert!(parse("[1]").is_err());
assert!(parse("{").is_err());
assert!(parse("true").is_ok());
}
#[test]
fn model_trees_stop_at_max_entries() {
let wide = Schema::new(
(0..MAX_ENTRIES + 5).map(|i| Field::new(format!("f{i}"), DataType::Integer)),
);
let (_, truncated) = SchemaTree::from_model(wide.clone()).root();
assert!(truncated);
let out = render(&SchemaTree::from_model(wide), 60);
assert_eq!(out.lines().count(), MAX_ENTRIES + 1);
assert!(
out.ends_with("… (the tree stops at 10000 entries)"),
"{}",
&out[out.len() - 80..]
);
let small = Schema::new([Field::new("a", DataType::list(DataType::Integer))]);
assert!(!SchemaTree::from_model(small).root().1);
let deep = Schema::new(
(0..MAX_ENTRIES - 1)
.map(|i| Field::new(format!("f{i}"), DataType::list(DataType::Integer))),
);
let (root, truncated) = SchemaTree::from_model(deep).root();
assert!(truncated);
assert!(root.children().last().unwrap().is_elided());
}
#[test]
fn model_trees_draw_tables_and_stop_at_their_depth() {
let schema = Schema::new([Field::new(
"a",
DataType::Struct(vec![Field::new(
"b",
DataType::Struct(vec![Field::new("c", DataType::Integer)]),
)]),
)
.with_description("first\nsecond")]);
assert_eq!(
render(&SchemaTree::from_model(schema.clone()), 40),
"schema 1 field\n\
└── a struct first\n \
└── b struct\n \
└── c integer"
);
assert_eq!(
render(&SchemaTree::from_model(schema.clone()).max_depth(1), 40),
"schema 1 field\n\
└── a struct first\n \
└── …"
);
let tree = SchemaTree::from_model(schema.truncated(true));
assert!(tree.schema().is_none() && tree.model().is_some());
assert!(render(&tree, 60).ends_with("(the tree stops at 10000 entries)"));
}
}