diff --git a/Cargo.lock b/Cargo.lock index 300fd46..a830a23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1643,6 +1643,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "strum", "thiserror 2.0.12", "uuid", ] @@ -3122,6 +3123,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index 42851d3..e7ecf30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,6 @@ derive_builder = "0.20.2" futures = "0.3.31" interim-models = { path = "./interim-models" } interim-pgtypes = { path = "./interim-pgtypes" } -interim-server = { path = "./interim-server" } rand = "0.8.5" regex = "1.11.1" reqwest = { version = "0.12.8", features = ["json"] } diff --git a/interim-models/Cargo.toml b/interim-models/Cargo.toml index 25653b8..1101e83 100644 --- a/interim-models/Cargo.toml +++ b/interim-models/Cargo.toml @@ -11,5 +11,6 @@ regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } +strum = { version = "0.27.2", features = ["derive"] } thiserror = { workspace = true } uuid = { workspace = true } diff --git a/interim-models/migrations/20250528233517_lenses.up.sql b/interim-models/migrations/20250528233517_lenses.up.sql index 1885ed4..7934f3a 100644 --- a/interim-models/migrations/20250528233517_lenses.up.sql +++ b/interim-models/migrations/20250528233517_lenses.up.sql @@ -5,7 +5,7 @@ create table if not exists lenses ( name text not null, base_id uuid not null references bases(id) on delete cascade, class_oid oid not null, - filter jsonb not null default '{}'::jsonb, + filter jsonb not null default 'null'::jsonb, order_by jsonb not null default '[]'::jsonb, display_type lens_display_type not null default 'table' ); diff --git a/interim-models/src/expression.rs b/interim-models/src/expression.rs new file mode 100644 index 0000000..78bebef --- /dev/null +++ b/interim-models/src/expression.rs @@ -0,0 +1,250 @@ +use std::fmt::Display; + +use interim_pgtypes::escape_identifier; +use serde::{Deserialize, Serialize}; + +use crate::field::Encodable; + +#[derive(Clone, Debug, PartialEq)] +pub struct QueryFragment { + /// SQL string, split wherever there is a query parameter. For example, + /// `select * from foo where id = $1 and status = $2` is represented along + /// the lines of `["select * from foo where id = ", " and status = ", ""]`. + /// `plain_sql` should always have exactly one more element than `params`. + plain_sql: Vec, + params: Vec, +} + +impl QueryFragment { + pub fn to_sql(&self, first_param_idx: usize) -> String { + assert!(self.plain_sql.len() == self.params.len() + 1); + self.plain_sql + .iter() + .cloned() + .zip((first_param_idx..).map(|n| format!("${n}"))) + .fold( + Vec::with_capacity(2 * self.plain_sql.len()), + |mut acc, pair| { + acc.extend([pair.0, pair.1]); + acc + }, + ) + .get(0..(2 * self.plain_sql.len() - 1)) + .expect("already asserted sufficient length") + .join("") + } + + pub fn to_params(&self) -> Vec { + self.params.clone() + } + + pub fn from_sql(sql: &str) -> Self { + Self { + plain_sql: vec![sql.to_owned()], + params: vec![], + } + } + + pub fn from_param(param: Encodable) -> Self { + Self { + plain_sql: vec!["".to_owned(), "".to_owned()], + params: vec![param], + } + } + + 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() + .expect("already asserted that vec contains at least 1 item"); + let head = other + .plain_sql + .first() + .expect("already asserted that vec contains at least 1 item"); + self.plain_sql.push(format!("{tail}{head}")); + for value in other.plain_sql.drain(1..) { + self.plain_sql.push(value); + } + self.params.append(&mut other.params); + } + + /// Combine multiple QueryFragments with a separator, similar to Vec::join(). + pub fn join>(fragments: I, sep: Self) -> Self { + let mut acc = QueryFragment::from_sql(""); + let mut iter = fragments.into_iter(); + let mut fragment = match iter.next() { + Some(value) => value, + None => return acc, + }; + for next_fragment in iter { + acc.push(fragment); + acc.push(sep.clone()); + fragment = next_fragment; + } + acc.push(fragment); + acc + } + + /// Convenience method equivalent to: + /// `QueryFragment::concat(fragments, QueryFragment::from_sql(""))` + pub fn concat>(fragments: I) -> Self { + Self::join(fragments, Self::from_sql("")) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "t", content = "c")] +pub enum PgExpressionAny { + Comparison(PgComparisonExpression), + Identifier(PgIdentifierExpression), + Literal(Encodable), + ToJson(PgToJsonExpression), +} + +impl PgExpressionAny { + pub fn into_query_fragment(self) -> QueryFragment { + match self { + Self::Comparison(expr) => expr.into_query_fragment(), + Self::Identifier(expr) => expr.into_query_fragment(), + Self::Literal(expr) => { + if expr.is_none() { + QueryFragment::from_sql("null") + } else { + QueryFragment::from_param(expr) + } + } + Self::ToJson(expr) => expr.into_query_fragment(), + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "t", content = "c")] +pub enum PgComparisonExpression { + Infix(PgInfixExpression), + IsNull(PgIsNullExpression), + IsNotNull(PgIsNotNullExpression), +} + +impl PgComparisonExpression { + fn into_query_fragment(self) -> QueryFragment { + match self { + Self::Infix(expr) => expr.into_query_fragment(), + Self::IsNull(expr) => expr.into_query_fragment(), + Self::IsNotNull(expr) => expr.into_query_fragment(), + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PgInfixExpression { + pub operator: T, + pub lhs: Box, + pub rhs: Box, +} + +impl PgInfixExpression { + fn into_query_fragment(self) -> QueryFragment { + QueryFragment::concat([ + QueryFragment::from_sql("(("), + self.lhs.into_query_fragment(), + QueryFragment::from_sql(&format!(") {} (", self.operator)), + self.rhs.into_query_fragment(), + QueryFragment::from_sql("))"), + ]) + } +} + +#[derive(Clone, Debug, strum::Display, Deserialize, PartialEq, Serialize)] +pub enum PgComparisonOperator { + #[strum(to_string = "and")] + And, + #[strum(to_string = "=")] + Eq, + #[strum(to_string = ">")] + Gt, + #[strum(to_string = "<")] + Lt, + #[strum(to_string = "<>")] + Neq, + #[strum(to_string = "or")] + Or, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PgIsNullExpression { + lhs: Box, +} + +impl PgIsNullExpression { + fn into_query_fragment(self) -> QueryFragment { + QueryFragment::concat([ + QueryFragment::from_sql("(("), + self.lhs.into_query_fragment(), + QueryFragment::from_sql(") is null)"), + ]) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PgIsNotNullExpression { + lhs: Box, +} + +impl PgIsNotNullExpression { + fn into_query_fragment(self) -> QueryFragment { + QueryFragment::concat([ + QueryFragment::from_sql("(("), + self.lhs.into_query_fragment(), + QueryFragment::from_sql(") is not null)"), + ]) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PgIdentifierExpression { + pub parts_raw: Vec, +} + +impl PgIdentifierExpression { + fn into_query_fragment(self) -> QueryFragment { + QueryFragment::join( + self.parts_raw + .iter() + .map(|part| QueryFragment::from_sql(&escape_identifier(part))), + QueryFragment::from_sql("."), + ) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct PgToJsonExpression { + entries: Vec<(String, PgExpressionAny)>, +} + +impl PgToJsonExpression { + /// Generates a query fragment to the effect of: + /// `to_json((select ($expr) as "ident", ($expr2) as "ident2"))` + fn into_query_fragment(self) -> QueryFragment { + if self.entries.is_empty() { + QueryFragment::from_sql("'{}'") + } else { + QueryFragment::concat([ + QueryFragment::from_sql("to_json((select "), + QueryFragment::join( + self.entries.into_iter().map(|(key, value)| { + QueryFragment::concat([ + QueryFragment::from_sql("("), + value.into_query_fragment(), + QueryFragment::from_sql(&format!(") as {}", escape_identifier(&key))), + ]) + }), + QueryFragment::from_sql(", "), + ), + QueryFragment::from_sql("))"), + ]) + } + } +} diff --git a/interim-models/src/field.rs b/interim-models/src/field.rs index 7eb86de..afff827 100644 --- a/interim-models/src/field.rs +++ b/interim-models/src/field.rs @@ -39,18 +39,13 @@ impl Field { FieldType::InterimUser {} => "cell-interim-user", FieldType::Text {} => "cell-text", FieldType::Timestamp { .. } => "cell-timestamp", - FieldType::Uuid { .. } => "cell-uuid", + FieldType::Uuid {} => "cell-uuid", FieldType::Unknown => "cell-unknown", } } pub fn webc_custom_attrs(&self) -> Vec<(String, String)> { - match self.field_type.clone() { - sqlx::types::Json(FieldType::Uuid { - default_with_version: Some(_), - }) => vec![("has_default".to_owned(), "true".to_owned())], - _ => vec![], - } + vec![] } pub fn get_value_encodable(&self, row: &PgRow) -> Result { @@ -109,9 +104,7 @@ pub enum FieldType { Timestamp { format: String, }, - Uuid { - default_with_version: Option, - }, + Uuid {}, /// A special variant for when the field type is not specified and cannot be /// inferred. This isn't represented as an error, because we still want to /// be able to define display behavior via the .render() method. @@ -125,9 +118,7 @@ impl FieldType { "timestamp" => Self::Timestamp { format: RFC_3339_S.to_owned(), }, - "uuid" => Self::Uuid { - default_with_version: None, - }, + "uuid" => Self::Uuid {}, _ => Self::Unknown, } } diff --git a/interim-models/src/lens.rs b/interim-models/src/lens.rs index ef2d3cc..8ccac4b 100644 --- a/interim-models/src/lens.rs +++ b/interim-models/src/lens.rs @@ -1,9 +1,9 @@ use derive_builder::Builder; use serde::Serialize; -use sqlx::{postgres::types::Oid, query_as}; +use sqlx::{postgres::types::Oid, query, query_as, types::Json}; use uuid::Uuid; -use crate::client::AppDbClient; +use crate::{client::AppDbClient, expression::PgExpressionAny}; #[derive(Clone, Debug, Serialize)] pub struct Lens { @@ -12,6 +12,7 @@ pub struct Lens { pub base_id: Uuid, pub class_oid: Oid, pub display_type: LensDisplayType, + pub filter: Json>, } impl Lens { @@ -19,6 +20,10 @@ impl Lens { InsertableLensBuilder::default() } + pub fn update() -> LensUpdateBuilder { + LensUpdateBuilder::default() + } + pub fn with_id(id: Uuid) -> WithIdQuery { WithIdQuery { id } } @@ -46,7 +51,8 @@ select name, base_id, class_oid, - display_type as "display_type: LensDisplayType" + display_type as "display_type: LensDisplayType", + filter as "filter: Json>" from lenses where id = $1 "#, @@ -65,7 +71,8 @@ select name, base_id, class_oid, - display_type as "display_type: LensDisplayType" + display_type as "display_type: LensDisplayType", + filter as "filter: Json>" from lenses where id = $1 "#, @@ -106,7 +113,8 @@ select name, base_id, class_oid, - display_type as "display_type: LensDisplayType" + display_type as "display_type: LensDisplayType", + filter as "filter: Json>" from lenses where base_id = $1 and class_oid = $2 "#, @@ -145,7 +153,8 @@ returning name, base_id, class_oid, - display_type as "display_type: LensDisplayType" + display_type as "display_type: LensDisplayType", + filter as "filter: Json>" "#, Uuid::now_v7(), self.base_id, @@ -157,3 +166,25 @@ returning .await } } + +#[derive(Builder, Clone, Debug)] +pub struct LensUpdate { + id: Uuid, + #[builder(setter(strip_option = true))] + filter: Option>, +} + +impl LensUpdate { + pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> { + if let Some(filter) = self.filter { + query!( + "update lenses set filter = $1 where id = $2", + Json(filter) as Json>, + self.id + ) + .execute(&mut *app_db.conn) + .await?; + } + Ok(()) + } +} diff --git a/interim-models/src/lib.rs b/interim-models/src/lib.rs index 4656507..f90e3c2 100644 --- a/interim-models/src/lib.rs +++ b/interim-models/src/lib.rs @@ -1,5 +1,6 @@ pub mod base; pub mod client; +pub mod expression; pub mod field; pub mod lens; pub mod rel_invitation; diff --git a/interim-server/src/router.rs b/interim-server/src/router.rs index 2904748..446dc9f 100644 --- a/interim-server/src/router.rs +++ b/interim-server/src/router.rs @@ -63,7 +63,7 @@ pub fn new_router(state: AppState) -> Router<()> { ) .route_with_tsr( "/d/{base_id}/r/{class_oid}/l/{lens_id}/", - get(routes::lenses::lens_page), + get(routes::lens_index::lens_page_get), ) .route( "/d/{base_id}/r/{class_oid}/l/{lens_id}/get-data", @@ -81,6 +81,10 @@ pub fn new_router(state: AppState) -> Router<()> { "/d/{base_id}/r/{class_oid}/l/{lens_id}/update-value", post(routes::lenses::update_value_page_post), ) + .route( + "/d/{base_id}/r/{class_oid}/l/{lens_id}/set-filter", + post(routes::lens_set_filter::lens_set_filter_page_post), + ) .route( "/d/{base_id}/r/{class_oid}/l/{lens_id}/insert", post(routes::lens_insert::insert_page_post), diff --git a/interim-server/src/routes/lens_index.rs b/interim-server/src/routes/lens_index.rs new file mode 100644 index 0000000..1712326 --- /dev/null +++ b/interim-server/src/routes/lens_index.rs @@ -0,0 +1,72 @@ +use askama::Template; +use axum::{ + extract::{Path, State}, + response::{Html, IntoResponse as _, Response}, +}; +use interim_models::{base::Base, expression::PgExpressionAny, lens::Lens}; +use interim_pgtypes::pg_attribute::PgAttribute; +use sqlx::postgres::types::Oid; + +use crate::{ + app_error::AppError, + app_state::AppDbConn, + base_pooler::{BasePooler, RoleAssignment}, + navbar::{NavLocation, Navbar, RelLocation}, + settings::Settings, + user::CurrentUser, +}; + +use super::LensPagePath; + +pub async fn lens_page_get( + State(settings): State, + State(mut base_pooler): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, + Path(LensPagePath { + lens_id, + base_id, + class_oid, + }): Path, +) -> Result { + // FIXME auth + let base = Base::with_id(base_id).fetch_one(&mut app_db).await?; + let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?; + + let mut base_client = base_pooler + .acquire_for(lens.base_id, RoleAssignment::User(current_user.id)) + .await?; + + let attrs = PgAttribute::all_for_rel(lens.class_oid) + .fetch_all(&mut base_client) + .await?; + let attr_names: Vec = attrs.iter().map(|attr| attr.attname.clone()).collect(); + + #[derive(Template)] + #[template(path = "lens0_2.html")] + struct ResponseTemplate { + attr_names: Vec, + filter: Option, + settings: Settings, + navbar: Navbar, + } + Ok(Html( + ResponseTemplate { + attr_names, + filter: lens.filter.0, + navbar: Navbar::builder() + .root_path(settings.root_path.clone()) + .base(base.clone()) + .populate_rels(&mut app_db, &mut base_client) + .await? + .current(NavLocation::Rel( + Oid(class_oid), + Some(RelLocation::Lens(lens.id)), + )) + .build()?, + settings, + } + .render()?, + ) + .into_response()) +} diff --git a/interim-server/src/routes/lens_set_filter.rs b/interim-server/src/routes/lens_set_filter.rs new file mode 100644 index 0000000..d9e9a0b --- /dev/null +++ b/interim-server/src/routes/lens_set_filter.rs @@ -0,0 +1,35 @@ +use axum::{extract::Path, response::Response}; +use axum_extra::extract::Form; +use interim_models::{expression::PgExpressionAny, lens::Lens}; +use serde::Deserialize; + +use crate::{app_error::AppError, app_state::AppDbConn, navigator::Navigator, user::CurrentUser}; + +use super::LensPagePath; + +#[derive(Deserialize)] +pub struct FormBody { + filter_expression: String, +} + +pub async fn lens_set_filter_page_post( + navigator: Navigator, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(_): CurrentUser, + Path(LensPagePath { lens_id, .. }): Path, + Form(body): Form, +) -> Result { + // FIXME auth, csrf + + let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?; + + let filter: Option = serde_json::from_str(&body.filter_expression)?; + Lens::update() + .id(lens.id) + .filter(filter) + .build()? + .execute(&mut app_db) + .await?; + + Ok(navigator.lens_page(&lens).redirect_to()) +} diff --git a/interim-server/src/routes/lenses.rs b/interim-server/src/routes/lenses.rs index 0ed7664..d3dcbf3 100644 --- a/interim-server/src/routes/lenses.rs +++ b/interim-server/src/routes/lenses.rs @@ -141,50 +141,6 @@ pub async fn add_lens_page_post( Ok(navigator.lens_page(&lens).redirect_to()) } -pub async fn lens_page( - State(settings): State, - State(mut base_pooler): State, - AppDbConn(mut app_db): AppDbConn, - CurrentUser(current_user): CurrentUser, - Path(LensPagePath { - lens_id, - base_id, - class_oid, - }): Path, -) -> Result { - // FIXME auth - let base = Base::with_id(base_id).fetch_one(&mut app_db).await?; - let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?; - - let mut base_client = base_pooler - .acquire_for(lens.base_id, RoleAssignment::User(current_user.id)) - .await?; - - #[derive(Template)] - #[template(path = "lens0_2.html")] - struct ResponseTemplate { - settings: Settings, - navbar: Navbar, - } - Ok(Html( - ResponseTemplate { - navbar: Navbar::builder() - .root_path(settings.root_path.clone()) - .base(base.clone()) - .populate_rels(&mut app_db, &mut base_client) - .await? - .current(NavLocation::Rel( - Oid(class_oid), - Some(RelLocation::Lens(lens.id)), - )) - .build()?, - settings, - } - .render()?, - ) - .into_response()) -} - pub async fn get_data_page_get( State(settings): State, State(mut base_pooler): State, @@ -232,8 +188,8 @@ pub async fn get_data_page_get( }; const FRONTEND_ROW_LIMIT: i64 = 1000; - let rows: Vec = query(&format!( - "select {0} from {1}.{2} limit $1", + let mut sql_raw = format!( + "select {0} from {1}.{2}", pkey_attrs .iter() .chain(attrs.iter()) @@ -242,10 +198,28 @@ pub async fn get_data_page_get( .join(", "), escape_identifier(&rel.regnamespace), escape_identifier(&rel.relname), - )) - .bind(FRONTEND_ROW_LIMIT) - .fetch_all(base_client.get_conn()) - .await?; + ); + let rows: Vec = if let Some(filter_expr) = lens.filter.0 { + let filter_fragment = filter_expr.into_query_fragment(); + let filter_params = filter_fragment.to_params(); + sql_raw = format!( + "{sql_raw} where {0} limit ${1}", + filter_fragment.to_sql(1), + filter_params.len() + 1 + ); + let mut q = query(&sql_raw); + for param in filter_params { + q = param.bind_onto(q); + } + q = q.bind(FRONTEND_ROW_LIMIT); + q.fetch_all(base_client.get_conn()).await? + } else { + sql_raw = format!("{sql_raw} limit $1"); + query(&sql_raw) + .bind(FRONTEND_ROW_LIMIT) + .fetch_all(base_client.get_conn()) + .await? + }; #[derive(Serialize)] struct DataRow { diff --git a/interim-server/src/routes/mod.rs b/interim-server/src/routes/mod.rs index d82865d..de5eb8c 100644 --- a/interim-server/src/routes/mod.rs +++ b/interim-server/src/routes/mod.rs @@ -2,7 +2,9 @@ use serde::Deserialize; use uuid::Uuid; pub mod bases; +pub mod lens_index; pub mod lens_insert; +pub mod lens_set_filter; pub mod lenses; pub mod relations; diff --git a/interim-server/templates/base.html b/interim-server/templates/base.html index c200e69..237c174 100644 --- a/interim-server/templates/base.html +++ b/interim-server/templates/base.html @@ -8,7 +8,6 @@ {% block main %}{% endblock main %} {% if settings.dev != 0 %} - + + {% endblock %} diff --git a/interim-server/templates/navbar.html b/interim-server/templates/navbar.html index d9ec390..872c66e 100644 --- a/interim-server/templates/navbar.html +++ b/interim-server/templates/navbar.html @@ -45,7 +45,7 @@