add total count indicator to table view

This commit is contained in:
Brent Schroeter 2026-01-19 21:12:00 +00:00
parent 341e02a41b
commit 4ef7d8f922
6 changed files with 197 additions and 85 deletions

View file

@ -1,7 +1,7 @@
use bigdecimal::BigDecimal;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::Postgres;
use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
@ -35,6 +35,16 @@ impl Datum {
}
}
/// Push this as a parameter to a [`QueryBuilder`].
pub fn push_bind_onto(self, builder: &mut QueryBuilder<'_, Postgres>) {
match self {
Self::Numeric(value) => builder.push_bind(value),
Self::Text(value) => builder.push_bind(value),
Self::Timestamp(value) => builder.push_bind(value),
Self::Uuid(value) => builder.push_bind(value),
};
}
/// Transform the contained value into a serde_json::Value.
pub fn inner_as_value(&self) -> serde_json::Value {
let serialized = serde_json::to_value(self).unwrap();

View file

@ -2,12 +2,16 @@ use std::fmt::Display;
use phono_backends::escape_identifier;
use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use crate::datum::Datum;
/// Representation of a partial, parameterized SQL query. Allows callers to
/// build queries iteratively and dynamically, handling parameter numbering
/// (`$1`, `$2`, `$3`, ...) automatically.
///
/// This is similar to [`sqlx::QueryBuilder`], except that [`QueryFragment`]
/// objects are composable and may be concatenated to each other.
#[derive(Clone, Debug, PartialEq)]
pub struct QueryFragment {
/// SQL string, split wherever there is a query parameter. For example,
@ -19,8 +23,14 @@ pub struct QueryFragment {
}
impl QueryFragment {
pub fn to_sql(&self, first_param_idx: usize) -> String {
/// Validate invariants. Should be run immediately before returning any
/// useful output.
fn gut_checks(&self) {
assert!(self.plain_sql.len() == self.params.len() + 1);
}
pub fn to_sql(&self, first_param_idx: usize) -> String {
self.gut_checks();
self.plain_sql
.iter()
.cloned()
@ -39,6 +49,7 @@ impl QueryFragment {
/// Returns only the parameterized values, in order.
pub fn to_params(&self) -> Vec<Datum> {
self.gut_checks();
self.params.clone()
}
@ -50,6 +61,11 @@ impl QueryFragment {
}
}
/// Convenience function to construct an empty value.
pub fn empty() -> Self {
Self::from_sql("")
}
/// Parse from a parameter value with no additional SQL. (Renders as `$n`,
/// where`n` is the appropriate parameter index.)
pub fn from_param(param: Datum) -> Self {
@ -61,8 +77,6 @@ impl QueryFragment {
/// Append another query fragment to this one.
pub fn push(&mut self, mut other: QueryFragment) {
assert!(self.plain_sql.len() == self.params.len() + 1);
assert!(other.plain_sql.len() == other.params.len() + 1);
let tail = self
.plain_sql
.pop()
@ -101,6 +115,33 @@ impl QueryFragment {
pub fn concat<I: IntoIterator<Item = Self>>(fragments: I) -> Self {
Self::join(fragments, Self::from_sql(""))
}
/// Checks whether value is empty. A value is considered empty if the
/// resulting SQL code is 0 characters long.
pub fn is_empty(&self) -> bool {
self.gut_checks();
self.plain_sql.len() == 1
&& self
.plain_sql
.first()
.expect("already checked that len == 1")
.is_empty()
}
}
impl From<QueryFragment> for QueryBuilder<'_, Postgres> {
fn from(value: QueryFragment) -> Self {
value.gut_checks();
let mut builder = QueryBuilder::new("");
let mut param_iter = value.params.into_iter();
for plain_sql in value.plain_sql {
builder.push(plain_sql);
if let Some(param) = param_iter.next() {
param.push_bind_onto(&mut builder);
}
}
builder
}
}
/// Building block of a syntax tree for a constrained subset of SQL that can be

View file

@ -71,7 +71,6 @@ impl Field {
.or(Err(ParseError::FieldNotFound))?;
let type_info = value_ref.type_info();
let ty = type_info.name();
dbg!(&ty);
Ok(match ty {
"NUMERIC" => {
Datum::Numeric(<Option<BigDecimal> as Decode<Postgres>>::decode(value_ref).unwrap())

View file

@ -16,8 +16,8 @@ use phono_models::{
};
use serde::{Deserialize, Serialize};
use sqlx::{
Postgres, QueryBuilder,
postgres::{PgRow, types::Oid},
query,
};
use tracing::debug;
use uuid::Uuid;
@ -107,57 +107,70 @@ pub(super) async fn get(
field_info
};
let sql_fragment = {
// Defensive programming: Make `sql_fragment` immutable once built.
let mut sql_fragment = QueryFragment::from_sql(&format!(
"select {0} from {1}",
pkey_attrs
let main_data_query = GetDataQuery {
selection: QueryFragment::from_sql(
&pkey_attrs
.iter()
.chain(attrs.iter())
.map(|attr| escape_identifier(&attr.attname))
.map(|attr| {
format!(
"main.{ident_esc}",
ident_esc = escape_identifier(&attr.attname)
)
})
.collect::<Vec<_>>()
.join(", "),
rel.get_identifier(),
));
if let Some(filter_expr) = portal.table_filter.0.clone() {
sql_fragment.push(QueryFragment::from_sql(" where "));
sql_fragment.push(filter_expr.into_query_fragment());
}
if let Some(subfilter_expr) = form.subfilter.and_then(|value| {
if value.is_empty() {
None
} else {
serde_json::from_str::<Option<PgExpressionAny>>(&value)
// Ignore invalid input. A user likely pasted incorrectly
// or made a typo.
.inspect_err(|_| debug!("ignoring invalid subfilter expression"))
.ok()
.flatten()
}
}) {
sql_fragment.push(QueryFragment::from_sql(
if portal.table_filter.0.is_some() {
" and "
} else {
" where "
},
));
sql_fragment.push(subfilter_expr.into_query_fragment());
}
sql_fragment.push(QueryFragment::from_sql(" order by _id limit "));
sql_fragment.push(QueryFragment::from_param(Datum::Numeric(Some(
FRONTEND_ROW_LIMIT.into(),
))));
sql_fragment
),
source: QueryFragment::from_sql(&format!(
"{ident_esc} as main",
ident_esc = rel.get_identifier()
)),
filters: QueryFragment::join(
[
portal
.table_filter
.0
.map(|filter| filter.into_query_fragment()),
form.subfilter
.and_then(|value| {
if value.is_empty() {
None
} else {
serde_json::from_str::<Option<PgExpressionAny>>(&value)
// Ignore invalid input. A user likely pasted incorrectly
// or made a typo.
.inspect_err(|_| debug!("ignoring invalid subfilter expression"))
.ok()
.flatten()
}
})
.map(|filter| filter.into_query_fragment()),
]
.into_iter()
.flatten(),
QueryFragment::from_sql(" and "),
),
order: QueryFragment::from_sql("_id"),
limit: QueryFragment::from_param(Datum::Numeric(Some(FRONTEND_ROW_LIMIT.into()))),
};
let sql_raw = sql_fragment.to_sql(1);
let mut q = query(&sql_raw);
for param in sql_fragment.to_params() {
q = param.bind_onto(q);
}
q = q.bind(FRONTEND_ROW_LIMIT);
let rows: Vec<PgRow> = q.fetch_all(workspace_client.get_conn()).await?;
let count_query = GetDataQuery {
selection: QueryFragment::from_sql("count(*)"),
order: QueryFragment::empty(),
limit: QueryFragment::empty(),
..main_data_query.clone()
};
// TODO: Consider running queries in a transaction to improve consistency.
let rows: Vec<PgRow> = QueryBuilder::from(main_data_query)
.build()
.fetch_all(workspace_client.get_conn())
.await?;
let count: i64 = QueryBuilder::from(count_query)
.build_query_scalar()
.fetch_one(workspace_client.get_conn())
.await?;
#[derive(Serialize)]
struct DataRow {
@ -188,14 +201,55 @@ pub(super) async fn get(
#[derive(Serialize)]
struct ResponseBody {
rows: Vec<DataRow>,
fields: Vec<TableFieldInfo>,
pkeys: Vec<String>,
rows: Vec<DataRow>,
count: i64,
}
Ok(Json(ResponseBody {
count,
rows: data_rows,
fields,
pkeys,
})
.into_response())
}
/// Helper type to make it easier to build and reason about multiple related SQL
/// queries.
#[derive(Clone, Debug)]
struct GetDataQuery {
selection: QueryFragment,
source: QueryFragment,
filters: QueryFragment,
order: QueryFragment,
limit: QueryFragment,
}
impl From<GetDataQuery> for QueryFragment {
fn from(value: GetDataQuery) -> Self {
let mut result = QueryFragment::from_sql("select ");
result.push(value.selection);
result.push(QueryFragment::from_sql(" from "));
result.push(value.source);
if !value.filters.is_empty() {
result.push(QueryFragment::from_sql(" where "));
result.push(value.filters);
}
if !value.order.is_empty() {
result.push(QueryFragment::from_sql(" order by "));
result.push(value.order);
}
if !value.limit.is_empty() {
result.push(QueryFragment::from_sql(" limit "));
result.push(value.limit);
}
result
}
}
impl From<GetDataQuery> for QueryBuilder<'_, Postgres> {
fn from(value: GetDataQuery) -> Self {
QueryFragment::from(value).into()
}
}

View file

@ -32,7 +32,6 @@
grid-area: table;
grid-template:
'headers' max-content
'subfilter-indicator' max-content
'main' 1fr
'inserter' max-content;
height: 100%;
@ -161,13 +160,6 @@
padding: var(--default-padding);
}
/* ======== Subfilter Indicator ======== */
.subfilter-indicator {
grid-area: subfilter-indicator;
padding: 8px;
}
/* ======== Table Body ======== */
.table-viewer__main {
@ -287,13 +279,17 @@
}
}
.table-viewer__count {
opacity: 0.5;
}
.table-viewer__inserter {
grid-area: inserter;
margin-bottom: 2rem;
.table-viewer__inserter-help {
font-size: 1rem;
font-weight: 300;
font-weight: normal;
margin: 8px;
opacity: 0.5;
}

View file

@ -53,6 +53,7 @@
};
type LazyData = {
count: number;
rows: Row[];
fields: FieldInfo[];
};
@ -527,6 +528,7 @@
(async function () {
const get_data_response_schema = z.object({
count: z.number().int(),
rows: z.array(
z.object({
pkey: z.string(),
@ -540,6 +542,7 @@
);
const body = get_data_response_schema.parse(await resp.json());
lazy_data = {
count: body.count,
fields: body.fields,
rows: body.rows.map(({ data, pkey }) => ({ data, key: pkey })),
};
@ -638,34 +641,43 @@
<FieldAdder {columns}></FieldAdder>
</div>
</div>
{#if subfilter && subfilter !== "null"}
<div class="subfilter-indicator">
<a href="?">&hellip;</a>
</div>
{/if}
<div class="table-viewer__main">
{@render table_region({
region: "main",
rows: lazy_data.rows,
on_cell_click: (ev: MouseEvent, coords: Coords) => {
if (ev.metaKey || ev.ctrlKey) {
set_selections([
coords,
...selections
.filter((sel) => !coords_eq(sel.coords, coords))
.map((sel) => sel.coords),
]);
} else if (ev.shiftKey) {
move_cursor(coords, { additive: true });
} else {
move_cursor(coords);
}
},
})}
{#if subfilter && subfilter !== "null"}
<div class="padded padded-sm">
<a href="?">&hellip;</a>
</div>
{/if}
<div>
{@render table_region({
region: "main",
rows: lazy_data.rows,
on_cell_click: (ev: MouseEvent, coords: Coords) => {
if (ev.metaKey || ev.ctrlKey) {
set_selections([
coords,
...selections
.filter((sel) => !coords_eq(sel.coords, coords))
.map((sel) => sel.coords),
]);
} else if (ev.shiftKey) {
move_cursor(coords, { additive: true });
} else {
move_cursor(coords);
}
},
})}
</div>
<div class="table-viewer__count padded padded-sm">
{lazy_data.count} records total
{#if lazy_data.count > lazy_data.rows.length || true}
({lazy_data.count - lazy_data.rows.length} hidden; use filters to narrow
your search)
{/if}
</div>
</div>
<form method="post" action="insert" class="table-viewer__inserter">
<h3 class="table-viewer__inserter-help">
Insert rows &mdash; press "shift + enter" to jump here or add a row
<b>Insert rows</b> (press "shift + enter" to jump here or add a row)
</h3>
<div class="table-viewer__inserter-main">
<div class="table-viewer__inserter-rows">