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