Skip to content

Commit f123f75

Browse files
fix(rest): keep a nullValues sentinel blank through an inline case transform (#25646)
Fixes #25611 Clause-②: no Metadata-protocol: none ## What `applyInlineEntryRules` (`packages/rest/src/import-prepare.ts`) ran an inline entry's `transform` on a string cell first and the blank test second. Core's `isBlank` matches a `nullValues` entry exactly (raw or trimmed spelling), so `uppercase` / `lowercase` rewrote a sentinel out of being one: with `nullValues: ['n/a']` and `transform: 'uppercase'`, the cell `n/a` became `N/A` and was stored as a value, skipping the entry's `defaultValue`, cell coercion's null handling, and `required`. The fix judges blankness on the raw cell and transforms only a non-blank string cell. A sentinel stays a sentinel through `defaultValue`, `required` and cell coercion; a non-blank cell is transformed exactly as before (including a cell whose transformed spelling matches a sentinel, which is blank afterwards as it was before). `packages/core/src/utils/import-coerce.ts` is untouched: `isBlank` keeps its exact-match semantics for every import door. ## Proof Six new pins in `packages/rest/src/import-inline-mapping-entry.test.ts`, through the real `POST /api/v1/data/:object/import` route handler on sqlite `:memory:`, for `uppercase` (sentinel `n/a`) and `lowercase` (sentinel `N/A`): - the sentinel cell is stored unset (`null`), and a non-sentinel cell in the same request is still transformed (`Jp` to `JP` / `jp`) — the control; - with `defaultValue`, the sentinel cell takes the default; - with `required: true`, the request is refused `400` `MISSING_REQUIRED_FIELD` and nothing is written. - Before the fix (test commit `a5c8099acc`): `Tests 6 failed | 8 passed (14)`, e.g. `expected 'N/A' to be null`, `expected 'n/a' to be 'cn-default'`, `expected undefined to be 400`. - After the fix (`a0b29be00d`): `Test Files 1 passed (1)`, `Tests 14 passed (14)`; the eight existing cases stay green. - Ablation (on `a0b29be00d`, via `scripts/ablation-replace.mjs`, anchor 1 to 0, blob `56b834253a13` to `a7b8d42cf5e5`): old order restored, `Tests 6 failed | 8 passed (14)`; restored by `git checkout HEAD`, blob back to `56b834253a1304b3b88231bb4b191009fca22a75` = HEAD blob, `git diff HEAD` empty. The suite imports `./rest-server` from source, so no `dist/` leg applies. - `pnpm --filter @objectstack/rest test` on `a0b29be00d`: `Test Files 320 passed (320)`, `Tests 6406 passed | 326 skipped`; `pnpm --filter @objectstack/rest typecheck`: exit 0; eslint on both touched files: exit 0. - `dispatch-gates --commands` on `a0b29be00d`: 68 commands run, 67 exit 0; `pnpm check:dual-build-cjs-loads` exit 3 `PREREQUISITE NOT MET` (it needs a whole-workspace `dist/`, only the rest closure was built) — NOT MEASURED locally, left to CI's `Lint & Repo Gates`. `--ran` reconciliation: 68 of 68 accounted for. ## Acceptance notes - A whitespace-only cell under `transform: 'trim'` is now left as is rather than trimmed to an empty string by the entry; both spellings are blank to `isBlank` and to cell coercion, so the stored outcome is unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01JVTd3PprStGa26aez5omLB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f4860da commit f123f75

3 files changed

Lines changed: 78 additions & 2 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
Clause-②: no
6+
Metadata-protocol: none
7+
8+
`POST /api/v1/data/:object/import`: a cell the request declares null through `nullValues` now stays blank whatever case `transform` its inline mapping entry declares. The blank test reads the raw cell and a blank cell is not transformed, so an `uppercase` or `lowercase` entry no longer rewrites a sentinel such as `n/a` into a stored value (`N/A`): the cell is stored unset, takes the entry's `defaultValue`, or is refused by `required` as blank. A non-blank cell is transformed as before.

‎packages/rest/src/import-inline-mapping-entry.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,67 @@ describe('import route — inline FieldMappingEntry rules (#25491)', () => {
218218
expect(stored).toMatchObject({ name: ' Acme ', country: 'cn' });
219219
});
220220
});
221+
222+
/**
223+
* [#25611] A cell the request declares null (`nullValues`) stays blank whatever
224+
* case `transform` its entry declares: the blank test reads the raw cell, so a
225+
* sentinel is never rewritten out of being one (`n/a` upper-cased to `N/A`
226+
* would no longer match `nullValues: ['n/a']` and be stored as a value).
227+
*/
228+
describe('import route — a nullValues sentinel survives an inline case transform (#25611)', () => {
229+
let engine: any;
230+
let route: any;
231+
const call = async (object: string, body: any) => {
232+
const res = makeRes();
233+
await route.handler({ params: { object }, body } as any, res);
234+
return res;
235+
};
236+
const countryOf = async (name: string) => {
237+
const [row] = await engine.find('supplier', { where: { name } });
238+
return row?.country;
239+
};
240+
241+
beforeEach(async () => {
242+
({ engine, route } = await boot());
243+
});
244+
245+
for (const [transform, sentinel] of [['uppercase', 'n/a'], ['lowercase', 'N/A']] as const) {
246+
it(`${transform}: the sentinel cell is stored unset, a non-sentinel cell is still transformed`, async () => {
247+
const res = await call('supplier', {
248+
format: 'csv', csv: `Name,Country\nA,${sentinel}\nB,Jp\n`, nullValues: [sentinel],
249+
mapping: [
250+
{ sourceField: 'Name', targetField: 'name' },
251+
{ sourceField: 'Country', targetField: 'country', transform },
252+
],
253+
});
254+
expect(res._json).toMatchObject({ ok: 2, created: 2 });
255+
expect(await countryOf('A')).toBeNull();
256+
expect(await countryOf('B')).toBe(transform === 'uppercase' ? 'JP' : 'jp');
257+
});
258+
259+
it(`${transform}: the sentinel cell takes the entry's defaultValue`, async () => {
260+
const res = await call('supplier', {
261+
format: 'csv', csv: `Name,Country\nA,${sentinel}\n`, nullValues: [sentinel],
262+
mapping: [
263+
{ sourceField: 'Name', targetField: 'name' },
264+
{ sourceField: 'Country', targetField: 'country', transform, defaultValue: 'cn-default' },
265+
],
266+
});
267+
expect(res._json).toMatchObject({ ok: 1, created: 1 });
268+
expect(await countryOf('A')).toBe('cn-default');
269+
});
270+
271+
it(`${transform}: a required entry refuses the sentinel cell as blank, nothing written`, async () => {
272+
const res = await call('supplier', {
273+
format: 'csv', csv: `Name,Country\nA,${sentinel}\n`, nullValues: [sentinel],
274+
mapping: [
275+
{ sourceField: 'Name', targetField: 'name' },
276+
{ sourceField: 'Country', targetField: 'country', transform, required: true },
277+
],
278+
});
279+
expect(res._status).toBe(400);
280+
expect(res._json?.error?.code ?? res._json?.code).toBe('MISSING_REQUIRED_FIELD');
281+
expect(await engine.find('supplier', {})).toHaveLength(0);
282+
});
283+
}
284+
});

