Skip to content

Commit 27299b9

Browse files
live1206jorgerangel-msftCopilot
authored
[http-client-csharp] Preserve shipped date-time property names (#11773)
## Summary - preserve exact public GA property names when their date-time stems match the current MTG-normalized property - keep canonical MTG naming for new properties and when a GA name is incompatible or already claimed - keep customization suppression and canonical wire information associated with the correct spec property across preserved names, aliases, exact names, and refiltering - cover historical, canonical, projected-provider, collision, customization, and sibling-ownership scenarios - normalize `ExpireTime` and `ExpireOn` to `ExpiresOn` for new APIs, making `ExpireOn` and `ExpirationTime` share the semantic `Expires` stem Fixes #11772 ## Validation - `dotnet test packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/test/Microsoft.TypeSpec.Generator.Tests.csproj --no-restore --filter 'FullyQualifiedName~PropertyProviderTests|FullyQualifiedName~ModelCustomizationTests|FullyQualifiedName~CSharpNameExtensionsTests' -- NUnit.NumberOfTestWorkers=1` --------- Co-authored-by: Jorge Rangel <jorgerangel@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0a5e8332-8ebe-44b1-97c5-44cc31caf25e
1 parent f6897a6 commit 27299b9

46 files changed

Lines changed: 1559 additions & 67 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/CanonicalTypeProvider.cs

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ internal class CanonicalTypeProvider : TypeProvider
1818
{
1919
private readonly TypeProvider _generatedTypeProvider;
2020
private readonly Dictionary<string, InputModelProperty> _specPropertiesMap;
21+
private readonly HashSet<string> _exactSpecPropertyNames;
2122
private readonly Dictionary<string, string?> _serializedNameMap;
2223
private readonly Dictionary<InputModelProperty, PropertyProvider> _propertyProviderMap = new();
2324
private readonly HashSet<string> _renamedProperties;
@@ -30,14 +31,17 @@ public CanonicalTypeProvider(TypeProvider generatedTypeProvider, InputType? inpu
3031
var inputModel = inputType as InputModelType;
3132
_specProperties = inputModel?.Properties ?? [];
3233
_specPropertiesMap = [];
34+
_exactSpecPropertyNames = [];
3335
foreach (var property in _specProperties)
3436
{
3537
var name = property.IsExactName ? property.Name : property.Name.ToIdentifierName();
3638
_specPropertiesMap.TryAdd(name, property);
37-
if (!property.IsExactName)
38-
{
39-
_specPropertiesMap.TryAdd(name.NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()), property);
40-
}
39+
_exactSpecPropertyNames.Add(name);
40+
}
41+
foreach (var property in _specProperties.Where(p => !p.IsExactName))
42+
{
43+
var name = property.Name.ToIdentifierName();
44+
_specPropertiesMap.TryAdd(name.NormalizeCSharpAcronyms(property.Type.IsDateTimeInputType()), property);
4145
}
4246
_serializedNameMap = BuildSerializationNameMap();
4347
_renamedProperties = (_generatedTypeProvider.CustomCodeView?.Properties ?? [])
@@ -92,9 +96,23 @@ protected internal override MethodProvider[] BuildMethods()
9296

9397
protected internal override PropertyProvider[] BuildProperties()
9498
{
99+
// Building Properties populates GeneratedPropertiesBySpecName as part of customization filtering.
100+
// Keep these reads together and in this order so the map is never observed mid-build.
95101
var generatedProperties = _generatedTypeProvider.Properties;
102+
var generatedPropertiesBySpecName = _generatedTypeProvider.GeneratedPropertiesBySpecName;
96103
var customProperties = _generatedTypeProvider.CustomCodeView?.Properties ?? [];
97104

105+
// Exact emitted names take precedence over canonical aliases. A canonical alias from an earlier
106+
// property must not shadow a later property that actually emits that name.
107+
foreach (var generatedProperty in generatedPropertiesBySpecName.Values)
108+
{
109+
if (generatedProperty.InputProperty is InputModelProperty preservedSpecProperty &&
110+
_exactSpecPropertyNames.Add(generatedProperty.Name))
111+
{
112+
_specPropertiesMap[generatedProperty.Name] = preservedSpecProperty;
113+
}
114+
}
115+
98116
// Update the serializedName of generated properties if necessary
99117
foreach (var generatedProperty in generatedProperties)
100118
{
@@ -163,7 +181,9 @@ protected internal override PropertyProvider[] BuildProperties()
163181
// Filter out generated properties that have been customized to avoid duplicates.
164182
// This is needed because EnsureBuilt caches members without applying customization
165183
// filtering, so _generatedTypeProvider.Properties may contain unfiltered results.
166-
var filteredGeneratedProperties = FilterCustomizedProperties(generatedProperties);
184+
var filteredGeneratedProperties = FilterCustomizedProperties(
185+
generatedProperties,
186+
generatedPropertiesBySpecName);
167187

168188
if (_specProperties.Count > 0)
169189
{
@@ -186,9 +206,9 @@ protected internal override PropertyProvider[] BuildProperties()
186206

187207
foreach (var prop in customProperties)
188208
{
189-
// Check if custom property is in spec
190-
if (_specPropertiesMap.TryGetValue(prop.Name, out var specProp) ||
191-
(prop.OriginalName != null && TryGetSpecProperty(prop.OriginalName, out specProp)))
209+
// Check if custom property is in spec.
210+
if ((prop.OriginalName != null && TryGetSpecProperty(prop.OriginalName, out var specProp)) ||
211+
_specPropertiesMap.TryGetValue(prop.Name, out specProp))
192212
{
193213
inputProperties.Add(specProp);
194214
}

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator/src/Providers/PropertyProvider.cs

Lines changed: 124 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,43 @@ private PropertyProvider(InputProperty inputProperty, CSharpType propertyType, T
103103
var hasOutputUsage = inputProperty.EnclosingType?.Usage.HasFlag(InputModelTypeUsage.Output) ?? false;
104104
Modifiers = IsDiscriminator || (!hasOutputUsage && _isRequiredNonNullableConstant) ? MethodSignatureModifiers.Internal : MethodSignatureModifiers.Public;
105105
var identifierName = inputProperty.IsExactName ? inputProperty.Name : inputProperty.Name.ToIdentifierName();
106-
var lastContractProperties = enclosingType.LastContractView?.Properties;
107-
var legacyName = identifierName == enclosingType.Name
108-
? $"{identifierName}Property"
109-
: identifierName;
110-
if (!inputProperty.IsExactName &&
111-
(lastContractProperties is null ||
112-
!lastContractProperties.Any(p => p.Name == legacyName)))
106+
if (!inputProperty.IsExactName)
113107
{
114-
identifierName = identifierName
115-
.NormalizeCSharpAcronyms(inputProperty.Type.IsDateTimeInputType());
108+
var isDateTime = inputProperty.Type.IsDateTimeInputType();
109+
var canonicalName = identifierName.NormalizeCSharpAcronyms(isDateTime);
110+
var enclosingTypeName = enclosingType.Name;
111+
var lastContractProperties = enclosingType.LastContractView?.Properties
112+
.Where(p => MethodSignatureHelper.IsPublicApi(p.Modifiers))
113+
.ToList();
114+
115+
// An exact input-identifier match is authoritative: it is the name this property would have had
116+
// before normalization, so it does not need the disambiguation required by normalized candidates.
117+
// The shipped member may carry the enclosing-type collision suffix, so candidates are compared
118+
// against the collision-adjusted form of each name we are looking for.
119+
var previousProperty =
120+
lastContractProperties?.FirstOrDefault(p => p.Name == AvoidPropertyNameCollision(identifierName, enclosingTypeName))
121+
?? lastContractProperties?.FirstOrDefault(p =>
122+
p.Name == AvoidPropertyNameCollision(canonicalName, enclosingTypeName) &&
123+
!IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType));
124+
125+
if (previousProperty is null &&
126+
isDateTime &&
127+
!identifierName.EndsWith("On", StringComparison.Ordinal))
128+
{
129+
// Both the current and previous conventions render date-time names as <stem>On. Requiring
130+
// that suffix on the contract name prevents a removed property such as StartDate from being
131+
// mistaken for the historical name of StartTime even though both normalize to StartsOn.
132+
var specStem = identifierName.NormalizeCSharpAcronyms().GetDateTimeStem();
133+
previousProperty = specStem is null
134+
? null
135+
: lastContractProperties?.FirstOrDefault(p =>
136+
HasDateTimeStem(p.Name, specStem, enclosingTypeName) &&
137+
p.Type.WithNullable(false).Equals(Type.WithNullable(false)) &&
138+
!IsClaimedBySiblingProperty(p.Name, inputProperty, enclosingType));
139+
}
140+
identifierName = previousProperty?.Name ?? canonicalName;
116141
}
117-
Name = identifierName == enclosingType.Name
118-
? $"{identifierName}Property"
119-
: identifierName;
142+
Name = AvoidPropertyNameCollision(identifierName, enclosingType.Name);
120143
Body = new AutoPropertyBody(propHasSetter, setterModifier, GetPropertyInitializationValue(propertyType, inputProperty));
121144

122145
WireInfo = new PropertyWireInformation(inputProperty);
@@ -180,6 +203,95 @@ private void BuildDocs()
180203
}
181204
}
182205

206+
private static string AvoidPropertyNameCollision(string propertyName, string enclosingTypeName) =>
207+
propertyName == enclosingTypeName ? $"{propertyName}Property" : propertyName;
208+
209+
private static bool HasDateTimeStem(string contractName, string specStem, string enclosingTypeName)
210+
{
211+
if (contractName == AvoidPropertyNameCollision(enclosingTypeName, enclosingTypeName))
212+
{
213+
contractName = enclosingTypeName;
214+
}
215+
216+
return contractName.EndsWith("On", StringComparison.Ordinal) &&
217+
contractName.GetDateTimeStem() == specStem;
218+
}
219+
220+
private static bool IsClaimedBySiblingProperty(
221+
string contractName,
222+
InputProperty inputProperty,
223+
TypeProvider enclosingType)
224+
{
225+
var enclosingTypeName = enclosingType.Name;
226+
227+
foreach (var sibling in inputProperty.EnclosingType?.Properties ?? [])
228+
{
229+
if (ReferenceEquals(sibling, inputProperty))
230+
{
231+
continue;
232+
}
233+
234+
if (sibling.IsExactName)
235+
{
236+
if (AvoidPropertyNameCollision(sibling.Name, enclosingTypeName) == contractName)
237+
{
238+
return true;
239+
}
240+
241+
continue;
242+
}
243+
244+
// Compare both the raw identifier and the name the sibling actually emits, since acronym
245+
// normalization can move it onto the shipped name even when the identifier does not match.
246+
var siblingIdentifier = sibling.Name.ToIdentifierName();
247+
var siblingGenerated = siblingIdentifier.NormalizeCSharpAcronyms(sibling.Type.IsDateTimeInputType());
248+
if (AvoidPropertyNameCollision(siblingIdentifier, enclosingTypeName) == contractName ||
249+
AvoidPropertyNameCollision(siblingGenerated, enclosingTypeName) == contractName)
250+
{
251+
return true;
252+
}
253+
}
254+
255+
// A customization that explicitly targets a different spec property owns its public name outright.
256+
// A raw-name customization is excluded: it has no declared target, so it is intended to replace
257+
// whichever generated property ends up with that name, including a preserved one. Fields are
258+
// included because customization filtering treats a custom field as a claim on a property name.
259+
foreach (var customName in GetExplicitlyRenamedCustomNames(enclosingType))
260+
{
261+
if (customName.OriginalName == inputProperty.Name ||
262+
customName.OriginalName == inputProperty.Name.ToIdentifierName())
263+
{
264+
continue;
265+
}
266+
267+
if (customName.Name == contractName)
268+
{
269+
return true;
270+
}
271+
}
272+
273+
return false;
274+
}
275+
276+
private static IEnumerable<(string Name, string OriginalName)> GetExplicitlyRenamedCustomNames(TypeProvider enclosingType)
277+
{
278+
foreach (var customProperty in enclosingType.CustomCodeView?.Properties ?? [])
279+
{
280+
if (customProperty.OriginalName is { } propertyOriginalName)
281+
{
282+
yield return (customProperty.Name, propertyOriginalName);
283+
}
284+
}
285+
286+
foreach (var customField in enclosingType.CustomCodeView?.Fields ?? [])
287+
{
288+
if (customField.OriginalName is { } fieldOriginalName)
289+
{
290+
yield return (customField.Name, fieldOriginalName);
291+
}
292+
}
293+
}
294+
183295
private static bool IsPropertyPrivate(MethodSignatureModifiers modifiers, TypeSignatureModifiers enclosingTypeModifiers)
184296
{
185297
return (modifiers.HasFlag(MethodSignatureModifiers.Private) && !modifiers.HasFlag(MethodSignatureModifiers.Protected))

0 commit comments

Comments
 (0)