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"
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",

View file

@ -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<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() {
const parsed = JSON.parse(this.value);
if (parsed === null) {
return html`
<code>---</code>
let inner: TemplateResult = 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>
`;
} else {
inner = html`
${this._contents}
`;
}
return html`
<div>
${parsed}
<div class="outer" @click="${this._handleClick}">
${inner}
</div>
`;
}

View file

@ -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<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() {
if (this.isNull) {
return html`
let inner: TemplateResult = 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>
`;
} else {
inner = html`
<code>${this._contents}</code>
`;
}
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
[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 }

View file

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

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 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<Vec<AttrFilter>>",
label,
display_type as "display_type: SelectionDisplayType",
visible
field_type as "field_type: sqlx::types::Json<FieldType>",
visible,
width_px
from lens_selections
where lens_id = $1
"#,

View file

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

View file

@ -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<Vec<AttrFilter>>,
pub label: Option<String>,
pub display_type: Option<SelectionDisplayType>,
pub field_type: Option<sqlx::types::Json<FieldType>>,
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<String>,
pub display_type: Option<SelectionDisplayType>,
}
#[derive(Builder, Clone, Debug)]
pub struct InsertableSelection {
lens_id: Uuid,
@ -83,7 +72,7 @@ pub struct InsertableSelection {
#[builder(default, setter(strip_option))]
label: Option<String>,
#[builder(default, setter(strip_option))]
display_type: Option<SelectionDisplayType>,
field_type: Option<FieldType>,
#[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<Vec<AttrFilter>>",
label,
display_type as "display_type: SelectionDisplayType",
visible
field_type as "field_type?: sqlx::types::Json<FieldType>",
visible,
width_px
"#,
Uuid::now_v7(),
self.lens_id,
sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json<Vec<AttrFilter>>,
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,
)
.fetch_one(app_db)

View file

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

View file

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

View file

@ -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

View file

@ -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<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 {
/// 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, 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 {

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)]
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 }
interim-models = { workspace = true }
interim-pgtypes = { workspace = true }
nom = "8.0.0"
oauth2 = "4.4.2"
percent-encoding = "2.3.1"
rand = { workspace = true }

View file

@ -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,

View file

@ -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<String>),
Integer(Option<i32>),
@ -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<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 {
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 {
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 <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 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;

View file

@ -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,

View file

@ -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),

View file

@ -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<String, Encodable>,
data: PgRow,
}
let rows: Vec<Row> = 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::<Vec<_>>()
.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<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)]
#[template(path = "lens.html")]
struct ResponseTemplate {
fields: Vec<Field>,
all_columns: Vec<PgAttribute>,
rows: Vec<PgRow>,
rows: Vec<Row>,
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<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)]
pub struct ViewerPagePath {
base_id: Uuid,

View file

@ -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},

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/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-controller.mjs"></script>
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
<table class="viewer">
<thead>
<tr>
{% for field in fields %}
<th>
<th width="{{ field.width_px }}">
<div class="padded-cell">{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
</th>
{% endfor %}
@ -20,12 +21,21 @@
</thead>
<tbody>
{% for row in rows %}
<tr>
<tr data-pkey="{{ row.pkeys | json }}">
{% for field in fields %}
<td>
{% 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 }}
</{{ field.webc_tag() | safe }}
{% when Err with (err) %}
<span class="pg-value-error">{{ err }}</span>
{% endmatch %}