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 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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="?">…</a>
|
<a href="?">…</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 — 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">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue