Skip to main content

icydb/db/sql/
mod.rs

1//! Defines the public SQL text/result payload types exposed by the facade crate.
2//!
3//! This module consumes already-executed SQL outputs and renders stable
4//! endpoint-friendly row payloads; parsing and execution stay in `icydb-core`.
5
6use 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    /// Construct one projection row payload.
24    #[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    /// Borrow projection column names.
34    #[must_use]
35    pub const fn columns(&self) -> &[String] {
36        self.columns.as_slice()
37    }
38
39    /// Borrow rendered row values.
40    #[must_use]
41    pub const fn rows(&self) -> &[Vec<String>] {
42        self.rows.as_slice()
43    }
44
45    /// Return projected row count.
46    #[must_use]
47    pub const fn row_count(&self) -> u32 {
48        self.row_count
49    }
50
51    /// Consume and return projection row parts.
52    #[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    /// Build one endpoint-friendly rows payload from one projection result.
69    #[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    /// Borrow this output as one render-ready projection row payload.
81    #[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    /// Render this payload into deterministic shell-friendly lines.
122    #[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    /// Render this payload into one newline-separated display string.
142    #[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    // Phase 1: emit top-level entity identity metadata.
248    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    // Phase 2: emit field descriptors in stable model order.
253    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    // Phase 3: emit index descriptors or explicit empty marker.
265    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    // Phase 4: emit relation descriptors or explicit empty marker.
281    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    // Phase 1: seed surface header and handle empty-projection output.
356    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    // Phase 2: compute per-column display widths from headers + row values.
366    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    // Phase 3: render deterministic ASCII table surface.
382    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    // Phase 1: seed grouped header metadata and expose the outward continuation
401    // cursor on its own line when grouped pagination has more rows.
402    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    // Phase 2: compute per-column display widths from headers + grouped row values.
415    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    // Phase 3: render the grouped page as the same deterministic ASCII table
427    // shape used by projection payloads.
428    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//
490// TESTS
491//
492
493#[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}