Skip to content

Commit f22eca3

Browse files
authored
Merge pull request #75 from panoramicdata/fix/issue-74-paging-field-parsing
Fix paging when the ordering field is not a plain UTC string
2 parents 2d516c9 + 386b5b8 commit f22eca3

4 files changed

Lines changed: 271 additions & 32 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
using AwesomeAssertions;
2+
using Newtonsoft.Json.Linq;
3+
using ServiceNow.Api.Exceptions;
4+
using System.Globalization;
5+
using Xunit;
6+
7+
namespace ServiceNow.Api.Test;
8+
9+
/// <summary>
10+
/// Regression tests for how the ordering field is read out of a returned row when paging.
11+
///
12+
/// These run entirely against a stubbed message handler, so they need no credentials and no network.
13+
///
14+
/// The bug they were written for (issue #74, reported as #25): paging read the ordering field with
15+
/// ToString() and concatenated "Z" onto it. That breaks whenever sysparm_display_value is set:
16+
/// with "all" every field is returned as a { display_value, value } object, so ToString() yields JSON
17+
/// and the parse throws, taking the whole query with it.
18+
/// </summary>
19+
public class PagingFieldParsingTests
20+
{
21+
private const string TableName = "cmdb_ci";
22+
private const int PageSize = 1000;
23+
private static readonly DateTime _baseTime = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
24+
25+
/// <summary>
26+
/// The reported failure: with sysparm_display_value=all the ordering field is an object, not a scalar.
27+
/// </summary>
28+
[Fact]
29+
public async Task ObjectShapedPagingField_PagesInsteadOfThrowing()
30+
{
31+
using var handler = new StubServiceNowHandler(totalCount: 1_500,
32+
[
33+
MakeObjectShapedPage(0, PageSize),
34+
MakeObjectShapedPage(PageSize, 500),
35+
[]
36+
]);
37+
38+
using var client = new ServiceNowClient(handler);
39+
40+
var result = await client
41+
.GetAllByQueryAsync(TableName, cancellationToken: TestContext.Current.CancellationToken)
42+
.ConfigureAwait(true);
43+
44+
result.Should().HaveCount(1_500, "an object-shaped ordering field must not break paging");
45+
}
46+
47+
/// <summary>
48+
/// With both representations present the raw value is the one to page on, since it carries the
49+
/// underlying UTC timestamp rather than a timezone-and-format-dependent rendering of it.
50+
/// </summary>
51+
[Fact]
52+
public async Task ObjectShapedPagingField_PagesOnTheRawValueNotTheDisplayValue()
53+
{
54+
// value says 10:00 UTC; display_value says something entirely different.
55+
var fullPage = MakePageSharingOneTimestamp(new JObject
56+
{
57+
["display_value"] = "31/12/2030 23:59:59",
58+
["value"] = "2026-01-01 10:00:00"
59+
});
60+
61+
using var handler = new StubServiceNowHandler(totalCount: PageSize, [fullPage, []]);
62+
using var client = new ServiceNowClient(handler);
63+
64+
_ = await client
65+
.GetAllByQueryAsync(TableName, cancellationToken: TestContext.Current.CancellationToken)
66+
.ConfigureAwait(true);
67+
68+
// The second request carries the paging window, built from whichever representation was used.
69+
handler.RequestUris.Should().HaveCountGreaterThan(1);
70+
handler.RequestUris[1].Should().Contain("2026-01-01 10:00:00", "the raw value is the correct boundary");
71+
handler.RequestUris[1].Should().NotContain("2030", "the display value must not be used as the boundary");
72+
}
73+
74+
/// <summary>
75+
/// A value carrying its own offset used to be corrupted by concatenating "Z" onto it. It should now be
76+
/// converted to UTC properly.
77+
/// </summary>
78+
[Fact]
79+
public async Task PagingFieldWithAnExplicitOffset_IsConvertedToUtc()
80+
{
81+
// 05:00 at +05:00 is midnight UTC.
82+
var fullPage = MakePageSharingOneTimestamp("2026-01-02T05:00:00+05:00");
83+
84+
using var handler = new StubServiceNowHandler(totalCount: PageSize, [fullPage, []]);
85+
using var client = new ServiceNowClient(handler);
86+
87+
_ = await client
88+
.GetAllByQueryAsync(TableName, cancellationToken: TestContext.Current.CancellationToken)
89+
.ConfigureAwait(true);
90+
91+
handler.RequestUris.Should().HaveCountGreaterThan(1);
92+
handler.RequestUris[1].Should().Contain("2026-01-02 00:00:00", "the offset should be applied, not ignored");
93+
}
94+
95+
/// <summary>
96+
/// An ordering field that is not a date at all should say so clearly, naming the field and the value,
97+
/// rather than surfacing a bare FormatException from inside a LINQ Max().
98+
/// </summary>
99+
[Fact]
100+
public async Task UnparseablePagingField_ThrowsNamingTheFieldAndValue()
101+
{
102+
var fullPage = MakePageSharingOneTimestamp("not a date at all");
103+
104+
using var handler = new StubServiceNowHandler(totalCount: PageSize, [fullPage, []]);
105+
using var client = new ServiceNowClient(handler);
106+
107+
var act = async () => await client
108+
.GetAllByQueryAsync(TableName, cancellationToken: TestContext.Current.CancellationToken)
109+
.ConfigureAwait(true);
110+
111+
(await act.Should().ThrowAsync<ServiceNowApiException>().ConfigureAwait(true))
112+
.WithMessage("*sys_created_on*not a date at all*");
113+
}
114+
115+
/// <summary>
116+
/// A plain UTC string, which is what comes back with no sysparm_display_value, must keep working exactly
117+
/// as before. This is the overwhelmingly common case.
118+
/// </summary>
119+
[Fact]
120+
public async Task PlainUtcPagingField_StillPagesAsBefore()
121+
{
122+
using var handler = new StubServiceNowHandler(totalCount: 1_500,
123+
[
124+
MakePlainPage(0, PageSize),
125+
MakePlainPage(PageSize, 500),
126+
[]
127+
]);
128+
129+
using var client = new ServiceNowClient(handler);
130+
131+
var result = await client
132+
.GetAllByQueryAsync(TableName, cancellationToken: TestContext.Current.CancellationToken)
133+
.ConfigureAwait(true);
134+
135+
result.Should().HaveCount(1_500);
136+
}
137+
138+
/// <summary>
139+
/// A full page of distinct records that all share one ordering-field value, so that the paging window the
140+
/// client derives from the page is deterministic and can be asserted on. The sys_ids must differ or the
141+
/// client's de-duplication would collapse the page to a single row.
142+
/// </summary>
143+
private static List<JObject> MakePageSharingOneTimestamp(JToken sharedCreatedOn)
144+
=> [.. Enumerable.Range(0, PageSize).Select(i => new JObject
145+
{
146+
["sys_id"] = $"sys{i:D8}",
147+
["sys_created_on"] = sharedCreatedOn.DeepClone()
148+
})];
149+
150+
private static List<JObject> MakePlainPage(int startIndex, int count)
151+
=> [.. Enumerable.Range(0, count).Select(i => new JObject
152+
{
153+
["sys_id"] = $"sys{startIndex + i:D8}",
154+
["sys_created_on"] = _baseTime.AddSeconds(startIndex + i).ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
155+
})];
156+
157+
/// <summary>
158+
/// The shape returned when sysparm_display_value=all is requested.
159+
/// </summary>
160+
private static List<JObject> MakeObjectShapedPage(int startIndex, int count)
161+
=> [.. Enumerable.Range(0, count).Select(i =>
162+
{
163+
var created = _baseTime.AddSeconds(startIndex + i);
164+
return new JObject
165+
{
166+
["sys_id"] = $"sys{startIndex + i:D8}",
167+
["sys_created_on"] = new JObject
168+
{
169+
["display_value"] = created.ToString("dd/MM/yyyy HH:mm:ss", CultureInfo.InvariantCulture),
170+
["value"] = created.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
171+
}
172+
};
173+
})];
174+
}

