1
0
Fork 0
forked from 2sys/phonograph

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 bigdecimal::BigDecimal;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::Postgres; use sqlx::{Postgres, QueryBuilder};
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[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. /// Transform the contained value into a serde_json::Value.
pub fn inner_as_value(&self) -> serde_json::Value { pub fn inner_as_value(&self) -> serde_json::Value {
let serialized = serde_json::to_value(self).unwrap(); let serialized = serde_json::to_value(self).unwrap();

View file

@ -2,12 +2,16 @@ use std::fmt::Display;
use phono_backends::escape_identifier; use phono_backends::escape_identifier;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Postgres, QueryBuilder};
use crate::datum::Datum; use crate::datum::Datum;
/// Representation of a partial, parameterized SQL query. Allows callers to /// Representation of a partial, parameterized SQL query. Allows callers to
/// build queries iteratively and dynamically, handling parameter numbering /// build queries iteratively and dynamically, handling parameter numbering
/// (`$1`, `$2`, `$3`, ...) automatically. /// (`$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)] #[derive(Clone, Debug, PartialEq)]
pub struct QueryFragment { pub struct QueryFragment {
/// SQL string, split wherever there is a query parameter. For example, /// SQL string, split wherever there is a query parameter. For example,
@ -19,8 +23,14 @@ pub struct QueryFragment {
} }
impl 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); 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 self.plain_sql
.iter() .iter()
.cloned() .cloned()
@ -39,6 +49,7 @@ impl QueryFragment {
/// Returns only the parameterized values, in order. /// Returns only the parameterized values, in order.
pub fn to_params(&self) -> Vec<Datum> { pub fn to_params(&self) -> Vec<Datum> {
self.gut_checks();
self.params.clone() 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`, /// Parse from a parameter value with no additional SQL. (Renders as `$n`,
/// where`n` is the appropriate parameter index.) /// where`n` is the appropriate parameter index.)
pub fn from_param(param: Datum) -> Self { pub fn from_param(param: Datum) -> Self {
@ -61,8 +77,6 @@ impl QueryFragment {
/// Append another query fragment to this one. /// Append another query fragment to this one.
pub fn push(&mut self, mut other: QueryFragment) { 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 let tail = self
.plain_sql .plain_sql
.pop() .pop()
@ -101,6 +115,33 @@ impl QueryFragment {
pub fn concat<I: IntoIterator<Item = Self>>(fragments: I) -> Self { pub fn concat<I: IntoIterator<Item = Self>>(fragments: I) -> Self {
Self::join(fragments, Self::from_sql("")) 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 /// 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))?; .or(Err(ParseError::FieldNotFound))?;
let type_info = value_ref.type_info(); let type_info = value_ref.type_info();
let ty = type_info.name(); let ty = type_info.name();
dbg!(&ty);
Ok(match ty { Ok(match ty {
"NUMERIC" => { "NUMERIC" => {
Datum::Numeric(<Option<BigDecimal> as Decode<Postgres>>::decode(value_ref).unwrap()) 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 serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
Postgres, QueryBuilder,
postgres::{PgRow, types::Oid}, postgres::{PgRow, types::Oid},
query,
}; };
use tracing::debug; use tracing::debug;
use uuid::Uuid; use uuid::Uuid;
@ -107,23 +107,32 @@ pub(super) async fn get(
field_info field_info
}; };
let sql_fragment = { let main_data_query = GetDataQuery {
// Defensive programming: Make `sql_fragment` immutable once built. selection: QueryFragment::from_sql(
let mut sql_fragment = QueryFragment::from_sql(&format!( &pkey_attrs
"select {0} from {1}",
pkey_attrs
.iter() .iter()
.chain(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<_>>() .collect::<Vec<_>>()
.join(", "), .join(", "),
rel.get_identifier(), ),
)); source: QueryFragment::from_sql(&format!(
if let Some(filter_expr) = portal.table_filter.0.clone() { "{ident_esc} as main",
sql_fragment.push(QueryFragment::from_sql(" where ")); ident_esc = rel.get_identifier()
sql_fragment.push(filter_expr.into_query_fragment()); )),
} filters: QueryFragment::join(
if let Some(subfilter_expr) = form.subfilter.and_then(|value| { [
portal
.table_filter
.0
.map(|filter| filter.into_query_fragment()),
form.subfilter
.and_then(|value| {
if value.is_empty() { if value.is_empty() {
None None
} else { } else {
@ -134,30 +143,34 @@ pub(super) async fn get(
.ok() .ok()
.flatten() .flatten()
} }
}) { })
sql_fragment.push(QueryFragment::from_sql( .map(|filter| filter.into_query_fragment()),
if portal.table_filter.0.is_some() { ]
" and " .into_iter()
} else { .flatten(),
" where " QueryFragment::from_sql(" and "),
}, ),
)); order: QueryFragment::from_sql("_id"),
sql_fragment.push(subfilter_expr.into_query_fragment()); limit: QueryFragment::from_param(Datum::Numeric(Some(FRONTEND_ROW_LIMIT.into()))),
}
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
}; };
let sql_raw = sql_fragment.to_sql(1); let count_query = GetDataQuery {
let mut q = query(&sql_raw); selection: QueryFragment::from_sql("count(*)"),
for param in sql_fragment.to_params() { order: QueryFragment::empty(),
q = param.bind_onto(q); limit: QueryFragment::empty(),
} ..main_data_query.clone()
q = q.bind(FRONTEND_ROW_LIMIT); };
let rows: Vec<PgRow> = q.fetch_all(workspace_client.get_conn()).await?;
// 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)] #[derive(Serialize)]
struct DataRow { struct DataRow {
@ -188,14 +201,55 @@ pub(super) async fn get(
#[derive(Serialize)] #[derive(Serialize)]
struct ResponseBody { struct ResponseBody {
rows: Vec<DataRow>,
fields: Vec<TableFieldInfo>, fields: Vec<TableFieldInfo>,
pkeys: Vec<String>, pkeys: Vec<String>,
rows: Vec<DataRow>,
count: i64,
} }
Ok(Json(ResponseBody { Ok(Json(ResponseBody {
count,
rows: data_rows, rows: data_rows,
fields, fields,
pkeys, pkeys,
}) })
.into_response()) .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-area: table;
grid-template: grid-template:
'headers' max-content 'headers' max-content
'subfilter-indicator' max-content
'main' 1fr 'main' 1fr
'inserter' max-content; 'inserter' max-content;
height: 100%; height: 100%;
@ -161,13 +160,6 @@
padding: var(--default-padding); padding: var(--default-padding);
} }
/* ======== Subfilter Indicator ======== */
.subfilter-indicator {
grid-area: subfilter-indicator;
padding: 8px;
}
/* ======== Table Body ======== */ /* ======== Table Body ======== */
.table-viewer__main { .table-viewer__main {
@ -287,13 +279,17 @@
} }
} }
.table-viewer__count {
opacity: 0.5;
}
.table-viewer__inserter { .table-viewer__inserter {
grid-area: inserter; grid-area: inserter;
margin-bottom: 2rem; margin-bottom: 2rem;
.table-viewer__inserter-help { .table-viewer__inserter-help {
font-size: 1rem; font-size: 1rem;
font-weight: 300; font-weight: normal;
margin: 8px; margin: 8px;
opacity: 0.5; opacity: 0.5;
} }

View file

@ -53,6 +53,7 @@
}; };
type LazyData = { type LazyData = {
count: number;
rows: Row[]; rows: Row[];
fields: FieldInfo[]; fields: FieldInfo[];
}; };
@ -527,6 +528,7 @@
(async function () { (async function () {
const get_data_response_schema = z.object({ const get_data_response_schema = z.object({
count: z.number().int(),
rows: z.array( rows: z.array(
z.object({ z.object({
pkey: z.string(), pkey: z.string(),
@ -540,6 +542,7 @@
); );
const body = get_data_response_schema.parse(await resp.json()); const body = get_data_response_schema.parse(await resp.json());
lazy_data = { lazy_data = {
count: body.count,
fields: body.fields, fields: body.fields,
rows: body.rows.map(({ data, pkey }) => ({ data, key: pkey })), rows: body.rows.map(({ data, pkey }) => ({ data, key: pkey })),
}; };
@ -638,12 +641,13 @@
<FieldAdder {columns}></FieldAdder> <FieldAdder {columns}></FieldAdder>
</div> </div>
</div> </div>
<div class="table-viewer__main">
{#if subfilter && subfilter !== "null"} {#if subfilter && subfilter !== "null"}
<div class="subfilter-indicator"> <div class="padded padded-sm">
<a href="?">&hellip;</a> <a href="?">&hellip;</a>
</div> </div>
{/if} {/if}
<div class="table-viewer__main"> <div>
{@render table_region({ {@render table_region({
region: "main", region: "main",
rows: lazy_data.rows, rows: lazy_data.rows,
@ -663,9 +667,17 @@
}, },
})} })}
</div> </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"> <form method="post" action="insert" class="table-viewer__inserter">
<h3 class="table-viewer__inserter-help"> <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> </h3>
<div class="table-viewer__inserter-main"> <div class="table-viewer__inserter-main">
<div class="table-viewer__inserter-rows"> <div class="table-viewer__inserter-rows">