Skip to content

Commit e7ef2db

Browse files
committed
test: pin the blank-typed-value write door on every engine and REST door
Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd3dd08 commit e7ef2db

3 files changed

Lines changed: 579 additions & 0 deletions

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20308 — a BLANK on a non-string-typed column reaches the driver as `null`,
5+
* on every engine write door.
6+
*
7+
* ## The defect
8+
*
9+
* `validateOne` reads `''` (and whitespace) as missing and returns before its
10+
* type checks, and nothing rewrote the value, so the driver received it as
11+
* sent. Measured on `origin/main` de091b50e6 through the real engine: memory
12+
* and SQLite stored `''` in number, currency, percent, rating, slider,
13+
* progress, summary, boolean, toggle, date, datetime and time columns on every
14+
* door (insert, insert[], insertMany, update by id, update by predicate), and
15+
* PostgreSQL refused the statement (`22P02` / `22007`; `500 DATABASE_ERROR` at
16+
* REST). One clear, three outcomes.
17+
*
18+
* ## What this file pins
19+
*
20+
* What the DRIVER receives, on each door — the one thing every backend then
21+
* stores (memory and MongoDB store it verbatim; the SQL physical column is
22+
* `packages/rest/src/rest-data-blank-typed-value.test.ts`). And the four
23+
* interactions the door's placement decides:
24+
*
25+
* - `required` refuses a blank exactly as it refuses `null` (same code);
26+
* - on insert a blank takes a declared `defaultValue` exactly as `null` does
27+
* (#2706) — the door runs BEFORE the defaults;
28+
* - a `readonlyWhen` lock still holds against a caller's blank — the door
29+
* runs BEFORE the caller-value snapshot the lock judges against;
30+
* - the dry run (`validate()`) agrees with the write.
31+
*
32+
* The string-stored controls (`text`, `lookup`, `select`) keep their `''` —
33+
* the spec's `str_empty` side of the line.
34+
*/
35+
36+
import { describe, it, expect, beforeEach } from 'vitest';
37+
import { NON_TEXT_STORED_VALUE_TYPES } from '@objectstack/spec/data';
38+
import { ObjectQL } from './engine.js';
39+
import { ValidationError } from './validation/record-validator.js';
40+
41+
const TYPED = [...NON_TEXT_STORED_VALUE_TYPES];
42+
const f = (t: string) => `f_${t}`;
43+
44+
interface Call { fn: string; data: Record<string, unknown> }
45+
46+
function makeStubDriver() {
47+
const calls: Call[] = [];
48+
const rows = new Map<string, Record<string, unknown>>();
49+
const idOf = (q: any): string | undefined => {
50+
const w = q?.where ?? q?.filter ?? q;
51+
const id = w?.id;
52+
return typeof id === 'string' ? id : undefined;
53+
};
54+
let n = 0;
55+
const driver: any = {
56+
name: 'stub', version: '0.0.0', supports: {},
57+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
58+
async find(_o: string, q: any) {
59+
const id = idOf(q);
60+
const inIds = q?.where?.id?.$in as string[] | undefined;
61+
const all = [...rows.values()];
62+
if (id) return all.filter((r) => r.id === id);
63+
if (inIds) return all.filter((r) => inIds.includes(r.id as string));
64+
return all;
65+
},
66+
async findOne(_o: string, q: any) {
67+
const id = idOf(q);
68+
return (id ? rows.get(id) : rows.values().next().value) ?? null;
69+
},
70+
async count() { return rows.size; },
71+
async create(_o: string, data: Record<string, unknown>) {
72+
calls.push({ fn: 'create', data: { ...data } });
73+
const row = { ...data, id: (data.id as string) ?? `r${++n}` };
74+
rows.set(row.id as string, row);
75+
return row;
76+
},
77+
async bulkCreate(o: string, list: Record<string, unknown>[]) {
78+
calls.push({ fn: 'bulkCreate', data: { rows: list.map((r) => ({ ...r })) } });
79+
const out: Record<string, unknown>[] = [];
80+
for (const r of list) {
81+
const row = { ...r, id: (r.id as string) ?? `r${++n}` };
82+
rows.set(row.id as string, row);
83+
out.push(row);
84+
}
85+
return out;
86+
},
87+
async update(_o: string, id: string, data: Record<string, unknown>) {
88+
calls.push({ fn: 'update', data: { ...data } });
89+
const row = { ...(rows.get(id) ?? {}), ...data, id };
90+
rows.set(id, row);
91+
return row;
92+
},
93+
async updateMany(_o: string, _ast: unknown, data: Record<string, unknown>) {
94+
calls.push({ fn: 'updateMany', data: { ...data } });
95+
return rows.size;
96+
},
97+
async upsert(o: string, data: Record<string, unknown>) { return this.create(o, data); },
98+
async delete() { return true; },
99+
async bulkUpdate() { return []; }, async bulkDelete() {},
100+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
101+
async commit() {}, async rollback() {},
102+
};
103+
return { driver, calls, rows };
104+
}
105+
106+
const OBJ = {
107+
name: 'blank_door',
108+
label: 'Blank door',
109+
fields: {
110+
id: { name: 'id', type: 'text' as const, primaryKey: true },
111+
c_text: { name: 'c_text', type: 'text' as const },
112+
c_lookup: { name: 'c_lookup', type: 'lookup' as const, reference: 'blank_door' },
113+
c_select: { name: 'c_select', type: 'select' as const, options: [{ value: 'a', label: 'A' }] },
114+
...Object.fromEntries(TYPED.map((t) => [f(t), { name: f(t), type: t }])),
115+
},
116+
};
117+
118+
const REQ = {
119+
name: 'blank_req',
120+
label: 'Blank required',
121+
fields: {
122+
id: { name: 'id', type: 'text' as const, primaryKey: true },
123+
status: { name: 'status', type: 'text' as const },
124+
r_number: { name: 'r_number', type: 'number' as const, required: true },
125+
rd_number: { name: 'rd_number', type: 'number' as const, required: true, defaultValue: 5 },
126+
od_date: { name: 'od_date', type: 'date' as const, defaultValue: '2020-01-01' },
127+
locked_amount: { name: 'locked_amount', type: 'number' as const, readonlyWhen: "record.status == 'closed'" },
128+
},
129+
};
130+
131+
const VALID: Record<string, unknown> = {
132+
f_number: 0, f_currency: 9.5, f_percent: 0.25, f_rating: 3, f_slider: 10, f_progress: 50, f_summary: 7,
133+
f_boolean: false, f_toggle: true, f_date: '2026-09-27', f_datetime: '2026-09-27T10:00:00.000Z', f_time: '14:30:00',
134+
};
135+
136+
const blankTyped = (v: string) => Object.fromEntries(TYPED.map((t) => [f(t), v]));
137+
const STR_BLANK = { c_text: '', c_lookup: '', c_select: '' };
138+
139+
function expectTypedNull(data: Record<string, unknown>) {
140+
for (const t of TYPED) expect(data[f(t)], t).toBeNull();
141+
}
142+
143+
describe('engine write doors: a blank on a non-string-typed column reaches the driver as null (#20308)', () => {
144+
let engine: ObjectQL;
145+
let stub: ReturnType<typeof makeStubDriver>;
146+
147+
beforeEach(async () => {
148+
stub = makeStubDriver();
149+
engine = new ObjectQL();
150+
engine.registerDriver(stub.driver, true);
151+
await engine.init();
152+
engine.registry.registerObject(OBJ as any);
153+
engine.registry.registerObject(REQ as any);
154+
});
155+
156+
const last = (fn: string) => [...stub.calls].reverse().find((c) => c.fn === fn)!.data;
157+
158+
it.each([[''], [' ']])('insert, one row: %j → null; the string-stored controls keep their blank', async (blank) => {
159+
const payload = { id: 'a', ...blankTyped(blank), ...STR_BLANK };
160+
await engine.insert('blank_door', payload);
161+
const sent = last('create');
162+
expectTypedNull(sent);
163+
expect([sent.c_text, sent.c_lookup, sent.c_select]).toEqual(['', '', '']);
164+
// The caller's own object is never rewritten.
165+
expect(payload.f_number).toBe(blank);
166+
});
167+
168+
it('insert, an array of rows and insertMany: every row', async () => {
169+
await engine.insert('blank_door', [{ id: 'b1', ...blankTyped('') }, { id: 'b2', ...blankTyped('') }]);
170+
const sent = [...stub.calls].filter((c) => c.fn === 'create' || c.fn === 'bulkCreate');
171+
const written = sent.flatMap((c) => (c.fn === 'bulkCreate' ? (c.data.rows as Record<string, unknown>[]) : [c.data]));
172+
expect(written.map((r) => r.id)).toEqual(['b1', 'b2']);
173+
for (const r of written) expectTypedNull(r);
174+
175+
stub.calls.length = 0;
176+
const outcomes = await engine.insertMany('blank_door', [{ id: 'm1', ...blankTyped('') }]);
177+
expect(outcomes.map((o) => o.ok)).toEqual([true]);
178+
const many = [...stub.calls].filter((c) => c.fn === 'create' || c.fn === 'bulkCreate')
179+
.flatMap((c) => (c.fn === 'bulkCreate' ? (c.data.rows as Record<string, unknown>[]) : [c.data]));
180+
expect(many).toHaveLength(1);
181+
expectTypedNull(many[0]!);
182+
});
183+
184+
it('update by id and update by predicate', async () => {
185+
await engine.insert('blank_door', { id: 'u1', ...VALID });
186+
await engine.update('blank_door', { id: 'u1', ...blankTyped(''), ...STR_BLANK });
187+
const byId = last('update');
188+
expectTypedNull(byId);
189+
expect([byId.c_text, byId.c_lookup, byId.c_select]).toEqual(['', '', '']);
190+
191+
await engine.update('blank_door', blankTyped(''), { where: { id: { $in: ['u1'] } }, multi: true } as any);
192+
expectTypedNull(last('updateMany'));
193+
});
194+
195+
it('a valid value on every typed column — falsy ones included — is unchanged', async () => {
196+
await engine.insert('blank_door', { id: 'v1', ...VALID });
197+
const sent = last('create');
198+
for (const [k, v] of Object.entries(VALID)) expect(sent[k], k).toStrictEqual(v);
199+
});
200+
201+
it('required refuses a blank exactly as it refuses null — insert and update', async () => {
202+
const codes = async (fn: () => Promise<unknown>) => {
203+
try { await fn(); return null; } catch (e) {
204+
expect(e).toBeInstanceOf(ValidationError);
205+
return { code: (e as ValidationError).code, fields: (e as ValidationError).fields.map((x) => [x.field, x.code]) };
206+
}
207+
};
208+
const blank = await codes(() => engine.insert('blank_req', { id: 'q1', r_number: '' }));
209+
const nul = await codes(() => engine.insert('blank_req', { id: 'q2', r_number: null }));
210+
expect(blank).toEqual({ code: 'VALIDATION_FAILED', fields: [['r_number', 'required']] });
211+
expect(blank).toEqual(nul);
212+
213+
await engine.insert('blank_req', { id: 'q3', r_number: 1 });
214+
const cleared = await codes(() => engine.update('blank_req', { id: 'q3', r_number: '' }));
215+
expect(cleared).toEqual({ code: 'VALIDATION_FAILED', fields: [['r_number', 'required']] });
216+
});
217+
218+
it('on insert a blank takes the declared defaultValue exactly as null does — required or not', async () => {
219+
await engine.insert('blank_req', { id: 'd1', r_number: 1, rd_number: '', od_date: '' });
220+
const blank = last('create');
221+
await engine.insert('blank_req', { id: 'd2', r_number: 1, rd_number: null, od_date: null });
222+
const nul = last('create');
223+
expect([blank.rd_number, blank.od_date]).toEqual([5, '2020-01-01']);
224+
expect([nul.rd_number, nul.od_date]).toEqual([5, '2020-01-01']);
225+
});
226+
227+
it('the dry run agrees with the write on the same blank', async () => {
228+
const preview = await engine.validate('blank_req', { id: 'p1', r_number: 1, rd_number: '' });
229+
expect(preview.valid).toBe(true);
230+
const refused = await engine.validate('blank_req', { id: 'p2', r_number: '' });
231+
expect(refused.valid).toBe(false);
232+
expect(refused.results?.[0]?.errors.map((e: any) => [e.field, e.code])).toEqual([['r_number', 'required']]);
233+
});
234+
235+
it('a readonlyWhen lock still holds against a caller blank — the snapshot it judges is the normalised one', async () => {
236+
await engine.insert('blank_req', { id: 'l1', r_number: 1, status: 'closed', locked_amount: 10 });
237+
await engine.update('blank_req', { id: 'l1', locked_amount: '' });
238+
const sent = last('update');
239+
expect('locked_amount' in sent).toBe(false);
240+
expect(stub.rows.get('l1')?.locked_amount).toBe(10);
241+
242+
// Unlocked, the same blank clears the column to null.
243+
await engine.insert('blank_req', { id: 'l2', r_number: 1, status: 'open', locked_amount: 10 });
244+
await engine.update('blank_req', { id: 'l2', locked_amount: '' });
245+
expect(last('update').locked_amount).toBeNull();
246+
});
247+
});
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20308 — the write door's reading of a BLANK string, and the numeric type
5+
* door, at the validator module.
6+
*
7+
* `normalizeBlankTypedValues` rewrites `''` / whitespace on a non-string-typed
8+
* column to `null` and leaves everything else alone. Both populations are read
9+
* from the spec's own sets, so a type joining `NON_TEXT_STORED_VALUE_TYPES` or
10+
* `NUMERIC_VALUE_TYPES` tomorrow is covered here without an edit — and a type
11+
* leaving the string side would turn the control half red.
12+
*
13+
* The engine-level half (what reaches the driver on every door) is
14+
* `../engine-blank-typed-value-door.test.ts`; the physical-column half, through
15+
* REST on SQLite, is `packages/rest/src/rest-data-blank-typed-value.test.ts`.
16+
*/
17+
18+
import { describe, it, expect } from 'vitest';
19+
import {
20+
NON_TEXT_STORED_VALUE_TYPES,
21+
NUMERIC_VALUE_TYPES,
22+
STRING_VALUE_TYPES,
23+
} from '@objectstack/spec/data';
24+
import { normalizeBlankTypedValues, validateRecord, ValidationError } from './record-validator.js';
25+
26+
const typed = [...NON_TEXT_STORED_VALUE_TYPES];
27+
// The string-stored controls: the whole string class, plus the other types
28+
// whose stored value is a string (`str_empty`'s side of the line).
29+
const stringStored = [...STRING_VALUE_TYPES, 'select', 'radio', 'lookup', 'master_detail', 'user', 'autonumber'];
30+
31+
function schemaOf(types: readonly string[]) {
32+
const fields: Record<string, { name: string; type: string; reference?: string; options?: unknown[] }> = {};
33+
for (const t of types) {
34+
fields[`f_${t}`] = {
35+
name: `f_${t}`,
36+
type: t,
37+
...(t === 'lookup' || t === 'master_detail' ? { reference: 'other' } : {}),
38+
...(t === 'select' || t === 'radio' ? { options: [{ value: 'a', label: 'A' }] } : {}),
39+
};
40+
}
41+
return { fields } as any;
42+
}
43+
44+
function rowOf(types: readonly string[], value: unknown) {
45+
return Object.fromEntries(types.map((t) => [`f_${t}`, value]));
46+
}
47+
48+
describe('normalizeBlankTypedValues (#20308)', () => {
49+
it('the population is the spec set, and it is the one the card names', () => {
50+
// A control on the set itself: if it ever emptied, every case below would
51+
// pass over nothing.
52+
expect(typed.sort()).toEqual(
53+
['boolean', 'currency', 'date', 'datetime', 'number', 'percent', 'progress', 'rating', 'slider', 'summary', 'time', 'toggle'],
54+
);
55+
for (const t of stringStored) expect(NON_TEXT_STORED_VALUE_TYPES.has(t)).toBe(false);
56+
});
57+
58+
it.each([[''], [' '], ['\t\n']])('a blank %j on every non-string-typed column becomes null', (blank) => {
59+
const out = normalizeBlankTypedValues(schemaOf(typed), rowOf(typed, blank)) as Record<string, unknown>;
60+
for (const t of typed) expect(out[`f_${t}`], t).toBeNull();
61+
});
62+
63+
it("a string-stored column's blank is untouched — the str_empty side", () => {
64+
for (const blank of ['', ' ']) {
65+
const row = rowOf(stringStored, blank);
66+
const out = normalizeBlankTypedValues(schemaOf(stringStored), row);
67+
expect(out).toBe(row);
68+
for (const t of stringStored) expect((out as Record<string, unknown>)[`f_${t}`], t).toBe(blank);
69+
}
70+
});
71+
72+
it('a non-blank value of any kind passes through untouched — falsy is not blank', () => {
73+
for (const v of [null, undefined, 0, false, 'abc', ' x ', '0', 'false', '2026-09-27', 42, true]) {
74+
const row = rowOf(typed, v);
75+
expect(normalizeBlankTypedValues(schemaOf(typed), row), String(v)).toBe(row);
76+
}
77+
});
78+
79+
it('never mutates the caller: a copy comes back only when something changed', () => {
80+
const schema = schemaOf(['number', 'text']);
81+
const row = { f_number: '', f_text: '', extra: 1 };
82+
const out = normalizeBlankTypedValues(schema, row) as Record<string, unknown>;
83+
expect(out).not.toBe(row);
84+
expect(row).toEqual({ f_number: '', f_text: '', extra: 1 });
85+
expect(out).toEqual({ f_number: null, f_text: '', extra: 1 });
86+
});
87+
88+
it('an array: rows copied only where they changed, the array only when a row did', () => {
89+
const schema = schemaOf(['date', 'text']);
90+
const clean = { f_date: '2026-01-01' };
91+
const blank = { f_date: '' };
92+
const rows = [clean, blank];
93+
const out = normalizeBlankTypedValues(schema, rows) as Array<Record<string, unknown>>;
94+
expect(out).not.toBe(rows);
95+
expect(out[0]).toBe(clean);
96+
expect(out[1]).toEqual({ f_date: null });
97+
expect(rows[1]).toBe(blank);
98+
expect(blank.f_date).toBe('');
99+
const untouched = [clean, { f_text: '' }];
100+
expect(normalizeBlankTypedValues(schema, untouched)).toBe(untouched);
101+
});
102+
103+
it('an undeclared key, an inherited name and a schema without fields are left alone', () => {
104+
const schema = schemaOf(['number']);
105+
const row = { nope: '', constructor: '' };
106+
expect(normalizeBlankTypedValues(schema, row)).toBe(row);
107+
expect(normalizeBlankTypedValues({} as any, { f_number: '' })).toEqual({ f_number: '' });
108+
expect(normalizeBlankTypedValues(null, { f_number: '' })).toEqual({ f_number: '' });
109+
});
110+
});
111+
112+
describe('the numeric type door is NUMERIC_VALUE_TYPES (#20308)', () => {
113+
function refusal(type: string, value: unknown) {
114+
try {
115+
validateRecord(schemaOf([type]), { [`f_${type}`]: value }, 'insert');
116+
return null;
117+
} catch (e) {
118+
expect(e).toBeInstanceOf(ValidationError);
119+
return e as ValidationError;
120+
}
121+
}
122+
123+
it.each([...NUMERIC_VALUE_TYPES])('%s refuses a non-numeric string with invalid_number', (type) => {
124+
const e = refusal(type, 'abc');
125+
expect(e?.code).toBe('VALIDATION_FAILED');
126+
expect(e?.fields.map((f) => [f.field, f.code])).toEqual([[`f_${type}`, 'invalid_number']]);
127+
});
128+
129+
it.each([...NUMERIC_VALUE_TYPES])('%s still accepts a finite number', (type) => {
130+
expect(refusal(type, 7)).toBeNull();
131+
});
132+
133+
it('progress and summary take the type check only — no bound or scale is newly enforced', () => {
134+
// The boundary the branch states: a declared `max` / `scale` on these two
135+
// was never enforced, and this change does not start (a separate decision).
136+
for (const type of ['progress', 'summary']) {
137+
const fields = { [`f_${type}`]: { name: `f_${type}`, type, max: 100, scale: 0 } } as any;
138+
expect(() => validateRecord({ fields }, { [`f_${type}`]: 150.5 }, 'insert'), type).not.toThrow();
139+
}
140+
});
141+
});

0 commit comments

Comments
 (0)