‎packages/rest/src/import-prepare.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ function readInlineEntryRule(
262262

263263
/**
264264
* Apply each inline entry's rules to every row, in place, before the cells
265-
* are coerced: the transform to a string cell, then `defaultValue` to a cell
265+
* are coerced: the transform to a non-blank string cell, then `defaultValue` to a cell
266266
* that is blank (what cell coercion calls blank: absent, empty, whitespace or
267267
* one of the request's `nullValues`), then `required` to what is left. A
268268
* required cell still blank refuses the whole request before any row is
@@ -278,7 +278,11 @@ function applyInlineEntryRules(
278278
rows.forEach((row, i) => {
279279
for (const rule of rules) {
280280
let value = row[rule.targetField];
281-
if (typeof value === 'string') value = INLINE_TRANSFORMS[rule.transform](value);
281+
// Blankness is judged on the raw cell: a case transform would rewrite a
282+
// `nullValues` sentinel (matched exactly) out of being one, so a blank
283+
// cell is never transformed and stays blank for the rules below and
284+
// for cell coercion.
285+
if (typeof value === 'string' && !isBlank(value, nullValues)) value = INLINE_TRANSFORMS[rule.transform](value);
282286
if (rule.hasDefault && isBlank(value, nullValues)) value = rule.defaultValue;
283287
if (value !== undefined) row[rule.targetField] = value;
284288
if (rule.required && isBlank(value, nullValues)) {

0 commit comments

Comments
 (0)