ServiceNow.Api.Test/PagingTerminationTests.cs

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
using Newtonsoft.Json.Linq;
33
using ServiceNow.Api.Exceptions;
44
using System.Globalization;
5-
using System.Net;
6-
using System.Text;
75
using Xunit;
86

97
namespace ServiceNow.Api.Test;
@@ -201,31 +199,4 @@ private static List<JObject> MakePage(int startIndex, int count, bool sameTimest
201199

202200
return page;
203201
}
204-
205-
/// <summary>
206-
/// Serves a fixed sequence of pages, and reports a fixed X-Total-Count. The query is ignored:
207-
/// these tests are about the termination and validation logic, not query construction.
208-
/// </summary>
209-
private sealed class StubServiceNowHandler(int totalCount, IReadOnlyList<List<JObject>> pages) : HttpMessageHandler
210-
{
211-
private int _requestCount;
212-
213-
public int RequestCount => _requestCount;
214-
215-
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
216-
{
217-
var index = _requestCount++;
218-
var rows = index < pages.Count ? pages[index] : [];
219-
220-
var payload = new JObject { ["result"] = new JArray(rows) };
221-
222-
var response = new HttpResponseMessage(HttpStatusCode.OK)
223-
{
224-
Content = new StringContent(payload.ToString(), Encoding.UTF8, "application/json")
225-
};
226-
response.Headers.Add("X-Total-Count", totalCount.ToString(CultureInfo.InvariantCulture));
227-
228-
return Task.FromResult(response);
229-
}
230-
}
231202
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using Newtonsoft.Json.Linq;
2+
using System.Globalization;
3+
using System.Net;
4+
using System.Text;
5+
using System.Web;
6+
7+
namespace ServiceNow.Api.Test;
8+
9+
/// <summary>
10+
/// Serves a fixed sequence of pages and reports a fixed X-Total-Count, so that paging behaviour can be
11+
/// exercised deterministically without a live ServiceNow instance. The query is ignored when choosing what
12+
/// to return: these tests are about how responses are interpreted, not about query construction. The
13+
/// requested URLs are recorded so that a test can assert on what the client asked for.
14+
/// </summary>
15+
internal sealed class StubServiceNowHandler(int totalCount, IReadOnlyList<List<JObject>> pages) : HttpMessageHandler
16+
{
17+
private readonly List<string> _requestUris = [];
18+
19+
public int RequestCount => _requestUris.Count;
20+
21+
/// <summary>
22+
/// The path and query of every request made, in order.
23+
/// </summary>
24+
public IReadOnlyList<string> RequestUris => _requestUris;
25+
26+
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
27+
{
28+
var index = _requestUris.Count;
29+
30+
// HttpUtility.UrlDecode is the exact inverse of the HttpUtility.UrlEncode the client uses, so a
31+
// space encoded as '+' comes back as a space. Uri.UnescapeDataString would leave it as '+'.
32+
_requestUris.Add(HttpUtility.UrlDecode(request.RequestUri?.PathAndQuery ?? string.Empty));
33+
34+
var rows = index < pages.Count ? pages[index] : [];
35+
var payload = new JObject { ["result"] = new JArray(rows) };
36+
37+
var response = new HttpResponseMessage(HttpStatusCode.OK)
38+
{
39+
Content = new StringContent(payload.ToString(), Encoding.UTF8, "application/json")
40+
};
41+
response.Headers.Add("X-Total-Count", totalCount.ToString(CultureInfo.InvariantCulture));
42+
43+
return Task.FromResult(response);
44+
}
45+
}

