Skip to content

feat: resolve $dynamicRef generic bindings with per-context classes - #7978

Open
aqeelat wants to merge 3 commits into
microsoft:mainfrom
aqeelat:feat/dynamicref-generic-binding
Open

feat: resolve $dynamicRef generic bindings with per-context classes#7978
aqeelat wants to merge 3 commits into
microsoft:mainfrom
aqeelat:feat/dynamicref-generic-binding

Conversation

@aqeelat

@aqeelat aqeelat commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 2 of #7815. Builds on Phase 1 (#7817) which resolved recursive $dynamicRef via a thread-local dynamic-scope stack.

This PR adds support for the template binding pattern: when the same template schema is referenced from multiple contexts with different $defs + $dynamicAnchor bindings, each binding context now produces a distinct concrete class instead of silently sharing one class with the first binding's types. This PR emits concrete specializations (e.g., PaginatedTemplateUser, PaginatedTemplateGroup), not reusable generic type declarations.

When no binding context is active but multiple candidate schemas declare a matching $dynamicAnchor, the $dynamicRef resolves to a composed union type instead of degrading to UntypedNode.

Additionally, this PR adds CodeDOM foundation for generic type-parameter emission (Phase 4 Step 1): CodeTypeParameter element and TypeParameters collection on ProprietableBlockDeclaration. These are populated as metadata on suffixed template classes but not yet rendered by language writers — generated output is byte-identical with or without this addition. Per-language writer PRs will follow.

What changed

KiotaBuilder.cs:

  • DynamicScopeFrame(Schema, BindingSuffix) record replaces bare IOpenApiSchema on the dynamic-scope stack, carrying the computed suffix alongside each frame.
  • TryGetDynamicBindingSuffix — when a schema carries $defs entries with $dynamicAnchor, produces a suffix from the bound type names (or route segment + operation context for inline bindings). Definitions are sorted by key for deterministic output. Context suffix is computed once, not duplicated per anchor.
  • ContainsDynamicReference + GetActiveDynamicBindingSuffix — when a component has no local $defs but contains a reachable $dynamicRef, inherits the binding suffix from the active dynamic scope.
  • Dynamic-ref resolution refactored to a local function. Tries each scope frame as-is first (carries binding $defs), then the unwrapped target. Recursive check runs before GetExistingDeclaration so a bare class can't bypass the suffix.
  • Multi-candidate fallback — when scope-walk resolution fails, enumerates component schemas declaring a matching $dynamicAnchor and composes a union type (or single type for one candidate).
  • AddDynamicBindingTypeParameters — populates CodeTypeParameter metadata on template classes at both binding sites (CreateModelDeclarationAndType and CreateInheritedModelDeclarationCore). Named T + anchor (e.g., TItemType). Not rendered by writers yet.
  • CreateCollectionModelDeclaration conditionally pushes onto the dynamic scope when the array schema carries $defs with $dynamicAnchor.
  • ExtractAnchorName changed from private to internal for unit testing.

CodeDOM (CodeTypeParameter.cs, ProprietableBlock.cs):

  • New CodeTypeParameter : CodeTerminal — minimal marker element carrying a name (e.g., TItemType).
  • ProprietableBlockDeclaration gains TypeParameters collection (ConcurrentDictionary, sorted by name) + AddTypeParameter + IsGeneric. Both ClassDeclaration and InterfaceDeclaration inherit this.

Test coverage

