Skip to content

Commit 4c5740d

Browse files
committed
Merge origin/main (fb194c7) into claude/issue-20481-date-write-iso-only
Claude-Session: https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN Co-authored-by: Claude <noreply@anthropic.com>
2 parents 9f0d292 + fb194c7 commit 4c5740d

4 files changed

Lines changed: 289 additions & 1 deletion

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
fix(rest): `POST /api/v1/data/:object/import` reads a comma in a number cell only as a thousands group, and refuses every other comma instead of storing a different number (#20497)
6+
7+
Clause-②: no (narrowing)
8+
9+
**BREAKING for callers of the import door.** A cell for a numeric field
10+
(`number`, `currency`, `percent` and the other numeric value types) that
11+
carries a comma is now admitted only when the comma groups thousands: 1 to 3
12+
leading digits, then groups of exactly three digits, and only before any `.`
13+
(`1,000`, `12,345.67`, `-1,234,567.89`). Any other
14+
comma makes the cell that row's `invalid_number` error, the same code the
15+
plain write doors (create, update, batch) already answer for the cell. The
16+
reader used to strip every comma before parsing, so each of these was stored
17+
as a DIFFERENT number while the import reported `ok 1, errors 0`. For each
18+
shape, change the cell FROM the refused spelling TO the one that says what you
19+
mean:
20+
21+
- **A decimal comma.** FROM `3,14`, `1,5`, `0,5`, `1.000,5` (stored as `314`,
22+
`15`, `5`, `1.0005`) TO a `.` decimal point with no grouping, or with comma
23+
grouping: `3.14`, `1.5`, `0.5`, `1000.5` or `1,000.5`. A file exported with a
24+
decimal-comma locale has to be converted before import. No locale is
25+
guessed: `1,500` is always one thousand five hundred.
26+
- **A comma that does not group thousands.** FROM `1,2,3`, `1,23`, `1,0000`,
27+
`1234,567`, `,123`, `1,000,` or a comma after the `.` (`12,345.6,7`), each
28+
stored with its commas removed, TO the number with no separators, or with
29+
well-formed thousands grouping.
30+
- **Grouping by twos (`12,34,567`, `1,00,000`).** FROM that grouping TO
31+
`1234567` / `100000`, or `1,234,567` / `100,000`. These used to import as
32+
the number they denote. They are refused now because the reader cannot tell
33+
a two-digit group from a decimal comma, and it no longer guesses.
34+
35+
**What is not affected.** Every cell without a comma reads exactly as before.
36+
That is measured over the platform numeric grammar's 41 case rows: the only
37+
row whose import reading changed is `1.000,5`. A well-formed thousands grouping
38+
reads as before, with or without a leading currency symbol, a trailing `%`, a
39+
sign or accounting parentheses (`$1,000`, `1,234%`, `(1,234)`). A JSON number,
40+
and an xlsx cell Excel stores as a number, never pass through this reading.
41+
The dry run answers the same verdicts as the real write. A refused cell fails
42+
only its own row, and the rest of the batch imports as before.
43+
44+
**If you are refused.** The row's result carries `code: 'invalid_number'` and
45+
quotes the cell, so the file can be corrected and re-imported.
46+
47+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or reshaped: no spec key, no export, no stored row. The import door's cell reader accepts fewer spellings of a number inside an imported file, which is data, not metadata, so `objectstack migrate meta` has nothing to reach. The row's invalid_number error quotes the refused cell, and the repair is to write the number with a `.` decimal point. -->

‎packages/rest/src/import-coerce.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,36 @@ describe('parseNumberCell', () => {
4949
expect(parseNumberCell('12x3')).toBeUndefined();
5050
expect(parseNumberCell('')).toBeUndefined();
5151
});
52+
53+
// [#20497] A comma is read only where it groups thousands: 1 to 3 leading
54+
// digits, then groups of exactly three, and only before any `.`.
55+
it.each([
56+
['1,000', 1000],
57+
['12,345.67', 12345.67],
58+
['1,234,567.89', 1234567.89],
59+
['-1,234', -1234],
60+
['(1,234)', -1234],
61+
['$1,000', 1000],
62+
['1,234%', 1234],
63+
['1,000e3', 1_000_000],
64+
])('admits the well-formed thousands grouping %j as %s', (cell, n) => {
65+
expect(parseNumberCell(cell)).toBe(n);
66+
});
67+
68+
it.each([
69+
// The card's four cells: stored as 314, 15, 1.0005 and 123 before.
70+
'3,14', '1,5', '1.000,5', '1,2,3',
71+
// A decimal comma, however it is dressed.
72+
'0,5', '(3,14)', '$1,5', '1,5%',
73+
// A group that is not exactly three digits, or a comma out of place.
74+
'1,23', '1,0000', '1234,567', ',123', '-,123', '1,000,', '.5,000',
75+
// A comma after the `.`.
76+
'12,345.6,7',
77+
// A grouping other than thousands — no locale is guessed.
78+
'12,34,567', '1,00,000',
79+
])('refuses %j rather than reading it as some other number', (cell) => {
80+
expect(parseNumberCell(cell)).toBeUndefined();
81+
});
5282
});
5383

