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