implement basic record editing

This commit is contained in:
Brent Schroeter 2025-07-08 16:54:51 -07:00
parent 1116e40590
commit 9c1c11a277
29 changed files with 733 additions and 130 deletions

7
Cargo.lock generated
View file

@ -1636,11 +1636,14 @@ dependencies = [
name = "interim-models" name = "interim-models"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"chrono",
"derive_builder", "derive_builder",
"interim-pgtypes", "interim-pgtypes",
"regex", "regex",
"serde", "serde",
"serde_json",
"sqlx", "sqlx",
"thiserror 2.0.12",
"uuid", "uuid",
] ]
@ -1648,10 +1651,13 @@ dependencies = [
name = "interim-pgtypes" name = "interim-pgtypes"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"chrono",
"derive_builder", "derive_builder",
"nom 8.0.0",
"regex", "regex",
"serde", "serde",
"sqlx", "sqlx",
"thiserror 2.0.12",
"uuid", "uuid",
] ]
@ -1672,7 +1678,6 @@ dependencies = [
"futures", "futures",
"interim-models", "interim-models",
"interim-pgtypes", "interim-pgtypes",
"nom 8.0.0",
"oauth2", "oauth2",
"percent-encoding", "percent-encoding",
"rand 0.8.5", "rand 0.8.5",

View file

@ -1,28 +1,128 @@
import { html, LitElement } from "lit"; import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
@customElement("cell-text") @customElement("cell-text")
export class CellText extends LitElement { 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"; value = "null";
@property({ attribute: "editable", type: Boolean, reflect: true }) @property({ attribute: true, type: Boolean })
editable = false; editable = false;
@property({ type: Boolean }) @property({ attribute: true })
editing = false; column = "";
@property()
pkeyJson = "";
@state()
private _editing = false;
@state()
private _updating = false;
private _inputRef = createRef<HTMLTextAreaElement>();
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() { protected override render() {
const parsed = JSON.parse(this.value); let inner: TemplateResult = html`
if (parsed === null) {
return html` `;
<code>---</code>
if (this._updating) {
inner = html`
Loading...
`;
}
if (this._editing) {
inner = html`
<div>
<textarea ${ref(this._inputRef)} @blur="${this
._handleBlur}">${this._contents ??
""}</textarea>
<button type="button" @click="${this.cancelEdit}">
<custom-icon name="x-mark" alt="cancel"></custom-icon>
</button>
</div>
`;
} else if (this._contents === null) {
inner = html`
<code>NULL</code>
`;
} else {
inner = html`
${this._contents}
`; `;
} }
return html` return html`
<div> <div class="outer" @click="${this._handleClick}">
${parsed} ${inner}
</div> </div>
`; `;
} }

View file

@ -1,20 +1,128 @@
import { html, LitElement } from "lit"; import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
@customElement("cell-uuid") @customElement("cell-uuid")
export class CellUuid extends LitElement { 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<HTMLTextAreaElement>();
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() { protected override render() {
if (this.isNull) { let inner: TemplateResult = html`
return html`
`;
if (this._updating) {
inner = html`
Loading...
`;
}
if (this._editing) {
inner = html`
<div>
<textarea ${ref(this._inputRef)} @blur="${this
._handleBlur}">${this._contents ?? ""}</textarea>
<button type="button" @click="${this.cancelEdit}">
<custom-icon name="x-mark" alt="cancel"></custom-icon>
</button>
</div>
`;
} else if (this._contents === null) {
inner = html`
<code>NULL</code> <code>NULL</code>
`; `;
} else {
inner = html`
<code>${this._contents}</code>
`;
} }
return html` return html`
<code><slot></slot></code> <div class="outer" @click="${this._handleClick}">
${inner}
</div>
`; `;
} }
} }

View file

@ -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;
});
}
});
});

View file

@ -4,9 +4,12 @@ edition.workspace = true
version.workspace = true version.workspace = true
[dependencies] [dependencies]
chrono = { workspace = true }
derive_builder = { workspace = true } derive_builder = { workspace = true }
interim-pgtypes = { path = "../interim-pgtypes" } interim-pgtypes = { path = "../interim-pgtypes" }
regex = { workspace = true } regex = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }

