add support for numeric fields

This commit is contained in:
Brent Schroeter 2025-11-11 01:26:48 +00:00
parent 95a4165163
commit 68ca114d5e
19 changed files with 171 additions and 69 deletions

31
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -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<BigDecimal>),
Text(Option<String>),
Timestamp(Option<DateTime<Utc>>),
Uuid(Option<Uuid>),
@ -19,6 +28,7 @@ impl Datum {
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::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,
}
}
}

View file

@ -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(<Option<BigDecimal> as Decode<Postgres>>::decode(value_ref).unwrap())
}
"TEXT" | "VARCHAR" => {
Datum::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap())
}

View file

@ -12,6 +12,7 @@ pub enum Presentation {
allow_custom: bool,
options: Vec<DropdownOption>,
},
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<Self> {
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 {

View file

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

View file

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

View file

@ -42,6 +42,7 @@ impl TryFrom<PresentationForm> 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())?,
},

View file

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

View file

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

View file

@ -184,7 +184,7 @@
<i class="ti ti-cube"></i>
{/if}
</button>
{#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)}
<input
{...interactive_handlers}
bind:this={text_input_element}

View file

@ -1,18 +1,37 @@
import * as uuid from "uuid";
import { z } from "zod";
import { get_empty_datum_for, Presentation } from "./presentation.svelte.ts";
import {
get_empty_datum_for,
type Presentation,
} from "./presentation.svelte.ts";
type Assert<_T extends true> = 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<typeof datum_schema>;
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,10 +97,16 @@ 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");
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<typeof presentation["t"] extends never ? true : false>;
throw new Error("should be unreachable");
}

View file

@ -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 (value.is_null) {
return { t: "Text", c: undefined };
}
if (
!presentation.c.allow_custom &&
presentation.c.options.every((option) =>
option.value !== value.text_value
)
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 get_empty_datum_for(presentation);
}
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<typeof presentation extends never ? true : false>;
throw new Error("this should be unreachable");
}

View file

@ -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}
/>
<button
aria-label="Remove option"
class="button--clear"
onclick={() => {
if (presentation?.t !== "Dropdown") {

View file

@ -65,6 +65,8 @@
<span slot="button-contents">
{#if field.field.presentation.t === "Dropdown"}
<i class="ti ti-pointer"></i>
{:else if field.field.presentation.t === "Numeric"}
<i class="ti ti-decimal"></i>
{:else if field.field.presentation.t === "Text"}
<i class="ti ti-file-text"></i>
{:else if field.field.presentation.t === "Timestamp"}

View file

@ -6,6 +6,7 @@ type Assert<_T extends true> = void;
export const all_presentation_tags = [
"Dropdown",
"Numeric",
"Text",
"Timestamp",
"Uuid",
@ -36,6 +37,11 @@ type _TextInputModesAssertionB = Assert<
: false
>;
const presentation_numeric_schema = z.object({
t: z.literal("Numeric"),
c: z.object({}),
});
const text_input_mode_schema = z.union([
z.object({
t: z.literal("SingleLine"),
@ -91,6 +97,7 @@ export type PresentationUuid = z.infer<typeof presentation_uuid_schema>;
export const presentation_schema = z.union([
presentation_dropdown_schema,
presentation_numeric_schema,
presentation_text_schema,
presentation_timestamp_schema,
presentation_uuid_schema,
@ -102,6 +109,9 @@ export function get_empty_datum_for(presentation: Presentation): Datum {
if (presentation.t === "Dropdown") {
return { t: "Text", c: undefined };
}
if (presentation.t === "Numeric") {
return { t: "Numeric", c: undefined };
}
if (presentation.t === "Text") {
return { t: "Text", c: undefined };
}

View file

@ -102,6 +102,17 @@
{:else}
UNKNOWN
{/if}
{:else if field.field.presentation.t === "Numeric"}
<div
class="lens-cell__content lens-cell__content--numeric"
class:lens-cell__content--null={value.c === undefined}
>
{#if value.c === undefined}
<i class={["ti", null_value_class]}></i>
{:else}
{value.c}
{/if}
</div>
{:else if field.field.presentation.t === "Text"}
<div
class="lens-cell__content lens-cell__content--text"

View file

@ -20,7 +20,7 @@
import {
type Datum,
datum_schema,
parse_clipboard_value,
parse_datum_from_text,
} from "./datum.svelte";
import DatumEditor from "./datum-editor.svelte";
import { type Row, type FieldInfo, field_info_schema } from "./field.svelte";
@ -455,7 +455,7 @@
const parsed_tsv: Datum[][] = paste_text.split("\n").map((line) => {
const raw_values = line.split("\t");
return fields.map(({ field: { presentation } }, i) =>
parse_clipboard_value(presentation, raw_values[i] ?? ""),
parse_datum_from_text(presentation, raw_values[i] ?? ""),
);
});
if (selections.length === 1) {