Skip to content

Commit b13fc74

Browse files
feat(cli): os migrate blank-multi-values cleans the empty lists and empty strings stored on optional multi-value columns (#29343 ruling A, step 3) (#30508)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKpJdJ6QTJwiKsZotmRvbid)_ domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF ## 维护者速读 - **改了什么:** 新增一次性清理命令 `os migrate blank-multi-values`:把多选、复选框、标签、多选查找这类可选多值字段里以前存下的空列表 `[]` 和空字符串 `''` 改写成 `null`,和现在清空字段时写入口存的形式一致。默认只预览(按对象、按字段计数,不写任何行),加 `--apply` 才改写。 - **为什么改:** #29343 之后清空一个多值字段存的是 `null`,但之前清空的旧行还是 `'[]'` 或 `'""'`,于是「为空」筛选、分组和读出来的值把新旧两批清空的行分成三种;`'""'` 连 `$empty` 都找不到。 - **风险与回滚:** 只在 `--apply` 时改数据,且只改「空列表 / 空字符串」这两种值;必填字段、只读字段(比如 `sys_activity.changed_fields` 里平台盖章的 `[]`)、单值字段、有内容的列表都不碰。列集直接读写入口自己的规则 `blankAsNullColumns`,两边不会不一致。重复运行只会报 0。回滚即撤本 PR(已改写的 `null` 与今天清空的行完全相同,无需回写)。 - **先看:** `packages/cli/src/commands/migrate/blank-multi-values.ts` 顶部的 `blankMultiValueColumns` 和 `isBlankMultiValue`(选哪些列、什么算空);`blank-single-values.ts` 里抽出的共享遍历 `walkBlankColumns`(兄弟命令改为调用它,行为不变)。 - **两分钟验证:** `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/blank-multi-values.integration.test.ts`,预期 6 条全绿,其中「改写前 `$null` 只找到今天清空的那行」和「`--apply` 后三行都读作 null」两条。 - **需要你做的:** 无,席位落地。 Fixes #29653 Clause-②: no Metadata-protocol: none ## What changes **A new data step, `os migrate blank-multi-values`, beside `blank-single-values`.** The card names an `os migrate value-shapes` step; the vehicle follows the server seat's ruling on #29431 (record 6588116815) for the single-value sibling: `value-shapes` documents that it rewrites nothing and records the ADR-0104 flag, so a repair of stored blanks is a command of its own, with no deployment flag. The binding text of ruling A on #29343 (record 6591577985, execution step 3) is "an `os migrate` data step, filed as its own card, like #29431", and the maintainer's words were 「另开一张清理已存 '[]' 的 migrate 卡」; `value-shapes` appears only in the card title. Named in the report's deviations so the seat can overrule. - **Columns** (`blankMultiValueColumns`): the `'emptyList'` arm of `blankAsNullColumns` (`@objectstack/core`), the one rule the write door and the seed replay read. That is every optional column on the `$empty` table's `multi_value` row (`isMultiValueField`: `multiselect`, `checkboxes`, `tags`, and `select` / `radio` / `lookup` / `user` / `file` / `image` with `multiple: true`). Out by that same rule, with no second list: `required` columns, lifecycle columns, and `readonly: true` columns (ruling record 6598733139, the scope note on the card: `sys_activity.changed_fields` stores a stamped `[]`). - **What counts as blank** (`isBlankMultiValue`): asked of the door's own `blankStorageForm(value, 'emptyList')`, so `[]` and exactly `''`, never whitespace and never a non-empty list. - **The walk** is the sibling's: `cleanBlankSingleValues`' body moves, unchanged, into an exported `walkBlankColumns(engine, logger, options, spec)`, where `spec` names the step, the column selector and the blank test. `cleanBlankSingleValues` now calls it with its own two; its tests pass unchanged. Keyset walk by `id`, value tested in JS, per-row failures reported and the rest continued, unreadable objects reported and the run marked truncated. - **The command** mirrors `blank-single-values`: dry run by default on a read-only boot, `--apply` (with `--yes` or a prompt), SQLite occupancy gate (`--force`), `--object` narrowing that refuses an undeclared name, `--max-records`, `--json`. - **Rosters**: the one-shot family (`schema-migrate.one-shot-family.integration.test.ts`), JSON stdout purity (`json-stdout-purity.e2e.test.ts`) and the confirmation exit pin (`migrate-confirmation-exit.pin.test.ts`) gain the new command, as PR #29615 added its sibling. - **Docs**: `content/docs/deployment/cli.mdx` gains the table row and a section, beside `blank-single-values`. - **Postgres**: not covered, as the sibling step is not (its integration test is SQLite only); the walk reads and writes through the engine, not dialect SQL. ## Tests `packages/cli/src/commands/migrate/blank-multi-values.integration.test.ts` (new, real `ObjectQL` + SQLite `SqlDriver`; legacy rows planted through the driver below the door): - **Premise (first act of the order):** the door, through `engine.update` with `[]`, stores `null` on all four types; the driver-planted legacy rows store `'[]'` and `'""'`. - **Red before:** `$null: true` finds only the row cleared today; `$empty: true` misses the stored `''`; the read shapes are `null`, `[]` and `''` for the three rows. - **Column set:** exactly one column per type (multiselect, checkboxes, tags, `multiple: true` lookup); `required`, `readonly` and single-value columns are out. - **Dry run:** counts 2 per in-scope column plus 1 on a second object (9), writes nothing. - **`--apply`:** every legacy value physically `null`; `$null` finds all three rows; `$empty` finds the former `''`; `required` / `readonly` / single-value columns and non-empty lists stay byte-identical; a re-run reads zero. - **`objects`** narrows the walk. <details><summary>Runs (head cdb3666..4864b01, before the main merge)</summary> - `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 src/commands/migrate/blank-multi-values.integration.test.ts src/commands/migrate/blank-single-values.integration.test.ts` → `Test Files 2 passed (2)`, `Tests 12 passed (12)`, lock VERDICT command-exit 0. - Ablation (committed first, through `scripts/ablation-replace.mjs`, anchor x1 → x0, blob f45c48e → 25dba17b): `isBlankMultiValue` reduced to the empty list only → `Tests 2 failed | 4 passed (6)` (the dry-run count and the `--apply` pin); restored, blob == HEAD, `git diff HEAD` empty. - `pnpm --filter @objectstack/cli typecheck` → exit 0 (test layer: 3 file(s) / 28 error(s) held in the ledger, unchanged). - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` → `Test Files 2 failed | 388 passed (390)`, `Tests 5453 passed | 29 skipped`; the two files were `published-subpath-console.pin` and `published-subpath-hook-body.pin`, which refused because `packages/cli/dist` was not built. After `pnpm --filter @objectstack/cli build`, those two plus `migrate-confirmation-exit.pin` → `Test Files 3 passed (3)`, `Tests 81 passed (81)`. - `src/utils/schema-migrate.one-shot-family.integration.test.ts` → `Tests 164 passed (164)`. - `OS_TEST_TIERS=nightly … vitest run test/json-stdout-purity.e2e.test.ts` → `Tests 101 passed (101)`. - `pnpm lint` → exit 0. </details> ## Acceptance notes - `required` multi-value columns stay out of scope, as the card says. - The card's title names `value-shapes`; the ruling binds only "an `os migrate` data step … like #29431", so this follows #29431's vehicle; see What changes. ## Verification Local subset, derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the merge base `c58008187` (8 paths, +620 / -5, under the 3000-line threshold); every exit code captured before any pipe. - `dispatch-gates --ran`: **92 derived famil(ies) accounted for — 89 run (all exit 0), 3 NOT-MEASURED (exit 3, prerequisite not met)**: - `node scripts/check-plugin-teardown-shape.mjs --self-test` — its fixture is pinned to a commit this shallow clone does not hold. - `pnpm --filter @objectstack/spec run check:skill-examples` — reads the built client SDK (`@objectstack/client-react`, `@objectstack/client`), outside this diff's build closure. - `pnpm check:dual-build-cjs-loads` — reads `@objectstack/studio`'s `dist/`, outside this diff's build closure. - `check:i18n`, `check:i18n-coverage` and `check:i18n-walk-parity` first answered exit 3 (CLI not yet built) and were re-run after `pnpm --filter @objectstack/cli build`: exit 0 each. - None of the three reads a path this PR changes; CI runs each. - `pnpm lint` → exit 0. - After `git merge origin/main` (driver-sql and spec moved): `pnpm install --frozen-lockfile`, `pnpm --filter '@objectstack/cli...' build`, `rm -rf packages/runtime/.objectstack`, then the new suite, the sibling suite, the one-shot family and the confirmation pin → `Test Files 4 passed (4)`, `Tests 228 passed (228)`, lock VERDICT command-exit 0, at head `2ae51bb5`. - Dogfood: n/a (no `!` in the title, no `(narrowing)` arm). - CI check-runs: not yet reported at the time of writing. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CYyGh7z9Ki2krSKBC6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d2e2f6f commit b13fc74

8 files changed

Lines changed: 620 additions & 5 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os migrate blank-multi-values` rewrites the `[]` and `''` still stored on optional multi-value columns to `null`
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Since #29343 the write door stores a cleared optional multi-value value (`[]` or `''` on a `multiselect`, `checkboxes` or `tags` column, or on a `multiple: true` `select`, `radio`, `lookup`, `user`, `file` or `image` column) as `null` (ruling A on #29343). Rows cleared before that still hold the JSON text `'[]'` or `'""'`, so `= null`, grouping and the read shape split them from rows cleared since. This new command is the one-time clean that ruling's execution step 3 binds, an `os migrate` data step of its own beside `blank-single-values`, not part of `os migrate value-shapes`, which rewrites nothing.
11+
12+
- **Columns.** Read from `blankAsNullColumns` in `@objectstack/core`, the rule the write door and the seed replay read: its optional columns on the `$empty` table's `multi_value` row. A `required` column, a `readonly` column (a platform-stamped `[]`, such as `sys_activity.changed_fields`) and a single-value column are never touched, nor is a non-empty list.
13+
- **Dry run by default.** It counts the stored `[]` and `''` per object and column and writes no row. `--apply` rewrites each affected row's blank columns to `null` through the engine. `--object` narrows the walk and refuses a name the deployment does not declare. `--apply` refuses a SQLite file another process holds unless `--force`.
14+
- **No flag.** Nothing is gated on it. A re-run that reports zero is the verification.

‎content/docs/deployment/cli.mdx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1305,6 +1305,7 @@ where the data lives.
13051305
| `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run |
13061306
| `os migrate stored-expressions` | Fill a stored computed field (a non-formula field declaring `expression`) on the rows stored before the field was declared, which hold `NULL` until each is next written; the boot warns, naming the field, when a sample of rows holds such a `NULL`. `--changed` also recomputes values an edited expression no longer gives. Rows an expression cannot compute while they hold a value are listed, never written. No flag; idempotent |
13071307
| `os migrate blank-single-values` | Rewrite the empty strings (`''`) still stored on optional single-value option, reference and file columns to `null`, the form a cleared value is stored in today, so `$empty` finds those rows. Repairs values; no flag, nothing depends on it having run. See [below](#os-migrate-blank-single-values) |
1308+
| `os migrate blank-multi-values` | Rewrite the empty lists (`[]`) and empty strings (`''`) still stored on optional multi-value columns to `null`, the form a cleared value is stored in today, so rows cleared before and after read alike. Repairs values; no flag, nothing depends on it having run. See [below](#os-migrate-blank-multi-values) |
13081309
| `os migrate option-codes` | Rewrite option values still stored as a number, a boolean or SQLite `"N.0"` text (rows written before option fields stored the code string) to that code string, so filters, reads and unique keys see one value. Values it cannot decide are listed, never rewritten. No flag; idempotent. See [below](#os-migrate-option-codes) |
13091310
| `os migrate text-scalars` | Rewrite numbers and booleans still stored raw on text fields (rows written before text fields stored the string) to that string, so filters, reads and unique keys see one value. SQLite `"N.0"` text it cannot decide is listed, never rewritten. No flag; idempotent. See [below](#os-migrate-text-scalars) |
13101311
| `os migrate record-currency-codes` | Write the currency code you give into the rows of a record-mode currency field that hold an amount and no code, the rows stored before the field was switched to `currencyMode: 'record'`. The code is never derived. No flag; idempotent. See [below](#os-migrate-record-currency-codes) |
@@ -1533,6 +1534,29 @@ column and a text-like column are never touched. Idempotent: a second run
15331534
finds nothing and writes nothing, and re-running until it reports zero is the
15341535
verification, so it records no flag.
15351536
1537+
#### `os migrate blank-multi-values`
1538+
1539+
Clearing an optional multi-value field (`multiselect`, `checkboxes`, `tags`,
1540+
or a `select`, `radio`, `lookup`, `user`, `file` or `image` field with
1541+
`multiple: true`) stores `null`. Rows cleared before that rule landed still
1542+
hold an empty list or an empty string, so a filter on `null`, a grouping and
1543+
the value a read returns all tell them apart from rows cleared since, and
1544+
`$empty: true` does not find the empty string.
1545+
1546+
```bash
1547+
os migrate blank-multi-values # Dry run: counts per object and column, writes no rows
1548+
os migrate blank-multi-values --apply # Rewrite [] and '' to null (prompts)
1549+
os migrate blank-multi-values --apply --yes --json # CI / scripts
1550+
os migrate blank-multi-values --object account # Restrict to one object (repeatable)
1551+
```
1552+
1553+
The columns are the ones the write door itself stores a cleared value as
1554+
`null` on, so the two never disagree. A `required` column, a `readonly`
1555+
column (its empty list is a value the platform stamped) and a single-value
1556+
column are never touched, nor is a list that holds anything. Idempotent: a
1557+
second run finds nothing and writes nothing, and re-running until it reports
1558+
zero is the verification, so it records no flag.
1559+
15361560
#### `os migrate option-codes`
15371561
15381562
A number or a boolean written to a `select`, `radio`, `multiselect`,
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os migrate blank-multi-values` (#29653): the walk on a real engine
5+
* (`ObjectQL` + sqlite `SqlDriver`).
6+
*
7+
* The write door stores a cleared optional multi-value value (`[]` or `''`)
8+
* as `null` since #29343 (ruling A, record 6591577985). Rows stored before it
9+
* still hold the JSON text `'[]'` or `'""'`. The legacy rows here are written
10+
* through the DRIVER, below the engine's door, which is how the before-state
11+
* stored them; the physical column is read with the driver's own query
12+
* builder.
13+
*
14+
* Pinned: the door stores `null` today (the premise), and before the run
15+
* `$null` and the read shape split the legacy rows from it and `$empty` misses
16+
* the stored `''`; the column set is
17+
* `blankAsNullColumns`' `'emptyList'` arm, one column per type (multiselect,
18+
* checkboxes, tags, a `multiple: true` lookup), with no `required`, `readonly`
19+
* or single-value column; a dry run counts per object and column and writes
20+
* nothing; `--apply` stores `null`, so the legacy rows read like one cleared
21+
* today, and leaves out-of-scope columns and non-empty lists exactly as
22+
* stored; a re-run reads zero; `objects` narrows the walk.
23+
*/
24+
25+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
26+
import { ObjectQL } from '@objectstack/objectql';
27+
import { SqlDriver } from '@objectstack/driver-sql';
28+
import { blankMultiValueColumns, cleanBlankMultiValues } from './blank-multi-values.js';
29+
30+
const OPTS = [{ value: 'gold', label: 'Gold' }, { value: 'silver', label: 'Silver' }];
31+
const TARGET = {
32+
name: 'bmv_target', label: 'Target', systemFields: false,
33+
fields: { id: { name: 'id', type: 'text' as const, primaryKey: true }, name: { name: 'name', type: 'text' as const } },
34+
};
35+
const IN_SCOPE = {
36+
c_multiselect: { name: 'c_multiselect', type: 'multiselect' as const, options: OPTS },
37+
c_checkboxes: { name: 'c_checkboxes', type: 'checkboxes' as const, options: OPTS },
38+
c_tags: { name: 'c_tags', type: 'tags' as const },
39+
c_lookups: { name: 'c_lookups', type: 'lookup' as const, reference: 'bmv_target', multiple: true },
40+
};
41+
const PER_TYPE = Object.keys(IN_SCOPE);
42+
// Out of scope, each for its own reason: `required` (refused at the door),
43+
// `readonly` (a platform-stamped `[]`, record 6598733139), single-value
44+
// (`blank-single-values`' arm).
45+
const OUT_OF_SCOPE = {
46+
r_tags: { name: 'r_tags', type: 'tags' as const, required: true },
47+
ro_tags: { name: 'ro_tags', type: 'tags' as const, readonly: true },
48+
s_select: { name: 's_select', type: 'select' as const, options: OPTS },
49+
};
50+
const OBJ = {
51+
name: 'bmv_obj', label: 'Blank multi values', systemFields: false,
52+
fields: { id: { name: 'id', type: 'text' as const, primaryKey: true }, ...IN_SCOPE, ...OUT_OF_SCOPE },
53+
};
54+
const OTHER = {
55+
name: 'bmv_other', label: 'Other', systemFields: false,
56+
fields: { id: { name: 'id', type: 'text' as const, primaryKey: true }, o_tags: { name: 'o_tags', type: 'tags' as const } },
57+
};
58+
59+
const silent = { info: () => {}, warn: () => {} };
60+
const live: ObjectQL[] = [];
61+
afterEach(async () => {
62+
while (live.length) {
63+
try { await live.pop()?.destroy(); } catch { /* noop */ }
64+
}
65+
});
66+
67+
async function boot() {
68+
const engine = new ObjectQL();
69+
live.push(engine);
70+
const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
71+
engine.registerDriver(driver, true);
72+
await engine.init();
73+
for (const o of [TARGET, OBJ, OTHER]) engine.registry.registerObject(o as any);
74+
await engine.syncSchemas();
75+
await engine.insert('bmv_target', { id: 't1', name: 'T1' });
76+
const knex = (driver as any).knex;
77+
const stored = async (object: string, id: string) =>
78+
(await knex(object).where({ id }).first()) as Record<string, unknown>;
79+
const ids = async (object: string, where: Record<string, unknown>) =>
80+
((await engine.find(object, { where, fields: ['id'] })) as Array<{ id: string }>).map((r) => r.id).sort();
81+
return { engine, driver, stored, ids };
82+
}
83+
type Ctx = Awaited<ReturnType<typeof boot>>;
84+
85+
/**
86+
* The before-state, below the engine's door: `[]` on one row and `''` on
87+
* another for every column, a row holding values, and a row cleared through
88+
* today's door.
89+
*/
90+
async function plantLegacy(ctx: Ctx) {
91+
const all = [...PER_TYPE, 'r_tags', 'ro_tags'];
92+
await ctx.driver.create('bmv_obj', { id: 'legacy_list', ...Object.fromEntries(all.map((f) => [f, []])) });
93+
await ctx.driver.create('bmv_obj', { id: 'legacy_blank', ...Object.fromEntries(all.map((f) => [f, ''])), s_select: '' });
94+
await ctx.driver.create('bmv_obj', {
95+
id: 'held', c_multiselect: ['gold'], c_checkboxes: ['silver'], c_tags: ['x'], c_lookups: ['t1'], r_tags: ['x'],
96+
});
97+
await ctx.driver.create('bmv_other', { id: 'other', o_tags: [] });
98+
await ctx.engine.insert('bmv_obj', { id: 'cleared_now', r_tags: ['x'] });
99+
await ctx.engine.update('bmv_obj', { id: 'cleared_now', ...Object.fromEntries(PER_TYPE.map((f) => [f, []])) });
100+
}
101+
102+
describe('os migrate blank-multi-values: the column set', () => {
103+
it('is the emptyList arm of blankAsNullColumns: one column per multi-value type, nothing out of scope', () => {
104+
expect(blankMultiValueColumns(OBJ).map((c) => c.name).sort()).toEqual([...PER_TYPE].sort());
105+
expect(blankMultiValueColumns(OBJ).map((c) => c.type).sort()).toEqual(['checkboxes', 'lookup', 'multiselect', 'tags']);
106+
});
107+
});
108+
109+
describe('os migrate blank-multi-values: the walk on sqlite (#29653)', () => {
110+
let ctx: Ctx;
111+
beforeEach(async () => {
112+
ctx = await boot();
113+
await plantLegacy(ctx);
114+
});
115+
116+
it('premise: the door stores null for a cleared column, and the legacy rows store the JSON texts', async () => {
117+
const now = await ctx.stored('bmv_obj', 'cleared_now');
118+
const list = await ctx.stored('bmv_obj', 'legacy_list');
119+
const blank = await ctx.stored('bmv_obj', 'legacy_blank');
120+
for (const f of PER_TYPE) {
121+
expect(now[f], `${f} door`).toBeNull();
122+
expect(list[f], `${f} legacy []`).toBe('[]');
123+
expect(blank[f], `${f} legacy ''`).toBe('""');
124+
}
125+
});
126+
127+
it('red before: $null finds only the row cleared today, $empty misses the stored "", the read shapes differ', async () => {
128+
const read = async (id: string) => (await ctx.engine.find('bmv_obj', { where: { id }, fields: PER_TYPE }))[0] as Record<string, unknown>;
129+
const [now, list, blank] = [await read('cleared_now'), await read('legacy_list'), await read('legacy_blank')];
130+
for (const f of PER_TYPE) {
131+
expect(await ctx.ids('bmv_obj', { [f]: { $null: true } }), f).toEqual(['cleared_now']);
132+
expect(await ctx.ids('bmv_obj', { [f]: { $empty: true } }), f).not.toContain('legacy_blank');
133+
expect([now[f], list[f], blank[f]], f).toEqual([null, [], '']);
134+
}
135+
});
136+
137+
it('a dry run counts per object and column and writes nothing', async () => {
138+
const before = [await ctx.stored('bmv_obj', 'legacy_list'), await ctx.stored('bmv_obj', 'legacy_blank')];
139+
const report = await cleanBlankMultiValues(ctx.engine as any, silent, {});
140+
expect(report.applied).toBe(false);
141+
expect(report.scannedObjects.sort()).toEqual(['bmv_obj', 'bmv_other']);
142+
const counted = Object.fromEntries(report.columns.map((c) => [`${c.object}.${c.field}`, [c.blankRows, c.cleared]]));
143+
expect(counted).toEqual({
144+
...Object.fromEntries(PER_TYPE.map((f) => [`bmv_obj.${f}`, [2, 0]])),
145+
'bmv_other.o_tags': [1, 0],
146+
});
147+
expect(report.blankValues).toBe(9);
148+
expect(report.cleared).toBe(0);
149+
expect(report.failures).toEqual([]);
150+
expect([await ctx.stored('bmv_obj', 'legacy_list'), await ctx.stored('bmv_obj', 'legacy_blank')]).toEqual(before);
151+
});
152+
153+
it('--apply stores null: legacy rows read like one cleared today, out-of-scope columns stay as stored, a re-run reads zero', async () => {
154+
const before = { list: await ctx.stored('bmv_obj', 'legacy_list'), blank: await ctx.stored('bmv_obj', 'legacy_blank') };
155+
const report = await cleanBlankMultiValues(ctx.engine as any, silent, { apply: true });
156+
expect(report.failures).toEqual([]);
157+
expect(report.cleared).toBe(9);
158+
159+
const after = { list: await ctx.stored('bmv_obj', 'legacy_list'), blank: await ctx.stored('bmv_obj', 'legacy_blank') };
160+
for (const f of PER_TYPE) {
161+
expect(after.list[f], `${f} [] physical`).toBeNull();
162+
expect(after.blank[f], `${f} '' physical`).toBeNull();
163+
expect(await ctx.ids('bmv_obj', { [f]: { $null: true } }), f).toEqual(['cleared_now', 'legacy_blank', 'legacy_list']);
164+
expect(await ctx.ids('bmv_obj', { [f]: { $empty: true } }), f).toContain('legacy_blank');
165+
}
166+
// `required`, `readonly` and single-value columns keep exactly what they held.
167+
for (const f of Object.keys(OUT_OF_SCOPE)) {
168+
expect(after.list[f], f).toEqual(before.list[f]);
169+
expect(after.blank[f], f).toEqual(before.blank[f]);
170+
}
171+
expect(after.list.ro_tags).toBe('[]');
172+
expect(after.blank.s_select).toBe('');
173+
// A row holding values is not touched.
174+
const held = await ctx.stored('bmv_obj', 'held');
175+
expect([held.c_multiselect, held.c_tags, held.c_lookups]).toEqual(['["gold"]', '["x"]', '["t1"]']);
176+
expect((await ctx.stored('bmv_other', 'other')).o_tags).toBeNull();
177+
178+
const again = await cleanBlankMultiValues(ctx.engine as any, silent, { apply: true });
179+
expect([again.blankValues, again.cleared, again.columns]).toEqual([0, 0, []]);
180+
});
181+
182+
it('objects narrows the walk to the named objects', async () => {
183+
const report = await cleanBlankMultiValues(ctx.engine as any, silent, { apply: true, objects: ['bmv_other'] });
184+
expect(report.scannedObjects).toEqual(['bmv_other']);
185+
expect(report.cleared).toBe(1);
186+
expect((await ctx.stored('bmv_obj', 'legacy_list')).c_tags).toBe('[]');
187+
});
188+
});

0 commit comments

Comments
 (0)