Skip to content

Commit 99b26ca

Browse files
committed
test(rest): a number field refuses a non-number at every REST write door on SQLite (#20309)
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 983b866 commit 99b26ca

1 file changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20309 — a number field refuses a non-number at every REST write door, on a
5+
* real engine (`ObjectQL` + sqlite `SqlDriver`) and the real `RestServer`
6+
* routes (the harness `rest-data-blank-typed-value.test.ts` boots).
7+
*
8+
* Measured on `origin/main` c74de10a94 with this harness: `POST /data/:object`
9+
* with `[500]` on a number field answered 201, and SQLite stored the TEXT
10+
* `'[500]'`, which `GET` returned as the string `"[500]"`. `[]` stored `'[]'`,
11+
* `true` stored `1`, and `'0x10'` stored the TEXT `'0x10'` (read back as
12+
* `16`). Every one of those now answers `400 VALIDATION_FAILED` with the field
13+
* code `invalid_number`, and no row is written or changed.
14+
*
15+
* The PHYSICAL column is read with the driver's own query builder and SQLite's
16+
* `typeof()`, past every engine read coercion, because the read repair makes a
17+
* stored TEXT `'0x10'` look like the number `16`.
18+
*
19+
* Controls: `[5, 7]` was already refused and still is; a JS number is stored as
20+
* a SQLite `real` (`integer` on `rating`) and read back unchanged; a blank is
21+
* still stored as `null` (#20308).
22+
*/
23+
24+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
25+
import { COMPUTED_VALUE_TYPES, NUMERIC_VALUE_TYPES } from '@objectstack/spec/data';
26+
import { ObjectQL } from '@objectstack/objectql';
27+
import { SqlDriver } from '@objectstack/driver-sql';
28+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
29+
import { RestServer } from './rest-server';
30+
31+
const JUDGED = [...NUMERIC_VALUE_TYPES].filter((t) => !COMPUTED_VALUE_TYPES.has(t));
32+
const f = (t: string) => `f_${t}`;
33+
34+
const OBJ = {
35+
name: 'num_rest', label: 'Number', systemFields: false,
36+
fields: {
37+
id: { name: 'id', type: 'text' as const, primaryKey: true },
38+
...Object.fromEntries(JUDGED.map((t) => [f(t), { name: f(t), type: t }])),
39+
},
40+
};
41+
42+
/** The card's table and the coercions it named. */
43+
const REFUSED: ReadonlyArray<readonly [string, unknown]> = [
44+
['[500]', [500]],
45+
['[5, 7]', [5, 7]],
46+
['[]', []],
47+
['true', true],
48+
["'0x10'", '0x10'],
49+
["' 12 '", ' 12 '],
50+
];
51+
52+
const liveEngines: ObjectQL[] = [];
53+
afterEach(async () => {
54+
while (liveEngines.length) {
55+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
56+
}
57+
});
58+
59+
function createMockServer() {
60+
const noop = () => {};
61+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
62+
}
63+
64+
function makeRes() {
65+
const res: any = {
66+
write: () => true, end: () => {},
67+
header: () => res,
68+
status: (code: number) => { res._status = code; return res; },
69+
json: (body: any) => { res._json = body; return res; },
70+
};
71+
return res;
72+
}
73+
74+
async function boot() {
75+
const engine = new ObjectQL();
76+
liveEngines.push(engine);
77+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
78+
engine.registerDriver(driver, true);
79+
await engine.init();
80+
engine.registry.registerObject(OBJ as any);
81+
await engine.syncSchemas();
82+
83+
const protocol = new ObjectStackProtocolImplementation(engine as any);
84+
const rest = new RestServer(createMockServer() as any, protocol as any, {
85+
api: { requireAuth: false }, batch: { enableBatchEndpoint: true },
86+
} as any);
87+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
88+
rest.registerRoutes();
89+
const call = async (method: string, path: string, params: Record<string, string>, body: unknown) => {
90+
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
91+
expect(route, `${method} ${path}`).toBeDefined();
92+
const res = makeRes();
93+
await route!.handler({ params, body, query: {}, headers: {} } as any, res);
94+
return { status: res._status ?? 200, body: res._json };
95+
};
96+
/** The physical cell and its SQLite storage class, read past every engine read coercion. */
97+
const cell = async (id: string, col: string) => {
98+
const rows = await (driver as any).knex.raw(`select "${col}" as v, typeof("${col}") as c from "num_rest" where id = ?`, [id]);
99+
return rows[0] as { v: unknown; c: string } | undefined;
100+
};
101+
return { engine, call, cell };
102+
}
103+
104+
describe('REST write doors on SQLite: a number field refuses a non-number (#20309)', () => {
105+
let ctx: Awaited<ReturnType<typeof boot>>;
106+
beforeEach(async () => { ctx = await boot(); });
107+
108+
const expectRefused = (res: { status: number; body: any }, field: string) => {
109+
expect(res.status).toBe(400);
110+
expect(res.body).toMatchObject({ code: 'VALIDATION_FAILED' });
111+
expect(res.body.fields.map((x: any) => [x.field, x.code])).toEqual([[field, 'invalid_number']]);
112+
};
113+
const expectRowRefused = (res: { status: number; body: any }) => {
114+
expect(res.body.results.map((r: any) => [r.success, r.errors?.[0]?.code])).toEqual([[false, 'VALIDATION_FAILED']]);
115+
};
116+
117+
describe.each(JUDGED)('%s', (type) => {
118+
const col = f(type);
119+
120+
it.each(REFUSED)('%s: POST and batch create write no row; PATCH, batch update and updateMany leave the stored number', async (_l, value) => {
121+
expectRefused(await ctx.call('POST', '/api/v1/data/:object', { object: 'num_rest' }, { id: 'c1', [col]: structuredClone(value) }), col);
122+
expect(await ctx.cell('c1', col)).toBeUndefined();
123+
124+
expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/batch', { object: 'num_rest' },
125+
{ operation: 'create', records: [{ data: { id: 'c2', [col]: structuredClone(value) } }] }));
126+
expect(await ctx.cell('c2', col)).toBeUndefined();
127+
128+
await ctx.engine.insert('num_rest', { id: 'u1', [col]: 7 });
129+
expectRefused(await ctx.call('PATCH', '/api/v1/data/:object/:id', { object: 'num_rest', id: 'u1' }, { [col]: structuredClone(value) }), col);
130+
expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/batch', { object: 'num_rest' },
131+
{ operation: 'update', records: [{ id: 'u1', data: { [col]: structuredClone(value) } }] }));
132+
expectRowRefused(await ctx.call('POST', '/api/v1/data/:object/updateMany', { object: 'num_rest' },
133+
{ records: [{ id: 'u1', data: { [col]: structuredClone(value) } }] }));
134+
expect((await ctx.cell('u1', col))?.v).toBe(7);
135+
});
136+
137+
it('CONTROL: a JS number is stored as a number and read back unchanged, through POST and PATCH', async () => {
138+
const created = await ctx.call('POST', '/api/v1/data/:object', { object: 'num_rest' }, { id: 'n1', [col]: 500 });
139+
expect(created.status).toBe(201);
140+
expect(await ctx.cell('n1', col)).toEqual({ v: 500, c: type === 'rating' ? 'integer' : 'real' });
141+
const patched = await ctx.call('PATCH', '/api/v1/data/:object/:id', { object: 'num_rest', id: 'n1' }, { [col]: 12.5 });
142+
expect(patched.status).toBe(200);
143+
expect(await ctx.cell('n1', col)).toEqual({ v: 12.5, c: 'real' });
144+
const read = await ctx.engine.findOne('num_rest', { where: { id: 'n1' } }) as Record<string, unknown>;
145+
expect(read[col]).toBe(12.5);
146+
});
147+
});
148+
149+
it('CONTROL: a blank is still stored as null (#20308)', async () => {
150+
const res = await ctx.call('POST', '/api/v1/data/:object', { object: 'num_rest' }, { id: 'e1', ...Object.fromEntries(JUDGED.map((t) => [f(t), ''])) });
151+
expect(res.status).toBe(201);
152+
for (const t of JUDGED) expect(await ctx.cell('e1', f(t)), t).toEqual({ v: null, c: 'null' });
153+
});
154+
});

0 commit comments

Comments
 (0)