Fixture Pattern Languages
generic-binding.yaml $ref bindings in $defs 5
inline-binding.yaml Inline schema bindings (no $ref) 5
recursive-generic-binding.yaml Combined recursive + generic 5
request-body-generic-binding.yaml Bindings in request body 5
multi-anchor-generic-binding.yaml Two $dynamicAnchor slots per template 5
array-root-dynamicref.yaml $dynamicRef in array items at response root 5
multi-candidate-no-binding.yaml Multi-candidate $dynamicRef with no binding → union 5
unresolved-dynamicref.yaml Unresolved $dynamicRefUntypedNode C#
multi-inline-binding.yaml Multiple inline anchors, context dedup C#
mixed-anchor-binding.yaml Mixed $ref + inline anchors, ordering C#
inherited-generic-binding.yaml Inherited template (allOf) with binding 5
multi-error-inline-binding.yaml Multiple error response bindings C#
request-response-inline-binding.yaml Same route, request vs response bindings C#
inherited-component-binding.yaml Nested component inherits binding identity C#
no-operation-id-inline-binding.yaml Disambiguation without operationId C#
namespaced-binding.yaml Qualified ref IDs (v1.User vs v2.User) C#

Five-language fixtures assert language-specific typed deserialization or composed-type semantics. C#-only fixtures assert builder-level model identity and binding-specific type resolution.

CodeDOM unit tests in CodeTypeParameterTests.cs (3 tests) + DynamicBindingPopulatesTypeParametersOnTemplateAsync in KiotaBuilderDynamicRefTests.cs. ExtractAnchorName unit tests in KiotaBuilderDynamicRefTests.cs.

Behavioral change

Referenced schemas whose site declares $defs entries containing $dynamicAnchor + $ref now get a suffixed class name. Multi-candidate $dynamicRef without active binding context now produces a composed union type instead of UntypedNode. These are the intended fixes but will change generated output for any existing spec that happens to match these patterns.

Remaining work (Phase 4)

  • Per-language writer changes to render TypeParameters as generic declarations (PaginatedTemplate<TItemType>) and drop the concrete suffix. Each language will be a separate PR.
  • Generic constraints (where T : IParsable) not yet supported — deferred.

Copilot AI review requested due to automatic review settings July 22, 2026 11:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from 5a95ec9 to 2dff270 Compare July 22, 2026 15:18
Copilot AI review requested due to automatic review settings July 22, 2026 15:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 22, 2026 19:59
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from 2dff270 to 7465ee3 Compare July 22, 2026 19:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aqeelat
aqeelat marked this pull request as ready for review July 22, 2026 20:01
@aqeelat
aqeelat requested a review from a team as a code owner July 22, 2026 20:02
@gavinbarron
gavinbarron requested a review from Copilot July 23, 2026 01:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (3)

tests/Kiota.Builder.IntegrationTests/GenerateSample.cs:548

  • These assertions only guard against an unsuffixed TreeTemplate in languages that emit the class keyword. For Go, an unsuffixed template would be emitted as type TreeTemplate struct, which isn’t currently checked.
        Assert.DoesNotContain("class TreeTemplate\n", allModelText, StringComparison.Ordinal);
        Assert.DoesNotContain("class TreeTemplate ", allModelText, StringComparison.Ordinal);
        Assert.DoesNotContain("class TreeTemplate:", allModelText, StringComparison.Ordinal);

tests/Kiota.Builder.IntegrationTests/GenerateSample.cs:575

  • These assertions only check for class SearchTemplate..., which won’t catch an unsuffixed Go declaration (type SearchTemplate struct). Adding the Go-specific pattern makes the per-language guarantee more robust.
        Assert.DoesNotContain("class SearchTemplate\n", allModelText, StringComparison.Ordinal);
        Assert.DoesNotContain("class SearchTemplate ", allModelText, StringComparison.Ordinal);
        Assert.DoesNotContain("class SearchTemplate:", allModelText, StringComparison.Ordinal);

tests/Kiota.Builder.IntegrationTests/GenerateSample.cs:603

  • Same issue as other template checks: class EnvelopeTemplate... won’t detect an unsuffixed Go model (type EnvelopeTemplate struct).
        Assert.DoesNotContain("class EnvelopeTemplate\n", allModelText, StringComparison.Ordinal);
        Assert.DoesNotContain("class EnvelopeTemplate ", allModelText, StringComparison.Ordinal);
        Assert.DoesNotContain("class EnvelopeTemplate:", allModelText, StringComparison.Ordinal);

