ability to add columns

This commit is contained in:
Brent Schroeter 2025-07-18 16:20:03 -07:00
parent afafb49cd6
commit 389bd27b33
16 changed files with 328 additions and 120 deletions

View file

@ -82,7 +82,7 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
label_value: "",
name_value: "",
name_customized: False,
field_type: "text",
field_type: "Text",
),
effect.none(),
)
@ -144,7 +144,7 @@ fn view(model: Model) -> Element(Msg) {
)
True ->
html.div([attr.class("header")], [
html.form([attr.method("post"), attr.action("create-column")], [
html.form([attr.method("post"), attr.action("add-column")], [
label_input(value: model.label_value, on_input: UserUpdatedLabel),
html.button(
[attr.type_("button"), attr.popovertarget("config-popover")],
@ -200,7 +200,6 @@ fn config_popover(model: Model) -> Element(Msg) {
),
html.select(
[
attr.type_("text"),
attr.name("field_type"),
attr.class("form-section__input"),
attr.id("field-type-select"),
@ -208,11 +207,11 @@ fn config_popover(model: Model) -> Element(Msg) {
],
[
html.option(
[attr.value("text"), attr.checked(model.field_type == "text")],
[attr.value("Text"), attr.checked(model.field_type == "Text")],
"Text",
),
html.option(
[attr.value("decimal"), attr.checked(model.field_type == "decimal")],
[attr.value("Decimal"), attr.checked(model.field_type == "Decimal")],
"Decimal",
),
],

View file

@ -1,3 +1,3 @@
drop table if exists lens_selections;
drop table if exists fields;
drop table if exists lenses;
drop type if exists lens_display_type;

View file

@ -11,12 +11,11 @@ create table if not exists lenses (
);
create index on lenses (base_id);
create table if not exists lens_selections (
create table if not exists fields (
id uuid not null primary key,
lens_id uuid not null references lenses(id) on delete cascade,
attr_filters jsonb not null default '[]'::jsonb,
name text not null,
label text,
field_type jsonb,
visible boolean not null default true,
field_type jsonb not null,
width_px int not null default 200
);

View file

@ -1,16 +1,18 @@
use chrono::{DateTime, Utc};
use derive_builder::Builder;
use interim_pgtypes::pg_attribute::PgAttribute;
use serde::{Deserialize, Serialize};
use sqlx::{Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow};
use sqlx::{
Decode, PgExecutor, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow, query_as,
};
use thiserror::Error;
use uuid::Uuid;
const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S";
pub 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 id: Uuid,
pub name: String,
pub label: Option<String>,
pub field_type: sqlx::types::Json<FieldType>,
@ -18,8 +20,13 @@ pub struct Field {
}
impl Field {
pub fn insertable_builder() -> InsertableFieldBuilder {
InsertableFieldBuilder::default()
}
pub fn default_from_attr(attr: &PgAttribute) -> Self {
Self {
id: Uuid::now_v7(),
name: attr.attname.clone(),
label: None,
field_type: sqlx::types::Json(FieldType::default_from_attr(attr)),
@ -112,8 +119,73 @@ impl FieldType {
_ => Self::Unknown,
}
}
/// Returns a SQL fragment for the default data type for creating or
/// altering a backing column, such as "integer", or "timestamptz". Returns
/// None if the field type is Unknown.
pub fn attr_data_type_fragment(&self) -> Option<&'static str> {
match self {
Self::Integer => Some("integer"),
Self::InterimUser | Self::Text => Some("text"),
Self::Timestamp { .. } => Some("timestamptz"),
Self::Uuid => Some("uuid"),
Self::Unknown => None,
}
}
}
// -------- Insertable --------
#[derive(Builder, Clone, Debug)]
pub struct InsertableField {
lens_id: Uuid,
name: String,
#[builder(default)]
label: Option<String>,
field_type: FieldType,
#[builder(default = 200)]
width_px: i32,
}
impl InsertableField {
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Field, sqlx::Error> {
query_as!(
Field,
r#"
insert into fields
(id, lens_id, name, label, field_type, width_px)
values ($1, $2, $3, $4, $5, $6)
returning
id,
name,
label,
field_type as "field_type: sqlx::types::Json<FieldType>",
width_px
"#,
Uuid::now_v7(),
self.lens_id,
self.name,
self.label,
sqlx::types::Json::<_>(self.field_type) as sqlx::types::Json<FieldType>,
self.width_px,
)
.fetch_one(app_db)
.await
}
}
impl InsertableFieldBuilder {
pub fn default_from_attr(attr: &PgAttribute) -> Self {
Self {
name: Some(attr.attname.clone()),
field_type: Some(FieldType::default_from_attr(attr)),
..Self::default()
}
}
}
// -------- Errors --------
/// Error when parsing a sqlx value to JSON
#[derive(Debug, Error)]
pub enum ParseError {
@ -125,6 +197,9 @@ pub enum ParseError {
UnknownType,
}
// -------- Encodable --------
// TODO this should probably be moved to another crate
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "t", content = "c")]
pub enum Encodable {
@ -147,26 +222,3 @@ impl Encodable {
}
}
}
// 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,10 +3,7 @@ use serde::Serialize;
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use uuid::Uuid;
use crate::{
field::FieldType,
selection::{AttrFilter, Selection},
};
use crate::field::{Field, FieldType};
#[derive(Clone, Debug, Serialize)]
pub struct Lens {
@ -68,21 +65,20 @@ where base_id = $1 and class_oid = $2
.await
}
pub async fn fetch_selections<'a, E: PgExecutor<'a>>(
pub async fn fetch_fields<'a, E: PgExecutor<'a>>(
&self,
app_db: E,
) -> Result<Vec<Selection>, sqlx::Error> {
) -> Result<Vec<Field>, sqlx::Error> {
query_as!(
Selection,
Field,
r#"
select
id,
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
name,
label,
field_type as "field_type: sqlx::types::Json<FieldType>",
visible,
width_px
from lens_selections
from fields
where lens_id = $1
"#,
self.id

View file

@ -1,5 +1,5 @@
pub mod field;
pub mod lens;
pub mod selection;
// pub mod selection;
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();

View file

@ -4,3 +4,14 @@ pub mod pg_class;
pub mod pg_database;
pub mod pg_namespace;
pub mod pg_role;
/// Given a raw identifier (such as a table name, column name, etc.), format it
/// so that it may be safely interpolated into a SQL query.
pub fn escape_identifier(identifier: &str) -> String {
// Escaping identifiers for Postgres is fairly easy, provided that the input is
// already known to contain no invalid multi-byte sequences. Backslashes may
// remain as-is, and embedded double quotes are escaped simply by doubling
// them (`"` becomes `""`). Refer to the PQescapeInternal() function in
// libpq (fe-exec.c) and Diesel's PgQueryBuilder::push_identifier().
format!("\"{}\"", identifier.replace('"', "\"\""))
}

View file

@ -1,6 +1,6 @@
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
use crate::{pg_acl::PgAclItem, pg_namespace::PgNamespace};
use crate::{escape_identifier, pg_acl::PgAclItem, pg_namespace::PgNamespace};
pub struct PgClass {
/// Row identifier
@ -9,6 +9,8 @@ pub struct PgClass {
pub relname: String,
/// The OID of the namespace that contains this relation
pub relnamespace: Oid,
/// SYNTHESIZED: name of this relation's namespace as text
pub regnamespace: String,
/// The OID of the data type that corresponds to this table's row type, if any; zero for indexes, sequences, and toast tables, which have no pg_type entry
pub reltype: Oid,
/// For typed tables, the OID of the underlying composite type; zero for all other relations
@ -50,6 +52,7 @@ select
oid,
relname,
relnamespace,
relnamespace::regnamespace::text as "regnamespace!",
reltype,
reloftype,
relowner,
@ -89,6 +92,7 @@ select
oid,
relname,
relnamespace,
relnamespace::regnamespace::text as "regnamespace!",
reltype,
reloftype,
relowner,
@ -123,6 +127,15 @@ where
// to the namespace that contains it. If not, that's an error.
.ok_or(sqlx::Error::RowNotFound)
}
/// Get ecaped identifier, including namespace.
pub fn get_identifier(&self) -> String {
format!(
"{0}.{1}",
escape_identifier(&self.regnamespace),
escape_identifier(&self.relname)
)
}
}
pub enum PgRelKind {

View file

@ -14,6 +14,17 @@ macro_rules! not_found {
};
}
macro_rules! bad_request {
($message:literal) => {
AppError::BadRequest($message.to_owned())
};
($message:literal, $($param:expr),+) => {
AppError::BadRequest(format!($message, $($param)+))
};
}
pub(crate) use bad_request;
pub(crate) use not_found;
/// Custom error type that maps to appropriate HTTP responses.

View file

@ -1,13 +1,5 @@
use sqlx::{query, PgConnection, Row as _};
pub fn escape_identifier(identifier: &str) -> String {
// Escaping identifiers for Postgres is fairly easy, provided that the input is
// already known to contain no invalid multi-byte sequences. Backslashes may
// remain as-is, and embedded double quotes are escaped simply by doubling
// them (`"` becomes `""`). Refer to the PQescapeInternal() function in
// libpq (fe-exec.c) and Diesel's PgQueryBuilder::push_identifier().
format!("\"{}\"", identifier.replace('"', "\"\""))
}
use interim_pgtypes::escape_identifier;
use sqlx::{PgConnection, Row as _, query};
pub async fn init_role(rolname: &str, client: &mut PgConnection) -> Result<(), sqlx::Error> {
let session_user = query!("select session_user;")

View file

@ -19,6 +19,7 @@ mod cli;
mod db_conns;
mod lenses;
mod middleware;
mod navigator;
mod rel_invitations;
mod router;
mod routes;

View file

@ -0,0 +1,48 @@
use axum::{
extract::FromRequestParts,
http::request::Parts,
response::{IntoResponse as _, Redirect, Response},
};
use interim_models::lens::Lens;
use crate::{app_error::AppError, app_state::AppState};
/// Helper type for semantically generating URI paths, e.g. for redirects.
#[derive(Clone, Debug)]
pub struct Navigator {
root_path: String,
sub_path: String,
}
impl Navigator {
pub fn lens_page(&self, lens: &Lens) -> Self {
Self {
sub_path: format!(
"/d/{0}/r/{1}/l/{2}/",
lens.base_id.simple(),
lens.class_oid.0,
lens.id.simple()
),
..self.clone()
}
}
pub fn redirect_to(&self) -> Response {
Redirect::to(&format!("{0}{1}", self.root_path, self.sub_path)).into_response()
}
}
impl<S> FromRequestParts<S> for Navigator
where
S: Into<AppState> + Clone + Sync,
{
type Rejection = AppError;
async fn from_request_parts(_: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state: AppState = state.clone().into();
Ok(Navigator {
root_path: app_state.settings.root_path.clone(),
sub_path: "/".to_owned(),
})
}
}

View file

@ -69,9 +69,13 @@ pub fn new_router(state: AppState) -> Router<()> {
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-lens",
post(routes::lenses::update_lens_page_post),
)
// .route(
// "/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}/add-selection",
post(routes::lenses::add_selection_page_post),
"/d/{base_id}/r/{class_oid}/l/{lens_id}/add-column",
post(routes::lenses::add_column_page_post),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-value",

View file

@ -5,18 +5,14 @@ use axum::{
response::{Html, IntoResponse as _, Redirect, Response},
};
use axum_extra::extract::Form;
use interim_pgtypes::escape_identifier;
use serde::Deserialize;
use sqlx::{query, query_scalar};
use uuid::Uuid;
use crate::{
app_error::AppError,
app_state::AppDbConn,
base_pooler::BasePooler,
base_user_perms::sync_perms_for_base,
bases::Base,
db_conns::{escape_identifier, init_role},
settings::Settings,
app_error::AppError, app_state::AppDbConn, base_pooler::BasePooler,
base_user_perms::sync_perms_for_base, bases::Base, db_conns::init_role, settings::Settings,
users::CurrentUser,
};

View file

@ -4,15 +4,15 @@ use askama::Template;
use axum::{
Json,
extract::{Path, State},
response::{Html, IntoResponse as _, Redirect, Response},
response::{Html, IntoResponse, Redirect, Response},
};
use axum_extra::extract::Form;
use interim_models::{
field::{Encodable, Field},
field::{Encodable, Field, FieldType, InsertableFieldBuilder, RFC_3339_S},
lens::{Lens, LensDisplayType},
selection::{AttrFilter, Selection},
};
use interim_pgtypes::{
escape_identifier,
pg_attribute::{PgAttribute, fetch_attributes_for_rel, fetch_primary_keys_for_rel},
pg_class::PgClass,
};
@ -25,11 +25,12 @@ use sqlx::{
use uuid::Uuid;
use crate::{
app_error::{AppError, not_found},
app_error::{AppError, bad_request, not_found},
app_state::AppDbConn,
base_pooler::BasePooler,
bases::Base,
db_conns::{escape_identifier, init_role},
db_conns::init_role,
navigator::Navigator,
settings::Settings,
users::CurrentUser,
};
@ -99,13 +100,19 @@ pub struct AddLensPagePostForm {
}
pub async fn add_lens_page_post(
State(Settings { root_path, .. }): State<Settings>,
State(mut base_pooler): State<BasePooler>,
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
Path(LensesPagePath { base_id, class_oid }): Path<LensesPagePath>,
Form(AddLensPagePostForm { name }): Form<AddLensPagePostForm>,
) -> Result<Response, AppError> {
// FIXME auth
// FIXME csrf
let mut client = base_pooler.acquire_for(base_id).await?;
let attrs = fetch_attributes_for_rel(Oid(class_oid), &mut *client).await?;
let lens = Lens::insertable_builder()
.base_id(base_id)
.class_oid(Oid(class_oid))
@ -114,12 +121,16 @@ pub async fn add_lens_page_post(
.build()?
.insert(&mut *app_db)
.await?;
Ok(Redirect::to(&format!(
"{root_path}/d/{0}/r/{class_oid}/l/{1}",
base_id.simple(),
lens.id.simple()
))
.into_response())
for attr in attrs {
InsertableFieldBuilder::default_from_attr(&attr)
.lens_id(lens.id)
.build()?
.insert(&mut *app_db)
.await?;
}
Ok(navigator.lens_page(&lens).redirect_to())
}
#[derive(Deserialize)]
@ -134,27 +145,24 @@ pub async fn lens_page(
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>,
Path(LensPagePath { lens_id, .. }): Path<LensPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let base = Base::fetch_by_id(base_id, &mut *app_db)
let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
.await?
.ok_or(AppError::NotFound("no base found with that id".to_owned()))?;
let mut client = base_pooler.acquire_for(base_id).await?;
.ok_or(not_found!("lens not found"))?;
let base = Base::fetch_by_id(lens.base_id, &mut *app_db)
.await?
.ok_or(not_found!("no base found with that id"))?;
let mut client = base_pooler.acquire_for(lens.base_id).await?;
init_role(
&format!("{}{}", &base.user_role_prefix, &current_user.id.simple()),
&mut client,
)
.await?;
// FIXME auth
let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client)
let class = PgClass::fetch_by_oid(lens.class_oid, &mut *client)
.await?
.ok_or(AppError::NotFound(
"no relation found with that oid".to_owned(),
@ -165,15 +173,9 @@ pub async fn lens_page(
.await?
.ok_or(not_found!("no lens found with that id"))?;
let attrs = fetch_attributes_for_rel(Oid(class_oid), &mut *client).await?;
let selections = lens.fetch_selections(&mut *app_db).await?;
let mut fields: Vec<Field> = Vec::with_capacity(selections.len());
for selection in selections.clone() {
fields.append(&mut selection.resolve_fields_from_attrs(&attrs));
}
let pkey_attrs = fetch_primary_keys_for_rel(Oid(class_oid), &mut *client).await?;
let attrs = fetch_attributes_for_rel(lens.class_oid, &mut *client).await?;
let fields = lens.fetch_fields(&mut *app_db).await?;
let pkey_attrs = fetch_primary_keys_for_rel(lens.class_oid, &mut *client).await?;
const FRONTEND_ROW_LIMIT: i64 = 1000;
struct Row {
@ -214,7 +216,6 @@ pub async fn lens_page(
fields: Vec<Field>,
all_columns: Vec<PgAttribute>,
rows: Vec<Row>,
selections_json: String,
settings: Settings,
}
Ok(Html(
@ -222,7 +223,6 @@ pub async fn lens_page(
all_columns: attrs,
fields,
rows,
selections_json: serde_json::to_string(&selections)?,
settings,
}
.render()?,
@ -231,42 +231,129 @@ pub async fn lens_page(
}
#[derive(Debug, Deserialize)]
pub struct AddSelectionPageForm {
column: String,
pub struct AddColumnPageForm {
name: String,
label: String,
field_type: String,
timestamp_format: Option<String>,
}
pub async fn add_selection_page_post(
fn try_field_type_from_form(form: &AddColumnPageForm) -> Result<FieldType, AppError> {
let serialized = match form.field_type.as_str() {
"Timestamp" => {
json!({
"t": form.field_type,
"c": {
"format": form.timestamp_format.clone().unwrap_or(RFC_3339_S.to_owned()),
},
})
}
_ => json!({"t": form.field_type}),
};
serde_json::from_value(serialized).or(Err(bad_request!("unable to parse field type")))
}
pub async fn add_column_page_post(
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
base_id,
class_oid,
lens_id,
}): Path<LensPagePath>,
Form(form): Form<AddSelectionPageForm>,
Path(LensPagePath { lens_id, .. }): Path<LensPagePath>,
Form(form): Form<AddColumnPageForm>,
) -> Result<Response, AppError> {
dbg!(&form);
// FIXME auth
// FIXME csrf
// FIXME validate column name length is less than 64
let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
.await?
.ok_or(not_found!("lens not found"))?;
Selection::insertable_builder()
let base = Base::fetch_by_id(lens.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?;
init_role(
&format!("{}{}", &base.user_role_prefix, &current_user.id.simple()),
&mut client,
)
.await?;
let class = PgClass::fetch_by_oid(lens.class_oid, &mut *client)
.await?
.ok_or(not_found!("pg class not found"))?;
let field_type = try_field_type_from_form(&form)?;
let data_type_fragment = field_type.attr_data_type_fragment().ok_or(bad_request!(
"cannot create column with type specified as Unknown"
))?;
query(&format!(
r#"
alter table {0}
add column if not exists {1} {2}
"#,
class.get_identifier(),
escape_identifier(&form.name),
data_type_fragment
))
.execute(&mut *client)
.await?;
Field::insertable_builder()
.lens_id(lens.id)
.attr_filters(vec![AttrFilter::NameEq(form.column)])
.name(form.name)
.label(if form.label.is_empty() {
None
} else {
Some(form.label)
})
.field_type(field_type)
.build()?
.insert(&mut *app_db)
.await?;
Ok(Redirect::to(&format!(
"{0}/d/{base_id}/r/{class_oid}/l/{lens_id}/",
settings.root_path
))
.into_response())
Ok(navigator.lens_page(&lens).redirect_to())
}
// #[derive(Debug, Deserialize)]
// pub struct AddSelectionPageForm {
// column: String,
// }
//
// pub async fn add_selection_page_post(
// State(settings): State<Settings>,
// AppDbConn(mut app_db): AppDbConn,
// CurrentUser(current_user): CurrentUser,
// Path(LensPagePath {
// base_id,
// class_oid,
// lens_id,
// }): Path<LensPagePath>,
// Form(form): Form<AddSelectionPageForm>,
// ) -> Result<Response, AppError> {
// dbg!(&form);
// // FIXME auth
// // FIXME csrf
//
// let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
// .await?
// .ok_or(not_found!("lens not found"))?;
// Selection::insertable_builder()
// .lens_id(lens.id)
// .attr_filters(vec![AttrFilter::NameEq(form.column)])
// .build()?
// .insert(&mut *app_db)
// .await?;
//
// Ok(Redirect::to(&format!(
// "{0}/d/{base_id}/r/{class_oid}/l/{lens_id}/",
// settings.root_path
// ))
// .into_response())
// }
pub async fn update_lens_page_post(
State(settings): State<Settings>,
AppDbConn(mut app_db): AppDbConn,

View file

@ -46,5 +46,4 @@
{% endfor %}
</tbody>
</table>
<lens-controls selections="{{ selections_json }}"></lens-controls>
{% endblock %}