Skip to content

Commit b427dae

Browse files
committed
feat: implement pagination helper and refactor submodel descriptor and repository services to utilize new pagination logic
1 parent b22b66a commit b427dae

8 files changed

Lines changed: 200 additions & 229 deletions

File tree

source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/AasRepository/AasRepositoryServiceTests.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,8 @@ public async Task GetShellsByFiltersAsync_WithIdShort_QueriesFilteredShellMetada
328328
.GetDataForShellsByAssetIdsAsync(
329329
manifests,
330330
Arg.Is<ShellSearchFilter>(f => f != null && f.IdShort == targetIdShort),
331+
null,
332+
null,
331333
cancellationToken)
332334
.Returns(new ShellDescriptorsMetaData
333335
{
@@ -347,7 +349,7 @@ public async Task GetShellsByFiltersAsync_WithIdShort_QueriesFilteredShellMetada
347349
Assert.Single(result.Result);
348350
Assert.Equal("aas-1", result.Result[0].Id);
349351
await _pluginDataHandler.Received(1)
350-
.GetDataForShellsByAssetIdsAsync(manifests, Arg.Is<ShellSearchFilter>(f => f != null && f.IdShort == targetIdShort), cancellationToken);
352+
.GetDataForShellsByAssetIdsAsync(manifests, Arg.Is<ShellSearchFilter>(f => f != null && f.IdShort == targetIdShort), null, null, cancellationToken);
351353
}
352354

353355
[Fact]

source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/Discovery/AssetIdSearchServiceTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ public async Task SearchShellsByAssetLinkAsync_ReturnsAasIds()
5151
_ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync(
5252
Arg.Any<IReadOnlyList<PluginManifest>>(),
5353
Arg.Any<ShellSearchFilter?>(),
54+
Arg.Any<int?>(),
55+
Arg.Any<string?>(),
5456
Arg.Any<CancellationToken>())
5557
.Returns(metadata);
5658

@@ -83,6 +85,8 @@ public async Task SearchShellsByAssetLinkAsync_WithPagination_ReturnsPagedResult
8385
_ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync(
8486
Arg.Any<IReadOnlyList<PluginManifest>>(),
8587
Arg.Any<ShellSearchFilter?>(),
88+
Arg.Any<int?>(),
89+
Arg.Any<string?>(),
8690
Arg.Any<CancellationToken>())
8791
.Returns(metadata);
8892

@@ -102,6 +106,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenPluginTimeout_ThrowsPluginNot
102106
_ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync(
103107
Arg.Any<IReadOnlyList<PluginManifest>>(),
104108
Arg.Any<ShellSearchFilter?>(),
109+
Arg.Any<int?>(),
110+
Arg.Any<string?>(),
105111
Arg.Any<CancellationToken>())
106112
.Throws(new RequestTimeoutException());
107113

@@ -119,6 +125,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenUnauthorized_ThrowsServiceUnA
119125
_ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync(
120126
Arg.Any<IReadOnlyList<PluginManifest>>(),
121127
Arg.Any<ShellSearchFilter?>(),
128+
Arg.Any<int?>(),
129+
Arg.Any<string?>(),
122130
Arg.Any<CancellationToken>())
123131
.Throws(new AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure.UnauthorizedAccessException());
124132

@@ -136,6 +144,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenResponseParsingError_ThrowsIn
136144
_ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync(
137145
Arg.Any<IReadOnlyList<PluginManifest>>(),
138146
Arg.Any<ShellSearchFilter?>(),
147+
Arg.Any<int?>(),
148+
Arg.Any<string?>(),
139149
Arg.Any<CancellationToken>())
140150
.Throws(new ResponseParsingException());
141151

@@ -154,6 +164,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenMultiPluginConflict_ThrowsInt
154164
_ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync(
155165
Arg.Any<IReadOnlyList<PluginManifest>>(),
156166
Arg.Any<ShellSearchFilter?>(),
167+
Arg.Any<int?>(),
168+
Arg.Any<string?>(),
157169
Arg.Any<CancellationToken>())
158170
.Throws(new MultiPluginConflictException());
159171

@@ -171,6 +183,8 @@ public async Task SearchShellsByAssetLinkAsync_WhenResourceNotFound_ThrowsIntern
171183
_ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync(
172184
Arg.Any<IReadOnlyList<PluginManifest>>(),
173185
Arg.Any<ShellSearchFilter?>(),
186+
Arg.Any<int?>(),
187+
Arg.Any<string?>(),
174188
Arg.Any<CancellationToken>())
175189
.Throws(new ResourceNotFoundException());
176190

@@ -200,6 +214,8 @@ public async Task SearchShellsByAssetLinkAsync_FiltersOutEmptyIds()
200214
_ = _pluginDataHandler.GetDataForShellsByAssetIdsAsync(
201215
Arg.Any<IReadOnlyList<PluginManifest>>(),
202216
Arg.Any<ShellSearchFilter?>(),
217+
Arg.Any<int?>(),
218+
Arg.Any<string?>(),
203219
Arg.Any<CancellationToken>())
204220
.Returns(metadata);
205221

source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/SubmodelRepository/SubmodelRepositoryServiceTests.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@ public async Task GetAllSubmodelsAsync_FiltersShellsByIdShort_WhenIdShortProvide
378378
.GetDataForShellsByAssetIdsAsync(
379379
Arg.Any<IReadOnlyList<PluginManifest>>(),
380380
Arg.Is<ShellSearchFilter?>(f => f != null && f.IdShort == IdShort),
381+
Arg.Any<int?>(),
382+
Arg.Any<string?>(),
381383
Arg.Any<CancellationToken>())
382384
.Returns(new ShellDescriptorsMetaData { ShellDescriptors = [] });
383385

@@ -387,6 +389,8 @@ await _pluginDataHandler.Received(1)
387389
.GetDataForShellsByAssetIdsAsync(
388390
Arg.Any<IReadOnlyList<PluginManifest>>(),
389391
Arg.Is<ShellSearchFilter?>(f => f != null && f.IdShort == IdShort),
392+
Arg.Any<int?>(),
393+
Arg.Any<string?>(),
390394
Arg.Any<CancellationToken>());
391395
}
392396

@@ -526,7 +530,7 @@ public async Task GetAllSubmodelsAsync_WhenLimitReachedAtAasBoundary_CursorSubmo
526530
]);
527531

528532
_templateService
529-
.GetFilteredSubmodelTemplateAsync(Arg.Any<string>(), (string?)null, Arg.Any<SubmodelQueryOptions?>(), Arg.Any<CancellationToken>())
533+
.GetFilteredSubmodelTemplateAsync(Arg.Any<string>(), Arg.Is<string?>(default(string)), Arg.Any<SubmodelQueryOptions?>(), Arg.Any<CancellationToken>())
530534
.Returns(TestData.CreateSubmodel());
531535
_semanticIdHandler.Extract(Arg.Any<ISubmodel>()).Returns(CreateSubmodelTreeNode(""));
532536
_pluginDataHandler
@@ -569,7 +573,7 @@ public async Task GetAllSubmodelsAsync_WhenLimitReachedAtFirstAasBoundary_Cursor
569573
]);
570574

571575
_templateService
572-
.GetFilteredSubmodelTemplateAsync(Arg.Any<string>(), (string?)null, Arg.Any<SubmodelQueryOptions?>(), Arg.Any<CancellationToken>())
576+
.GetFilteredSubmodelTemplateAsync(Arg.Any<string>(), Arg.Is<string?>(default(string)), Arg.Any<SubmodelQueryOptions?>(), Arg.Any<CancellationToken>())
573577
.Returns(TestData.CreateSubmodel());
574578
_semanticIdHandler.Extract(Arg.Any<ISubmodel>()).Returns(CreateSubmodelTreeNode(""));
575579
_pluginDataHandler

source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataHandlerTests.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_ReturnsShellDescript
631631

632632
_pluginDataProvider
633633
.GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any<IList<PluginRequestMetaData>>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
634-
.Returns(new List<HttpContent> { httpResponse.Content });
634+
.Returns(new List<string> { responseJson });
635635

636636
const string Json = """[{"name":"sn","value":"123"}]""";
637637

@@ -674,7 +674,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenNullDeserializat
674674

675675
_pluginDataProvider
676676
.GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any<IList<PluginRequestMetaData>>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
677-
.Returns([httpResponse.Content]);
677+
.Returns(new List<string> { "null" });
678678

