diff --git a/.chronus/changes/merge-patch-template-2026-6-3-21-10-0.md b/.chronus/changes/merge-patch-template-2026-6-3-21-10-0.md new file mode 100644 index 0000000000..0245130db3 --- /dev/null +++ b/.chronus/changes/merge-patch-template-2026-6-3-21-10-0.md @@ -0,0 +1,21 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-azure-core" +--- + +Introduce `MergePatch` function for use in templates. + +```typespec +model MyPatchBody is MergePatch; +``` + +Adds a simplified MergePatch template that takes a model and a rename function, returning a transformed model with: +- All properties made optional (except discriminator properties) +- Default values removed +- Only updatable properties included +- Nested model types recursively transformed +- Record value types recursively transformed +- Array item types left unchanged + +Also adds `templateRenamer` and `mapRenamer` helper functions for naming transformed types. diff --git a/packages/typespec-azure-core/generated-defs/Azure.Core.ts b/packages/typespec-azure-core/generated-defs/Azure.Core.ts index c7706e1ddc..2dd4d70f1f 100644 --- a/packages/typespec-azure-core/generated-defs/Azure.Core.ts +++ b/packages/typespec-azure-core/generated-defs/Azure.Core.ts @@ -3,6 +3,7 @@ import type { DecoratorValidatorCallbacks, Enum, EnumMember, + FunctionContext, Model, ModelProperty, Operation, @@ -210,3 +211,25 @@ export type AzureCoreDecorators = { useFinalStateVia: UseFinalStateViaDecorator; uniqueItems: UniqueItemsDecorator; }; + +export type ApplySimplifiedMergePatchFunctionImplementation = ( + context: FunctionContext, + input: Model, + rename: unknown, +) => Model; + +export type MapRenamerFunctionImplementation = ( + context: FunctionContext, + mapping: Record, +) => unknown; + +export type TemplateRenamerFunctionImplementation = ( + context: FunctionContext, + template: string, +) => unknown; + +export type AzureCoreFunctions = { + applySimplifiedMergePatch: ApplySimplifiedMergePatchFunctionImplementation; + mapRenamer: MapRenamerFunctionImplementation; + templateRenamer: TemplateRenamerFunctionImplementation; +}; diff --git a/packages/typespec-azure-core/generated-defs/Azure.Core.ts-test.ts b/packages/typespec-azure-core/generated-defs/Azure.Core.ts-test.ts index 371fbcc416..d455ebf6eb 100644 --- a/packages/typespec-azure-core/generated-defs/Azure.Core.ts-test.ts +++ b/packages/typespec-azure-core/generated-defs/Azure.Core.ts-test.ts @@ -1,10 +1,15 @@ // An error in the imports would mean that the decorator is not exported or // doesn't have the right name. -import { $decorators } from "@azure-tools/typespec-azure-core"; -import type { AzureCoreDecorators } from "./Azure.Core.js"; +import { $decorators, $functions } from "@azure-tools/typespec-azure-core"; +import type { AzureCoreDecorators, AzureCoreFunctions } from "./Azure.Core.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ const _decs: AzureCoreDecorators = $decorators["Azure.Core"]; + +/** + * An error here would mean that the exported function is not using the same signature. Make sure to have export const $funcName: FuncNameFunction = (...) => ... + */ +const _funcs: AzureCoreFunctions = $functions["Azure.Core"]; diff --git a/packages/typespec-azure-core/lib/azure-core.tsp b/packages/typespec-azure-core/lib/azure-core.tsp index 326bbd3357..7eb8895f5c 100644 --- a/packages/typespec-azure-core/lib/azure-core.tsp +++ b/packages/typespec-azure-core/lib/azure-core.tsp @@ -9,6 +9,7 @@ import "./operations.tsp"; import "./obsolete.tsp"; import "./decorators.tsp"; import "./legacy.tsp"; +import "./merge-patch.tsp"; import "../dist/src/tsp-index.js"; namespace Azure.Core; diff --git a/packages/typespec-azure-core/lib/merge-patch.tsp b/packages/typespec-azure-core/lib/merge-patch.tsp new file mode 100644 index 0000000000..1cb3c4e588 --- /dev/null +++ b/packages/typespec-azure-core/lib/merge-patch.tsp @@ -0,0 +1,24 @@ +namespace Azure.Core; + +using TypeSpec.Reflection; + +alias MergePatch< + T extends Model, + Rename extends valueof fn(name: valueof string) => valueof string +> = applySimplifiedMergePatch(T, Rename); + +#suppress "experimental-feature" +internal extern fn applySimplifiedMergePatch( + input: Reflection.Model, + rename: valueof fn(name: valueof string) => valueof string +): Reflection.Model; + +#suppress "experimental-feature" +extern fn mapRenamer(mapping: valueof Record): valueof fn( + name: valueof string +) => valueof string; + +#suppress "experimental-feature" +extern fn templateRenamer(template: valueof string): valueof fn( + name: valueof string +) => valueof string; diff --git a/packages/typespec-azure-core/src/index.ts b/packages/typespec-azure-core/src/index.ts index f256791393..7148d7dcbc 100644 --- a/packages/typespec-azure-core/src/index.ts +++ b/packages/typespec-azure-core/src/index.ts @@ -28,4 +28,4 @@ export { FinalStateValue } from "./state/final-state.js"; export * from "./traits.js"; export * from "./utils.js"; /** @internal */ -export { $decorators } from "./tsp-index.js"; +export { $decorators, $functions } from "./tsp-index.js"; diff --git a/packages/typespec-azure-core/src/merge-patch.ts b/packages/typespec-azure-core/src/merge-patch.ts new file mode 100644 index 0000000000..018c3bbc3a --- /dev/null +++ b/packages/typespec-azure-core/src/merge-patch.ts @@ -0,0 +1,248 @@ +import { + FunctionContext, + FunctionValue, + getDiscriminator, + getLifecycleVisibilityEnum, + isVisible, + Model, + ModelProperty, + Program, + VisibilityFilter, +} from "@typespec/compiler"; +import { + unsafe_mutateSubgraph as mutateSubgraph, + unsafe_Mutator as Mutator, + unsafe_MutatorFlow as MutatorFlow, +} from "@typespec/compiler/experimental"; +import { $ } from "@typespec/compiler/typekit"; +import type { + ApplySimplifiedMergePatchFunctionImplementation, + MapRenamerFunctionImplementation, + TemplateRenamerFunctionImplementation, +} from "../generated-defs/Azure.Core.js"; + +type Renamer = FunctionValue<[name: string], string>; +type MergePatchSubject = Model | ModelProperty; + +type MutateSubgraphResult = ReturnType; + +const MUTATOR_CACHE = Symbol.for("Azure.Core.SimplifiedMergePatchMutatorCache"); +const MUTATOR_RESULT_CACHE = Symbol.for("Azure.Core.SimplifiedMergePatchMutatorResultCache"); + +interface SimplifiedMergePatchMutatorCache { + [MUTATOR_CACHE]?: WeakMap>; +} + +interface SimplifiedMergePatchMutatorResultCache { + [MUTATOR_RESULT_CACHE]?: WeakMap; +} + +export const applySimplifiedMergePatch: ApplySimplifiedMergePatchFunctionImplementation = ( + context, + input, + rename, +) => { + if (!isRenamer(rename)) { + throw new Error("Expected rename to be a function value."); + } + const mutatorCache = ((context.program as SimplifiedMergePatchMutatorCache)[MUTATOR_CACHE] ??= + new WeakMap()); + + let byInput = mutatorCache.get(rename); + if (!byInput) { + byInput = new WeakMap(); + mutatorCache.set(rename, byInput); + } + + let mutator = byInput.get(input); + if (!mutator) { + mutator = createSimplifiedMergePatchMutator(context, input, rename); + byInput.set(input, mutator); + } + + const { type } = cachedMutateSubgraph(context.program, mutator, input); + if (type.kind !== "Model") { + throw new Error("Expected simplified merge patch transform to return a model."); + } + + return type; +}; + +export const mapRenamer: MapRenamerFunctionImplementation = (context, mapping) => { + return createRenamer(context.program, (name) => mapping[name] ?? name); +}; + +export const templateRenamer: TemplateRenamerFunctionImplementation = (context, template) => { + return createRenamer(context.program, (name) => template.replaceAll("{name}", name)); +}; + +function createRenamer(program: Program, rename: (name: string) => string): Renamer { + const stringType = program.checker.getStdType("string"); + const stringValueConstraint = { + entityKind: "MixedParameterConstraint", + valueType: stringType, + } as const; + const parameter = program.checker.createAndFinishType({ + kind: "FunctionParameter", + mixed: true, + name: "name", + optional: false, + rest: false, + type: stringValueConstraint, + }) as any; + const functionType = program.checker.createAndFinishType({ + kind: "FunctionType", + parameters: [parameter], + returnType: stringValueConstraint, + }) as any; + + return { + entityKind: "Value", + valueKind: "Function", + type: functionType, + parameters: [parameter], + returnType: stringValueConstraint, + implementation: (_context, name) => rename(name), + }; +} + +function createSimplifiedMergePatchMutator( + context: FunctionContext, + input: Model, + rename: Renamer, +): Mutator { + const lifecycle = getLifecycleVisibilityEnum(context.program); + const updateFilter: VisibilityFilter = { + any: new Set([lifecycle.members.get("Update")!]), + }; + + const propertyMutator: Mutator = { + name: "SimplifiedMergePatchProperty", + ModelProperty: { + filter: () => MutatorFlow.DoNotRecur, + replace: (property, clone, program) => { + let modified = false; + const discriminator = isDiscriminatorProperty(program, property); + const optional = discriminator ? false : true; + + if (property.optional !== optional) { + clone.optional = optional; + modified = true; + } + + if (property.defaultValue !== undefined) { + clone.defaultValue = undefined; + modified = true; + } + + if (property.type.kind === "Model" && !$(program).array.is(property.type)) { + const mutated = cachedMutateSubgraph(program, self, property.type); + if (mutated.type !== property.type) { + clone.type = mutated.type; + modified = true; + } + } + + return modified ? clone : property; + }, + }, + }; + + const self: Mutator = { + name: "SimplifiedMergePatch", + Model: { + filter: () => MutatorFlow.DoNotRecur, + replace: (model, clone, program, realm) => { + if ($(program).array.is(model)) { + return model; + } + + let modified = model !== input; + + if ($(program).record.is(model) && model.indexer && model.indexer.value.kind === "Model") { + const mutated = cachedMutateSubgraph(program, self, model.indexer.value); + if (mutated.type !== model.indexer.value) { + clone.indexer = { ...model.indexer, value: mutated.type }; + modified = true; + } + } + + for (const [name, property] of model.properties) { + if (!isVisible(program, property, updateFilter)) { + const clonedProperty = clone.properties.get(name); + if (clonedProperty) { + clone.properties.delete(name); + realm.remove(clonedProperty); + } + modified = true; + continue; + } + + const mutated = cachedMutateSubgraph(program, propertyMutator, property); + const nextProperty = mutated.type as ModelProperty; + if (nextProperty !== property) { + nextProperty.model = clone; + clone.properties.set(name, nextProperty); + modified = true; + } + } + + if (!modified) { + return model; + } + + if (model !== input) { + renameModel(context, rename, clone); + } + + return clone; + }, + }, + }; + + return self; +} + +function cachedMutateSubgraph( + program: Program, + mutator: Mutator, + source: MergePatchSubject, +): MutateSubgraphResult { + const cache = ((mutator as SimplifiedMergePatchMutatorResultCache)[MUTATOR_RESULT_CACHE] ??= + new WeakMap()); + const cached = cache.get(source); + if (cached) { + return cached; + } + + const mutated = mutateSubgraph(program, [mutator], source); + cache.set(source, mutated); + return mutated; +} + +function isDiscriminatorProperty(program: Program, property: ModelProperty): boolean { + if (!property.model) { + return false; + } + + return getDiscriminator(program, property.model)?.propertyName === property.name; +} + +function isRenamer(value: unknown): value is Renamer { + return ( + typeof value === "object" && + value !== null && + "entityKind" in value && + value.entityKind === "Value" && + "valueKind" in value && + value.valueKind === "Function" + ); +} + +function renameModel(context: FunctionContext, rename: Renamer, model: Model) { + if (!model.name || model.name === "Record") { + return; + } + + model.name = context.callFunction(rename.implementation, model.name); +} diff --git a/packages/typespec-azure-core/src/tsp-index.ts b/packages/typespec-azure-core/src/tsp-index.ts index 6e875a37d8..fd6192e449 100644 --- a/packages/typespec-azure-core/src/tsp-index.ts +++ b/packages/typespec-azure-core/src/tsp-index.ts @@ -1,6 +1,6 @@ import type { AzureCoreFoundationsDecorators } from "../generated-defs/Azure.Core.Foundations.js"; import type { AzureCoreFoundationsPrivateDecorators } from "../generated-defs/Azure.Core.Foundations.Private.js"; -import type { AzureCoreDecorators } from "../generated-defs/Azure.Core.js"; +import type { AzureCoreDecorators, AzureCoreFunctions } from "../generated-defs/Azure.Core.js"; import type { AzureCoreTraitsDecorators } from "../generated-defs/Azure.Core.Traits.js"; import type { AzureCoreTraitsPrivateDecorators } from "../generated-defs/Azure.Core.Traits.Private.js"; import { $requestParameter, $responseProperty } from "./decorators.js"; @@ -29,6 +29,7 @@ import { $spreadCustomParameters } from "./decorators/private/spread-custom-para import { $spreadCustomResponseProperties } from "./decorators/private/spread-custom-response-properties.js"; import { $uniqueItems } from "./decorators/unique-items.js"; import { $useFinalStateVia } from "./decorators/use-final-state-via.js"; +import { applySimplifiedMergePatch, mapRenamer, templateRenamer } from "./merge-patch.js"; import { $addTraitProperties, $applyTraitOverride, @@ -45,6 +46,15 @@ import { export { $lib } from "./lib.js"; export { $onValidate } from "./validate.js"; +/** @internal */ +export const $functions = { + "Azure.Core": { + applySimplifiedMergePatch, + mapRenamer, + templateRenamer, + } satisfies AzureCoreFunctions, +}; + /** @internal */ export const $decorators = { "Azure.Core": { diff --git a/packages/typespec-azure-core/test/merge-patch.test.ts b/packages/typespec-azure-core/test/merge-patch.test.ts new file mode 100644 index 0000000000..0866478714 --- /dev/null +++ b/packages/typespec-azure-core/test/merge-patch.test.ts @@ -0,0 +1,261 @@ +import { Model } from "@typespec/compiler"; +import { expectDiagnosticEmpty, t, TesterInstance } from "@typespec/compiler/testing"; +import { $ } from "@typespec/compiler/typekit"; +import { deepStrictEqual, ok, strictEqual } from "assert"; +import { beforeEach, describe, it } from "vitest"; +import { Tester } from "./test-host.js"; + +let runner: TesterInstance; +beforeEach(async () => { + runner = await Tester.createInstance(); +}); + +describe("MergePatch", () => { + describe("property optionality", () => { + it("makes required properties optional", async () => { + const { Result } = await runner.compile(t.code` + model Input { + required: string; + alreadyOptional?: string; + } + model ${t.model("Result")} is MergePatch; + `); + + strictEqual(Result.properties.get("required")?.optional, true); + strictEqual(Result.properties.get("alreadyOptional")?.optional, true); + }); + + it("keeps discriminator properties required", async () => { + const { Result } = await runner.compile(t.code` + @discriminator("kind") + model Input { + kind: "input"; + name: string; + } + model ${t.model("Result")} is MergePatch; + `); + + strictEqual(Result.properties.get("kind")?.optional, false); + strictEqual(Result.properties.get("name")?.optional, true); + }); + }); + + describe("default removal", () => { + it("removes default values from properties", async () => { + const { Result } = await runner.compile(t.code` + model Input { + withDefault: string = "hello"; + withoutDefault: string; + } + model ${t.model("Result")} is MergePatch; + `); + + strictEqual(Result.properties.get("withDefault")?.defaultValue, undefined); + strictEqual(Result.properties.get("withoutDefault")?.defaultValue, undefined); + }); + }); + + describe("visibility filtering", () => { + it("includes properties with default lifecycle visibility", async () => { + const { Result } = await runner.compile(t.code` + model Input { + noExplicitVisibility: string; + } + model ${t.model("Result")} is MergePatch; + `); + + ok(Result.properties.has("noExplicitVisibility")); + }); + + it("includes properties with Lifecycle.Update visibility", async () => { + const { Result } = await runner.compile(t.code` + model Input { + @visibility(Lifecycle.Update) + updateOnly: string; + } + model ${t.model("Result")} is MergePatch; + `); + + ok(Result.properties.has("updateOnly")); + }); + + it("excludes properties with only Lifecycle.Read visibility", async () => { + const { Result } = await runner.compile(t.code` + model Input { + @visibility(Lifecycle.Read) + readOnly: string; + } + model ${t.model("Result")} is MergePatch; + `); + + ok(!Result.properties.has("readOnly")); + }); + + it("excludes properties with only Lifecycle.Create visibility", async () => { + const { Result } = await runner.compile(t.code` + model Input { + @visibility(Lifecycle.Create) + createOnly: string; + } + model ${t.model("Result")} is MergePatch; + `); + + ok(!Result.properties.has("createOnly")); + }); + + it("includes properties with non-Lifecycle visibility", async () => { + const { Result } = await runner.compile(t.code` + enum Other { Flag } + model Input { + @visibility(Other.Flag) + otherVis: string; + } + model ${t.model("Result")} is MergePatch; + `); + + ok(Result.properties.has("otherVis")); + }); + }); + + describe("recursive transformation", () => { + it("transforms model-typed properties recursively", async () => { + const { Result, program } = await runner.compile(t.code` + model Child { + value: string; + @visibility(Lifecycle.Read) + readOnly: string; + } + model Input { + child: Child; + } + model ${t.model("Result")} is MergePatch; + `); + + const childProp = Result.properties.get("child")!; + strictEqual(childProp.type.kind, "Model"); + const childPatch = childProp.type as Model; + strictEqual(childPatch.name, "ChildPatch"); + strictEqual(childPatch.properties.get("value")?.optional, true); + ok(!childPatch.properties.has("readOnly")); + }); + + it("does NOT transform array item types", async () => { + const { Result, Child, program } = await runner.compile(t.code` + model ${t.model("Child")} { + value: string; + } + model Input { + children: Child[]; + } + model ${t.model("Result")} is MergePatch; + `); + + const childrenProp = Result.properties.get("children")!; + strictEqual(childrenProp.type.kind, "Model"); + const arrayType = childrenProp.type as Model; + ok($(program).array.is(arrayType)); + strictEqual(arrayType.indexer?.value, Child); + }); + + it("transforms Record value types recursively", async () => { + const { Result, program } = await runner.compile(t.code` + model Child { + value: string; + @visibility(Lifecycle.Read) + readOnly: string; + } + model Input { + metadata: Record; + } + model ${t.model("Result")} is MergePatch; + `); + + const metaProp = Result.properties.get("metadata")!; + strictEqual(metaProp.type.kind, "Model"); + const recordType = metaProp.type as Model; + ok($(program).record.is(recordType)); + const valueType = recordType.indexer?.value as Model; + strictEqual(valueType.name, "ChildPatch"); + strictEqual(valueType.properties.get("value")?.optional, true); + ok(!valueType.properties.has("readOnly")); + }); + }); + + describe("rename functions", () => { + it("names transformed types using templateRenamer", async () => { + const { Result } = await runner.compile(t.code` + model Child { value: string; } + model Input { child: Child; } + model ${t.model("Result")} is MergePatch; + `); + + const childType = Result.properties.get("child")!.type as Model; + strictEqual(childType.name, "ChildUpdate"); + }); + + it("names transformed types using mapRenamer", async () => { + const { Result } = await runner.compile(t.code` + model Child { value: string; } + model Input { child: Child; } + model ${t.model("Result")} is MergePatch; + `); + + const childType = Result.properties.get("child")!.type as Model; + strictEqual(childType.name, "ChildPatch"); + }); + + it("mapRenamer falls back to original name if not in mapping", async () => { + const { Result } = await runner.compile(t.code` + model Child { value: string; } + model Input { child: Child; } + model ${t.model("Result")} is MergePatch; + `); + + const childType = Result.properties.get("child")!.type as Model; + strictEqual(childType.name, "Child"); + }); + + it("supports renamer builders as standalone values", async () => { + const [{ Observer }, diagnostics] = await runner.compileAndDiagnose(t.code` + const mappedName = mapRenamer(#{ Foo: "Bar" }); + const templatedName = templateRenamer("{name}Patch"); + + model ${t.model("Observer")} { + mapped: string = mappedName("Foo"); + unchanged: string = mappedName("Baz"); + templated: string = templatedName("Foo"); + } + `); + + expectDiagnosticEmpty(diagnostics); + deepStrictEqual( + [ + Observer.properties.get("mapped")?.defaultValue, + Observer.properties.get("unchanged")?.defaultValue, + Observer.properties.get("templated")?.defaultValue, + ].map((x) => + x?.entityKind === "Value" && x.valueKind === "StringValue" ? x.value : undefined, + ), + ["Bar", "Baz", "FooPatch"], + ); + }); + }); + + describe("same renamer reused across multiple calls", () => { + it("works correctly when same renamer is used for different models", async () => { + const { ResultA, ResultB } = await runner.compile(t.code` + model ChildA { value: string; } + model ChildB { other: int32; } + model InputA { child: ChildA; } + model InputB { child: ChildB; } + model ${t.model("ResultA")} is MergePatch; + model ${t.model("ResultB")} is MergePatch; + `); + + const childA = ResultA.properties.get("child")!.type as Model; + const childB = ResultB.properties.get("child")!.type as Model; + strictEqual(childA.name, "ChildAPatch"); + strictEqual(childB.name, "ChildBPatch"); + }); + }); +});