Skip to content

Commit 375469f

Browse files
committed
fix(objectql): derive the blank arm from the $empty table's null_only row plus a stated one-string set; file references join (#29277)
Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
1 parent e607df7 commit 375469f

4 files changed

Lines changed: 120 additions & 52 deletions

File tree

‎.changeset/29277-single-value-blank-null.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ fix(objectql): a cleared optional single-value `select` or `lookup` stores `null
77
Clause-②: no
88
Metadata-protocol: none
99

10-
A cleared `select` or `lookup` sent as `''` was stored as sent. `''` is neither an option code nor a record id, so it held no value, yet it was one to the store and to the `is_empty` filter: on a unique column the second record cleared was refused "A record with this value already exists", and on every column the cleared record did not match `is_empty` (`$empty`), whose ruled table reads only `null` as empty on these types.
10+
A cleared `select`, `lookup` or single file field sent as `''` was stored as sent. `''` is neither an option code, a record id nor a file id, so it held no value, yet it was one to the store and to the `is_empty` filter: on a unique column the second record cleared was refused "A record with this value already exists", and on every column the cleared record did not match `is_empty` (`$empty`), whose ruled table reads only `null` as empty on these types.
1111

12-
- **Now.** The engine's write door (`insert`, `insertMany`, `update` by id and by predicate, and the `validate` dry run; every REST, batch, upsert and import door reaches it) stores exactly `''` as `null` on every optional column whose type is a single-value option or reference type on the `$empty` table's `null_only` row: `select`, `radio`, `lookup`, `master_detail`, `user`, `tree`. Unique or not.
12+
- **Now.** The engine's write door (`insert`, `insertMany`, `update` by id and by predicate, and the `validate` dry run; every REST, batch, upsert and import door reaches it) stores exactly `''` as `null` on every optional column the `$empty` table puts on its `null_only` row whose value is one string: the option types (`select`, `radio`), the reference types (`lookup`, `master_detail`, `user`, `tree`) and the file references (`image`, `file`, `avatar`, `video`, `audio`). Unique or not. Object- and array-valued types (`json`, `composite`, `location`, …) and the engine-owned `formula` / `autonumber` keep what they store.
1313
- **Read shape.** Such a field cleared from now on reads back `null`, not `''`. It matches `is_empty` / `$empty: true`, `$null: true` and `field = null`, and no longer matches `field = ''`.
1414
- **Unchanged.** The `$empty` table itself. A `required` field given `''` is refused `required` as before, also when it declares a `defaultValue`. Whitespace is a value. A `multiple: true` option or reference column keeps what it stores. A real duplicate is still `409 UNIQUE_VIOLATION`.
1515
- **Defaults.** An optional field that declares a `defaultValue` now takes it on insert when sent `''`, exactly as when sent `null`.

‎packages/objectql/src/validation/record-validator.single-value-blank.test.ts‎

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,43 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* #29277 — the write door's arm for a cleared single-value option or reference
5-
* column, at the validator module: `normalizeBlankTypedValues` stores exactly
6-
* `''` as `null` on every OPTIONAL column whose type is string-stored
7-
* single-value (`SINGLE_OPTION_TYPES`, `REFERENCE_VALUE_TYPES`) and sits on the
8-
* `$empty` table's `null_only` row (maintainer ruling B on #29346, record
4+
* #29277 — the write door's arm for a cleared one-string column on the
5+
* `$empty` table's `null_only` row, at the validator module:
6+
* `normalizeBlankTypedValues` stores exactly `''` as `null` on every OPTIONAL
7+
* column the table puts on `null_only` whose value is one string — an option
8+
* code, a record id, a file id (maintainer ruling B on #29346, record
99
* 6579813296).
1010
*
11-
* `''` is neither an option code nor a record id, so it is no value on those
12-
* columns; the `$empty` table already reads only `null` as empty there, and is
13-
* unchanged. The population is read from the spec's own sets and the table's
14-
* own expansion, so a type joining either class tomorrow is covered here
15-
* without an edit. The engine and physical-column halves, through REST on
16-
* SQLite, are `packages/rest/src/rest-data-single-value-blank.test.ts`.
11+
* The population is DERIVED from the table: every `FieldType` whose
12+
* expansion is `null_only` and which is not typed (`NON_TEXT_STORED_VALUE_TYPES`,
13+
* the #20308 arm) must be classified — one string (joins), object/array
14+
* (`STRUCTURED_JSON_TYPES`, out) or engine-owned (`COMPUTED_VALUE_TYPES`,
15+
* out) — so a type joining the row unclassified turns the first case red. The
16+
* engine and physical-column halves, through REST on SQLite, are
17+
* `packages/rest/src/rest-data-single-value-blank.test.ts`.
1718
*/
1819

1920
import { describe, it, expect } from 'vitest';
2021
import {
22+
FieldType,
2123
REFERENCE_VALUE_TYPES,
2224
SINGLE_OPTION_TYPES,
25+
FILE_REFERENCE_TYPES,
26+
STRUCTURED_JSON_TYPES,
27+
COMPUTED_VALUE_TYPES,
28+
NON_TEXT_STORED_VALUE_TYPES,
2329
MULTI_CAPABLE_TYPES,
2430
expandEmptyOperator,
2531
} from '@objectstack/spec/data';
2632
import { normalizeBlankTypedValues } from './record-validator.js';
2733

28-
const singleValue = [...SINGLE_OPTION_TYPES, ...REFERENCE_VALUE_TYPES];
34+
/** Every declarable type the `$empty` table puts on `null_only` that the typed arm does not own. */
35+
const nullOnlyUntyped = FieldType.options.filter(
36+
(t) => expandEmptyOperator({ type: t }).arm === 'null_only' && !NON_TEXT_STORED_VALUE_TYPES.has(t),
37+
);
38+
const oneString = new Set<string>([...SINGLE_OPTION_TYPES, ...REFERENCE_VALUE_TYPES, ...FILE_REFERENCE_TYPES]);
39+
const singleValue = nullOnlyUntyped.filter((t) => oneString.has(t));
40+
const leftOut = nullOnlyUntyped.filter((t) => !oneString.has(t));
2941
const multiCapable = singleValue.filter((t) => MULTI_CAPABLE_TYPES.has(t));
3042

3143
type Def = Record<string, unknown>;
@@ -45,12 +57,25 @@ function schemaOf(types: readonly string[], extra: Def = {}) {
4557
const rowOf = (types: readonly string[], value: unknown) => Object.fromEntries(types.map((t) => [`f_${t}`, value]));
4658

4759
describe('normalizeBlankTypedValues: optional single-value option / reference columns (#29277)', () => {
48-
it('the population is the ruled set, every member on the $empty table null_only row', () => {
49-
// A control on the set itself: if it ever emptied, every case below would
50-
// pass over nothing.
51-
expect([...singleValue].sort()).toEqual(['lookup', 'master_detail', 'radio', 'select', 'tree', 'user']);
52-
for (const t of singleValue) expect(expandEmptyOperator({ type: t }).arm, t).toBe('null_only');
53-
expect(multiCapable.sort()).toEqual(['lookup', 'radio', 'select', 'user']);
60+
it('every untyped null_only type is classified: one string joins, object/array and engine-owned stay out', () => {
61+
for (const t of nullOnlyUntyped) {
62+
const classes = [oneString.has(t), STRUCTURED_JSON_TYPES.has(t), COMPUTED_VALUE_TYPES.has(t)].filter(Boolean);
63+
expect(classes, `${t}: exactly one storage class`).toHaveLength(1);
64+
}
65+
// Controls on the sets themselves: if one ever emptied, every case below
66+
// would pass over nothing.
67+
expect([...singleValue].sort()).toEqual(
68+
['audio', 'avatar', 'file', 'image', 'lookup', 'master_detail', 'radio', 'select', 'tree', 'user', 'video'],
69+
);
70+
expect([...leftOut].sort()).toEqual(
71+
['address', 'autonumber', 'composite', 'formula', 'json', 'location', 'record', 'repeater', 'vector'],
72+
);
73+
expect(multiCapable.sort()).toEqual(['file', 'image', 'lookup', 'radio', 'select', 'user']);
74+
});
75+
76+
it('the left-out types keep "": object/array-stored and engine-owned', () => {
77+
const row = rowOf(leftOut, '');
78+
expect(normalizeBlankTypedValues(schemaOf(leftOut), row)).toBe(row);
5479
});
5580

5681
it('"" on every optional member becomes null, unique or not', () => {

‎packages/objectql/src/validation/record-validator.ts‎

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ function isInReadonlyScope(name: string, def: FieldDef, scope: ReadonlyValueScop
720720
* [#20308] A BLANK string on a non-string-typed column is that column's typed
721721
* blank, `null` — the write door's one reading of it. [#29176] So is `''` on
722722
* an optional text-like column a unique constraint covers, and [#29277] `''`
723-
* on an optional single-value option or reference column (each its own
723+
* on an optional single-value option, reference or file column (each its own
724724
* section below).
725725
*
726726
* "Blank" is exactly what {@link isMissing} calls missing: `''` or a string of
@@ -759,15 +759,15 @@ function isInReadonlyScope(name: string, def: FieldDef, scope: ReadonlyValueScop
759759
* - a blank here also takes a declared `defaultValue` on insert, as the typed
760760
* arm's does — the door runs before the defaults, by design.
761761
*
762-
* ## [#29277] Single-value option and reference columns
762+
* ## [#29277] One-string columns on the `$empty` table's `null_only` row
763763
*
764-
* Exactly `''` on a column that is ALL of: string-stored single-value on the
765-
* `$empty` table's `null_only` row ({@link isSingleValueCodeColumn}: a
766-
* `SINGLE_OPTION_TYPES` or `REFERENCE_VALUE_TYPES` member that
767-
* `expandEmptyOperator` puts on `null_only`, so `multiple: true` is out), and
768-
* not `required: true` — is `null` from the door on, unique or not
769-
* (maintainer ruling B on #29346, record 6579813296). `''` is neither an
770-
* option code nor a record id, so it holds no value there; the ruled table
764+
* Exactly `''` on a column that is ALL of: on the `$empty` table's
765+
* `null_only` row (`expandEmptyOperator`, the table's own answer — so a
766+
* `multiple: true` column, the `multi_value` row, is out), storing ONE string
767+
* ({@link ONE_STRING_STORED_TYPES}), and not `required: true` — is `null` from
768+
* the door on, unique or not (maintainer ruling B on #29346, record
769+
* 6579813296; {@link isOneStringNullOnlyColumn}). `''` is neither an option
770+
* code, a record id nor a file id, so it holds no value there; the ruled table
771771
* reads only `null` as empty on that row, and is unchanged — a row that
772772
* already stores `''` keeps answering as a value until a data step cleans it.
773773
* On a unique column the second record cleared is no longer refused `409`.
@@ -777,8 +777,9 @@ function isInReadonlyScope(name: string, def: FieldDef, scope: ReadonlyValueScop
777777
* ## What it does NOT touch
778778
*
779779
* ⛔ Every other string-stored column — text no unique constraint covers, a
780-
* `multiple: true` option or reference column (the table's `multi_value`
781-
* row), a file reference, a computed column. There `''` is a value, and the
780+
* `multiple: true` option, reference or file column (the table's
781+
* `multi_value` row), and the `null_only` types that do not store one string
782+
* (see {@link ONE_STRING_STORED_TYPES}). There `''` is a value, and the
782783
* spec's value round-trip fixture pins it on text (`str_empty`: an empty
783784
* string must not become null). ⛔ A non-blank value, and every non-string value, of any
784785
* type: `null`, `0`, `false`, `'abc'` pass through untouched, so every type
@@ -854,28 +855,49 @@ function normalizeBlankTypedRow(
854855
* blank here would turn that refusal into a silent clear.
855856
*
856857
* The single-value option and reference types are not text-like: their arm
857-
* is {@link isSingleValueCodeColumn} (#29277), which needs no unique
858+
* is {@link isOneStringNullOnlyColumn} (#29277), which needs no unique
858859
* constraint.
859860
*/
860861
const UNIQUE_BLANK_TEXT_TYPES: ReadonlySet<string> = new Set(
861862
[...STRING_VALUE_TYPES].filter((t) => t !== 'password' && t !== 'secret'),
862863
);
863864

864865
/**
865-
* [#29277] The string-stored single-value classes: an option code
866-
* (`SINGLE_OPTION_TYPES`) or a record id (`REFERENCE_VALUE_TYPES`), read as
867-
* the spec's constants, never re-listed.
866+
* [#29277] The storage-shape half of the arm: the spec's value classes whose
867+
* single value IS one string — an option code (`SINGLE_OPTION_TYPES`), a
868+
* record id (`REFERENCE_VALUE_TYPES`), an opaque `sys_file` id
869+
* (`FILE_REFERENCE_TYPES`; SQL stores it JSON-encoded, still one string to
870+
* every reader). Read as the spec's constants, never re-listed. The spec has
871+
* no predicate for "stores one string", so this set is stated here, and
872+
* `record-validator.single-value-blank.test.ts` ties it to the table: every
873+
* type on the `null_only` row that is not typed (`NON_TEXT_STORED_VALUE_TYPES`)
874+
* must be in this set or in one of the two classes left out below, so a type
875+
* that joins the row unclassified turns that pin red.
876+
*
877+
* Left out, each measured on SQLite through the engine:
878+
*
879+
* - `STRUCTURED_JSON_TYPES` (`json`, `composite`, `record`, `location`,
880+
* `address`, `repeater`, `vector`): the value is an object or an array, so
881+
* `''` is not a blank of its shape. What empty means for them is a
882+
* separate question.
883+
* - `COMPUTED_VALUE_TYPES` (`formula`, `autonumber`; `summary` is typed): the
884+
* engine owns the value — a formula is never stored, an autonumber is
885+
* issued on insert whatever the caller sent, `''` included.
868886
*/
869-
const SINGLE_VALUE_CODE_TYPES: ReadonlySet<string> = new Set([...SINGLE_OPTION_TYPES, ...REFERENCE_VALUE_TYPES]);
887+
const ONE_STRING_STORED_TYPES: ReadonlySet<string> = new Set([
888+
...SINGLE_OPTION_TYPES,
889+
...REFERENCE_VALUE_TYPES,
890+
...FILE_REFERENCE_TYPES,
891+
]);
870892

871893
/**
872-
* [#29277] Is `def` a string-stored single-value column on the `$empty`
873-
* table's `null_only` row? The row is the table's own answer
874-
* (`expandEmptyOperator`), so a `multiple: true` column — the `multi_value`
875-
* row — is never one, and a type the table moves is followed without an edit.
894+
* [#29277] Is `def` on the `$empty` table's `null_only` row, storing one
895+
* string? The row is the table's own answer (`expandEmptyOperator`), read
896+
* first: a `multiple: true` column — the `multi_value` row — is never one,
897+
* and a type the table moves off the row is followed without an edit.
876898
*/
877-
function isSingleValueCodeColumn(def: FieldDef): boolean {
878-
return SINGLE_VALUE_CODE_TYPES.has(def.type) && expandEmptyOperator(def).arm === 'null_only';
899+
function isOneStringNullOnlyColumn(def: FieldDef): boolean {
900+
return expandEmptyOperator(def).arm === 'null_only' && ONE_STRING_STORED_TYPES.has(def.type);
879901
}
880902

881903
/**
@@ -889,8 +911,8 @@ function isSingleValueCodeColumn(def: FieldDef): boolean {
889911
* for one. Both surfaces are read through `isUniqueDeclared`, the drivers'
890912
* own predicate, so a text column the store does not constrain is never
891913
* rewritten;
892-
* - [#29277] a single-value option or reference column
893-
* ({@link isSingleValueCodeColumn}), unique or not.
914+
* - [#29277] a one-string column on the `$empty` table's `null_only` row
915+
* ({@link isOneStringNullOnlyColumn}), unique or not.
894916
*/
895917
function blankAsNullColumns(fields: Record<string, FieldDef>, indexes: unknown): ReadonlySet<string> {
896918
const covered = new Set<string>();
@@ -906,7 +928,7 @@ function blankAsNullColumns(fields: Record<string, FieldDef>, indexes: unknown):
906928
const out = new Set<string>();
907929
for (const [name, def] of Object.entries(fields)) {
908930
if (!def || SKIP_FIELDS.has(name) || def.required === true) continue;
909-
if (UNIQUE_BLANK_TEXT_TYPES.has(def.type) ? covered.has(name) : isSingleValueCodeColumn(def)) out.add(name);
931+
if (UNIQUE_BLANK_TEXT_TYPES.has(def.type) ? covered.has(name) : isOneStringNullOnlyColumn(def)) out.add(name);
910932
}
911933
return out;
912934
}

‎packages/rest/src/rest-data-single-value-blank.test.ts‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
* `NULL` as distinct from every `NULL`), and on every column the clear was not
1313
* empty to `$empty`, whose ruled table reads only `null` as empty on these
1414
* types. Maintainer ruling B on #29346 (record 6579813296): the write door
15-
* stores `''` as `null` on every optional column whose type is string-stored
16-
* single-value on the table's `null_only` row — `select`, `radio`, `lookup`,
17-
* `master_detail`, `user`, `tree` today.
15+
* stores `''` as `null` on every optional column the table puts on its
16+
* `null_only` row whose value is one string — `select`, `radio`, `lookup`,
17+
* `master_detail`, `user`, `tree`, and the file references `image`, `file`,
18+
* `avatar`, `video`, `audio` today (an opaque file id; SQL stores it
19+
* JSON-encoded, and a cleared one collided the same way).
1820
*
1921
* The PHYSICAL column is read with the driver's own query builder. Pinned
2022
* beside it: the `$empty` table is unchanged (a row that already holds `''`
@@ -48,14 +50,23 @@ const OBJ = {
4850
c_master: { name: 'c_master', type: 'master_detail' as const, reference: 'svb_target' },
4951
c_user: { name: 'c_user', type: 'user' as const },
5052
c_tree: { name: 'c_tree', type: 'tree' as const },
53+
c_image: { name: 'c_image', type: 'image' as const },
54+
c_file: { name: 'c_file', type: 'file' as const },
55+
c_avatar: { name: 'c_avatar', type: 'avatar' as const },
56+
c_video: { name: 'c_video', type: 'video' as const },
57+
c_audio: { name: 'c_audio', type: 'audio' as const },
58+
u_image: { name: 'u_image', type: 'image' as const, unique: true },
5159
u_select: { name: 'u_select', type: 'select' as const, options: OPTS, unique: true },
5260
u_lookup: { name: 'u_lookup', type: 'lookup' as const, reference: 'svb_target', unique: true },
5361
grp: { name: 'grp', type: 'text' as const },
5462
i_lookup: { name: 'i_lookup', type: 'lookup' as const, reference: 'svb_target' },
5563
},
5664
indexes: [{ name: 'idx_svb_obj_grp_lookup', fields: ['grp', 'i_lookup'], unique: 'global' as const }],
5765
};
58-
const PER_TYPE = ['c_select', 'c_radio', 'c_lookup', 'c_master', 'c_user', 'c_tree'] as const;
66+
const PER_TYPE = [
67+
'c_select', 'c_radio', 'c_lookup', 'c_master', 'c_user', 'c_tree',
68+
'c_image', 'c_file', 'c_avatar', 'c_video', 'c_audio',
69+
] as const;
5970
const REQ = {
6071
name: 'svb_req', label: 'Required', systemFields: false,
6172
fields: {
@@ -136,17 +147,20 @@ describe('REST write doors on SQLite: a cleared optional single-value option / r
136147
let ctx: Ctx;
137148
beforeEach(async () => { ctx = await boot(); });
138149

139-
it('POST create with "" on every ruled type: 201, every column stores NULL', async () => {
150+
it('POST create with "" on every joined type: 201, every column stores NULL', async () => {
140151
const res = await create(ctx, { id: 'p1', name: 'r1', ...allBlank });
141152
expect(res.status).toBe(201);
142153
const row = await ctx.stored('svb_obj', 'p1');
143154
for (const f of PER_TYPE) expect(row?.[f], f).toBeNull();
144155
});
145156

146-
it('PATCH clearing every ruled type: 200, every column stores NULL', async () => {
157+
it('PATCH clearing every joined type: 200, every column stores NULL', async () => {
147158
// `c_user` is left unset: no `sys_user` is registered here to resolve an id.
148159
expect((await create(ctx, { id: 'e0', name: 'parent' })).status).toBe(201);
149-
const set = { c_select: 'gold', c_radio: 'gold', c_lookup: 't1', c_master: 't1', c_tree: 'e0' };
160+
const set = {
161+
c_select: 'gold', c_radio: 'gold', c_lookup: 't1', c_master: 't1', c_tree: 'e0',
162+
c_image: 'img_1', c_file: 'fil_1', c_avatar: 'ava_1', c_video: 'vid_1', c_audio: 'aud_1',
163+
};
150164
expect((await create(ctx, { id: 'e1', name: 'r1', ...set })).status).toBe(201);
151165
const res = await patch(ctx, 'e1', allBlank);
152166
expect(res.status).toBe(200);
@@ -174,6 +188,13 @@ describe('REST write doors on SQLite: a cleared optional single-value option / r
174188
for (const id of ['w1', 'w2']) expect((await ctx.stored('svb_obj', id))?.u_lookup, id).toBeNull();
175189
});
176190

191+
it('unique image: two records created with "" both land (no 409)', async () => {
192+
const a = await create(ctx, { id: 'g1', name: 'r1', u_image: '' });
193+
const b = await create(ctx, { id: 'g2', name: 'r2', u_image: '' });
194+
expect([a.status, b.status]).toEqual([201, 201]);
195+
for (const id of ['g1', 'g2']) expect((await ctx.stored('svb_obj', id))?.u_image, id).toBeNull();
196+
});
197+
177198
it('a member of a declared unique index: same group, cleared lookup, both land', async () => {
178199
const a = await create(ctx, { id: 'i1', name: 'r1', grp: 'g', i_lookup: '' });
179200
const b = await create(ctx, { id: 'i2', name: 'r2', grp: 'g', i_lookup: '' });

0 commit comments

Comments
 (0)