use hayson_engine::catalog::{IntegrityReport, MigrateOutcome, OrphanRecord, Relation};
use hayson_engine::compaction::CompactionResult;
use hayson_engine::database::{Layout, LayoutMigrationResult, Severity, VerifyReport};
use hayson_engine::query::{AccessPathSummary, OverlayStrategy, PlanBranch, PlanOverlay, QueryStrategy};
use hayson_engine::schema::HaysonRecord;
use hayson_engine::table::{BrokenLink, RefreshOutcome, RotationResult, TableStats};
use hayson_engine::txn::UnitOfWorkResult;
use serde_json::{json, Value};
pub fn migrate_format1_outcome_to_json(outcome: &MigrateOutcome) -> Value {
match outcome {
MigrateOutcome::AlreadyMigrated { catalog } => json!({
"migrated": false,
"catalog": catalog.display().to_string(),
"tables": [],
"removedSchemaFiles": [],
}),
MigrateOutcome::Migrated {
catalog,
tables,
removed_schema_files,
} => json!({
"migrated": true,
"catalog": catalog.display().to_string(),
"tables": tables,
"removedSchemaFiles": removed_schema_files
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
}),
}
}
pub fn record_to_json(record: &HaysonRecord) -> Value {
Value::Object(
record
.iter()
.map(|(field, value)| (field.clone(), value.clone()))
.collect(),
)
}
pub fn broken_links_to_json(broken: &[BrokenLink]) -> Value {
Value::Array(
broken
.iter()
.map(|link| json!({ "field": link.field, "path": link.path }))
.collect(),
)
}
fn strategy_name(strategy: QueryStrategy) -> &'static str {
match strategy {
QueryStrategy::Primary => "primary",
QueryStrategy::Exact => "exact",
QueryStrategy::Sorted => "sorted",
QueryStrategy::Fulltext => "fulltext",
QueryStrategy::Union => "union",
QueryStrategy::Scan => "scan",
}
}
fn plan_branch_to_json(branch: &PlanBranch) -> Value {
json!({
"strategy": strategy_name(branch.strategy),
"field": branch.field,
"candidates": branch.candidates,
})
}
fn overlay_strategy_name(strategy: OverlayStrategy) -> &'static str {
match strategy {
OverlayStrategy::Exact => "exact",
OverlayStrategy::Fulltext => "fulltext",
OverlayStrategy::Scan => "scan",
}
}
fn plan_overlay_to_json(overlay: &PlanOverlay) -> Value {
json!({
"staged": overlay.staged,
"suppressed": overlay.suppressed,
"rescored": overlay.rescored,
"strategy": overlay_strategy_name(overlay.strategy),
"size": overlay.size,
})
}
pub fn plan_to_json(plan: &AccessPathSummary) -> Value {
json!({
"strategy": strategy_name(plan.strategy),
"field": plan.field,
"index": plan.index,
"candidates": plan.candidates,
"ranked": plan.ranked,
"filteredOnProjection": plan.filtered_on_projection,
"reads": plan.reads,
"branches": plan.branches.as_ref().map(|branches| branches.iter().map(plan_branch_to_json).collect::<Vec<_>>()),
"overlay": plan.overlay.as_ref().map(plan_overlay_to_json),
})
}
pub fn query_result_to_json(
keys: &[String],
records: &[HaysonRecord],
matched: usize,
plan: &AccessPathSummary,
) -> Value {
json!({
"keys": keys,
"records": records.iter().map(record_to_json).collect::<Vec<_>>(),
"matched": matched,
"plan": plan_to_json(plan),
})
}
pub fn optional_records_to_json(records: &[Option<HaysonRecord>]) -> Value {
Value::Array(
records
.iter()
.map(|record| record.as_ref().map(record_to_json).unwrap_or(Value::Null))
.collect(),
)
}
pub fn unit_of_work_result_to_json(result: &UnitOfWorkResult) -> Value {
json!({ "id": result.id, "tables": result.tables, "operations": result.operations })
}
pub fn relation_to_json(relation: &Relation) -> Value {
json!({
"name": relation.name,
"from": { "table": relation.from.table, "field": relation.from.field },
"to": { "table": relation.to.table, "field": relation.to.field },
"cardinality": relation.cardinality.as_str(),
"onDelete": relation.on_delete.map(|action| action.as_str()),
"enforce": relation.enforce,
})
}
pub fn relations_to_json(relations: &[Relation]) -> Value {
Value::Array(relations.iter().map(relation_to_json).collect())
}
fn orphan_record_to_json(orphan: &OrphanRecord) -> Value {
json!({
"relation": orphan.relation,
"table": orphan.table,
"field": orphan.field,
"primaryKey": orphan.primary_key,
"value": orphan.value,
})
}
pub fn integrity_report_to_json(report: &IntegrityReport) -> Value {
json!({ "orphans": report.orphans.iter().map(orphan_record_to_json).collect::<Vec<_>>() })
}
pub fn layout_to_json(layout: &Layout) -> Value {
let metadata_file = layout.metadata_file();
let metadata_path = layout.metadata_path().display().to_string();
json!({
"kind": layout.kind().as_str(),
"root": layout.root().display().to_string(),
"metadataFile": metadata_file,
"metadataPath": metadata_path,
"catalogFile": metadata_file,
"catalog": metadata_path,
"manifestFile": metadata_file,
"manifest": metadata_path,
"tables": layout.tables().display().to_string(),
"indexes": layout.indexes().display().to_string(),
"runtime": layout.runtime().display().to_string(),
"lock": layout.lock_path().display().to_string(),
"journal": layout.journal_path().display().to_string(),
})
}
pub fn layout_migration_to_json(result: &LayoutMigrationResult) -> Value {
json!({
"root": result.root.display().to_string(),
"from": result.from.as_str(),
"to": result.to.as_str(),
"moves": result
.moves
.iter()
.map(|step| json!({ "from": step.from.display().to_string(), "to": step.to.display().to_string() }))
.collect::<Vec<_>>(),
"unclaimed": result
.unclaimed
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>(),
"changed": result.changed,
"dryRun": result.dry_run,
})
}
pub fn verify_report_to_json(report: &VerifyReport) -> Value {
json!({
"problems": report
.problems
.iter()
.map(|problem| json!({
"severity": match problem.severity {
Severity::Error => "error",
Severity::Warning => "warning",
},
"code": problem.code,
"table": problem.table,
"file": problem.file,
"offset": problem.offset,
"message": problem.message,
}))
.collect::<Vec<_>>(),
"tablesChecked": report.tables_checked,
"logLinesChecked": report.log_lines_checked,
})
}
pub fn table_stats_to_json(stats: &TableStats) -> Value {
json!({
"records": stats.records,
"liveBytes": stats.live_bytes,
"deadBytes": stats.dead_bytes,
"logBytes": stats.log_bytes,
"queueDepth": stats.queue_depth,
"gen": stats.gen,
"seq": stats.seq,
})
}
pub fn refresh_outcome_to_json(outcome: RefreshOutcome) -> Value {
let (name, entries) = match outcome {
RefreshOutcome::Unchanged => ("unchanged", 0),
RefreshOutcome::CaughtUp { entries } => ("caughtUp", entries),
RefreshOutcome::Reloaded => ("reloaded", 0),
};
json!({ "changed": outcome.changed(), "outcome": name, "entries": entries })
}
pub fn rotation_result_to_json(result: &RotationResult) -> Value {
json!({
"rotated": result.rotated,
"alreadyCurrent": result.already_current,
"skipped": result.skipped,
})
}
pub fn compaction_result_to_json(result: &CompactionResult) -> Value {
json!({
"records": result.records,
"bytesBefore": result.bytes_before,
"bytesAfter": result.bytes_after,
"reclaimedBytes": result.reclaimed_bytes,
})
}
#[cfg(test)]
mod tests {
use hayson_engine::catalog::{RelationCardinality, RelationEnd};
use super::*;
#[test]
fn a_query_result_carries_keys_records_the_match_count_and_the_plan_in_one_document() {
let mut record = HaysonRecord::new();
record.insert("id".into(), json!("u1"));
record.insert("name".into(), json!("Ania"));
let plan = AccessPathSummary {
strategy: QueryStrategy::Exact,
field: Some("email".into()),
index: Some("users_email".into()),
candidates: 1,
ranked: false,
filtered_on_projection: true,
overlay: None,
reads: 1,
branches: None,
};
let described = query_result_to_json(&["u1".to_string()], &[record], 7, &plan);
assert_eq!(described["keys"], json!(["u1"]));
assert_eq!(described["records"], json!([{ "id": "u1", "name": "Ania" }]));
assert_eq!(
described["matched"],
json!(7),
"the page is one key wide, the match count is not"
);
assert_eq!(described["plan"]["strategy"], json!("exact"));
assert_eq!(described["plan"]["index"], json!("users_email"));
assert_eq!(described["plan"]["branches"], Value::Null);
}
#[test]
fn a_plan_carries_ranked_and_filtered_on_projection_under_the_names_the_ts_query_plan_declares() {
let plan = AccessPathSummary {
strategy: QueryStrategy::Fulltext,
field: None,
index: None,
candidates: 2,
ranked: true,
filtered_on_projection: false,
overlay: None,
reads: 2,
branches: None,
};
let described = plan_to_json(&plan);
assert_eq!(described["ranked"], json!(true));
assert_eq!(described["filteredOnProjection"], json!(false));
}
#[test]
fn every_query_strategy_has_a_lower_case_name_the_ts_union_already_uses() {
let all = [
QueryStrategy::Primary,
QueryStrategy::Exact,
QueryStrategy::Sorted,
QueryStrategy::Fulltext,
QueryStrategy::Union,
QueryStrategy::Scan,
];
let names: Vec<&str> = all.into_iter().map(strategy_name).collect();
assert_eq!(names, vec!["primary", "exact", "sorted", "fulltext", "union", "scan"]);
}
#[test]
fn a_relation_is_flattened_into_the_shape_the_ts_relation_type_declares() {
let relation = Relation {
name: "orders_user".into(),
from: RelationEnd {
table: "orders".into(),
field: "userId".into(),
},
to: RelationEnd {
table: "users".into(),
field: "id".into(),
},
cardinality: RelationCardinality::ManyToOne,
on_delete: None,
enforce: Some(true),
};
let described = relations_to_json(&[relation]);
assert_eq!(described[0]["from"], json!({ "table": "orders", "field": "userId" }));
assert_eq!(described[0]["onDelete"], Value::Null);
assert_eq!(described[0]["enforce"], json!(true));
}
#[test]
fn a_get_many_answer_keeps_a_hole_where_the_table_held_no_record() {
let mut record = HaysonRecord::new();
record.insert("id".into(), json!("u1"));
let described = optional_records_to_json(&[None, Some(record), None]);
assert_eq!(described, json!([Value::Null, { "id": "u1" }, Value::Null]));
}
#[test]
fn a_committed_unit_reports_the_id_the_tables_and_how_many_operations_it_carried() {
let result = UnitOfWorkResult {
id: "42-1-0".into(),
tables: vec!["users".into(), "audit".into()],
operations: 3,
};
let described = unit_of_work_result_to_json(&result);
assert_eq!(
described,
json!({ "id": "42-1-0", "tables": ["users", "audit"], "operations": 3 })
);
}
#[test]
fn table_stats_cross_the_boundary_under_the_camel_case_names_the_ts_type_declares() {
let stats = TableStats {
records: 2,
live_bytes: 40,
dead_bytes: 10,
log_bytes: 50,
queue_depth: 0,
gen: 1,
seq: 7,
};
let described = table_stats_to_json(&stats);
assert_eq!(
described,
json!({ "records": 2, "liveBytes": 40, "deadBytes": 10, "logBytes": 50, "queueDepth": 0, "gen": 1, "seq": 7 })
);
}
#[test]
fn each_refresh_outcome_names_itself_and_agrees_with_its_own_changed_flag() {
let unchanged = refresh_outcome_to_json(RefreshOutcome::Unchanged);
let caught_up = refresh_outcome_to_json(RefreshOutcome::CaughtUp { entries: 3 });
let reloaded = refresh_outcome_to_json(RefreshOutcome::Reloaded);
assert_eq!(
unchanged,
json!({ "changed": false, "outcome": "unchanged", "entries": 0 })
);
assert_eq!(
caught_up,
json!({ "changed": true, "outcome": "caughtUp", "entries": 3 })
);
assert_eq!(
reloaded,
json!({ "changed": true, "outcome": "reloaded", "entries": 0 })
);
}
#[test]
fn a_record_keeps_its_field_order_across_the_boundary() {
let mut record = HaysonRecord::new();
record.insert("id".into(), json!("u1"));
record.insert("age".into(), json!(31));
let text = record_to_json(&record).to_string();
assert_eq!(
text, r#"{"age":31,"id":"u1"}"#,
"HaysonRecord is a BTreeMap — field order is its key order"
);
}
}