diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs index 44e817622b4..d8863a68bb0 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/ModelProvider.cs @@ -183,9 +183,23 @@ protected override string BuildNamespace() => string.IsNullOrEmpty(_inputModel.N CodeModelGenerator.Instance.TypeFactory.PrimaryNamespace : CodeModelGenerator.Instance.TypeFactory.GetCleanNameSpace(_inputModel.Namespace); + public override CSharpType? BaseType => _modelBaseType ??= BaseModelProvider?.Type ?? base.BaseType; + private CSharpType? _modelBaseType; + protected override CSharpType? BuildBaseType() { - return BaseModelProvider?.Type; + // Note: this is intentionally spec-only and does NOT route through BaseModelProvider. + // BaseModelProvider depends on BuildBaseType() (for the spec-fallback path) and any + // back-reference would create infinite recursion. Subclasses may override this to + // point at hand-written/external types; BaseModelProvider will then naturally + // resolve to null (not in CSharpTypeMap), keeping BaseType and BaseModelProvider + // consistent with each other. + if (_inputModel.BaseModel == null) + { + return null; + } + + return CodeModelGenerator.Instance.TypeFactory.CreateModel(_inputModel.BaseModel)?.Type; } protected override TypeProvider[] BuildSerializationProviders() @@ -291,19 +305,20 @@ private static bool IsDiscriminator(InputProperty property) private ModelProvider? BuildBaseModelProvider() { - // consider models that have been customized to inherit from a different generated model + // CustomCodeView wins: when a user hand-writes `partial class Foo : Bar`, that + // declaration is the source of truth for the C# inheritance hierarchy. if (CustomCodeView?.BaseType != null) { - var baseType = CustomCodeView.BaseType; + var customBase = CustomCodeView.BaseType; // If the custom base type doesn't have a resolved namespace, then try to resolve it from the input model map. // This will happen if a model is customized to inherit from another generated model, but that generated model // was not also defined in custom code so Roslyn does not recognize it. - if (string.IsNullOrEmpty(baseType.Namespace)) + if (string.IsNullOrEmpty(customBase.Namespace)) { // Cheap check: the base model may already be created and registered under the right name. if (CodeModelGenerator.Instance.TypeFactory.TypeProvidersByName.TryGetValue( - baseType.Name, out var resolvedProvider) && + customBase.Name, out var resolvedProvider) && resolvedProvider is ModelProvider resolvedModel) { return resolvedModel; @@ -318,7 +333,7 @@ private static bool IsDiscriminator(InputProperty property) } if (CodeModelGenerator.Instance.TypeFactory.TypeProvidersByName.TryGetValue( - baseType.Name, out resolvedProvider) && + customBase.Name, out resolvedProvider) && resolvedProvider is ModelProvider resolvedAfterCreate) { return resolvedAfterCreate; @@ -326,8 +341,8 @@ private static bool IsDiscriminator(InputProperty property) } // Try to find the base type in the CSharpTypeMap - if (baseType != null && CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.TryGetValue( - baseType, + if (CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.TryGetValue( + customBase, out var customBaseType) && customBaseType is ModelProvider customBaseModel) { @@ -336,18 +351,32 @@ private static bool IsDiscriminator(InputProperty property) // If the custom base type has a namespace (external type), we don't return it here // as it's handled by BuildBaseTypeProvider() which returns a TypeProvider - if (!string.IsNullOrEmpty(baseType?.Namespace)) + if (!string.IsNullOrEmpty(customBase.Namespace)) { return null; } } - if (_inputModel.BaseModel == null) + // Spec / subclass-overridden base. We deliberately call BuildBaseType() (instead of + // reading _inputModel.BaseModel directly) so that subclasses overriding BuildBaseType + // to point at a hand-written/external type stay consistent: BaseModelProvider will + // resolve to null because the override result won't be in CSharpTypeMap. + var baseType = BuildBaseType(); + if (baseType == null) { return null; } - return CodeModelGenerator.Instance.TypeFactory.CreateModel(_inputModel.BaseModel); + if (CodeModelGenerator.Instance.TypeFactory.CSharpTypeMap.TryGetValue(baseType, out var baseProvider) && + baseProvider is ModelProvider baseModel) + { + return baseModel; + } + + // BuildBaseType was overridden to point at a non-generated type (e.g. external base + // class for hand-written hierarchies). BaseModelProvider must be null so visitors + // and serializers don't walk into a parent that isn't actually in the C# hierarchy. + return null; } private List BuildAdditionalPropertyFields() diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs index 2031d0e11f2..5bf51619b21 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/TypeProvider.cs @@ -210,7 +210,7 @@ private TypeSignatureModifiers BuildDeclarationModifiersInternal() private protected virtual bool FilterCustomizedMembers => true; - public CSharpType? BaseType => _baseType ??= BuildBaseType() ?? CustomCodeView?.BaseType; + public virtual CSharpType? BaseType => _baseType ??= BuildBaseType() ?? CustomCodeView?.BaseType; private CSharpType? _baseType; public WhereExpression? WhereClause => _whereClause ??= BuildWhereClause(); diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs index 1302db936ac..1018c9b8d0f 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs @@ -434,6 +434,11 @@ void WritePropertyAccessorModifiers(MethodSignatureModifiers modifiers) public void UseNamespace(string @namespace) { + if (string.IsNullOrEmpty(@namespace)) + { + return; + } + if (_currentNamespace == @namespace) { return; diff --git a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs index 7d6bcd7b992..0b37b6876e1 100644 --- a/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs +++ b/packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Providers/ModelProviders/ModelProviderTests.cs @@ -422,6 +422,54 @@ public void BuildBaseType() Assert.AreEqual(baseModel!.Type, derivedModel!.Type.BaseType); } + [Test] + public void OverridingBuildBaseTypeKeepsBaseModelProviderConsistent() + { + // A derived ModelProvider that overrides BuildBaseType to point at an external + // (hand-written) base type — emulating what downstream emitters such as the Azure + // mgmt/provisioning generators do when they replace the spec inheritance with a + // hand-written base class. Before the fix, BaseType pointed at the override but + // BaseModelProvider was still computed from InputModelType.BaseModel, so visitors + // that walked BaseModelProvider saw a parent that wasn't actually in the C# + // inheritance chain. + var inputBase = InputFactory.Model( + "specBaseModel", + usage: InputModelTypeUsage.Input, + properties: [InputFactory.Property("baseProp", InputPrimitiveType.String)]); + var inputChild = InputFactory.Model( + "childModel", + usage: InputModelTypeUsage.Input, + properties: [InputFactory.Property("childProp", InputPrimitiveType.String)], + baseModel: inputBase); + + MockHelpers.LoadMockGenerator(inputModelTypes: [inputBase, inputChild]); + + var externalBase = new CSharpType(typeof(object)); + var childProvider = new ModelProviderWithExternalBase(inputChild, externalBase); + + // BaseType honors the override. + Assert.AreEqual(externalBase, childProvider.BaseType); + + // BaseModelProvider is consistent with BaseType: since the external base is not a + // generated ModelProvider, BaseModelProvider must be null. Before the fix it was + // still resolving to the spec base ("SpecBaseModel"), causing properties to be + // double-counted by visitors that walked BaseModelProvider. + Assert.IsNull(childProvider.BaseModelProvider); + } + + private class ModelProviderWithExternalBase : ModelProvider + { + private readonly CSharpType _externalBase; + + public ModelProviderWithExternalBase(InputModelType inputModel, CSharpType externalBase) + : base(inputModel) + { + _externalBase = externalBase; + } + + protected override CSharpType? BuildBaseType() => _externalBase; + } + [Test] public void BuildModelAsStruct() {