1use hayson_engine::catalog::{IntegrityReport, MigrateOutcome, OrphanRecord, Relation};
2use hayson_engine::compaction::CompactionResult;
3use hayson_engine::database::{
4 BackupResult, Layout, LayoutMigrationResult, PackResult, RestoreResult, Severity, VerifyReport,
5};
6use hayson_engine::query::{AccessPathSummary, OverlayStrategy, PlanBranch, PlanOverlay, QueryStrategy};
7use hayson_engine::schema::HaysonRecord;
8use hayson_engine::table::{BrokenLink, ImageInfo, ImageUsage, RefreshOutcome, RotationResult, TableStats};
9use hayson_engine::txn::UnitOfWorkResult;
10use serde_json::{json, Value};
11
12pub fn migrate_format1_outcome_to_json(outcome: &MigrateOutcome) -> Value {
13 match outcome {
14 MigrateOutcome::AlreadyMigrated { catalog } => json!({
15 "migrated": false,
16 "catalog": catalog.display().to_string(),
17 "tables": [],
18 "removedSchemaFiles": [],
19 }),
20 MigrateOutcome::Migrated {
21 catalog,
22 tables,
23 removed_schema_files,
24 } => json!({
25 "migrated": true,
26 "catalog": catalog.display().to_string(),
27 "tables": tables,
28 "removedSchemaFiles": removed_schema_files
29 .iter()
30 .map(|path| path.display().to_string())
31 .collect::<Vec<_>>(),
32 }),
33 }
34}
35
36pub fn record_to_json(record: &HaysonRecord) -> Value {
37 Value::Object(
38 record
39 .iter()
40 .map(|(field, value)| (field.clone(), value.clone()))
41 .collect(),
42 )
43}
44
45pub fn broken_links_to_json(broken: &[BrokenLink]) -> Value {
49 Value::Array(
50 broken
51 .iter()
52 .map(|link| json!({ "field": link.field, "path": link.path }))
53 .collect(),
54 )
55}
56
57pub fn images_to_json(images: &[ImageInfo]) -> Value {
60 Value::Array(
61 images
62 .iter()
63 .map(|image| {
64 json!({
65 "physicalPath": image.physical_path,
66 "bytes": image.bytes,
67 "missing": image.is_missing(),
68 "referencedBy": image
69 .referenced_by
70 .iter()
71 .map(|r| json!({ "key": r.key, "field": r.field, "virtualPath": r.virtual_path }))
72 .collect::<Vec<_>>(),
73 })
74 })
75 .collect(),
76 )
77}
78
79pub fn image_usage_to_json(usage: &ImageUsage) -> Value {
81 json!({
82 "count": usage.count,
83 "bytes": usage.bytes,
84 "orphanCount": usage.orphan_count,
85 "orphanBytes": usage.orphan_bytes,
86 })
87}
88
89pub fn backup_result_to_json(result: &BackupResult) -> Value {
91 json!({ "dest": result.dest.display().to_string(), "files": result.files, "bytes": result.bytes })
92}
93
94pub fn pack_result_to_json(result: &PackResult) -> Value {
96 json!({
97 "dest": result.dest.display().to_string(),
98 "files": result.files,
99 "bytes": result.bytes,
100 "archiveBytes": result.archive_bytes,
101 })
102}
103
104pub fn restore_result_to_json(result: &RestoreResult) -> Value {
106 json!({
107 "dir": result.dir.display().to_string(),
108 "files": result.files,
109 "bytes": result.bytes,
110 "replaced": result.replaced,
111 })
112}
113
114fn strategy_name(strategy: QueryStrategy) -> &'static str {
115 match strategy {
116 QueryStrategy::Primary => "primary",
117 QueryStrategy::Exact => "exact",
118 QueryStrategy::Sorted => "sorted",
119 QueryStrategy::Fulltext => "fulltext",
120 QueryStrategy::Union => "union",
121 QueryStrategy::Scan => "scan",
122 }
123}
124
125fn plan_branch_to_json(branch: &PlanBranch) -> Value {
126 json!({
127 "strategy": strategy_name(branch.strategy),
128 "field": branch.field,
129 "candidates": branch.candidates,
130 })
131}
132
133fn overlay_strategy_name(strategy: OverlayStrategy) -> &'static str {
134 match strategy {
135 OverlayStrategy::Exact => "exact",
136 OverlayStrategy::Fulltext => "fulltext",
137 OverlayStrategy::Scan => "scan",
138 }
139}
140
141fn plan_overlay_to_json(overlay: &PlanOverlay) -> Value {
142 json!({
143 "staged": overlay.staged,
144 "suppressed": overlay.suppressed,
145 "rescored": overlay.rescored,
146 "strategy": overlay_strategy_name(overlay.strategy),
147 "size": overlay.size,
148 })
149}
150
151pub fn plan_to_json(plan: &AccessPathSummary) -> Value {
152 json!({
153 "strategy": strategy_name(plan.strategy),
154 "field": plan.field,
155 "index": plan.index,
156 "candidates": plan.candidates,
157 "ranked": plan.ranked,
158 "filteredOnProjection": plan.filtered_on_projection,
159 "reads": plan.reads,
160 "branches": plan.branches.as_ref().map(|branches| branches.iter().map(plan_branch_to_json).collect::<Vec<_>>()),
161 "overlay": plan.overlay.as_ref().map(plan_overlay_to_json),
162 })
163}
164
165pub fn query_result_to_json(
170 keys: &[String],
171 records: &[HaysonRecord],
172 matched: usize,
173 plan: &AccessPathSummary,
174) -> Value {
175 json!({
176 "keys": keys,
177 "records": records.iter().map(record_to_json).collect::<Vec<_>>(),
178 "matched": matched,
179 "plan": plan_to_json(plan),
180 })
181}
182
183pub fn optional_records_to_json(records: &[Option<HaysonRecord>]) -> Value {
187 Value::Array(
188 records
189 .iter()
190 .map(|record| record.as_ref().map(record_to_json).unwrap_or(Value::Null))
191 .collect(),
192 )
193}
194
195pub fn unit_of_work_result_to_json(result: &UnitOfWorkResult) -> Value {
199 json!({ "id": result.id, "tables": result.tables, "operations": result.operations })
200}
201
202pub fn relation_to_json(relation: &Relation) -> Value {
203 json!({
204 "name": relation.name,
205 "from": { "table": relation.from.table, "field": relation.from.field },
206 "to": { "table": relation.to.table, "field": relation.to.field },
207 "cardinality": relation.cardinality.as_str(),
208 "onDelete": relation.on_delete.map(|action| action.as_str()),
209 "enforce": relation.enforce,
210 })
211}
212
213pub fn relations_to_json(relations: &[Relation]) -> Value {
214 Value::Array(relations.iter().map(relation_to_json).collect())
215}
216
217fn orphan_record_to_json(orphan: &OrphanRecord) -> Value {
218 json!({
219 "relation": orphan.relation,
220 "table": orphan.table,
221 "field": orphan.field,
222 "primaryKey": orphan.primary_key,
223 "value": orphan.value,
224 })
225}
226
227pub fn integrity_report_to_json(report: &IntegrityReport) -> Value {
228 json!({ "orphans": report.orphans.iter().map(orphan_record_to_json).collect::<Vec<_>>() })
229}
230
231pub fn layout_to_json(layout: &Layout) -> Value {
236 let metadata_file = layout.metadata_file();
237 let metadata_path = layout.metadata_path().display().to_string();
238 json!({
239 "kind": layout.kind().as_str(),
240 "root": layout.root().display().to_string(),
241 "metadataFile": metadata_file,
242 "metadataPath": metadata_path,
243 "catalogFile": metadata_file,
244 "catalog": metadata_path,
245 "manifestFile": metadata_file,
246 "manifest": metadata_path,
247 "tables": layout.tables().display().to_string(),
248 "indexes": layout.indexes().display().to_string(),
249 "runtime": layout.runtime().display().to_string(),
250 "lock": layout.lock_path().display().to_string(),
251 "journal": layout.journal_path().display().to_string(),
252 })
253}
254
255pub fn layout_migration_to_json(result: &LayoutMigrationResult) -> Value {
259 json!({
260 "root": result.root.display().to_string(),
261 "from": result.from.as_str(),
262 "to": result.to.as_str(),
263 "moves": result
264 .moves
265 .iter()
266 .map(|step| json!({ "from": step.from.display().to_string(), "to": step.to.display().to_string() }))
267 .collect::<Vec<_>>(),
268 "unclaimed": result
269 .unclaimed
270 .iter()
271 .map(|path| path.display().to_string())
272 .collect::<Vec<_>>(),
273 "changed": result.changed,
274 "dryRun": result.dry_run,
275 })
276}
277
278pub fn verify_report_to_json(report: &VerifyReport) -> Value {
281 json!({
282 "problems": report
283 .problems
284 .iter()
285 .map(|problem| json!({
286 "severity": match problem.severity {
287 Severity::Error => "error",
288 Severity::Warning => "warning",
289 },
290 "code": problem.code,
291 "table": problem.table,
292 "file": problem.file,
293 "offset": problem.offset,
294 "message": problem.message,
295 }))
296 .collect::<Vec<_>>(),
297 "tablesChecked": report.tables_checked,
298 "logLinesChecked": report.log_lines_checked,
299 })
300}
301
302pub fn table_stats_to_json(stats: &TableStats) -> Value {
303 json!({
304 "records": stats.records,
305 "liveBytes": stats.live_bytes,
306 "deadBytes": stats.dead_bytes,
307 "logBytes": stats.log_bytes,
308 "queueDepth": stats.queue_depth,
309 "gen": stats.gen,
310 "seq": stats.seq,
311 })
312}
313
314pub fn refresh_outcome_to_json(outcome: RefreshOutcome) -> Value {
318 let (name, entries) = match outcome {
319 RefreshOutcome::Unchanged => ("unchanged", 0),
320 RefreshOutcome::CaughtUp { entries } => ("caughtUp", entries),
321 RefreshOutcome::Reloaded => ("reloaded", 0),
322 };
323 json!({ "changed": outcome.changed(), "outcome": name, "entries": entries })
324}
325
326pub fn rotation_result_to_json(result: &RotationResult) -> Value {
329 json!({
330 "rotated": result.rotated,
331 "alreadyCurrent": result.already_current,
332 "skipped": result.skipped,
333 })
334}
335
336pub fn compaction_result_to_json(result: &CompactionResult) -> Value {
338 json!({
339 "records": result.records,
340 "bytesBefore": result.bytes_before,
341 "bytesAfter": result.bytes_after,
342 "reclaimedBytes": result.reclaimed_bytes,
343 })
344}
345
346#[cfg(test)]
347mod tests {
348 use hayson_engine::catalog::{RelationCardinality, RelationEnd};
349
350 use super::*;
351
352 #[test]
353 fn a_query_result_carries_keys_records_the_match_count_and_the_plan_in_one_document() {
354 let mut record = HaysonRecord::new();
355 record.insert("id".into(), json!("u1"));
356 record.insert("name".into(), json!("Ania"));
357 let plan = AccessPathSummary {
358 strategy: QueryStrategy::Exact,
359 field: Some("email".into()),
360 index: Some("users_email".into()),
361 candidates: 1,
362 ranked: false,
363 filtered_on_projection: true,
364 overlay: None,
365 reads: 1,
366 branches: None,
367 };
368
369 let described = query_result_to_json(&["u1".to_string()], &[record], 7, &plan);
370
371 assert_eq!(described["keys"], json!(["u1"]));
372 assert_eq!(described["records"], json!([{ "id": "u1", "name": "Ania" }]));
373 assert_eq!(
374 described["matched"],
375 json!(7),
376 "the page is one key wide, the match count is not"
377 );
378 assert_eq!(described["plan"]["strategy"], json!("exact"));
379 assert_eq!(described["plan"]["index"], json!("users_email"));
380 assert_eq!(described["plan"]["branches"], Value::Null);
381 }
382
383 #[test]
384 fn a_plan_carries_ranked_and_filtered_on_projection_under_the_names_the_ts_query_plan_declares() {
385 let plan = AccessPathSummary {
386 strategy: QueryStrategy::Fulltext,
387 field: None,
388 index: None,
389 candidates: 2,
390 ranked: true,
391 filtered_on_projection: false,
392 overlay: None,
393 reads: 2,
394 branches: None,
395 };
396
397 let described = plan_to_json(&plan);
398
399 assert_eq!(described["ranked"], json!(true));
400 assert_eq!(described["filteredOnProjection"], json!(false));
401 }
402
403 #[test]
404 fn every_query_strategy_has_a_lower_case_name_the_ts_union_already_uses() {
405 let all = [
406 QueryStrategy::Primary,
407 QueryStrategy::Exact,
408 QueryStrategy::Sorted,
409 QueryStrategy::Fulltext,
410 QueryStrategy::Union,
411 QueryStrategy::Scan,
412 ];
413 let names: Vec<&str> = all.into_iter().map(strategy_name).collect();
414
415 assert_eq!(names, vec!["primary", "exact", "sorted", "fulltext", "union", "scan"]);
416 }
417
418 #[test]
419 fn a_relation_is_flattened_into_the_shape_the_ts_relation_type_declares() {
420 let relation = Relation {
421 name: "orders_user".into(),
422 from: RelationEnd {
423 table: "orders".into(),
424 field: "userId".into(),
425 },
426 to: RelationEnd {
427 table: "users".into(),
428 field: "id".into(),
429 },
430 cardinality: RelationCardinality::ManyToOne,
431 on_delete: None,
432 enforce: Some(true),
433 };
434
435 let described = relations_to_json(&[relation]);
436
437 assert_eq!(described[0]["from"], json!({ "table": "orders", "field": "userId" }));
438 assert_eq!(described[0]["onDelete"], Value::Null);
439 assert_eq!(described[0]["enforce"], json!(true));
440 }
441
442 #[test]
443 fn a_get_many_answer_keeps_a_hole_where_the_table_held_no_record() {
444 let mut record = HaysonRecord::new();
445 record.insert("id".into(), json!("u1"));
446
447 let described = optional_records_to_json(&[None, Some(record), None]);
448
449 assert_eq!(described, json!([Value::Null, { "id": "u1" }, Value::Null]));
450 }
451
452 #[test]
453 fn a_committed_unit_reports_the_id_the_tables_and_how_many_operations_it_carried() {
454 let result = UnitOfWorkResult {
455 id: "42-1-0".into(),
456 tables: vec!["users".into(), "audit".into()],
457 operations: 3,
458 };
459
460 let described = unit_of_work_result_to_json(&result);
461
462 assert_eq!(
463 described,
464 json!({ "id": "42-1-0", "tables": ["users", "audit"], "operations": 3 })
465 );
466 }
467
468 #[test]
469 fn table_stats_cross_the_boundary_under_the_camel_case_names_the_ts_type_declares() {
470 let stats = TableStats {
471 records: 2,
472 live_bytes: 40,
473 dead_bytes: 10,
474 log_bytes: 50,
475 queue_depth: 0,
476 gen: 1,
477 seq: 7,
478 };
479
480 let described = table_stats_to_json(&stats);
481
482 assert_eq!(
483 described,
484 json!({ "records": 2, "liveBytes": 40, "deadBytes": 10, "logBytes": 50, "queueDepth": 0, "gen": 1, "seq": 7 })
485 );
486 }
487
488 #[test]
489 fn each_refresh_outcome_names_itself_and_agrees_with_its_own_changed_flag() {
490 let unchanged = refresh_outcome_to_json(RefreshOutcome::Unchanged);
491 let caught_up = refresh_outcome_to_json(RefreshOutcome::CaughtUp { entries: 3 });
492 let reloaded = refresh_outcome_to_json(RefreshOutcome::Reloaded);
493
494 assert_eq!(
495 unchanged,
496 json!({ "changed": false, "outcome": "unchanged", "entries": 0 })
497 );
498 assert_eq!(
499 caught_up,
500 json!({ "changed": true, "outcome": "caughtUp", "entries": 3 })
501 );
502 assert_eq!(
503 reloaded,
504 json!({ "changed": true, "outcome": "reloaded", "entries": 0 })
505 );
506 }
507
508 #[test]
509 fn a_record_keeps_its_field_order_across_the_boundary() {
510 let mut record = HaysonRecord::new();
511 record.insert("id".into(), json!("u1"));
512 record.insert("age".into(), json!(31));
513
514 let text = record_to_json(&record).to_string();
515
516 assert_eq!(
517 text, r#"{"age":31,"id":"u1"}"#,
518 "HaysonRecord is a BTreeMap — field order is its key order"
519 );
520 }
521}