diff --git a/Cargo.lock b/Cargo.lock index 2ccb3cb..0163ef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -397,6 +397,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bigdecimal" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560f42649de9fa436b73517378a147ec21f6c997a546581df4b4b31677828934" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", + "serde_json", +] + [[package]] name = "bincode" version = "1.3.3" @@ -1739,6 +1754,7 @@ dependencies = [ name = "interim-models" version = "0.0.1" dependencies = [ + "bigdecimal", "chrono", "derive_builder", "interim-pgtypes", @@ -1786,6 +1802,7 @@ dependencies = [ "async-session", "axum", "axum-extra", + "bigdecimal", "chrono", "clap", "config", @@ -2079,6 +2096,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -3203,6 +3230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64 0.22.1", + "bigdecimal", "bytes", "chrono", "crc", @@ -3280,6 +3308,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags 2.9.4", "byteorder", "bytes", @@ -3324,6 +3353,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags 2.9.4", "byteorder", "chrono", @@ -3341,6 +3371,7 @@ dependencies = [ "log", "md-5", "memchr", + "num-bigint", "once_cell", "rand 0.8.5", "serde", diff --git a/Cargo.toml b/Cargo.toml index f48c8b0..0977be6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ edition = "2024" [workspace.dependencies] anyhow = { version = "1.0.91", features = ["backtrace"] } +bigdecimal = { version = "0.4.9", features = ["serde-json"] } chrono = { version = "0.4.41", features = ["serde"] } derive_builder = "0.20.2" futures = "0.3.31" @@ -22,7 +23,7 @@ regex = "1.11.1" reqwest = { version = "0.12.8", features = ["json"] } serde = { version = "1.0.213", features = ["derive"] } serde_json = "1.0.132" -sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros", "bigdecimal"] } strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.12" tokio = { version = "1.42.0", features = ["full"] } diff --git a/interim-models/Cargo.toml b/interim-models/Cargo.toml index 6dad812..c336562 100644 --- a/interim-models/Cargo.toml +++ b/interim-models/Cargo.toml @@ -4,6 +4,7 @@ edition.workspace = true version.workspace = true [dependencies] +bigdecimal = { workspace = true } chrono = { workspace = true } derive_builder = { workspace = true } interim-pgtypes = { path = "../interim-pgtypes" } diff --git a/interim-models/src/datum.rs b/interim-models/src/datum.rs index a7fdbee..c01051e 100644 --- a/interim-models/src/datum.rs +++ b/interim-models/src/datum.rs @@ -1,3 +1,4 @@ +use bigdecimal::BigDecimal; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::Postgres; @@ -6,6 +7,14 @@ use uuid::Uuid; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(tag = "t", content = "c")] pub enum Datum { + // BigDecimal is used because a user may insert a value directly via SQL + // which overflows the representational space of `rust_decimal::Decimal`. + // Note that by default, [`BigDecimal`] serializes to JSON as a string. This + // behavior can be modified, but it's a pain when paired with the [`Option`] + // type. String representation should be acceptable for the UI, as [`Datum`] + // values should always be parsed through Zod, which can coerce the value to + // a number transparently. + Numeric(Option), Text(Option), Timestamp(Option>), Uuid(Option), @@ -19,6 +28,7 @@ impl Datum { query: sqlx::query::Query<'a, Postgres, ::Arguments<'a>>, ) -> sqlx::query::Query<'a, Postgres, ::Arguments<'a>> { match self { + Self::Numeric(value) => query.bind(value), Self::Text(value) => query.bind(value), Self::Timestamp(value) => query.bind(value), Self::Uuid(value) => query.bind(value), @@ -38,8 +48,10 @@ impl Datum { pub fn is_none(&self) -> bool { match self { - Self::Text(None) | Self::Timestamp(None) | Self::Uuid(None) => true, - Self::Text(_) | Self::Timestamp(_) | Self::Uuid(_) => false, + Self::Numeric(None) | Self::Text(None) | Self::Timestamp(None) | Self::Uuid(None) => { + true + } + Self::Numeric(_) | Self::Text(_) | Self::Timestamp(_) | Self::Uuid(_) => false, } } } diff --git a/interim-models/src/field.rs b/interim-models/src/field.rs index ef7e463..1030538 100644 --- a/interim-models/src/field.rs +++ b/interim-models/src/field.rs @@ -1,3 +1,4 @@ +use bigdecimal::BigDecimal; use chrono::{DateTime, Utc}; use derive_builder::Builder; use interim_pgtypes::pg_attribute::PgAttribute; @@ -72,6 +73,9 @@ impl Field { let ty = type_info.name(); dbg!(&ty); Ok(match ty { + "NUMERIC" => { + Datum::Numeric( as Decode>::decode(value_ref).unwrap()) + } "TEXT" | "VARCHAR" => { Datum::Text( as Decode>::decode(value_ref).unwrap()) } diff --git a/interim-models/src/presentation.rs b/interim-models/src/presentation.rs index 7717be6..b61b117 100644 --- a/interim-models/src/presentation.rs +++ b/interim-models/src/presentation.rs @@ -12,6 +12,7 @@ pub enum Presentation { allow_custom: bool, options: Vec, }, + Numeric {}, Text { input_mode: TextInputMode, }, @@ -30,11 +31,12 @@ pub struct DropdownOption { impl Presentation { /// Returns a SQL fragment for the default data type for creating or /// altering a backing column, such as "integer", or "timestamptz". - pub fn attr_data_type_fragment(&self) -> String { + pub fn attr_data_type_fragment(&self) -> &'static str { match self { - Self::Dropdown { .. } | Self::Text { .. } => "text".to_owned(), - Self::Timestamp { .. } => "timestamptz".to_owned(), - Self::Uuid { .. } => "uuid".to_owned(), + Self::Dropdown { .. } | Self::Text { .. } => "text", + Self::Numeric { .. } => "numeric", + Self::Timestamp { .. } => "timestamptz", + Self::Uuid { .. } => "uuid", } } @@ -42,6 +44,7 @@ impl Presentation { /// Returns None if no default presentation exists. pub fn default_from_attr(attr: &PgAttribute) -> Option { match attr.regtype.to_lowercase().as_str() { + "numeric" => Some(Self::Numeric {}), "text" => Some(Self::Text { input_mode: TextInputMode::MultiLine {}, }), @@ -52,16 +55,6 @@ impl Presentation { _ => None, } } - - /// Bet the web component tag name to use for rendering a UI cell. - pub fn cell_webc_tag(&self) -> String { - match self { - Self::Dropdown { .. } => "cell-dropdown".to_owned(), - Self::Text { .. } => "cell-text".to_owned(), - Self::Timestamp { .. } => "cell-timestamp".to_owned(), - Self::Uuid {} => "cell-uuid".to_owned(), - } - } } impl Default for Presentation { diff --git a/interim-server/Cargo.toml b/interim-server/Cargo.toml index 9e44de2..49c8157 100644 --- a/interim-server/Cargo.toml +++ b/interim-server/Cargo.toml @@ -9,6 +9,7 @@ askama = { version = "0.14.0", features = ["serde_json", "urlencode"] } async-session = "3.0.0" axum = { version = "0.8.1", features = ["macros", "ws"] } axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] } +bigdecimal = { workspace = true } chrono = { workspace = true } clap = { version = "4.5.31", features = ["derive"] } config = "0.14.1" diff --git a/interim-server/src/navigator.rs b/interim-server/src/navigator.rs index ed3561b..0bc5512 100644 --- a/interim-server/src/navigator.rs +++ b/interim-server/src/navigator.rs @@ -29,21 +29,21 @@ pub(crate) struct Navigator { } impl Navigator { - pub(crate) fn workspace_page(&self) -> WorkspacePageBuilder { + pub(crate) fn workspace_page(&'_ self) -> WorkspacePageBuilder<'_> { WorkspacePageBuilder { root_path: Some(&self.root_path), ..Default::default() } } - pub(crate) fn portal_page(&self) -> PortalPageBuilder { + pub(crate) fn portal_page(&'_ self) -> PortalPageBuilder<'_> { PortalPageBuilder { root_path: Some(&self.root_path), ..Default::default() } } - pub(crate) fn form_page(&self, portal_id: Uuid) -> FormPageBuilder { + pub(crate) fn form_page(&'_ self, portal_id: Uuid) -> FormPageBuilder<'_> { FormPageBuilder { root_path: Some(&self.root_path), portal_id: Some(portal_id), @@ -52,7 +52,7 @@ impl Navigator { /// Returns a [`NavigatorPage`] builder for navigating to a relation's /// "settings" page. - pub(crate) fn rel_page(&self) -> RelPageBuilder { + pub(crate) fn rel_page(&'_ self) -> RelPageBuilder<'_> { RelPageBuilder { root_path: Some(&self.root_path), ..Default::default() diff --git a/interim-server/src/presentation_form.rs b/interim-server/src/presentation_form.rs index 022a59b..2172a08 100644 --- a/interim-server/src/presentation_form.rs +++ b/interim-server/src/presentation_form.rs @@ -42,6 +42,7 @@ impl TryFrom for Presentation { .map(|(color, value)| DropdownOption { color, value }) .collect(), }, + Presentation::Numeric { .. } => Presentation::Numeric {}, Presentation::Text { .. } => Presentation::Text { input_mode: TextInputMode::try_from(form_value.text_input_mode.as_str())?, }, diff --git a/interim-server/src/settings.rs b/interim-server/src/settings.rs index 01836b3..ee2a295 100644 --- a/interim-server/src/settings.rs +++ b/interim-server/src/settings.rs @@ -58,14 +58,6 @@ fn default_host() -> String { "127.0.0.1".to_owned() } -fn default_db_role_prefix() -> String { - "__phono__".to_owned() -} - -fn default_phono_table_namespace() -> String { - "phono".to_owned() -} - #[derive(Clone, Debug, Deserialize)] pub(crate) struct AuthSettings { pub(crate) client_id: String, diff --git a/sass/viewer.scss b/sass/viewer.scss index f0b6bac..14e0e76 100644 --- a/sass/viewer.scss +++ b/sass/viewer.scss @@ -88,6 +88,14 @@ $table-border-color: #ccc; padding: 0 8px; } + &--numeric { + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 8px; + } + &--text { overflow: hidden; text-overflow: ellipsis; diff --git a/svelte/src/datum-editor.svelte b/svelte/src/datum-editor.svelte index 970cbd5..b72dcc8 100644 --- a/svelte/src/datum-editor.svelte +++ b/svelte/src/datum-editor.svelte @@ -184,7 +184,7 @@ {/if} - {#if field_info.field.presentation.t === "Dropdown" || field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"} + {#if ["Dropdown", "Numeric", "Text", "Uuid"].includes(field_info.field.presentation.t)} = void; export const all_datum_tags = [ + "Numeric", "Text", "Timestamp", "Uuid", ] as const; // Type checking to ensure that all valid enum tags are included. -type _ = Assert< +type _DatumTagsAssertionA = Assert< Datum["t"] extends (typeof all_datum_tags)[number] ? true : false >; +type _DatumTagsAssertionB = Assert< + (typeof all_datum_tags)[number] extends Datum["t"] ? true : false +>; + +const NUMERIC_REGEX = /^-?[0-9]+(\.[0-9]+)?$/; + +const datum_numeric_schema = z.object({ + t: z.literal("Numeric"), + // The `bigdecimal` Rust library serializes values to strings by default. + // Doing the same here helps to avoid weird floating point conversion errors + // when displaying. + c: z.string().regex(NUMERIC_REGEX).nullish().transform((x) => x ?? undefined), +}); const datum_text_schema = z.object({ t: z.literal("Text"), @@ -30,6 +49,7 @@ const datum_uuid_schema = z.object({ }); export const datum_schema = z.union([ + datum_numeric_schema, datum_text_schema, datum_timestamp_schema, datum_uuid_schema, @@ -37,7 +57,11 @@ export const datum_schema = z.union([ export type Datum = z.infer; -export function parse_clipboard_value( +/** + * Attempt to parse a Datum from a textual representation, for example when + * pasting values from the clipboard. If unsuccessful, returns an empty Datum. + */ +export function parse_datum_from_text( presentation: Presentation, input: string, ): Datum { @@ -50,6 +74,22 @@ export function parse_clipboard_value( } else { return get_empty_datum_for(presentation); } + } else if (presentation.t === "Numeric") { + // For now, commas and spaces are accepted as thousands-separators, but full + // stops are not. Only full stops are allowed as the decimal separator. This + // is about as flexible as parsing can be with regards to international + // standards from an English-centric perspective, without resorting to + // troublesome heuristics to disambiguate between thousands-separators and + // decimal separators. + const input_cleaned = input.trim().replace(/^\+/, "").replace( + /(?:,|\s+)([0-9]{3})(,|.|\s|$)/g, + "$1$2", + ); + console.log(NUMERIC_REGEX.test(input_cleaned)); + return { + t: "Numeric", + c: NUMERIC_REGEX.test(input_cleaned) ? input_cleaned : undefined, + }; } else if (presentation.t === "Text") { return { t: "Text", c: input }; } else if (presentation.t === "Timestamp") { @@ -57,9 +97,15 @@ export function parse_clipboard_value( console.warn("parsing timestamps from clipboard is not yet supported"); return get_empty_datum_for(presentation); } else if (presentation.t === "Uuid") { - // TODO: implement - console.warn("parsing uuids from clipboard is not yet supported"); - return get_empty_datum_for(presentation); + try { + return { + t: "Uuid", + c: uuid.stringify(uuid.parse(input)), + }; + } catch { + // uuid.parse() throws a TypeError if unsuccessful. + return get_empty_datum_for(presentation); + } } type _ = Assert; throw new Error("should be unreachable"); diff --git a/svelte/src/editor-state.svelte.ts b/svelte/src/editor-state.svelte.ts index d9e4e05..c0c761e 100644 --- a/svelte/src/editor-state.svelte.ts +++ b/svelte/src/editor-state.svelte.ts @@ -1,7 +1,10 @@ import * as uuid from "uuid"; -import { type Datum } from "./datum.svelte.ts"; -import { type Presentation } from "./presentation.svelte.ts"; +import { type Datum, parse_datum_from_text } from "./datum.svelte.ts"; +import { + get_empty_datum_for, + type Presentation, +} from "./presentation.svelte.ts"; type Assert<_T extends true> = void; @@ -23,7 +26,7 @@ export const DEFAULT_EDITOR_STATE: EditorState = { }; export function editor_state_from_datum(value: Datum): EditorState { - if (value.t === "Text") { + if (value.t === "Numeric" || value.t === "Text") { return { ...DEFAULT_EDITOR_STATE, text_value: value.c ?? "", @@ -57,43 +60,25 @@ export function datum_from_editor_state( value: EditorState, presentation: Presentation, ): Datum | undefined { - if (presentation.t === "Dropdown") { + if ( + presentation.t === "Dropdown" || presentation.t === "Numeric" || + presentation.t === "Text" || presentation.t === "Uuid" + ) { + // Presentations which derive values directly from the editor's text input + // can all be handled with the same logic. if (value.is_null) { - return { t: "Text", c: undefined }; + return get_empty_datum_for(presentation); } - if ( - !presentation.c.allow_custom && - presentation.c.options.every((option) => - option.value !== value.text_value - ) - ) { + const parsed = parse_datum_from_text(presentation, value.text_value); + if (parsed.c === undefined) { return undefined; } - return { - t: "Text", - c: value.text_value, - }; - } - if (presentation.t === "Text") { - return { t: "Text", c: value.is_null ? undefined : value.text_value }; + return parsed; } if (presentation.t === "Timestamp") { // FIXME throw new Error("not yet implemented"); } - if (presentation.t === "Uuid") { - try { - return { - t: "Uuid", - c: value.is_null - ? undefined - : uuid.stringify(uuid.parse(value.text_value)), - }; - } catch { - // uuid.parse() throws a TypeError if unsuccessful. - return undefined; - } - } type _ = Assert; throw new Error("this should be unreachable"); } diff --git a/svelte/src/field-details.svelte b/svelte/src/field-details.svelte index b7e473c..c9ebe44 100644 --- a/svelte/src/field-details.svelte +++ b/svelte/src/field-details.svelte @@ -59,6 +59,9 @@ field. This is typically rendered within a popover component, and within an HTML if (tag === "Dropdown") { return { t: "Dropdown", c: { allow_custom: false, options: [] } }; } + if (tag === "Numeric") { + return { t: "Numeric", c: {} }; + } if (tag === "Text") { return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } }; } @@ -153,6 +156,7 @@ field. This is typically rendered within a popover component, and within an HTML value={option.value} />