View file

@ -16,6 +16,7 @@ create table if not exists lens_selections (
lens_id uuid not null references lenses(id) on delete cascade, lens_id uuid not null references lenses(id) on delete cascade,
attr_filters jsonb not null default '[]'::jsonb, attr_filters jsonb not null default '[]'::jsonb,
label text, label text,
display_type text, field_type jsonb,
visible boolean not null default true visible boolean not null default true,
width_px int not null default 200
); );

View file

@ -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<Utc>),
Uuid(Uuid),
}
impl Encode<'a, Postgres> for Sqlizable {}

172
interim-models/src/field.rs Normal file
View file

@ -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<String>,
pub field_type: sqlx::types::Json<FieldType>,
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<Encodable, ParseError> {
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(<Option<i32> as Decode<Postgres>>::decode(value_ref).unwrap())
}
"TEXT" | "VARCHAR" => {
Encodable::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap())
}
"UUID" => {
Encodable::Uuid(<Option<Uuid> as Decode<Postgres>>::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<i32>),
Text(Option<String>),
Timestamptz(Option<DateTime<Utc>>),
Uuid(Option<Uuid>),
}
impl Encodable {
pub fn bind_onto<'a>(
&'a self,
query: sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>>,
) -> sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::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 <Postgres as sqlx::Database>::ArgumentBuffer<'a>,
// ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
// 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: <Postgres as sqlx::Database>::ValueRef<'a>,
// ) -> Result<Self, sqlx::error::BoxDynError> {
// let value: String = Decode::<'a, Postgres>::decode(value)?;
// Ok(serde_json::from_str(&value)?)
// }
// }

View file