Comment thread tests/Kiota.Builder.Tests/KiotaBuilderDynamicRefTests.cs
Comment thread tests/Kiota.Builder.IntegrationTests/GenerateSample.cs
Comment thread src/Kiota.Builder/KiotaBuilder.cs Outdated
Copilot AI review requested due to automatic review settings July 23, 2026 09:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (1)

tests/Kiota.Builder.Tests/KiotaBuilderDynamicRefTests.cs:16

  • KiotaBuilder is referenced without importing the Kiota.Builder namespace, so this new test file won’t compile (unless a global using exists, which it doesn’t in this test project). Add using Kiota.Builder; or fully-qualify the type.
    public void ExtractAnchorNameReturnsExpectedValue(string dynamicRef, string expected)
    {
        Assert.Equal(expected, KiotaBuilder.ExtractAnchorName(dynamicRef));
    }

Comment thread src/Kiota.Builder/KiotaBuilder.cs Outdated
Copilot AI review requested due to automatic review settings July 23, 2026 22:06
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from a15bbd1 to bbc5397 Compare July 23, 2026 22:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…classes

test(builder): expand dynamic-ref coverage and fix review items
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from bbc5397 to 354fc0d Compare July 23, 2026 22:12
@baywet
baywet requested a review from Copilot July 24, 2026 12:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comment thread src/Kiota.Builder/KiotaBuilder.cs Outdated
Copilot AI review requested due to automatic review settings July 24, 2026 17:50
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from 354fc0d to 6772aaf Compare July 24, 2026 17:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from cd5a1de to f9adc5c Compare July 29, 2026 13:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@aqeelat

aqeelat commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

The failing tests were flaky. I'm attempting to fix them in #8005

Copilot AI review requested due to automatic review settings July 30, 2026 07:08
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from f9adc5c to e84d27e Compare July 30, 2026 07:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 1, 2026 21:38
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from e84d27e to f6232d6 Compare August 1, 2026 21:38
@aqeelat

aqeelat commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Added CodeDOM foundation for Phase 4 generic type-parameter emission in this commit. CodeTypeParameter element and TypeParameters collection are populated as metadata on suffixed template classes but not yet rendered by language writers — generated output is byte-identical. Per-language writer PRs will follow to render <TItemType> and drop the concrete suffix.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Kiota.Builder/KiotaBuilder.cs:2124

  • When composing a union for multi-candidate $dynamicRef (no active binding), the union type name is derived only from currentNode.GetClassName(...) and ignores suffixForInlineSchema/anchor identity. If multiple such unions are produced under the same node (e.g., two properties with different multi-candidate $dynamicRef anchors), they can end up with the same CodeUnionType.Name. Downstream wrapper generation de-dupes primarily by name, so this can cause the second union to reuse the first wrapper and produce an incorrect property type.

Include suffixForInlineSchema in the union name (consistent with other inline composed-type naming) to keep wrapper names stable and collision-resistant.

                    var unionType = new CodeUnionType { Name = currentNode.GetClassName(config.StructuredMimeTypes, operation: operation, schema: schema).CleanupSymbolName() };

Copilot AI review requested due to automatic review settings August 2, 2026 08:08
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from f6232d6 to fe23941 Compare August 2, 2026 08:08
@aqeelat

