1use candid::CandidType;
7use serde::Deserialize;
8
9use crate::{
10 db::{EntityFieldDescription, EntitySchemaDescription},
11 value::{Value, ValueEnum},
12};
13
14#[cfg_attr(doc, doc = "SqlProjectionRows\n\nRender-ready SQL projection rows.")]
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct SqlProjectionRows {
17 columns: Vec<String>,
18 rows: Vec<Vec<String>>,
19 row_count: u32,
20}
21
22impl SqlProjectionRows {
23 #[must_use]
25 pub const fn new(columns: Vec<String>, rows: Vec<Vec<String>>, row_count: u32) -> Self {
26 Self {
27 columns,
28 rows,
29 row_count,
30 }
31 }
32
33 #[must_use]
35 pub const fn columns(&self) -> &[String] {
36 self.columns.as_slice()
37 }
38
39 #[must_use]
41 pub const fn rows(&self) -> &[Vec<String>] {
42 self.rows.as_slice()
43 }
44
45 #[must_use]
47 pub const fn row_count(&self) -> u32 {
48 self.row_count
49 }
50
51 #[must_use]
53 pub fn into_parts(self) -> (Vec<String>, Vec<Vec<String>>, u32) {
54 (self.columns, self.rows, self.row_count)
55 }
56}
57
58#[cfg_attr(doc, doc = "SqlQueryRowsOutput\n\nStructured SQL projection payload.")]
59#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
60pub struct SqlQueryRowsOutput {
61 pub entity: String,
62 pub columns: Vec<String>,
63 pub rows: Vec<Vec<String>>,
64 pub row_count: u32,
65}
66
67impl SqlQueryRowsOutput {
68 #[must_use]
70 pub fn from_projection(entity: String, projection: SqlProjectionRows) -> Self {
71 let (columns, rows, row_count) = projection.into_parts();
72 Self {
73 entity,
74 columns,
75 rows,
76 row_count,
77 }
78 }
79
80 #[must_use]
82 pub fn as_projection_rows(&self) -> SqlProjectionRows {
83 SqlProjectionRows::new(self.columns.clone(), self.rows.clone(), self.row_count)
84 }
85}
86
87#[cfg_attr(doc, doc = "SqlGroupedRowsOutput\n\nStructured grouped SQL payload.")]
88#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
89pub struct SqlGroupedRowsOutput {
90 pub entity: String,
91 pub columns: Vec<String>,
92 pub rows: Vec<Vec<String>>,
93 pub row_count: u32,
94 pub next_cursor: Option<String>,
95}
96
97#[cfg_attr(doc, doc = "SqlQueryResult\n\nUnified SQL endpoint result.")]
98#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
99pub enum SqlQueryResult {
100 Projection(SqlQueryRowsOutput),
101 Grouped(SqlGroupedRowsOutput),
102 Explain {
103 entity: String,
104 explain: String,
105 },
106 Describe(EntitySchemaDescription),
107 ShowIndexes {
108 entity: String,
109 indexes: Vec<String>,
110 },
111 ShowColumns {
112 entity: String,
113 columns: Vec<EntityFieldDescription>,
114 },
115 ShowEntities {
116 entities: Vec<String>,
117 },
118}
119
120impl SqlQueryResult {
121 #[must_use]
123 pub fn render_lines(&self) -> Vec<String> {
124 match self {
125 Self::Projection(rows) => {
126 render_projection_lines(rows.entity.as_str(), &rows.as_projection_rows())
127 }
128 Self::Grouped(rows) => render_grouped_lines(rows),
129 Self::Explain { explain, .. } => render_explain_lines(explain.as_str()),
130 Self::Describe(description) => render_describe_lines(description),
131 Self::ShowIndexes { entity, indexes } => {
132 render_show_indexes_lines(entity.as_str(), indexes.as_slice())
133 }
134 Self::ShowColumns { entity, columns } => {
135 render_show_columns_lines(entity.as_str(), columns.as_slice())
136 }
137 Self::ShowEntities { entities } => render_show_entities_lines(entities.as_slice()),
138 }
139 }
140
141 #[must_use]
143 pub fn render_text(&self) -> String {
144 self.render_lines().join("\n")
145 }
146}
147
148#[cfg_attr(doc, doc = "Render one value into a shell-friendly stable text form.")]
149#[must_use]
150pub fn render_value_text(value: &Value) -> String {
151 match value {
152 Value::Account(v) => v.to_string(),
153 Value::Blob(v) => render_blob_value(v),
154 Value::Bool(v) => v.to_string(),
155 Value::Date(v) => v.to_string(),
156 Value::Decimal(v) => v.to_string(),
157 Value::Duration(v) => render_duration_value(v.as_millis()),
158 Value::Enum(v) => render_enum(v),
159 Value::Float32(v) => v.to_string(),
160 Value::Float64(v) => v.to_string(),
161 Value::Int(v) => v.to_string(),
162 Value::Int128(v) => v.to_string(),
163 Value::IntBig(v) => v.to_string(),
164 Value::List(items) => render_list_value(items.as_slice()),
165 Value::Map(entries) => render_map_value(entries.as_slice()),
166 Value::Null => "null".to_string(),
167 Value::Principal(v) => v.to_string(),
168 Value::Subaccount(v) => v.to_string(),
169 Value::Text(v) => v.clone(),
170 Value::Timestamp(v) => v.as_millis().to_string(),
171 Value::Uint(v) => v.to_string(),
172 Value::Uint128(v) => v.to_string(),
173 Value::UintBig(v) => v.to_string(),
174 Value::Ulid(v) => v.to_string(),
175 Value::Unit => "()".to_string(),
176 }
177}
178
179fn render_blob_value(bytes: &[u8]) -> String {
180 let mut rendered = String::from("0x");
181 rendered.push_str(hex_encode(bytes).as_str());
182
183 rendered
184}
185
186fn render_duration_value(millis: u64) -> String {
187 let mut rendered = millis.to_string();
188 rendered.push_str("ms");
189
190 rendered
191}
192
193fn render_list_value(items: &[Value]) -> String {
194 let mut rendered = String::from("[");
195
196 for (index, item) in items.iter().enumerate() {
197 if index != 0 {
198 rendered.push_str(", ");
199 }
200
201 rendered.push_str(render_value_text(item).as_str());
202 }
203
204 rendered.push(']');
205
206 rendered
207}
208
209fn render_map_value(entries: &[(Value, Value)]) -> String {
210 let mut rendered = String::from("{");
211
212 for (index, (key, value)) in entries.iter().enumerate() {
213 if index != 0 {
214 rendered.push_str(", ");
215 }
216
217 rendered.push_str(render_value_text(key).as_str());
218 rendered.push_str(": ");
219 rendered.push_str(render_value_text(value).as_str());
220 }
221
222 rendered.push('}');
223
224 rendered
225}
226
227#[cfg_attr(
228 doc,
229 doc = "Render one SQL EXPLAIN text payload as endpoint output lines."
230)]
231#[must_use]
232pub fn render_explain_lines(explain: &str) -> Vec<String> {
233 let mut lines = vec!["surface=explain".to_string()];
234 lines.extend(explain.lines().map(ToString::to_string));
235
236 lines
237}
238
239#[cfg_attr(
240 doc,
241 doc = "Render one typed `DESCRIBE` payload into deterministic shell output lines."
242)]
243#[must_use]
244pub fn render_describe_lines(description: &EntitySchemaDescription) -> Vec<String> {
245 let mut lines = Vec::new();
246
247 lines.push(format!("entity: {}", description.entity_name()));
249 lines.push(format!("path: {}", description.entity_path()));
250 lines.push(format!("primary_key: {}", description.primary_key()));
251
252 lines.push("fields:".to_string());
254 for field in description.fields() {
255 lines.push(format!(
256 " - {}: {} (primary_key={}, queryable={})",
257 field.name(),
258 field.kind(),
259 field.primary_key(),
260 field.queryable(),
261 ));
262 }
263
264 if description.indexes().is_empty() {
266 lines.push("indexes: []".to_string());
267 } else {
268 lines.push("indexes:".to_string());
269 for index in description.indexes() {
270 let unique = if index.unique() { ", unique" } else { "" };
271 lines.push(format!(
272 " - {}({}){}",
273 index.name(),
274 index.fields().join(", "),
275 unique,
276 ));
277 }
278 }
279
280 if description.relations().is_empty() {
282 lines.push("relations: []".to_string());
283 } else {
284 lines.push("relations:".to_string());
285 for relation in description.relations() {
286 lines.push(format!(
287 " - {} -> {} ({:?}, {:?})",
288 relation.field(),
289 relation.target_entity_name(),
290 relation.strength(),
291 relation.cardinality(),
292 ));
293 }
294 }
295
296 lines
297}
298
299#[cfg_attr(
300 doc,
301 doc = "Render one `SHOW INDEXES` payload into deterministic shell output lines."
302)]
303#[must_use]
304pub fn render_show_indexes_lines(entity: &str, indexes: &[String]) -> Vec<String> {
305 let mut lines = vec![format!(
306 "surface=indexes entity={entity} index_count={}",
307 indexes.len()
308 )];
309 lines.extend(indexes.iter().cloned());
310
311 lines
312}
313
314#[cfg_attr(
315 doc,
316 doc = "Render one `SHOW COLUMNS` payload into deterministic shell output lines."
317)]
318#[must_use]
319pub fn render_show_columns_lines(entity: &str, columns: &[EntityFieldDescription]) -> Vec<String> {
320 let mut lines = vec![format!(
321 "surface=columns entity={entity} column_count={}",
322 columns.len()
323 )];
324 lines.extend(columns.iter().map(|column| {
325 format!(
326 "{}: {} (primary_key={}, queryable={})",
327 column.name(),
328 column.kind(),
329 column.primary_key(),
330 column.queryable(),
331 )
332 }));
333
334 lines
335}
336
337#[cfg_attr(
338 doc,
339 doc = "Render one helper-level `SHOW ENTITIES` payload into deterministic lines."
340)]
341#[must_use]
342pub fn render_show_entities_lines(entities: &[String]) -> Vec<String> {
343 let mut lines = vec!["surface=entities".to_string()];
344 lines.extend(entities.iter().map(|entity| format!("entity={entity}")));
345
346 lines
347}
348
349#[cfg_attr(
350 doc,
351 doc = "Render one SQL projection payload into pretty table lines for shell output."
352)]
353#[must_use]
354pub fn render_projection_lines(entity: &str, projection: &SqlProjectionRows) -> Vec<String> {
355 let mut lines = vec![format!(
357 "surface=projection entity={entity} row_count={}",
358 projection.row_count()
359 )];
360 if projection.columns().is_empty() {
361 lines.push("(no projected columns)".to_string());
362 return lines;
363 }
364
365 let mut widths = projection
367 .columns()
368 .iter()
369 .map(String::len)
370 .collect::<Vec<_>>();
371 for row in projection.rows() {
372 for (index, value) in row.iter().enumerate() {
373 if index >= widths.len() {
374 widths.push(value.len());
375 } else {
376 widths[index] = widths[index].max(value.len());
377 }
378 }
379 }
380
381 let separator = render_table_separator(widths.as_slice());
383 lines.push(separator.clone());
384 lines.push(render_table_row(projection.columns(), widths.as_slice()));
385 lines.push(separator.clone());
386 for row in projection.rows() {
387 lines.push(render_table_row(row.as_slice(), widths.as_slice()));
388 }
389 lines.push(separator);
390
391 lines
392}
393
394#[cfg_attr(
395 doc,
396 doc = "Render one grouped SQL payload into pretty table lines for shell output."
397)]
398#[must_use]
399pub fn render_grouped_lines(grouped: &SqlGroupedRowsOutput) -> Vec<String> {
400 let mut lines = vec![format!(
403 "surface=grouped entity={} row_count={}",
404 grouped.entity, grouped.row_count
405 )];
406 if let Some(next_cursor) = &grouped.next_cursor {
407 lines.push(format!("next_cursor={next_cursor}"));
408 }
409 if grouped.columns.is_empty() {
410 lines.push("(no grouped columns)".to_string());
411 return lines;
412 }
413
414 let mut widths = grouped.columns.iter().map(String::len).collect::<Vec<_>>();
416 for row in &grouped.rows {
417 for (index, value) in row.iter().enumerate() {
418 if index >= widths.len() {
419 widths.push(value.len());
420 } else {
421 widths[index] = widths[index].max(value.len());
422 }
423 }
424 }
425
426 let separator = render_table_separator(widths.as_slice());
429 lines.push(separator.clone());
430 lines.push(render_table_row(
431 grouped.columns.as_slice(),
432 widths.as_slice(),
433 ));
434 lines.push(separator.clone());
435 for row in &grouped.rows {
436 lines.push(render_table_row(row.as_slice(), widths.as_slice()));
437 }
438 lines.push(separator);
439
440 lines
441}
442
443fn render_table_separator(widths: &[usize]) -> String {
444 let segments = widths
445 .iter()
446 .map(|width| "-".repeat(width.saturating_add(2)))
447 .collect::<Vec<_>>();
448
449 format!("+{}+", segments.join("+"))
450}
451
452fn render_table_row(cells: &[String], widths: &[usize]) -> String {
453 let mut parts = Vec::with_capacity(widths.len());
454 for (index, width) in widths.iter().copied().enumerate() {
455 let value = cells.get(index).map_or("", String::as_str);
456 parts.push(format!("{value:<width$}"));
457 }
458
459 format!("| {} |", parts.join(" | "))
460}
461
462fn hex_encode(bytes: &[u8]) -> String {
463 const HEX: &[u8; 16] = b"0123456789abcdef";
464 let mut out = String::with_capacity(bytes.len().saturating_mul(2));
465 for byte in bytes {
466 out.push(HEX[(byte >> 4) as usize] as char);
467 out.push(HEX[(byte & 0x0f) as usize] as char);
468 }
469
470 out
471}
472
473fn render_enum(value: &ValueEnum) -> String {
474 let mut rendered = String::new();
475 if let Some(path) = value.path() {
476 rendered.push_str(path);
477 rendered.push_str("::");
478 }
479 rendered.push_str(value.variant());
480 if let Some(payload) = value.payload() {
481 rendered.push('(');
482 rendered.push_str(render_value_text(payload).as_str());
483 rendered.push(')');
484 }
485
486 rendered
487}
488
489#[cfg(test)]
494mod tests {
495 use crate::db::sql::{
496 SqlQueryResult, SqlQueryRowsOutput, render_describe_lines, render_show_columns_lines,
497 render_show_entities_lines, render_show_indexes_lines,
498 };
499 use crate::db::{
500 EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
501 EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
502 };
503
504 #[test]
505 fn render_describe_lines_output_contract_vector_is_stable() {
506 let description = EntitySchemaDescription::new(
507 "schema.public.ExampleEntity".to_string(),
508 "ExampleEntity".to_string(),
509 "id".to_string(),
510 vec![
511 EntityFieldDescription::new("id".to_string(), "Ulid".to_string(), true, true),
512 EntityFieldDescription::new("name".to_string(), "Text".to_string(), false, true),
513 ],
514 vec![
515 EntityIndexDescription::new(
516 "example_entity_name_idx".to_string(),
517 false,
518 vec!["name".to_string()],
519 ),
520 EntityIndexDescription::new(
521 "example_entity_pk".to_string(),
522 true,
523 vec!["id".to_string()],
524 ),
525 ],
526 vec![EntityRelationDescription::new(
527 "mentor_id".to_string(),
528 "schema.public.User".to_string(),
529 "User".to_string(),
530 "user_store".to_string(),
531 EntityRelationStrength::Strong,
532 EntityRelationCardinality::Single,
533 )],
534 );
535
536 assert_eq!(
537 render_describe_lines(&description),
538 vec![
539 "entity: ExampleEntity".to_string(),
540 "path: schema.public.ExampleEntity".to_string(),
541 "primary_key: id".to_string(),
542 "fields:".to_string(),
543 " - id: Ulid (primary_key=true, queryable=true)".to_string(),
544 " - name: Text (primary_key=false, queryable=true)".to_string(),
545 "indexes:".to_string(),
546 " - example_entity_name_idx(name)".to_string(),
547 " - example_entity_pk(id), unique".to_string(),
548 "relations:".to_string(),
549 " - mentor_id -> User (Strong, Single)".to_string(),
550 ],
551 "describe shell output must remain contract-stable across release lines",
552 );
553 }
554
555 #[test]
556 fn render_show_indexes_lines_output_contract_vector_is_stable() {
557 let indexes = vec![
558 "PRIMARY KEY (id)".to_string(),
559 "INDEX example_entity_name_idx(name)".to_string(),
560 ];
561
562 assert_eq!(
563 render_show_indexes_lines("ExampleEntity", indexes.as_slice()),
564 vec![
565 "surface=indexes entity=ExampleEntity index_count=2".to_string(),
566 "PRIMARY KEY (id)".to_string(),
567 "INDEX example_entity_name_idx(name)".to_string(),
568 ],
569 "show-indexes shell output must remain contract-stable across release lines",
570 );
571 }
572
573 #[test]
574 fn render_show_columns_lines_output_contract_vector_is_stable() {
575 let columns = vec![
576 EntityFieldDescription::new("id".to_string(), "Ulid".to_string(), true, true),
577 EntityFieldDescription::new("name".to_string(), "Text".to_string(), false, true),
578 ];
579
580 assert_eq!(
581 render_show_columns_lines("ExampleEntity", columns.as_slice()),
582 vec![
583 "surface=columns entity=ExampleEntity column_count=2".to_string(),
584 "id: Ulid (primary_key=true, queryable=true)".to_string(),
585 "name: Text (primary_key=false, queryable=true)".to_string(),
586 ],
587 "show-columns shell output must remain contract-stable across release lines",
588 );
589 }
590
591 #[test]
592 fn render_show_entities_lines_output_contract_vector_is_stable() {
593 let entities = vec![
594 "ExampleEntity".to_string(),
595 "Order".to_string(),
596 "User".to_string(),
597 ];
598
599 assert_eq!(
600 render_show_entities_lines(entities.as_slice()),
601 vec![
602 "surface=entities".to_string(),
603 "entity=ExampleEntity".to_string(),
604 "entity=Order".to_string(),
605 "entity=User".to_string(),
606 ],
607 "show-entities shell output must remain contract-stable across release lines",
608 );
609 }
610
611 #[test]
612 fn sql_query_result_projection_render_lines_output_contract_vector_is_stable() {
613 let projection = SqlQueryRowsOutput {
614 entity: "User".to_string(),
615 columns: vec!["name".to_string()],
616 rows: vec![vec!["alice".to_string()]],
617 row_count: 1,
618 };
619 let result = SqlQueryResult::Projection(projection);
620
621 assert_eq!(
622 result.render_lines(),
623 vec![
624 "surface=projection entity=User row_count=1".to_string(),
625 "+-------+".to_string(),
626 "| name |".to_string(),
627 "+-------+".to_string(),
628 "| alice |".to_string(),
629 "+-------+".to_string(),
630 ],
631 "projection query-result rendering must remain contract-stable across release lines",
632 );
633 }
634}