Skip to content

Commit cf050c6

Browse files
Fix property lookup to support inheritance in pagination response models (microsoft#9419)
The emitter failed when pagination properties (`@nextLink`, `@pageItems`) were defined in base models rather than directly on the response model. Property lookups used `Properties.First()` which only searched direct properties, causing `InvalidOperationException` for inherited properties. ## Changes - **Added recursive property lookup**: New `FindPropertyInModelHierarchy()` method traverses the model inheritance chain to locate properties defined in base classes - **Updated `CollectionResultDefinition.GetNextPagePropertyType()`**: Replaced direct property access with hierarchy-aware lookup - **Updated `ModelProviderSnippets.BuildPropertyAccessExpression()`**: Applied same pattern for consistency across property access paths - **Added test coverage**: 4 test cases covering sync/async and generic/non-generic scenarios with inherited pagination properties ## Example Now supports this pattern: ```typespec model ResponseList { @nextlink nextLink?: string; } model FooResponseList extends ResponseList { @pageItems value?: FooResource[]; } ``` Previously, the emitter would throw when trying to locate `nextLink` on `FooResponseList` since it only checked direct properties. <!-- START COPILOT ORIGINAL PROMPT --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>[http-client-csharp] failed to find nested nextLink property for a list operation</issue_title> > <issue_description>If the response model is inherited from another model that only defines `nextLink`, like this: > ``` js > model ResponseList { > /** > * The uri to fetch the next page of resources. Call ListNext() fetches next page of resources. > */ > @nextlink > nextLink?: string; > } > > model FooResponseList extends ResponseList { > /** > * List of resources. > */ > @pageItems > value?: FooResource[]; > } > ``` > Then the emitter throws an exception because it doesn’t check for nested `nextLink` values. This results in an error like the following: > ``` > Sequence contains no matching element > at System.Linq.ThrowHelper.ThrowNoMatchException() > at System.Linq.Enumerable.First[TSource](IEnumerable`1 source, Func`2 predicate) > at Microsoft.TypeSpec.Generator.ClientModel.Providers.CollectionResultDefinition.GetNextPagePropertyType() > at Microsoft.TypeSpec.Generator.ClientModel.Providers.CollectionResultDefinition..ctor(ClientProvider client, InputPagingServiceMethod serviceMethod, CSharpType itemModelType, Boolean isAsync) > at Azure.Generator.Providers.AzureCollectionResultDefinition..ctor(ClientProvider client, InputPagingServiceMethod serviceMethod, CSharpType itemModelType, Boolean isAsync) > at Azure.Generator.Providers.AzureClientResponseProvider.CreateClientCollectionResultDefinition(ClientProvider client, InputPagingServiceMethod serviceMethod, CSharpType type, Boolean isAsync) > at Microsoft.TypeSpec.Generator.ClientModel.Providers.ScmMethodProviderCollection.BuildProtocolMethod(MethodProvider createRequestMethod, Boolean isAsync, Boolean shouldMakeParametersRequired) > at Microsoft.TypeSpec.Generator.ClientModel.Providers.ScmMethodProviderCollection.BuildMethods() > at Microsoft.TypeSpec.Generator.ClientModel.Providers.ScmMethodProviderCollection.get_MethodProviders() > at Microsoft.TypeSpec.Generator.ClientModel.Providers.ScmMethodProviderCollection.GetEnumerator() > at System.Collections.Generic.List`1.AddRange(IEnumerable`1 collection) > at Microsoft.TypeSpec.Generator.ClientModel.Providers.ClientProvider.BuildMethods() > at Microsoft.TypeSpec.Generator.Providers.TypeProvider.get_Methods() > at Microsoft.TypeSpec.Generator.ClientModel.Providers.ClientProvider.GetMethodCollectionByOperation(InputOperation operation) > ```</issue_description> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > </comments> > </details> <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes microsoft#9418 <!-- START COPILOT CODING AGENT TIPS --> --- 💬 We'd love your input! Share your thoughts on Copilot coding agent in our [2 minute survey](https://gh.io/copilot-coding-agent-survey). --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ArthurMa1978 <20514459+ArthurMa1978@users.noreply.github.com>
1 parent 42634f7 commit cf050c6

7 files changed

Lines changed: 363 additions & 2 deletions

File tree

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ private CSharpType GetNextPagePropertyType()
143143
PropertyProvider? property = null;
144144
for (int i = 0; i < NextPagePropertySegments.Count; i++)
145145
{
146-
property = model.Properties.First(p => p.WireInfo?.SerializedName == NextPagePropertySegments[i]);
146+
property = FindPropertyInModelHierarchy(model, NextPagePropertySegments[i]);
147147

148148
if (i < NextPagePropertySegments.Count - 1)
149149
{
@@ -154,6 +154,29 @@ private CSharpType GetNextPagePropertyType()
154154
return property!.Type;
155155
}
156156

157+
/// <summary>
158+
/// Searches for a property with the specified serialized name in the model and its base models.
159+
/// </summary>
160+
private PropertyProvider FindPropertyInModelHierarchy(TypeProvider model, string serializedName)
161+
{
162+
// First, try to find the property in the current model
163+
var property = model.Properties.FirstOrDefault(p => p.WireInfo?.SerializedName == serializedName);
164+
if (property != null)
165+
{
166+
return property;
167+
}
168+
169+
// If not found, search in the base model hierarchy
170+
if (model is ModelProvider modelProvider && modelProvider.BaseModelProvider != null)
171+
{
172+
return FindPropertyInModelHierarchy(modelProvider.BaseModelProvider, serializedName);
173+
}
174+
175+
// If not found anywhere, throw an exception with a helpful message
176+
throw new InvalidOperationException(
177+
$"Property with serialized name '{serializedName}' not found in model '{model.Name}' or its base models.");
178+
}
179+
157180
protected override string BuildRelativeFilePath() => Path.Combine("src", "Generated", "CollectionResults", $"{Name}.cs");
158181

159182
protected override string BuildNamespace() => Client.Type.Namespace;

packages/http-client-csharp/generator/Microsoft.TypeSpec.Generator.ClientModel/test/Providers/CollectionResultDefinitions/NextLinkTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,62 @@ public void UsesValidFieldIdentifierNames()
238238
Assert.IsTrue(fields.Any(f => f.Name == "_foo"));
239239
}
240240

241+
[Test]
242+
public void InheritedNextLinkInBody()
243+
{
244+
CreatePagingOperationWithInheritedNextLink(InputResponseLocation.Body);
245+
246+
var collectionResultDefinition = ScmCodeModelGenerator.Instance.OutputLibrary.TypeProviders.FirstOrDefault(
247+
t => t is CollectionResultDefinition && t.Name == "CatClientGetCatsCollectionResult");
248+
Assert.IsNotNull(collectionResultDefinition);
249+
250+
var writer = new TypeProviderWriter(collectionResultDefinition!);
251+
var file = writer.Write();
252+
Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content);
253+
}
254+
255+
[Test]
256+
public void InheritedNextLinkInBodyAsync()
257+
{
258+
CreatePagingOperationWithInheritedNextLink(InputResponseLocation.Body);
259+
260+
var collectionResultDefinition = ScmCodeModelGenerator.Instance.OutputLibrary.TypeProviders.FirstOrDefault(
261+
t => t is CollectionResultDefinition && t.Name == "CatClientGetCatsAsyncCollectionResult");
262+
Assert.IsNotNull(collectionResultDefinition);
263+
264+
var writer = new TypeProviderWriter(collectionResultDefinition!);
265+
var file = writer.Write();
266+
Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content);
267+
}
268+
269+
[Test]
270+
public void InheritedNextLinkInBodyOfT()
271+
{
272+
CreatePagingOperationWithInheritedNextLink(InputResponseLocation.Body);
273+
274+
var collectionResultDefinition = ScmCodeModelGenerator.Instance.OutputLibrary.TypeProviders.FirstOrDefault(
275+
t => t is CollectionResultDefinition && t.Name == "CatClientGetCatsCollectionResultOfT");
276+
Assert.IsNotNull(collectionResultDefinition);
277+
278+
var writer = new TypeProviderWriter(collectionResultDefinition!);
279+
var file = writer.Write();
280+
Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content);
281+
}
282+
283+
[Test]
284+
public void InheritedNextLinkInBodyOfTAsync()
285+
{
286+
CreatePagingOperationWithInheritedNextLink(InputResponseLocation.Body);
287+
288+
var collectionResultDefinition = ScmCodeModelGenerator.Instance.OutputLibrary.TypeProviders.FirstOrDefault(
289+
t => t is CollectionResultDefinition && t.Name == "CatClientGetCatsAsyncCollectionResultOfT");
290+
Assert.IsNotNull(collectionResultDefinition);
291+
292+
var writer = new TypeProviderWriter(collectionResultDefinition!);
293+
var file = writer.Write();
294+
Assert.AreEqual(Helpers.GetExpectedFromFile(), file.Content);
295+
}
296+
241297
private static void CreatePagingOperation(InputResponseLocation responseLocation, bool isNested = false)
242298
{
243299
var inputModel = InputFactory.Model("cat", properties:
@@ -267,5 +323,29 @@ private static void CreatePagingOperation(InputResponseLocation responseLocation
267323

268324
MockHelpers.LoadMockGenerator(inputModels: () => [inputModel], clients: () => [client]);
269325
}
326+
327+
private static void CreatePagingOperationWithInheritedNextLink(InputResponseLocation responseLocation)
328+
{
329+
var inputModel = InputFactory.Model("cat", properties:
330+
[
331+
InputFactory.Property("color", InputPrimitiveType.String, isRequired: true),
332+
]);
333+
334+
// Create the base model with nextLink property
335+
var nextCatProperty = InputFactory.Property("nextCat", InputPrimitiveType.Url);
336+
var baseModel = InputFactory.Model("basePage", properties: [nextCatProperty]);
337+
338+
// Create the derived model that inherits from base and adds cats property
339+
var catsProperty = InputFactory.Property("cats", InputFactory.Array(inputModel));
340+
var derivedModel = InputFactory.Model("page", properties: [catsProperty], baseModel: baseModel);
341+
342+
var pagingMetadata = InputFactory.NextLinkPagingMetadata(["cats"], ["nextCat"], responseLocation);
343+
var response = InputFactory.OperationResponse([200], derivedModel);
344+
var operation = InputFactory.Operation("getCats", responses: [response]);
345+
var inputServiceMethod = InputFactory.PagingServiceMethod("getCats", operation, pagingMetadata: pagingMetadata);
346+
var client = InputFactory.Client("catClient", methods: [inputServiceMethod]);
347+
348+
MockHelpers.LoadMockGenerator(inputModels: () => [inputModel, baseModel, derivedModel], clients: () => [client]);
349+
}
270350
}
271351
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// <auto-generated/>
2+
3+
#nullable disable
4+
5+
using System;
6+
using System.ClientModel;
7+
using System.ClientModel.Primitives;
8+
using System.Collections.Generic;
9+
using Sample.Models;
10+
11+
namespace Sample
12+
{
13+
internal partial class CatClientGetCatsCollectionResult : global::System.ClientModel.Primitives.CollectionResult
14+
{
15+
private readonly global::Sample.CatClient _client;
16+
private readonly global::System.ClientModel.Primitives.RequestOptions _options;
17+
18+
public CatClientGetCatsCollectionResult(global::Sample.CatClient client, global::System.ClientModel.Primitives.RequestOptions options)
19+
{
20+
_client = client;
21+
_options = options;
22+
}
23+
24+
public override global::System.Collections.Generic.IEnumerable<global::System.ClientModel.ClientResult> GetRawPages()
25+
{
26+
global::System.ClientModel.Primitives.PipelineMessage message = _client.CreateGetCatsRequest(_options);
27+
global::System.Uri nextPageUri = null;
28+
while (true)
29+
{
30+
global::System.ClientModel.ClientResult result = global::System.ClientModel.ClientResult.FromResponse(_client.Pipeline.ProcessMessage(message, _options));
31+
yield return result;
32+
33+
nextPageUri = ((global::Sample.Models.Page)result).NextCat;
34+
if ((nextPageUri == null))
35+
{
36+
yield break;
37+
}
38+
message = _client.CreateNextGetCatsRequest(nextPageUri, _options);
39+
}
40+
}
41+
42+
public override global::System.ClientModel.ContinuationToken GetContinuationToken(global::System.ClientModel.ClientResult page)
43+
{
44+
global::System.Uri nextPage = ((global::Sample.Models.Page)page).NextCat;
45+
if ((nextPage != null))
46+
{
47+
return global::System.ClientModel.ContinuationToken.FromBytes(global::System.BinaryData.FromString(nextPage.AbsoluteUri));
48+
}
49+
else
50+
{
51+
return null;
52+
}
53+
}
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// <auto-generated/>
2+
3+
#nullable disable
4+
5+
using System;
6+
using System.ClientModel;
7+
using System.ClientModel.Primitives;
8+
using System.Collections.Generic;
9+
using Sample.Models;
10+
11+
namespace Sample
12+
{
13+
internal partial class CatClientGetCatsAsyncCollectionResult : global::System.ClientModel.Primitives.AsyncCollectionResult
14+
{
15+
private readonly global::Sample.CatClient _client;
16+
private readonly global::System.ClientModel.Primitives.RequestOptions _options;
17+
18+
public CatClientGetCatsAsyncCollectionResult(global::Sample.CatClient client, global::System.ClientModel.Primitives.RequestOptions options)
19+
{
20+
_client = client;
21+
_options = options;
22+
}
23+
24+
public override async global::System.Collections.Generic.IAsyncEnumerable<global::System.ClientModel.ClientResult> GetRawPagesAsync()
25+
{
26+
global::System.ClientModel.Primitives.PipelineMessage message = _client.CreateGetCatsRequest(_options);
27+
global::System.Uri nextPageUri = null;
28+
while (true)
29+
{
30+
global::System.ClientModel.ClientResult result = global::System.ClientModel.ClientResult.FromResponse(await _client.Pipeline.ProcessMessageAsync(message, _options).ConfigureAwait(false));
31+
yield return result;
32+
33+
nextPageUri = ((global::Sample.Models.Page)result).NextCat;
34+
if ((nextPageUri == null))
35+
{
36+
yield break;
37+
}
38+
message = _client.CreateNextGetCatsRequest(nextPageUri, _options);
39+
}
40+
}
41+
42+
public override global::System.ClientModel.ContinuationToken GetContinuationToken(global::System.ClientModel.ClientResult page)
43+
{
44+
global::System.Uri nextPage = ((global::Sample.Models.Page)page).NextCat;
45+
if ((nextPage != null))
46+
{
47+
return global::System.ClientModel.ContinuationToken.FromBytes(global::System.BinaryData.FromString(nextPage.AbsoluteUri));
48+
}
49+
else
50+
{
51+
return null;
52+
}
53+
}
54+
}
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// <auto-generated/>
2+
3+
#nullable disable
4+
5+
using System;
6+
using System.ClientModel;
7+
using System.ClientModel.Primitives;
8+
using System.Collections.Generic;
9+
using Sample.Models;
10+
11+
namespace Sample
12+
{
13+
internal partial class CatClientGetCatsCollectionResultOfT : global::System.ClientModel.CollectionResult<global::Sample.Models.Cat>
14+
{
15+
private readonly global::Sample.CatClient _client;
16+
private readonly global::System.ClientModel.Primitives.RequestOptions _options;
17+
18+
public CatClientGetCatsCollectionResultOfT(global::Sample.CatClient client, global::System.ClientModel.Primitives.RequestOptions options)
19+
{
20+
_client = client;
21+
_options = options;
22+
}
23+
24+
public override global::System.Collections.Generic.IEnumerable<global::System.ClientModel.ClientResult> GetRawPages()
25+
{
26+
global::System.ClientModel.Primitives.PipelineMessage message = _client.CreateGetCatsRequest(_options);
27+
global::System.Uri nextPageUri = null;
28+
while (true)
29+
{
30+
global::System.ClientModel.ClientResult result = global::System.ClientModel.ClientResult.FromResponse(_client.Pipeline.ProcessMessage(message, _options));
31+
yield return result;
32+
33+
nextPageUri = ((global::Sample.Models.Page)result).NextCat;
34+
if ((nextPageUri == null))
35+
{
36+
yield break;
37+
}
38+
message = _client.CreateNextGetCatsRequest(nextPageUri, _options);
39+
}
40+
}
41+
42+
public override global::System.ClientModel.ContinuationToken GetContinuationToken(global::System.ClientModel.ClientResult page)
43+
{
44+
global::System.Uri nextPage = ((global::Sample.Models.Page)page).NextCat;
45+
if ((nextPage != null))
46+
{
47+
return global::System.ClientModel.ContinuationToken.FromBytes(global::System.BinaryData.FromString(nextPage.AbsoluteUri));
48+
}
49+
else
50+
{
51+
return null;
52+
}
53+
}
54+
55+
protected override global::System.Collections.Generic.IEnumerable<global::Sample.Models.Cat> GetValuesFromPage(global::System.ClientModel.ClientResult page)
56+
{
57+
return ((global::Sample.Models.Page)page).Cats;
58+
}
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// <auto-generated/>
2+
3+
#nullable disable
4+
5+
using System;
6+
using System.ClientModel;
7+
using System.ClientModel.Primitives;
8+
using System.Collections.Generic;
9+
using System.Threading.Tasks;
10+
using Sample.Models;
11+
12+
namespace Sample
13+
{
14+
internal partial class CatClientGetCatsAsyncCollectionResultOfT : global::System.ClientModel.AsyncCollectionResult<global::Sample.Models.Cat>
15+
{
16+
private readonly global::Sample.CatClient _client;
17+
private readonly global::System.ClientModel.Primitives.RequestOptions _options;
18+
19+
public CatClientGetCatsAsyncCollectionResultOfT(global::Sample.CatClient client, global::System.ClientModel.Primitives.RequestOptions options)
20+
{
21+
_client = client;
22+
_options = options;
23+
}
24+
25+
public override async global::System.Collections.Generic.IAsyncEnumerable<global::System.ClientModel.ClientResult> GetRawPagesAsync()
26+
{
27+
global::System.ClientModel.Primitives.PipelineMessage message = _client.CreateGetCatsRequest(_options);
28+
global::System.Uri nextPageUri = null;
29+
while (true)
30+
{
31+
global::System.ClientModel.ClientResult result = global::System.ClientModel.ClientResult.FromResponse(await _client.Pipeline.ProcessMessageAsync(message, _options).ConfigureAwait(false));
32+
yield return result;
33+
34+
nextPageUri = ((global::Sample.Models.Page)result).NextCat;
35+
if ((nextPageUri == null))
36+
{
37+
yield break;
38+
}
39+
message = _client.CreateNextGetCatsRequest(nextPageUri, _options);
40+
}
41+
}
42+
43+
public override global::System.ClientModel.ContinuationToken GetContinuationToken(global::System.ClientModel.ClientResult page)
44+
{
45+
global::System.Uri nextPage = ((global::Sample.Models.Page)page).NextCat;
46+
if ((nextPage != null))
47+
{
48+
return global::System.ClientModel.ContinuationToken.FromBytes(global::System.BinaryData.FromString(nextPage.AbsoluteUri));
49+
}
50+
else
51+
{
52+
return null;
53+
}
54+
}
55+
56+
protected override async global::System.Collections.Generic.IAsyncEnumerable<global::Sample.Models.Cat> GetValuesFromPageAsync(global::System.ClientModel.ClientResult page)
57+
{
58+
foreach (global::Sample.Models.Cat item in ((global::Sample.Models.Page)page).Cats)
59+
{
60+
yield return item;
61+
await global::System.Threading.Tasks.Task.Yield();
62+
}
63+
}
64+
}
65+
}

0 commit comments

Comments
 (0)