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