Skip to content

Commit e228a1f

Browse files
committed
fix(objectql): forced pins and changeset for the single-value blank arm (#29277)
Claude-Session: https://claude.ai/code/session_01ADHkmRPC4f86r9bWGuYWJu Co-authored-by: Claude <noreply@anthropic.com>
1 parent a71af04 commit e228a1f

7 files changed

Lines changed: 45 additions & 21 deletions

‎.changeset/29176-optional-unique-blank-null.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ Metadata-protocol: none
1010
A cleared text input is sent as `''`, and the write path stored it as sent. Every unique constraint the drivers build is NULL-distinct, so `null` never collides, but `''` is a value: the first record whose SKU (tax id, employee number, …) was cleared saved, and the second was refused "A record with this value already exists" although neither held a value. Measured on SQLite and the memory driver: two `''` rows collide, two `null` rows do not.
1111

1212
- **Now.** The engine's write door, which `insert`, `insertMany`, `update` (by id and by predicate) and the dry run (`validate`) apply before anything else reads the payload, stores exactly `''` as `null` on a column that is text-like (`text`, `textarea`, `email`, `url`, `phone`, `markdown`, `html`, `richtext`, `code`, `color`, `signature`, `qrcode`), not `required`, and covered by a unique constraint: the field's own `unique`, or membership in a declared `indexes[]` entry carrying `unique`. Every REST, batch, upsert and import door reaches the engine through those methods.
13-
- **Unchanged.** A `required` field given `''` is refused `required` exactly as before, also when it declares a `defaultValue`. A real duplicate is still `409 UNIQUE_VIOLATION`. Whitespace is a value and is stored as sent. `password` and `secret` keep their `''` refusal. A column no unique constraint covers, and `select` / `lookup` columns, keep storing `''`.
13+
- **Unchanged.** A `required` field given `''` is refused `required` exactly as before, also when it declares a `defaultValue`. A real duplicate is still `409 UNIQUE_VIOLATION`. Whitespace is a value and is stored as sent. `password` and `secret` keep their `''` refusal. A text column no unique constraint covers keeps storing `''`. (Optional single-value `select` / `lookup` columns store a cleared `''` as `null` too, unique or not: a separate change in this release.)
1414
- **Defaults.** An optional unique field that declares a `defaultValue` now takes it on insert when sent `''`, exactly as when sent `null`.
1515
- **Rows already stored.** Existing `''` values are not migrated. They keep matching a `field = ''` filter; a newly cleared row matches `$null: true` instead. The `is_empty` filter (`$empty`) matches both, as before. A newly cleared row no longer collides with a stored `''`.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a cleared optional single-value `select` or `lookup` stores `null`, so a second record cleared on a unique one is no longer refused `409 UNIQUE_VIOLATION`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
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.
11+
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.
13+
- **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 = ''`.
14+
- **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`.
15+
- **Defaults.** An optional field that declares a `defaultValue` now takes it on insert when sent `''`, exactly as when sent `null`.
16+
- **Rows already stored.** Existing `''` values are not migrated by this change; they keep answering as a value (`$empty: false`, `field = ''`) until a data step cleans them.

‎packages/objectql/src/engine-blank-typed-value-door.test.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@
2929
* runs BEFORE the caller-value snapshot the lock judges against;
3030
* - the dry run (`validate()`) agrees with the write.
3131
*
32-
* The string-stored controls (`text`, `lookup`, `select`) keep their `''` —
33-
* the spec's `str_empty` side of the line.
32+
* The text control keeps its `''` — the spec's `str_empty` side of the line;
33+
* an optional `lookup` / `select` `''` is `null` since #29277
34+
* (`validation/record-validator.single-value-blank.test.ts`).
3435
*/
3536

3637
import { describe, it, expect, beforeEach } from 'vitest';
@@ -155,7 +156,7 @@ describe('engine write doors: a blank on a non-string-typed column reaches the d
155156
await engine.insert('blank_door', payload);
156157
const sent = last('create');
157158
expectTypedNull(sent);
158-
expect([sent.c_text, sent.c_lookup, sent.c_select]).toEqual(['', '', '']);
159+
expect([sent.c_text, sent.c_lookup, sent.c_select]).toEqual(['', null, null]);
159160
// The caller's own object is never rewritten.
160161
expect(payload.f_number).toBe(blank);
161162
});
@@ -181,7 +182,7 @@ describe('engine write doors: a blank on a non-string-typed column reaches the d
181182
await engine.update('blank_door', { id: 'u1', ...blankTyped(''), ...STR_BLANK });
182183
const byId = last('update');
183184
expectTypedNull(byId);
184-
expect([byId.c_text, byId.c_lookup, byId.c_select]).toEqual(['', '', '']);
185+
expect([byId.c_text, byId.c_lookup, byId.c_select]).toEqual(['', null, null]);
185186

186187
await engine.update('blank_door', blankTyped(''), { where: { id: { $in: ['u1'] } }, multi: true } as any);
187188
expectTypedNull(last('updateMany'));

‎packages/objectql/src/engine-optional-unique-blank-door.test.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
* of a declared `indexes[]` entry carrying `unique`;
2323
* - its edges, each a control: a column no unique constraint covers keeps
2424
* `''` (the spec's `str_empty` side of the line); whitespace is a value, as
25-
* the `$empty` operator's ruled table reads it; `select` / `lookup` keep
26-
* `''` (not text-like); a non-blank value is untouched;
25+
* the `$empty` operator's ruled table reads it; a non-blank value is
26+
* untouched. (A unique `select` / `lookup` `''` is `null` too since #29277,
27+
* on every optional column, unique or not: `pick` / `ref` below.)
2728
* - `required` is unchanged: a required unique field's `''` is refused
2829
* `required` exactly as before — including when the field declares a
2930
* `defaultValue`, which a blank rewritten to `null` would have taken;
@@ -135,10 +136,10 @@ const CRED = {
135136
},
136137
};
137138

138-
/** The covered columns of {@link OBJ}: both unique surfaces, three scopes. */
139-
const COVERED_NAMES = ['sku', 'tax_id', 'email', 'code'];
140-
/** The columns that keep their `''`: no unique constraint, or not text-like. */
141-
const KEPT_NAMES = ['note', 'plain_idx', 'off', 'pick', 'ref'];
139+
/** The covered columns of {@link OBJ}: both unique surfaces, three scopes; and [#29277] the single-value select / lookup. */
140+
const COVERED_NAMES = ['sku', 'tax_id', 'email', 'code', 'pick', 'ref'];
141+
/** The text columns that keep their `''`: no unique constraint. */
142+
const KEPT_NAMES = ['note', 'plain_idx', 'off'];
142143
const blanksOf = (names: readonly string[]) => Object.fromEntries(names.map((n) => [n, '']));
143144
const COVERED = blanksOf(COVERED_NAMES);
144145
const KEPT = blanksOf(KEPT_NAMES);

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ import {
2626
import { normalizeBlankTypedValues, validateRecord, ValidationError } from './record-validator.js';
2727

2828
const typed = [...NON_TEXT_STORED_VALUE_TYPES];
29-
// The string-stored controls: the whole string class, plus the other types
30-
// whose stored value is a string (`str_empty`'s side of the line).
31-
const stringStored = [...STRING_VALUE_TYPES, 'select', 'radio', 'lookup', 'master_detail', 'user', 'autonumber'];
29+
// The string-stored controls: the whole string class, plus a computed string
30+
// type (`str_empty`'s side of the line). The single-value option and reference
31+
// types store `''` as `null` since #29277, by their own arm
32+
// (`record-validator.single-value-blank.test.ts`); they are still not typed.
33+
const stringStored = [...STRING_VALUE_TYPES, 'autonumber'];
34+
const singleValueCode = ['select', 'radio', 'lookup', 'master_detail', 'user', 'tree'];
3235

3336
function schemaOf(types: readonly string[]) {
3437
const fields: Record<string, { name: string; type: string; reference?: string; options?: unknown[] }> = {};
@@ -54,7 +57,7 @@ describe('normalizeBlankTypedValues (#20308)', () => {
5457
expect(typed.sort()).toEqual(
5558
['boolean', 'currency', 'date', 'datetime', 'number', 'percent', 'progress', 'rating', 'slider', 'summary', 'time', 'toggle'],
5659
);
57-
for (const t of stringStored) expect(NON_TEXT_STORED_VALUE_TYPES.has(t)).toBe(false);
60+
for (const t of [...stringStored, ...singleValueCode]) expect(NON_TEXT_STORED_VALUE_TYPES.has(t), t).toBe(false);
5861
});
5962

6063
it.each([[''], [' '], ['\t\n']])('a blank %j on every non-string-typed column becomes null', (blank) => {

‎packages/objectql/src/validation/record-validator.optional-unique-blank.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import { normalizeBlankTypedValues } from './record-validator.js';
2020

2121
const CREDENTIAL = ['password', 'secret'];
2222
const textLike = [...STRING_VALUE_TYPES].filter((t) => !CREDENTIAL.includes(t));
23-
// Single-value string-stored types the `$empty` table reads `''` as a value on.
24-
const otherStringStored = ['select', 'radio', 'lookup', 'master_detail', 'user', 'autonumber'];
23+
// A string-stored type outside both string arms: `unique` alone never pulls it
24+
// in. (The single-value option and reference types have their own arm since
25+
// #29277, unique or not: `record-validator.single-value-blank.test.ts`.)
26+
const otherStringStored = ['autonumber'];
2527

2628
type Def = Record<string, unknown>;
2729
function schemaOf(types: readonly string[], extra: Def = {}, indexes?: unknown[]) {

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
* same bodies (`500 DATABASE_ERROR`; `failed` rows with `INTERNAL_ERROR` on the
1414
* batch doors). The door now stores the typed blank, `null`, on every door.
1515
*
16-
* Controls: a text / lookup / select `''` is stored as `''` (the spec's
17-
* `str_empty` — an empty string must not become null); a valid value on every
16+
* Controls: a text `''` is stored as `''` (the spec's `str_empty` — an empty
17+
* string must not become null), while an optional lookup / select `''` is
18+
* `null` since #29277 (`rest-data-single-value-blank.test.ts`); a valid value on every
1819
* typed column is stored unchanged; a required field's blank is refused as
1920
* `400 VALIDATION_FAILED` / `required`; and a non-numeric string on `progress`
2021
* is refused as `invalid_number`.
@@ -152,12 +153,12 @@ describe('REST write doors on SQLite: a cleared typed column stores null (#20308
152153

153154
const seed = (id: string) => ctx.engine.insert('blank_rest', { id, ...VALID });
154155

155-
it('POST create: the typed blanks store null; the string-stored blanks store ""', async () => {
156+
it('POST create: the typed blanks store null; a text blank stores "", a lookup / select blank null (#29277)', async () => {
156157
const res = await ctx.call('POST', '/api/v1/data/:object', { object: 'blank_rest' }, { id: 'c1', ...blankTyped(), ...STR_BLANK });
157158
expect(res.status).toBe(201);
158159
const raw = await ctx.stored('blank_rest', 'c1');
159160
expectTypedNull(raw, 'stored');
160-
expect([raw!.c_text, raw!.c_lookup, raw!.c_select]).toEqual(['', '', '']);
161+
expect([raw!.c_text, raw!.c_lookup, raw!.c_select]).toEqual(['', null, null]);
161162
const read = await ctx.engine.findOne('blank_rest', { where: { id: 'c1' } });
162163
expectTypedNull(read as any, 'read');
163164
// The before-state's worst cell: a stored '' on a boolean read back as `false`.

0 commit comments

Comments
 (0)