Skip to content
Draft
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
21 changes: 21 additions & 0 deletions .chronus/changes/merge-patch-template-2026-6-3-21-10-0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-azure-core"
---

Introduce `MergePatch` function for use in templates.

```typespec
model MyPatchBody is MergePatch<MyResource, templateRenamer("{name}Patch")>;
```

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.
23 changes: 23 additions & 0 deletions packages/typespec-azure-core/generated-defs/Azure.Core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
DecoratorValidatorCallbacks,
Enum,
EnumMember,
FunctionContext,
Model,
ModelProperty,
Operation,
Expand Down Expand Up @@ -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<string, string>,
) => unknown;

export type TemplateRenamerFunctionImplementation = (
context: FunctionContext,
template: string,
) => unknown;

export type AzureCoreFunctions = {
applySimplifiedMergePatch: ApplySimplifiedMergePatchFunctionImplementation;
mapRenamer: MapRenamerFunctionImplementation;
templateRenamer: TemplateRenamerFunctionImplementation;
};
Original file line number Diff line number Diff line change
@@ -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"];
1 change: 1 addition & 0 deletions packages/typespec-azure-core/lib/azure-core.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
24 changes: 24 additions & 0 deletions packages/typespec-azure-core/lib/merge-patch.tsp
Original file line number Diff line number Diff line change
@@ -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<string>): valueof fn(
name: valueof string
) => valueof string;

#suppress "experimental-feature"
extern fn templateRenamer(template: valueof string): valueof fn(
name: valueof string
) => valueof string;
2 changes: 1 addition & 1 deletion packages/typespec-azure-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
248 changes: 248 additions & 0 deletions packages/typespec-azure-core/src/merge-patch.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mutateSubgraph>;

const MUTATOR_CACHE = Symbol.for("Azure.Core.SimplifiedMergePatchMutatorCache");
const MUTATOR_RESULT_CACHE = Symbol.for("Azure.Core.SimplifiedMergePatchMutatorResultCache");

interface SimplifiedMergePatchMutatorCache {
[MUTATOR_CACHE]?: WeakMap<Renamer, WeakMap<Model, Mutator>>;
}

interface SimplifiedMergePatchMutatorResultCache {
[MUTATOR_RESULT_CACHE]?: WeakMap<object, MutateSubgraphResult>;
}

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);
}
12 changes: 11 additions & 1 deletion packages/typespec-azure-core/src/tsp-index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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": {
Expand Down
Loading
Loading