679679
var filter = new ShellSearchFilter { SpecificAssetIds = [] };
680680
await Assert.ThrowsAsync<ResponseParsingException>(() =>
@@ -711,7 +711,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenInvalidJson_Thro
711711

712712
_pluginDataProvider
713713
.GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any<IList<PluginRequestMetaData>>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
714-
.Returns([httpResponse.Content]);
714+
.Returns(new List<string> { "invalid json!" });
715715

716716
var filter = new ShellSearchFilter { SpecificAssetIds = [] };
717717
await Assert.ThrowsAsync<ResponseParsingException>(() =>
@@ -758,7 +758,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_SetsHrefOnResults()
758758

759759
_pluginDataProvider
760760
.GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any<IList<PluginRequestMetaData>>(), Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
761-
.Returns([httpResponse.Content]);
761+
.Returns(new List<string> { responseJson });
762762

763763
var filter = new ShellSearchFilter { SpecificAssetIds = [] };
764764
var result = await _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, null, null, CancellationToken.None);
@@ -829,8 +829,8 @@ public async Task GetDataForShellsByAssetIdsAsync_WithIdShort_PassesIdShort()
829829
const string targetIdShort = "Motor001";
830830

831831
_pluginDataProvider
832-
.GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any<IList<PluginRequestMetaData>>(), null, targetIdShort, Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
833-
.Returns([httpResponse.Content]);
832+
.GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any<IList<PluginRequestMetaData>>(), Arg.Is<string?>(default(string)), Arg.Is<string?>(targetIdShort), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
833+
.Returns(new List<string> { responseJson });
834834

835835
var filter = new ShellSearchFilter { IdShort = targetIdShort };
836836
var result = await _sut.GetDataForShellsByAssetIdsAsync(manifests, filter, null, null, CancellationToken.None);
@@ -839,7 +839,7 @@ public async Task GetDataForShellsByAssetIdsAsync_WithIdShort_PassesIdShort()
839839
Assert.Single(result.ShellDescriptors);
840840
Assert.Equal("urn:aas:001", result.ShellDescriptors[0].Id);
841841
await _pluginDataProvider.Received(1)
842-
.GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any<IList<PluginRequestMetaData>>(), null, targetIdShort, Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
842+
.GetDataForShellDescriptorsByAssetIdsAsync(Arg.Any<IList<PluginRequestMetaData>>(), Arg.Is<string?>(default(string)), Arg.Is<string?>(targetIdShort), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>());
843843
}
844844

