|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | +// |
| 3 | +// #22445 (d4) — the `update`-mode preview keeps a stored column only where the |
| 4 | +// read door served the caller that very value, so a column the read door |
| 5 | +// served TRANSFORMED (a partial mask) is judged as empty. This file is the |
| 6 | +// representation control for that comparison, on a real driver (driver-sql over |
| 7 | +// SQLite): a plain readable column of every common type must reach the rules |
| 8 | +// as its stored value. If the read door and the prior-row read represented one |
| 9 | +// of them differently, the comparison would judge a readable column empty and |
| 10 | +// the preview would answer on less than the write knows. |
| 11 | +// |
| 12 | +// Each `n_<column>` is required while its column holds the stored value. The |
| 13 | +// stored row satisfies every rule; the patch clears every `n_<column>`, so a |
| 14 | +// rule that judges the STORED value refuses with `required` (a compliant row |
| 15 | +// made violating), and a rule that judged the column empty would answer |
| 16 | +// otherwise (a fault, or an admission). The by-id update is the reference. |
| 17 | + |
| 18 | +import { describe, it, expect, afterEach } from 'vitest'; |
| 19 | +import { ObjectQL } from '@objectstack/objectql'; |
| 20 | +import { SqlDriver } from '@objectstack/driver-sql'; |
| 21 | + |
| 22 | +const OBJECT = 'pv_repr'; |
| 23 | + |
| 24 | +/** column → [field definition, the predicate its `n_<column>` is required under, stored value]. */ |
| 25 | +const COLUMNS: Record<string, [Record<string, unknown>, string, unknown]> = { |
| 26 | + amount: [{ type: 'number' }, 'record.amount > 10', 50], |
| 27 | + active: [{ type: 'boolean' }, 'record.active == true', true], |
| 28 | + due_on: [{ type: 'date' }, 'record.due_on != null', '2026-01-15'], |
| 29 | + happened_at: [{ type: 'datetime' }, 'record.happened_at != null', '2026-01-15T08:30:00.000Z'], |
| 30 | + meta: [{ type: 'json' }, 'record.meta != null', { a: 1, b: [1, 2] }], |
| 31 | + tags: [{ type: 'multiselect', options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }] }, 'record.tags != null', ['a', 'b']], |
| 32 | + tier: [{ type: 'select', options: [{ label: 'Gold', value: 'gold' }, { label: 'Basic', value: 'basic' }] }, "record.tier == 'gold'", 'gold'], |
| 33 | + code: [{ type: 'text' }, "record.code == 'X1'", 'X1'], |
| 34 | +}; |
| 35 | + |
| 36 | +const fields: Record<string, Record<string, unknown>> = { title: { name: 'title', label: 'Title', type: 'text' } }; |
| 37 | +for (const [column, [def, predicate]] of Object.entries(COLUMNS)) { |
| 38 | + fields[column] = { name: column, label: column, ...def }; |
| 39 | + fields[`n_${column}`] = { name: `n_${column}`, label: `n_${column}`, type: 'text', requiredWhen: predicate }; |
| 40 | +} |
| 41 | + |
| 42 | +const liveEngines: ObjectQL[] = []; |
| 43 | +afterEach(async () => { |
| 44 | + while (liveEngines.length) { |
| 45 | + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } |
| 46 | + } |
| 47 | +}); |
| 48 | + |
| 49 | +async function boot() { |
| 50 | + const engine = new ObjectQL(); |
| 51 | + liveEngines.push(engine); |
| 52 | + engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true); |
| 53 | + await engine.init(); |
| 54 | + engine.registry.registerObject({ name: OBJECT, label: 'Preview Representation', fields } as any); |
| 55 | + await engine.syncSchemas(); |
| 56 | + const stored: Record<string, unknown> = { title: 'stored' }; |
| 57 | + for (const [column, [, , value]] of Object.entries(COLUMNS)) { |
| 58 | + stored[column] = value; |
| 59 | + stored[`n_${column}`] = 'kept'; |
| 60 | + } |
| 61 | + const created = await engine.insert(OBJECT, stored) as Record<string, unknown>; |
| 62 | + return { engine, id: created.id as string }; |
| 63 | +} |
| 64 | + |
| 65 | +const clearAll = () => Object.fromEntries(Object.keys(COLUMNS).map((c) => [`n_${c}`, null])); |
| 66 | +const findings = (errors: Array<{ field: string; code: string }>) => |
| 67 | + errors.map((e) => ({ field: e.field, code: e.code })).sort((a, b) => a.field.localeCompare(b.field)); |
| 68 | + |
| 69 | +describe('#22445 (d4) a plain readable column reaches the preview\'s rules as its stored value', () => { |
| 70 | + it('every column type judges the stored value, as the by-id update does', async () => { |
| 71 | + const { engine, id } = await boot(); |
| 72 | + const expected = Object.keys(COLUMNS).map((c) => ({ field: `n_${c}`, code: 'required' })) |
| 73 | + .sort((a, b) => a.field.localeCompare(b.field)); |
| 74 | + |
| 75 | + const preview = await engine.validate(OBJECT, { ...clearAll(), id }, { mode: 'update', context: { userId: 'reader' } }); |
| 76 | + expect(findings(preview.results![0]!.errors)).toEqual(expected); |
| 77 | + |
| 78 | + let writeErrors: Array<{ field: string; code: string }> = []; |
| 79 | + try { |
| 80 | + await engine.update(OBJECT, { ...clearAll(), id }, { context: { userId: 'reader' } }); |
| 81 | + } catch (e: any) { |
| 82 | + expect(e?.code).toBe('VALIDATION_FAILED'); |
| 83 | + writeErrors = e.fields; |
| 84 | + } |
| 85 | + expect(findings(writeErrors)).toEqual(expected); |
| 86 | + }); |
| 87 | + |
| 88 | + it('control: each rule admits once its own column is cleared in the same patch', async () => { |
| 89 | + const { engine, id } = await boot(); |
| 90 | + const patch: Record<string, unknown> = { ...clearAll(), id }; |
| 91 | + for (const column of Object.keys(COLUMNS)) patch[column] = null; |
| 92 | + const preview = await engine.validate(OBJECT, patch, { mode: 'update', context: { userId: 'reader' } }); |
| 93 | + expect(findings(preview.results![0]!.errors)).toEqual( |
| 94 | + // `null > 10` has no overload: the number rule cannot judge a cleared column. |
| 95 | + [{ field: 'n_amount', code: 'rule_violation' }], |
| 96 | + ); |
| 97 | + }); |
| 98 | +}); |
0 commit comments