use askama::Template;
use axum::response::Response;
use chrono::DateTime;
use chrono_tz::Tz;
use laterite_core::query::{bind_values, bind_values_as, build, text_cast};
use laterite_core::{AnyRowExt, Db};
use sea_query::{Alias, Expr, Order, Query};
use serde::Deserialize;
use crate::sql::valid_ident;
use crate::{render, render_error, AdminState};
const ID_ALIAS: &str = "_lat_id";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDir {
Asc,
Desc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColumnKind {
#[default]
Text,
DateTime,
Date,
Time,
Bool,
}
#[derive(Debug, Clone)]
pub struct ListColumn {
pub field: String,
pub label: String,
pub kind: ColumnKind,
}
impl ListColumn {
pub fn new(field: &str, label: &str) -> Self {
Self {
field: field.to_string(),
label: label.to_string(),
kind: ColumnKind::Text,
}
}
pub fn datetime(mut self) -> Self {
self.kind = ColumnKind::DateTime;
self
}
pub fn date(mut self) -> Self {
self.kind = ColumnKind::Date;
self
}
pub fn time(mut self) -> Self {
self.kind = ColumnKind::Time;
self
}
pub fn yes_no(mut self) -> Self {
self.kind = ColumnKind::Bool;
self
}
}
#[derive(Debug, Clone)]
pub struct ListConfig {
pub entity: String,
pub title: String,
pub columns: Vec<ListColumn>,
pub order_by: String,
pub order_dir: SortDir,
pub per_page: i64,
pub id_field: String,
pub edit_base: Option<String>,
pub creatable: bool,
}
#[derive(Deserialize)]
pub struct ListParams {
page: Option<i64>,
}
pub struct RowView {
pub id: String,
pub cells: Vec<String>,
}
pub struct ListPage {
pub rows: Vec<RowView>,
pub total: i64,
}
pub(crate) async fn query(db: &Db, config: &ListConfig, offset: i64) -> anyhow::Result<ListPage> {
if !valid_ident(&config.entity)
|| !valid_ident(&config.order_by)
|| !valid_ident(&config.id_field)
|| !config.columns.iter().all(|c| valid_ident(&c.field))
{
anyhow::bail!("invalid identifier in list config for '{}'", config.entity);
}
let dir = match config.order_dir {
SortDir::Asc => Order::Asc,
SortDir::Desc => Order::Desc,
};
let (sql, values) = {
let mut select = Query::select();
for column in &config.columns {
select.expr_as(
Expr::col(Alias::new(&column.field)).cast_as(Alias::new(text_cast(db.backend))),
Alias::new(&column.field),
);
}
select
.expr_as(
Expr::col(Alias::new(&config.id_field)).cast_as(Alias::new(text_cast(db.backend))),
Alias::new(ID_ALIAS),
)
.from(Alias::new(&config.entity))
.order_by(Alias::new(&config.order_by), dir)
.limit(config.per_page.max(0) as u64)
.offset(offset.max(0) as u64);
build(db.backend, select)
};
let raw = bind_values(sqlx::query(&sql), values)
.fetch_all(&db.pool)
.await?;
let (csql, cvalues) = {
let count = Query::select()
.expr(Expr::col(Alias::new(&config.id_field)).count())
.from(Alias::new(&config.entity))
.to_owned();
build(db.backend, count)
};
let total: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&csql), cvalues)
.fetch_one(&db.pool)
.await?
.0;
let rows = raw
.iter()
.map(|row| RowView {
id: get_text(row, ID_ALIAS),
cells: config
.columns
.iter()
.map(|c| get_text(row, &c.field))
.collect(),
})
.collect();
Ok(ListPage { rows, total })
}
fn get_text(row: &sqlx::any::AnyRow, column: &str) -> String {
row.get_text_opt(column).ok().flatten().unwrap_or_default()
}
fn format_cell(raw: &str, kind: ColumnKind, tz: Tz) -> String {
match kind {
ColumnKind::Text => raw.to_string(),
ColumnKind::Bool => match raw {
"1" | "true" => "Yes".to_string(),
"0" | "false" | "" => "No".to_string(),
other => other.to_string(),
},
ColumnKind::DateTime | ColumnKind::Date | ColumnKind::Time => {
match DateTime::parse_from_rfc3339(raw) {
Ok(dt) => {
let local = dt.with_timezone(&tz);
let pattern = match kind {
ColumnKind::Date => "%-d %b %Y",
ColumnKind::Time => "%H:%M",
_ => "%-d %b %Y, %H:%M",
};
local.format(pattern).to_string()
}
Err(_) => raw.to_string(),
}
}
}
}
pub(crate) async fn handle(
state: &AdminState,
config: &ListConfig,
params: ListParams,
shell: crate::Shell,
) -> Response {
let page = params.page.unwrap_or(1).max(1);
let offset = (page - 1) * config.per_page;
match query(&state.db, config, offset).await {
Ok(result) => {
let total_pages = ((result.total + config.per_page - 1) / config.per_page).max(1);
let rows = result
.rows
.into_iter()
.map(|row| RowView {
id: row.id,
cells: row
.cells
.iter()
.zip(&config.columns)
.map(|(raw, col)| format_cell(raw, col.kind, shell.tz))
.collect(),
})
.collect();
render(ListTemplate {
shell,
title: config.title.clone(),
columns: config.columns.iter().map(|c| c.label.clone()).collect(),
rows,
page,
total: result.total,
total_pages,
edit_base: config.edit_base.clone(),
creatable: config.creatable,
})
}
Err(_) => render_error(),
}
}
#[derive(Template)]
#[template(path = "list.html")]
struct ListTemplate {
shell: crate::Shell,
title: String,
columns: Vec<String>,
rows: Vec<RowView>,
page: i64,
total: i64,
total_pages: i64,
edit_base: Option<String>,
creatable: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> ListConfig {
ListConfig {
entity: "backend_users".to_string(),
title: "Users".to_string(),
columns: vec![
ListColumn::new("username", "Username"),
ListColumn::new("is_superuser", "Superuser"),
],
order_by: "created_at".to_string(),
order_dir: SortDir::Desc,
per_page: 25,
id_field: "id".to_string(),
edit_base: None,
creatable: false,
}
}
#[test]
fn formats_cells_by_kind() {
let ist: Tz = "Asia/Kolkata".parse().unwrap();
assert_eq!(
format_cell("2026-08-13T10:00:00+00:00", ColumnKind::DateTime, ist),
"13 Aug 2026, 15:30"
);
assert_eq!(
format_cell("2026-08-13T10:00:00+00:00", ColumnKind::Date, Tz::UTC),
"13 Aug 2026"
);
assert_eq!(
format_cell("2026-08-13T10:00:00+00:00", ColumnKind::Time, ist),
"15:30"
);
assert_eq!(format_cell("true", ColumnKind::Bool, Tz::UTC), "Yes");
assert_eq!(format_cell("false", ColumnKind::Bool, Tz::UTC), "No");
assert_eq!(format_cell("root", ColumnKind::Text, Tz::UTC), "root");
assert_eq!(format_cell("n/a", ColumnKind::DateTime, Tz::UTC), "n/a");
}
async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
laterite_core::testing::connect_test(&[laterite_auth::migrations()]).await
}
#[tokio::test]
async fn query_returns_display_rows() {
let (db, _guard) = test_db().await;
let hash = laterite_auth::password::hash_password("pw").unwrap();
laterite_auth::store::create_user(
&db,
"root",
"root@example.test",
"Ada",
None,
&hash,
true,
)
.await
.unwrap();
let result = query(&db, &config(), 0).await.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows[0].cells[0], "root");
assert_eq!(result.rows[0].cells[1], "1");
assert!(!result.rows[0].id.is_empty());
}
#[tokio::test]
async fn query_rejects_bad_identifiers() {
let (db, _guard) = test_db().await;
let mut bad = config();
bad.entity = "backend_users; drop table backend_users".to_string();
assert!(query(&db, &bad, 0).await.is_err());
}
}