1use hayson_engine::catalog::{IntegrityReport, MigrateOutcome, OrphanRecord, Relation};
2use hayson_engine::compaction::CompactionResult;
3use hayson_engine::database::{Layout, LayoutMigrationResult, 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
87fn strategy_name(strategy: QueryStrategy) -> &'static str {
88 match strategy {
89 QueryStrategy::Primary => "primary",
90 QueryStrategy::Exact => "exact",
91 QueryStrategy::Sorted => "sorted",
92 QueryStrategy::Fulltext => "fulltext",
93 QueryStrategy::Union => "union",
94 QueryStrategy::Scan => "scan",
95 }
96}
97
98fn plan_branch_to_json(branch: &PlanBranch) -> Value {
99 json!({
100 "strategy": strategy_name(branch.strategy),
101 "field": branch.field,
102 "candidates": branch.candidates,
103 })
104}
105
106fn overlay_strategy_name(strategy: OverlayStrategy) -> &'static str {
107 match strategy {
108 OverlayStrategy::Exact => "exact",
109 OverlayStrategy::Fulltext => "fulltext",
110 OverlayStrategy::Scan => "scan",
111 }
112}
113
114fn plan_overlay_to_json(overlay: &PlanOverlay) -> Value {
115 json!({
116 "staged": overlay.staged,
117 "suppressed": overlay.suppressed,
118 "rescored": overlay.rescored,
119 "strategy": overlay_strategy_name(overlay.strategy),
120 "size": overlay.size,
121 })
122}
123
124pub fn plan_to_json(plan: &AccessPathSummary) -> Value {
125 json!({
126 "strategy": strategy_name(plan.strategy),
127 "field": plan.field,
128 "index": plan.index,
129 "candidates": plan.candidates,
130 "ranked": plan.ranked,
131 "filteredOnProjection": plan.filtered_on_projection,
132 "reads": plan.reads,
133 "branches": plan.branches.as_ref().map(|branches| branches.iter().map(plan_branch_to_json).collect::<Vec<_>>()),
134 "overlay": plan.overlay.as_ref().map(plan_overlay_to_json),
135 })
136}
137
138pub fn query_result_to_json(
143 keys: &[String],
144 records: &[HaysonRecord],
145 matched: usize,
146 plan: &AccessPathSummary,
147) -> Value {
148 json!({
149 "keys": keys,
150 "records": records.iter().map(record_to_json).collect::<Vec<_>>(),
151 "matched": matched,
152 "plan": plan_to_json(plan),
153 })
154}
155
156pub fn optional_records_to_json(records: &[Option<HaysonRecord>]) -> Value {
160 Value::Array(
161 records
162 .iter()
163 .map(|record| record.as_ref().map(record_to_json).unwrap_or(Value::Null))
164 .collect(),
165 )
166}
167
168pub fn unit_of_work_result_to_json(result: &UnitOfWorkResult) -> Value {
172 json!({ "id": result.id, "tables": result.tables, "operations": result.operations })
173}
174
175pub fn relation_to_json(relation: &Relation) -> Value {
176 json!({
177 "name": relation.name,
178 "from": { "table": relation.from.table, "field": relation.from.field },
179 "to": { "table": relation.to.table, "field": relation.to.field },
180 "cardinality": relation.cardinality.as_str(),
181 "onDelete": relation.on_delete.map(|action| action.as_str()),
182 "enforce": relation.enforce,
183 })
184}
185
186pub fn relations_to_json(relations: &[Relation]) -> Value {
187 Value::Array(relations.iter().map(relation_to_json).collect())
188}
189
190fn orphan_record_to_json(orphan: &OrphanRecord) -> Value {
191 json!({
192 "relation": orphan.relation,
193 "table": orphan.table,
194 "field": orphan.field,
195 "primaryKey": orphan.primary_key,
196 "value": orphan.value,
197 })
198}
199
200pub fn integrity_report_to_json(report: &IntegrityReport) -> Value {
201 json!({ "orphans": report.orphans.iter().map(orphan_record_to_json).collect::<Vec<_>>() })
202}
203
204pub fn layout_to_json(layout: &Layout) -> Value {
209 let metadata_file = layout.metadata_file();
210 let metadata_path = layout.metadata_path().display().to_string();
211 json!({
212 "kind": layout.kind().as_str(),
213 "root": layout.root().display().to_string(),
214 "metadataFile": metadata_file,
215 "metadataPath": metadata_path,
216 "catalogFile": metadata_file,
217 "catalog": metadata_path,
218 "manifestFile": metadata_file,
219 "manifest": metadata_path,
220 "tables": layout.tables().display().to_string(),
221 "indexes": layout.indexes().display().to_string(),
222 "runtime": layout.runtime().display().to_string(),
223 "lock": layout.lock_path().display().to_string(),
224 "journal": layout.journal_path().display().to_string(),
225 })
226}
227
228pub fn layout_migration_to_json(result: &LayoutMigrationResult) -> Value {
232 json!({
233 "root": result.root.display().to_string(),
234 "from": result.from.as_str(),
235 "to": result.to.as_str(),
236 "moves": result
237 .moves
238 .iter()
239 .map(|step| json!({ "from": step.from.display().to_string(), "to": step.to.display().to_string() }))
240 .collect::<Vec<_>>(),
241 "unclaimed": result
242 .unclaimed
243 .iter()
244 .map(|path| path.display().to_string())
245 .collect::<Vec<_>>(),
246 "changed": result.changed,
247 "dryRun": result.dry_run,
248 })
249}
250
251pub fn verify_report_to_json(report: &VerifyReport) -> Value {
254 json!({
255 "problems": report
256 .problems
257 .iter()
258 .map(|problem| json!({
259 "severity": match problem.severity {
260 Severity::Error => "error",
261 Severity::Warning => "warning",
262 },
263 "code": problem.code,
264 "table": problem.table,
265 "file": problem.file,
266 "offset": problem.offset,
267 "message": problem.message,
268 }))
269 .collect::<Vec<_>>(),
270 "tablesChecked": report.tables_checked,
271 "logLinesChecked": report.log_lines_checked,
272 })
273}
274
275pub fn table_stats_to_json(stats: &TableStats) -> Value {
276 json!({
277 "records": stats.records,
278 "liveBytes": stats.live_bytes,
279 "deadBytes": stats.dead_bytes,
280 "logBytes": stats.log_bytes,
281 "queueDepth": stats.queue_depth,
282 "gen": stats.gen,
283 "seq": stats.seq,
284 })
285}
286
287pub fn refresh_outcome_to_json(outcome: RefreshOutcome) -> Value {
291 let (name, entries) = match outcome {
292 RefreshOutcome::Unchanged => ("unchanged", 0),
293 RefreshOutcome::CaughtUp { entries } => ("caughtUp", entries),
294 RefreshOutcome::Reloaded => ("reloaded", 0),
295 };
296 json!({ "changed": outcome.changed(), "outcome": name, "entries": entries })
297}
298
299pub fn rotation_result_to_json(result: &RotationResult) -> Value {
302 json!({
303 "rotated": result.rotated,
304 "alreadyCurrent": result.already_current,
305 "skipped": result.skipped,
306 })
307}
308
309pub fn compaction_result_to_json(result: &CompactionResult) -> Value {
311 json!({
312 "records": result.records,
313 "bytesBefore": result.bytes_before,
314 "bytesAfter": result.bytes_after,
315 "reclaimedBytes": result.reclaimed_bytes,
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use hayson_engine::catalog::{RelationCardinality, RelationEnd};
322
323 use super::*;
324
325 #[test]
326 fn a_query_result_carries_keys_records_the_match_count_and_the_plan_in_one_document() {
327 let mut record = HaysonRecord::new();
328 record.insert("id".into(), json!("u1"));
329 record.insert("name".into(), json!("Ania"));
330 let plan = AccessPathSummary {
331 strategy: QueryStrategy::Exact,
332 field: Some("email".into()),
333 index: Some("users_email".into()),
334 candidates: 1,
335 ranked: false,
336 filtered_on_projection: true,
337 overlay: None,
338 reads: 1,
339 branches: None,
340 };
341
342 let described = query_result_to_json(&["u1".to_string()], &[record], 7, &plan);
343
344 assert_eq!(described["keys"], json!(["u1"]));
345 assert_eq!(described["records"], json!([{ "id": "u1", "name": "Ania" }]));
346 assert_eq!(
347 described["matched"],
348 json!(7),
349 "the page is one key wide, the match count is not"
350 );
351 assert_eq!(described["plan"]["strategy"], json!("exact"));
352 assert_eq!(described["plan"]["index"], json!("users_email"));
353 assert_eq!(described["plan"]["branches"], Value::Null);
354 }
355
356 #[test]
357 fn a_plan_carries_ranked_and_filtered_on_projection_under_the_names_the_ts_query_plan_declares() {
358 let plan = AccessPathSummary {
359 strategy: QueryStrategy::Fulltext,
360 field: None,
361 index: None,
362 candidates: 2,
363 ranked: true,
364 filtered_on_projection: false,
365 overlay: None,
366 reads: 2,
367 branches: None,
368 };
369
370 let described = plan_to_json(&plan);
371
372 assert_eq!(described["ranked"], json!(true));
373 assert_eq!(described["filteredOnProjection"], json!(false));
374 }
375
376 #[test]
377 fn every_query_strategy_has_a_lower_case_name_the_ts_union_already_uses() {
378 let all = [
379 QueryStrategy::Primary,
380 QueryStrategy::Exact,
381 QueryStrategy::Sorted,
382 QueryStrategy::Fulltext,
383 QueryStrategy::Union,
384 QueryStrategy::Scan,
385 ];
386 let names: Vec<&str> = all.into_iter().map(strategy_name).collect();
387
388 assert_eq!(names, vec!["primary", "exact", "sorted", "fulltext", "union", "scan"]);
389 }
390
391 #[test]
392 fn a_relation_is_flattened_into_the_shape_the_ts_relation_type_declares() {
393 let relation = Relation {
394 name: "orders_user".into(),
395 from: RelationEnd {
396 table: "orders".into(),
397 field: "userId".into(),
398 },
399 to: RelationEnd {
400 table: "users".into(),
401 field: "id".into(),
402 },
403 cardinality: RelationCardinality::ManyToOne,
404 on_delete: None,
405 enforce: Some(true),
406 };
407
408 let described = relations_to_json(&[relation]);
409
410 assert_eq!(described[0]["from"], json!({ "table": "orders", "field": "userId" }));
411 assert_eq!(described[0]["onDelete"], Value::Null);
412 assert_eq!(described[0]["enforce"], json!(true));
413 }
414
415 #[test]
416 fn a_get_many_answer_keeps_a_hole_where_the_table_held_no_record() {
417 let mut record = HaysonRecord::new();
418 record.insert("id".into(), json!("u1"));
419
420 let described = optional_records_to_json(&[None, Some(record), None]);
421
422 assert_eq!(described, json!([Value::Null, { "id": "u1" }, Value::Null]));
423 }
424
425 #[test]
426 fn a_committed_unit_reports_the_id_the_tables_and_how_many_operations_it_carried() {
427 let result = UnitOfWorkResult {
428 id: "42-1-0".into(),
429 tables: vec!["users".into(), "audit".into()],
430 operations: 3,
431 };
432
433 let described = unit_of_work_result_to_json(&result);
434
435 assert_eq!(
436 described,
437 json!({ "id": "42-1-0", "tables": ["users", "audit"], "operations": 3 })
438 );
439 }
440
441 #[test]
442 fn table_stats_cross_the_boundary_under_the_camel_case_names_the_ts_type_declares() {
443 let stats = TableStats {
444 records: 2,
445 live_bytes: 40,
446 dead_bytes: 10,
447 log_bytes: 50,
448 queue_depth: 0,
449 gen: 1,
450 seq: 7,
451 };
452
453 let described = table_stats_to_json(&stats);
454
455 assert_eq!(
456 described,
457 json!({ "records": 2, "liveBytes": 40, "deadBytes": 10, "logBytes": 50, "queueDepth": 0, "gen": 1, "seq": 7 })
458 );
459 }
460
461 #[test]
462 fn each_refresh_outcome_names_itself_and_agrees_with_its_own_changed_flag() {
463 let unchanged = refresh_outcome_to_json(RefreshOutcome::Unchanged);
464 let caught_up = refresh_outcome_to_json(RefreshOutcome::CaughtUp { entries: 3 });
465 let reloaded = refresh_outcome_to_json(RefreshOutcome::Reloaded);
466
467 assert_eq!(
468 unchanged,
469 json!({ "changed": false, "outcome": "unchanged", "entries": 0 })
470 );
471 assert_eq!(
472 caught_up,
473 json!({ "changed": true, "outcome": "caughtUp", "entries": 3 })
474 );
475 assert_eq!(
476 reloaded,
477 json!({ "changed": true, "outcome": "reloaded", "entries": 0 })
478 );
479 }
480
481 #[test]
482 fn a_record_keeps_its_field_order_across_the_boundary() {
483 let mut record = HaysonRecord::new();
484 record.insert("id".into(), json!("u1"));
485 record.insert("age".into(), json!(31));
486
487 let text = record_to_json(&record).to_string();
488
489 assert_eq!(
490 text, r#"{"age":31,"id":"u1"}"#,
491 "HaysonRecord is a BTreeMap — field order is its key order"
492 );
493 }
494}