diff --git a/.changeset/27236-currency-object-value-lesson.md b/.changeset/27236-currency-object-value-lesson.md new file mode 100644 index 00000000000..459b0d90221 --- /dev/null +++ b/.changeset/27236-currency-object-value-lesson.md @@ -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 + + + +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. diff --git a/packages/objectql/src/validation/record-validator.test.ts b/packages/objectql/src/validation/record-validator.test.ts index 864ab991759..65b289236a9 100644 --- a/packages/objectql/src/validation/record-validator.test.ts +++ b/packages/objectql/src/validation/record-validator.test.ts @@ -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[0], data: Record, 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 diff --git a/packages/objectql/src/validation/record-validator.ts b/packages/objectql/src/validation/record-validator.ts index 1bf983da76e..f47f64b9f0e 100644 --- a/packages/objectql/src/validation/record-validator.ts +++ b/packages/objectql/src/validation/record-validator.ts @@ -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 @@ -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)) { diff --git a/packages/spec/src/system/validation-message.test.ts b/packages/spec/src/system/validation-message.test.ts index ee502adf085..c95db1df68b 100644 --- a/packages/spec/src/system/validation-message.test.ts +++ b/packages/spec/src/system/validation-message.test.ts @@ -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(/(?> 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)', @@ -174,6 +179,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> 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)', @@ -219,6 +225,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> 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)を入力してください', @@ -264,6 +271,7 @@ export const BUILTIN_VALIDATION_MESSAGES: Record> 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)',