Skip to content

Commit 8209a2f

Browse files
fix(objectql): a cleared optional multi-value field stores null, so a second record cleared on a unique one is no longer refused 409 (#29837)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ87zVbP3nBfJDyeiMeBBoJsW)_ Fixes #29343 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:可选的多值字段(multiselect、tags、checkboxes,及 `multiple: true` 的 lookup / user / select / file / image)写入 `[]` 或 `''` 时,写入口统一存成 `null`;级联 `set_null` 删光最后一个成员时也读回 `null`。 - 为什么改:`[]` 被存成 JSON 文本 `'[]'`,唯一约束下第二条清空的记录被拒 409;按裁决 A(记录 6591577985)执行,spec 半边已由 #29763 落地。 - 风险与回滚:读形状变化,原来读回 `[]` 的可选多值字段改读 `null`;`$empty` 两者都算空,只按 `Array.isArray` 判断的读者要处理 `null`。回滚即 revert 本 PR。 - 先看:`packages/core/src/utils/blank-storage-form.ts`(新 reach `'emptyList'`)、`packages/objectql/src/validation/record-validator.ts` 的 `normalizeBlankTypedRow`、`packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts`(钉子从 `[]` 改为 `null`)。 - 两分钟验证:`pnpm --filter @objectstack/rest exec vitest run src/rest-data-multi-value-blank.test.ts`,预期 15/15 通过(main 上 12 条红)。 - 需要你做的:无,席位落地。 ## What changes - **Write door** (cherry-picked from the built branch `claude/project-thread-tfknlu`, `eb81c1f6` → `ce827aa2`, `55aa2656` → `f147106e`): `blankAsNullColumns` in `@objectstack/core` gains the reach `'emptyList'` for every optional column on the `$empty` table's `multi_value` row, and `blankStorageForm` stores `[]`, or exactly `''`, there as `null`. The objectql door's row walk (`normalizeBlankTypedRow`) now hands an empty list to the rule. Required columns, whitespace and non-empty lists are untouched; the `$empty` table is unchanged. - **Pins moved to `null`** (`f0afc329`), the cascade `set_null` repair that empties an optional set now reads back `null` through the door: - `packages/objectql/src/engine-cascade-delete-multivalue-probe.test.ts` (docblock, the "LAST member" pin, the "every dependent row" pin); - `packages/objectql/src/engine-cascade-delete-user-field.test.ts` (multi-valued `set_null`); - `packages/runtime/src/cascade-delete-multivalue-lookup-real-driver.integration.test.ts` (real SQLite driver, read back from the database); - `packages/services/service-automation/src/builtin/delete-record-predicate-walk-batched.integration.test.ts` (batched predicate walk). - **Prose**: the "`[]`, never `null`" sentence is replaced for optional columns in `packages/core/src/utils/required-emptiness.ts`, the two `packages/objectql/src/engine.ts` blocks (`set_null` doc and the member-removal comment) and the `record-validator.test.ts` docblock. The `required` half (an empty list fails `required`) is kept everywhere. - **Docs**: six hand-written pages now say an emptied optional multi-value field is stored as `null`, that a row stored earlier can still hold `[]`, and that `is_empty` matches both: `content/docs/api/data-api.mdx`, `content/docs/data-modeling/field-types.mdx`, `content/docs/data-modeling/fields.mdx`, `content/docs/data-modeling/validation-rules.mdx`, `content/docs/deployment/troubleshooting.mdx`, `content/docs/protocol/objectql/types.mdx`. No release notes, no CHANGELOG, no `content/docs/references/`. - **Readonly columns** (`b702c0f1`, round-2 ruling A, record 6598733139): the `'emptyList'` arm never reaches a `readonly: true` column. A readonly column is platform-stamped and never cleared by a writer, so `sys_activity.changed_fields` keeps `[]` for "stamped, nothing changed" (ADR-0139 E6). `BlankAsNullFieldDef` gains `readonly?: boolean`. The `'blank'` and `'empty'` arms are unchanged. One unit pin covers it, with controls. - **Changeset** `.changeset/29343-multi-value-blank-null.md` (`@objectstack/objectql` and `@objectstack/core`, patch): names ruling A, the Clause-② widening and the read change (`[]` → `null` for optional multi-value, including the cascade repair). ## Why Ruling A on this card (record 6591577985): an optional multi-value column written `[]` or `''` stores `null`, unique-covered or not; `required` still means a non-empty list; the `$empty` table is unchanged. The spec half (`FieldSchema`'s `multiple` and `required` doc blocks) landed in #29763 for #29652. Stored `'[]'` / `'""'` rows are cleaned by #29653, not here. ## Tests All readings below are at head `8e4ba024` (the three commits plus a merge of `origin/main` `b62463f1`), except the red-first reading, which is at `origin/main` `97c2982c`. Head `60dc73ff` adds one comment-only commit: the `blankSingleValueColumns` docblock in `packages/cli/src/commands/migrate/blank-single-values.ts` (the contract review's should-fix, 6597445147). At `60dc73ff`, the cli unit test for the command passes 46/46, its integration test passes 6/6, and the cli typecheck exits 0. Head `b702c0f1` (after merging `origin/main` `55dca466` as `b784ff6a`) adds the readonly carve-out. At `b702c0f1` the following pass: - cli `activity-changed-fields.integration` 4/4 and `blank-single-values` 6/6; - plugin-audit `activity-changed-fields-backfill.integration` 5/5; - REST 34/34; - core 2937/2937 and its typecheck; - objectql 10777/10777; - service-automation 4/4; - the runtime real-driver suite (7, 1 skipped). With the carve-out removed, the new pin and exactly the CI reds go red (cli cases 2–4, plugin-audit case 2). They pass again once it is restored. - Red first, at `origin/main` `97c2982c` with its own build: `rest-data-multi-value-blank.test.ts` 12 failed / 3 passed (15). Every per-type unique case (multiselect, multi-value lookup, tags, unique-index member) answered 409 on the second `[]` or `''`. - Green after the change: that file plus `rest-data-single-value-blank` and `rest-data-optional-unique-blank` 34/34; objectql targeted files 191/191; runtime real-driver file 7 passed, 1 skipped (the Postgres cell, needs `OS_TEST_POSTGRES_URL`); service-automation batched walk 4/4. - Whole packages: `@objectstack/objectql` test 545 files, 10767/10767 (before and after merging `main`); `@objectstack/core` test 132 files, 2936/2936; metadata-protocol `seed-loader` files 26, 257/257; cli `blank-single-values.integration.test.ts` 6/6 (integration project). Typecheck exit 0 for core, objectql, runtime, service-automation. - Ablation, one-shot: `blankStorageForm`'s `'emptyList'` arm replaced to return the value unchanged (`scripts/ablation-replace.mjs`), core rebuilt, `ablation-dist-preflight.mjs` exit 0. Result: objectql 5 failed / 38 passed (both probe pins, the user-field pin, two door unit pins); REST 12 failed / 3 passed. Restored: blob equals HEAD, `git diff HEAD` empty, rebuilt, preflight `--absent` exit 0, the same files 43/43 and 15/15. A first attempt carried its marker in a comment, which the build strips; its preflight answered exit 1, so that run is void and was repeated with a code marker. ## Acceptance notes - Read change, stated in the changeset: an optional multi-value field cleared from now on reads `null`, not `[]`, including the `set_null` cascade repair that removes the last member. `$empty: true` matches both. A reader that branches on `Array.isArray` alone has to read `null` as empty too. - `packages/cli/src/commands/migrate/blank-single-values.ts` (docblock near line 109) still says the `blankAsNullColumns` map holds three arms; it now holds four. That file is outside this claim's surface; the behaviour is pinned (`blank-single-values.integration.test.ts` keeps its column set). Noted for the seat. - `test:repo` (vitest `repo` project) was not run locally; it belongs to CI (`Test Core`). ## Verification - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `8e4ba024`: 101 families derived. All 101 run locally; every one exit 0. At `60dc73ff`, the derivation is the same 101 families. The 46 families on the changed path re-ran, all exit 0, and `--ran` reports 101 run with 0 NOT-MEASURED. At `b702c0f1`: 101 derived, 101 run, all exit 0, and `--ran` exits 0. - Seven first answered exit 3 (`PREREQUISITE NOT MET`, built output missing: `check:skill-examples`, `check:dts-closure` (exit 1, read while a sibling build rewrote `driver-sql/dist`), `check:dual-build-cjs-loads`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`) or could not reach a pinned fixture commit on the shallow clone (`check-plugin-teardown-shape.mjs --self-test`). After building the client SDK and the packages closure, and fetching that commit, each re-ran exit 0. - `dispatch-gates --ran`: 101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - CI runs the full farm (`pnpm lint`, `test:repo`, dogfood is not owed: no `!` in the title and no `(narrowing)` arm). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind (`'emptyList'`). - [x] 3. No formatter ran; `pnpm lint` belongs to CI. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. The changeset carries both declaration lines, as does this body. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbWuRcoCQaZSTv51YHt --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWuRcoCQaZSTv51YHt)_ <!-- signed: domain:server#3 · os-warren · session_01JQwxbYyp6wSRcywGFK2L4A --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 74fd4bf commit 8209a2f

22 files changed

Lines changed: 585 additions & 95 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/core': patch
4+
---
5+
6+
fix(objectql): a cleared optional multi-value field stores `null`, so a second record cleared on a unique one is no longer refused `409 UNIQUE_VIOLATION`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
This executes ruling A on #29343 (record 6591577985), which amends the #9447 sentence "an emptied multi-value lookup reads back as `[]`, never `null`" for optional columns: a cleared optional multi-value field is stored as `null`, the same as one never set. The spec half (the `multiple` and `required` doc blocks of `FieldSchema`) landed separately; this is the runtime half. It is a widening (Clause-②): a write that used to be refused `409` now lands, and no write that used to land is refused.
12+
13+
A cleared `multiselect`, `checkboxes` or `tags` field, or a `select`, `radio`, `lookup`, `user`, `file` or `image` field with `multiple: true`, sent as `[]` was stored as the empty list (the JSON text `[]` on SQL), and sent as `''` was stored as the JSON text `""`. Neither holds a member, yet each was a value to the store: on a unique column, or a column in a unique index, the second record cleared was refused "A record with this value already exists".
14+
15+
- **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 `[]`, or exactly `''`, as `null` on every optional column the `$empty` table puts on its `multi_value` row. Unique or not. A non-empty list is stored as sent.
16+
- **Read change.** An optional multi-value field cleared from now on reads back `null`, not `[]`, whoever clears it: an API or form write of `[]` or `''`, and the `set_null` cascade repair that removes the last member of a multi-value reference (it used to leave `[]`). A reader that branched on `Array.isArray` alone reads `null` and `[]` alike as empty. It still matches `is_empty` / `$empty: true` (the table reads `null` and `[]` as empty on that row), and now also matches `$null: true` and `field = null`.
17+
- **Readonly columns.** A `readonly: true` multi-value column is platform-stamped and never cleared by a writer, so a `[]` written there keeps `[]` (ruling record 6598733139): `sys_activity.changed_fields` still stores `[]` for "stamped, nothing changed" and `null` for "unstamped".
18+
- **Unchanged.** The `$empty` table itself. A `required` multi-value field given `[]` or `''` is refused `required` as before, also when it declares a `defaultValue`. Whitespace is a value. A real duplicate is still `409 UNIQUE_VIOLATION`.
19+
- **Defaults.** An optional multi-value field that declares a `defaultValue` now takes it on insert when sent `[]` or `''`, exactly as when sent `null`.
20+
- **Rows already stored.** Existing `[]` and `""` values are not migrated by this change. They keep their stored form, still answer `$empty: true`, and still collide with each other on a unique column until a data step cleans them. `os migrate blank-single-values` does not touch these columns.
21+
- **Seed replay.** A seed that writes `[]` or `''` on such a column is compared in the form the door stores it, `null`. A row that already stores `[]` is rewritten to `null` once on the next replay, then no longer changes.
22+
- **Export.** `@objectstack/core`'s `BlankAsNullReach` gains the member `'emptyList'`, which `blankAsNullColumns` returns for these columns and `blankStorageForm` reads.

‎content/docs/api/data-api.mdx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,11 @@ ROW: the delete is refused only over rows whose set the member removal would
301301
empty, and `dependentCount` counts just those rows — a row that keeps another
302302
member is updated and does not block the delete. On a
303303
`multiple: true` reference where `set_null` does run, it
304-
removes just the deleted id from the array and keeps the rest, and a reference set
305-
emptied that way reads back as `[]` — never `null`, so a client that branches on
306-
`null` for "no link" misses the emptied case (the `multiple` doc block in
307-
`packages/spec/src/data/field.zod.ts`, rendered in the
304+
removes just the deleted id from the array and keeps the rest. On an optional field
305+
a reference set emptied that way reads back as `null`, the same as one never set; a
306+
row stored before that rule can still read back `[]`, so a client treats `null` and
307+
`[]` alike as "no link", as the `is_empty` filter (`$empty`) does (the `multiple` doc
308+
block in `packages/spec/src/data/field.zod.ts`, rendered in the
308309
[Field reference](/docs/references/data/field)).
309310

310311
A record that the same delete removes never blocks it. Before any refusal is

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ Reference to a record in another object (foreign key).
496496
|:---|:---|:---|:---|
497497
| `reference` | `string` | **required** | Target object name (snake_case). A stored-metadata table (`sys_metadata`, `sys_metadata_history`) is refused — see [below](#a-relationship-never-targets-a-stored-metadata-table) |
498498
| `referenceFilters` | `string[]` | — | **Removed** (#2377, ADR-0049) — no longer a recognized field property. `FieldSchema` is a strict object, so an unknown key is **rejected with guidance**, not silently stripped (ADR-0078): the error echoes the offending key and prescribes the replacement. Use structured `lookupFilters` + `dependsOn` instead; see [Relationships](/docs/data-modeling/relationships) |
499-
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**. On a `multiple: true` required lookup the escalation is judged per referencing row: only a row the member removal would leave EMPTY is refused. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and a set emptied that way is stored as `[]`, never `null` |
499+
| `deleteBehavior` | `'restrict' \| 'cascade' \| 'set_null'` | `'set_null'` | Behavior when referenced record is deleted. On a *required* lookup `set_null` is escalated to `restrict`, since a NOT NULL foreign key cannot be cleared — **whether the `set_null` was defaulted or written out explicitly**. On a `multiple: true` required lookup the escalation is judged per referencing row: only a row the member removal would leave EMPTY is refused. `cascade` and `restrict` are the values honored as written. Where `set_null` does run, a `multiple: true` lookup loses only the deleted **member** — the other members are kept, and on an optional field a set emptied that way is stored as `null`, the same as one never set |
500500

501501
```typescript
502502
{ name: 'company', label: 'Company', type: 'lookup', reference: 'account' }
@@ -1022,7 +1022,7 @@ These properties are available on **all** field types:
10221022
| `type` | `FieldType` | **required** | One of the supported field types |
10231023
| `required` | `boolean` | `false` | Whether the field is required |
10241024
| `unique` | `boolean \| 'global' \| 'organization'` | `false` (`'organization'` on `autonumber`) | Unique constraint **and its scope** (ADR-0120). `'organization'` = one holder per organization; `true` = that same per-organization scope (positional synonym — prefer the explicit spelling in new code); `'global'` = one holder across the whole installation; `false` = no constraint. Omitted ⇒ `false`, except on an `autonumber` field, where omitted ⇒ `'organization'` — an auto-number is a business identifier, so the platform makes it unique per organization by default; write `unique: false` explicitly to opt out. `'tenant'`/`'org'` are rejected — the word is `'organization'`. Full rule in the `unique` doc block in [`FieldSchema`](/docs/references/data/field) |
1025-
| `multiple` | `boolean` | `false` | Allow array of values (multi-record lookup, multi-select, multi-file). An emptied multi-value lookup reads back as `[]`, never `null` — the `multiple` doc block in [`FieldSchema`](/docs/references/data/field) |
1025+
| `multiple` | `boolean` | `false` | Allow array of values (multi-record lookup, multi-select, multi-file). An emptied optional multi-value field is stored as `null`, the same as one never set (`[]` and `""` written to it store `null`); a row stored before that rule can still hold `[]`, and `is_empty` matches both — the `multiple` doc block in [`FieldSchema`](/docs/references/data/field) |
10261026
| `searchable` | `boolean` | `false` | Search this field when the object declares no `searchableFields` — any type but `secret` / `password` / `vector`; a declared `searchableFields` is the whole set |
10271027
| `sortable` | `boolean` | `true` | Allow sorting by this field |
10281028
| `hidden` | `boolean` | `false` | Hide from default views |

‎content/docs/data-modeling/fields.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,9 +292,9 @@ Clearing a relationship is not a dangling reference: `null`, `""` and `[]` all m
292292
"no link". Which of them `deleteBehavior: 'set_null'` writes when the referenced
293293
record goes away depends on the field: a single-value lookup is set to `null`, while
294294
a `multiple: true` lookup keeps its other members and loses only the deleted one —
295-
and a set emptied that way is written as `[]`, never `null` (the `multiple` doc block
296-
in `packages/spec/src/data/field.zod.ts`, rendered in the
297-
[Field reference](/docs/references/data/field)).
295+
and on an optional field a set emptied that way is stored as `null`, the same as one
296+
never set (the `multiple` doc block in `packages/spec/src/data/field.zod.ts`, rendered
297+
in the [Field reference](/docs/references/data/field)).
298298
299299
<Callout type="info">
300300
`deleteBehavior` governs what happens to *this* record when the **referenced**

‎content/docs/data-modeling/validation-rules.mdx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,14 @@ These properties apply to **all** field types and are validated by the base `Fie
261261

262262
**Default constraints:** Validates that referenced record exists. Foreign key integrity enforced.
263263

264-
**Multi-value lookups (`multiple: true`).** The empty set is representable: an emptied
265-
multi-value lookup is stored and read back as `[]`, never `null` — including when
266-
`deleteBehavior: 'set_null'` removes the last remaining member. The declared contract
267-
lives in the `multiple` and `required` doc blocks of
268-
`packages/spec/src/data/field.zod.ts` (rendered in the
269-
[Field reference](/docs/references/data/field)), and it makes `required` on a
270-
multi-value lookup mean **non-empty array**.
264+
**Multi-value lookups (`multiple: true`).** An emptied optional multi-value lookup is
265+
stored and read back as `null`, the same as one never set — `[]` and `""` written to it
266+
store `null`, including when `deleteBehavior: 'set_null'` removes the last remaining
267+
member. A row stored before that rule can still hold `[]`; the `is_empty` filter
268+
(`$empty`) reads `null` and `[]` alike as empty. The declared contract lives in the
269+
`multiple` and `required` doc blocks of `packages/spec/src/data/field.zod.ts`
270+
(rendered in the [Field reference](/docs/references/data/field)), and it makes
271+
`required` on a multi-value lookup mean **non-empty array**.
271272

272273
<Callout type="info">
273274
The `required`-means-non-empty half is **enforced by the record validator** (#9476, per

‎content/docs/deployment/troubleshooting.mdx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,13 @@ client.data.find('project_task', { /* query */ });
250250

251251
<Callout type="info">
252252
On a `multiple: true` lookup, `set_null` clears only the **member** that was deleted —
253-
the child's other references survive — and a set emptied that way is stored as `[]`,
254-
never `null` (the `multiple` doc block in `packages/spec/src/data/field.zod.ts`,
255-
rendered in the [Field reference](/docs/references/data/field)). A cleanup query or
256-
client branch that looks for `null` to find cleared references will not match those
257-
rows; test for an empty array instead.
253+
the child's other references survive — and on an optional field a set emptied that
254+
way is stored as `null`, the same as one never set (the `multiple` doc block in
255+
`packages/spec/src/data/field.zod.ts`, rendered in the
256+
[Field reference](/docs/references/data/field)). A row stored before that rule can
257+
still hold `[]`, so a cleanup query or client branch that finds cleared references
258+
uses the `is_empty` filter (`$empty: true`), which matches both, rather than testing
259+
for `null` or for an empty array alone.
258260
</Callout>
259261

260262
---

‎content/docs/protocol/objectql/types.mdx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -726,12 +726,14 @@ const opportunities = await engine.find('opportunity', {
726726
- `restrict`: Prevent deletion if references exist
727727
- `cascade`: Delete this record when referenced record is deleted
728728

729-
> **Residual shape of a multi-value reference.** A `multiple: true` lookup emptied by
730-
> member removal is written as `[]`, **never `null`**. That is not a cascade
731-
> convention: the representation binds every writer — cascade repair, form clears and
732-
> API writes alike — so a reader of an array field never needs a null branch. The
733-
> guarantee is pinned by `FieldSchema`, in the `multiple` doc block of
734-
> `packages/spec/src/data/field.zod.ts` (rendered in the
729+
> **Residual shape of a multi-value reference.** An optional `multiple: true` lookup
730+
> emptied by member removal is stored as `null`, the same as one never set. That is
731+
> not a cascade convention: the representation binds every writer — cascade repair,
732+
> form clears and API writes alike, where `[]` and `""` written to an optional
733+
> multi-value field store `null`. A reader of an array field reads `null` and `[]`
734+
> alike as empty, as the `is_empty` filter (`$empty`) does, since a row stored before
735+
> this rule can still hold `[]`. The rule is pinned by `FieldSchema`, in the
736+
> `multiple` doc block of `packages/spec/src/data/field.zod.ts` (rendered in the
735737
> [Field reference](/docs/references/data/field)).
736738

737739
> **Required foreign keys.** A `required: true` lookup cannot be nulled, so

‎packages/cli/src/commands/migrate/blank-single-values.integration.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2222
import { ObjectQL } from '@objectstack/objectql';
2323
import { SqlDriver } from '@objectstack/driver-sql';
24+
import { blankAsNullColumns } from '@objectstack/core';
2425
import { blankSingleValueColumns, cleanBlankSingleValues } from './blank-single-values.js';
2526

2627
const OPTS = [{ value: 'gold', label: 'Gold' }, { value: 'silver', label: 'Silver' }];
@@ -100,6 +101,11 @@ describe('os migrate blank-single-values: the column set', () => {
100101
['audio', 'avatar', 'file', 'image', 'lookup', 'master_detail', 'radio', 'select', 'tree', 'user', 'video'],
101102
);
102103
});
104+
105+
it('[#29343] a multi_value-row column is in the door\'s map on its own reach, and out of this walk', () => {
106+
expect(blankAsNullColumns(OBJ as any).get('m_select')).toBe('emptyList');
107+
expect(blankSingleValueColumns(OBJ).map((c) => c.name)).not.toContain('m_select');
108+
});
103109
});
104110

105111
describe('os migrate blank-single-values: the walk on sqlite (#29431)', () => {

‎packages/cli/src/commands/migrate/blank-single-values.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,18 @@ const SYSTEM_CTX = { isSystem: true } as const;
106106
/**
107107
* The columns of one object this run cleans: the write door's #29277 arm, read
108108
* from `blankAsNullColumns` (`@objectstack/core`, the one rule the door and
109-
* the seed replay read). That map holds three arms; this run takes the two
109+
* the seed replay read). That map holds four arms; this run takes the two
110110
* `'empty'` ones minus the text-like columns of the #29176 unique arm, which
111111
* leaves exactly the optional single-value columns on the `$empty` table's
112112
* `null_only` row: options, references and file references today. The text
113113
* types are the spec's `STRING_VALUE_TYPES`, the set the map itself reads for
114114
* that arm: no type name is listed here.
115115
*
116-
* Out, by the map's own rule: `required` columns, lifecycle columns,
117-
* `multiple: true` columns (the `multi_value` row), typed columns (the #20308
118-
* `'blank'` arm, whose door already refuses to store a blank as a value).
116+
* Out, by the map's own rule: `required` columns and lifecycle columns. Out
117+
* because this run takes only `reach === 'empty'`: `multiple: true` columns
118+
* (the `multi_value` row), which the map holds on the #29343 `'emptyList'`
119+
* arm, and typed columns (the #20308 `'blank'` arm, whose door already refuses
120+
* to store a blank as a value).
119121
*/
120122
export function blankSingleValueColumns(schema: unknown): Array<{ name: string; type: string }> {
121123
const fields = (schema as { fields?: Record<string, BlankAsNullFieldDef | undefined> } | undefined)?.fields;

‎packages/core/src/utils/blank-storage-form.test.ts‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const schema = {
2424
indexes: [{ fields: ['idx_a'], unique: true }, { fields: ['code'] }],
2525
};
2626

27-
describe('blankAsNullColumns: the three arms of the write door\'s blank-as-null rule', () => {
27+
describe('blankAsNullColumns: the four arms of the write door\'s blank-as-null rule', () => {
2828
const cols = blankAsNullColumns(schema);
2929

3030
it('[#20308] a typed column takes any blank, required and lifecycle included', () => {
@@ -44,7 +44,45 @@ describe('blankAsNullColumns: the three arms of the write door\'s blank-as-null
4444
expect(cols.get('stage')).toBe('empty');
4545
expect(cols.get('ref')).toBe('empty');
4646
expect(cols.get('pic')).toBe('empty');
47-
for (const out of ['stage_req', 'tags', 'blob', 'num', 'created_by']) expect(cols.has(out), out).toBe(false);
47+
for (const out of ['stage_req', 'tags', 'blob', 'num', 'created_by']) expect(cols.get(out), out).not.toBe('empty');
48+
});
49+
50+
it('[#29343] "emptyList" on every optional column on the multi_value row, unique or not', () => {
51+
const list = blankAsNullColumns({
52+
fields: {
53+
tags: { type: 'select', multiple: true, options: [{ value: 'a' }] },
54+
ms: { type: 'multiselect', options: [{ value: 'a' }] },
55+
ms_u: { type: 'multiselect', unique: true, options: [{ value: 'a' }] },
56+
cb: { type: 'checkboxes', options: [{ value: 'a' }] },
57+
tg: { type: 'tags' },
58+
refs: { type: 'lookup', multiple: true },
59+
pics: { type: 'image', multiple: true },
60+
ms_req: { type: 'multiselect', required: true, options: [{ value: 'a' }] },
61+
created_by: { type: 'user', multiple: true },
62+
ref: { type: 'lookup' },
63+
},
64+
});
65+
for (const f of ['tags', 'ms', 'ms_u', 'cb', 'tg', 'refs', 'pics']) expect(list.get(f), f).toBe('emptyList');
66+
expect(list.has('ms_req')).toBe(false);
67+
expect(list.has('created_by')).toBe(false);
68+
expect(list.get('ref')).toBe('empty');
69+
});
70+
71+
it('[#29343] a readonly optional tags column written [] keeps [] — platform-stamped, never cleared (record 6598733139)', () => {
72+
const cols = blankAsNullColumns({
73+
fields: {
74+
changed_fields: { type: 'tags', required: false, readonly: true },
75+
tg: { type: 'tags' },
76+
stage_ro: { type: 'select', readonly: true, options: [{ value: 'a' }] },
77+
},
78+
});
79+
expect(cols.has('changed_fields')).toBe(false);
80+
const stamped: string[] = [];
81+
expect(blankStorageForm(stamped, cols.get('changed_fields'))).toBe(stamped);
82+
// Controls: the same type without readonly still takes the arm, and the
83+
// 'empty' arm is untouched by readonly.
84+
expect(cols.get('tg')).toBe('emptyList');
85+
expect(cols.get('stage_ro')).toBe('empty');
4886
});
4987

5088
it('lifecycle names are the five engine-owned columns', () => {
@@ -66,6 +104,17 @@ describe('blankStorageForm', () => {
66104
expect(blankStorageForm('', undefined)).toBe('');
67105
});
68106

107+
it('"emptyList" nulls [] and exactly ""; whitespace and a non-empty list stay', () => {
108+
expect(blankStorageForm([], 'emptyList')).toBeNull();
109+
expect(blankStorageForm('', 'emptyList')).toBeNull();
110+
const list = ['a'];
111+
expect(blankStorageForm(list, 'emptyList')).toBe(list);
112+
for (const v of [' ', 'a', 0, false, null, undefined]) expect(blankStorageForm(v, 'emptyList')).toBe(v);
113+
// An empty list on any other reach is not a blank of that column.
114+
const empty: unknown[] = [];
115+
for (const r of ['blank', 'empty', undefined] as const) expect(blankStorageForm(empty, r)).toBe(empty);
116+
});
117+
69118
it('a non-blank value or a non-string comes back as it is', () => {
70119
const obj = { a: 1 };
71120
for (const v of ['a', 0, false, null, undefined, obj]) {

0 commit comments

Comments
 (0)