aqeelat commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Re: the suppressed Copilot finding about union type name collisions when multiple multi-candidate `` anchors share a model — fixed. The union name now prefers typeNameForInlineSchema (e.g. `Envelope_data`, `Envelope_error`) with `suffixForInlineSchema` as fallback. Added a CodeDOM-level regression test (`UsesDistinctNamesForUnboundDynamicRefUnionsAsync`) asserting distinct `CodeUnionType.Name` values.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Kiota.Builder/KiotaBuilder.cs:2158

  • TryGetDynamicBindingSuffix calls ContainsDynamicReference(schema) even when there is no active binding suffix on the dynamic-scope stack. Since ContainsDynamicReference performs a deep traversal over schema graphs, this adds avoidable overhead for the common case (no binding context). You can short-circuit by first retrieving the active suffix and only scanning the schema when that suffix is non-null (and reuse it for both fallback returns).
    private static string? TryGetDynamicBindingSuffix(IOpenApiSchema schema, OpenApiUrlTreeNode currentNode, OpenApiOperation? operation = default, IOpenApiResponse? response = default, bool isRequestBody = false, string suffixForInlineSchema = "")
    {
        if (schema.Definitions is null || schema.Definitions.Count == 0)
            return ContainsDynamicReference(schema) ? GetActiveDynamicBindingSuffix() : null;
        var anchorSuffix = string.Empty;

Copilot AI review requested due to automatic review settings August 2, 2026 13:46
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from fe23941 to de5faf3 Compare August 2, 2026 13:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/Kiota.Builder/KiotaBuilder.cs:2132

  • In the multi-candidate $dynamicRef fallback (union case), AddModelDeclarationIfDoesntExist(..., c.Name.CleanupSymbolName(), ...) can generate redundant/incorrect declaration names for component keys that include namespace-like dots (e.g., v1.User). Consider extracting the terminal identifier segment (same as the refId naming path) before CleanupSymbolName so union member declarations match how referenced schemas are normally named.
                    {
                        var ns = GetShortestNamespace(codeNamespace, c.Schema);
                        var decl = AddModelDeclarationIfDoesntExist(currentNode, operation, c.Schema, c.Name.CleanupSymbolName(), ns);
                        unionType.AddType(new CodeType { TypeDefinition = decl });

src/Kiota.Builder/KiotaBuilder.cs:2122

  • In the multi-candidate $dynamicRef fallback (single-candidate case), using c.Name.CleanupSymbolName() can produce a declaration name that includes namespace qualifiers (e.g., v1.User -> v1User) instead of matching the normal refId-based naming (namespace ...Models.V1 + class User). This can create redundant/incorrect model names when component keys contain dots or path-like separators.

This issue also appears on line 2129 of the same file.

                        var c = candidates[0];
                        var ns = GetShortestNamespace(codeNamespace, c.Schema);
                        return new CodeType { TypeDefinition = AddModelDeclarationIfDoesntExist(currentNode, operation, c.Schema, c.Name.CleanupSymbolName(), ns) };

src/Kiota.Builder/KiotaBuilder.cs:2215

  • ContainsDynamicReference is used to decide whether to inherit the active binding suffix for schemas without local $defs. It currently walks refs/items/properties/defs/allOf/anyOf/oneOf, but it doesn’t consider AdditionalProperties schemas. If a dynamic ref appears under additionalProperties, the binding suffix won’t be inherited and resolution may regress to the unbound type.
        return !string.IsNullOrEmpty(schema.DynamicRef) ||
               schema is OpenApiSchemaReference { Target: { } target } && ContainsDynamicReference(target, visited) ||
               schema.Items is not null && ContainsDynamicReference(schema.Items, visited) ||
               schema.Properties?.Values.Any(x => ContainsDynamicReference(x, visited)) == true ||
               schema.Definitions?.Values.Any(x => ContainsDynamicReference(x, visited)) == true ||

Copilot AI review requested due to automatic review settings August 3, 2026 13:23
@aqeelat
aqeelat force-pushed the feat/dynamicref-generic-binding branch from de5faf3 to 8e969a1 Compare August 3, 2026 13:23
@aqeelat

aqeelat commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

All three suppressed Copilot findings addressed:

  1. Dotted key naming (Generate models #1, Update CodeProperty to support generating Get and Set code blocks #2): Union member names now use .Split('/').Last().Split('.').Last() to match the Phase 2 refId naming convention. v1.User produces class User in namespace V1, not V1User. Test fixture updated with namespaced keys to verify.

  2. AdditionalProperties scan (feature/java writer #3): ContainsDynamicReference now traverses AdditionalProperties schemas. Added InheritsBindingSuffixForDynamicRefsInAdditionalPropertiesAsync test verifying a `` under additionalProperties inherits the binding suffix.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated no new comments.

Suppressed comments (3)

src/Kiota.Builder/KiotaBuilder.cs:2134

  • Same namespace-collision issue as above in the union construction loop: candidate namespace and class name are derived from c.Schema + c.Name.Split('.'), which drops the dotted prefix into neither the namespace nor the type name. This can merge distinct component schemas into one declaration when multiple dotted keys share the same tail name.
                        var ns = GetShortestNamespace(codeNamespace, c.Schema);
                        var className = c.Name.Split('/').Last().Split('.').Last().CleanupSymbolName();
                        var decl = AddModelDeclarationIfDoesntExist(currentNode, operation, c.Schema, className, ns);
                        unionType.AddType(new CodeType { TypeDefinition = decl });

src/Kiota.Builder/KiotaBuilder.cs:2223

  • In CreateCollectionModelDeclaration, the dynamic-scope frame pushed for array-root schemas with $defs/$dynamicAnchor sets BindingSuffix to null. This prevents referenced templates inside the array items from inheriting the active binding suffix (via GetActiveDynamicBindingSuffix), so binding-aware specialization can be skipped in cases like “array of template with bound anchors”. Compute and store the binding suffix on the frame when pushing.
        var shouldPush = schema.Definitions?.Values.Any(static d => !string.IsNullOrEmpty(d.DynamicAnchor)) == true;
        if (shouldPush) _dynamicScope.Value!.Push(new(schema, null));

src/Kiota.Builder/KiotaBuilder.cs:2123

  • The multi-candidate $dynamicRef fallback derives the candidate model namespace from c.Schema via GetShortestNamespace, but component schemas aren’t references so GetReferenceId() is null. For dotted component keys (e.g. v1.StringModel), this collapses everything into the current namespace and can cause name collisions (e.g. v1.StringModel vs v2.StringModel both become StringModel). Use the component key (or schema ref id when available) to compute both namespace and class name consistently with other referenced-schema paths.

This issue also appears on line 2131 of the same file.

                        var ns = GetShortestNamespace(codeNamespace, c.Schema);
                        var className = c.Name.Split('/').Last().Split('.').Last().CleanupSymbolName();
                        return new CodeType { TypeDefinition = AddModelDeclarationIfDoesntExist(currentNode, operation, c.Schema, className, ns) };

Copilot AI review requested due to automatic review settings August 3, 2026 21:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/Kiota.Builder/KiotaBuilder.cs:2192

  • TryGetDynamicBindingSuffix only appends suffixForInlineSchema for response contexts when response is not null. For normal 2xx responses, CreateModelDeclarations is called without the response argument (e.g., GetExecutorMethodReturnType at KiotaBuilder.cs:1379-1380), so response is null and inline-binding suffixes can degrade to just the route-path portion when operation.OperationId is missing. That can cause distinct inline bindings (e.g., GET vs POST on the same path without operationIds) to incorrectly share the same suffixed template class, defeating the "per-context classes" goal.
                suffix += string.IsNullOrEmpty(operation?.OperationId) && !string.IsNullOrEmpty(suffixForInlineSchema) ?
                    suffixForInlineSchema.CleanupSymbolName().ToFirstCharacterUpperCase() :
                    RequestBodySuffix;
            else if (response is not null)
                suffix += string.IsNullOrEmpty(suffixForInlineSchema) ? ResponseSuffix : suffixForInlineSchema.CleanupSymbolName().ToFirstCharacterUpperCase();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants