Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/27236-currency-object-value-lesson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': patch
'@objectstack/objectql': patch
---

fix(objectql): a currency field written as an object, `{ value, currency }`, is refused with a sentence that says a currency value is a bare number

Clause-②: no
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) a refusal of a written VALUE at the record validator's numeric arm gains a second sentence: no authorable key, spelling or stored shape of metadata moves, and no schema, type or export changes. `packages/spec` gains one sentence in the built-in validation-message catalog (`invalid_number_currency_object`, a rendering variant of the existing `invalid_number` wire code, which is unchanged), and the accept set of the write door is the same before and after. A stored row keeps whatever it holds. The other categories are closed on facts: the packages publish (not `unpublished`); no ADR-0087 id covers a write-door value check (not `registered` / `already-registered`); and the change is a message, not a TypeScript declaration (not `runtime-interface-only` / `type-surface-only`). -->

The `{ value, currency }` shape was retired by ADR-0104 D1. A record write that still sends it to a `currency` field was refused with only "Amount must be a number", which does not say what to write instead.

- **`@objectstack/objectql`**: when a `currency` field's value is a plain object, the `invalid_number` refusal now reads: "Amount is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. A currency field declares its currency in currencyConfig: the fixed mode's defaultCurrency, or under the dynamic mode (the default) the tenant default currency". The wire code stays `invalid_number`, with no new key on the error, so no client contract moves. A non-number string, an array, and an object on a `number` field keep the plain sentence.
- **`@objectstack/spec`**: the validation-message catalog carries that sentence as `invalid_number_currency_object`, in en, zh-CN, ja-JP and es-ES. A deployment can override it under `validation.field.invalid_number_currency_object`, like any other built-in message.

What to do: nothing. Writes that were accepted are still accepted, and writes that were refused are still refused with the same code.
60 changes: 60 additions & 0 deletions packages/objectql/src/validation/record-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1361,6 +1361,66 @@ describe('validateRecord — `currency` is outside the max_scale enforced set (#
});
});

