1use hayson_engine::catalog::{IntegrityReport, OrphanRecord, Relation};
2use hayson_engine::compaction::CompactionResult;
3use hayson_engine::database::{Layout, LayoutMigrationResult};
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 record_to_json(record: &HaysonRecord) -> Value {
11 Value::Object(
12 record
13 .iter()
14 .map(|(field, value)| (field.clone(), value.clone()))
15 .collect(),
16 )
17}
18
19pub fn broken_links_to_json(broken: &[BrokenLink]) -> Value {
23 Value::Array(
24 broken
25 .iter()
26 .map(|link| json!({ "field": link.field, "path": link.path }))
27 .collect(),
28 )
29}
30
31fn strategy_name(strategy: QueryStrategy) -> &'static str {
32 match strategy {
33 QueryStrategy::Primary => "primary",
34 QueryStrategy::Exact => "exact",
35 QueryStrategy::Sorted => "sorted",
36 QueryStrategy::Fulltext => "fulltext",
37 QueryStrategy::Union => "union",
38 QueryStrategy::Scan => "scan",
39 }
40}
41
42fn plan_branch_to_json(branch: &PlanBranch) -> Value {
43 json!({
44 "strategy": strategy_name(branch.strategy),
45 "field": branch.field,
46 "candidates": branch.candidates,
47 })
48}
49
50fn overlay_strategy_name(strategy: OverlayStrategy) -> &'static str {
51 match strategy {
52 OverlayStrategy::Exact => "exact",
53 OverlayStrategy::Fulltext => "fulltext",
54 OverlayStrategy::Scan => "scan",
55 }
56}
57
58fn plan_overlay_to_json(overlay: &PlanOverlay) -> Value {
59 json!({
60 "staged": overlay.staged,
61 "suppressed": overlay.suppressed,
62 "rescored": overlay.rescored,
63 "strategy": overlay_strategy_name(overlay.strategy),
64 "size": overlay.size,
65 })
66}
67
68pub fn plan_to_json(plan: &AccessPathSummary) -> Value {
69 json!({
70 "strategy": strategy_name(plan.strategy),
71 "field": plan.field,
72 "index": plan.index,
73 "candidates": plan.candidates,
74 "ranked": plan.ranked,
75 "filteredOnProjection": plan.filtered_on_projection,
76 "reads": plan.reads,
77 "branches": plan.branches.as_ref().map(|branches| branches.iter().map(plan_branch_to_json).collect::<Vec<_>>()),
78 "overlay": plan.overlay.as_ref().map(plan_overlay_to_json),
79 })
80}
81
82pub fn query_result_to_json(
87 keys: &[String],
88 records: &[HaysonRecord],
89 matched: usize,
90 plan: &AccessPathSummary,
91) -> Value {
92 json!({
93 "keys": keys,
94 "records": records.iter().map(record_to_json).collect::<Vec<_>>(),
95 "matched": matched,
96 "plan": plan_to_json(plan),
97 })
98}
99
100pub fn optional_records_to_json(records: &[Option<HaysonRecord>]) -> Value {
104 Value::Array(
105 records
106 .iter()
107 .map(|record| record.as_ref().map(record_to_json).unwrap_or(Value::Null))
108 .collect(),
109 )
110}
111
112pub fn unit_of_work_result_to_json(result: &UnitOfWorkResult) -> Value {
116 json!({ "id": result.id, "tables": result.tables, "operations": result.operations })
117}
118
119pub fn relation_to_json(relation: &Relation) -> Value {
120 json!({
121 "name": relation.name,
122 "from": { "table": relation.from.table, "field": relation.from.field },
123 "to": { "table": relation.to.table, "field": relation.to.field },
124 "cardinality": relation.cardinality.as_str(),
125 "onDelete": relation.on_delete.map(|action| action.as_str()),
126 "enforce": relation.enforce,
127 })
128}
129
130pub fn relations_to_json(relations: &[Relation]) -> Value {
131 Value::Array(relations.iter().map(relation_to_json).collect())
132}
133
134fn orphan_record_to_json(orphan: &OrphanRecord) -> Value {
135 json!({
136 "relation": orphan.relation,
137 "table": orphan.table,
138 "field": orphan.field,
139 "primaryKey": orphan.primary_key,
140 "value": orphan.value,
141 })
142}
143
144pub fn integrity_report_to_json(report: &IntegrityReport) -> Value {
145 json!({ "orphans": report.orphans.iter().map(orphan_record_to_json).collect::<Vec<_>>() })
146}
147
148pub fn layout_to_json(layout: &Layout) -> Value {
153 let metadata_file = layout.metadata_file();
154 let metadata_path = layout.metadata_path().display().to_string();
155 json!({
156 "kind": layout.kind().as_str(),
157 "root": layout.root().display().to_string(),
158 "metadataFile": metadata_file,
159 "metadataPath": metadata_path,
160 "catalogFile": metadata_file,
161 "catalog": metadata_path,
162 "manifestFile": metadata_file,
163 "manifest": metadata_path,
164 "tables": layout.tables().display().to_string(),
165 "indexes": layout.indexes().display().to_string(),
166 "runtime": layout.runtime().display().to_string(),
167 "lock": layout.lock_path().display().to_string(),
168 "journal": layout.journal_path().display().to_string(),
169 })
170}
171
172pub fn layout_migration_to_json(result: &LayoutMigrationResult) -> Value {
176 json!({
177 "root": result.root.display().to_string(),
178 "from": result.from.as_str(),
179 "to": result.to.as_str(),
180 "moves": result
181 .moves
182 .iter()
183 .map(|step| json!({ "from": step.from.display().to_string(), "to": step.to.display().to_string() }))
184 .collect::<Vec<_>>(),
185 "unclaimed": result
186 .unclaimed
187 .iter()
188 .map(|path| path.display().to_string())
189 .collect::<Vec<_>>(),
190 "changed": result.changed,
191 "dryRun": result.dry_run,
192 })
193}
194
195pub fn table_stats_to_json(stats: &TableStats) -> Value {
196 json!({
197 "records": stats.records,
198 "liveBytes": stats.live_bytes,
199 "deadBytes": stats.dead_bytes,
200 "logBytes": stats.log_bytes,
201 "queueDepth": stats.queue_depth,
202 "gen": stats.gen,
203 "seq": stats.seq,
204 })
205}
206
207pub fn refresh_outcome_to_json(outcome: RefreshOutcome) -> Value {
211 let (name, entries) = match outcome {
212 RefreshOutcome::Unchanged => ("unchanged", 0),
213 RefreshOutcome::CaughtUp { entries } => ("caughtUp", entries),
214 RefreshOutcome::Reloaded => ("reloaded", 0),
215 };
216 json!({ "changed": outcome.changed(), "outcome": name, "entries": entries })
217}
218
219pub fn rotation_result_to_json(result: &RotationResult) -> Value {
222 json!({
223 "rotated": result.rotated,
224 "alreadyCurrent": result.already_current,
225 "skipped": result.skipped,
226 })
227}
228
229pub fn compaction_result_to_json(result: &CompactionResult) -> Value {
231 json!({
232 "records": result.records,
233 "bytesBefore": result.bytes_before,
234 "bytesAfter": result.bytes_after,
235 "reclaimedBytes": result.reclaimed_bytes,
236 })
237}
238
239#[cfg(test)]
240mod tests {
241 use hayson_engine::catalog::{RelationCardinality, RelationEnd};
242
243 use super::*;
244
245 #[test]
246 fn a_query_result_carries_keys_records_the_match_count_and_the_plan_in_one_document() {
247 let mut record = HaysonRecord::new();
248 record.insert("id".into(), json!("u1"));
249 record.insert("name".into(), json!("Ania"));
250 let plan = AccessPathSummary {
251 strategy: QueryStrategy::Exact,
252 field: Some("email".into()),
253 index: Some("users_email".into()),
254 candidates: 1,
255 ranked: false,
256 filtered_on_projection: true,
257 overlay: None,
258 reads: 1,
259 branches: None,
260 };
261
262 let described = query_result_to_json(&["u1".to_string()], &[record], 7, &plan);
263
264 assert_eq!(described["keys"], json!(["u1"]));
265 assert_eq!(described["records"], json!([{ "id": "u1", "name": "Ania" }]));
266 assert_eq!(
267 described["matched"],
268 json!(7),
269 "the page is one key wide, the match count is not"
270 );
271 assert_eq!(described["plan"]["strategy"], json!("exact"));
272 assert_eq!(described["plan"]["index"], json!("users_email"));
273 assert_eq!(described["plan"]["branches"], Value::Null);
274 }
275
276 #[test]
277 fn a_plan_carries_ranked_and_filtered_on_projection_under_the_names_the_ts_query_plan_declares() {
278 let plan = AccessPathSummary {
279 strategy: QueryStrategy::Fulltext,
280 field: None,
281 index: None,
282 candidates: 2,
283 ranked: true,
284 filtered_on_projection: false,
285 overlay: None,
286 reads: 2,
287 branches: None,
288 };
289
290 let described = plan_to_json(&plan);
291
292 assert_eq!(described["ranked"], json!(true));
293 assert_eq!(described["filteredOnProjection"], json!(false));
294 }
295
296 #[test]
297 fn every_query_strategy_has_a_lower_case_name_the_ts_union_already_uses() {
298 let all = [
299 QueryStrategy::Primary,
300 QueryStrategy::Exact,
301 QueryStrategy::Sorted,
302 QueryStrategy::Fulltext,
303 QueryStrategy::Union,
304 QueryStrategy::Scan,
305 ];
306 let names: Vec<&str> = all.into_iter().map(strategy_name).collect();
307
308 assert_eq!(names, vec!["primary", "exact", "sorted", "fulltext", "union", "scan"]);
309 }
310
311 #[test]
312 fn a_relation_is_flattened_into_the_shape_the_ts_relation_type_declares() {
313 let relation = Relation {
314 name: "orders_user".into(),
315 from: RelationEnd {
316 table: "orders".into(),
317 field: "userId".into(),
318 },
319 to: RelationEnd {
320 table: "users".into(),
321 field: "id".into(),
322 },
323 cardinality: RelationCardinality::ManyToOne,
324 on_delete: None,
325 enforce: Some(true),
326 };
327
328 let described = relations_to_json(&[relation]);
329
330 assert_eq!(described[0]["from"], json!({ "table": "orders", "field": "userId" }));
331 assert_eq!(described[0]["onDelete"], Value::Null);
332 assert_eq!(described[0]["enforce"], json!(true));
333 }
334
335 #[test]
336 fn a_get_many_answer_keeps_a_hole_where_the_table_held_no_record() {
337 let mut record = HaysonRecord::new();
338 record.insert("id".into(), json!("u1"));
339
340 let described = optional_records_to_json(&[None, Some(record), None]);
341
342 assert_eq!(described, json!([Value::Null, { "id": "u1" }, Value::Null]));
343 }
344
345 #[test]
346 fn a_committed_unit_reports_the_id_the_tables_and_how_many_operations_it_carried() {
347 let result = UnitOfWorkResult {
348 id: "42-1-0".into(),
349 tables: vec!["users".into(), "audit".into()],
350 operations: 3,
351 };
352
353 let described = unit_of_work_result_to_json(&result);
354
355 assert_eq!(
356 described,
357 json!({ "id": "42-1-0", "tables": ["users", "audit"], "operations": 3 })
358 );
359 }
360
361 #[test]
362 fn table_stats_cross_the_boundary_under_the_camel_case_names_the_ts_type_declares() {
363 let stats = TableStats {
364 records: 2,
365 live_bytes: 40,
366 dead_bytes: 10,
367 log_bytes: 50,
368 queue_depth: 0,
369 gen: 1,
370 seq: 7,
371 };
372
373 let described = table_stats_to_json(&stats);
374
375 assert_eq!(
376 described,
377 json!({ "records": 2, "liveBytes": 40, "deadBytes": 10, "logBytes": 50, "queueDepth": 0, "gen": 1, "seq": 7 })
378 );
379 }
380
381 #[test]
382 fn each_refresh_outcome_names_itself_and_agrees_with_its_own_changed_flag() {
383 let unchanged = refresh_outcome_to_json(RefreshOutcome::Unchanged);
384 let caught_up = refresh_outcome_to_json(RefreshOutcome::CaughtUp { entries: 3 });
385 let reloaded = refresh_outcome_to_json(RefreshOutcome::Reloaded);
386
387 assert_eq!(
388 unchanged,
389 json!({ "changed": false, "outcome": "unchanged", "entries": 0 })
390 );
391 assert_eq!(
392 caught_up,
393 json!({ "changed": true, "outcome": "caughtUp", "entries": 3 })
394 );
395 assert_eq!(
396 reloaded,
397 json!({ "changed": true, "outcome": "reloaded", "entries": 0 })
398 );
399 }
400
401 #[test]
402 fn a_record_keeps_its_field_order_across_the_boundary() {
403 let mut record = HaysonRecord::new();
404 record.insert("id".into(), json!("u1"));
405 record.insert("age".into(), json!(31));
406
407 let text = record_to_json(&record).to_string();
408
409 assert_eq!(
410 text, r#"{"age":31,"id":"u1"}"#,
411 "HaysonRecord is a BTreeMap — field order is its key order"
412 );
413 }
414}