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