Skip to main content

icydb/db/sql/
mod.rs

1//! Module: db::sql
2//!
3//! Responsibility: SQL-surface text rendering helpers for canister/CLI-facing endpoints.
4//! Does not own: SQL parsing/lowering/execution semantics.
5//! Boundary: consumes executed SQL projection/explain outputs and renders stable text payloads.
6
7use 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    /// Construct one projection row payload.
25    #[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    /// Borrow projection column names.
35    #[must_use]
36    pub const fn columns(&self) -> &[String] {
37        self.columns.as_slice()
38    }
39
40    /// Borrow rendered row values.
41    #[must_use]
42    pub const fn rows(&self) -> &[Vec<String>] {
43        self.rows.as_slice()
44    }
45
46    /// Return projected row count.
47    #[must_use]
48    pub const fn row_count(&self) -> u32 {
49        self.row_count
50    }
51
52    /// Consume and return projection row parts.
53    #[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    /// Build one endpoint-friendly rows payload from one projection result.
70    #[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    /// Borrow this output as one render-ready projection row payload.
82    #[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    /// Render this payload into deterministic shell-friendly lines.
112    #[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    /// Render this payload into one newline-separated display string.
131    #[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    // Phase 1: emit top-level entity identity metadata.
237    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    // Phase 2: emit field descriptors in stable model order.
242    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    // Phase 3: emit index descriptors or explicit empty marker.
254    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    // Phase 4: emit relation descriptors or explicit empty marker.
270    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    // Phase 1: seed surface header and handle empty-projection output.
345    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    // Phase 2: compute per-column display widths from headers + row values.
355    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    // Phase 3: render deterministic ASCII table surface.
371    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///
430/// TESTS
431///
432
433#[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}