From 9c1c11a2773edba76f9127682c1ebb1a62fbfc5d Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Tue, 8 Jul 2025 16:54:51 -0700 Subject: [PATCH] implement basic record editing --- Cargo.lock | 7 +- components/src/cell-text.ts | 124 +++++++++++-- components/src/cell-uuid.ts | 122 ++++++++++++- .../src/entrypoints/viewer-controller.ts | 11 ++ interim-models/Cargo.toml | 3 + .../migrations/20250528233517_lenses.up.sql | 5 +- interim-models/sqlizable.rs | 12 ++ interim-models/src/field.rs | 172 ++++++++++++++++++ interim-models/src/lens.rs | 10 +- interim-models/src/lib.rs | 1 + interim-models/src/selection.rs | 41 ++--- interim-pgtypes/Cargo.toml | 3 + interim-pgtypes/src/lib.rs | 5 + .../src/pg_acl.rs | 4 +- interim-pgtypes/src/pg_attribute.rs | 38 ++++ .../src/pg_class.rs | 15 +- .../src/pg_database.rs | 4 +- interim-pgtypes/src/pg_index.rs | 1 + interim-pgtypes/src/pg_namespace.rs | 39 ++++ .../src/pg_role.rs | 0 interim-server/Cargo.toml | 1 - interim-server/src/base_user_perms.rs | 14 +- interim-server/src/data_layer.rs | 68 ++++--- interim-server/src/main.rs | 5 - interim-server/src/rel_invitations.rs | 5 +- interim-server/src/router.rs | 12 +- interim-server/src/routes/lenses.rs | 111 +++++++++-- interim-server/src/routes/relations.rs | 10 +- interim-server/templates/lens.html | 20 +- 29 files changed, 733 insertions(+), 130 deletions(-) create mode 100644 components/src/entrypoints/viewer-controller.ts create mode 100644 interim-models/sqlizable.rs create mode 100644 interim-models/src/field.rs rename interim-server/src/pg_acls.rs => interim-pgtypes/src/pg_acl.rs (100%) rename interim-server/src/pg_classes.rs => interim-pgtypes/src/pg_class.rs (88%) rename interim-server/src/pg_databases.rs => interim-pgtypes/src/pg_database.rs (97%) create mode 100644 interim-pgtypes/src/pg_index.rs create mode 100644 interim-pgtypes/src/pg_namespace.rs rename interim-server/src/pg_roles.rs => interim-pgtypes/src/pg_role.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index c5f86fe..7c3d387 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1636,11 +1636,14 @@ dependencies = [ name = "interim-models" version = "0.0.1" dependencies = [ + "chrono", "derive_builder", "interim-pgtypes", "regex", "serde", + "serde_json", "sqlx", + "thiserror 2.0.12", "uuid", ] @@ -1648,10 +1651,13 @@ dependencies = [ name = "interim-pgtypes" version = "0.0.1" dependencies = [ + "chrono", "derive_builder", + "nom 8.0.0", "regex", "serde", "sqlx", + "thiserror 2.0.12", "uuid", ] @@ -1672,7 +1678,6 @@ dependencies = [ "futures", "interim-models", "interim-pgtypes", - "nom 8.0.0", "oauth2", "percent-encoding", "rand 0.8.5", diff --git a/components/src/cell-text.ts b/components/src/cell-text.ts index dad41d6..327ddd4 100644 --- a/components/src/cell-text.ts +++ b/components/src/cell-text.ts @@ -1,28 +1,128 @@ -import { html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; @customElement("cell-text") export class CellText extends LitElement { - @property({ attribute: true, type: String, reflect: true }) + /** + * When present, a JSON object with key "c" mapped to cell contents. + */ + @property({ attribute: true, reflect: true }) value = "null"; - @property({ attribute: "editable", type: Boolean, reflect: true }) + @property({ attribute: true, type: Boolean }) editable = false; - @property({ type: Boolean }) - editing = false; + @property({ attribute: true }) + column = ""; + + @property() + pkeyJson = ""; + + @state() + private _editing = false; + + @state() + private _updating = false; + + private _inputRef = createRef(); + + static override styles = css` + .outer { + width: 100%; + height: 100%; + font-family: "Funnel Sans"; + } + `; + + protected override updated() { + if (this._editing && this._inputRef.value) { + this._inputRef.value.focus(); + } + } + + private get _contents(): string | undefined { + return JSON.parse(this.value)?.c ?? undefined; + } + + startEdit() { + if (!this._updating) { + this._editing = true; + } + } + + cancelEdit() { + this._editing = false; + } + + confirmEdit(value: string) { + // TODO how to handle null vs empty string + (async () => { + this._editing = false; + this._updating = true; + const response = await fetch("update-value", { + method: "post", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + pkeys: JSON.parse(this.pkeyJson), + column: this.column, + value: { t: "Text", c: value }, + }), + }); + // TODO retry logic + if (response.ok) { + this.value = JSON.stringify({ t: "Text", c: value }); + this._updating = false; + } + })() + .catch(console.error); + } + + private _handleClick() { + if (!this._editing && !this._updating) { + this.startEdit(); + } + } + + private _handleBlur(ev: Event) { + this.confirmEdit((ev.target as HTMLTextAreaElement).value); + } protected override render() { - const parsed = JSON.parse(this.value); - if (parsed === null) { - return html` - --- + let inner: TemplateResult = html` + + `; + + if (this._updating) { + inner = html` + Loading... + `; + } + + if (this._editing) { + inner = html` +
+ + +
+ `; + } else if (this._contents === null) { + inner = html` + NULL + `; + } else { + inner = html` + ${this._contents} `; } return html` -
- ${parsed} +
+ ${inner}
`; } diff --git a/components/src/cell-uuid.ts b/components/src/cell-uuid.ts index 9ec4d79..c8476b9 100644 --- a/components/src/cell-uuid.ts +++ b/components/src/cell-uuid.ts @@ -1,20 +1,128 @@ -import { html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { css, html, LitElement, type TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { createRef, ref } from "lit/directives/ref.js"; @customElement("cell-uuid") export class CellUuid extends LitElement { - @property({ attribute: "is-null", type: Boolean, reflect: true }) - isNull = false; + /** + * When present, a JSON object with key "c" mapped to cell contents. + */ + @property({ attribute: true, reflect: true }) + value = "null"; + + @property({ attribute: true }) + column = ""; + + @property() + pkeyJson = ""; + + @state() + private _editing = false; + + @state() + private _updating = false; + + private _inputRef = createRef(); + + static override styles = css` + .outer { + width: 100%; + height: 100%; + font-family: "Funnel Sans"; + } + `; + + protected override updated() { + if (this._editing && this._inputRef.value) { + this._inputRef.value.focus(); + } + } + + private get _contents(): string | undefined { + return JSON.parse(this.value)?.c ?? undefined; + } + + startEdit() { + if (!this._updating) { + this._editing = true; + } + } + + cancelEdit() { + this._editing = false; + } + + confirmEdit(value: string) { + // TODO how to handle null vs empty string + (async () => { + this._editing = false; + this._updating = true; + const response = await fetch("update-value", { + method: "post", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + pkeys: JSON.parse(this.pkeyJson), + column: this.column, + value: { t: "Uuid", c: value }, + }), + }); + // TODO retry logic + if (response.ok) { + // FIXME If this is a primary key, need to freeze the whole row until + // edit is accepted by the server, and then update the pkeys value + // locally. + this.value = JSON.stringify({ t: "Uuid", c: value }); + this._updating = false; + } + })() + .catch(console.error); + } + + private _handleClick() { + if (!this._editing && !this._updating) { + this.startEdit(); + } + } + + private _handleBlur(ev: Event) { + this.confirmEdit((ev.target as HTMLTextAreaElement).value); + } protected override render() { - if (this.isNull) { - return html` + let inner: TemplateResult = html` + + `; + + if (this._updating) { + inner = html` + Loading... + `; + } + + if (this._editing) { + inner = html` +
+ + +
+ `; + } else if (this._contents === null) { + inner = html` NULL `; + } else { + inner = html` + ${this._contents} + `; } return html` - +
+ ${inner} +
`; } } diff --git a/components/src/entrypoints/viewer-controller.ts b/components/src/entrypoints/viewer-controller.ts new file mode 100644 index 0000000..57f0ad4 --- /dev/null +++ b/components/src/entrypoints/viewer-controller.ts @@ -0,0 +1,11 @@ +document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll("table.viewer > tbody > tr").forEach((tr) => { + const pkeyJson = tr.getAttribute("data-pkey"); + if (pkeyJson !== null) { + tr.querySelectorAll(".cell").forEach((node) => { + console.log(node); + (node as unknown as { pkeyJson: string }).pkeyJson = pkeyJson; + }); + } + }); +}); diff --git a/interim-models/Cargo.toml b/interim-models/Cargo.toml index 16e610d..25653b8 100644 --- a/interim-models/Cargo.toml +++ b/interim-models/Cargo.toml @@ -4,9 +4,12 @@ edition.workspace = true version.workspace = true [dependencies] +chrono = { workspace = true } derive_builder = { workspace = true } interim-pgtypes = { path = "../interim-pgtypes" } regex = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } sqlx = { workspace = true } +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 ef68156..a3afb7c 100644 --- a/interim-models/migrations/20250528233517_lenses.up.sql +++ b/interim-models/migrations/20250528233517_lenses.up.sql @@ -16,6 +16,7 @@ create table if not exists lens_selections ( lens_id uuid not null references lenses(id) on delete cascade, attr_filters jsonb not null default '[]'::jsonb, label text, - display_type text, - visible boolean not null default true + field_type jsonb, + visible boolean not null default true, + width_px int not null default 200 ); diff --git a/interim-models/sqlizable.rs b/interim-models/sqlizable.rs new file mode 100644 index 0000000..d24dc13 --- /dev/null +++ b/interim-models/sqlizable.rs @@ -0,0 +1,12 @@ +use chrono::{DateTime, Utc}; +use sqlx::{Encode, postgres::Postgres}; +use uuid::Uuid; + +pub enum Sqlizable { + Integer(i32), + Text(String), + Timestamptz(DateTime), + Uuid(Uuid), +} + +impl Encode<'a, Postgres> for Sqlizable {} diff --git a/interim-models/src/field.rs b/interim-models/src/field.rs new file mode 100644 index 0000000..97207ed --- /dev/null +++ b/interim-models/src/field.rs @@ -0,0 +1,172 @@ +use chrono::{DateTime, Utc}; +use interim_pgtypes::pg_attribute::PgAttribute; +use serde::{Deserialize, Serialize}; +use sqlx::{Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow}; +use thiserror::Error; +use uuid::Uuid; + +const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S"; + +/// A single column which can be passed to a front-end viewer. A Selection may +/// resolve to zero or more Fields. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Field { + pub name: String, + pub label: Option, + pub field_type: sqlx::types::Json, + pub width_px: i32, +} + +impl Field { + pub fn default_from_attr(attr: &PgAttribute) -> Self { + Self { + name: attr.attname.clone(), + label: None, + field_type: sqlx::types::Json(FieldType::default_from_attr(attr)), + width_px: 200, + } + } + + pub fn webc_tag(&self) -> &str { + match self.field_type.0 { + FieldType::Integer => "cell-integer", + FieldType::InterimUser => "cell-interim-user", + FieldType::Text => "cell-text", + FieldType::Timestamp { .. } => "cell-timestamp", + FieldType::Uuid => "cell-uuid", + FieldType::Unknown => "cell-unknown", + } + } + + pub fn webc_custom_attrs(&self) -> Vec<(String, String)> { + vec![] + } + + pub fn render(&self, value: &Encodable) -> String { + match (self.field_type.0.clone(), value) { + (FieldType::Integer, Encodable::Integer(Some(value))) => value.to_string(), + (FieldType::Integer, Encodable::Integer(None)) => "".to_owned(), + (FieldType::Integer, _) => "###".to_owned(), + (FieldType::InterimUser, Encodable::Text(value)) => todo!(), + (FieldType::InterimUser, _) => "###".to_owned(), + (FieldType::Text, Encodable::Text(Some(value))) => value.clone(), + (FieldType::Text, Encodable::Text(None)) => "".to_owned(), + (FieldType::Text, _) => "###".to_owned(), + (FieldType::Timestamp { format }, Encodable::Timestamptz(value)) => value + .map(|value| value.format(&format).to_string()) + .unwrap_or("".to_owned()), + (FieldType::Timestamp { .. }, _) => "###".to_owned(), + (FieldType::Uuid, Encodable::Uuid(Some(value))) => value.hyphenated().to_string(), + (FieldType::Uuid, Encodable::Uuid(None)) => "".to_owned(), + (FieldType::Uuid, _) => "###".to_owned(), + (FieldType::Unknown, _) => "###".to_owned(), + } + } + + pub fn get_value_encodable(&self, row: &PgRow) -> Result { + let value_ref = row + .try_get_raw(self.name.as_str()) + .or(Err(ParseError::FieldNotFound))?; + let type_info = value_ref.type_info(); + let ty = type_info.name(); + Ok(match ty { + "INT" | "INT4" => { + Encodable::Integer( as Decode>::decode(value_ref).unwrap()) + } + "TEXT" | "VARCHAR" => { + Encodable::Text( as Decode>::decode(value_ref).unwrap()) + } + "UUID" => { + Encodable::Uuid( as Decode>::decode(value_ref).unwrap()) + } + _ => return Err(ParseError::UnknownType), + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(tag = "t", content = "c")] +pub enum FieldType { + Integer, + InterimUser, + Text, + Timestamp { + format: String, + }, + 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. + Unknown, +} + +impl FieldType { + pub fn default_from_attr(attr: &PgAttribute) -> Self { + match attr.regtype.as_str() { + "integer" => Self::Integer, + "text" => Self::Text, + "timestamp" => Self::Timestamp { + format: RFC_3339_S.to_owned(), + }, + "uuid" => Self::Uuid, + _ => Self::Unknown, + } + } +} + +/// Error when parsing a sqlx value to JSON +#[derive(Debug, Error)] +pub enum ParseError { + #[error("incompatible json type")] + BadJsonType, + #[error("field not found in row")] + FieldNotFound, + #[error("unknown postgres type")] + UnknownType, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(tag = "t", content = "c")] +pub enum Encodable { + Integer(Option), + Text(Option), + Timestamptz(Option>), + Uuid(Option), +} + +impl Encodable { + pub fn bind_onto<'a>( + &'a self, + query: sqlx::query::Query<'a, Postgres, ::Arguments<'a>>, + ) -> sqlx::query::Query<'a, Postgres, ::Arguments<'a>> { + match self { + Self::Integer(value) => query.bind(value), + Self::Text(value) => query.bind(value), + Self::Timestamptz(value) => query.bind(value), + Self::Uuid(value) => query.bind(value), + } + } +} + +// impl<'a> Encode<'a, Postgres> for Encodable { +// fn encode_by_ref( +// &self, +// buf: &mut ::ArgumentBuffer<'a>, +// ) -> Result { +// match self { +// Self::Integer(value) => Encode::<'a, Postgres>::encode(value, buf), +// Self::Text(value) => Encode::<'a, Postgres>::encode(value, buf), +// Self::Timestamptz(value) => Encode::<'a, Postgres>::encode(value, buf), +// Self::Uuid(value) => Encode::<'a, Postgres>::encode(value, buf), +// } +// } +// } +// +// impl<'a> Decode<'a, Postgres> for FieldType { +// fn decode( +// value: ::ValueRef<'a>, +// ) -> Result { +// let value: String = Decode::<'a, Postgres>::decode(value)?; +// Ok(serde_json::from_str(&value)?) +// } +// } diff --git a/interim-models/src/lens.rs b/interim-models/src/lens.rs index 6fd036c..b6e0add 100644 --- a/interim-models/src/lens.rs +++ b/interim-models/src/lens.rs @@ -3,7 +3,10 @@ use serde::Serialize; use sqlx::{PgExecutor, postgres::types::Oid, query_as}; use uuid::Uuid; -use crate::selection::{AttrFilter, Selection, SelectionDisplayType}; +use crate::{ + field::FieldType, + selection::{AttrFilter, Selection}, +}; #[derive(Clone, Debug, Serialize)] pub struct Lens { @@ -76,8 +79,9 @@ select id, attr_filters as "attr_filters: sqlx::types::Json>", label, - display_type as "display_type: SelectionDisplayType", - visible + field_type as "field_type: sqlx::types::Json", + visible, + width_px from lens_selections where lens_id = $1 "#, diff --git a/interim-models/src/lib.rs b/interim-models/src/lib.rs index ac6fe06..4db5b4f 100644 --- a/interim-models/src/lib.rs +++ b/interim-models/src/lib.rs @@ -1,3 +1,4 @@ +pub mod field; pub mod lens; pub mod selection; diff --git a/interim-models/src/selection.rs b/interim-models/src/selection.rs index d2e225c..444bc65 100644 --- a/interim-models/src/selection.rs +++ b/interim-models/src/selection.rs @@ -5,13 +5,16 @@ use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, query_as}; use uuid::Uuid; +use crate::field::{Field, FieldType}; + #[derive(Clone, Debug, Serialize)] pub struct Selection { pub id: Uuid, pub attr_filters: sqlx::types::Json>, pub label: Option, - pub display_type: Option, + pub field_type: Option>, pub visible: bool, + pub width_px: i32, } impl Selection { @@ -30,7 +33,11 @@ impl Selection { .map(|attr| Field { name: attr.attname.clone(), label: self.label.clone(), - display_type: self.display_type.clone(), + field_type: self + .field_type + .clone() + .unwrap_or_else(|| sqlx::types::Json(FieldType::default_from_attr(&attr))), + width_px: self.width_px, }) .collect() } else { @@ -39,15 +46,6 @@ impl Selection { } } -#[derive(Clone, Debug, Serialize, sqlx::Type)] -#[sqlx(type_name = "TEXT")] -#[sqlx(rename_all = "lowercase")] -pub enum SelectionDisplayType { - Text, - InterimUser, - Timestamp, -} - #[derive(Clone, Debug, Deserialize, Serialize)] pub enum AttrFilter { NameEq(String), @@ -67,15 +65,6 @@ impl AttrFilter { } } -/// A single column which can be passed to a front-end viewer. A Selection may -/// resolve to zero or more Fields. -#[derive(Clone, Debug, Serialize)] -pub struct Field { - pub name: String, - pub label: Option, - pub display_type: Option, -} - #[derive(Builder, Clone, Debug)] pub struct InsertableSelection { lens_id: Uuid, @@ -83,7 +72,7 @@ pub struct InsertableSelection { #[builder(default, setter(strip_option))] label: Option, #[builder(default, setter(strip_option))] - display_type: Option, + field_type: Option, #[builder(default = true)] visible: bool, } @@ -94,20 +83,22 @@ impl InsertableSelection { Selection, r#" insert into lens_selections -(id, lens_id, attr_filters, label, display_type, visible) +(id, lens_id, attr_filters, label, field_type, visible) values ($1, $2, $3, $4, $5, $6) returning id, attr_filters as "attr_filters: sqlx::types::Json>", label, - display_type as "display_type: SelectionDisplayType", - visible + field_type as "field_type?: sqlx::types::Json", + visible, + width_px "#, Uuid::now_v7(), self.lens_id, sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json>, self.label, - self.display_type as Option, + self.field_type.map(|value| sqlx::types::Json::<_>(value)) + as Option>, self.visible, ) .fetch_one(app_db) diff --git a/interim-pgtypes/Cargo.toml b/interim-pgtypes/Cargo.toml index 16fb691..48e10b4 100644 --- a/interim-pgtypes/Cargo.toml +++ b/interim-pgtypes/Cargo.toml @@ -4,8 +4,11 @@ edition.workspace = true version.workspace = true [dependencies] +chrono = { workspace = true } derive_builder = { workspace = true } +nom = "8.0.0" regex = { workspace = true } serde = { workspace = true } sqlx = { workspace = true } +thiserror = { workspace = true } uuid = { workspace = true } diff --git a/interim-pgtypes/src/lib.rs b/interim-pgtypes/src/lib.rs index 5ed3c7d..01905e7 100644 --- a/interim-pgtypes/src/lib.rs +++ b/interim-pgtypes/src/lib.rs @@ -1 +1,6 @@ +pub mod pg_acl; pub mod pg_attribute; +pub mod pg_class; +pub mod pg_database; +pub mod pg_namespace; +pub mod pg_role; diff --git a/interim-server/src/pg_acls.rs b/interim-pgtypes/src/pg_acl.rs similarity index 100% rename from interim-server/src/pg_acls.rs rename to interim-pgtypes/src/pg_acl.rs index a02c1b9..95ec473 100644 --- a/interim-server/src/pg_acls.rs +++ b/interim-pgtypes/src/pg_acl.rs @@ -1,4 +1,5 @@ use nom::{ + AsChar as _, IResult, Parser, branch::alt, bytes::complete::{is_not, tag, take_till}, character::char, @@ -6,12 +7,11 @@ use nom::{ error::ParseError, multi::{many0, many1}, sequence::delimited, - AsChar as _, IResult, Parser, }; use sqlx::{ + Decode, Postgres, error::BoxDynError, postgres::{PgHasArrayType, PgTypeInfo, PgValueRef}, - Decode, Postgres, }; /// This type will automatically decode Postgres "aclitem" values, provided that diff --git a/interim-pgtypes/src/pg_attribute.rs b/interim-pgtypes/src/pg_attribute.rs index c9cea0f..fbdba05 100644 --- a/interim-pgtypes/src/pg_attribute.rs +++ b/interim-pgtypes/src/pg_attribute.rs @@ -9,6 +9,8 @@ pub struct PgAttribute { pub attname: String, /// The data type of this column (zero for a dropped column) pub atttypid: Oid, + /// SYNTHESIZED: textual representation of attribute type + pub regtype: String, /// A copy of pg_type.typlen of this column's type pub attlen: i16, /// The number of the column. Ordinary columns are numbered from 1 up. System columns, such as ctid, have (arbitrary) negative numbers. @@ -46,6 +48,7 @@ select attrelid, attname, atttypid, + atttypid::regtype::text as "regtype!", attlen, attnum, attnotnull as "attnotnull?", @@ -65,3 +68,38 @@ where attrelid = $1 and attnum > 0 and not attisdropped .fetch_all(client) .await } + +pub async fn fetch_primary_keys_for_rel<'a, E: PgExecutor<'a>>( + oid: Oid, + client: E, +) -> Result, sqlx::Error> { + query_as!( + PgAttribute, + r#" +select + a.attrelid as attrelid, + a.attname as attname, + a.atttypid as atttypid, + atttypid::regtype::text as "regtype!", + a.attlen as attlen, + a.attnum as attnum, + a.attnotnull as "attnotnull?", + a.atthasdef as atthasdef, + a.atthasmissing as atthasmissing, + a.attidentity as attidentity, + a.attgenerated as attgenerated, + a.attisdropped as attisdropped, + a.attislocal as attislocal, + a.attoptions as attoptions, + a.attfdwoptions as attfdwoptions +from pg_attribute a + join pg_index i + on a.attrelid = i.indrelid + and a.attnum = any(i.indkey) +where i.indrelid = $1 and i.indisprimary; + "#, + &oid + ) + .fetch_all(client) + .await +} diff --git a/interim-server/src/pg_classes.rs b/interim-pgtypes/src/pg_class.rs similarity index 88% rename from interim-server/src/pg_classes.rs rename to interim-pgtypes/src/pg_class.rs index 1806232..18dcbf2 100644 --- a/interim-server/src/pg_classes.rs +++ b/interim-pgtypes/src/pg_class.rs @@ -1,6 +1,6 @@ -use sqlx::{postgres::types::Oid, query_as, PgExecutor}; +use sqlx::{PgExecutor, postgres::types::Oid, query_as}; -use crate::pg_acls::PgAclItem; +use crate::{pg_acl::PgAclItem, pg_namespace::PgNamespace}; pub struct PgClass { /// Row identifier @@ -112,6 +112,17 @@ where .fetch_all(client) .await } + + pub async fn fetch_namespace<'a, E: PgExecutor<'a>>( + &self, + client: E, + ) -> Result { + PgNamespace::fetch_by_oid(self.relnamespace, client) + .await? + // If client has access to the class, it would expect to have access + // to the namespace that contains it. If not, that's an error. + .ok_or(sqlx::Error::RowNotFound) + } } pub enum PgRelKind { diff --git a/interim-server/src/pg_databases.rs b/interim-pgtypes/src/pg_database.rs similarity index 97% rename from interim-server/src/pg_databases.rs rename to interim-pgtypes/src/pg_database.rs index 0bec605..befce3d 100644 --- a/interim-server/src/pg_databases.rs +++ b/interim-pgtypes/src/pg_database.rs @@ -1,6 +1,6 @@ -use sqlx::{postgres::types::Oid, query_as, PgExecutor}; +use sqlx::{PgExecutor, postgres::types::Oid, query_as}; -use crate::pg_acls::PgAclItem; +use crate::pg_acl::PgAclItem; #[derive(Clone, Debug)] pub struct PgDatabase { diff --git a/interim-pgtypes/src/pg_index.rs b/interim-pgtypes/src/pg_index.rs new file mode 100644 index 0000000..2759dbe --- /dev/null +++ b/interim-pgtypes/src/pg_index.rs @@ -0,0 +1 @@ +use sqlx::query_as; diff --git a/interim-pgtypes/src/pg_namespace.rs b/interim-pgtypes/src/pg_namespace.rs new file mode 100644 index 0000000..48132b2 --- /dev/null +++ b/interim-pgtypes/src/pg_namespace.rs @@ -0,0 +1,39 @@ +use sqlx::{PgExecutor, postgres::types::Oid, query_as}; + +use crate::pg_acl::PgAclItem; + +#[derive(Clone, Debug)] +pub struct PgNamespace { + /// Row identifier + pub oid: Oid, + /// Name of the namespace + pub nspname: String, + /// Owner of the namespace + pub nspowner: Oid, + /// Access privileges; see Section 5.8 for details + pub nspacl: Option>, +} + +impl PgNamespace { + pub async fn fetch_by_oid<'a, E: PgExecutor<'a>>( + oid: Oid, + client: E, + ) -> Result, sqlx::Error> { + query_as!( + PgNamespace, + r#" +select + oid, + nspname, + nspowner, + nspacl::text[] as "nspacl: Vec" +from pg_namespace +where + oid = $1 +"#, + oid, + ) + .fetch_optional(client) + .await + } +} diff --git a/interim-server/src/pg_roles.rs b/interim-pgtypes/src/pg_role.rs similarity index 100% rename from interim-server/src/pg_roles.rs rename to interim-pgtypes/src/pg_role.rs diff --git a/interim-server/Cargo.toml b/interim-server/Cargo.toml index 35adf2b..7ac2741 100644 --- a/interim-server/Cargo.toml +++ b/interim-server/Cargo.toml @@ -17,7 +17,6 @@ dotenvy = "0.15.7" futures = { workspace = true } interim-models = { workspace = true } interim-pgtypes = { workspace = true } -nom = "8.0.0" oauth2 = "4.4.2" percent-encoding = "2.3.1" rand = { workspace = true } diff --git a/interim-server/src/base_user_perms.rs b/interim-server/src/base_user_perms.rs index 0b84d84..b765013 100644 --- a/interim-server/src/base_user_perms.rs +++ b/interim-server/src/base_user_perms.rs @@ -1,15 +1,15 @@ use std::collections::HashSet; use anyhow::{Context as _, Result}; -use sqlx::{query, PgConnection}; +use interim_pgtypes::{ + pg_acl::PgPrivilegeType, + pg_database::PgDatabase, + pg_role::{PgRole, RoleTree, user_id_from_rolname}, +}; +use sqlx::{PgConnection, query}; use uuid::Uuid; -use crate::{ - bases::Base, - pg_acls::PgPrivilegeType, - pg_databases::PgDatabase, - pg_roles::{user_id_from_rolname, PgRole, RoleTree}, -}; +use crate::bases::Base; pub struct BaseUserPerm { pub id: Uuid, diff --git a/interim-server/src/data_layer.rs b/interim-server/src/data_layer.rs index 3209f56..d76de2f 100644 --- a/interim-server/src/data_layer.rs +++ b/interim-server/src/data_layer.rs @@ -3,8 +3,9 @@ use std::fmt::Display; use anyhow::Result; use chrono::{DateTime, Utc}; use interim_models::selection::SelectionDisplayType; +use serde::{Deserialize, Serialize}; use sqlx::{ - ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, + ColumnIndex, Decode, Encode, Postgres, Row as _, TypeInfo as _, ValueRef as _, error::BoxDynError, postgres::{PgRow, PgTypeInfo, PgValueRef}, }; @@ -12,6 +13,8 @@ use uuid::Uuid; const DEFAULT_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z"; +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "t", content = "c")] pub enum Value { Text(Option), Integer(Option), @@ -53,37 +56,34 @@ impl Value { let value_ref = row.try_get_raw(idx)?; Self::decode(value_ref) } + + pub fn webc_tag(&self) -> &'static str { + match self { + Self::Text(_) => "cell-text", + Self::Integer(_) => todo!(), + Self::Timestamptz(_) => todo!(), + Self::Uuid(_) => "cell-uuid", + } + } + + pub fn as_json(&self) -> Result { + match self { + Self::Text(value) => serde_json::to_string(&value), + Self::Integer(value) => serde_json::to_string(&value), + Self::Timestamptz(value) => serde_json::to_string(&value), + Self::Uuid(value) => serde_json::to_string(&value), + } + } } impl ToHtmlString for Value { fn to_html_string(&self, display_type: &Option) -> String { - macro_rules! cell_html { - ($component:expr, $value:expr$(, $attr_name:expr => $attr_val:expr)*) => { - { - let value = $value.clone(); - let attrs: Vec = vec![ - format!("value=\"{}\"", serde_json::to_string(&value).unwrap().replace('"', "\\\"")), - $(format!("{}=\"{}\"", $attr_name, $attr_val.replace('"', "\\\"")),)* - ]; - format!( - "<{} {}>{}", - $component, - attrs.join(" "), - value.map(|value| value.to_string()).unwrap_or("-".to_owned()), - $component, - ) - } - }; - } match self { - Self::Text(value) => cell_html!("cell-text", value), - Self::Integer(value) => cell_html!("cell-integer", value), - Self::Timestamptz(value) => cell_html!( - "cell-timestamptz", - value, - "format" => DEFAULT_TIMESTAMP_FORMAT - ), - Self::Uuid(value) => cell_html!("cell-uuid", value), + Self::Text(Some(value)) => value.clone(), + Self::Integer(Some(value)) => format!("{value}"), + Self::Timestamptz(_) => todo!(), + Self::Uuid(Some(value)) => value.to_string(), + _ => "-".to_owned(), } } } @@ -126,3 +126,17 @@ impl<'a> Decode<'a, Postgres> for Value { } } } + +impl<'a> Encode<'a, Postgres> for Value { + fn encode_by_ref( + &self, + buf: &mut ::ArgumentBuffer<'a>, + ) -> std::result::Result { + match self { + Self::Text(value) => Encode::<'a, Postgres>::encode_by_ref(&value, buf), + Self::Integer(value) => Encode::<'a, Postgres>::encode_by_ref(&value, buf), + Self::Timestamptz(value) => value.encode_by_ref(buf), + Self::Uuid(value) => value.encode_by_ref(buf), + } + } +} diff --git a/interim-server/src/main.rs b/interim-server/src/main.rs index 81077cb..a9ba247 100644 --- a/interim-server/src/main.rs +++ b/interim-server/src/main.rs @@ -16,14 +16,9 @@ mod base_pooler; mod base_user_perms; mod bases; mod cli; -mod data_layer; mod db_conns; mod lenses; mod middleware; -mod pg_acls; -mod pg_classes; -mod pg_databases; -mod pg_roles; mod rel_invitations; mod router; mod routes; diff --git a/interim-server/src/rel_invitations.rs b/interim-server/src/rel_invitations.rs index bf19c60..9e21ae7 100644 --- a/interim-server/src/rel_invitations.rs +++ b/interim-server/src/rel_invitations.rs @@ -1,10 +1,9 @@ use chrono::{DateTime, Utc}; use derive_builder::Builder; -use sqlx::{postgres::types::Oid, query_as, PgExecutor}; +use interim_pgtypes::pg_acl::PgPrivilegeType; +use sqlx::{PgExecutor, postgres::types::Oid, query_as}; use uuid::Uuid; -use crate::pg_acls::PgPrivilegeType; - #[derive(Clone, Debug)] pub struct RelInvitation { pub id: Uuid, diff --git a/interim-server/src/router.rs b/interim-server/src/router.rs index 12f7e80..19cdc97 100644 --- a/interim-server/src/router.rs +++ b/interim-server/src/router.rs @@ -1,11 +1,11 @@ use std::net::SocketAddr; use axum::{ - extract::{ws::WebSocket, ConnectInfo, WebSocketUpgrade}, - http::{header::CACHE_CONTROL, HeaderValue}, + Router, + extract::{ConnectInfo, WebSocketUpgrade, ws::WebSocket}, + http::{HeaderValue, header::CACHE_CONTROL}, response::Response, routing::{any, get, post}, - Router, }; use axum_extra::routing::RouterExt as _; use tower::ServiceBuilder; @@ -58,7 +58,7 @@ pub fn new_router(state: AppState) -> Router<()> { get(routes::lenses::add_lens_page_get), ) .route( - "/d/{base_id}/r/{class_oid}/lenses/add", + "/d/{base_id}/r/{class_oid}/lenses/add/", post(routes::lenses::add_lens_page_post), ) .route_with_tsr( @@ -73,6 +73,10 @@ pub fn new_router(state: AppState) -> Router<()> { "/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection", post(routes::lenses::add_selection_page_post), ) + .route( + "/d/{base_id}/r/{class_oid}/l/{lens_id}/update-value", + post(routes::lenses::update_value_page_post), + ) .route_with_tsr( "/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/", get(routes::lenses::viewer_page), diff --git a/interim-server/src/routes/lenses.rs b/interim-server/src/routes/lenses.rs index e96beb1..414d30f 100644 --- a/interim-server/src/routes/lenses.rs +++ b/interim-server/src/routes/lenses.rs @@ -2,16 +2,22 @@ use std::collections::HashMap; use askama::Template; use axum::{ + Json, extract::{Path, State}, response::{Html, IntoResponse as _, Redirect, Response}, }; use axum_extra::extract::Form; use interim_models::{ + field::{Encodable, Field}, lens::{Lens, LensDisplayType}, - selection::{AttrFilter, Field, Selection}, + selection::{AttrFilter, Selection}, +}; +use interim_pgtypes::{ + pg_attribute::{PgAttribute, fetch_attributes_for_rel, fetch_primary_keys_for_rel}, + pg_class::PgClass, }; -use interim_pgtypes::pg_attribute::{PgAttribute, fetch_attributes_for_rel}; use serde::Deserialize; +use serde_json::json; use sqlx::{ postgres::{PgRow, types::Oid}, query, @@ -23,7 +29,6 @@ use crate::{ app_state::AppDbConn, base_pooler::BasePooler, bases::Base, - data_layer::{ToHtmlString as _, Value}, db_conns::{escape_identifier, init_role}, settings::Settings, users::CurrentUser, @@ -149,15 +154,12 @@ pub async fn lens_page( // FIXME auth - let class = query!( - "select relname from pg_class where oid = $1", - Oid(class_oid) - ) - .fetch_optional(&mut *client) - .await? - .ok_or(AppError::NotFound( - "no relation found with that oid".to_owned(), - ))?; + let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client) + .await? + .ok_or(AppError::NotFound( + "no relation found with that oid".to_owned(), + ))?; + let namespace = class.fetch_namespace(&mut *client).await?; let lens = Lens::fetch_by_id(lens_id, &mut *app_db) .await? @@ -171,25 +173,47 @@ pub async fn lens_page( fields.append(&mut selection.resolve_fields_from_attrs(&attrs)); } + let pkey_attrs = fetch_primary_keys_for_rel(Oid(class_oid), &mut *client).await?; + const FRONTEND_ROW_LIMIT: i64 = 1000; - let rows = query(&format!( - "select {} from {} limit $1", - attrs + struct Row { + pkeys: HashMap, + data: PgRow, + } + let rows: Vec = query(&format!( + "select {0} from {1}.{2} limit $1", + pkey_attrs .iter() - .map(|attr| attr.attname.clone()) + .chain(attrs.iter()) + .map(|attr| escape_identifier(&attr.attname)) .collect::>() .join(", "), + escape_identifier(&namespace.nspname), escape_identifier(&class.relname), )) .bind(FRONTEND_ROW_LIMIT) .fetch_all(&mut *client) - .await?; + .await? + .into_iter() + .map(|row| { + let mut pkey_values: HashMap = HashMap::new(); + for attr in pkey_attrs.clone() { + let field = Field::default_from_attr(&attr); + pkey_values.insert(field.name.clone(), field.get_value_encodable(&row).unwrap()); + } + Row { + pkeys: pkey_values, + data: row, + } + }) + .collect(); + #[derive(Template)] #[template(path = "lens.html")] struct ResponseTemplate { fields: Vec, all_columns: Vec, - rows: Vec, + rows: Vec, selections_json: String, settings: Settings, } @@ -265,6 +289,57 @@ pub async fn update_lens_page_post( .into_response()) } +#[derive(Deserialize)] +pub struct UpdateValuePageForm { + column: String, + pkeys: HashMap, + value: Encodable, +} + +pub async fn update_value_page_post( + State(settings): State, + State(mut base_pooler): State, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(current_user): CurrentUser, + Path(LensPagePath { + base_id, + class_oid, + lens_id: _, + }): Path, + Json(body): Json, +) -> Result { + // FIXME auth + // FIXME csrf + + let base = Base::fetch_by_id(base_id, &mut *app_db) + .await? + .ok_or(not_found!("no base found with that id"))?; + + let mut client = base_pooler.acquire_for(base_id).await?; + + let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client) + .await? + .ok_or(not_found!("unable to load table"))?; + let namespace = class.fetch_namespace(&mut *client).await?; + + let pkey_attrs = fetch_primary_keys_for_rel(Oid(class_oid), &mut *client).await?; + + body.pkeys + .get(&pkey_attrs.first().unwrap().attname) + .unwrap() + .bind_onto(body.value.bind_onto(query(&format!( + r#"update {0}.{1} set {2} = $1 where {3} = $2"#, + escape_identifier(&namespace.nspname), + escape_identifier(&class.relname), + escape_identifier(&body.column), + escape_identifier(&pkey_attrs.first().unwrap().attname), + )))) + .execute(&mut *client) + .await?; + + Ok(Json(json!({ "ok": true })).into_response()) +} + #[derive(Deserialize)] pub struct ViewerPagePath { base_id: Uuid, diff --git a/interim-server/src/routes/relations.rs b/interim-server/src/routes/relations.rs index b1af361..9fc0e7a 100644 --- a/interim-server/src/routes/relations.rs +++ b/interim-server/src/routes/relations.rs @@ -7,19 +7,21 @@ use axum::{ response::{Html, IntoResponse as _, Redirect, Response}, }; use axum_extra::extract::Form; +use interim_pgtypes::{ + pg_acl::PgPrivilegeType, + pg_class::{PgClass, PgRelKind}, + pg_role::{PgRole, RoleTree, user_id_from_rolname}, +}; use serde::Deserialize; use sqlx::postgres::types::Oid; use uuid::Uuid; use crate::{ - app_error::{not_found, AppError}, + app_error::{AppError, not_found}, app_state::AppDbConn, base_pooler::BasePooler, bases::Base, db_conns::init_role, - pg_acls::PgPrivilegeType, - pg_classes::{PgClass, PgRelKind}, - pg_roles::{user_id_from_rolname, PgRole, RoleTree}, rel_invitations::RelInvitation, settings::Settings, users::{CurrentUser, User}, diff --git a/interim-server/templates/lens.html b/interim-server/templates/lens.html index 6cdc06b..68692f6 100644 --- a/interim-server/templates/lens.html +++ b/interim-server/templates/lens.html @@ -4,12 +4,13 @@ + {% for field in fields %} - {% endfor %} @@ -20,12 +21,21 @@ {% for row in rows %} - + {% for field in fields %}
+
{{ field.label.clone().unwrap_or(field.name.clone()) }}
- {% match Value::get_from_row(row, field.name.as_str()) %} - {% when Ok with (value) %} - {{ value.to_html_string(field.display_type) | safe }} + {% match field.get_value_encodable(row.data) %} + {% when Ok with (encodable) %} + <{{ field.webc_tag() | safe }} + {% for (k, v) in field.webc_custom_attrs() %} + {{ k }}="{{ v }}" + {% endfor %} + column="{{ field.name }}" + value="{{ encodable | json }}" + class="cell" + > + {{ field.render(encodable) | safe }} + {{ err }} {% endmatch %}