ServiceNow.Api/ServiceNowClient.cs

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using ServiceNow.Api.MetaData;
77
using ServiceNow.Api.Tables;
88
using System.Diagnostics;
9+
using System.Globalization;
910
using System.Net.Http.Headers;
1011
using System.Text;
1112
using System.Text.RegularExpressions;
@@ -372,9 +373,7 @@ internal async Task<List<JObject>> GetAllByQueryInternalJObjectAsync(
372373
}
373374

374375
// At this point, we can be sure that we have the paging field in the data
375-
maxDateTimeRetrieved = items.Max(jObject =>
376-
// Parse and enforce source as being UTC (Z)
377-
DateTimeOffset.Parse((jObject[orderByField!]?.ToString() ?? string.Empty) + "Z"));
376+
maxDateTimeRetrieved = items.Max(jObject => ParsePagingFieldValue(jObject, orderByField!, tableName));
378377

379378
if (previousMaxDateTimeRetrieved == maxDateTimeRetrieved)
380379
{
@@ -496,6 +495,56 @@ private async Task<Page<T>> GetPageByQueryInternalAsync<T>(
496495
return pageResult;
497496
}
498497

498+
/// <summary>
499+
/// Reads the ordering field out of a returned row and converts it to a UTC DateTimeOffset, for use as the
500+
/// paging window boundary.
501+
/// </summary>
502+
/// <remarks>
503+
/// Two things make this less straightforward than it looks, both caused by sysparm_display_value.
504+
///
505+
/// With sysparm_display_value=all every field is returned as an object of the form
506+
/// { "display_value": ..., "value": ... } rather than a scalar, so calling ToString() on it yields JSON.
507+
/// The raw "value" is preferred here, because it carries the underlying UTC timestamp.
508+
///
509+
/// The value is also parsed with the invariant culture rather than the host's, since a display-formatted
510+
/// date such as 04/08/2026 would otherwise be interpreted differently depending on where the code runs,
511+
/// producing a wrong window rather than an error. AssumeUniversal replaces the previous approach of
512+
/// concatenating "Z" onto the string, which corrupted any value that already carried an offset.
513+
/// </remarks>
514+
private static DateTimeOffset ParsePagingFieldValue(JObject jObject, string orderByField, string tableName)
515+
{
516+
var token = jObject[orderByField];
517+
518+
// sysparm_display_value=all returns { display_value, value }: prefer the raw value.
519+
if (token is JObject valueObject)
520+
{
521+
token = valueObject["value"] ?? valueObject["display_value"];
522+
}
523+
524+
// Newtonsoft recognises ISO-8601 text during deserialisation and converts it to a date value before
525+
// we ever see it. Calling ToString() on that would render it in the HOST's culture and timezone,
526+
// which then reads back wrongly: an en-GB host turns 2026-01-02T05:00:00+05:00 into "02/01/2026
527+
// 07:00:00", which the invariant culture reads as 1 February. Take the value as a date directly.
528+
if (token?.Type == JTokenType.Date)
529+
{
530+
return token.ToObject<DateTimeOffset>().ToUniversalTime();
531+
}
532+
533+
var text = token?.ToString();
534+
535+
return !string.IsNullOrWhiteSpace(text)
536+
&& DateTimeOffset.TryParse(
537+
text,
538+
CultureInfo.InvariantCulture,
539+
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
540+
out var parsed)
541+
? parsed
542+
: throw new ServiceNowApiException(
543+
$"Could not interpret the paging field '{orderByField}' on table '{tableName}' as a date and time. " +
544+
$"The value was '{text ?? "<null>"}'. Paging requires a date/time field, so either set the " +
545+
$"{nameof(Options.PagingFieldName)} option (or the customOrderByField parameter) to one, or use a paged query instead.");
546+
}
547+
499548
private static string? BuildFieldListQueryParameter(List<string>? fieldList)
500549
=> fieldList?.Any() == true ? $"sysparm_fields={HttpUtility.UrlEncode(string.Join(",", fieldList))}" : null;
501550

0 commit comments

Comments
 (0)