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
11 changes: 11 additions & 0 deletions .changeset/24715-not-null-field-label-rest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/rest': patch
---

fix(rest): a `/data` create or update refused by a NOT NULL column names the field by its label, in the caller's language

Clause-②: no (message text only: the refusal's `status`, `code` and `fields[]` keys and codes do not move, and no accept set changes)

- **Before.** A field declared `storage: { notNull: true }` without `required` is refused by the database, not by the record validator. `PATCH /api/v1/data/OBJECT/ID` with `{ "unit_price": null }`, on a field labelled "Sales Price", answered `400 VALIDATION_FAILED` with `error` and `fields[0].message` both `"unit_price is required"`. The user saw the column key. The same field declared `required` was refused by the validator, by its label.
- **Now.** `POST /data/:object` and `PATCH /data/:object/:id` name the field the way the record validator names its own `required` refusal: the catalog's `required` sentence, in the caller's locale, with the field's translated label, then its declared label. A `zh-CN` caller reads `销售价格不能为空`. For a field that declares `storage.notNull`, the `hint` says to send a value, or to omit the field when a default or a hook populates it. It no longer prescribes `required` or `os migrate`.
- **Unchanged.** `status`, `code`, `fields[0].field` (the column key), `fields[0].code` (`required`), `object` and the key set. Drift keeps the drift hint: a field declared with neither `required` nor `storage.notNull` over a column that is still NOT NULL. A column the object does not declare keeps today's sentence. So does a refusal the driver attributes to another table, and any answer when the object or the i18n service cannot be read. The door makes the extra object read only for a NOT NULL refusal, and it is the by-name read the exposure gate already makes on every `/data` request.
10 changes: 10 additions & 0 deletions .changeset/24715-not-null-field-label-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/types': minor
---

fix(types): a NOT NULL refusal's hint no longer tells the author to declare the field `required`, and the caller that holds the field map can name the field by its label

Clause-②: yes (widening: three additive exports, `notNullRefusalSite`, `relabelNotNullRefusal` and the type `NotNullRefusalSite`, consumed by `@objectstack/rest`'s `/data` doors; no accept set, status or code moves, and the refusal's `fields[]` keys and codes do not move)

- **The hint holds for both ways in.** `mapDataError` classifies a driver's NOT NULL refusal from the driver's text, which names the column and nothing else. Its hint used to lead with "provide it, or declare the field `required` in the object metadata". For a field declared `storage: { notNull: true }` without `required` (ADR-0113's engine-populated column, filled by a hook or a default), declaring `required` is the move ADR-0113 D1 / D2 rule out: it puts the demand back on the form. The hint now leads with "requires a value: provide one", and names drift (`os migrate`) only as the case where the object declares neither `required` nor `storage.notNull`.
- **`notNullRefusalSite(answer)` and `relabelNotNullRefusal(answer, { message, declaresNotNull })`.** The first answers the column, and the table where the dialect names one (SQLite, Postgres), when `answer` is the NOT NULL refusal `mapDataError` built. It answers `undefined` for every other answer, including a record validator's own `required` refusal with the same `code`. The second replaces `error` and `fields[0].message` with the caller's sentence. For a field that declares `storage.notNull`, it also replaces the hint with one that names neither `required` nor `os migrate`. Every other key and value is unchanged. The classification itself still reads no field map.
27 changes: 23 additions & 4 deletions packages/rest/src/crud-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export interface CrudRouteHost {
resolveExecCtx: RestServer['resolveExecCtx'];
enforceAuth: RestServer['enforceAuth'];
enforceApiAccess: RestServer['enforceApiAccess'];
labelNotNullRefusal: RestServer['labelNotNullRefusal'];
}

/**
Expand Down Expand Up @@ -242,10 +243,13 @@ export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): vo
method: 'POST',
path: `${dataPath}/:object`,
handler: async (req: any, res: any) => {
// [#24715] Declared out here so the catch can read the
// caller's locale off it.
let context: any;
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
const p = await this.resolveProtocol(environmentId, req);
const context = await this.resolveExecCtx(environmentId, req);
context = await this.resolveExecCtx(environmentId, req);
if (this.enforceAuth(req, res, context)) return;
if (await this.enforceApiAccess(req, res, p, environmentId, 'create')) return;
// [#3899] The wire body IS the record. Validate the
Expand Down Expand Up @@ -291,7 +295,14 @@ export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): vo
applyDroppedFieldsHeader(res, result);
res.status(201).json(result);
} catch (error: any) {
const mapped = mapDataError(error, req.params?.object);
// [#24715] A NOT NULL refusal names the field by its label,
// in the caller's locale; every other answer is unchanged.
const mapped = await this.labelNotNullRefusal(
mapDataError(error, req.params?.object),
req,
isScoped ? req.params?.environmentId : undefined,
context,
);
logUnexpectedRouteError(error, mapped);
res.status(mapped.status).json(mapped.body);
}
Expand Down Expand Up @@ -382,10 +393,12 @@ export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): vo
method: 'PATCH',
path: `${dataPath}/:object/:id`,
handler: async (req: any, res: any) => {
// [#24715] As on the create: read by the catch.
let context: any;
try {
const environmentId = isScoped ? req.params?.environmentId : undefined;
const p = await this.resolveProtocol(environmentId, req);
const context = await this.resolveExecCtx(environmentId, req);
context = await this.resolveExecCtx(environmentId, req);
if (this.enforceAuth(req, res, context)) return;
// OCC: clients opt in by sending either the standard
// `If-Match` header or an `expectedVersion` field in
Expand Down Expand Up @@ -472,7 +485,13 @@ export function registerCrudEndpoints(this: CrudRouteHost, basePath: string): vo
applyDroppedFieldsHeader(res, result);
res.json(result);
} catch (error: any) {
const mapped = mapDataError(error, req.params?.object);
// [#24715] The create's relabel, on the update.
const mapped = await this.labelNotNullRefusal(
mapDataError(error, req.params?.object),
req,
isScoped ? req.params?.environmentId : undefined,
context,
);
logUnexpectedRouteError(error, mapped);
res.status(mapped.status).json(mapped.body);
}
Expand Down
242 changes: 242 additions & 0 deletions packages/rest/src/data-not-null-field-label-24715.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#24715] A `/data` write's NOT NULL refusal names the field by its LABEL, in
* the caller's locale, and a field declared `storage: { notNull: true }` is not
* told to declare `required`.
*
* `unit_price` (label "Sales Price") is ADR-0113's engine-populated column:
* `storage: { notNull: true }`, no `required`. The record validator judges
* `required` only, so a write with no value for it reaches the driver and the
* DATABASE refuses it. Measured on `origin/main` `7e7e605de` through these same
* two doors, before the change:
*
* PATCH /api/v1/data/OBJECT/ID { "unit_price": null }
* POST /api/v1/data/OBJECT { …, no unit_price }
* 400 { "error": "unit_price is required", "code": "VALIDATION_FAILED",
* "fields": [{ "field": "unit_price", "code": "required",
* "message": "unit_price is required" }],
* "hint": "The database column for 'unit_price' requires a value:
* provide it, or declare the field `required` in the
* object metadata. If the object declares neither … run
* 'os migrate' …", "object": "OBJECT" }
*
* Read through the public doors, over a REAL `ObjectQL` +
* `ObjectStackProtocolImplementation` + SQLite `:memory:`, with the stored
* metadata family registered so the protocol's by-name object read answers
* (the door reads the field's label and `storage` there).
*
* The controls: a field the metadata declares with neither `required` nor
* `storage.notNull` on a NOT NULL column (drift) keeps the drift hint; a column
* the metadata does not declare at all keeps the column's sentence. Pinned by
* kind: the sentence is the catalog's `required` sentence for the label (asked
* of `renderValidationMessage`, the record validator's own renderer), and the
* hint is judged by what it prescribes.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import {
SysMetadataAuditObject,
SysMetadataCommitObject,
SysMetadataHistoryObject,
SysMetadataObject,
} from '@objectstack/metadata-core';
import { renderValidationMessage } from '@objectstack/spec/system';
import { RestServer } from './rest-server';

const OBJECT = 'line_item_24715';

/** The metadata as the DDL saw it: every column the table is built with. */
const AS_SYNCED = {
name: OBJECT, label: 'Line item', systemFields: false,
fields: {
id: { name: 'id', type: 'text' as const, primaryKey: true },
name: { name: 'name', type: 'text' as const, label: 'Name' },
// ADR-0113's engine-populated column: NOT NULL on purpose, not `required`.
unit_price: { name: 'unit_price', type: 'number' as const, label: 'Sales Price', storage: { notNull: true } },
// The validator's own `required`, for the parity case.
list_price: { name: 'list_price', type: 'number' as const, label: 'List Price', required: true },
drift_col: { name: 'drift_col', type: 'text' as const, label: 'Drift Column', storage: { notNull: true } },
gone_col: { name: 'gone_col', type: 'text' as const, label: 'Gone Column', storage: { notNull: true } },
},
};

/**
* The metadata the doors read afterwards: `drift_col` declares neither
* `required` nor `storage.notNull` over a column that is still NOT NULL (drift),
* and `gone_col` is not declared at all.
*/
const SERVED = (() => {
const { gone_col: _gone, drift_col, ...fields } = AS_SYNCED.fields;
const { storage: _storage, ...undeclared } = drift_col;
return { ...AS_SYNCED, fields: { ...fields, drift_col: undeclared } };
})();

const ZH_LABELS: Record<string, string> = {
[`objects.${OBJECT}.fields.unit_price.label`]: '销售价格',
[`objects.${OBJECT}.fields.list_price.label`]: '标价',
};
/** An `II18nService` holding a `zh-CN` bundle: `t` echoes the key on a miss. */
const I18N = {
t: (key: string, locale: string) => (locale === 'zh-CN' && ZH_LABELS[key]) || key,
getLocales: () => ['en', 'zh-CN'],
};

function createMockServer() {
const noop = () => {};
return { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} };
}

function makeRes() {
const res: any = {
write: () => true, end: () => {},
header: () => res,
status: (code: number) => { res._status = code; return res; },
json: (body: any) => { res._json = body; return res; },
};
return res;
}

const liveEngines: ObjectQL[] = [];
afterEach(async () => {
while (liveEngines.length) {
try { await liveEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

async function boot(opts: { locale?: string; i18n?: boolean } = {}) {
const engine = new ObjectQL();
liveEngines.push(engine);
engine.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
await engine.init();
if (opts.i18n) engine.setI18nService(I18N);
engine.registry.registerObject(AS_SYNCED as any);
await engine.syncSchemas();
await engine.insert(OBJECT, { id: 'e1', name: 'existing', unit_price: 5, list_price: 7, drift_col: 'd', gone_col: 'g' } as any);
engine.registry.registerObject(SERVED as any);
// The protocol's object read consults the stored-metadata family; registered
// after the DDL, as a boot does, so its unprovisioned table reads as empty.
for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) {
if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any);
}

const protocol = new ObjectStackProtocolImplementation(engine as any);
const rest = new RestServer(
createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any,
undefined, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined,
// i18nServiceProvider — the 14th constructor argument.
async () => (opts.i18n ? I18N : undefined) as any,
);
(rest as any).resolveExecCtx = async () => ({ userId: 'test-user', ...(opts.locale ? { locale: opts.locale } : {}) });
rest.registerRoutes();
const call = async (method: string, path: string, body: unknown, params: Record<string, string> = { object: OBJECT }) => {
const route = rest.getRoutes().find((r: any) => r.method === method && r.path === path);
expect(route, `${method} ${path}`).toBeDefined();
const res = makeRes();
await route!.handler({ params, body, query: {}, headers: {} } as any, res);
return { status: res._status ?? 200, body: res._json };
};
const patch = (data: Record<string, unknown>) =>
call('PATCH', '/api/v1/data/:object/:id', data, { object: OBJECT, id: 'e1' });
const create = (data: Record<string, unknown>) => call('POST', '/api/v1/data/:object', data);
const stored = async () => (await engine.find(OBJECT, { where: { id: 'e1' } }) as any[])[0];
return { engine, patch, create, stored };
}

/** The catalog's `required` sentence for `label`, in `locale` — what the record validator renders. */
const requiredSentence = (label: string, field: string, locale?: string) =>
renderValidationMessage({ messageKey: 'required', label, field, params: {} }, { locale });

/** The envelope the classification answers, with the field's sentence `message`. */
function expectRefusal(door: { status: number; body: any }, field: string, message: string) {
expect(door.status, JSON.stringify(door.body)).toBe(400);
expect(door.body).toMatchObject({
code: 'VALIDATION_FAILED',
error: message,
fields: [{ field, code: 'required', message }],
object: OBJECT,
});
// No key is added or dropped, on the body or on the field.
expect(Object.keys(door.body).sort()).toEqual(['code', 'error', 'fields', 'hint', 'object']);
expect(Object.keys(door.body.fields[0]).sort()).toEqual(['code', 'field', 'message']);
}

/** The declared column's hint: the value, or the default / hook that supplies it — never `required`, never drift. */
function expectDeclaredNotNullHint(hint: unknown, field: string) {
const text = String(hint);
expect(text).toContain(`'${field}'`);
expect(text).toContain('`storage: { notNull: true }`');
expect(text).toMatch(/requires a value/);
expect(text).not.toContain('`required`');
expect(text).not.toMatch(/drift|os migrate/);
}

describe('[#24715] a declared `storage.notNull` field\'s refusal names its label', () => {
it('the fixture is the ADR-0113 posture: the validator lets the write through and the database refuses it', async () => {
const { engine } = await boot();
expect(AS_SYNCED.fields.unit_price).toMatchObject({ label: 'Sales Price', storage: { notNull: true } });
expect('required' in AS_SYNCED.fields.unit_price).toBe(false);
await expect(engine.update(OBJECT, { unit_price: null } as any, { where: { id: 'e1' } } as any)).rejects.toThrow(/not null/i);
});

it('PATCH writing null: the label in `error` and `fields[0].message`, the declared column\'s hint', async () => {
const { patch, stored } = await boot();
const door = await patch({ unit_price: null });
expectRefusal(door, 'unit_price', requiredSentence('Sales Price', 'unit_price'));
expect(door.body.error).not.toContain('unit_price');
expectDeclaredNotNullHint(door.body.hint, 'unit_price');
expect((await stored()).unit_price).toBe(5);
});

it('POST with no value: the same answer', async () => {
const { create, engine } = await boot();
const door = await create({ id: 'n1', name: 'new', list_price: 1, drift_col: 'd' });
expectRefusal(door, 'unit_price', requiredSentence('Sales Price', 'unit_price'));
expectDeclaredNotNullHint(door.body.hint, 'unit_price');
expect(await engine.find(OBJECT, { where: { id: 'n1' } })).toHaveLength(0);
});
});

describe('[#24715] localized the way the record validator localizes its own `required`', () => {
it('zh-CN: the translated label in the catalog\'s zh-CN sentence — the validator\'s template, word for word', async () => {
const { patch, create } = await boot({ locale: 'zh-CN', i18n: true });

const door = await patch({ unit_price: null });
expectRefusal(door, 'unit_price', requiredSentence('销售价格', 'unit_price', 'zh-CN'));
expectDeclaredNotNullHint(door.body.hint, 'unit_price');

// The validator, on the same request shape, for the field it refuses itself.
const own = await create({ id: 'n1', name: 'new', unit_price: 1, drift_col: 'd' });
expect(own.status, JSON.stringify(own.body)).toBe(400);
const validatorMessage = own.body.fields.find((f: any) => f.field === 'list_price')?.message;
expect(validatorMessage).toBe(requiredSentence('标价', 'list_price', 'zh-CN'));
expect(door.body.error.replace('销售价格', 'LABEL')).toBe(validatorMessage.replace('标价', 'LABEL'));
});

it('a bare `zh` reaches the zh-CN bundle, as the validator\'s locale negotiation does', async () => {
const { patch } = await boot({ locale: 'zh', i18n: true });
const door = await patch({ unit_price: null });
expectRefusal(door, 'unit_price', requiredSentence('销售价格', 'unit_price', 'zh-CN'));
});
});

describe('[#24715] the controls: what the field map cannot name keeps today\'s answer', () => {
it('drift — declared with neither `required` nor `storage.notNull` over a NOT NULL column: the label, and the drift hint', async () => {
const { patch } = await boot();
const door = await patch({ drift_col: null });
expectRefusal(door, 'drift_col', requiredSentence('Drift Column', 'drift_col'));
expect(String(door.body.hint)).toMatch(/drifted from metadata/);
expect(String(door.body.hint)).toMatch(/os migrate/);
});

it('a column the metadata does not declare: the column\'s sentence and the drift hint', async () => {
const { create } = await boot();
const door = await create({ id: 'n1', name: 'new', unit_price: 1, list_price: 1, drift_col: 'd' });
expectRefusal(door, 'gone_col', 'gone_col is required');
expect(String(door.body.hint)).toMatch(/os migrate/);
});
});
Loading
Loading