use candid::CandidType;
use icydb_core::{
db::{GroupedRow, SqlStatementResult},
types::Decimal,
};
use serde::Deserialize;
use crate::{
db::{EntityFieldDescription, EntitySchemaDescription},
value::{Value, ValueEnum},
};
#[cfg_attr(doc, doc = "SqlProjectionRows\n\nRender-ready SQL projection rows.")]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SqlProjectionRows {
columns: Vec<String>,
rows: Vec<Vec<String>>,
row_count: u32,
}
impl SqlProjectionRows {
#[must_use]
pub const fn new(columns: Vec<String>, rows: Vec<Vec<String>>, row_count: u32) -> Self {
Self {
columns,
rows,
row_count,
}
}
#[must_use]
pub const fn columns(&self) -> &[String] {
self.columns.as_slice()
}
#[must_use]
pub const fn rows(&self) -> &[Vec<String>] {
self.rows.as_slice()
}
#[must_use]
pub const fn row_count(&self) -> u32 {
self.row_count
}
#[must_use]
pub fn into_parts(self) -> (Vec<String>, Vec<Vec<String>>, u32) {
(self.columns, self.rows, self.row_count)
}
}
#[cfg_attr(doc, doc = "SqlQueryRowsOutput\n\nStructured SQL projection payload.")]
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct SqlQueryRowsOutput {
pub entity: String,
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
pub row_count: u32,
}
impl SqlQueryRowsOutput {
#[must_use]
pub fn from_projection(entity: String, projection: SqlProjectionRows) -> Self {
let (columns, rows, row_count) = projection.into_parts();
Self {
entity,
columns,
rows,
row_count,
}
}
#[must_use]
pub fn as_projection_rows(&self) -> SqlProjectionRows {
SqlProjectionRows::new(self.columns.clone(), self.rows.clone(), self.row_count)
}
}
#[cfg_attr(doc, doc = "SqlGroupedRowsOutput\n\nStructured grouped SQL payload.")]
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct SqlGroupedRowsOutput {
pub entity: String,
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
pub row_count: u32,
pub next_cursor: Option<String>,
}
#[cfg_attr(doc, doc = "SqlQueryResult\n\nUnified SQL endpoint result.")]
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
pub enum SqlQueryResult {
Count {
entity: String,
row_count: u32,
},
Projection(SqlQueryRowsOutput),
Grouped(SqlGroupedRowsOutput),
Explain {
entity: String,
explain: String,
},
Describe(EntitySchemaDescription),
ShowIndexes {
entity: String,
indexes: Vec<String>,
},
ShowColumns {
entity: String,
columns: Vec<EntityFieldDescription>,
},
ShowEntities {
entities: Vec<String>,
},
}
impl SqlQueryResult {
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
match self {
Self::Count { entity, row_count } => render_count_lines(entity.as_str(), *row_count),
Self::Projection(rows) => {
render_projection_lines(rows.entity.as_str(), &rows.as_projection_rows())
}
Self::Grouped(rows) => render_grouped_lines(rows),
Self::Explain { explain, .. } => render_explain_lines(explain.as_str()),
Self::Describe(description) => render_describe_lines(description),
Self::ShowIndexes { entity, indexes } => {
render_show_indexes_lines(entity.as_str(), indexes.as_slice())
}
Self::ShowColumns { entity, columns } => {
render_show_columns_lines(entity.as_str(), columns.as_slice())
}
Self::ShowEntities { entities } => render_show_entities_lines(entities.as_slice()),
}
}
#[must_use]
pub fn render_text(&self) -> String {
self.render_lines().join("\n")
}
}
pub(crate) fn sql_query_result_from_statement(
result: SqlStatementResult,
entity_name: String,
) -> SqlQueryResult {
match result {
SqlStatementResult::Count { row_count } => SqlQueryResult::Count {
entity: entity_name,
row_count,
},
SqlStatementResult::Projection {
columns,
fixed_scales,
rows,
row_count,
} => {
let rows = render_projection_rows(columns.as_slice(), fixed_scales.as_slice(), rows);
SqlQueryResult::Projection(SqlQueryRowsOutput::from_projection(
entity_name,
SqlProjectionRows::new(columns, rows, row_count),
))
}
SqlStatementResult::ProjectionText {
columns,
rows,
row_count,
} => SqlQueryResult::Projection(SqlQueryRowsOutput::from_projection(
entity_name,
SqlProjectionRows::new(columns, rows, row_count),
)),
SqlStatementResult::Grouped {
columns,
fixed_scales,
rows,
row_count,
next_cursor,
} => SqlQueryResult::Grouped(sql_grouped_rows_output(
entity_name,
columns,
fixed_scales,
rows,
row_count,
next_cursor,
)),
SqlStatementResult::Explain(explain) => SqlQueryResult::Explain {
entity: entity_name,
explain,
},
SqlStatementResult::Describe(description) => SqlQueryResult::Describe(description),
SqlStatementResult::ShowIndexes(indexes) => SqlQueryResult::ShowIndexes {
entity: entity_name,
indexes,
},
SqlStatementResult::ShowColumns(columns) => SqlQueryResult::ShowColumns {
entity: entity_name,
columns,
},
SqlStatementResult::ShowEntities(entities) => SqlQueryResult::ShowEntities { entities },
}
}
#[cfg_attr(doc, doc = "Render one value into a shell-friendly stable text form.")]
#[must_use]
pub fn render_value_text(value: &Value) -> String {
match value {
Value::Account(v) => v.to_string(),
Value::Blob(v) => render_blob_value(v),
Value::Bool(v) => v.to_string(),
Value::Date(v) => v.to_string(),
Value::Decimal(v) => v.to_string(),
Value::Duration(v) => render_duration_value(v.as_millis()),
Value::Enum(v) => render_enum(v),
Value::Float32(v) => v.to_string(),
Value::Float64(v) => v.to_string(),
Value::Int(v) => v.to_string(),
Value::Int128(v) => v.to_string(),
Value::IntBig(v) => v.to_string(),
Value::List(items) => render_list_value(items.as_slice()),
Value::Map(entries) => render_map_value(entries.as_slice()),
Value::Null => "null".to_string(),
Value::Principal(v) => v.to_string(),
Value::Subaccount(v) => v.to_string(),
Value::Text(v) => v.clone(),
Value::Timestamp(v) => v.as_millis().to_string(),
Value::Uint(v) => v.to_string(),
Value::Uint128(v) => v.to_string(),
Value::UintBig(v) => v.to_string(),
Value::Ulid(v) => v.to_string(),
Value::Unit => "()".to_string(),
}
}
fn render_projection_rows(
columns: &[String],
fixed_scales: &[Option<u32>],
rows: Vec<Vec<Value>>,
) -> Vec<Vec<String>> {
rows.into_iter()
.map(|row| {
row.into_iter()
.enumerate()
.map(|(index, value)| {
render_projection_value_text(
columns.get(index),
fixed_scales.get(index).copied().flatten(),
&value,
)
})
.collect::<Vec<_>>()
})
.collect()
}
fn render_projection_value_text(
column: Option<&String>,
fixed_scale: Option<u32>,
value: &Value,
) -> String {
let Some(scale) =
fixed_scale.or_else(|| column.and_then(|label| round_projection_scale(label.as_str())))
else {
return render_value_text(value);
};
match value {
Value::Decimal(decimal) => render_decimal_with_fixed_scale(decimal, scale),
_ => render_value_text(value),
}
}
fn round_projection_scale(column: &str) -> Option<u32> {
let body = column
.trim()
.strip_prefix("ROUND(")?
.strip_suffix(')')?
.trim();
let (_, scale) = body.rsplit_once(',')?;
scale.trim().parse::<u32>().ok()
}
fn render_decimal_with_fixed_scale(decimal: &Decimal, scale: u32) -> String {
let rounded = decimal.round_dp(scale);
if rounded.mantissa() == 0 {
if scale == 0 {
return "0".to_string();
}
return format!("0.{:0<width$}", "", width = scale as usize);
}
let negative = rounded.mantissa().is_negative();
let digits = rounded.mantissa().unsigned_abs().to_string();
let fixed = decimal_digits_with_scale(digits.as_str(), rounded.scale(), scale);
if negative { format!("-{fixed}") } else { fixed }
}
fn decimal_digits_with_scale(digits: &str, current_scale: u32, target_scale: u32) -> String {
if target_scale == 0 {
return digits.to_string();
}
let current_scale = current_scale as usize;
let target_scale = target_scale as usize;
let (integer, fraction) = if digits.len() <= current_scale {
let zeros = "0".repeat(current_scale - digits.len());
("0".to_string(), format!("{zeros}{digits}"))
} else {
let split = digits.len() - current_scale;
(digits[..split].to_string(), digits[split..].to_string())
};
let mut rendered = integer;
rendered.push('.');
rendered.push_str(fraction.as_str());
for _ in current_scale..target_scale {
rendered.push('0');
}
rendered
}
fn sql_grouped_rows_output(
entity_name: String,
columns: Vec<String>,
fixed_scales: Vec<Option<u32>>,
rows: Vec<GroupedRow>,
row_count: u32,
next_cursor: Option<String>,
) -> SqlGroupedRowsOutput {
let rows = rows
.into_iter()
.map(|row| {
row.group_key()
.iter()
.chain(row.aggregate_values().iter())
.enumerate()
.map(|(index, value)| {
render_projection_value_text(
columns.get(index),
fixed_scales.get(index).copied().flatten(),
value,
)
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
SqlGroupedRowsOutput {
entity: entity_name,
columns,
rows,
row_count,
next_cursor,
}
}
fn render_blob_value(bytes: &[u8]) -> String {
let mut rendered = String::from("0x");
rendered.push_str(hex_encode(bytes).as_str());
rendered
}
fn render_duration_value(millis: u64) -> String {
let mut rendered = millis.to_string();
rendered.push_str("ms");
rendered
}
fn render_list_value(items: &[Value]) -> String {
let mut rendered = String::from("[");
for (index, item) in items.iter().enumerate() {
if index != 0 {
rendered.push_str(", ");
}
rendered.push_str(render_value_text(item).as_str());
}
rendered.push(']');
rendered
}
fn render_map_value(entries: &[(Value, Value)]) -> String {
let mut rendered = String::from("{");
for (index, (key, value)) in entries.iter().enumerate() {
if index != 0 {
rendered.push_str(", ");
}
rendered.push_str(render_value_text(key).as_str());
rendered.push_str(": ");
rendered.push_str(render_value_text(value).as_str());
}
rendered.push('}');
rendered
}
#[cfg_attr(
doc,
doc = "Render one SQL EXPLAIN text payload as endpoint output lines."
)]
#[must_use]
pub fn render_explain_lines(explain: &str) -> Vec<String> {
let mut lines = vec!["surface=explain".to_string()];
lines.extend(explain.lines().map(ToString::to_string));
lines
}
#[cfg_attr(
doc,
doc = "Render one typed `DESCRIBE` payload into deterministic shell output lines."
)]
#[must_use]
pub fn render_describe_lines(description: &EntitySchemaDescription) -> Vec<String> {
let mut lines = Vec::new();
lines.push(format!("entity: {}", description.entity_name()));
lines.push(format!("path: {}", description.entity_path()));
lines.push(String::new());
lines.push("fields:".to_string());
let field_rows = description
.fields()
.iter()
.map(|field| {
vec![
field.name().to_string(),
field.kind().to_string(),
if field.primary_key() {
"yes".to_string()
} else {
"no".to_string()
},
if field.queryable() {
"yes".to_string()
} else {
"no".to_string()
},
]
})
.collect::<Vec<_>>();
render_describe_table_section(
&mut lines,
&[
"name".to_string(),
"type".to_string(),
"pk".to_string(),
"queryable".to_string(),
],
&field_rows,
);
lines.push(String::new());
if description.indexes().is_empty() {
lines.push("indexes: []".to_string());
} else {
lines.push("indexes:".to_string());
let index_rows = description
.indexes()
.iter()
.map(|index| {
vec![
index.name().to_string(),
index.fields().join(", "),
if index.unique() {
"yes".to_string()
} else {
"no".to_string()
},
]
})
.collect::<Vec<_>>();
render_describe_table_section(
&mut lines,
&[
"name".to_string(),
"fields".to_string(),
"unique".to_string(),
],
&index_rows,
);
}
lines.push(String::new());
if description.relations().is_empty() {
lines.push("relations: []".to_string());
} else {
lines.push("relations:".to_string());
let relation_rows = description
.relations()
.iter()
.map(|relation| {
vec![
relation.field().to_string(),
relation.target_entity_name().to_string(),
format!("{:?}", relation.strength()),
format!("{:?}", relation.cardinality()),
]
})
.collect::<Vec<_>>();
render_describe_table_section(
&mut lines,
&[
"field".to_string(),
"target".to_string(),
"strength".to_string(),
"cardinality".to_string(),
],
&relation_rows,
);
}
lines
}
fn render_describe_table_section(
lines: &mut Vec<String>,
headers: &[String],
rows: &[Vec<String>],
) {
let mut widths = headers.iter().map(String::len).collect::<Vec<_>>();
for row in rows {
for (index, value) in row.iter().enumerate() {
widths[index] = widths[index].max(value.len());
}
}
let separator = render_table_separator(widths.as_slice());
lines.push(separator.clone());
lines.push(render_table_row(headers, widths.as_slice()));
lines.push(separator.clone());
for row in rows {
lines.push(render_table_row(row.as_slice(), widths.as_slice()));
}
if !rows.is_empty() {
lines.push(separator);
}
}
#[cfg_attr(
doc,
doc = "Render one SQL count payload into deterministic shell output lines."
)]
#[must_use]
pub fn render_count_lines(entity: &str, row_count: u32) -> Vec<String> {
vec![format!(
"surface=count entity={entity} row_count={row_count}"
)]
}
#[cfg_attr(
doc,
doc = "Render one `SHOW INDEXES` payload into deterministic shell output lines."
)]
#[must_use]
pub fn render_show_indexes_lines(entity: &str, indexes: &[String]) -> Vec<String> {
let mut lines = vec![format!(
"surface=indexes entity={entity} index_count={}",
indexes.len()
)];
lines.extend(indexes.iter().cloned());
lines
}
#[cfg_attr(
doc,
doc = "Render one `SHOW COLUMNS` payload into deterministic shell output lines."
)]
#[must_use]
pub fn render_show_columns_lines(entity: &str, columns: &[EntityFieldDescription]) -> Vec<String> {
let mut lines = vec![format!(
"surface=columns entity={entity} column_count={}",
columns.len()
)];
lines.extend(columns.iter().map(|column| {
format!(
"{}: {} (primary_key={}, queryable={})",
column.name(),
column.kind(),
column.primary_key(),
column.queryable(),
)
}));
lines
}
#[cfg_attr(
doc,
doc = "Render one helper-level `SHOW ENTITIES` payload into deterministic lines."
)]
#[must_use]
pub fn render_show_entities_lines(entities: &[String]) -> Vec<String> {
let mut lines = vec!["surface=entities".to_string()];
lines.extend(entities.iter().map(|entity| format!("entity={entity}")));
lines
}
#[cfg_attr(
doc,
doc = "Render one SQL projection payload into pretty table lines for shell output."
)]
#[must_use]
pub fn render_projection_lines(_entity: &str, projection: &SqlProjectionRows) -> Vec<String> {
let mut lines = Vec::new();
if projection.columns().is_empty() {
lines.push("(no projected columns)".to_string());
return lines;
}
let mut widths = projection
.columns()
.iter()
.map(String::len)
.collect::<Vec<_>>();
for row in projection.rows() {
for (index, value) in row.iter().enumerate() {
if index >= widths.len() {
widths.push(value.len());
} else {
widths[index] = widths[index].max(value.len());
}
}
}
let separator = render_table_separator(widths.as_slice());
lines.push(separator.clone());
lines.push(render_table_row(projection.columns(), widths.as_slice()));
lines.push(separator.clone());
for row in projection.rows() {
lines.push(render_table_row(row.as_slice(), widths.as_slice()));
}
if !projection.rows().is_empty() {
lines.push(separator);
}
lines.push(String::new());
lines.push(render_result_row_count_line(projection.row_count()));
lines
}
#[cfg_attr(
doc,
doc = "Render one grouped SQL payload into pretty table lines for shell output."
)]
#[must_use]
pub fn render_grouped_lines(grouped: &SqlGroupedRowsOutput) -> Vec<String> {
let mut lines = Vec::new();
if let Some(next_cursor) = &grouped.next_cursor {
lines.push(format!("next_cursor={next_cursor}"));
}
if grouped.columns.is_empty() {
lines.push("(no grouped columns)".to_string());
return lines;
}
let mut widths = grouped.columns.iter().map(String::len).collect::<Vec<_>>();
for row in &grouped.rows {
for (index, value) in row.iter().enumerate() {
if index >= widths.len() {
widths.push(value.len());
} else {
widths[index] = widths[index].max(value.len());
}
}
}
let separator = render_table_separator(widths.as_slice());
lines.push(separator.clone());
lines.push(render_table_row(
grouped.columns.as_slice(),
widths.as_slice(),
));
lines.push(separator.clone());
for row in &grouped.rows {
lines.push(render_table_row(row.as_slice(), widths.as_slice()));
}
if !grouped.rows.is_empty() {
lines.push(separator);
}
lines.push(String::new());
lines.push(render_result_row_count_line(grouped.row_count));
lines
}
fn render_result_row_count_line(row_count: u32) -> String {
let noun = if row_count == 1 { "row" } else { "rows" };
format!("{} {noun},", render_grouped_decimal_u32(row_count))
}
fn render_grouped_decimal_u32(value: u32) -> String {
let digits = value.to_string();
let mut rendered = String::with_capacity(digits.len().saturating_add(digits.len() / 3));
let leading_group_len = digits.len().rem_euclid(3);
for (index, ch) in digits.chars().enumerate() {
if index > 0
&& (index == leading_group_len
|| (index > leading_group_len && (index - leading_group_len).rem_euclid(3) == 0))
{
rendered.push(',');
}
rendered.push(ch);
}
rendered
}
fn render_table_separator(widths: &[usize]) -> String {
let segments = widths
.iter()
.map(|width| "-".repeat(width.saturating_add(2)))
.collect::<Vec<_>>();
format!("+{}+", segments.join("+"))
}
fn render_table_row(cells: &[String], widths: &[usize]) -> String {
let mut parts = Vec::with_capacity(widths.len());
for (index, width) in widths.iter().copied().enumerate() {
let value = cells.get(index).map_or("", String::as_str);
parts.push(format!("{value:<width$}"));
}
format!("| {} |", parts.join(" | "))
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len().saturating_mul(2));
for byte in bytes {
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0f) as usize] as char);
}
out
}
fn render_enum(value: &ValueEnum) -> String {
let mut rendered = String::new();
if let Some(path) = value.path() {
rendered.push_str(path);
rendered.push_str("::");
}
rendered.push_str(value.variant());
if let Some(payload) = value.payload() {
rendered.push('(');
rendered.push_str(render_value_text(payload).as_str());
rendered.push(')');
}
rendered
}
#[cfg(test)]
mod tests {
use icydb_core::db::{GroupedRow, SqlStatementResult};
use icydb_core::types::Decimal;
use crate::db::sql::{
SqlGroupedRowsOutput, SqlQueryResult, SqlQueryRowsOutput, render_describe_lines,
render_show_columns_lines, render_show_entities_lines, render_show_indexes_lines,
sql_query_result_from_statement,
};
use crate::db::{
EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
};
use crate::value::Value;
#[test]
fn render_describe_lines_output_contract_vector_is_stable() {
let description = EntitySchemaDescription::new(
"schema.public.ExampleEntity".to_string(),
"ExampleEntity".to_string(),
"id".to_string(),
vec![
EntityFieldDescription::new("id".to_string(), "Ulid".to_string(), true, true),
EntityFieldDescription::new("name".to_string(), "Text".to_string(), false, true),
],
vec![
EntityIndexDescription::new(
"example_entity_name_idx".to_string(),
false,
vec!["name".to_string()],
),
EntityIndexDescription::new(
"example_entity_pk".to_string(),
true,
vec!["id".to_string()],
),
],
vec![EntityRelationDescription::new(
"mentor_id".to_string(),
"schema.public.User".to_string(),
"User".to_string(),
"user_store".to_string(),
EntityRelationStrength::Strong,
EntityRelationCardinality::Single,
)],
);
assert_eq!(
render_describe_lines(&description),
vec![
"entity: ExampleEntity".to_string(),
"path: schema.public.ExampleEntity".to_string(),
String::new(),
"fields:".to_string(),
"+------+------+-----+-----------+".to_string(),
"| name | type | pk | queryable |".to_string(),
"+------+------+-----+-----------+".to_string(),
"| id | Ulid | yes | yes |".to_string(),
"| name | Text | no | yes |".to_string(),
"+------+------+-----+-----------+".to_string(),
String::new(),
"indexes:".to_string(),
"+-------------------------+--------+--------+".to_string(),
"| name | fields | unique |".to_string(),
"+-------------------------+--------+--------+".to_string(),
"| example_entity_name_idx | name | no |".to_string(),
"| example_entity_pk | id | yes |".to_string(),
"+-------------------------+--------+--------+".to_string(),
String::new(),
"relations:".to_string(),
"+-----------+--------+----------+-------------+".to_string(),
"| field | target | strength | cardinality |".to_string(),
"+-----------+--------+----------+-------------+".to_string(),
"| mentor_id | User | Strong | Single |".to_string(),
"+-----------+--------+----------+-------------+".to_string(),
],
"describe shell output must remain contract-stable across release lines",
);
}
#[test]
fn render_show_indexes_lines_output_contract_vector_is_stable() {
let indexes = vec![
"PRIMARY KEY (id)".to_string(),
"INDEX example_entity_name_idx(name)".to_string(),
];
assert_eq!(
render_show_indexes_lines("ExampleEntity", indexes.as_slice()),
vec![
"surface=indexes entity=ExampleEntity index_count=2".to_string(),
"PRIMARY KEY (id)".to_string(),
"INDEX example_entity_name_idx(name)".to_string(),
],
"show-indexes shell output must remain contract-stable across release lines",
);
}
#[test]
fn render_show_columns_lines_output_contract_vector_is_stable() {
let columns = vec![
EntityFieldDescription::new("id".to_string(), "Ulid".to_string(), true, true),
EntityFieldDescription::new("name".to_string(), "Text".to_string(), false, true),
];
assert_eq!(
render_show_columns_lines("ExampleEntity", columns.as_slice()),
vec![
"surface=columns entity=ExampleEntity column_count=2".to_string(),
"id: Ulid (primary_key=true, queryable=true)".to_string(),
"name: Text (primary_key=false, queryable=true)".to_string(),
],
"show-columns shell output must remain contract-stable across release lines",
);
}
#[test]
fn render_show_entities_lines_output_contract_vector_is_stable() {
let entities = vec![
"ExampleEntity".to_string(),
"Order".to_string(),
"User".to_string(),
];
assert_eq!(
render_show_entities_lines(entities.as_slice()),
vec![
"surface=entities".to_string(),
"entity=ExampleEntity".to_string(),
"entity=Order".to_string(),
"entity=User".to_string(),
],
"show-entities shell output must remain contract-stable across release lines",
);
}
#[test]
fn sql_query_result_projection_render_lines_output_contract_vector_is_stable() {
let projection = SqlQueryRowsOutput {
entity: "User".to_string(),
columns: vec!["name".to_string()],
rows: vec![vec!["alice".to_string()]],
row_count: 1,
};
let result = SqlQueryResult::Projection(projection);
assert_eq!(
result.render_lines(),
vec![
"+-------+".to_string(),
"| name |".to_string(),
"+-------+".to_string(),
"| alice |".to_string(),
"+-------+".to_string(),
String::new(),
"1 row,".to_string(),
],
"projection query-result rendering must remain contract-stable across release lines",
);
}
#[test]
fn sql_query_result_projection_render_lines_empty_table_omits_trailing_separator() {
let projection = SqlQueryRowsOutput {
entity: "User".to_string(),
columns: vec![
"name".to_string(),
"hit_points".to_string(),
"strength".to_string(),
],
rows: Vec::new(),
row_count: 0,
};
let result = SqlQueryResult::Projection(projection);
assert_eq!(
result.render_lines(),
vec![
"+------+------------+----------+".to_string(),
"| name | hit_points | strength |".to_string(),
"+------+------------+----------+".to_string(),
String::new(),
"0 rows,".to_string(),
],
"empty projection tables should stop after the header separator instead of rendering a duplicate closing border",
);
}
#[test]
fn sql_query_result_grouped_render_lines_output_contract_vector_is_stable() {
let grouped = SqlGroupedRowsOutput {
entity: "User".to_string(),
columns: vec!["age".to_string(), "count(*)".to_string()],
rows: vec![
vec!["24".to_string(), "1".to_string()],
vec!["31".to_string(), "2".to_string()],
],
row_count: 2,
next_cursor: Some("cursor:age:31".to_string()),
};
let result = SqlQueryResult::Grouped(grouped);
assert_eq!(
result.render_lines(),
vec![
"next_cursor=cursor:age:31".to_string(),
"+-----+----------+".to_string(),
"| age | count(*) |".to_string(),
"+-----+----------+".to_string(),
"| 24 | 1 |".to_string(),
"| 31 | 2 |".to_string(),
"+-----+----------+".to_string(),
String::new(),
"2 rows,".to_string(),
],
"grouped query-result rendering must remain contract-stable across release lines",
);
}
#[test]
fn sql_query_result_row_count_footer_uses_grouped_decimal_formatting() {
let projection = SqlQueryRowsOutput {
entity: "User".to_string(),
columns: vec!["name".to_string()],
rows: Vec::new(),
row_count: 1_234,
};
let result = SqlQueryResult::Projection(projection);
assert_eq!(
result.render_lines().last(),
Some(&"1,234 rows,".to_string()),
"row-count footers should use grouped decimal formatting for large result sets",
);
}
#[test]
fn sql_query_result_from_statement_preserves_count_entity_and_row_count() {
let result = sql_query_result_from_statement(
SqlStatementResult::Count { row_count: 3 },
"User".to_string(),
);
assert_eq!(
result,
SqlQueryResult::Count {
entity: "User".to_string(),
row_count: 3,
},
"public SQL packaging must preserve outward count payload identity",
);
}
#[test]
fn sql_query_result_from_statement_preserves_projection_text_rows() {
let result = sql_query_result_from_statement(
SqlStatementResult::ProjectionText {
columns: vec!["lower(name)".to_string()],
rows: vec![vec!["alice".to_string()], vec!["bob".to_string()]],
row_count: 2,
},
"User".to_string(),
);
assert_eq!(
result,
SqlQueryResult::Projection(SqlQueryRowsOutput {
entity: "User".to_string(),
columns: vec!["lower(name)".to_string()],
rows: vec![vec!["alice".to_string()], vec!["bob".to_string()]],
row_count: 2,
}),
"public SQL packaging must preserve text projection payloads verbatim",
);
}
#[test]
fn sql_query_result_from_statement_preserves_scalar_arithmetic_and_round_projection_rows() {
let result = sql_query_result_from_statement(
SqlStatementResult::Projection {
columns: vec!["age - 1".to_string(), "ROUND(age / 3, 2)".to_string()],
fixed_scales: vec![None, Some(2)],
rows: vec![
vec![
Value::Decimal(Decimal::from_i128(23).expect("23 decimal")),
Value::Decimal(Decimal::new(800, 2)),
],
vec![
Value::Decimal(Decimal::from_i128(30).expect("30 decimal")),
Value::Decimal(Decimal::new(1033, 2)),
],
],
row_count: 2,
},
"User".to_string(),
);
assert_eq!(
result,
SqlQueryResult::Projection(SqlQueryRowsOutput {
entity: "User".to_string(),
columns: vec!["age - 1".to_string(), "ROUND(age / 3, 2)".to_string()],
rows: vec![
vec!["23".to_string(), "8.00".to_string()],
vec!["30".to_string(), "10.33".to_string()],
],
row_count: 2,
}),
"public SQL packaging must preserve arithmetic and ROUND projection labels and rendered decimal rows",
);
}
#[test]
fn sql_query_result_from_statement_preserves_fixed_scale_for_zero_round_projection_rows() {
let result = sql_query_result_from_statement(
SqlStatementResult::Projection {
columns: vec!["ROUND(age / 10, 3)".to_string()],
fixed_scales: vec![Some(3)],
rows: vec![vec![Value::Decimal(Decimal::ZERO)]],
row_count: 1,
},
"User".to_string(),
);
assert_eq!(
result,
SqlQueryResult::Projection(SqlQueryRowsOutput {
entity: "User".to_string(),
columns: vec!["ROUND(age / 10, 3)".to_string()],
rows: vec![vec!["0.000".to_string()]],
row_count: 1,
}),
"public SQL packaging must keep ROUND projection scale even for zero values",
);
}
#[test]
fn sql_query_result_from_statement_preserves_fixed_scale_for_aliased_round_projection_rows() {
let result = sql_query_result_from_statement(
SqlStatementResult::Projection {
columns: vec!["dextrisma".to_string()],
fixed_scales: vec![Some(3)],
rows: vec![vec![Value::Decimal(
Decimal::from_i128(16).expect("16 decimal"),
)]],
row_count: 1,
},
"User".to_string(),
);
assert_eq!(
result,
SqlQueryResult::Projection(SqlQueryRowsOutput {
entity: "User".to_string(),
columns: vec!["dextrisma".to_string()],
rows: vec![vec!["16.000".to_string()]],
row_count: 1,
}),
"public SQL packaging must preserve aliased ROUND projection scale even when the outward label no longer exposes ROUND(..., scale)",
);
}
#[test]
fn sql_query_result_from_statement_preserves_fixed_scale_for_grouped_round_rows() {
let result = sql_query_result_from_statement(
SqlStatementResult::Grouped {
columns: vec!["age".to_string(), "ROUND(AVG(age), 4)".to_string()],
fixed_scales: vec![None, Some(4)],
rows: vec![
GroupedRow::new(
vec![Value::Uint(12)],
vec![Value::Decimal(Decimal::from_i128(12).expect("12 decimal"))],
),
GroupedRow::new(
vec![Value::Uint(14)],
vec![Value::Decimal(Decimal::new(142_000, 4))],
),
],
row_count: 2,
next_cursor: None,
},
"User".to_string(),
);
assert_eq!(
result,
SqlQueryResult::Grouped(SqlGroupedRowsOutput {
entity: "User".to_string(),
columns: vec!["age".to_string(), "ROUND(AVG(age), 4)".to_string()],
rows: vec![
vec!["12".to_string(), "12.0000".to_string()],
vec!["14".to_string(), "14.2000".to_string()],
],
row_count: 2,
next_cursor: None,
}),
"public grouped SQL packaging must preserve fixed ROUND projection scale for grouped rows",
);
}
#[test]
fn sql_query_result_from_statement_preserves_grouped_rows_and_cursor() {
let result = sql_query_result_from_statement(
SqlStatementResult::Grouped {
columns: vec!["age".to_string(), "count(*)".to_string()],
fixed_scales: vec![None, None],
rows: vec![
GroupedRow::new(vec![Value::Uint(24)], vec![Value::Uint(1)]),
GroupedRow::new(vec![Value::Uint(31)], vec![Value::Uint(2)]),
],
row_count: 2,
next_cursor: Some("cursor:age:31".to_string()),
},
"User".to_string(),
);
assert_eq!(
result,
SqlQueryResult::Grouped(SqlGroupedRowsOutput {
entity: "User".to_string(),
columns: vec!["age".to_string(), "count(*)".to_string()],
rows: vec![
vec!["24".to_string(), "1".to_string()],
vec!["31".to_string(), "2".to_string()],
],
row_count: 2,
next_cursor: Some("cursor:age:31".to_string()),
}),
"public SQL packaging must preserve grouped rows and outward continuation cursor",
);
}
}