fix: address Copilot review round 41 — const does not demand a property, required-only branches
- `const` constrains a property that is PRESENT; it neither requires the
property nor acts as a default. It is now supplied automatically only where
the schema also requires the field — in `collectSchemaDefaults`,
`applySchemaConstants` and the TUI's initial values alike — so an optional
`dryRun: { const: true }` no longer turns a valid `{}` call into one that
asks the server to do something. A value that IS present is still corrected
to the constant, whatever supplied it, and the field stays read-only.
- Offerability is judged on the base+member merge rather than the raw member,
so `anyOf: [{ required: ["email"] }, { required: ["phone"] }]` over
root-declared properties is offered. It was declined, which left the gate
checking the base alone and accepting `{}` — which that schema rejects.
- The TUI's branch sections keep the schema's own `required` for the seeding
decision while still rendering every branch field non-required, which is what
keeps a static form submittable.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
8f935adaad
commit
b0b252a5c0
2 changed files with 167 additions and 22 deletions
|
|
@ -375,15 +375,32 @@ describe("root composition (#2123)", () => {
|
|||
expect(collectSchemaDefaults(UNION)).not.toHaveProperty("phone");
|
||||
});
|
||||
|
||||
it("seeds a const property on an ordinary schema too", () => {
|
||||
it("seeds a required const property on an ordinary schema too", () => {
|
||||
expect(
|
||||
collectSchemaDefaults({
|
||||
type: "object",
|
||||
properties: { version: { type: "string", const: "1" } },
|
||||
required: ["version"],
|
||||
}),
|
||||
).toEqual({ version: "1" });
|
||||
});
|
||||
|
||||
it("leaves an optional const out", () => {
|
||||
// `const` constrains a present value; it does not require the property or
|
||||
// act as a default, so seeding it would turn a valid `{}` call into one
|
||||
// that asks the server to do something.
|
||||
const optional: InspectorFormSchema = {
|
||||
type: "object",
|
||||
properties: { dryRun: { type: "boolean", const: true } },
|
||||
};
|
||||
expect(collectSchemaDefaults(optional)).toEqual({});
|
||||
expect(applySchemaConstants(optional, {})).toEqual({});
|
||||
// …but a value that IS present must be the one the schema fixes.
|
||||
expect(applySchemaConstants(optional, { dryRun: false })).toEqual({
|
||||
dryRun: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers a const over a conflicting default", () => {
|
||||
// `default` is an annotation, not a constraint, so a schema may advertise
|
||||
// one its own `const` rejects — seeding it would submit an invalid value
|
||||
|
|
@ -394,6 +411,7 @@ describe("root composition (#2123)", () => {
|
|||
properties: {
|
||||
v: { type: "string", const: "a", default: "b" },
|
||||
},
|
||||
required: ["v"],
|
||||
}),
|
||||
).toEqual({ v: "a" });
|
||||
});
|
||||
|
|
@ -454,6 +472,7 @@ describe("root composition (#2123)", () => {
|
|||
properties: Object.fromEntries([
|
||||
["__proto__", { type: "string", const: "kept" }],
|
||||
]),
|
||||
required: ["__proto__"],
|
||||
});
|
||||
expect(Object.hasOwn(seeded, "__proto__")).toBe(true);
|
||||
});
|
||||
|
|
@ -474,6 +493,7 @@ describe("root composition (#2123)", () => {
|
|||
kind: { type: "string", const: "email" },
|
||||
address: { type: "string" },
|
||||
},
|
||||
required: ["kind"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
|
|
@ -481,6 +501,7 @@ describe("root composition (#2123)", () => {
|
|||
kind: { type: "string", const: "sms" },
|
||||
phone: { type: "string" },
|
||||
},
|
||||
required: ["kind"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -145,7 +145,17 @@ export function collectSchemaDefaults(
|
|||
// to a form showing the branch the values actually identify.
|
||||
const { base, branches } = resolveRootUnion(schema);
|
||||
const selected = selectBranchIndex(branches, knownValues) ?? 0;
|
||||
const properties = (branches[selected]?.schema ?? base).properties ?? {};
|
||||
const effective = branches[selected]?.schema ?? base;
|
||||
const properties = effective.properties ?? {};
|
||||
// `const` constrains a property only when it is PRESENT — it neither requires
|
||||
// the property nor acts as a default. So it is supplied automatically for a
|
||||
// REQUIRED field, whose only submittable value was never in doubt and which
|
||||
// the form renders read-only, and left alone otherwise: seeding an optional
|
||||
// `dryRun: { const: true }` would turn a valid `{}` call into one that asks
|
||||
// the server to do something.
|
||||
const requiredNames = Array.isArray(effective.required)
|
||||
? effective.required
|
||||
: [];
|
||||
const result: Record<string, unknown> = {};
|
||||
// `properties` is a JSON record, so `__proto__` is a legal field name that a
|
||||
// plain assignment would drop into the legacy prototype setter rather than
|
||||
|
|
@ -164,10 +174,10 @@ export function collectSchemaDefaults(
|
|||
// 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.const !== undefined) {
|
||||
// `const` is a one-value enumeration, so the only submittable value is
|
||||
// already known — seeding it spares the user hand-typing a discriminator
|
||||
// the schema has fixed (#2123).
|
||||
if (fieldSchema.const !== undefined && requiredNames.includes(fieldName)) {
|
||||
// `const` is a one-value enumeration, so a required field's only
|
||||
// submittable value is already known — seeding it spares the user
|
||||
// hand-typing a discriminator the schema has fixed (#2123).
|
||||
//
|
||||
// It outranks `default`, which JSON Schema defines as an annotation
|
||||
// rather than a constraint: a schema may advertise a default its own
|
||||
|
|
@ -267,13 +277,21 @@ export function applySchemaConstants(
|
|||
): Record<string, unknown> {
|
||||
const { base, branches } = resolveRootUnion(schema);
|
||||
const selected = selectBranchIndex(branches, values) ?? 0;
|
||||
const properties = (branches[selected]?.schema ?? base).properties ?? {};
|
||||
const effective = branches[selected]?.schema ?? base;
|
||||
const properties = effective.properties ?? {};
|
||||
const required = Array.isArray(effective.required) ? effective.required : [];
|
||||
|
||||
const corrections: [string, unknown][] = [];
|
||||
for (const [name, rawSchema] of Object.entries(properties)) {
|
||||
const fieldSchema = normalizeNullableUnion(rawSchema);
|
||||
if (fieldSchema.const !== undefined) {
|
||||
corrections.push([name, fieldSchema.const]);
|
||||
// Corrected where the property is PRESENT — whatever supplied it, the
|
||||
// schema fixes the value — and inserted only where the schema also
|
||||
// requires it. An optional `const` the caller left out stays out:
|
||||
// `const` constrains a present value, it does not demand one.
|
||||
if (Object.hasOwn(values, name) || required.includes(name)) {
|
||||
corrections.push([name, fieldSchema.const]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Recurse: a nested object renders its own read-only fields, and the
|
||||
|
|
|
|||
Loading…
Reference in a new issue