845845
private const string AssetData = """

source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Providers/PluginDataProvider/Services/PluginDataProviderTests.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Net;
1+
using System.Net;
22
using System.Net.Http.Json;
33
using System.Text;
44
using System.Text.Json;
@@ -461,7 +461,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenNotFound_ThrowsR
461461
var metadata = new List<PluginRequestMetaData> { new(httpClientName, "") };
462462

463463
await Assert.ThrowsAsync<ResourceNotFoundException>(() =>
464-
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None));
464+
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None));
465465
}
466466

467467
[Fact]
@@ -481,7 +481,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenUnauthorized_Thr
481481
var metadata = new List<PluginRequestMetaData> { new(httpClientName, "") };
482482

483483
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
484-
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None));
484+
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None));
485485
}
486486

487487
[Fact]
@@ -495,7 +495,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenTimeout_ThrowsRe
495495
var metadata = new List<PluginRequestMetaData> { new(httpClientName, "") };
496496

497497
await Assert.ThrowsAsync<RequestTimeoutException>(() =>
498-
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None));
498+
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None));
499499
}
500500

501501
[Fact]
@@ -504,7 +504,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WithNullRequest_Skip
504504
var metadata = new List<PluginRequestMetaData> { null! };
505505

506506
await Assert.ThrowsAsync<ValidationFailedException>(() =>
507-
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None));
507+
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None));
508508
}
509509

510510
[Fact]
@@ -524,7 +524,7 @@ public async Task GetDataForShellDescriptorsByAssetIdsAsync_WhenForbidden_Throws
524524
var metadata = new List<PluginRequestMetaData> { new(httpClientName, "") };
525525

526526
await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
527-
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, CancellationToken.None));
527+
_sut.GetDataForShellDescriptorsByAssetIdsAsync(metadata, "[]", null, null, null, CancellationToken.None));
528528
}
529529

530530
private static List<ShellDescriptorMetaData> GetTestShellDescriptorDataList()
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using AAS.TwinEngine.DataEngine.DomainModel.SubmodelRepository;
2+
3+
namespace AAS.TwinEngine.DataEngine.ApplicationLogic.Services.Shared.Pagination;
4+
5+
public sealed record SubmodelPaginationPageResult(List<string> SubmodelIds, string? NextCursor);
6+
7+
public sealed class SubmodelPaginationState(SubmodelPaginationCursor? cursor)
8+
{
9+
public List<string> CollectedIds { get; } = [];
10+
public string? TrackingAasId { get; set; } = cursor?.AasId;
11+
public string? LastCollectedSubmodelId { get; set; }
12+
public string? SkipToSubmodelId { get; set; } = cursor?.SubmodelId;
13+
public bool IsFirstAasInResume { get; set; } = cursor is not null;
14+
}
15+
16+
public static class SubmodelPaginationHelper
17+
{
18+
public static async Task<SubmodelPaginationPageResult> CollectSubmodelPageAsync<TShell>(
19+
int pageSize,
20+
string? encodedCursor,
21+
Func<int, string?, CancellationToken, Task<(List<TShell> Shells, string? NextShellCursor)>> fetchShellBatchAsync,
22+
Func<TShell, string?> getShellId,
23+
Func<TShell, CancellationToken, Task<List<string>>> getSubmodelIdsAsync,
24+
CancellationToken cancellationToken)
25+
{
26+
var incomingCursor = SubmodelPaginationCursor.Decode(encodedCursor);
27+
var state = new SubmodelPaginationState(incomingCursor);
28+
var pluginCursor = state.TrackingAasId;
29+
30+
while (state.CollectedIds.Count < pageSize)
31+
{
32+
var (shellBatch, nextShellCursor) = await fetchShellBatchAsync(pageSize, pluginCursor, cancellationToken).ConfigureAwait(false);
33+
34+
var validShells = shellBatch.Where(s => !string.IsNullOrWhiteSpace(getShellId(s))).ToList();
35+
36+
if (validShells.Count == 0)
37+
{
38+
break;
39+
}
40+
41+
var limitReached = await ProcessShellBatchAsync(validShells, pageSize, state, getShellId, getSubmodelIdsAsync, cancellationToken).ConfigureAwait(false);
42+
43+
if (limitReached || nextShellCursor is null)
44+
{
45+
break;
46+
}
47+
48+
pluginCursor = state.TrackingAasId;
49+
}
50+
51+
var nextCursor = state.CollectedIds.Count >= pageSize
52+
? SubmodelPaginationCursor.Encode(state.LastCollectedSubmodelId, state.TrackingAasId)
53+
: null;
54+
55+
return new SubmodelPaginationPageResult(state.CollectedIds, nextCursor);
56+
}
57+
58+
private static async Task<bool> ProcessShellBatchAsync<TShell>(
59+
List<TShell> shellList,
60+
int pageSize,
61+
SubmodelPaginationState state,
62+
Func<TShell, string?> getShellId,
63+
Func<TShell, CancellationToken, Task<List<string>>> getSubmodelIdsAsync,
64+
CancellationToken cancellationToken)
65+
{
66+
foreach (var shell in shellList)
67+
{
68+
var shellId = getShellId(shell);
69+
var submodelIds = await getSubmodelIdsAsync(shell, cancellationToken).ConfigureAwait(false);
70+
71+
if (submodelIds.Count == 0)
72+
{
73+
state.TrackingAasId = shellId;
74+
state.IsFirstAasInResume = false;
75+
continue;
76+
}
77+
78+
var startIndex = 0;
79+
80+
if (state.IsFirstAasInResume && state.SkipToSubmodelId is not null)
81+
{
82+
startIndex = submodelIds.IndexOf(state.SkipToSubmodelId) + 1;
83+
state.IsFirstAasInResume = false;
84+
state.SkipToSubmodelId = null;
85+
}
86+
else
87+
{
88+
state.IsFirstAasInResume = false;
89+
}
90+
91+
for (var i = startIndex; i < submodelIds.Count; i++)
92+
{
93+
var submodelId = submodelIds[i];
94+
if (!state.CollectedIds.Contains(submodelId, StringComparer.OrdinalIgnoreCase))
95+
{
96+
state.CollectedIds.Add(submodelId);
97+
}
98+
state.LastCollectedSubmodelId = submodelId;
99+
100+
if (state.CollectedIds.Count >= pageSize)
101+
{
102+
if (state.CollectedIds.Contains(submodelIds.Last(), StringComparer.OrdinalIgnoreCase))
103+
{
104+
state.TrackingAasId = shellId;
105+
}
106+
107+
return true;
108+
}
109+
}
110+
111+
state.TrackingAasId = shellId;
112+
}
113+
114+
return false;
115+
}
116+
}

0 commit comments

Comments
 (0)