use std::collections::HashMap;
use actix_web::Result as AwResult;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use maud::{PreEscaped, html};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{ConnectionStore, db};
// ---------------------------------------------------------------------------
// AG Grid request / response types
// ---------------------------------------------------------------------------
#[derive(Deserialize, Debug)]
struct SortModel {
#[serde(rename = "colId")]
col_id: String,
sort: String,
}
/// A column reference as sent by AG Grid in rowGroupCols / valueCols / pivotCols.
#[derive(Deserialize, Debug)]
struct ColItem {
/// Unique column identifier (usually same as field).
id: String,
/// The data field name.
field: String,
/// Aggregation function (only present for value columns).
#[serde(rename = "aggFunc", default)]
agg_func: Option<String>,
}
#[derive(Deserialize, Debug)]
struct AgGridRowRequest {
#[serde(rename = "startRow", default)]
start_row: i64,
#[serde(rename = "endRow", default = "default_end_row")]
end_row: i64,
#[serde(rename = "sortModel", default)]
sort_model: Vec<SortModel>,
#[serde(rename = "filterModel", default)]
filter_model: HashMap<String, Value>,
/// Columns used to group rows into tree nodes.
#[serde(rename = "rowGroupCols", default)]
row_group_cols: Vec<ColItem>,
/// Columns being aggregated (value columns with aggFunc).
#[serde(rename = "valueCols", default)]
value_cols: Vec<ColItem>,
/// Columns whose distinct values become new pivot columns.
#[serde(rename = "pivotCols", default)]
pivot_cols: Vec<ColItem>,
/// Whether the grid is currently in pivot mode.
#[serde(rename = "pivotMode", default)]
pivot_mode: bool,
/// Path of group key values to the currently requested node.
#[serde(rename = "groupKeys", default)]
group_keys: Vec<String>,
}
fn default_end_row() -> i64 {
100
}
// ---------------------------------------------------------------------------
// Filter model → SQL helpers
// ---------------------------------------------------------------------------
fn filter_condition_to_sql(col: &str, filter: &Value) -> Option<String> {
if let Some(operator) = filter.get("operator").and_then(|v| v.as_str()) {
let c1 = filter
.get("condition1")
.and_then(|c| filter_condition_to_sql(col, c));
let c2 = filter
.get("condition2")
.and_then(|c| filter_condition_to_sql(col, c));
return match (c1, c2) {
(Some(c1), Some(c2)) => Some(format!("({c1} {operator} {c2})")),
(Some(c), None) | (None, Some(c)) => Some(c),
(None, None) => None,
};
}
let filter_type = filter.get("filterType").and_then(|v| v.as_str())?;
let col_q = format!("`{}`", col.replace('`', "``"));
// Set filter – multi-select: { filterType: "set", values: [...] }
if filter_type == "set" {
let values = filter.get("values").and_then(|v| v.as_array())?;
if values.is_empty() {
return None;
}
let literals: Vec<String> = values
.iter()
.map(|v| match v {
Value::Null => "NULL".to_string(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => {
if *b {
"1".to_string()
} else {
"0".to_string()
}
}
Value::String(s) => format!("'{}'", s.replace('\'', "''")),
_ => format!("'{}'", v.to_string().replace('\'', "''")),
})
.collect();
return Some(format!("{col_q} IN ({})", literals.join(", ")));
}
let filter_op = filter.get("type").and_then(|v| v.as_str())?;
match filter_type {
"text" => {
let val = filter
.get("filter")
.and_then(|v| v.as_str())
.unwrap_or_default();
let esc = val.replace('\'', "''");
match filter_op {
"equals" => Some(format!("{col_q} = '{esc}'")),
"notEqual" => Some(format!("{col_q} != '{esc}'")),
"contains" => Some(format!("{col_q} ILIKE '%{esc}%'")),
"notContains" => Some(format!("{col_q} NOT ILIKE '%{esc}%'")),
"startsWith" => Some(format!("{col_q} ILIKE '{esc}%'")),
"endsWith" => Some(format!("{col_q} ILIKE '%{esc}'")),
"blank" => Some(format!("({col_q} = '' OR {col_q} IS NULL)")),
"notBlank" => Some(format!("({col_q} != '' AND {col_q} IS NOT NULL)")),
_ => None,
}
}
"number" => {
if filter_op == "inRange" {
let from = filter.get("filter").and_then(|v| v.as_f64())?;
let to = filter.get("filterTo").and_then(|v| v.as_f64())?;
Some(format!("{col_q} BETWEEN {from} AND {to}"))
} else {
let val = filter.get("filter").and_then(|v| v.as_f64())?;
let op = match filter_op {
"equals" => "=",
"notEqual" => "!=",
"lessThan" => "<",
"lessThanOrEqual" => "<=",
"greaterThan" => ">",
"greaterThanOrEqual" => ">=",
_ => return None,
};
Some(format!("{col_q} {op} {val}"))
}
}
"date" => {
if filter_op == "inRange" {
let from = filter
.get("dateFrom")
.and_then(|v| v.as_str())
.unwrap_or_default();
let to = filter
.get("dateTo")
.and_then(|v| v.as_str())
.unwrap_or_default();
Some(format!(
"{col_q} BETWEEN '{}' AND '{}'",
from.replace('\'', "''"),
to.replace('\'', "''")
))
} else {
let val = filter
.get("dateFrom")
.and_then(|v| v.as_str())
.unwrap_or_default();
let esc = val.replace('\'', "''");
let op = match filter_op {
"equals" => "=",
"notEqual" => "!=",
"lessThan" => "<",
"lessThanOrEqual" => "<=",
"greaterThan" => ">",
"greaterThanOrEqual" => ">=",
_ => return None,
};
Some(format!("{col_q} {op} '{esc}'"))
}
}
_ => None,
}
}
fn filter_conditions(filter_model: &HashMap<String, Value>) -> Vec<String> {
filter_model
.iter()
.filter_map(|(col, filter)| filter_condition_to_sql(col, filter))
.collect()
}
fn build_where_clause(filter_model: &HashMap<String, Value>) -> String {
let conditions = filter_conditions(filter_model);
if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
}
}
/// Combine an arbitrary list of SQL condition strings into a WHERE clause.
fn join_conditions_as_where(conditions: &[String]) -> String {
let non_empty: Vec<&str> = conditions
.iter()
.filter(|c| !c.is_empty())
.map(|c| c.as_str())
.collect();
if non_empty.is_empty() {
String::new()
} else {
format!(" WHERE {}", non_empty.join(" AND "))
}
}
fn build_order_clause(sort_model: &[SortModel]) -> String {
if sort_model.is_empty() {
return String::new();
}
let parts: Vec<String> = sort_model
.iter()
// ag-Grid-AutoColumn is a virtual column used by AG Grid for row-group display;
// it has no corresponding column in ClickHouse, so skip it.
.filter(|s| s.col_id != "ag-Grid-AutoColumn")
.map(|s| {
let col_q = format!("`{}`", s.col_id.replace('`', "``"));
let dir = if s.sort.to_lowercase() == "desc" {
"DESC"
} else {
"ASC"
};
format!("{col_q} {dir}")
})
.collect();
if parts.is_empty() {
return String::new();
}
format!(" ORDER BY {}", parts.join(", "))
}
// ---------------------------------------------------------------------------
// Column-def generation from ClickHouse types
// ---------------------------------------------------------------------------
fn is_numeric_ch_type(ch_type: &str) -> bool {
let inner = ch_type
.strip_prefix("Nullable(")
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(ch_type);
inner.starts_with("Int")
|| inner.starts_with("UInt")
|| inner.starts_with("Float")
|| inner.starts_with("Decimal")
}
/// Column def for the plain server-side grid (filtering + sorting only).
fn ch_type_to_ag_col_def(name: &str, ch_type: &str) -> Value {
let inner = ch_type
.strip_prefix("Nullable(")
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(ch_type);
let (filter, extra_type) = if is_numeric_ch_type(inner) {
("agNumberColumnFilter", Some("numericColumn"))
} else if inner.starts_with("DateTime") || inner.starts_with("Date") {
("agDateColumnFilter", None)
} else {
("agSetColumnFilter", None)
};
let mut def = json!({
"field": name,
"headerName": name,
"filter": filter,
"sortable": true,
"resizable": true,
"floatingFilter": true,
});
if let Some(t) = extra_type {
def["type"] = Value::String(t.to_string());
}
def
}
/// Column def for the pivot grid – adds enableRowGroup / enablePivot / enableValue / aggFunc.
fn ch_type_to_pivot_col_def(name: &str, ch_type: &str) -> Value {
let mut def = ch_type_to_ag_col_def(name, ch_type);
let numeric = is_numeric_ch_type(ch_type);
def["enableRowGroup"] = json!(true);
def["enablePivot"] = json!(!numeric); // non-numeric cols make good pivot axes
def["enableValue"] = json!(numeric);
if numeric {
def["aggFunc"] = json!("sum");
}
def
}
// ---------------------------------------------------------------------------
// Pivot SQL helpers
// ---------------------------------------------------------------------------
/// Maximum distinct values collected per pivot column. Keeps generated SQL
/// and the number of result columns manageable.
const MAX_PIVOT_VALUES: usize = 50;
/// Sanitise a pivot value so it can be used safely as a SQL alias / AG Grid field name.
fn sanitize_pivot_field_part(s: &str) -> String {
let cleaned: String = s
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect();
// Collapse consecutive underscores and strip leading/trailing ones.
let mut result = String::new();
let mut prev_underscore = false;
for c in cleaned.chars() {
if c == '_' {
if !prev_underscore && !result.is_empty() {
result.push('_');
}
prev_underscore = true;
} else {
result.push(c);
prev_underscore = false;
}
}
let result = result.trim_end_matches('_').to_string();
if result.is_empty() {
"null".to_string()
} else {
result
}
}
/// Quote a literal value for use inside a ClickHouse WHERE comparison.
/// Numbers are left bare; everything else is single-quoted.
fn quote_pivot_literal(val: &str) -> String {
if val.parse::<f64>().is_ok() {
val.to_string()
} else {
format!("'{}'", val.replace('\'', "''"))
}
}
/// Map an AG Grid aggFunc name to the corresponding ClickHouse conditional
/// aggregate function name (the `If` variant).
fn ch_agg_func_if(agg_func: &str) -> &'static str {
match agg_func.to_lowercase().as_str() {
"sum" => "sumIf",
"avg" => "avgIf",
"min" => "minIf",
"max" => "maxIf",
"count" => "countIf",
"first" => "anyIf",
"last" => "anyLastIf",
_ => "sumIf",
}
}
/// Map an AG Grid aggFunc name to the corresponding ClickHouse plain aggregate
/// function name (no condition), for grouped-only (no pivot) queries.
fn ch_agg_func(agg_func: &str) -> &'static str {
match agg_func.to_lowercase().as_str() {
"sum" => "sum",
"avg" => "avg",
"min" => "min",
"max" => "max",
"count" => "count",
"first" => "any",
"last" => "anyLast",
_ => "sum",
}
}
/// Fetch up to `MAX_PIVOT_VALUES` distinct values for a single pivot column,
/// respecting the active filter conditions.
async fn get_pivot_distinct_values(
conn: &crate::connections::Connection,
database: &str,
table: &str,
pivot_col: &str,
extra_conditions: &[String],
) -> Vec<String> {
let col_q = format!("`{}`", pivot_col.replace('`', "``"));
let where_clause = join_conditions_as_where(extra_conditions);
let query = format!(
"SELECT DISTINCT {col_q} FROM {database}.{table}{where_clause} \
ORDER BY {col_q} \
LIMIT {MAX_PIVOT_VALUES} \
FORMAT TSVWithNames"
);
let client = reqwest::Client::new();
let mut request = client
.post(conn.url())
.query(&[("user", conn.user.as_str())])
.body(query);
if !conn.password.is_empty() {
request = request.query(&[("password", conn.password.as_str())]);
}
let Ok(response) = request.send().await else {
return Vec::new();
};
if !response.status().is_success() {
return Vec::new();
}
let Ok(text) = response.text().await else {
return Vec::new();
};
text.lines()
.skip(1) // skip TSV header
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect()
}
/// Compute the Cartesian product of per-pivot-column value lists.
fn cartesian_product(sets: &[Vec<String>]) -> Vec<Vec<String>> {
sets.iter().fold(vec![vec![]], |acc, set| {
acc.into_iter()
.flat_map(|combo| {
set.iter().map(move |val| {
let mut new_combo = combo.clone();
new_combo.push(val.clone());
new_combo
})
})
.collect()
})
}
/// Build the SELECT list and the list of result field names for a pivot query.
///
/// Returns `(select_exprs, pivot_result_fields)`.
fn build_pivot_select_exprs(
pivot_cols: &[ColItem],
pivot_value_sets: &[Vec<String>],
value_cols: &[ColItem],
) -> (Vec<String>, Vec<String>) {
let combos = cartesian_product(pivot_value_sets);
let mut exprs = Vec::new();
let mut fields = Vec::new();
for combo in &combos {
// Build the WHERE condition for this pivot combination.
let combo_condition: Vec<String> = pivot_cols
.iter()
.zip(combo.iter())
.map(|(pc, val)| {
let col_q = format!("`{}`", pc.field.replace('`', "``"));
format!("{col_q} = {}", quote_pivot_literal(val))
})
.collect();
let combo_cond_sql = combo_condition.join(" AND ");
// Sanitised prefix for field names: join pivot value parts with underscore.
let combo_prefix: String = combo
.iter()
.map(|v| sanitize_pivot_field_part(v))
.collect::<Vec<_>>()
.join("_");
for vc in value_cols {
let agg_fn = ch_agg_func_if(vc.agg_func.as_deref().unwrap_or("sum"));
let val_q = format!("`{}`", vc.field.replace('`', "``"));
// Field name convention: {pivotValues}_{valueColId} (underscore separator,
// matching AG Grid's default serverSidePivotResultFieldSeparator).
let field_name = format!("{combo_prefix}~{}", sanitize_pivot_field_part(&vc.id));
let alias = format!("`{field_name}`");
exprs.push(format!("{agg_fn}({val_q}, {combo_cond_sql}) AS {alias}"));
fields.push(field_name);
}
}
(exprs, fields)
}
/// Build the complete pivot SQL query and return it together with the list of
/// pivot result field names that must be forwarded to AG Grid.
///
/// * `group_col` – the column to GROUP BY at the current tree depth (None → no groups).
/// * `conditions` – all WHERE conditions already as strings (filter + groupKey).
#[allow(clippy::too_many_arguments)]
fn build_pivot_query(
database: &str,
table: &str,
group_col: Option<&str>,
conditions: &[String],
pivot_cols: &[ColItem],
pivot_value_sets: &[Vec<String>],
value_cols: &[ColItem],
sort_model: &[SortModel],
limit: usize,
offset: usize,
) -> (String, Vec<String>) {
let mut select_parts: Vec<String> = Vec::new();
// Leading GROUP BY column (if any).
if let Some(gc) = group_col {
select_parts.push(format!("`{}`", gc.replace('`', "``")));
}
let (pivot_exprs, pivot_fields) =
build_pivot_select_exprs(pivot_cols, pivot_value_sets, value_cols);
select_parts.extend(pivot_exprs);
if select_parts.is_empty() {
// Nothing to pivot on – return a sentinel.
return (String::new(), Vec::new());
}
let where_clause = join_conditions_as_where(conditions);
let group_by = group_col
.map(|gc| format!(" GROUP BY `{}`", gc.replace('`', "``")))
.unwrap_or_default();
let order_by = build_order_clause(sort_model);
let sql = format!(
"SELECT {} FROM {database}.{table}{where_clause}{group_by}{order_by} \
LIMIT {limit} OFFSET {offset} \
FORMAT JSONEachRow",
select_parts.join(", ")
);
(sql, pivot_fields)
}
/// Build a plain GROUP BY (no pivot) query with aggregate value columns.
#[allow(clippy::too_many_arguments)]
fn build_grouped_query(
database: &str,
table: &str,
group_col: &str,
conditions: &[String],
value_cols: &[ColItem],
sort_model: &[SortModel],
limit: usize,
offset: usize,
) -> String {
let mut select_parts = vec![format!("`{}`", group_col.replace('`', "``"))];
for vc in value_cols {
let agg_fn = ch_agg_func(vc.agg_func.as_deref().unwrap_or("sum"));
let val_q = format!("`{}`", vc.field.replace('`', "``"));
let alias = format!("`{}`", vc.id.replace('`', "``"));
select_parts.push(format!("{agg_fn}({val_q}) AS {alias}"));
}
let where_clause = join_conditions_as_where(conditions);
let group_by = format!(" GROUP BY `{}`", group_col.replace('`', "``"));
let order_by = build_order_clause(sort_model);
format!(
"SELECT {} FROM {database}.{table}{where_clause}{group_by}{order_by} \
LIMIT {limit} OFFSET {offset} \
FORMAT JSONEachRow",
select_parts.join(", ")
)
}
/// Execute a raw SQL string against ClickHouse and return JSONEachRow-parsed rows.
async fn execute_json_query(
conn: &crate::connections::Connection,
sql: &str,
) -> Result<Vec<Value>, Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let mut request = client
.post(conn.url())
.query(&[("user", conn.user.as_str())])
.body(sql.to_string());
if !conn.password.is_empty() {
request = request.query(&[("password", conn.password.as_str())]);
}
let response = request.send().await?;
if !response.status().is_success() {
let err = response.text().await?;
return Err(format!("ClickHouse error: {err}").into());
}
let text = response.text().await?;
let rows = text
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.collect();
Ok(rows)
}
/// Count distinct values in `group_col` respecting a WHERE clause.
/// Falls back to COUNT(*) when no group column is given (single-row case).
async fn count_distinct_group(
conn: &crate::connections::Connection,
database: &str,
table: &str,
group_col: Option<&str>,
conditions: &[String],
) -> i64 {
let where_clause = join_conditions_as_where(conditions);
let count_expr = match group_col {
Some(gc) => format!("uniqExact(`{}`)", gc.replace('`', "``")),
None => "1".to_string(), // single summary row
};
let sql =
format!("SELECT {count_expr} FROM {database}.{table}{where_clause} FORMAT TSVWithNames");
let client = reqwest::Client::new();
let mut request = client
.post(conn.url())
.query(&[("user", conn.user.as_str())])
.body(sql);
if !conn.password.is_empty() {
request = request.query(&[("password", conn.password.as_str())]);
}
let Ok(response) = request.send().await else {
return -1;
};
if !response.status().is_success() {
return -1;
}
let Ok(text) = response.text().await else {
return -1;
};
text.lines()
.nth(1)
.and_then(|l| l.trim().parse::<i64>().ok())
.unwrap_or(-1)
}
// ---------------------------------------------------------------------------
// Page – GET /ag-grid
// ---------------------------------------------------------------------------
#[get("/ag-grid")]
pub async fn ag_grid_page(
req: HttpRequest,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let Some(ch) = store.active_client() else {
let content = html! {
div class="w-100 flex flex-column items-center justify-center" style="height: 100%;" {
div class="bg-near-black ba b--white-20 br2 pa4 mw6 w-100 tc" {
p class="f4 white-70 mb3" { "No active database connection" }
a href="/connections"
hx-get="/connections"
hx-target="#feature"
hx-swap="innerHTML"
hx-push-url="true"
class="dib bg-orange white br2 ph3 pv2 no-underline f6 fw6 hover-bg-dark-orange" {
"Configure a connection"
}
}
}
};
return if req.headers().get("HX-Request").is_some() {
Ok(content)
} else {
Ok(super::render_layout(&content))
};
};
let databases = db::all_databases(ch).await;
let content = html! {
link rel="stylesheet" href="/assets/ag-grid.min.css";
link rel="stylesheet" href="/assets/ag-theme-balham.min.css";
script src="/assets/ag-grid-enterprise.min.js" {}
div class="w-100 flex flex-column" style="height: 100%;" {
div class="bg-black-70 pa3" {
div class="flex items-end" {
div class="mr2" {
select
id="ag-database-select"
name="database"
class="input-reset ba b--white-30 ph2 pv1 br2 f7 bg-white-10 white"
style="color: white;"
hx-get="/ag-grid/tables"
hx-include="[name='database']"
hx-target="#ag-table-select-container"
hx-swap="innerHTML"
hx-trigger="change" {
option value="" selected disabled style="background-color: #1a1a1a; color: #ccc;" {
"Database..."
}
@for database in &databases {
option value=(database.name) style="background-color: #1a1a1a; color: white;" {
(database.name)
}
}
}
}
div id="ag-table-select-container" {}
}
}
div class="w-100 ph3 flex-auto flex flex-column" style="overflow: hidden;" {
h1 id="ag-heading" class="f4 fw6 white-90 mb3 lh-title" { "Select a database" }
div id="ag-grid-content" class="flex-auto flex flex-column" style="overflow: hidden;" {
p class="white-70 f6 i tc pa4" {
"Select a database and table to load the grid"
}
}
}
}
};
if req.headers().get("HX-Request").is_some() {
Ok(content)
} else {
Ok(super::render_layout(&content))
}
}
// ---------------------------------------------------------------------------
// Table selector partial – GET /ag-grid/tables
// ---------------------------------------------------------------------------
#[get("/ag-grid/tables")]
pub async fn ag_grid_get_tables(
params: web::Query<HashMap<String, String>>,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let db_name = params.get("database").map(|s| s.as_str()).unwrap_or("");
if db_name.is_empty() {
return Ok(html! {});
}
let Some(ch) = store.active_client() else {
return Ok(html! { p class="white-70 f6" { "No active connection." } });
};
let tables = db::all_tables(ch, db_name).await;
Ok(html! {
h1 id="ag-heading"
class="f4 fw6 white-90 mb3 lh-title"
hx-swap-oob="true" {
(db_name)
}
div class="mr2" {
select
id="ag-table-select"
name="table"
class="input-reset ba b--white-30 ph2 pv1 br2 f7 bg-white-10 white"
style="color: white;"
hx-get="/ag-grid/grid"
hx-include="[name='database']"
hx-target="#ag-grid-content"
hx-swap="innerHTML"
hx-trigger="change" {
option value="" selected disabled style="background-color: #1a1a1a; color: #ccc;" {
"Table..."
}
@for table in &tables {
option value=(table.name) style="background-color: #1a1a1a; color: white;" {
(table.name)
}
}
}
}
})
}
// ---------------------------------------------------------------------------
// Grid container partial – GET /ag-grid/grid
// ---------------------------------------------------------------------------
#[get("/ag-grid/grid")]
pub async fn ag_grid_get_grid(
params: web::Query<HashMap<String, String>>,
store: web::Data<ConnectionStore>,
) -> AwResult<maud::Markup> {
let db_name = params.get("database").cloned().unwrap_or_default();
let table_name = params.get("table").cloned().unwrap_or_default();
if db_name.is_empty() || table_name.is_empty() {
return Ok(html! { p class="white-70 f6 pa3" { "Invalid parameters." } });
}
let Some(_conn) = store.active() else {
return Ok(html! { p class="white-70 f6 pa3" { "No active connection." } });
};
let active_tab = params
.get("tab")
.map(|s| s.as_str())
.unwrap_or("server-side");
Ok(html! {
h1 id="ag-heading"
class="f4 fw6 white-90 mb3 lh-title"
hx-swap-oob="true" {
(db_name) span class="white-50" { " / " } (table_name)
}
// Tab bar
div class="flex bb b--white-20 mb0" {
button
id="tab-btn-server-side"
class={"f6 fw6 ph3 pv2 bn pointer mr1 br2 br--top "
@if active_tab == "server-side" { "bg-dark-orange white" }
@else { "bg-white-10 white-70 hover-white hover-bg-white-20" }}
hx-get={"/ag-grid/grid?database=" (&db_name) "&table=" (&table_name) "&tab=server-side"}
hx-target="#ag-grid-content"
hx-swap="innerHTML" {
"Server-Side Grid"
}
button
id="tab-btn-pivot"
class={"f6 fw6 ph3 pv2 bn pointer mr1 br2 br--top "
@if active_tab == "pivot" { "bg-dark-orange white" }
@else { "bg-white-10 white-70 hover-white hover-bg-white-20" }}
hx-get={"/ag-grid/grid?database=" (&db_name) "&table=" (&table_name) "&tab=pivot"}
hx-target="#ag-grid-content"
hx-swap="innerHTML" {
"Pivot"
}
}
// Tab content
div id="ag-tab-content" class="flex-auto flex flex-column" style="overflow: hidden; min-height: 0;" {
@if active_tab == "server-side" {
(render_server_side_tab(&db_name, &table_name))
} @else if active_tab == "pivot" {
(render_pivot_tab(&db_name, &table_name))
}
}
})
}
// ---------------------------------------------------------------------------
// Tab renderers
// ---------------------------------------------------------------------------
fn render_server_side_tab(database: &str, table: &str) -> maud::Markup {
let col_defs_url = format!("/ag-grid/api/column-defs?database={database}&table={table}");
let rows_url = format!("/ag-grid/api/rows?database={database}&table={table}");
let set_filter_values_base =
format!("/ag-grid/api/set-filter-values?database={database}&table={table}");
let script = format!(
r#"
(async function() {{
const gridDiv = document.getElementById('ag-server-side-grid');
if (!gridDiv) return;
let columnDefs;
try {{
const res = await fetch({col_defs_url_json});
if (!res.ok) throw new Error('Failed to load column defs: ' + res.status);
columnDefs = await res.json();
}} catch (e) {{
gridDiv.innerHTML = '<p style="color:#ff6b6b;padding:1rem">Error loading column definitions: ' + e.message + '</p>';
return;
}}
// Inject server-side value fetching for set filters so users can pick
// multiple values from a dropdown (e.g. to_city = NewYork OR Madrid).
columnDefs.forEach(function(colDef) {{
if (colDef.filter === 'agSetColumnFilter') {{
colDef.filterParams = colDef.filterParams || {{}};
colDef.filterParams.values = function(params) {{
fetch({set_filter_values_base_json} + '&column=' + encodeURIComponent(colDef.field))
.then(function(r) {{ return r.json(); }})
.then(function(data) {{ params.success(data); }})
.catch(function() {{ params.success([]); }});
}};
}}
}});
const datasource = {{
getRows: function(params) {{
fetch({rows_url_json}, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(params.request)
}})
.then(function(res) {{
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}})
.then(function(data) {{
params.success({{ rowData: data.rowData, rowCount: data.rowCount }});
}})
.catch(function(err) {{
console.error('AG Grid datasource error:', err);
params.fail();
}});
}}
}};
agGrid.createGrid(gridDiv, {{
columnDefs: columnDefs,
rowModelType: 'serverSide',
serverSideDatasource: datasource,
defaultColDef: {{
flex: 1,
minWidth: 120,
resizable: true,
sortable: true,
filter: true,
floatingFilter: true,
}},
pagination: true,
paginationPageSize: 100,
cacheBlockSize: 100,
animateRows: true,
theme: 'legacy',
}});
}})();
"#,
col_defs_url_json = serde_json::to_string(&col_defs_url).unwrap(),
rows_url_json = serde_json::to_string(&rows_url).unwrap(),
set_filter_values_base_json = serde_json::to_string(&set_filter_values_base).unwrap(),
);
html! {
div id="ag-server-side-grid"
class="ag-theme-balham-dark flex-auto"
style="width: 100%; height: 100%; min-height: 400px;" {}
(PreEscaped(format!("<script>{script}</script>")))
}
}
fn render_pivot_tab(database: &str, table: &str) -> maud::Markup {
let col_defs_url = format!("/ag-grid/api/pivot-auto-config?database={database}&table={table}");
let pivot_rows_url = format!("/ag-grid/api/pivot-rows?database={database}&table={table}");
let set_filter_values_base =
format!("/ag-grid/api/set-filter-values?database={database}&table={table}");
// The pivot datasource passes the full AG Grid request (including rowGroupCols,
// valueCols, pivotCols, groupKeys) to the server and forwards pivotResultFields
// back to AG Grid so it can auto-generate secondary pivot columns.
let script = format!(
r#"
(async function() {{
const gridDiv = document.getElementById('ag-pivot-grid');
if (!gridDiv) return;
let columnDefs;
try {{
const res = await fetch({col_defs_url_json});
if (!res.ok) throw new Error('Failed to load column defs: ' + res.status);
columnDefs = await res.json();
}} catch (e) {{
gridDiv.innerHTML = '<p style="color:#ff6b6b;padding:1rem">Error loading column definitions: ' + e.message + '</p>';
return;
}}
// Inject server-side value fetching for set filters so users can pick
// multiple values from a dropdown (e.g. to_city = NewYork OR Madrid).
columnDefs.forEach(function(colDef) {{
if (colDef.filter === 'agSetColumnFilter') {{
colDef.filterParams = colDef.filterParams || {{}};
colDef.filterParams.values = function(params) {{
fetch({set_filter_values_base_json} + '&column=' + encodeURIComponent(colDef.field))
.then(function(r) {{ return r.json(); }})
.then(function(data) {{ params.success(data); }})
.catch(function() {{ params.success([]); }});
}};
}}
}});
const datasource = {{
getRows: function(params) {{
fetch({pivot_rows_url_json}, {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(params.request)
}})
.then(function(res) {{
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
}})
.then(function(data) {{
params.success({{
rowData: data.rowData,
rowCount: data.rowCount,
// AG Grid uses these field names to auto-build pivot result columns.
// The default separator is '_' which matches our naming convention.
pivotResultFields: data.pivotFields || [],
}});
}})
.catch(function(err) {{
console.error('AG Grid pivot datasource error:', err);
params.fail();
}});
}}
}};
agGrid.createGrid(gridDiv, {{
columnDefs: columnDefs,
rowModelType: 'serverSide',
serverSideDatasource: datasource,
// Pivot mode must be true for the grid to send pivot-related fields.
pivotMode: true,
// Separator used when constructing result field names from pivot values.
// Must match the naming convention used by the server.
serverSidePivotResultFieldSeparator: '~',
defaultColDef: {{
flex: 1,
minWidth: 100,
resizable: true,
sortable: true,
}},
// Show the column tool panel so users can drag columns into
// Row Groups / Values / Column Labels (Pivot) buckets.
sideBar: {{
toolPanels: [
{{
id: 'columns',
labelDefault: 'Columns',
labelKey: 'columns',
iconKey: 'columns',
toolPanel: 'agColumnsToolPanel',
}},
{{
id: 'filters',
labelDefault: 'Filters',
labelKey: 'filters',
iconKey: 'filter',
toolPanel: 'agFiltersToolPanel',
}},
],
defaultToolPanel: 'columns',
}},
animateRows: true,
theme: 'legacy',
}});
}})();
"#,
col_defs_url_json = serde_json::to_string(&col_defs_url).unwrap(),
pivot_rows_url_json = serde_json::to_string(&pivot_rows_url).unwrap(),
set_filter_values_base_json = serde_json::to_string(&set_filter_values_base).unwrap(),
);
html! {
p class="f7 white-60 mb2 mt2 lh-copy" {
"Use the "
strong { "Columns" }
" panel on the right to drag fields into "
strong { "Row Groups" }
", "
strong { "Values" }
" and "
strong { "Column Labels" }
" buckets."
}
div id="ag-pivot-grid"
class="ag-theme-balham-dark flex-auto"
style="width: 100%; height: 100%; min-height: 400px;" {}
(PreEscaped(format!("<script>{script}</script>")))
}
}
// ---------------------------------------------------------------------------
// JSON API – GET /ag-grid/api/column-defs (plain grid)
// ---------------------------------------------------------------------------
#[get("/ag-grid/api/column-defs")]
pub async fn ag_grid_column_defs(
params: web::Query<HashMap<String, String>>,
store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
let db_name = params.get("database").cloned().unwrap_or_default();
let table_name = params.get("table").cloned().unwrap_or_default();
let Some(conn) = store.active() else {
return Ok(
HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
);
};
let columns = db::describe_table(&conn, &db_name, &table_name).await;
if columns.is_empty() {
return Ok(
HttpResponse::NotFound().json(json!({"error": "Table not found or has no columns"}))
);
}
let col_defs: Vec<Value> = columns
.iter()
.map(|c| ch_type_to_ag_col_def(&c.name, &c.column_type))
.collect();
Ok(HttpResponse::Ok().json(col_defs))
}
// ---------------------------------------------------------------------------
// JSON API – GET /ag-grid/api/set-filter-values (distinct values for set filter)
// ---------------------------------------------------------------------------
/// Maximum number of distinct values returned for a set filter column.
const MAX_SET_FILTER_VALUES: usize = 1000;
#[get("/ag-grid/api/set-filter-values")]
pub async fn ag_grid_set_filter_values(
params: web::Query<HashMap<String, String>>,
store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
let db_name = params.get("database").cloned().unwrap_or_default();
let table_name = params.get("table").cloned().unwrap_or_default();
let column = params.get("column").cloned().unwrap_or_default();
if column.is_empty() {
return Ok(HttpResponse::BadRequest().json(json!({"error": "column param required"})));
}
let Some(conn) = store.active() else {
return Ok(
HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
);
};
let col_q = format!("`{}`", column.replace('`', "``"));
let query = format!(
"SELECT DISTINCT {col_q} FROM {db_name}.{table_name} \
ORDER BY {col_q} \
LIMIT {MAX_SET_FILTER_VALUES} \
FORMAT TSVWithNames"
);
let client = reqwest::Client::new();
let mut request = client
.post(conn.url())
.query(&[("user", conn.user.as_str())])
.body(query);
if !conn.password.is_empty() {
request = request.query(&[("password", conn.password.as_str())]);
}
let response = match request.send().await {
Ok(r) => r,
Err(e) => {
return Ok(HttpResponse::InternalServerError()
.json(json!({"error": format!("ClickHouse request failed: {e}")})));
}
};
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Ok(HttpResponse::InternalServerError()
.json(json!({"error": format!("ClickHouse error: {body}")})));
}
let text = match response.text().await {
Ok(t) => t,
Err(e) => {
return Ok(HttpResponse::InternalServerError()
.json(json!({"error": format!("Failed to read response: {e}")})));
}
};
let values: Vec<String> = text
.lines()
.skip(1) // skip TSV header row
.filter(|l| !l.trim().is_empty())
.map(|l| l.to_string())
.collect();
Ok(HttpResponse::Ok().json(values))
}
// ---------------------------------------------------------------------------
// Pivot auto-config helpers
// ---------------------------------------------------------------------------
/// Fetch the approximate cardinality of every listed column in one query.
/// Uses `uniqExact` for accuracy. Returns a map of column_name → count.
async fn get_column_cardinalities(
conn: &crate::connections::Connection,
database: &str,
table: &str,
col_names: &[String],
) -> HashMap<String, u64> {
if col_names.is_empty() {
return HashMap::new();
}
let exprs: Vec<String> = col_names
.iter()
.map(|n| {
let q = format!("`{}`", n.replace('`', "``"));
format!("uniqExact({q}) AS `{}`", n.replace('`', "``"))
})
.collect();
let sql = format!(
"SELECT {} FROM {database}.{table} FORMAT JSONEachRow",
exprs.join(", ")
);
let client = reqwest::Client::new();
let mut request = client
.post(conn.url())
.query(&[("user", conn.user.as_str())])
.body(sql);
if !conn.password.is_empty() {
request = request.query(&[("password", conn.password.as_str())]);
}
let Ok(resp) = request.send().await else {
return HashMap::new();
};
if !resp.status().is_success() {
return HashMap::new();
}
let Ok(text) = resp.text().await else {
return HashMap::new();
};
// Expect exactly one JSONEachRow line.
let Some(line) = text.lines().find(|l| !l.trim().is_empty()) else {
return HashMap::new();
};
let Ok(Value::Object(map)) = serde_json::from_str::<Value>(line) else {
return HashMap::new();
};
map.into_iter()
.filter_map(|(k, v)| {
let count = match &v {
Value::Number(n) => n.as_u64(),
Value::String(s) => s.parse::<u64>().ok(),
_ => None,
}?;
Some((k, count))
})
.collect()
}
/// Roles that can be assigned to a column in pivot mode.
#[derive(Debug, Clone, PartialEq, Eq)]
enum PivotRole {
RowGroup,
Pivot,
Value(String), // aggFunc
None,
}
/// Decide the pivot role of each column based on its CH type and cardinality.
fn assign_pivot_roles(
columns: &[db::ColumnInfo],
cardinalities: &HashMap<String, u64>,
) -> Vec<(db::ColumnInfo, PivotRole)> {
let mut result: Vec<(db::ColumnInfo, PivotRole)> = Vec::new();
// Counters so we don't create too many row-groups or pivots.
let mut pivot_count = 0usize;
let mut row_group_count = 0usize;
for col in columns {
let inner = col
.column_type
.strip_prefix("Nullable(")
.and_then(|s: &str| s.strip_suffix(')'))
.unwrap_or(&col.column_type);
let cardinality = cardinalities.get(&col.name).copied().unwrap_or(u64::MAX);
let role = if inner.starts_with("Float") || inner.starts_with("Decimal") {
PivotRole::Value("sum".to_string())
} else if inner == "Bool" || inner == "Boolean" {
// Booleans always have exactly 2 values – ideal pivot axis.
pivot_count += 1;
PivotRole::Pivot
} else if inner.starts_with("Int") || inner.starts_with("UInt") {
// Integer columns: use as value unless low-cardinality (good pivot axis).
if cardinality <= 20 && pivot_count == 0 {
pivot_count += 1;
PivotRole::Pivot
} else {
PivotRole::Value("sum".to_string())
}
} else if inner.starts_with("DateTime") {
// DateTime is too granular for grouping.
PivotRole::None
} else if inner.starts_with("Date") {
if cardinality <= 366 && row_group_count < 2 {
row_group_count += 1;
PivotRole::RowGroup
} else {
PivotRole::None
}
} else {
// String / Enum / other text-like types.
if cardinality <= 20 && pivot_count == 0 {
pivot_count += 1;
PivotRole::Pivot
} else if cardinality <= 500 && row_group_count < 2 {
row_group_count += 1;
PivotRole::RowGroup
} else {
PivotRole::None
}
};
result.push((col.clone(), role));
}
result
}
/// Build fully-configured column defs for the pivot grid.
/// Columns with roles get `rowGroup`, `pivot`, or `aggFunc` pre-set so the
/// first `getRows` call already sends correct metadata to the server.
fn build_auto_config_col_defs(roles: &[(db::ColumnInfo, PivotRole)]) -> Vec<Value> {
roles
.iter()
.map(|(col, role)| {
let mut def = ch_type_to_pivot_col_def(&col.name, &col.column_type);
match role {
PivotRole::RowGroup => {
def["rowGroup"] = json!(true);
def["hide"] = json!(true);
}
PivotRole::Pivot => {
def["pivot"] = json!(true);
def["hide"] = json!(true);
}
PivotRole::Value(agg) => {
def["aggFunc"] = json!(agg);
}
PivotRole::None => {}
}
def
})
.collect()
}
// ---------------------------------------------------------------------------
// JSON API – GET /ag-grid/api/pivot-auto-config
// ---------------------------------------------------------------------------
#[get("/ag-grid/api/pivot-auto-config")]
pub async fn ag_grid_pivot_auto_config(
params: web::Query<HashMap<String, String>>,
store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
let db_name = params.get("database").cloned().unwrap_or_default();
let table_name = params.get("table").cloned().unwrap_or_default();
let Some(conn) = store.active() else {
return Ok(
HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
);
};
let columns = db::describe_table(&conn, &db_name, &table_name).await;
if columns.is_empty() {
return Ok(
HttpResponse::NotFound().json(json!({"error": "Table not found or has no columns"}))
);
}
// Only bother computing cardinalities for non-float, non-DateTime columns
// since floats always become values and DateTimes are never grouped.
let candidate_names: Vec<String> = columns
.iter()
.filter(|c| {
let inner = c
.column_type
.strip_prefix("Nullable(")
.and_then(|s| s.strip_suffix(')'))
.unwrap_or(&c.column_type);
!inner.starts_with("Float")
&& !inner.starts_with("Decimal")
&& !inner.starts_with("DateTime")
})
.map(|c| c.name.clone())
.collect();
let cardinalities =
get_column_cardinalities(&conn, &db_name, &table_name, &candidate_names).await;
let roles = assign_pivot_roles(&columns, &cardinalities);
let col_defs = build_auto_config_col_defs(&roles);
Ok(HttpResponse::Ok().json(col_defs))
}
// ---------------------------------------------------------------------------
// JSON API – GET /ag-grid/api/pivot-column-defs (pivot grid)
// ---------------------------------------------------------------------------
#[get("/ag-grid/api/pivot-column-defs")]
pub async fn ag_grid_pivot_column_defs(
params: web::Query<HashMap<String, String>>,
store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
let db_name = params.get("database").cloned().unwrap_or_default();
let table_name = params.get("table").cloned().unwrap_or_default();
let Some(conn) = store.active() else {
return Ok(
HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
);
};
let columns = db::describe_table(&conn, &db_name, &table_name).await;
if columns.is_empty() {
return Ok(
HttpResponse::NotFound().json(json!({"error": "Table not found or has no columns"}))
);
}
let col_defs: Vec<Value> = columns
.iter()
.map(|c| ch_type_to_pivot_col_def(&c.name, &c.column_type))
.collect();
Ok(HttpResponse::Ok().json(col_defs))
}
// ---------------------------------------------------------------------------
// JSON API – POST /ag-grid/api/rows (plain server-side grid)
// ---------------------------------------------------------------------------
#[post("/ag-grid/api/rows")]
pub async fn ag_grid_rows(
params: web::Query<HashMap<String, String>>,
body: web::Json<AgGridRowRequest>,
store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
let db_name = params.get("database").cloned().unwrap_or_default();
let table_name = params.get("table").cloned().unwrap_or_default();
let Some(conn) = store.active() else {
return Ok(
HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
);
};
let req = body.into_inner();
let where_clause = build_where_clause(&req.filter_model);
let order_clause = build_order_clause(&req.sort_model);
let start_row = req.start_row.max(0) as usize;
let end_row = req.end_row.max(1) as usize;
let limit = end_row.saturating_sub(start_row).min(10_000);
let rows_result = db::get_table_rows_json(
&conn,
&db_name,
&table_name,
limit,
start_row,
&where_clause,
&order_clause,
)
.await;
let count_result = db::get_table_count(&conn, &db_name, &table_name, &where_clause).await;
match rows_result {
Ok(rows) => {
let row_count: i64 = count_result.unwrap_or(-1);
Ok(HttpResponse::Ok().json(json!({
"rowData": rows,
"rowCount": row_count,
})))
}
Err(e) => {
log::error!("ag_grid_rows error: {e}");
let err_msg = e.to_string();
Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
}
}
}
// ---------------------------------------------------------------------------
// JSON API – POST /ag-grid/api/pivot-rows
// ---------------------------------------------------------------------------
#[post("/ag-grid/api/pivot-rows")]
pub async fn ag_grid_pivot_rows(
params: web::Query<HashMap<String, String>>,
body: web::Json<AgGridRowRequest>,
store: web::Data<ConnectionStore>,
) -> AwResult<HttpResponse> {
let db_name = params.get("database").cloned().unwrap_or_default();
let table_name = params.get("table").cloned().unwrap_or_default();
let Some(conn) = store.active() else {
return Ok(
HttpResponse::ServiceUnavailable().json(json!({"error": "No active connection"}))
);
};
let req = body.into_inner();
let start_row = req.start_row.max(0) as usize;
let end_row = req.end_row.max(1) as usize;
let limit = end_row.saturating_sub(start_row).min(10_000);
// -----------------------------------------------------------------------
// Build WHERE conditions
// -----------------------------------------------------------------------
// 1. Conditions from the AG Grid filter model.
let filter_only_conditions = filter_conditions(&req.filter_model);
let mut conditions = filter_only_conditions.clone();
// 2. Conditions from groupKeys – each key constrains the corresponding
// rowGroupCol, narrowing the query to a single sub-group node.
for (group_col, key_val) in req.row_group_cols.iter().zip(req.group_keys.iter()) {
let col_q = format!("`{}`", group_col.field.replace('`', "``"));
conditions.push(format!("{col_q} = {}", quote_pivot_literal(key_val)));
}
// -----------------------------------------------------------------------
// Determine the GROUP BY column at the current tree depth.
// depth == groupKeys.len() → the next row group column (if any).
// -----------------------------------------------------------------------
let depth = req.group_keys.len();
let group_col: Option<&str> = req.row_group_cols.get(depth).map(|c| c.field.as_str());
// -----------------------------------------------------------------------
// Branch on whether pivot mode is actually active with pivot columns.
// -----------------------------------------------------------------------
if req.pivot_mode && !req.pivot_cols.is_empty() && !req.value_cols.is_empty() {
// --- Full pivot path ---
// Collect distinct values for each pivot column.
let mut pivot_value_sets: Vec<Vec<String>> = Vec::new();
for pc in &req.pivot_cols {
// Use only filter conditions (not groupKey conditions) so that pivot
// columns are consistent across all tree levels – a group that has no
// data for a city will return 0 rather than omitting that column entirely.
let vals = get_pivot_distinct_values(
&conn,
&db_name,
&table_name,
&pc.field,
&filter_only_conditions,
)
.await;
pivot_value_sets.push(vals);
}
// Nothing to pivot on (table may be empty or all values filtered out).
if pivot_value_sets.iter().any(|v| v.is_empty()) {
return Ok(HttpResponse::Ok().json(json!({
"rowData": [],
"rowCount": 0,
"pivotFields": [],
})));
}
let (sql, pivot_fields) = build_pivot_query(
&db_name,
&table_name,
group_col,
&conditions,
&req.pivot_cols,
&pivot_value_sets,
&req.value_cols,
&req.sort_model,
limit,
start_row,
);
if sql.is_empty() {
return Ok(HttpResponse::Ok().json(json!({
"rowData": [],
"rowCount": 0,
"pivotFields": [],
})));
}
let rows_result = execute_json_query(&conn, &sql).await;
let row_count =
count_distinct_group(&conn, &db_name, &table_name, group_col, &conditions).await;
match rows_result {
Ok(rows) => Ok(HttpResponse::Ok().json(json!({
"rowData": rows,
"rowCount": row_count,
"pivotFields": pivot_fields,
}))),
Err(e) => {
log::error!("ag_grid_pivot_rows (pivot) error: {e}");
let err_msg = e.to_string();
Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
}
}
} else if !req.row_group_cols.is_empty() && !req.value_cols.is_empty() {
// --- Grouped aggregation (pivot mode on but no pivot cols yet) ---
let Some(gc) = group_col else {
// At leaf level with no more group columns – return raw rows.
let where_clause = join_conditions_as_where(&conditions);
let order_clause = build_order_clause(&req.sort_model);
let rows_result = db::get_table_rows_json(
&conn,
&db_name,
&table_name,
limit,
start_row,
&where_clause,
&order_clause,
)
.await;
let count = db::get_table_count(&conn, &db_name, &table_name, &where_clause).await;
return match rows_result {
Ok(rows) => Ok(HttpResponse::Ok().json(json!({
"rowData": rows,
"rowCount": count.unwrap_or(-1),
"pivotFields": [],
}))),
Err(e) => {
let err_msg = e.to_string();
Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
}
};
};
let sql = build_grouped_query(
&db_name,
&table_name,
gc,
&conditions,
&req.value_cols,
&req.sort_model,
limit,
start_row,
);
let rows_result = execute_json_query(&conn, &sql).await;
let row_count =
count_distinct_group(&conn, &db_name, &table_name, Some(gc), &conditions).await;
match rows_result {
Ok(rows) => Ok(HttpResponse::Ok().json(json!({
"rowData": rows,
"rowCount": row_count,
"pivotFields": [],
}))),
Err(e) => {
log::error!("ag_grid_pivot_rows (grouped) error: {e}");
let err_msg = e.to_string();
Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
}
}
} else {
// --- Fallback: no groups, no pivot – return raw rows ---
let where_clause = join_conditions_as_where(&conditions);
let order_clause = build_order_clause(&req.sort_model);
let rows_result = db::get_table_rows_json(
&conn,
&db_name,
&table_name,
limit,
start_row,
&where_clause,
&order_clause,
)
.await;
let count = db::get_table_count(&conn, &db_name, &table_name, &where_clause).await;
match rows_result {
Ok(rows) => Ok(HttpResponse::Ok().json(json!({
"rowData": rows,
"rowCount": count.unwrap_or(-1),
"pivotFields": [],
}))),
Err(e) => {
let err_msg = e.to_string();
Ok(HttpResponse::InternalServerError().json(json!({"error": err_msg})))
}
}
}
}