|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * An option-level `default: true` gets NO physical column DEFAULT (#7246). |
| 5 | + * |
| 6 | + * The engine now honours the select idiom — `ObjectQL.applyFieldDefaults` falls |
| 7 | + * back to the option marked `default: true` when the field declares no |
| 8 | + * `defaultValue`. The DDL deliberately does NOT follow, and because that reads |
| 9 | + * as an oversight next to case 4 of `applyDeclaredColumnDefault` (an ordinary |
| 10 | + * literal IS emitted), it is pinned here rather than left to a comment. |
| 11 | + * |
| 12 | + * Why not emit, in short — the long form lives on |
| 13 | + * `SqlDriver.applyDeclaredColumnDefault`: |
| 14 | + * |
| 15 | + * - `defaultValue` beats the option flag, and that precedence lives in ONE |
| 16 | + * place, the engine. A column DEFAULT is a second resolver. |
| 17 | + * - On `multiple: true` the default is an ARRAY; there is no scalar DDL form, |
| 18 | + * so emitting needs a carve-out the author cannot see. |
| 19 | + * - This method runs for fresh and re-materialized columns only, never a |
| 20 | + * retrofit, so emitting would give new databases a DEFAULT that older ones |
| 21 | + * on identical metadata lack — and `detectDrift`'s only `default_mismatch` |
| 22 | + * producer is the #4560 runtime-token check, so nothing would report it. |
| 23 | + * |
| 24 | + * The #4560 discipline is NOT the reason: an option's `value` is a plain |
| 25 | + * literal, so it could legally be emitted. This is a design decision about |
| 26 | + * where a default is resolved, and the last test states the consequence that |
| 27 | + * makes it safe — every ObjectStack write path stores the value regardless, |
| 28 | + * because the engine, not the database, supplies it. |
| 29 | + */ |
| 30 | + |
| 31 | +import { describe, it, expect, afterEach, vi } from 'vitest'; |
| 32 | +import { SqlDriver } from '../src/index.js'; |
| 33 | + |
| 34 | +describe('SqlDriver — an option `default: true` never becomes a column DEFAULT (#7246)', () => { |
| 35 | + let knexInstance: any; |
| 36 | + |
| 37 | + const makeDriver = () => { |
| 38 | + const d = new SqlDriver({ |
| 39 | + client: 'better-sqlite3', |
| 40 | + connection: { filename: ':memory:' }, |
| 41 | + useNullAsDefault: true, |
| 42 | + }); |
| 43 | + knexInstance = (d as any).knex; |
| 44 | + (d as any).logger = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }; |
| 45 | + return d; |
| 46 | + }; |
| 47 | + |
| 48 | + /** The raw `CREATE TABLE` SQLite stored — the only unambiguous view of a DEFAULT. */ |
| 49 | + const tableSql = async (table: string): Promise<string> => { |
| 50 | + const row = await knexInstance.raw( |
| 51 | + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", |
| 52 | + [table], |
| 53 | + ); |
| 54 | + return String(row?.[0]?.sql ?? row?.sql ?? ''); |
| 55 | + }; |
| 56 | + |
| 57 | + afterEach(async () => { |
| 58 | + await knexInstance?.destroy(); |
| 59 | + }); |
| 60 | + |
| 61 | + const optionZoo = [ |
| 62 | + { |
| 63 | + name: 'option_zoo', |
| 64 | + fields: { |
| 65 | + title: { type: 'string' }, |
| 66 | + // Option-default only — the shape 30 fields in the shipped corpus use. |
| 67 | + f_status: { |
| 68 | + type: 'select', |
| 69 | + options: [ |
| 70 | + { label: 'Draft', value: 'draft', default: true }, |
| 71 | + { label: 'Active', value: 'active' }, |
| 72 | + ], |
| 73 | + }, |
| 74 | + // BOTH declared: the field-level literal is emitted (case 4), and it is |
| 75 | + // also the value the engine resolves — the two agree, which is the |
| 76 | + // property that matters when both sides can answer. |
| 77 | + f_stage: { |
| 78 | + type: 'select', |
| 79 | + defaultValue: 'approved', |
| 80 | + options: [ |
| 81 | + { label: 'Draft', value: 'draft', default: true }, |
| 82 | + { label: 'Approved', value: 'approved' }, |
| 83 | + ], |
| 84 | + }, |
| 85 | + }, |
| 86 | + }, |
| 87 | + ]; |
| 88 | + |
| 89 | + it('creates an option-defaulted column with NO database default', async () => { |
| 90 | + const driver = makeDriver(); |
| 91 | + await driver.initObjects(optionZoo as any); |
| 92 | + |
| 93 | + const info = await knexInstance('option_zoo').columnInfo(); |
| 94 | + expect(info.f_status.defaultValue ?? null).toBeNull(); |
| 95 | + |
| 96 | + const sql = await tableSql('option_zoo'); |
| 97 | + expect(sql).not.toContain("DEFAULT 'draft'"); |
| 98 | + }); |
| 99 | + |
| 100 | + it('REGRESSION: a field-level `defaultValue` on the SAME field is still emitted', async () => { |
| 101 | + // The exclusion is scoped to the option flag. Losing case 4 here would be a |
| 102 | + // silent behaviour change for every literal default in the platform. |
| 103 | + const driver = makeDriver(); |
| 104 | + await driver.initObjects(optionZoo as any); |
| 105 | + const info = await knexInstance('option_zoo').columnInfo(); |
| 106 | + expect(String(info.f_stage.defaultValue)).toContain('approved'); |
| 107 | + // ...and never the option the field-level key outranks. |
| 108 | + expect(String(info.f_stage.defaultValue)).not.toContain('draft'); |
| 109 | + }); |
| 110 | + |
| 111 | + it('a driver-level insert that omits the field stores NULL — the engine, not the database, defaults it', async () => { |
| 112 | + // The consequence of the decision, stated rather than left implicit: this |
| 113 | + // is a RAW driver write, below the engine. Through `ObjectQL.insert` the |
| 114 | + // same omission stores 'draft' (pinned in |
| 115 | + // objectql/src/engine-select-option-default.test.ts), which is every |
| 116 | + // ObjectStack write path. Only a writer bypassing the engine sees this |
| 117 | + // NULL — and that writer is not reading `options` either. |
| 118 | + const driver = makeDriver(); |
| 119 | + await driver.initObjects(optionZoo as any); |
| 120 | + await driver.create('option_zoo', { id: 'o1', title: 't' }, { bypassTenantAudit: true }); |
| 121 | + const row = await knexInstance('option_zoo').where('id', 'o1').first(); |
| 122 | + expect(row.f_status).toBeNull(); |
| 123 | + }); |
| 124 | +}); |
0 commit comments