Skip to content

fix(http-client-csharp): restore historical bases over mapped ancestors - #12310

Open
Wei Hu (live1206) wants to merge 10 commits into
microsoft:mainfrom
live1206:fix/12304-mapped-base-restoration
Open

Wei Hu (live1206) wants to merge 10 commits into
microsoft:mainfrom
live1206:fix/12304-mapped-base-restoration

Conversation

@live1206

@live1206 Wei Hu (live1206) commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #12304 (the original BotData existing-mapped-base scenario).

  • Restore a compatible historical framework base over an existing mapped CLR ancestor, reconciling child-owned and ancestor-owned inherited properties without changing input ownership or shared providers.
  • Keep canonicalization and constructor traversal consistent with the model-local projection. Preserve source-owner vetoes, custom property/field authority, and type/accessor/collision/ambiguity safeguards.
  • Retain the historical BotData location constructor through compatible inherited-property assignment, preserving parameter types/defaults and rejecting unsafe conversions or inaccessible setters.
  • Preserve projected child properties during deserialization. Getter-only collection population requires an explicit downstream mutability contract; unknown interfaces and read-only wrappers retain the current base.

Scope after the split

This PR does not introduce a framework constructor-default approval API. The separate Network proposal #12311 is withdrawn. The latest plan is an SDK-owned legacy constructor with existing CodeGenSuppress, plus a narrowly scoped relaxation of the custom-constructor restoration guard here if it remains small. That relaxation is not implemented yet; the flat-base and real-SDK validation prerequisites still apply.

BotData’s inherited-property constructor repair is included: restoring its base alone does not retain its historical public location overload.

Change-specific validation

  • The original BotData regression remains intact: historical base, inherited Location/Tags ownership, and the public non-nullable location constructor.
  • Controls cover nonempty-base canonicalization, duplicate ancestor initialization, input/shared-provider isolation, source-owner vetoes in both cache orders, custom property/field aliases, and inherited parameter/default/setter safety.
  • Added compiled scalar/dictionary/list JSON round trips, read-only-wrapper and unapproved-interface fallback controls, virtual-event override/hiding controls, and an emitted constructor baseline including inherited backing-field exclusion.
  • Latest local validation: 148 core + 75 ClientModel focused checks, 3,058/3,058 core Generator tests, and 2,648/2,648 ClientModel tests passed, with no skipped tests. CI for 4cb8aac5e passed, including Windows/Linux C# tests and regeneration checks (build 7013300).

No real SDK regeneration, GA ApiCompat run, or inherited ARM-envelope JSON-roundtrip validation is claimed. The separate flat-base feature remains #12303.

- by copilot

@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:client:csharp Issue for the C# client emitter: @typespec/http-client-csharp label Oct 20, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@typespec/http-client-csharp@12310

commit: ac1cc78

@github-actions

Copy link
Copy Markdown
Contributor

No changes needing a change description found.

@live1206 Wei Hu (live1206) changed the title fix(http-client-csharp): preserve mapped bases and historical constructors fix(http-client-csharp): restore historical bases over mapped ancestors Oct 20, 2026

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.

🟡 Changes recommended

The synthetic full constructor can omit inherited Location, causing deserialization to leave the property at its default value.

3 open issues
What changed in this PR

This PR addresses #12304 by extending C# client-generator compatibility handling to restore historical framework bases over existing mapped ancestors, including BotData’s inherited properties and public location constructor. Flat-model restoration and framework-default constructor approval remain outside this PR’s scope.

Changes:

  • Adds model-local inherited-property projections while preserving input ownership and shared providers.
  • Updates base resolution, canonical properties, and constructor traversal to use those projections.
  • Adds regression coverage for compatibility safeguards, customization authority, and historical constructor metadata.

Paths are relative to packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/.

File Description
test/​Providers/​TestData/​MappedFrameworkBaseRestorationTests/​RestoresMoreDerivedFrameworkBaseWithChildOwnedOptionalProperties/​BotData.cs Supplies the historical BotData contract fixture.
test/​Providers/​MappedModelBaseUpgradeTests.cs Tests ancestor-member preservation and hiding checks.
test/​Providers/​MappedModelBaseUpgradeRegressionTests.cs Covers projection, customization, and mapping ambiguity.
test/​Providers/​MappedFrameworkBaseRestorationTests.cs Covers BotData restoration, ownership isolation, and vetoes.
test/​Providers/​InheritedFrameworkConstructorTests.cs Tests historical parameter, default, and setter safety.
src/​TypeFactory.cs Adds cache-aware property lookup without caching new conversions.
src/​Providers/​SystemObjectModelProvider.cs Distinguishes upgrade projections and adjusts constructor traversal.
src/​Providers/​ModelProvider.cs Restores constructors through compatible inherited-property assignments.
src/​Providers/​MappedModelBaseUpgrade.cs Implements local projection and ancestor compatibility checks.
src/​Providers/​MappedModelBaseCompatibility.cs Applies projection-aware collision and displaced-base checks.
src/​Providers/​CanonicalTypeProvider.cs Includes projected input properties in canonicalization.
src/​Providers/​BackCompatModelBaseResolver.cs Creates upgrade projections during historical-base resolution.

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 29, 2026 03:14

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.

