forked from 2sys/phonograph
add total count indicator to table view
This commit is contained in:
parent
341e02a41b
commit
4ef7d8f922
6 changed files with 197 additions and 85 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,23 +107,32 @@ 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| {
|
||||
),
|
||||
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 {
|
||||
|
|
@ -134,30 +143,34 @@ pub(super) async fn get(
|
|||
.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
|
||||
})
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,12 +641,13 @@
|
|||
<FieldAdder {columns}></FieldAdder>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-viewer__main">
|
||||
{#if subfilter && subfilter !== "null"}
|
||||
<div class="subfilter-indicator">
|
||||
<div class="padded padded-sm">
|
||||
<a href="?">…</a>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="table-viewer__main">
|
||||
<div>
|
||||
{@render table_region({
|
||||
region: "main",
|
||||
rows: lazy_data.rows,
|
||||
|
|
@ -663,9 +667,17 @@
|
|||
},
|
||||
})}
|
||||
</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 — 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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue