From 6fdc396a638ea06850b5195adcc685f540e8a81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 17 Jul 2026 21:17:06 +0700 Subject: [PATCH 1/2] Fix SQLite date-like column edits producing NaN SQLite columns declared as date/datetime/timestamp get NUMERIC affinity, so Studio grouped them as numeric and coerced date-string edits to NaN. - Introspect date-like declared types with group "string" so their values are edited and stored as text, matching other SQLite tools. - Never coerce to NaN: NumericInput and coerceToValue only produce a number when the input actually parses as one, otherwise the raw text is kept (SQLite NUMERIC affinity stores non-numeric text as TEXT). Fixes prisma/studio#1361 Co-Authored-By: Claude Fable 5 --- .changeset/sqlite-date-like-columns-text.md | 7 + Architecture/cell-editing.md | 1 + FEATURES.md | 2 + data/sqlite-core/adapter.test.ts | 62 +++++++- data/sqlite-core/adapter.ts | 10 +- data/sqlite-core/datatype.test.ts | 54 ++++++- data/sqlite-core/datatype.ts | 29 ++++ data/sqlite-core/introspection.test.ts | 4 +- lib/conversionUtils.test.ts | 56 +++++++ lib/conversionUtils.ts | 12 +- ui/studio/input/NumericInput.test.tsx | 156 ++++++++++++++++++++ ui/studio/input/NumericInput.tsx | 16 +- vitest.config.ts | 2 +- 13 files changed, 395 insertions(+), 16 deletions(-) create mode 100644 .changeset/sqlite-date-like-columns-text.md create mode 100644 lib/conversionUtils.test.ts create mode 100644 ui/studio/input/NumericInput.test.tsx diff --git a/.changeset/sqlite-date-like-columns-text.md b/.changeset/sqlite-date-like-columns-text.md new file mode 100644 index 00000000..69037204 --- /dev/null +++ b/.changeset/sqlite-date-like-columns-text.md @@ -0,0 +1,7 @@ +--- +"@prisma/studio-core": patch +--- + +# Fix SQLite date-like column edits producing NaN + +SQLite columns declared as `date`, `datetime`, or `timestamp` get NUMERIC affinity, so Studio treated their date-string values as numbers and coerced edits to `NaN`. Date-like declared types are now edited as text and stored as-is, and numeric cell edits, pastes, and filters only coerce input to a number when it actually parses as one — non-numeric text is kept as text, matching SQLite's NUMERIC-affinity semantics, so `NaN` is never written. diff --git a/Architecture/cell-editing.md b/Architecture/cell-editing.md index 30084db7..3e5e5c82 100644 --- a/Architecture/cell-editing.md +++ b/Architecture/cell-editing.md @@ -95,6 +95,7 @@ Required semantics: - The inline editor popover MUST keep the cancel action but MUST NOT expose a per-cell save button once table-level staging is enabled. - Staging should only submit when value changed according to component rules. - Empty value semantics (`NULL`, default, empty string) MUST be explicit and type-aware per input component. +- `NumericInput` (and numeric value coercion in paste/filter paths) MUST never stage `NaN`: input is only coerced to a number when it parses as one, otherwise the raw text is staged (SQLite NUMERIC-affinity columns store it as TEXT per affinity rules; stricter engines reject it with a clear error). SQLite columns with date-like declared types (`date`, `datetime`, `timestamp`) are introspected with group `string` so they edit as text in the first place. ## Focused-Cell Contract diff --git a/FEATURES.md b/FEATURES.md index 2acaa70e..fa1b90f5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -277,6 +277,8 @@ Editable cells open popover editors with datatype-specific controls for raw text Save/cancel keyboard behavior is standardized, and null/default/empty semantics are handled explicitly per input type. Native PostgreSQL arrays can be edited from JSON-style array values and are written back with explicit array casts when inline SQL literals are required. PostgreSQL user-defined enum arrays also persist through that same staged-edit flow, with schema-qualified casts emitted in a form PostgreSQL accepts for `enum[]` writes. +SQLite columns with date-like declared types (`date`, `datetime`, `timestamp`) are edited as text and stored as-is despite their NUMERIC affinity, matching how SQLite itself stores date strings. +Numeric editing never writes `NaN`: input is only coerced to a number when it actually parses as one, and non-numeric text is passed through so SQLite NUMERIC-affinity columns keep it as text while stricter databases reject it with a clear error. ## Staged Multi-Cell Editing diff --git a/data/sqlite-core/adapter.test.ts b/data/sqlite-core/adapter.test.ts index 9ffba71c..bc888bea 100644 --- a/data/sqlite-core/adapter.test.ts +++ b/data/sqlite-core/adapter.test.ts @@ -226,7 +226,7 @@ describe("sqlite-core/adapter", () => { "date": { "datatype": { "affinity": "NUMERIC", - "group": "numeric", + "group": "string", "isArray": false, "isNative": true, "name": "date", @@ -249,7 +249,7 @@ describe("sqlite-core/adapter", () => { "datetime": { "datatype": { "affinity": "NUMERIC", - "group": "numeric", + "group": "string", "isArray": false, "isNative": true, "name": "datetime", @@ -939,6 +939,64 @@ describe("sqlite-core/adapter", () => { }); }); + describe("update", () => { + it("stores date-like strings as text in NUMERIC-affinity date columns", async () => { + database.exec('DELETE FROM "animals" WHERE "id" = 201'); + database.exec(` + INSERT INTO "animals" ("id", "name", "datetime") + VALUES (201, 'capybara', '2021-11-01 21:30:00') + `); + + const [introspectionError, introspection] = await adapter.introspect({}); + + expect(introspectionError).toBeNull(); + + const table = introspection?.schemas.main?.tables?.animals; + + if (!table) { + throw new Error("Expected main.animals table in introspection"); + } + + // date-like declared types are edited as text, not numbers. + expect(table.columns.datetime?.datatype).toMatchObject({ + affinity: "NUMERIC", + group: "string", + }); + + try { + const [error, result] = await adapter.update( + { + changes: { datetime: "2021-11-01 22:30:00" }, + row: { id: 201 }, + table, + }, + {}, + ); + + expect(error).toBeNull(); + expect(result?.row).toEqual( + expect.objectContaining({ + datetime: "2021-11-01 22:30:00", + id: 201, + }), + ); + + const stored = database + .prepare( + 'SELECT "datetime", typeof("datetime") as "type" FROM "animals" WHERE "id" = 201', + ) + .get() as { datetime: string; type: string }; + + expect(stored).toEqual({ + datetime: "2021-11-01 22:30:00", + type: "text", + }); + } finally { + database.exec('DELETE FROM "animals" WHERE "id" = 201'); + } + }); + }); + describe("updateMany", () => { it("updates multiple rows through the transactional executor path", async () => { database.exec('DELETE FROM "users" WHERE "id" IN (101, 102)'); diff --git a/data/sqlite-core/adapter.ts b/data/sqlite-core/adapter.ts index 1574fb22..ec7bbf7c 100644 --- a/data/sqlite-core/adapter.ts +++ b/data/sqlite-core/adapter.ts @@ -23,10 +23,7 @@ import { import { asQuery, type Query, type QueryResult } from "../query"; import { createSqlEditorSchemaFromIntrospection } from "../sql-editor-schema"; import type { Either } from "../type-utils"; -import { - determineColumnAffinity, - SQLITE_AFFINITY_TO_METADATA, -} from "./datatype"; +import { determineColumnMetadata } from "./datatype"; import { getDeleteQuery, getInsertQuery, @@ -394,7 +391,7 @@ function createIntrospection(args: { maxPKSeen = Math.max(maxPKSeen, pk); - const affinity = determineColumnAffinity(datatype); + const metadata = determineColumnMetadata(datatype); /** * `INTEGER PRIMARY KEY` columns act as `rowid` alias. `rowid` columns @@ -419,8 +416,7 @@ function createIntrospection(args: { columnsRecord[columnName] = { datatype: { - ...SQLITE_AFFINITY_TO_METADATA[affinity], - affinity, + ...metadata, isArray: false, isNative: true, name: datatype, diff --git a/data/sqlite-core/datatype.test.ts b/data/sqlite-core/datatype.test.ts index dd82aa6b..11b2a5b0 100644 --- a/data/sqlite-core/datatype.test.ts +++ b/data/sqlite-core/datatype.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { determineColumnAffinity } from "./datatype"; +import { determineColumnAffinity, determineColumnMetadata } from "./datatype"; describe("determineColumnAffinity", () => { it.each([ @@ -51,3 +51,55 @@ describe("determineColumnAffinity", () => { }, ); }); + +describe("determineColumnMetadata", () => { + it.each([ + // date-like declared types keep NUMERIC affinity, but their values are + // treated as text so Studio never coerces date strings to numbers. + { datatype: "DATE", expected: { affinity: "NUMERIC", group: "string" } }, + { + datatype: "DATETIME", + expected: { affinity: "NUMERIC", group: "string" }, + }, + { + datatype: "datetime", + expected: { affinity: "NUMERIC", group: "string" }, + }, + { + datatype: "TIMESTAMP", + expected: { affinity: "NUMERIC", group: "string" }, + }, + { datatype: "TIME", expected: { affinity: "NUMERIC", group: "string" } }, + + // other NUMERIC affinity declared types stay numeric. + { + datatype: "NUMERIC", + expected: { affinity: "NUMERIC", group: "numeric" }, + }, + { + datatype: "DECIMAL(10,5)", + expected: { affinity: "NUMERIC", group: "numeric" }, + }, + { + datatype: "BOOLEAN", + expected: { affinity: "NUMERIC", group: "numeric" }, + }, + + // non-NUMERIC affinities are untouched. + { datatype: "INT", expected: { affinity: "INTEGER", group: "numeric" } }, + { datatype: "REAL", expected: { affinity: "REAL", group: "numeric" } }, + { + datatype: "VARCHAR(255)", + expected: { affinity: "TEXT", group: "string" }, + }, + { datatype: "BLOB", expected: { affinity: "BLOB", group: "raw" } }, + { datatype: null, expected: { affinity: "BLOB", group: "raw" } }, + ])( + "should determine metadata for $datatype as $expected", + ({ datatype, expected }) => { + const actual = determineColumnMetadata(datatype); + + expect(actual).toEqual(expected); + }, + ); +}); diff --git a/data/sqlite-core/datatype.ts b/data/sqlite-core/datatype.ts index fbc580d3..310a5f7c 100644 --- a/data/sqlite-core/datatype.ts +++ b/data/sqlite-core/datatype.ts @@ -32,6 +32,35 @@ export const SQLITE_AFFINITY_TO_METADATA: Record< }, }; +/** + * Declared types like `date`, `datetime`, or `timestamp` fall through SQLite's + * affinity rules to NUMERIC, but their stored values are date/time strings. + */ +const DATE_LIKE_DECLARED_TYPE_REGEX = /DATE|TIME/; + +/** + * Resolves the affinity and Studio datatype metadata for a declared type. + * + * Date-like declared types (`date`, `datetime`, `timestamp`, ...) keep their + * NUMERIC affinity but are grouped as strings: their values are date/time + * text, and treating them as numbers would coerce edits to `NaN`. + */ +export function determineColumnMetadata( + declaredDataType: string | null, +): Pick & { affinity: SQLiteAffinity } { + const affinity = determineColumnAffinity(declaredDataType); + + if ( + affinity === "NUMERIC" && + declaredDataType && + DATE_LIKE_DECLARED_TYPE_REGEX.test(declaredDataType.toUpperCase()) + ) { + return { affinity, group: "string" }; + } + + return { affinity, ...SQLITE_AFFINITY_TO_METADATA[affinity] }; +} + /** * https://sqlite.org/datatype3.html#determination_of_column_affinity * diff --git a/data/sqlite-core/introspection.test.ts b/data/sqlite-core/introspection.test.ts index d1d23329..fd5b6c26 100644 --- a/data/sqlite-core/introspection.test.ts +++ b/data/sqlite-core/introspection.test.ts @@ -715,7 +715,7 @@ describe("sqlite-core/introspection", () => { "date": { "datatype": { "affinity": "NUMERIC", - "group": "numeric", + "group": "string", "isArray": false, "isNative": true, "name": "date", @@ -738,7 +738,7 @@ describe("sqlite-core/introspection", () => { "datetime": { "datatype": { "affinity": "NUMERIC", - "group": "numeric", + "group": "string", "isArray": false, "isNative": true, "name": "datetime", diff --git a/lib/conversionUtils.test.ts b/lib/conversionUtils.test.ts new file mode 100644 index 00000000..12dcdc49 --- /dev/null +++ b/lib/conversionUtils.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import type { Column } from "@/data"; + +import { coerceToValue } from "./conversionUtils"; + +function createColumn(datatype: Partial): Column { + return { + datatype: { + group: "numeric", + isArray: false, + isNative: true, + name: "NUMERIC", + options: [], + schema: "main", + ...datatype, + }, + defaultValue: null, + fkColumn: null, + fkSchema: null, + fkTable: null, + isAutoincrement: false, + isComputed: false, + isRequired: false, + name: "value", + nullable: true, + pkPosition: null, + schema: "main", + table: "things", + } as Column; +} + +describe("coerceToValue", () => { + it("coerces numeric text to a number for numeric columns", () => { + const column = createColumn({ affinity: "NUMERIC" }); + + expect(coerceToValue(column, "=", "42.5")).toBe(42.5); + }); + + it("coerces empty input to null for numeric columns", () => { + const column = createColumn({ affinity: "NUMERIC" }); + + expect(coerceToValue(column, "=", "")).toBeNull(); + }); + + it("keeps non-numeric text as-is instead of producing NaN", () => { + // SQLite `datetime` columns get NUMERIC affinity, but their values are + // date strings. Coercion must never produce NaN; non-numeric text stays + // text, matching SQLite's own NUMERIC affinity semantics. + const column = createColumn({ affinity: "NUMERIC", name: "datetime" }); + + expect(coerceToValue(column, "=", "2021-11-01 22:30:00")).toBe( + "2021-11-01 22:30:00", + ); + }); +}); diff --git a/lib/conversionUtils.ts b/lib/conversionUtils.ts index ce49ca43..4ab4adbd 100644 --- a/lib/conversionUtils.ts +++ b/lib/conversionUtils.ts @@ -52,7 +52,17 @@ export function coerceToValue( } if (dataTypeGroup === "numeric") { - return value === "" ? null : Number(value); + if (value === "") { + return null; + } + + const parsed = Number(value); + + // Only coerce input that actually parses as a number. Non-numeric text is + // kept as-is (never NaN) so e.g. date strings in SQLite NUMERIC-affinity + // columns survive, matching SQLite's own affinity semantics where + // non-numeric text is stored as TEXT. + return Number.isNaN(parsed) ? value : parsed; } return value; diff --git a/ui/studio/input/NumericInput.test.tsx b/ui/studio/input/NumericInput.test.tsx new file mode 100644 index 00000000..9bc4cee1 --- /dev/null +++ b/ui/studio/input/NumericInput.test.tsx @@ -0,0 +1,156 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { Column } from "@/data/adapter"; + +import { NumericInput } from "./NumericInput"; + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function createNumericAffinityColumn(): Column { + // Mirrors a SQLite column declared as e.g. `datetime`/`decimal`, which gets + // NUMERIC affinity and therefore the numeric input. + return { + datatype: { + affinity: "NUMERIC", + group: "numeric", + isArray: false, + isNative: true, + name: "decimal", + options: [], + schema: "main", + }, + defaultValue: null, + fkColumn: null, + fkSchema: null, + fkTable: null, + isAutoincrement: false, + isComputed: false, + isRequired: false, + name: "value", + nullable: true, + pkPosition: null, + schema: "main", + table: "things", + } as Column; +} + +function renderNumericInput(args: { + onSubmit: (value: unknown) => void; + value: unknown; +}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const input = container.querySelector("input"); + + if (!input) { + throw new Error("Expected numeric input element"); + } + + return { + cleanup() { + act(() => { + root.unmount(); + }); + container.remove(); + }, + input, + }; +} + +function inputText(element: HTMLInputElement, value: string) { + const valueSetter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set?.bind(element); + + valueSetter?.(value); + element.dispatchEvent(new Event("input", { bubbles: true })); +} + +function pressEnter(element: HTMLInputElement) { + element.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Enter", + }), + ); +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("NumericInput", () => { + it("submits numeric text as a number", () => { + const onSubmit = vi.fn(); + const harness = renderNumericInput({ onSubmit, value: 1 }); + + act(() => { + inputText(harness.input, "42.5"); + }); + act(() => { + pressEnter(harness.input); + }); + + expect(onSubmit).toHaveBeenCalledWith(42.5); + + harness.cleanup(); + }); + + it("submits non-numeric text as-is instead of NaN", () => { + // Reproduces prisma/studio#1361: typing a date-like string into a SQLite + // NUMERIC-affinity column must never be written as NaN. + const onSubmit = vi.fn(); + const harness = renderNumericInput({ + onSubmit, + value: "2021-11-01 21:30:00", + }); + + act(() => { + inputText(harness.input, "2021-11-01 22:30:00"); + }); + act(() => { + pressEnter(harness.input); + }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("2021-11-01 22:30:00"); + + harness.cleanup(); + }); + + it("submits the empty value when cleared", () => { + const onSubmit = vi.fn(); + const harness = renderNumericInput({ onSubmit, value: 1 }); + + act(() => { + inputText(harness.input, ""); + }); + act(() => { + pressEnter(harness.input); + }); + + expect(onSubmit).toHaveBeenCalledWith(null); + + harness.cleanup(); + }); +}); diff --git a/ui/studio/input/NumericInput.tsx b/ui/studio/input/NumericInput.tsx index 57fe55c6..61d2d854 100644 --- a/ui/studio/input/NumericInput.tsx +++ b/ui/studio/input/NumericInput.tsx @@ -10,7 +10,7 @@ export interface NumericInputProps { column: Column; context: "edit" | "insert"; onNavigate?: (direction: CellEditNavigationDirection) => void; - onSubmit: (value: number | null | undefined) => void; + onSubmit: (value: number | string | null | undefined) => void; readonly: boolean; showSaveAction?: boolean; value: unknown; @@ -54,7 +54,19 @@ export function NumericInput(props: NumericInputProps) { : currentValue; if (currentValueForComparison !== valueAsString) { - onSubmit(currentValue === "" ? emptyValue : Number(currentValue)); + if (currentValue === "") { + onSubmit(emptyValue); + + return true; + } + + const parsed = Number(currentValue); + + // Never submit NaN: non-numeric input is passed through as text. For + // SQLite NUMERIC-affinity columns (e.g. declared `decimal`) this + // matches SQLite semantics, and elsewhere the database rejects the + // value with a clear error instead of silently storing NaN. + onSubmit(Number.isNaN(parsed) ? currentValue : parsed); return true; } diff --git a/vitest.config.ts b/vitest.config.ts index 5b5ef45d..7d5e3b09 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -56,7 +56,7 @@ export default defineConfig({ environment: "node", exclude: [...configDefaults.exclude, ...localTestExcludes], fileParallelism: false, - include: ["data/**/*.test.ts"], + include: ["data/**/*.test.ts", "lib/**/*.test.ts"], name: "data", }, }, From 8dd1489610ecba635431b6716047b52d0be4fd6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Sat, 18 Jul 2026 16:21:01 +0700 Subject: [PATCH 2/2] Match date-like declared types as whole tokens The date-like check used a bare /DATE|TIME/ substring match, so NUMERIC-affinity declared types like CANDIDATE, DATED, or RUNTIME were misclassified as date-like. Use word-boundary token matching instead, which still covers modifiers like DATETIME(6) and TIMESTAMP WITH TIME ZONE, and add near-miss regression cases. Co-Authored-By: Claude Fable 5 --- data/sqlite-core/datatype.test.ts | 28 ++++++++++++++++++++++++++++ data/sqlite-core/datatype.ts | 6 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/data/sqlite-core/datatype.test.ts b/data/sqlite-core/datatype.test.ts index 11b2a5b0..7d351b09 100644 --- a/data/sqlite-core/datatype.test.ts +++ b/data/sqlite-core/datatype.test.ts @@ -70,6 +70,34 @@ describe("determineColumnMetadata", () => { expected: { affinity: "NUMERIC", group: "string" }, }, { datatype: "TIME", expected: { affinity: "NUMERIC", group: "string" } }, + { + datatype: "DATETIME(6)", + expected: { affinity: "NUMERIC", group: "string" }, + }, + { + datatype: "TIMESTAMP WITH TIME ZONE", + expected: { affinity: "NUMERIC", group: "string" }, + }, + + // NUMERIC-affinity declared types that merely contain a date/time + // substring are not date-like and stay numeric. + { + datatype: "CANDIDATE", + expected: { affinity: "NUMERIC", group: "numeric" }, + }, + { datatype: "DATED", expected: { affinity: "NUMERIC", group: "numeric" } }, + { + datatype: "RUNTIME", + expected: { affinity: "NUMERIC", group: "numeric" }, + }, + { + datatype: "LIFETIME", + expected: { affinity: "NUMERIC", group: "numeric" }, + }, + { + datatype: "TIMES", + expected: { affinity: "NUMERIC", group: "numeric" }, + }, // other NUMERIC affinity declared types stay numeric. { diff --git a/data/sqlite-core/datatype.ts b/data/sqlite-core/datatype.ts index 310a5f7c..f13dace1 100644 --- a/data/sqlite-core/datatype.ts +++ b/data/sqlite-core/datatype.ts @@ -35,8 +35,12 @@ export const SQLITE_AFFINITY_TO_METADATA: Record< /** * Declared types like `date`, `datetime`, or `timestamp` fall through SQLite's * affinity rules to NUMERIC, but their stored values are date/time strings. + * + * Only whole tokens count (`DATETIME(6)`, `TIMESTAMP WITH TIME ZONE`), so + * NUMERIC-affinity declared types that merely contain such a substring + * (`CANDIDATE`, `DATED`, `RUNTIME`) are not misclassified as date-like. */ -const DATE_LIKE_DECLARED_TYPE_REGEX = /DATE|TIME/; +const DATE_LIKE_DECLARED_TYPE_REGEX = /\b(?:DATE|DATETIME|TIME|TIMESTAMP)\b/; /** * Resolves the affinity and Studio datatype metadata for a declared type.