🟡 Changes recommended

Unconsumed ancestor-owned properties can be omitted from deserialization, silently losing JSON values, while getter-only collections with unknown mutability can throw during restoration.

2 open issues
2 resolved since last review

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 29, 2026 03: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.

🔵 Needs a closer look

Restoration can silently discard projected ancestor property values during deserialization and turn null wire collections into empty collections.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Reject nullable wire collections during restoration

packages/​http-client-csharp/​generator/​Microsoft.TypeSpec.Generator/​src/​Providers/​SystemObjectModelProvider.cs:116

Checking CLR nullability does not reject nullable wire collections: ApplyCurrentInputMetadata replaces Type with the historical non-nullable type while retaining WireInfo.IsNullable. With an approved getter-only Tags and nullable input, {"tags":null} therefore passes restoration, but ModelProvider.cs:1996 skips population and leaves an empty dictionary; reject nullable wire collections here and add a null-payload fallback control.

2 resolved since last review

Review effort: Balanced

@live1206

Copy link
Copy Markdown
Contributor Author

regen: Azure/azure-sdk-for-net#64320

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.

Two deserialization cases need follow-up.

Copilot AI balanced review requested due to automatic review settings October 30, 2026 00: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.

🔵 Needs a closer look

Human assessment is needed to weigh an unpublished, nit-level finding about C# test inputs conflicting with repository guidance.

No open issues 🎉

Review effort: Balanced

Copilot AI balanced review requested due to automatic review settings October 30, 2026 01:13

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.

🔵 Needs a closer look

Human assessment is needed because projected-property restoration can discard source-only wire-name overrides and silently change the JSON contract.

No open issues 🎉

Review effort: Balanced

@live1206

Copy link
Copy Markdown
Contributor Author

latest regen: Azure/azure-sdk-for-net#64353


public CanonicalTypeProvider(TypeProvider generatedTypeProvider, InputType? inputType)
public CanonicalTypeProvider(TypeProvider generatedTypeProvider, InputType? inputType,
IReadOnlyList<InputModelProperty>? inputProperties = null)

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.

imo this is still confusing. Can we guarantee that inputProperties will originate from the inputType ? Or are we allowing inputProperties to possibly originate from another input type ? Previously, we knew the properties always came from the inputmodel..

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Clarified in ac1cc78. The normal constructor still takes properties from inputType. Mapped views now use a separate CreateForPropertyProjection factory whose contract explicitly allows properties from a descendant or multiple models in its hierarchy, retaining their original input owners. The model argument supplies model-level metadata, not property provenance. Canonicalization and input-ownership regressions pass.

- by copilot

Comment on lines +117 to +126
if (model.InputModel.BaseModel is not { } inputBase ||
!IsDerivedFromCurrentMappedBase(model, candidate.SystemType) ||
CodeModelGenerator.Instance.TypeFactory.CreateModel(inputBase) is not SystemObjectModelProvider current ||
!PreservesFrameworkMembers(current.SystemType.FrameworkType, candidate.SystemType.FrameworkType) ||
candidate.GetAdditionalSerializationProperties().Any(property =>
property.IsInitOnly || !ModelProvider.HasAccessibleInheritedSetter(property) &&
!candidate.CanPopulateInheritedCollection(property)))
{
return false;
}

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.

this is really difficult to follow...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Simplified in ac1cc78. Historical matching is now a shared named helper, and source-owner/cache/customization validation is isolated in PreservesSourceOwnerProperty. The hierarchy loop now handles only traversal, matching, and duplicate tracking. Existing mapped-base regressions pass.

- by copilot

Comment on lines +1994 to +1995
? inheritedProperty.Invoke("Add", item.Property("Key"), item.Property("Value"))
: inheritedProperty.Invoke("Add", item)).Terminate());

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.

nit: can we please try and use Snippets here ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated in ac1cc78 to use DictionarySnippets.Add and ListSnippets.Add on the typed inherited-property expression. There is no existing Clear snippet, so that invocation uses nameof(ICollection<object>.Clear) rather than a string literal. The compiled dictionary/list payload round-trip regressions pass.

- by copilot