5484
describe('parseDateCell', () => {

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,12 +218,33 @@ export function parseBooleanCell(raw: unknown): boolean | undefined {
218218

219219
// ── numbers ────────────────────────────────────────────────────────
220220

221+
/**
222+
* The one comma placement a numeric cell may carry: a thousands grouping of the
223+
* integer part — an optional sign, 1 to 3 leading digits, then one or more
224+
* groups of exactly three digits, ending the integer part (end of cell, `.` or
225+
* the exponent marker follows) — and no comma anywhere after it. `1,000`,
226+
* `12,345.67`, `-1,234,567` match; `3,14`, `1,5`, `1.000,5`, `1,2,3`,
227+
* `1234,567`, `1,0000` and `12,345.6,7` do not.
228+
*/
229+
const THOUSANDS_GROUPED_INTEGER = /^[+-]?\d{1,3}(?:,\d{3})+(?![\d,])[^,]*$/;
230+
221231
/**
222232
* Parse a numeric cell, tolerating the punctuation spreadsheets add: thousands
223233
* separators (`1,234`), a leading currency symbol (`$` `¥` `€` `£` `¥`), a
224234
* trailing percent sign (`25%` → `25`), and accounting-style parenthesised
225235
* negatives (`(1,234)` → `-1234`). Returns `undefined` when the residue is not
226236
* a finite number.
237+
*
238+
* A comma is a thousands separator and nothing else, and only where it groups
239+
* thousands (#20497): 1 to 3 leading digits, then groups of exactly three,
240+
* and only before any `.` (`1,000`, `12,345.67`) — see
241+
* {@link THOUSANDS_GROUPED_INTEGER}. Any other comma makes the cell
242+
* unparseable (`undefined`, so the row's `invalid_number` error), never a
243+
* different number: a decimal-comma spelling (`3,14`, `1,5`, `1.000,5`) or a
244+
* stray comma (`1,2,3`) used to have every comma stripped and was stored as
245+
* `314`, `15`, `1.0005`, `123`. No locale is guessed — `1,500` is always one
246+
* thousand five hundred, never 1.5 — so a file written with a decimal comma is
247+
* refused cell by cell rather than read as some other value.
227248
*/
228249
export function parseNumberCell(raw: unknown): number | undefined {
229250
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
@@ -233,7 +254,10 @@ export function parseNumberCell(raw: unknown): number | undefined {
233254
if (/^\(.*\)$/.test(s)) { negative = true; s = s.slice(1, -1).trim(); }
234255
s = s.replace(/^[$¥€£¥]\s*/, ''); // leading currency symbol
235256
s = s.replace(/%$/, '').trim(); // trailing percent
236-
s = s.replace(/,/g, ''); // thousands separators
257+
if (s.includes(',')) {
258+
if (!THOUSANDS_GROUPED_INTEGER.test(s)) return undefined;
259+
s = s.replace(/,/g, ''); // a well-formed thousands grouping
260+
}
237261
if (s === '' || !/^[+-]?\d*\.?\d+(e[+-]?\d+)?$/i.test(s)) return undefined;
238262
const n = Number(s);
239263
if (!Number.isFinite(n)) return undefined;
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20497] `POST /api/v1/data/:object/import` reads a comma in a number cell
5+
* as a thousands separator ONLY where it groups thousands — 1 to 3 leading
6+
* digits, then groups of exactly three, and only before any `.` — and refuses
7+
* every other comma as that row's `invalid_number` error, through the real
8+
* route over a real `SqlDriver` (better-sqlite3 `:memory:`).
9+
*
10+
* Measured through this route, JSON rows, `writeMode: 'insert'`, on
11+
* `InMemoryDriver` and on `SqlDriver` (better-sqlite3) alike, at the base
12+
* (`9449512a31`) and at the head of the PR that landed this file:
13+
*
14+
* | cell | base: stored · answer | head |
15+
* |:--|:--|:--|
16+
* | `'3,14'` | `314` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored |
17+
* | `'1,5'` | `15` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored |
18+
* | `'1.000,5'` | `1.0005` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored |
19+
* | `'1,2,3'` | `123` · ok 1, errors 0 | row refused, `invalid_number`, nothing stored |
20+
* | `'1,000'` / `'12,345.67'` / `'(1,234)'` | `1000` / `12345.67` / `-1234` | unchanged |
21+
*
22+
* The `InMemoryDriver` row is this file's by construction, not by a second
23+
* arm: the cell is judged by the import's own reader (`parseNumberCell`)
24+
* before any driver is reached, so one verdict holds on every driver. A test
25+
* import of `@objectstack/driver-memory` is also not this file's to add — its
26+
* test consumers are a ruled, ledgered set
27+
* (`scripts/driver-memory-census.ledger.json`, `pnpm check:driver-memory-census`).
28+
*
29+
* The plain create door already answered `400 VALIDATION_FAILED` with field
30+
* code `invalid_number` for each of the four; the import row now answers the
31+
* same code. The reader's own case table (every documented form, the comma
32+
* probes) is `import-coerce.test.ts`'s `parseNumberCell` block; this file pins
33+
* the door.
34+
*/
35+
36+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
37+
import { ObjectQL } from '@objectstack/objectql';
38+
import { SqlDriver } from '@objectstack/driver-sql';
39+
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
40+
import { RestServer } from './rest-server';
41+
42+
const OBJECT = 'import_thousands_20497';
43+
44+
const LEDGER = {
45+
name: OBJECT, label: 'Ledger 20497', systemFields: false,
46+
fields: {
47+
id: { name: 'id', type: 'text' as const, primaryKey: true },
48+
amount: { name: 'amount', type: 'number' as const, label: 'Amount' },
49+
},
50+
};
51+
52+
/** The card's four cells: each used to be stored as a different number. */
53+
const REFUSED = ['3,14', '1,5', '1.000,5', '1,2,3'] as const;
54+
55+
/** The admitted controls: a well-formed thousands grouping keeps its reading. */
56+
const ADMITTED: ReadonlyArray<readonly [cell: string, stored: number]> = [
57+
['1,000', 1000],
58+
['12,345.67', 12345.67],
59+
['(1,234)', -1234],
60+
];
61+
62+
function makeSqliteDriver() {
63+
return new SqlDriver({
64+
client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true,
65+
});
66+
}
67+
68+
function createMockServer() {
69+
const noop = () => {};
70+
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
71+
}
72+
73+
function makeRes() {
74+
const res: any = {
75+
write: () => true, end: () => {},
76+
header: () => res,
77+
status: (code: number) => { res._status = code; return res; },
78+
json: (body: any) => { res._json = body; return res; },
79+
};
80+
return res;
81+
}
82+
83+
const liveEngines: ObjectQL[] = [];
84+
afterEach(async () => {
85+
while (liveEngines.length) {
86+
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
87+
}
88+
});
89+
90+
async function boot() {
91+
const engine = new ObjectQL();
92+
liveEngines.push(engine);
93+
engine.registerDriver(makeSqliteDriver(), true);
94+
await engine.init();
95+
engine.registry.registerObject(LEDGER as any);
96+
await engine.syncSchemas();
97+
const protocol = new ObjectStackProtocolImplementation(engine as any);
98+
const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any);
99+
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user' });
100+
rest.registerRoutes();
101+
const find = (method: string, path: string) =>
102+
rest.getRoutes().find((r: any) => r.method === method && r.path === path) as any;
103+
const importRoute = find('POST', '/api/v1/data/:object/import');
104+
const createRoute = find('POST', '/api/v1/data/:object');
105+
expect(importRoute).toBeDefined();
106+
expect(createRoute).toBeDefined();
107+
const send = async (route: any, body: unknown) => {
108+
const res = makeRes();
109+
await route.handler({ params: { object: OBJECT }, body } as any, res);
110+
return res;
111+
};
112+
return {
113+
engine,
114+
importRows: (body: Record<string, unknown>) => send(importRoute, body),
115+
create: (body: Record<string, unknown>) => send(createRoute, body),
116+
};
117+
}
118+
119+
describe('[#20497] /import — a comma is read only as a thousands group', () => {
120+
let ctx: Awaited<ReturnType<typeof boot>>;
121+
beforeEach(async () => { ctx = await boot(); });
122+
123+
it.each(REFUSED)('refuses %j as that row\'s invalid_number error and writes its sibling row', async (cell) => {
124+
const res = await ctx.importRows({
125+
format: 'json', writeMode: 'insert',
126+
rows: [{ id: 'bad', amount: cell }, { id: 'good', amount: 7 }],
127+
});
128+
129+
expect(res._status ?? 200).toBe(200);
130+
expect(res._json).toMatchObject({ total: 2, ok: 1, errors: 1, created: 1 });
131+
expect(res._json.results[0]).toMatchObject({
132+
row: 1, ok: false, action: 'failed', field: 'amount', code: 'invalid_number',
133+
});
134+
// Refused, not stored as some other number.
135+
expect(await ctx.engine.findOne(OBJECT, { where: { id: 'bad' } })).toBeNull();
136+
expect((await ctx.engine.findOne(OBJECT, { where: { id: 'good' } }))?.amount).toBe(7);
137+
});
138+
139+
it.each(ADMITTED)('admits the thousands grouping %j and stores %s', async (cell, stored) => {
140+
const res = await ctx.importRows({
141+
format: 'json', writeMode: 'insert',
142+
rows: [{ id: 'r', amount: cell }],
143+
});
144+
145+
expect(res._json).toMatchObject({ total: 1, ok: 1, errors: 0, created: 1 });
146+
expect(res._json.results[0]).toMatchObject({ row: 1, ok: true, action: 'created' });
147+
expect((await ctx.engine.findOne(OBJECT, { where: { id: 'r' } }))?.amount).toBe(stored);
148+
});
149+
150+
it('gives the same verdicts to quoted CSV cells', async () => {
151+
const csv = [
152+
'ID,Amount',
153+
...REFUSED.map((cell, i) => `r${i},"${cell}"`),
154+
...ADMITTED.map(([cell], j) => `c${j},"${cell}"`),
155+
].join('\n');
156+
const res = await ctx.importRows({ format: 'csv', csv, writeMode: 'insert', mapping: { ID: 'id', Amount: 'amount' } });
157+
158+
expect(res._json).toMatchObject({ total: REFUSED.length + ADMITTED.length, ok: ADMITTED.length, errors: REFUSED.length });
159+
const failedFields = res._json.results.filter((r: any) => !r.ok).map((r: any) => [r.field, r.code]);
160+
expect(failedFields).toEqual(REFUSED.map(() => ['amount', 'invalid_number']));
161+
for (const [j, [, stored]] of ADMITTED.entries()) {
162+
expect((await ctx.engine.findOne(OBJECT, { where: { id: `c${j}` } }))?.amount).toBe(stored);
163+
}
164+
});
165+
166+
it('dry run predicts the same refusals and persists nothing', async () => {
167+
const res = await ctx.importRows({
168+
format: 'json', writeMode: 'insert', dryRun: true,
169+
rows: REFUSED.map((amount, i) => ({ id: `d${i}`, amount })),
170+
});
171+
172+
expect(res._json).toMatchObject({ dryRun: true, total: REFUSED.length, ok: 0, errors: REFUSED.length });
173+
for (const r of res._json.results) expect(r).toMatchObject({ ok: false, field: 'amount', code: 'invalid_number' });
174+
for (const [i] of REFUSED.entries()) {
175+
expect(await ctx.engine.findOne(OBJECT, { where: { id: `d${i}` } })).toBeNull();
176+
}
177+
});
178+
179+
it('answers the code the plain create door answers for the same cells', async () => {
180+
for (const [i, amount] of REFUSED.entries()) {
181+
const res = await ctx.create({ id: `p${i}`, amount });
182+
expect(res._status).toBe(400);
183+
expect(res._json).toMatchObject({ code: 'VALIDATION_FAILED' });
184+
expect(res._json.fields[0]).toMatchObject({ field: 'amount', code: 'invalid_number' });
185+
}
186+
});
187+
});

0 commit comments

Comments
 (0)