@ -3,7 +3,10 @@ use serde::Serialize;
use sqlx::{PgExecutor, postgres::types::Oid, query_as}; use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use uuid::Uuid; use uuid::Uuid;
use crate::selection::{AttrFilter, Selection, SelectionDisplayType}; use crate::{
field::FieldType,
selection::{AttrFilter, Selection},
};
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct Lens { pub struct Lens {
@ -76,8 +79,9 @@ select
id, id,
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>", attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
label, label,
display_type as "display_type: SelectionDisplayType", field_type as "field_type: sqlx::types::Json<FieldType>",
visible visible,
width_px
from lens_selections from lens_selections
where lens_id = $1 where lens_id = $1
"#, "#,

View file

@ -1,3 +1,4 @@
pub mod field;
pub mod lens; pub mod lens;
pub mod selection; pub mod selection;

View file

@ -5,13 +5,16 @@ use serde::{Deserialize, Serialize};
use sqlx::{PgExecutor, query_as}; use sqlx::{PgExecutor, query_as};
use uuid::Uuid; use uuid::Uuid;
use crate::field::{Field, FieldType};
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct Selection { pub struct Selection {
pub id: Uuid, pub id: Uuid,
pub attr_filters: sqlx::types::Json<Vec<AttrFilter>>, pub attr_filters: sqlx::types::Json<Vec<AttrFilter>>,
pub label: Option<String>, pub label: Option<String>,
pub display_type: Option<SelectionDisplayType>, pub field_type: Option<sqlx::types::Json<FieldType>>,
pub visible: bool, pub visible: bool,
pub width_px: i32,
} }
impl Selection { impl Selection {
@ -30,7 +33,11 @@ impl Selection {
.map(|attr| Field { .map(|attr| Field {
name: attr.attname.clone(), name: attr.attname.clone(),
label: self.label.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() .collect()
} else { } 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)] #[derive(Clone, Debug, Deserialize, Serialize)]
pub enum AttrFilter { pub enum AttrFilter {
NameEq(String), 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<String>,
pub display_type: Option<SelectionDisplayType>,
}
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
pub struct InsertableSelection { pub struct InsertableSelection {
lens_id: Uuid, lens_id: Uuid,
@ -83,7 +72,7 @@ pub struct InsertableSelection {
#[builder(default, setter(strip_option))] #[builder(default, setter(strip_option))]
label: Option<String>, label: Option<String>,
#[builder(default, setter(strip_option))] #[builder(default, setter(strip_option))]
display_type: Option<SelectionDisplayType>, field_type: Option<FieldType>,
#[builder(default = true)] #[builder(default = true)]
visible: bool, visible: bool,
} }
@ -94,20 +83,22 @@ impl InsertableSelection {
Selection, Selection,
r#" r#"
insert into lens_selections 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) values ($1, $2, $3, $4, $5, $6)
returning returning
id, id,
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>", attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
label, label,
display_type as "display_type: SelectionDisplayType", field_type as "field_type?: sqlx::types::Json<FieldType>",
visible visible,
width_px
"#, "#,
Uuid::now_v7(), Uuid::now_v7(),
self.lens_id, self.lens_id,
sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json<Vec<AttrFilter>>, sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json<Vec<AttrFilter>>,
self.label, self.label,
self.display_type as Option<SelectionDisplayType>, self.field_type.map(|value| sqlx::types::Json::<_>(value))
as Option<sqlx::types::Json<FieldType>>,
self.visible, self.visible,
) )
.fetch_one(app_db) .fetch_one(app_db)

View file

@ -4,8 +4,11 @@ edition.workspace = true
version.workspace = true version.workspace = true
[dependencies] [dependencies]
chrono = { workspace = true }
derive_builder = { workspace = true } derive_builder = { workspace = true }
nom = "8.0.0"
regex = { workspace = true } regex = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }

View file

@ -1 +1,6 @@
pub mod pg_acl;
pub mod pg_attribute; pub mod pg_attribute;
pub mod pg_class;
pub mod pg_database;
pub mod pg_namespace;
pub mod pg_role;

View file

@ -1,4 +1,5 @@
use nom::{ use nom::{
AsChar as _, IResult, Parser,
branch::alt, branch::alt,
bytes::complete::{is_not, tag, take_till}, bytes::complete::{is_not, tag, take_till},
character::char, character::char,
@ -6,12 +7,11 @@ use nom::{
error::ParseError, error::ParseError,
multi::{many0, many1}, multi::{many0, many1},
sequence::delimited, sequence::delimited,
AsChar as _, IResult, Parser,
}; };
use sqlx::{ use sqlx::{
Decode, Postgres,
error::BoxDynError, error::BoxDynError,
postgres::{PgHasArrayType, PgTypeInfo, PgValueRef}, postgres::{PgHasArrayType, PgTypeInfo, PgValueRef},
Decode, Postgres,
}; };
/// This type will automatically decode Postgres "aclitem" values, provided that /// This type will automatically decode Postgres "aclitem" values, provided that

View file

@ -9,6 +9,8 @@ pub struct PgAttribute {
pub attname: String, pub attname: String,
/// The data type of this column (zero for a dropped column) /// The data type of this column (zero for a dropped column)
pub atttypid: Oid, pub atttypid: Oid,
/// SYNTHESIZED: textual representation of attribute type
pub regtype: String,
/// A copy of pg_type.typlen of this column's type /// A copy of pg_type.typlen of this column's type
pub attlen: i16, pub attlen: i16,
/// The number of the column. Ordinary columns are numbered from 1 up. System columns, such as ctid, have (arbitrary) negative numbers. /// 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, attrelid,
attname, attname,
atttypid, atttypid,
atttypid::regtype::text as "regtype!",
attlen, attlen,
attnum, attnum,
attnotnull as "attnotnull?", attnotnull as "attnotnull?",
@ -65,3 +68,38 @@ where attrelid = $1 and attnum > 0 and not attisdropped
.fetch_all(client) .fetch_all(client)
.await .await
} }
pub async fn fetch_primary_keys_for_rel<'a, E: PgExecutor<'a>>(
oid: Oid,
client: E,
) -> Result<Vec<PgAttribute>, 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
}

View file

@ -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 { pub struct PgClass {
/// Row identifier /// Row identifier
@ -112,6 +112,17 @@ where
.fetch_all(client) .fetch_all(client)
.await .await
} }
pub async fn fetch_namespace<'a, E: PgExecutor<'a>>(
&self,
client: E,
) -> Result<PgNamespace, sqlx::Error> {
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 { pub enum PgRelKind {

View file

@ -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)] #[derive(Clone, Debug)]
pub struct PgDatabase { pub struct PgDatabase {

View file

@ -0,0 +1 @@
use sqlx::query_as;

View file

@ -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<Vec<PgAclItem>>,
}
impl PgNamespace {
pub async fn fetch_by_oid<'a, E: PgExecutor<'a>>(
oid: Oid,
client: E,
) -> Result<Option<PgNamespace>, sqlx::Error> {
query_as!(
PgNamespace,
r#"
select
oid,
nspname,
nspowner,
nspacl::text[] as "nspacl: Vec<PgAclItem>"
from pg_namespace
where
oid = $1
"#,
oid,
)
.fetch_optional(client)
.await
}
}

View file

@ -17,7 +17,6 @@ dotenvy = "0.15.7"
futures = { workspace = true } futures = { workspace = true }
interim-models = { workspace = true } interim-models = { workspace = true }
interim-pgtypes = { workspace = true } interim-pgtypes = { workspace = true }
nom = "8.0.0"
oauth2 = "4.4.2" oauth2 = "4.4.2"
percent-encoding = "2.3.1" percent-encoding = "2.3.1"
rand = { workspace = true } rand = { workspace = true }

View file

@ -1,15 +1,15 @@
use std::collections::HashSet; use std::collections::HashSet;
use anyhow::{Context as _, Result}; 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 uuid::Uuid;
use crate::{ use crate::bases::Base;
bases::Base,
pg_acls::PgPrivilegeType,
pg_databases::PgDatabase,
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
};
pub struct BaseUserPerm { pub struct BaseUserPerm {
pub id: Uuid, pub id: Uuid,

View file

@ -3,8 +3,9 @@ use std::fmt::Display;
use anyhow::Result; use anyhow::Result;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use interim_models::selection::SelectionDisplayType; use interim_models::selection::SelectionDisplayType;
use serde::{Deserialize, Serialize};
use sqlx::{ use sqlx::{
ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, ColumnIndex, Decode, Encode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
error::BoxDynError, error::BoxDynError,
postgres::{PgRow, PgTypeInfo, PgValueRef}, postgres::{PgRow, PgTypeInfo, PgValueRef},
}; };
@ -12,6 +13,8 @@ use uuid::Uuid;
const DEFAULT_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z"; 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 { pub enum Value {
Text(Option<String>), Text(Option<String>),
Integer(Option<i32>), Integer(Option<i32>),
@ -53,37 +56,34 @@ impl Value {
let value_ref = row.try_get_raw(idx)?; let value_ref = row.try_get_raw(idx)?;
Self::decode(value_ref) 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<String, serde_json::Error> {
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 { impl ToHtmlString for Value {
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String { fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String {
macro_rules! cell_html {
($component:expr, $value:expr$(, $attr_name:expr => $attr_val:expr)*) => {
{
let value = $value.clone();
let attrs: Vec<String> = 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 { match self {
Self::Text(value) => cell_html!("cell-text", value), Self::Text(Some(value)) => value.clone(),
Self::Integer(value) => cell_html!("cell-integer", value), Self::Integer(Some(value)) => format!("{value}"),
Self::Timestamptz(value) => cell_html!( Self::Timestamptz(_) => todo!(),
"cell-timestamptz", Self::Uuid(Some(value)) => value.to_string(),
value, _ => "-".to_owned(),
"format" => DEFAULT_TIMESTAMP_FORMAT
),
Self::Uuid(value) => cell_html!("cell-uuid", value),
} }
} }
} }
@ -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 <Postgres as sqlx::Database>::ArgumentBuffer<'a>,
) -> std::result::Result<sqlx::encode::IsNull, BoxDynError> {
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),
}
}
}

View file

@ -16,14 +16,9 @@ mod base_pooler;
mod base_user_perms; mod base_user_perms;
mod bases; mod bases;
mod cli; mod cli;
mod data_layer;
mod db_conns; mod db_conns;
mod lenses; mod lenses;
mod middleware; mod middleware;
mod pg_acls;
mod pg_classes;
mod pg_databases;
mod pg_roles;
mod rel_invitations; mod rel_invitations;
mod router; mod router;
mod routes; mod routes;

View file

@ -1,10 +1,9 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derive_builder::Builder; 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 uuid::Uuid;
use crate::pg_acls::PgPrivilegeType;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RelInvitation { pub struct RelInvitation {
pub id: Uuid, pub id: Uuid,

View file

@ -1,11 +1,11 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use axum::{ use axum::{
extract::{ws::WebSocket, ConnectInfo, WebSocketUpgrade}, Router,
http::{header::CACHE_CONTROL, HeaderValue}, extract::{ConnectInfo, WebSocketUpgrade, ws::WebSocket},
http::{HeaderValue, header::CACHE_CONTROL},
response::Response, response::Response,
routing::{any, get, post}, routing::{any, get, post},
Router,
}; };
use axum_extra::routing::RouterExt as _; use axum_extra::routing::RouterExt as _;
use tower::ServiceBuilder; use tower::ServiceBuilder;
@ -58,7 +58,7 @@ pub fn new_router(state: AppState) -> Router<()> {
get(routes::lenses::add_lens_page_get), get(routes::lenses::add_lens_page_get),
) )
.route( .route(
"/d/{base_id}/r/{class_oid}/lenses/add", "/d/{base_id}/r/{class_oid}/lenses/add/",
post(routes::lenses::add_lens_page_post), post(routes::lenses::add_lens_page_post),
) )
.route_with_tsr( .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", "/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection",
post(routes::lenses::add_selection_page_post), 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( .route_with_tsr(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/", "/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/",
get(routes::lenses::viewer_page), get(routes::lenses::viewer_page),

View file

@ -2,16 +2,22 @@ use std::collections::HashMap;
use askama::Template; use askama::Template;
use axum::{ use axum::{
Json,
extract::{Path, State}, extract::{Path, State},
response::{Html, IntoResponse as _, Redirect, Response}, response::{Html, IntoResponse as _, Redirect, Response},
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
use interim_models::{ use interim_models::{
field::{Encodable, Field},
lens::{Lens, LensDisplayType}, 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::Deserialize;
use serde_json::json;
use sqlx::{ use sqlx::{
postgres::{PgRow, types::Oid}, postgres::{PgRow, types::Oid},
query, query,
@ -23,7 +29,6 @@ use crate::{
app_state::AppDbConn, app_state::AppDbConn,
base_pooler::BasePooler, base_pooler::BasePooler,
bases::Base, bases::Base,
data_layer::{ToHtmlString as _, Value},
db_conns::{escape_identifier, init_role}, db_conns::{escape_identifier, init_role},
settings::Settings, settings::Settings,
users::CurrentUser, users::CurrentUser,
@ -149,15 +154,12 @@ pub async fn lens_page(
// FIXME auth // FIXME auth
let class = query!( let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client)
"select relname from pg_class where oid = $1", .await?
Oid(class_oid) .ok_or(AppError::NotFound(
) "no relation found with that oid".to_owned(),
.fetch_optional(&mut *client) ))?;
.await? let namespace = class.fetch_namespace(&mut *client).await?;
.ok_or(AppError::NotFound(
"no relation found with that oid".to_owned(),
))?;
let lens = Lens::fetch_by_id(lens_id, &mut *app_db) let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
.await? .await?
@ -171,25 +173,47 @@ pub async fn lens_page(
fields.append(&mut selection.resolve_fields_from_attrs(&attrs)); 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; const FRONTEND_ROW_LIMIT: i64 = 1000;
let rows = query(&format!( struct Row {
"select {} from {} limit $1", pkeys: HashMap<String, Encodable>,
attrs data: PgRow,
}
let rows: Vec<Row> = query(&format!(
"select {0} from {1}.{2} limit $1",
pkey_attrs
.iter() .iter()
.map(|attr| attr.attname.clone()) .chain(attrs.iter())
.map(|attr| escape_identifier(&attr.attname))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "), .join(", "),
escape_identifier(&namespace.nspname),
escape_identifier(&class.relname), escape_identifier(&class.relname),
)) ))
.bind(FRONTEND_ROW_LIMIT) .bind(FRONTEND_ROW_LIMIT)
.fetch_all(&mut *client) .fetch_all(&mut *client)
.await?; .await?
.into_iter()
.map(|row| {
let mut pkey_values: HashMap<String, Encodable> = 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)] #[derive(Template)]
#[template(path = "lens.html")] #[template(path = "lens.html")]
struct ResponseTemplate { struct ResponseTemplate {
fields: Vec<Field>, fields: Vec<Field>,
all_columns: Vec<PgAttribute>, all_columns: Vec<PgAttribute>,
rows: Vec<PgRow>, rows: Vec<Row>,
selections_json: String, selections_json: String,
settings: Settings, settings: Settings,
} }
@ -265,6 +289,57 @@ pub async fn update_lens_page_post(
.into_response()) .into_response())
} }
#[derive(Deserialize)]
pub struct UpdateValuePageForm {
column: String,
pkeys: HashMap<String, Encodable>,
value: Encodable,
}
pub async fn update_value_page_post(
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
base_id,
class_oid,
lens_id: _,
}): Path<LensPagePath>,
Json(body): Json<UpdateValuePageForm>,
) -> Result<Response, AppError> {
// 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)] #[derive(Deserialize)]
pub struct ViewerPagePath { pub struct ViewerPagePath {
base_id: Uuid, base_id: Uuid,

View file

@ -7,19 +7,21 @@ use axum::{
response::{Html, IntoResponse as _, Redirect, Response}, response::{Html, IntoResponse as _, Redirect, Response},
}; };
use axum_extra::extract::Form; 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 serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app_error::{not_found, AppError}, app_error::{AppError, not_found},
app_state::AppDbConn, app_state::AppDbConn,
base_pooler::BasePooler, base_pooler::BasePooler,
bases::Base, bases::Base,
db_conns::init_role, db_conns::init_role,
pg_acls::PgPrivilegeType,
pg_classes::{PgClass, PgRelKind},
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
rel_invitations::RelInvitation, rel_invitations::RelInvitation,
settings::Settings, settings::Settings,
users::{CurrentUser, User}, users::{CurrentUser, User},

View file

@ -4,12 +4,13 @@
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script> <script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script> <script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-components.mjs"></script> <script type="module" src="{{ settings.root_path }}/js_dist/viewer-components.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-controller.mjs"></script>
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css"> <link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
<table class="viewer"> <table class="viewer">
<thead> <thead>
<tr> <tr>
{% for field in fields %} {% for field in fields %}
<th> <th width="{{ field.width_px }}">
<div class="padded-cell">{{ field.label.clone().unwrap_or(field.name.clone()) }}</div> <div class="padded-cell">{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
</th> </th>
{% endfor %} {% endfor %}
@ -20,12 +21,21 @@
</thead> </thead>
<tbody> <tbody>
{% for row in rows %} {% for row in rows %}
<tr> <tr data-pkey="{{ row.pkeys | json }}">
{% for field in fields %} {% for field in fields %}
<td> <td>
{% match Value::get_from_row(row, field.name.as_str()) %} {% match field.get_value_encodable(row.data) %}
{% when Ok with (value) %} {% when Ok with (encodable) %}
{{ value.to_html_string(field.display_type) | safe }} <{{ 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 }}
</{{ field.webc_tag() | safe }}
{% when Err with (err) %} {% when Err with (err) %}
<span class="pg-value-error">{{ err }}</span> <span class="pg-value-error">{{ err }}</span>
{% endmatch %} {% endmatch %}