|
| 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 | +}); |
0 commit comments