diff --git a/phono-models/src/datum.rs b/phono-models/src/datum.rs index c01051e..b56e27e 100644 --- a/phono-models/src/datum.rs +++ b/phono-models/src/datum.rs @@ -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(); diff --git a/phono-models/src/expression.rs b/phono-models/src/expression.rs index b3cfde9..b7e9603 100644 --- a/phono-models/src/expression.rs +++ b/phono-models/src/expression.rs @@ -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 { + 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>(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 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 diff --git a/phono-models/src/field.rs b/phono-models/src/field.rs index 0462d3a..443cc20 100644 --- a/phono-models/src/field.rs +++ b/phono-models/src/field.rs @@ -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( as Decode>::decode(value_ref).unwrap()) diff --git a/phono-server/src/routes/relations_single/get_data_handler.rs b/phono-server/src/routes/relations_single/get_data_handler.rs index 7013a13..d1bb500 100644 --- a/phono-server/src/routes/relations_single/get_data_handler.rs +++ b/phono-server/src/routes/relations_single/get_data_handler.rs @@ -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::>() .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::>(&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::>(&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 = 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 = 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, fields: Vec, pkeys: Vec, + rows: Vec, + 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 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 for QueryBuilder<'_, Postgres> { + fn from(value: GetDataQuery) -> Self { + QueryFragment::from(value).into() + } +} diff --git a/static/portal-table.css b/static/portal-table.css index 3668097..c184387 100644 --- a/static/portal-table.css +++ b/static/portal-table.css @@ -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; } diff --git a/svelte/src/table-viewer.webc.svelte b/svelte/src/table-viewer.webc.svelte index ad1b3b9..f0745a0 100644 --- a/svelte/src/table-viewer.webc.svelte +++ b/svelte/src/table-viewer.webc.svelte @@ -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 @@ - {#if subfilter && subfilter !== "null"} -
- -
- {/if}
- {@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"} +
+ +
+ {/if} +
+ {@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); + } + }, + })} +
+
+ {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} +

- Insert rows — press "shift + enter" to jump here or add a row + Insert rows (press "shift + enter" to jump here or add a row)