/**
* [#27236] A `currency` field written as an object, the retired
* `{ value, currency }` shape (ADR-0104 D1), is refused with the SAME wire code
* as before, `invalid_number` and nothing more, and a sentence that teaches the
* shape: a bare number, with the currency declared in `currencyConfig` or taken
* from the tenant default. The accept set does not move. A non-number string, an
* array, and an object on a `number` field keep the plain sentence (CONTROLS).
*/
describe('[#27236] validateRecord — a currency value written as an object names the bare-number shape', () => {
const refusalOf = (schema: Parameters<typeof validateRecord>[0], data: Record<string, unknown>, mode: 'insert' | 'update' = 'insert') => {
try {
validateRecord(schema, data, mode);
} catch (e) {
expect(e).toBeInstanceOf(ValidationError);
return (e as ValidationError).fields;
}
return null;
};
const lesson = renderValidationMessage({ messageKey: 'invalid_number_currency_object', label: 'Amount', field: 'amount' });
const plain = renderValidationMessage({ messageKey: 'invalid_number', label: 'Amount', field: 'amount' });
const dynamic = { fields: { amount: { type: 'currency', label: 'Amount' } } };
const fixed = { fields: { amount: { type: 'currency', label: 'Amount', currencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' } } } };

for (const [name, schema] of [['dynamic', dynamic], ['fixed', fixed]] as const) {
it(`refuses { value, currency } on a ${name}-mode field with invalid_number and the lesson, on insert and update`, () => {
for (const mode of ['insert', 'update'] as const) {
expect(refusalOf(schema, { amount: { value: 10, currency: 'USD' } }, mode)).toEqual([
{ field: 'amount', code: 'invalid_number', message: lesson, label: 'Amount' },
]);
}
});
}

it('the lesson is a different sentence from the plain one and names currencyConfig', () => {
expect(lesson).not.toBe(plain);
expect(lesson.startsWith('Amount is a currency amount: write it as a bare number')).toBe(true);
expect(lesson).toContain('currencyConfig');
});

it('a bare number passes', () => {
expect(refusalOf(dynamic, { amount: 10 })).toBeNull();
expect(refusalOf(fixed, { amount: 10 }, 'update')).toBeNull();
});

it('CONTROL — a non-number string on a currency field keeps the plain sentence', () => {
expect(refusalOf(dynamic, { amount: 'abc' })).toEqual([{ field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' }]);
});

it('CONTROL — an array on a currency field keeps the plain sentence', () => {
expect(refusalOf(dynamic, { amount: [10] })).toEqual([{ field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' }]);
});

it('CONTROL — an object on a number field keeps the plain sentence (the lesson is currency-only)', () => {
const number = { fields: { amount: { type: 'number', label: 'Amount' } } };
expect(refusalOf(number, { amount: { value: 10, currency: 'USD' } })).toEqual([
{ field: 'amount', code: 'invalid_number', message: plain, label: 'Amount' },
]);
});
});

/**
* [#20846] A `date` / `datetime` value the kind's rule reads, whose year falls
* outside the kind's supported years, is refused with the SAME wire code as
Expand Down
18 changes: 17 additions & 1 deletion packages/objectql/src/validation/record-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,17 @@ function isJudgedNumberType(type: string): boolean {
return NUMERIC_VALUE_TYPES.has(type) && !COMPUTED_VALUE_TYPES.has(type);
}

/**
* [#27236] A plain object literal (`{ … }` or a null-prototype record), never
* an array, a `Date` or another class instance. It chooses the sentence of a
* `currency` field's `invalid_number` refusal, never the verdict.
*/
function isPlainObjectValue(value: unknown): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

/**
* [#20309] A STRING on a number-typed field that the platform's numeric grammar
* reads is written as the NUMBER it denotes — so what the record validator's
Expand Down Expand Up @@ -1201,9 +1212,14 @@ function validateOne(
// the payload at the door, so the driver stores what was judged. `min`,
// `max`, `scale` and `precision` below read that number, as they read a
// number.
//
// [#27236] A plain object on a `currency` field is the retired
// `{ value, currency }` shape (ADR-0104 D1). It keeps the code, so no client
// contract moves; its sentence says the value is a bare number and where the
// currency is declared. The sentence is the spec catalog's, never a copy.
if (isJudgedNumberType(t)) {
if (typeof value !== 'number' && typeof value !== 'string') {
return fail('invalid_number');
return fail('invalid_number', undefined, t === 'currency' && isPlainObjectValue(value) ? 'invalid_number_currency_object' : undefined);
}
const n = typeof value === 'number' ? value : parseNumericString(value);
if (n === undefined || !Number.isFinite(n)) {
Expand Down
27 changes: 27 additions & 0 deletions packages/spec/src/system/validation-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,30 @@ describe('invalid_date_range / invalid_datetime_range — the years are paramete
).toBe('发生时间必须是 UTC 年份在 1000 年至 9999 年之间的日期时间');
});
});

/**
* #27236 — `invalid_number`'s currency sentence: a `currency` field written as
* an object, the retired `{ value, currency }` shape (ADR-0104 D1). Every
* locale teaches the same three facts, and none names a currency code: the
* field's `currencyConfig` or the tenant default decides the currency, never
* the sentence (#27119 Q2 = X).
*/
describe('invalid_number_currency_object — the shape lesson, with no currency code of its own', () => {
it('every locale names the retired shape, currencyConfig, both modes and ADR-0104', () => {
for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) {
const template = catalog.invalid_number_currency_object;
expect(template, locale).toBeDefined();
for (const word of ['{ value, currency }', 'currencyConfig', 'defaultCurrency', 'fixed', 'dynamic', 'ADR-0104']) {
expect(template, `${locale}: ${word}`).toContain(word);
}
}
});

it('no locale spells a currency code', () => {
for (const [locale, catalog] of Object.entries(BUILTIN_VALIDATION_MESSAGES)) {
const prose = catalog.invalid_number_currency_object.replace('ADR-0104', '');
expect(prose, locale).not.toMatch(/(?<![A-Za-z])[A-Z]{3}(?![A-Za-z])/);
expect(prose, locale).not.toMatch(/[$¥€£]/);
}
});
});
8 changes: 8 additions & 0 deletions packages/spec/src/system/validation-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>>
invalid_url: '{{label}} must be a valid URL (scheme://...)',
invalid_phone: '{{label}} must be a valid phone number',
invalid_number: '{{label}} must be a number',
// `invalid_number`'s second sentence (#27236): a `currency` field written
// as an object, the retired `{ value, currency }` shape (ADR-0104 D1). The
// sentence names no currency code: the field's `currencyConfig` or the
// tenant default decides it (#27119 Q2 = X).
invalid_number_currency_object: '{{label}} is a currency amount: write it as a bare number (ADR-0104), such as 10, not an object such as { value, currency }. A currency field declares its currency in currencyConfig: the fixed mode\'s defaultCurrency, or under the dynamic mode (the default) the tenant default currency',
invalid_boolean: '{{label}} must be true or false',
invalid_date: '{{label}} must be a valid date (ISO-8601)',
invalid_datetime: '{{label}} must be a valid datetime (ISO-8601)',
Expand Down Expand Up @@ -174,6 +179,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>>
invalid_url: '{{label}}必须是有效的 URL(scheme://...)',
invalid_phone: '{{label}}必须是有效的电话号码',
invalid_number: '{{label}}必须是数字',
invalid_number_currency_object: '{{label}}是货币金额:请直接写数字(ADR-0104),如 10,不要写成 { value, currency } 这样的对象。币种在字段的 currencyConfig 中声明:fixed 模式取 defaultCurrency,dynamic 模式(默认)取组织默认币种',
invalid_boolean: '{{label}}必须是 true 或 false',
invalid_date: '{{label}}必须是有效的日期(ISO-8601)',
invalid_datetime: '{{label}}必须是有效的日期时间(ISO-8601)',
Expand Down Expand Up @@ -219,6 +225,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>>
invalid_url: '{{label}}は有効な URL(scheme://...)を入力してください',
invalid_phone: '{{label}}は有効な電話番号を入力してください',
invalid_number: '{{label}}は数値で入力してください',
invalid_number_currency_object: '{{label}}は通貨金額です。{ value, currency } のようなオブジェクトではなく、数値のみ(ADR-0104、例: 10)で入力してください。通貨はフィールドの currencyConfig で宣言します。fixed モードでは defaultCurrency、dynamic モード(既定)では組織の既定通貨です',
invalid_boolean: '{{label}}は true または false で入力してください',
invalid_date: '{{label}}は有効な日付(ISO-8601)を入力してください',
invalid_datetime: '{{label}}は有効な日時(ISO-8601)を入力してください',
Expand Down Expand Up @@ -264,6 +271,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record<string, Record<string, string>>
invalid_url: '{{label}} debe ser una URL válida (scheme://...)',
invalid_phone: '{{label}} debe ser un número de teléfono válido',
invalid_number: '{{label}} debe ser un número',
invalid_number_currency_object: '{{label}} es un importe de moneda: escríbalo como un número sin más (ADR-0104), por ejemplo 10, no como un objeto del tipo { value, currency }. El campo declara su moneda en currencyConfig: el defaultCurrency del modo fixed o, en el modo dynamic (el predeterminado), la moneda predeterminada del inquilino',
invalid_boolean: '{{label}} debe ser true o false',
invalid_date: '{{label}} debe ser una fecha válida (ISO-8601)',
invalid_datetime: '{{label}} debe ser una fecha y hora válidas (ISO-8601)',
Expand Down
Loading