/// <param name="mappedBase">The mapped framework base containing the property.</param>
/// <param name="property">The inherited property to populate during deserialization.</param>
/// <returns>True only when the framework contract guarantees safe collection population.</returns>
protected virtual bool IsLastContractModelBaseCollectionMutableCore(CSharpType mappedBase, PropertyProvider property)

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.

can we at least make this internal if there's no usage outside of mtg ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed in ac1cc78. Removed the protected core hook and made IsLastContractModelBaseCollectionMutable internal virtual, updating the test setups accordingly. No production override was found in this checkout. The collection round-trip and unsafe-collection fallback regressions pass.

- by copilot

Comment on lines +512 to +517
internal PropertyProvider? CreateUncachedProperty(InputProperty property, TypeProvider enclosingType)
=> CreatePropertyCore(property, enclosingType);

// Honor cached results without caching new conversions, which may be reentrant.
internal PropertyProvider? GetProperty(InputProperty property, TypeProvider enclosingType)
=> PropertyCache.TryGetValue(property, out var cached) ? cached : CreateUncachedProperty(property, enclosingType);

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.

as I'm reviewing this, I'm wondering why do we need CreateUncachedProperty and this method? we could just make PropertyCache internal since we are the only users. Mutating it should still only happen in the typefactory, but I don't see an issue with reading the dictionary directly in mtg

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated in ac1cc78: PropertyCache is exposed internally as an IReadOnlyDictionary, with writes remaining inside TypeFactory, and the redundant GetProperty wrapper is removed. CreateUncachedProperty remains necessary to dispatch through protected factory overrides and visitors for a specific owner without reading/populating the input-keyed cache during projection or reentrant validation. Both cache-order regressions pass.

- by copilot

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.

The earlier ancestor-deserialization and nullable-collection findings are addressed. One remaining correctness concern and one nonblocking simplification below.

!(matchedProperties ??= []).Add(matches[0]) ||
factory.GetProperty(property, owner) is not { } current ||
owner.IsPropertySuppressed(current) ||
current.Name != matches[0].Name || HasCustomAlias(owner, property, current.Name, matches[0].Name))

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.

[P2] Preserve source-owner visitor changes

PreVisitProperty can modify the property (LibraryVisitor.cs:121–128), but this only validates its name: a source-owner-specific change to Location’s type or setter still passes, then disappears when ApplyCurrentInputMetadata recreates it under the framework owner. Reject source-owner customizations the projection cannot preserve, with regression coverage for both cache orders.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in ac1cc78. Restoration now rejects source-owner CLR-shape changes that framework projection would discard, comparing the visited property against an unvisited source-owner property. Added ten cases covering type, setter presence/accessibility, member accessibility, and init-only changes in both cache orders; all ten failed before the fix and pass afterward. Both full unit suites pass: 3,069 core + 2,651 ClientModel tests, with no skips. CI for the new commit is still outstanding, so this thread remains unresolved.

- by copilot

foreach (var property in input.Properties)
{
var matches = historicalProperties.Where(previous =>
factory.IsLastContractModelBasePropertyCompatible(candidateType, property, previous)).ToArray();

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.

Nonblocking: consolidate duplicated matching rules

The property matching, ambiguity detection, and historical wire-metadata checks duplicate BackCompatModelBaseResolver.cs:111–125, leaving two implementations of the same compatibility rules to maintain. Extract those shared checks into a small helper, keeping hierarchy traversal and source-owner validation specific to each path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Consolidated in ac1cc78. Both paths now use TryMatchHistoricalProperty for compatibility matching, ambiguity/identity detection, and known historical wire-metadata conflicts. Hierarchy traversal, duplicate ownership, and source-owner validation remain specific to each path. Both restoration paths' existing regressions pass.

- by copilot

Copilot AI balanced review requested due to automatic review settings October 31, 2026 12:14

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.

🟡 Changes recommended

The internal opt-in cannot be overridden by downstream TypeFactory subclasses in other assemblies, preventing approval of getter-only collection mappings and causing their base upgrades to be rejected.

1 open issue

Review effort: Balanced

Comment on lines +145 to +146
internal virtual bool IsLastContractModelBaseCollectionMutable(CSharpType mappedBase, PropertyProvider property)
=> false;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This recommendation conflicts with the earlier request to make this hook internal when there is no usage outside MTG: #12310 (comment) . The latest change followed that request; no production override was found in this checkout. A protected Core hook would allow non-friend downstream assemblies to approve known getter-only collection contracts, but whether we need that extension point is still an API-scope decision. Leaving the visibility decision open for now; no code change is being made in response to this comment yet.

- by copilot

This branch has not been deployed

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

Labels

emitter:client:csharp Issue for the C# client emitter: @typespec/http-client-csharp

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[http-client-csharp] Support restoring last-contract bases over an existing framework-mapped base

4 participants