mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-09 01:35:18 +02:00
356 lines
9.9 KiB
Rust
356 lines
9.9 KiB
Rust
use color_eyre::eyre::Result;
|
|
use omnigraph_server::ReadOutputFormat;
|
|
use omnigraph_server::api::ReadOutput;
|
|
use omnigraph_server::config::TableCellLayout;
|
|
use serde_json::{Map, Value};
|
|
|
|
pub struct ReadRenderOptions {
|
|
pub max_column_width: usize,
|
|
pub cell_layout: TableCellLayout,
|
|
}
|
|
|
|
pub fn render_read(
|
|
output: &ReadOutput,
|
|
format: ReadOutputFormat,
|
|
options: &ReadRenderOptions,
|
|
) -> Result<String> {
|
|
match format {
|
|
ReadOutputFormat::Json => Ok(serde_json::to_string_pretty(output)?),
|
|
ReadOutputFormat::Jsonl => render_jsonl(output),
|
|
ReadOutputFormat::Csv => render_csv(output),
|
|
ReadOutputFormat::Kv => Ok(render_kv(output)),
|
|
ReadOutputFormat::Table => Ok(render_table(output, options)),
|
|
}
|
|
}
|
|
|
|
fn render_jsonl(output: &ReadOutput) -> Result<String> {
|
|
let mut lines = Vec::new();
|
|
lines.push(serde_json::to_string(&serde_json::json!({
|
|
"kind": "metadata",
|
|
"query_name": output.query_name,
|
|
"target": output.target,
|
|
"row_count": output.row_count,
|
|
}))?);
|
|
for row in rows(output) {
|
|
lines.push(serde_json::to_string(&row)?);
|
|
}
|
|
Ok(lines.join("\n"))
|
|
}
|
|
|
|
fn render_csv(output: &ReadOutput) -> Result<String> {
|
|
let rows = rows(output);
|
|
let columns = columns(output, &rows);
|
|
let mut lines = Vec::new();
|
|
lines.push(
|
|
columns
|
|
.iter()
|
|
.map(|column| csv_escape(column))
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
);
|
|
for row in rows {
|
|
lines.push(
|
|
columns
|
|
.iter()
|
|
.map(|column| csv_escape(&stringify_value(row.get(column).unwrap_or(&Value::Null))))
|
|
.collect::<Vec<_>>()
|
|
.join(","),
|
|
);
|
|
}
|
|
Ok(lines.join("\n"))
|
|
}
|
|
|
|
fn render_kv(output: &ReadOutput) -> String {
|
|
let mut lines = vec![header_line(output)];
|
|
let rows = rows(output);
|
|
if rows.is_empty() {
|
|
lines.push("(no rows)".to_string());
|
|
return lines.join("\n");
|
|
}
|
|
|
|
for (idx, row) in rows.iter().enumerate() {
|
|
if idx > 0 {
|
|
lines.push(String::new());
|
|
}
|
|
lines.push(format!("row {}", idx + 1));
|
|
for column in columns(output, &rows) {
|
|
lines.push(format!(
|
|
"{}: {}",
|
|
column,
|
|
stringify_value(row.get(&column).unwrap_or(&Value::Null))
|
|
));
|
|
}
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn render_table(output: &ReadOutput, options: &ReadRenderOptions) -> String {
|
|
let mut lines = vec![header_line(output)];
|
|
let rows = rows(output);
|
|
let columns = columns(output, &rows);
|
|
|
|
if columns.is_empty() {
|
|
lines.push("(no rows)".to_string());
|
|
return lines.join("\n");
|
|
}
|
|
|
|
let widths = columns
|
|
.iter()
|
|
.map(|column| {
|
|
let mut width = column.chars().count();
|
|
for row in &rows {
|
|
let rendered =
|
|
normalize_cell(&stringify_value(row.get(column).unwrap_or(&Value::Null)));
|
|
let longest = rendered
|
|
.lines()
|
|
.map(|line| line.chars().count())
|
|
.max()
|
|
.unwrap_or(0);
|
|
width = width.max(longest.min(options.max_column_width));
|
|
}
|
|
width.min(options.max_column_width.max(8))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
lines.push(render_table_line(&columns, &widths));
|
|
lines.push(
|
|
widths
|
|
.iter()
|
|
.map(|width| "-".repeat(*width))
|
|
.collect::<Vec<_>>()
|
|
.join("-+-"),
|
|
);
|
|
|
|
for row in rows {
|
|
let cell_lines = columns
|
|
.iter()
|
|
.zip(widths.iter())
|
|
.map(|(column, width)| {
|
|
split_cell(
|
|
&normalize_cell(&stringify_value(row.get(column).unwrap_or(&Value::Null))),
|
|
*width,
|
|
options.cell_layout,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let line_count = cell_lines.iter().map(Vec::len).max().unwrap_or(1);
|
|
for line_idx in 0..line_count {
|
|
let rendered = cell_lines
|
|
.iter()
|
|
.zip(widths.iter())
|
|
.map(|(segments, width)| {
|
|
let segment = segments.get(line_idx).cloned().unwrap_or_default();
|
|
pad_to_width(&segment, *width)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
lines.push(rendered.join(" | "));
|
|
}
|
|
}
|
|
|
|
lines.join("\n")
|
|
}
|
|
|
|
fn render_table_line(columns: &[String], widths: &[usize]) -> String {
|
|
columns
|
|
.iter()
|
|
.zip(widths.iter())
|
|
.map(|(column, width)| pad_to_width(column, *width))
|
|
.collect::<Vec<_>>()
|
|
.join(" | ")
|
|
}
|
|
|
|
fn header_line(output: &ReadOutput) -> String {
|
|
format!(
|
|
"{} rows from {} via {}",
|
|
output.row_count,
|
|
output
|
|
.target
|
|
.snapshot
|
|
.as_deref()
|
|
.map(|id| format!("snapshot {}", id))
|
|
.or_else(|| {
|
|
output
|
|
.target
|
|
.branch
|
|
.as_deref()
|
|
.map(|branch| format!("branch {}", branch))
|
|
})
|
|
.unwrap_or_else(|| "target".to_string()),
|
|
output.query_name
|
|
)
|
|
}
|
|
|
|
fn rows(output: &ReadOutput) -> Vec<Map<String, Value>> {
|
|
output
|
|
.rows
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.map(|row| match row {
|
|
Value::Object(map) => map.clone(),
|
|
other => {
|
|
let mut map = Map::new();
|
|
map.insert("value".to_string(), other.clone());
|
|
map
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn columns(output: &ReadOutput, rows: &[Map<String, Value>]) -> Vec<String> {
|
|
if !output.columns.is_empty() {
|
|
return output.columns.clone();
|
|
}
|
|
|
|
let mut columns = rows
|
|
.iter()
|
|
.flat_map(|row| row.keys().cloned())
|
|
.collect::<Vec<_>>();
|
|
columns.sort();
|
|
columns.dedup();
|
|
columns
|
|
}
|
|
|
|
fn stringify_value(value: &Value) -> String {
|
|
match value {
|
|
Value::Null => "null".to_string(),
|
|
Value::String(text) => text.clone(),
|
|
Value::Bool(boolean) => boolean.to_string(),
|
|
Value::Number(number) => number.to_string(),
|
|
other => serde_json::to_string(other).unwrap_or_else(|_| "<invalid json>".to_string()),
|
|
}
|
|
}
|
|
|
|
fn normalize_cell(value: &str) -> String {
|
|
value.replace('\n', "\\n")
|
|
}
|
|
|
|
fn split_cell(value: &str, width: usize, layout: TableCellLayout) -> Vec<String> {
|
|
if value.is_empty() {
|
|
return vec![String::new()];
|
|
}
|
|
if value.chars().count() <= width {
|
|
return vec![value.to_string()];
|
|
}
|
|
match layout {
|
|
TableCellLayout::Truncate => vec![truncate(value, width)],
|
|
TableCellLayout::Wrap => wrap(value, width),
|
|
}
|
|
}
|
|
|
|
fn truncate(value: &str, width: usize) -> String {
|
|
if width <= 1 {
|
|
return value.chars().take(width).collect();
|
|
}
|
|
let keep = width.saturating_sub(1);
|
|
let mut out = value.chars().take(keep).collect::<String>();
|
|
out.push('…');
|
|
out
|
|
}
|
|
|
|
fn wrap(value: &str, width: usize) -> Vec<String> {
|
|
let chars = value.chars().collect::<Vec<_>>();
|
|
chars
|
|
.chunks(width.max(1))
|
|
.map(|chunk| chunk.iter().collect::<String>())
|
|
.collect()
|
|
}
|
|
|
|
fn pad_to_width(value: &str, width: usize) -> String {
|
|
let value_width = value.chars().count();
|
|
if value_width >= width {
|
|
value.to_string()
|
|
} else {
|
|
format!("{}{}", value, " ".repeat(width - value_width))
|
|
}
|
|
}
|
|
|
|
fn csv_escape(value: &str) -> String {
|
|
if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
|
|
format!("\"{}\"", value.replace('"', "\"\""))
|
|
} else {
|
|
value.to_string()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use omnigraph_server::api::{ReadOutput, ReadTargetOutput};
|
|
|
|
use super::*;
|
|
|
|
fn sample_output() -> ReadOutput {
|
|
ReadOutput {
|
|
query_name: "get_person".to_string(),
|
|
target: ReadTargetOutput {
|
|
branch: Some("main".to_string()),
|
|
snapshot: None,
|
|
},
|
|
row_count: 1,
|
|
columns: vec!["name".to_string(), "age".to_string()],
|
|
rows: serde_json::json!([{ "name": "Alice", "age": 30 }]),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn csv_format_outputs_header_and_rows() {
|
|
let rendered = render_read(
|
|
&sample_output(),
|
|
ReadOutputFormat::Csv,
|
|
&ReadRenderOptions {
|
|
max_column_width: 80,
|
|
cell_layout: TableCellLayout::Truncate,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(rendered.lines().next().unwrap().contains("name,age"));
|
|
assert!(rendered.contains("Alice,30"));
|
|
}
|
|
|
|
#[test]
|
|
fn jsonl_format_emits_metadata_first() {
|
|
let rendered = render_read(
|
|
&sample_output(),
|
|
ReadOutputFormat::Jsonl,
|
|
&ReadRenderOptions {
|
|
max_column_width: 80,
|
|
cell_layout: TableCellLayout::Truncate,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
let first = rendered.lines().next().unwrap();
|
|
assert!(first.contains("\"kind\":\"metadata\""));
|
|
assert!(
|
|
rendered
|
|
.lines()
|
|
.nth(1)
|
|
.unwrap()
|
|
.contains("\"name\":\"Alice\"")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn render_falls_back_to_discovered_columns_for_legacy_payloads() {
|
|
let mut output = sample_output();
|
|
output.columns.clear();
|
|
|
|
let rendered = render_read(
|
|
&output,
|
|
ReadOutputFormat::Csv,
|
|
&ReadRenderOptions {
|
|
max_column_width: 80,
|
|
cell_layout: TableCellLayout::Truncate,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(rendered.lines().next().unwrap().contains("age,name"));
|
|
}
|
|
|
|
#[test]
|
|
fn csv_quotes_carriage_returns() {
|
|
assert_eq!(csv_escape("hello\rworld"), "\"hello\rworld\"");
|
|
}
|
|
}
|