fix: address Copilot review on the nullable-union collapse
Four findings, all valid: 1. Returning `T` was unsound. The collapse genuinely changes two fields — `type` stops being an array, `anyOf` becomes `undefined` — so a caller whose `T` typed either that way could keep treating it so afterwards. The result is now `T | NormalizedNullableUnion<T>`, which states exactly that: `Omit<T, "type" | "anyOf">` plus a `RenderableType` `type`, a cleared `anyOf`, and `nullable`. The one assertion left is isolated in a `collapsed()` helper, needed only because `Omit` stays deferred while `T` is open. 2. A typeless `enum` does not imply strings. JSON Schema allows `enum: [1, 2]`, and inferring `"string"` for it would hand numbers to a `Select` declared `string[]`, and make the TUI submit `"1"` for `1`. Now only an all-string enum earns the inference; anything else stays on the JSON fallback. An explicit `type` is still authoritative. 3. `hasMissingRequiredFields` counted every null as missing, so clearing a *required* nullable enum — newly possible via the clear button — would disable submit on a value the schema permits. `required` constrains presence, not content, so null now counts as present exactly when the field's schema admits it. 4. `collectSchemaDefaults` read the raw union, so a default nested inside a nullable object's branch was displayed by the form but never seeded into the submitted values. It normalizes each property first, like the renderer does. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
17a4c78dd3
commit
e4e16bb3e6
4 changed files with 207 additions and 20 deletions
|
|
@ -144,6 +144,39 @@ describe("normalizeNullableUnion", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// JSON Schema's `enum` is untyped, so a typeless branch only implies strings
|
||||
// when every member is one. Guessing otherwise would hand a number to a
|
||||
// renderer that declared the option list `string[]`.
|
||||
it("does not infer string for a typeless non-string enum branch", () => {
|
||||
const schema = { anyOf: [{ enum: [1, 2] }, { type: "null" as const }] };
|
||||
expect(normalizeNullableUnion(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("does not infer string for a typeless mixed enum branch", () => {
|
||||
const schema = { anyOf: [{ enum: ["a", 2] }, { type: "null" as const }] };
|
||||
expect(normalizeNullableUnion(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("does not infer string for a typeless empty enum branch", () => {
|
||||
const schema = { anyOf: [{ enum: [] }, { type: "null" as const }] };
|
||||
expect(normalizeNullableUnion(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
// An explicit `type` is authoritative — the enum members are the server's
|
||||
// problem at that point, not an inference this function is making.
|
||||
it("still collapses a non-string enum branch that declares its type", () => {
|
||||
expect(
|
||||
normalizeNullableUnion({
|
||||
anyOf: [{ type: "number", enum: [1, 2] }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
type: "number",
|
||||
enum: [1, 2],
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("infers string for a typeless enum branch", () => {
|
||||
expect(
|
||||
normalizeNullableUnion({
|
||||
|
|
|
|||
|
|
@ -221,6 +221,42 @@ describe("collectSchemaDefaults", () => {
|
|||
it("returns an empty object when the schema has no properties", () => {
|
||||
expect(collectSchemaDefaults({ type: "object" })).toEqual({});
|
||||
});
|
||||
|
||||
// The form normalizes a nullable union before rendering, so this has to as
|
||||
// well: a nested object's `properties` live on the union's surviving branch,
|
||||
// and a default only visible there would be displayed but never submitted.
|
||||
it("collects defaults hoisted out of a nullable object union", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
profile: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { nick: { type: "string", default: "ada" } },
|
||||
},
|
||||
{ type: "null" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(collectSchemaDefaults(schema)).toEqual({
|
||||
profile: { nick: "ada" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a default declared on the union wrapper itself", () => {
|
||||
const schema: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
direction: {
|
||||
default: "envio",
|
||||
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(collectSchemaDefaults(schema)).toEqual({ direction: "envio" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMissingRequiredFields", () => {
|
||||
|
|
@ -247,4 +283,44 @@ describe("hasMissingRequiredFields", () => {
|
|||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// JSON Schema `required` constrains presence, not content — so an explicit
|
||||
// `null` satisfies a required field whose schema admits null. Since #1928 the
|
||||
// user can produce exactly that by clearing a nullable enum, and treating it
|
||||
// as missing would disable submit on a value the schema calls valid.
|
||||
it("accepts an explicit null for a required field that admits null", () => {
|
||||
const nullable: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
direction: {
|
||||
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
|
||||
},
|
||||
},
|
||||
required: ["direction"],
|
||||
};
|
||||
expect(hasMissingRequiredFields(nullable, { direction: null })).toBe(false);
|
||||
expect(hasMissingRequiredFields(nullable, {})).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts an explicit null for a required type: [T, null] field", () => {
|
||||
const nullable: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: { note: { type: ["string", "null"] } },
|
||||
required: ["note"],
|
||||
};
|
||||
expect(hasMissingRequiredFields(nullable, { note: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("still rejects null for a required field whose schema is not nullable", () => {
|
||||
expect(hasMissingRequiredFields(schema, { name: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects null for a required field the schema does not describe", () => {
|
||||
const undescribed: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: ["ghost"],
|
||||
};
|
||||
expect(hasMissingRequiredFields(undescribed, { ghost: null })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
|
||||
|
||||
export type JsonValue =
|
||||
| string
|
||||
| number
|
||||
|
|
@ -116,7 +118,12 @@ export function collectSchemaDefaults(
|
|||
): Record<string, unknown> {
|
||||
const properties = schema.properties ?? {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [fieldName, fieldSchema] of Object.entries(properties)) {
|
||||
for (const [fieldName, rawSchema] of Object.entries(properties)) {
|
||||
// Collapse a nullable union first, for the same reason `SchemaForm` does:
|
||||
// a nested object's `properties` live on the union's surviving branch, so
|
||||
// without this the form would *display* a hoisted default that never
|
||||
// reached the seeded values — the field would submit empty (#1928).
|
||||
const fieldSchema = normalizeNullableUnion(rawSchema);
|
||||
if (fieldSchema.default !== undefined) {
|
||||
result[fieldName] = fieldSchema.default;
|
||||
} else if (fieldSchema.type === "object" && fieldSchema.properties) {
|
||||
|
|
@ -133,15 +140,29 @@ export function collectSchemaDefaults(
|
|||
* Whether any of the schema's required top-level fields is missing a value in
|
||||
* `values` (absent, null, or empty string). Used to gate a form's submit
|
||||
* action until required fields are supplied.
|
||||
*
|
||||
* `null` counts as missing only for a field that does not admit it. JSON
|
||||
* Schema's `required` constrains *presence*, not content, so a required field
|
||||
* whose schema is a nullable union is satisfied by an explicit `null` — and
|
||||
* since #1928 the user can produce exactly that by clearing a nullable enum.
|
||||
* Treating it as missing would leave the form's submit permanently disabled on
|
||||
* a value the schema calls valid.
|
||||
*/
|
||||
export function hasMissingRequiredFields(
|
||||
schema: InspectorFormSchema,
|
||||
values: Record<string, unknown>,
|
||||
): boolean {
|
||||
const required = schema.required ?? [];
|
||||
const properties = schema.properties ?? {};
|
||||
return required.some((field) => {
|
||||
const value = values[field];
|
||||
return value === undefined || value === null || value === "";
|
||||
if (value === null) {
|
||||
const fieldSchema = properties[field];
|
||||
return fieldSchema === undefined
|
||||
? true
|
||||
: normalizeNullableUnion(fieldSchema).nullable !== true;
|
||||
}
|
||||
return value === undefined || value === "";
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ const RENDERABLE_TYPES = [
|
|||
"object",
|
||||
] as const;
|
||||
|
||||
type RenderableType = (typeof RENDERABLE_TYPES)[number];
|
||||
/** A `type` name the collapse is willing to produce. */
|
||||
export type RenderableType = (typeof RENDERABLE_TYPES)[number];
|
||||
|
||||
function isRenderableType(type: unknown): type is RenderableType {
|
||||
return RENDERABLE_TYPES.includes(type as RenderableType);
|
||||
|
|
@ -51,6 +52,71 @@ function toBranch(value: unknown): Record<string, unknown> | null {
|
|||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a branch's `enum` is one a *typeless* branch can be read as a string
|
||||
* enum.
|
||||
*
|
||||
* JSON Schema's `enum` is untyped — `[1, 2]`, `[true, false]`, and `[null]` are
|
||||
* all legal — so a bare `{ enum: [...] }` does **not** imply strings. Guessing
|
||||
* `"string"` for a numeric enum would hand non-strings to a renderer that has
|
||||
* declared them `string[]`: the web `Select` would receive numbers, and the TUI
|
||||
* would `String(...)` them and submit `"1"` where the server expects `1`. So
|
||||
* only an all-string enum earns the inference; anything else stays on the
|
||||
* fallback path, which renders the value honestly as JSON.
|
||||
*/
|
||||
function isStringEnum(value: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((member) => typeof member === "string")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a collapse that actually matched. Spelled out rather than
|
||||
* reusing `T` because the collapse genuinely changes two of `T`'s fields:
|
||||
* `type` becomes a single renderable name (never an array, never `"null"`),
|
||||
* and `anyOf` is cleared. Returning `T` for these would let a caller whose `T`
|
||||
* types `type` as an array go on treating it as one after it has become a
|
||||
* string, or dereference an `anyOf` that is now `undefined`.
|
||||
*
|
||||
* Note this cannot make the *hoist* sound: an `anyOf` branch is `unknown`, so
|
||||
* any keyword lifted off it is whatever the server sent, however `T` declares
|
||||
* it. {@link isStringEnum} validates the one keyword the renderers dereference
|
||||
* as a typed array (`enum`); the rest reach widgets that read them defensively.
|
||||
*/
|
||||
export type NormalizedNullableUnion<T extends NullableUnionSchema> = Omit<
|
||||
T,
|
||||
"type" | "anyOf"
|
||||
> & {
|
||||
type?: RenderableType;
|
||||
anyOf?: undefined;
|
||||
nullable?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the collapsed schema.
|
||||
*
|
||||
* The assertion is needed because the spread merges an unresolved generic `T`
|
||||
* with an `unknown` branch, and `NormalizedNullableUnion<T>`'s `Omit` stays
|
||||
* deferred while `T` is open — so TS cannot verify the two line up even though
|
||||
* every field is set right here. Isolated in this one function so the exported
|
||||
* signature carries the contract and nothing else has to assert it.
|
||||
*/
|
||||
function collapsed<T extends NullableUnionSchema>(
|
||||
schema: T,
|
||||
branch: Record<string, unknown> | undefined,
|
||||
type: RenderableType,
|
||||
): NormalizedNullableUnion<T> {
|
||||
return {
|
||||
...schema,
|
||||
...branch,
|
||||
type,
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
} as NormalizedNullableUnion<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and
|
||||
* FastMCP's optional arguments emit — into `{ type: <T>, nullable: true }`.
|
||||
|
|
@ -76,7 +142,7 @@ function toBranch(value: unknown): Record<string, unknown> | null {
|
|||
*/
|
||||
export function normalizeNullableUnion<T extends NullableUnionSchema>(
|
||||
schema: T,
|
||||
): T {
|
||||
): T | NormalizedNullableUnion<T> {
|
||||
if (schema.anyOf?.length === 2) {
|
||||
const branches = schema.anyOf.map(toBranch);
|
||||
const nullBranch = branches.find((entry) => entry?.type === "null");
|
||||
|
|
@ -85,21 +151,13 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>(
|
|||
);
|
||||
|
||||
if (nullBranch && branch) {
|
||||
// A bare `{ enum: [...] }` branch carries no `type`; JSON Schema allows
|
||||
// that, and every value such a schema admits here is a string.
|
||||
const type = branch.type ?? (branch.enum ? "string" : undefined);
|
||||
// A branch may carry an `enum` and no `type`; JSON Schema allows that, and
|
||||
// an all-string enum is unambiguously a string field. See isStringEnum for
|
||||
// why a non-string enum deliberately does not get the same treatment.
|
||||
const type =
|
||||
branch.type ?? (isStringEnum(branch.enum) ? "string" : undefined);
|
||||
if (isRenderableType(type)) {
|
||||
// The result is `schema` with `type` narrowed to a value it already
|
||||
// admitted, the branch's keywords merged in, and `nullable` added. TS
|
||||
// cannot express "a spread of T and a subset of T is still a T", and a
|
||||
// non-generic return would force every caller to cast instead.
|
||||
return {
|
||||
...schema,
|
||||
...branch,
|
||||
type,
|
||||
anyOf: undefined,
|
||||
nullable: true,
|
||||
} as T;
|
||||
return collapsed(schema, branch, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -111,8 +169,7 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>(
|
|||
) {
|
||||
const type = schema.type.find((member) => member !== "null");
|
||||
if (isRenderableType(type)) {
|
||||
// Same reasoning as above.
|
||||
return { ...schema, type, nullable: true } as T;
|
||||
return collapsed(schema, undefined, type);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue