Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/sqlite-date-like-columns-text.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions Architecture/cell-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 60 additions & 2 deletions data/sqlite-core/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ describe("sqlite-core/adapter", () => {
"date": {
"datatype": {
"affinity": "NUMERIC",
"group": "numeric",
"group": "string",
"isArray": false,
"isNative": true,
"name": "date",
Expand All @@ -249,7 +249,7 @@ describe("sqlite-core/adapter", () => {
"datetime": {
"datatype": {
"affinity": "NUMERIC",
"group": "numeric",
"group": "string",
"isArray": false,
"isNative": true,
"name": "datetime",
Expand Down Expand Up @@ -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)');
Expand Down
10 changes: 3 additions & 7 deletions data/sqlite-core/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -419,8 +416,7 @@ function createIntrospection(args: {

columnsRecord[columnName] = {
datatype: {
...SQLITE_AFFINITY_TO_METADATA[affinity],
affinity,
...metadata,
isArray: false,
isNative: true,
name: datatype,
Expand Down
82 changes: 81 additions & 1 deletion data/sqlite-core/datatype.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { determineColumnAffinity } from "./datatype";
import { determineColumnAffinity, determineColumnMetadata } from "./datatype";

describe("determineColumnAffinity", () => {
it.each([
Expand Down Expand Up @@ -51,3 +51,83 @@ 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" } },
{
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.
{
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);
},
);
});
33 changes: 33 additions & 0 deletions data/sqlite-core/datatype.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,39 @@ 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 = /\b(?:DATE|DATETIME|TIME|TIMESTAMP)\b/;

/**
* 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<DataType, "format" | "group"> & { 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
*
Expand Down
4 changes: 2 additions & 2 deletions data/sqlite-core/introspection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ describe("sqlite-core/introspection", () => {
"date": {
"datatype": {
"affinity": "NUMERIC",
"group": "numeric",
"group": "string",
"isArray": false,
"isNative": true,
"name": "date",
Expand All @@ -738,7 +738,7 @@ describe("sqlite-core/introspection", () => {
"datetime": {
"datatype": {
"affinity": "NUMERIC",
"group": "numeric",
"group": "string",
"isArray": false,
"isNative": true,
"name": "datetime",
Expand Down
56 changes: 56 additions & 0 deletions lib/conversionUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";

import type { Column } from "@/data";

import { coerceToValue } from "./conversionUtils";

function createColumn(datatype: Partial<Column["datatype"]>): 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",
);
});
});
12 changes: 11 additions & 1 deletion lib/conversionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading