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
11 changes: 11 additions & 0 deletions .changeset/field-type-fidelity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/driver-sql": patch
---

fix(driver-sql): round-trip rating/slider/toggle/progress with type fidelity

`rating`/`slider`/`toggle`/`progress` had no case in the DDL column-type switch, so they fell to `default → table.string` (TEXT affinity). SQLite then coerced the written value to a string — `rating: 4` read back `'4'`, `toggle: true` read back `'1'` — so the value persisted but the JS type leaked on read. On a low-code platform where field types are author-driven, a field that silently returns the wrong type is a runtime-fidelity trap the static gates and value-loss tests don't catch.

- `rating`/`slider`/`progress` now map to a REAL (numeric) column.
- `toggle` maps to a boolean column and is registered in the boolean read-coercion path, so stored `1`/`0` come back as real JS booleans.
- The object-valued `record`/`video`/`audio` types are folded into the shared `JSON_COLUMN_TYPES` source, and the DDL `default` case now derives JSON-vs-string from that set, so the column-type switch and `isJsonField` (the read-side deserializer) can no longer drift.
6 changes: 3 additions & 3 deletions packages/dogfood/test/field-zoo-roundtrip.dogfood.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ const MATRIX: FieldCase[] = [
{ field: 'f_number', type: 'number', check: { kind: 'equal', write: 42 } },
{ field: 'f_currency', type: 'currency', check: { kind: 'equal', write: 1234.56 } },
{ field: 'f_percent', type: 'percent', check: { kind: 'equal', write: 75 } },
{ field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 }, xfail: true },
{ field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 }, xfail: true },
{ field: 'f_rating', type: 'rating', check: { kind: 'equal', write: 4 } },
{ field: 'f_slider', type: 'slider', check: { kind: 'equal', write: 25 } },
// temporal — f_time is a #2004 fix (time-of-day)
{ field: 'f_date', type: 'date', check: { kind: 'equal', write: '2024-03-15' } },
{ field: 'f_time', type: 'time', check: { kind: 'equal', write: '14:30:00' } },
// logic
{ field: 'f_boolean', type: 'boolean', check: { kind: 'equal', write: true } },
{ field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true }, xfail: true },
{ field: 'f_toggle', type: 'toggle', check: { kind: 'equal', write: true } },
// scalar selection
{ field: 'f_select', type: 'select', check: { kind: 'equal', write: 'high' } },
{ field: 'f_radio', type: 'radio', check: { kind: 'equal', write: 'yes' } },
Expand Down
130 changes: 130 additions & 0 deletions packages/plugins/driver-sql/src/sql-driver-numeric-fidelity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Scalar field types must round-trip with TYPE fidelity, not just value
* survival. Regression (#field-zoo): `rating`/`slider`/`toggle`/`progress`
* were absent from the DDL column-type switch, so they fell to
* `default → table.string` and got TEXT affinity — SQLite then coerced the
* written number/boolean to a string ('4' not 4, '1' not true). The value
* persisted, so value-loss tests stayed green; only the JS type leaked, which
* is a runtime-fidelity trap on a low-code platform where an AI authors
* arbitrary field types.
*
* The fix maps `rating`/`slider`/`progress` to a REAL (numeric) column and
* `toggle` to a boolean column + the `booleanFields` read-coercion registry,
* and folds the object-valued `record`/`video`/`audio` into the shared
* `JSON_COLUMN_TYPES` source so they store/parse as JSON like `composite`.
*
* `number`/`currency`/`percent`/`boolean` are included as the working control:
* the SAME harness must keep returning them with correct types.
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { SqlDriver } from '../src/index.js';

describe('SqlDriver scalar type fidelity (rating/slider/toggle/progress)', () => {
let driver: SqlDriver;

beforeEach(async () => {
driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.initObjects([
{
name: 'zoo',
fields: {
name: { type: 'string' },
// control — these already round-tripped with correct types
f_number: { type: 'number' },
f_currency: { type: 'currency' },
f_percent: { type: 'percent' },
f_boolean: { type: 'boolean' },
// the leak: numeric scalars that used to land as TEXT
f_rating: { type: 'rating', max: 5 },
f_slider: { type: 'slider', min: 0, max: 100, step: 5 },
f_progress: { type: 'progress', min: 0, max: 100 },
// the leak: a switch-rendered boolean
f_toggle: { type: 'toggle' },
// object-valued types folded into JSON_COLUMN_TYPES
f_record: { type: 'record' },
f_video: { type: 'video' },
f_audio: { type: 'audio' },
},
},
]);
});

afterEach(async () => {
await driver.disconnect();
});

it('round-trips numeric scalars as numbers, not strings', async () => {
await driver.create(
'zoo',
{
id: 'z1',
name: 'Specimen',
f_number: 42,
f_currency: 1234.56,
f_percent: 75,
f_rating: 4,
f_slider: 25,
f_progress: 60,
},
{ bypassTenantAudit: true },
);
const row = await driver.findOne('zoo', 'z1', { bypassTenantAudit: true });

// control
expect(row.f_number).toBe(42);
expect(row.f_currency).toBeCloseTo(1234.56, 5);
expect(row.f_percent).toBe(75);

// the fix: these used to come back as '4' / '25' / '60'
expect(typeof row.f_rating).toBe('number');
expect(row.f_rating).toBe(4);
expect(typeof row.f_slider).toBe('number');
expect(row.f_slider).toBe(25);
expect(typeof row.f_progress).toBe('number');
expect(row.f_progress).toBe(60);
});

it('round-trips toggle as a boolean, not 1/0 or a string', async () => {
await driver.create('zoo', { id: 'z2', name: 'B', f_boolean: true, f_toggle: true }, { bypassTenantAudit: true });
await driver.create('zoo', { id: 'z3', name: 'C', f_boolean: false, f_toggle: false }, { bypassTenantAudit: true });

const on = await driver.findOne('zoo', 'z2', { bypassTenantAudit: true });
const off = await driver.findOne('zoo', 'z3', { bypassTenantAudit: true });

// control
expect(on.f_boolean).toBe(true);
expect(off.f_boolean).toBe(false);

// the fix: toggle used to come back as '1' / '0'
expect(typeof on.f_toggle).toBe('boolean');
expect(on.f_toggle).toBe(true);
expect(typeof off.f_toggle).toBe('boolean');
expect(off.f_toggle).toBe(false);
});

it('round-trips object-valued record/video/audio as objects, not strings', async () => {
await driver.create(
'zoo',
{
id: 'z4',
name: 'D',
f_record: { home: '+1', work: '+2' },
f_video: { url: 'https://cdn/v.mp4', duration: 12 },
f_audio: { url: 'https://cdn/a.mp3', duration: 30 },
},
{ bypassTenantAudit: true },
);
const row = await driver.findOne('zoo', 'z4', { bypassTenantAudit: true });

expect(row.f_record).toEqual({ home: '+1', work: '+2' });
expect(row.f_video).toEqual({ url: 'https://cdn/v.mp4', duration: 12 });
expect(row.f_audio).toEqual({ url: 'https://cdn/a.mp3', duration: 30 });
});
});
51 changes: 29 additions & 22 deletions packages/plugins/driver-sql/src/sql-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ const GLOBAL_TENANT = '__global__';
* the drift between them is exactly what let array-valued fields (multiselect/
* checkboxes/tags/repeater/vector) reach the SQLite binder un-serialized and
* crash with "SQLite3 can only bind numbers, strings, bigints, buffers, and
* null" (#field-zoo). `image`/`file`/`avatar` hold structured upload metadata;
* `composite`/`address`/`location` are objects; the rest are arrays.
* null" (#field-zoo). `image`/`file`/`avatar`/`video`/`audio` hold structured
* upload metadata; `composite`/`address`/`location`/`record` are objects; the
* rest are arrays.
*/
const JSON_COLUMN_TYPES = new Set<string>([
'json', 'object', 'array',
'image', 'file', 'avatar',
'json', 'object', 'array', 'record',
'image', 'file', 'avatar', 'video', 'audio',
'location', 'address', 'composite',
'multiselect', 'checkboxes', 'tags', 'repeater', 'vector',
]);
Expand Down Expand Up @@ -1115,7 +1116,10 @@ export class SqlDriver implements IDataDriver {
if (this.isJsonField(type, field)) {
jsonCols.push(name);
}
if (type === 'boolean') {
// `toggle` shares boolean storage/affinity, so it needs the same
// read coercion (stored 1/0 → JS true/false) or it leaks back as a
// number/string instead of a boolean (#field-zoo).
if (type === 'boolean' || type === 'toggle') {
booleanCols.push(name);
}
if (type === 'date') {
Expand Down Expand Up @@ -1841,9 +1845,23 @@ export class SqlDriver implements IDataDriver {
case 'number':
case 'currency':
case 'percent':
// `rating`/`slider`/`progress` are authored as numeric scalars (a star
// count, a slider position, a percent-of-completion). Without an explicit
// case they fell to `default → table.string`, giving the column TEXT
// affinity so SQLite coerced the written number to a string ('4' not 4) —
// a silent type-fidelity leak the value-loss tests didn't catch. REAL
// affinity round-trips them as JS numbers (#field-zoo).
case 'rating':
case 'slider':
case 'progress':
col = table.float(name);
break;
// `toggle` is a boolean rendered as a switch. Same leak as above (TEXT
// affinity stored '1'); a boolean column gives NUMERIC affinity and the
// `booleanFields` read-coercion below converts the stored 1/0 back to a
// real JS boolean.
case 'boolean':
case 'toggle':
col = table.boolean(name);
break;
case 'date':
Expand All @@ -1855,22 +1873,6 @@ export class SqlDriver implements IDataDriver {
case 'time':
col = table.time(name);
break;
case 'json':
case 'object':
case 'array':
case 'image':
case 'file':
case 'avatar':
case 'location':
case 'address':
case 'composite':
case 'multiselect':
case 'checkboxes':
case 'tags':
case 'repeater':
case 'vector':
col = table.json(name);
break;
case 'lookup':
col = table.string(name);
if (field.reference_to) {
Expand All @@ -1887,7 +1889,12 @@ export class SqlDriver implements IDataDriver {
case 'formula':
return; // Virtual — no column
default:
col = table.string(name);
// Array/object-valued types are stored as a JSON column. Driven by the
// single `JSON_COLUMN_TYPES` source so this DDL switch and `isJsonField`
// (the read-side deserializer) can never drift — the drift between them
// is exactly what let array-valued fields reach the binder un-serialized
// (#field-zoo). Everything else is a plain string.
col = JSON_COLUMN_TYPES.has(type) ? table.json(name) : table.string(name);
}

if (col) {
Expand Down
Loading