diff --git a/.chronus/changes/merge-patch-null-record-values-2026-08-16.md b/.chronus/changes/merge-patch-null-record-values-2026-08-16.md new file mode 100644 index 00000000000..a33d76b078e --- /dev/null +++ b/.chronus/changes/merge-patch-null-record-values-2026-08-16.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/http" +--- + +Allow `null` values in `Record` properties transformed by `MergePatchUpdate` so individual keys can be deleted. diff --git a/packages/http/src/merge-patch.ts b/packages/http/src/merge-patch.ts index 1e9378a1c31..dd4c663473c 100644 --- a/packages/http/src/merge-patch.ts +++ b/packages/http/src/merge-patch.ts @@ -366,14 +366,18 @@ function createMergePatchMutator( model.indexer!.value, ).type, }; - } else if ($(realm).record.is(model) && isMergePatchSubject(model.indexer!.value)) { + } else if ($(realm).record.is(model)) { + const value = isMergePatchSubject(model.indexer!.value) + ? mutateSubgraph( + program, + [_optionalInteriorMutator ?? self], // records are always CreateOrUpdate + model.indexer!.value, + ).type + : model.indexer!.value; clone.indexer = { key: model.indexer!.key, - value: mutateSubgraph( - program, - [_optionalInteriorMutator ?? self], // records are always CreateOrUpdate - model.indexer!.value, - ).type, + // A null record value represents deletion of that key in a merge patch. + value: nullableRecordValue(realm, value), }; } @@ -483,6 +487,23 @@ function createMergePatchMutator( ], }); } + + function nullableRecordValue(realm: Realm, type: Type): Union { + const types: Type[] = []; + addType(type); + addType($(realm).intrinsic.null); + return $(realm).union.create(types); + + function addType(type: Type): void { + if ($(realm).union.is(type)) { + for (const variant of type.variants.values()) { + addType(variant.type); + } + } else if (!types.includes(type)) { + types.push(type); + } + } + } } type MergePatchSubject = Model | Union | ModelProperty | UnionVariant | Tuple; diff --git a/packages/http/test/merge-patch.test.ts b/packages/http/test/merge-patch.test.ts index 81766b01897..9dade3f633c 100644 --- a/packages/http/test/merge-patch.test.ts +++ b/packages/http/test/merge-patch.test.ts @@ -419,7 +419,10 @@ describe("mutator validation", () => { const resource = getNonNullableType(related.type); ok(resource); deepStrictEqual(resource.kind, "Model"); - const valueResource = $(runner.program).record.getElementType(resource); + const recordValue = $(runner.program).record.getElementType(resource); + ok(recordValue); + expect(checkNullableUnion(runner.program, recordValue)).toBe(true); + const valueResource = getNonNullableType(recordValue); ok(valueResource); deepStrictEqual(valueResource.kind, "Model"); validateResource(valueResource); @@ -710,7 +713,10 @@ describe("visibility transforms", () => { deepStrictEqual(envelope.kind, "Model"); const record = checkProperty(envelope, "subject", true, "Model").type; deepStrictEqual(record.kind, "Model"); - const innerEnvelope = record.indexer?.value; + const value = record.indexer?.value; + ok(value); + expect(checkNullableUnion(runner.program, value)).toBe(true); + const innerEnvelope = getNonNullableType(value); ok(innerEnvelope); deepStrictEqual(innerEnvelope.kind, "Model"); checkProperty(innerEnvelope, "id", true, "Scalar", "string"); @@ -964,7 +970,10 @@ describe("visibility transforms", () => { deepStrictEqual(envelope.kind, "Model"); const record = checkProperty(envelope, "subject", true, "Model").type; deepStrictEqual(record.kind, "Model"); - const innerEnvelope = record.indexer?.value; + const value = record.indexer?.value; + ok(value); + expect(checkNullableUnion(runner.program, value)).toBe(true); + const innerEnvelope = getNonNullableType(value); ok(innerEnvelope); deepStrictEqual(innerEnvelope.kind, "Model"); checkProperty(innerEnvelope, "id", true, "Scalar", "string"); @@ -973,6 +982,55 @@ describe("visibility transforms", () => { isNullableUnion(checkProperty(innerEnvelope, "updateOnly", true, "Union")); expect(innerEnvelope.properties.size).toBe(4); }); + + it("allows null record values to delete individual keys", async () => { + const [typeGraph, diag] = await compileAndDiagnoseWithRunner( + runner, + ` + model Foo { + tags?: Record; + values?: Record; + nullableValues?: Record; + } + + @patch op update(@body body: MergePatchUpdate): Foo;`, + ); + expectDiagnosticEmpty(diag); + const envelope = typeGraph[0].parameters?.body?.type; + ok(envelope); + deepStrictEqual(envelope.kind, "Model"); + const tags = getNonNullableType(checkProperty(envelope, "tags", true, "Union").type); + ok(tags); + deepStrictEqual(tags.kind, "Model"); + const value = tags.indexer?.value; + ok(value); + expect(checkNullableUnion(runner.program, value)).toBe(true); + const stringValue = getNonNullableType(value); + ok(stringValue); + deepStrictEqual(stringValue.kind, "Scalar"); + expect(stringValue.name).toBe("string"); + + for (const [name, expectedVariants] of [ + ["values", 3], + ["nullableValues", 2], + ] as const) { + const record = getNonNullableType(checkProperty(envelope, name, true, "Union").type); + ok(record); + deepStrictEqual(record.kind, "Model"); + const recordValue = record.indexer?.value; + ok(recordValue); + deepStrictEqual(recordValue.kind, "Union"); + expect(recordValue.variants.size).toBe(expectedVariants); + expect( + [...recordValue.variants.values()].filter( + (variant) => variant.type === $(runner.program).intrinsic.null, + ), + ).toHaveLength(1); + expect( + [...recordValue.variants.values()].every((variant) => variant.type.kind !== "Union"), + ).toBe(true); + } + }); it("handles complex (required) model property visibility for MergePatchCreateOrUpdate", async () => { const [typeGraph, diag] = await compileAndDiagnoseWithRunner( runner,