diff --git a/src/Core/Resolvers/SqlPaginationUtil.cs b/src/Core/Resolvers/SqlPaginationUtil.cs
index 852138ef09..6b7dce7c90 100644
--- a/src/Core/Resolvers/SqlPaginationUtil.cs
+++ b/src/Core/Resolvers/SqlPaginationUtil.cs
@@ -608,8 +608,7 @@ public static string ConstructBaseUriForPagination(HttpContext httpContext, stri
///
/// Builds a query string by appending or replacing the $after token with the specified value.
///
- /// This method does not include the in the returned query
- /// string. It only processes and formats the query string parameters.
+ /// This method returns only the query string portion (no path or host).
/// A collection of existing query string parameters. If , an empty collection is used.
/// The $after parameter, if present, will be removed before appending the new token.
/// The new value for the $after token. If this value is , empty, or whitespace, no
@@ -638,18 +637,17 @@ public static string BuildQueryStringWithAfterToken(NameValueCollection? querySt
queryString += $"{afterPrefix}{RequestParser.AFTER_URL}={newAfterPayload}";
}
- // Construct final link
- // return $"{path}{queryString}";
return queryString;
}
///
- /// Gets a consolidated next link for pagination in JSON format.
+ /// Returns the next-page link for cursor-based pagination as a
+ /// wrapping a single-element array of the form [ { "nextLink": "..." } ].
///
- /// The base Pagination Uri
- /// The query string with after value
- /// True, if the next link should be relative
- ///
+ /// The base pagination URI.
+ /// The query string with the $after value already merged in.
+ /// True to return only the path + query (no host); false for an absolute URL.
+ /// JsonElement wrapping the next-page URL.
public static JsonElement GetConsolidatedNextLinkForPagination(string baseUri, string queryString, bool isNextLinkRelative = false)
{
UriBuilder uriBuilder = new(baseUri)
@@ -663,12 +661,10 @@ public static JsonElement GetConsolidatedNextLinkForPagination(string baseUri, s
? uriBuilder.Uri.PathAndQuery // returns just "/api/?$after...", no host
: uriBuilder.Uri.AbsoluteUri; // returns full URL
- // Return serialized JSON object
string jsonString = JsonSerializer.Serialize(new[]
{
new { nextLink = nextLinkValue }
});
-
return JsonSerializer.Deserialize(jsonString);
}
diff --git a/src/Core/Resolvers/SqlResponseHelpers.cs b/src/Core/Resolvers/SqlResponseHelpers.cs
index 3736054d7f..1ba0c8b639 100644
--- a/src/Core/Resolvers/SqlResponseHelpers.cs
+++ b/src/Core/Resolvers/SqlResponseHelpers.cs
@@ -22,16 +22,16 @@ public class SqlResponseHelpers
{
///
- /// Format the results from a Find operation. Check if there is a requirement
- /// for a nextLink/after, and if so, add this value to the array of JsonElements to
- /// be used as part of the response.
+ /// Format the results from a Find operation. If a nextLink/after token is required for
+ /// pagination, the envelope is built directly via an anonymous response object so that
+ /// pagination metadata is carried out-of-band rather than encoded into the row collection.
///
- /// The JsonDocument from the query.
+ /// The JsonElement from the query (object for single-row, array for collections).
/// The RequestContext.
/// The metadataprovider.
/// Runtimeconfig object
/// HTTP context associated with the API request
- /// True if request is done through MCP endpoint
+ /// true if invoked from the MCP endpoint (emit after); false or null for REST (emit nextLink).
/// An OkObjectResult from a Find operation that has been correctly formatted.
public static OkObjectResult FormatFindResult(
JsonElement findOperationResponse,
@@ -41,49 +41,48 @@ public static OkObjectResult FormatFindResult(
HttpContext httpContext,
bool? isMcpRequest = null)
{
-
- // When there are no rows returned from the database, the jsonElement will be an empty array.
- // In that case, the response is returned as is.
+ // Empty result set: return the standard envelope { "value": [] } and skip extra-field/cursor work.
if (findOperationResponse.ValueKind is JsonValueKind.Array && findOperationResponse.GetArrayLength() == 0)
{
return OkResponse(findOperationResponse);
}
- HashSet extraFieldsInResponse = (findOperationResponse.ValueKind is not JsonValueKind.Array)
- ? DetermineExtraFieldsInResponse(findOperationResponse, context.FieldsToBeReturned)
- : DetermineExtraFieldsInResponse(findOperationResponse.EnumerateArray().First(), context.FieldsToBeReturned);
+ bool isCollection = findOperationResponse.ValueKind is JsonValueKind.Array;
+
+ // Compute additional fields that were fetched for cursor/$orderby computation but
+ // are not part of $select and so should be stripped from the response payload.
+ JsonElement firstRowProbe = isCollection ? findOperationResponse.EnumerateArray().First() : findOperationResponse;
+ HashSet extraFieldsInResponse = DetermineExtraFieldsInResponse(firstRowProbe, context.FieldsToBeReturned);
uint defaultPageSize = runtimeConfig.DefaultPageSize();
uint maxPageSize = runtimeConfig.MaxPageSize();
+ bool hasNext = isCollection && SqlPaginationUtil.HasNext(findOperationResponse, context.First, defaultPageSize, maxPageSize);
- // If the results are not a collection or if the query does not have a next page
- // no nextLink/after is needed. So, the response is returned after removing the extra fields.
- if (findOperationResponse.ValueKind is not JsonValueKind.Array || !SqlPaginationUtil.HasNext(findOperationResponse, context.First, defaultPageSize, maxPageSize))
+ // No-pagination path: single object, or a collection without a next page.
+ if (!hasNext)
{
- // If there are no additional fields present, the response is returned directly. When there
- // are extra fields, they are removed before returning the response.
if (extraFieldsInResponse.Count == 0)
{
return OkResponse(findOperationResponse);
}
- else
- {
- return findOperationResponse.ValueKind is JsonValueKind.Array ? OkResponse(JsonSerializer.SerializeToElement(RemoveExtraFieldsInResponseWithMultipleItems(findOperationResponse.EnumerateArray().ToList(), extraFieldsInResponse)))
- : OkResponse(RemoveExtraFieldsInResponseWithSingleItem(findOperationResponse, extraFieldsInResponse));
- }
+
+ return isCollection
+ ? OkResponse(JsonSerializer.SerializeToElement(RemoveExtraFieldsInResponseWithMultipleItems(findOperationResponse.EnumerateArray().ToList(), extraFieldsInResponse)))
+ : OkResponse(RemoveExtraFieldsInResponseWithSingleItem(findOperationResponse, extraFieldsInResponse));
}
- List rootEnumerated = findOperationResponse.EnumerateArray().ToList();
+ // Paginated path.
+ List rows = findOperationResponse.EnumerateArray().ToList();
// More records exist than requested, we know this by requesting 1 extra record,
// that extra record is removed here.
- rootEnumerated.RemoveAt(rootEnumerated.Count - 1);
+ rows.RemoveAt(rows.Count - 1);
// The fields such as primary keys, fields in $orderby clause that are retrieved in addition to the
// fields requested in the $select clause are required for calculating the $after element which is part of nextLink.
// So, the extra fields are removed post the calculation of $after element.
string after = SqlPaginationUtil.MakeCursorFromJsonElement(
- element: rootEnumerated[rootEnumerated.Count - 1],
+ element: rows[rows.Count - 1],
orderByColumns: context.OrderByClauseOfBackingColumns,
primaryKey: sqlMetadataProvider.GetSourceDefinition(context.EntityName).PrimaryKey,
entityName: context.EntityName,
@@ -94,40 +93,38 @@ public static OkObjectResult FormatFindResult(
// When there are extra fields present, they are removed before returning the response.
if (extraFieldsInResponse.Count > 0)
{
- rootEnumerated = RemoveExtraFieldsInResponseWithMultipleItems(rootEnumerated, extraFieldsInResponse);
+ rows = RemoveExtraFieldsInResponseWithMultipleItems(rows, extraFieldsInResponse);
}
- // Create an 'after' object if the request comes from MCP endpoint.
+ // MCP endpoint: { value: [...], after: "" }
if (isMcpRequest is true)
{
- string jsonString = JsonSerializer.Serialize(new[]
+ return new OkObjectResult(new
{
- new { after = after }
+ value = rows,
+ after = after
});
- JsonElement afterElement = JsonSerializer.Deserialize(jsonString);
-
- rootEnumerated.Add(afterElement);
}
- // Create a 'nextLink' object if the request comes from REST endpoint.
- else
- {
- string basePaginationUri = SqlPaginationUtil.ConstructBaseUriForPagination(httpContext, runtimeConfig.Runtime?.BaseRoute);
-
- // Build the query string with the $after token.
- string queryString = SqlPaginationUtil.BuildQueryStringWithAfterToken(
- queryStringParameters: context!.ParsedQueryString,
- newAfterPayload: after);
- // Get the final consolidated nextLink for the pagination.
- JsonElement nextLink = SqlPaginationUtil.GetConsolidatedNextLinkForPagination(
- baseUri: basePaginationUri,
- queryString: queryString,
- isNextLinkRelative: runtimeConfig.NextLinkRelative());
-
- rootEnumerated.Add(nextLink);
- }
+ // REST endpoint: { value: [...], nextLink: "" }
+ string basePaginationUri = SqlPaginationUtil.ConstructBaseUriForPagination(httpContext, runtimeConfig.Runtime?.BaseRoute);
+ string queryString = SqlPaginationUtil.BuildQueryStringWithAfterToken(
+ queryStringParameters: context.ParsedQueryString,
+ newAfterPayload: after);
+ UriBuilder uriBuilder = new(basePaginationUri)
+ {
+ // Form final link by appending the query string
+ Query = queryString
+ };
+ string nextLink = runtimeConfig.NextLinkRelative()
+ ? uriBuilder.Uri.PathAndQuery // returns just "/api/?$after...", no host
+ : uriBuilder.Uri.AbsoluteUri; // returns full URL
- return OkResponse(JsonSerializer.SerializeToElement(rootEnumerated), isMcpRequest);
+ return new OkObjectResult(new
+ {
+ value = rows,
+ nextLink = nextLink
+ });
}
///
@@ -139,9 +136,17 @@ public static OkObjectResult FormatFindResult(
///
/// Response json retrieved from the database
/// List of fields to be returned in the response.
- /// Additional fields that are present in the response
+ /// Additional fields that are present in the response. Returns an empty set when is not a JSON object (e.g. a scalar or array-typed row value), since there are no named properties to filter.
private static HashSet DetermineExtraFieldsInResponse(JsonElement response, List fieldsToBeReturned)
{
+ // Guard: a result row is normally a JSON object, but with database engines that can return
+ // array/scalar/collection-typed shapes at the row level there is nothing to enumerate. In that
+ // case there are no extra-field columns to strip, so return an empty set rather than throwing.
+ if (response.ValueKind is not JsonValueKind.Object)
+ {
+ return new HashSet();
+ }
+
HashSet fieldsPresentInResponse = new();
foreach (JsonProperty property in response.EnumerateObject())
@@ -200,60 +205,34 @@ private static JsonElement RemoveExtraFieldsInResponseWithSingleItem(JsonElement
}
///
- /// Helper function returns an OkObjectResult with provided arguments in a
- /// form that complies with vNext Api guidelines.
+ /// Helper function returns an OkObjectResult that wraps a single JsonElement (object or array)
+ /// into the standard { "value": [ ... ] } envelope used by REST/MCP responses.
+ ///
+ /// Pagination metadata (nextLink/after) is intentionally NOT inferred from the
+ /// shape of . attaches those fields
+ /// out-of-band when needed. This avoids confusing array-typed column values (e.g. SQL Server
+ /// JSON arrays, vector/collection types) with a pagination sentinel.
+ ///
+ /// is accepted for source compatibility with prior versions
+ /// of Microsoft.DataApiBuilder.Core but is no longer used: the envelope shape produced
+ /// here is identical for REST and MCP, and pagination metadata is built by
+ /// .
///
/// Value representing the Json results of the client's request.
- /// True if request is done through MCP endpoint.
+ /// Unused; preserved for backwards-compatible call sites.
/// Correctly formatted OkObjectResult.
public static OkObjectResult OkResponse(JsonElement jsonResult, bool? isMcpRequest = null)
{
- // For consistency we return all values as type Array
- if (jsonResult.ValueKind != JsonValueKind.Array)
- {
- string jsonString = $"[{JsonSerializer.Serialize(jsonResult)}]";
- jsonResult = JsonSerializer.Deserialize(jsonString);
- }
+ _ = isMcpRequest; // intentionally unused; kept for source compatibility.
- List resultEnumerated = jsonResult.EnumerateArray().ToList();
- // More than 0 records, and the last element is of type array, then we have pagination
- if (resultEnumerated.Count > 0 && resultEnumerated[resultEnumerated.Count - 1].ValueKind == JsonValueKind.Array)
- {
- // Get the 'nextLink' or 'after'
- // resultEnumerated will be an array of the form
- // [{object1}, {object2},...{objectlimit}, [{nextLinkObject/afterObject}]]
- // if the last element is of type array, we know it is 'nextLink'
- // if the request is done through the REST endpoint and it is
- // 'after' if the request is done through the MCP endpoint,
- // we strip the "[" and "]" and then save the element
- // into a dictionary with a key of "nextLinkAfter" and a value that
- // represents the nextLink/after data we require.
- string nextLinkAfterJsonString = JsonSerializer.Serialize(resultEnumerated[resultEnumerated.Count - 1]);
- Dictionary nextLinkAfter = JsonSerializer.Deserialize>(nextLinkAfterJsonString[1..^1])!;
- IEnumerable value = resultEnumerated.Take(resultEnumerated.Count - 1);
-
- // Check 'after' object if request is done through MCP endpoint.
- if (isMcpRequest is true)
- {
- return new OkObjectResult(new
- {
- value = value,
- after = nextLinkAfter["after"]
- });
- }
-
- // Check 'nextLink' object if request is done through REST endpoint.
- return new OkObjectResult(new
- {
- value = value,
- @nextLink = nextLinkAfter["nextLink"]
- });
- }
+ // For consistency we always return the payload as an array under "value".
+ List rows = jsonResult.ValueKind is JsonValueKind.Array
+ ? jsonResult.EnumerateArray().ToList()
+ : new List { jsonResult };
- // no pagination, do not need nextLink
return new OkObjectResult(new
{
- value = resultEnumerated
+ value = rows
});
}
diff --git a/src/Service.Tests/UnitTests/SqlResponseHelpersUnitTests.cs b/src/Service.Tests/UnitTests/SqlResponseHelpersUnitTests.cs
new file mode 100644
index 0000000000..1cfee324c6
--- /dev/null
+++ b/src/Service.Tests/UnitTests/SqlResponseHelpersUnitTests.cs
@@ -0,0 +1,340 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Text.Json;
+using Azure.DataApiBuilder.Config.DatabasePrimitives;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Models;
+using Azure.DataApiBuilder.Core.Resolvers;
+using Azure.DataApiBuilder.Core.Services;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Focused unit tests for .
+ ///
+ /// Tests 1-5 pin the response envelope shape across each path the method takes:
+ /// (1) empty result, (2) single object, (3) collection without next page,
+ /// (4) collection with next page (REST), (5) collection with next page (MCP).
+ ///
+ /// Test 6 documents that rows containing array-valued columns (the shape enabled by
+ /// SQL Server's JSON/vector types) round-trip through the response pipeline unchanged.
+ ///
+ /// Test 7 is the load-bearing regression guard for the shape-sentinel removal:
+ /// it pins that a result whose last top-level element is itself a JSON array — the
+ /// exact trigger the pre-refactor used to
+ /// detect a pagination sentinel — is now correctly treated as ordinary data.
+ ///
+ [TestClass]
+ public class SqlResponseHelpersUnitTests
+ {
+ private const string ENTITY_NAME = "Book";
+
+ #region Tests
+
+ ///
+ /// An empty result set returns the standard envelope { "value": [] } and no
+ /// pagination metadata.
+ ///
+ [TestMethod]
+ public void FormatFindResult_EmptyArray_ReturnsValueOnlyEnvelope()
+ {
+ JsonElement input = ParseJson("[]");
+ FindRequestContext context = CreateContext(fieldsToBeReturned: new List());
+
+ OkObjectResult result = SqlResponseHelpers.FormatFindResult(
+ findOperationResponse: input,
+ context: context,
+ sqlMetadataProvider: Mock.Of(),
+ runtimeConfig: CreateRuntimeConfig(),
+ httpContext: new DefaultHttpContext());
+
+ JsonElement envelope = SerializeValue(result);
+ AssertHasNoPaginationFields(envelope);
+ Assert.AreEqual(0, envelope.GetProperty("value").GetArrayLength());
+ }
+
+ ///
+ /// A single-object result (FindById) is wrapped into { "value": [ { ... } ] }
+ /// with no pagination metadata.
+ ///
+ [TestMethod]
+ public void FormatFindResult_SingleObject_ReturnsValueOnlyEnvelope()
+ {
+ JsonElement input = ParseJson(@"{ ""id"": 1, ""title"": ""Dune"" }");
+ FindRequestContext context = CreateContext(fieldsToBeReturned: new List { "id", "title" });
+
+ OkObjectResult result = SqlResponseHelpers.FormatFindResult(
+ findOperationResponse: input,
+ context: context,
+ sqlMetadataProvider: Mock.Of(),
+ runtimeConfig: CreateRuntimeConfig(),
+ httpContext: new DefaultHttpContext());
+
+ JsonElement envelope = SerializeValue(result);
+ AssertHasNoPaginationFields(envelope);
+
+ JsonElement value = envelope.GetProperty("value");
+ Assert.AreEqual(1, value.GetArrayLength());
+ Assert.AreEqual(1, value[0].GetProperty("id").GetInt32());
+ Assert.AreEqual("Dune", value[0].GetProperty("title").GetString());
+ }
+
+ ///
+ /// A collection result that does NOT exceed $first has no next page; the envelope
+ /// is { "value": [ ... ] } with no pagination metadata.
+ ///
+ [TestMethod]
+ public void FormatFindResult_CollectionWithoutNextPage_ReturnsValueOnlyEnvelope()
+ {
+ // first = 5, only 2 rows: HasNext is false.
+ JsonElement input = ParseJson(@"[ { ""id"": 1 }, { ""id"": 2 } ]");
+ FindRequestContext context = CreateContext(
+ fieldsToBeReturned: new List { "id" },
+ first: 5);
+
+ OkObjectResult result = SqlResponseHelpers.FormatFindResult(
+ findOperationResponse: input,
+ context: context,
+ sqlMetadataProvider: Mock.Of(),
+ runtimeConfig: CreateRuntimeConfig(),
+ httpContext: new DefaultHttpContext());
+
+ JsonElement envelope = SerializeValue(result);
+ AssertHasNoPaginationFields(envelope);
+ Assert.AreEqual(2, envelope.GetProperty("value").GetArrayLength());
+ }
+
+ ///
+ /// REST: a collection with more rows than requested produces the
+ /// { "value": [ ... ], "nextLink": "..." } envelope and trims the +1 probe row.
+ ///
+ [TestMethod]
+ public void FormatFindResult_CollectionWithNextPage_Rest_ReturnsNextLinkEnvelope()
+ {
+ // first = 1, 2 rows: HasNext is true, last (probe) row is dropped.
+ JsonElement input = ParseJson(@"[ { ""id"": 1 }, { ""id"": 2 } ]");
+ FindRequestContext context = CreateContext(
+ fieldsToBeReturned: new List { "id" },
+ first: 1);
+
+ DefaultHttpContext httpContext = new();
+ httpContext.Request.Scheme = "https";
+ httpContext.Request.Host = new HostString("localhost");
+ httpContext.Request.Path = "/api/Book";
+
+ OkObjectResult result = SqlResponseHelpers.FormatFindResult(
+ findOperationResponse: input,
+ context: context,
+ sqlMetadataProvider: CreateMetadataProviderWithIdPrimaryKey(),
+ runtimeConfig: CreateRuntimeConfig(),
+ httpContext: httpContext);
+
+ JsonElement envelope = SerializeValue(result);
+ JsonElement value = envelope.GetProperty("value");
+ Assert.AreEqual(1, value.GetArrayLength(), "Probe row should have been removed.");
+ Assert.AreEqual(1, value[0].GetProperty("id").GetInt32());
+
+ Assert.IsTrue(envelope.TryGetProperty("nextLink", out JsonElement nextLink),
+ "REST paginated response must carry a 'nextLink' field.");
+ Assert.IsTrue(nextLink.GetString()!.Contains("$after="), "nextLink should encode the $after cursor.");
+ Assert.IsFalse(envelope.TryGetProperty("after", out _),
+ "REST paginated response must NOT carry an 'after' field.");
+ }
+
+ ///
+ /// MCP: a collection with more rows than requested produces the
+ /// { "value": [ ... ], "after": "..." } envelope and trims the +1 probe row.
+ ///
+ [TestMethod]
+ public void FormatFindResult_CollectionWithNextPage_Mcp_ReturnsAfterEnvelope()
+ {
+ JsonElement input = ParseJson(@"[ { ""id"": 1 }, { ""id"": 2 } ]");
+ FindRequestContext context = CreateContext(
+ fieldsToBeReturned: new List { "id" },
+ first: 1);
+
+ OkObjectResult result = SqlResponseHelpers.FormatFindResult(
+ findOperationResponse: input,
+ context: context,
+ sqlMetadataProvider: CreateMetadataProviderWithIdPrimaryKey(),
+ runtimeConfig: CreateRuntimeConfig(),
+ httpContext: new DefaultHttpContext(),
+ isMcpRequest: true);
+
+ JsonElement envelope = SerializeValue(result);
+ JsonElement value = envelope.GetProperty("value");
+ Assert.AreEqual(1, value.GetArrayLength(), "Probe row should have been removed.");
+
+ Assert.IsTrue(envelope.TryGetProperty("after", out JsonElement after),
+ "MCP paginated response must carry an 'after' field.");
+ Assert.IsFalse(string.IsNullOrEmpty(after.GetString()), "after cursor should be populated.");
+ Assert.IsFalse(envelope.TryGetProperty("nextLink", out _),
+ "MCP paginated response must NOT carry a 'nextLink' field.");
+ }
+
+ ///
+ /// Pins that rows containing array-valued columns (e.g. a SQL Server JSON array, vector,
+ /// or other collection-typed column) round-trip through the response pipeline unchanged.
+ /// This is forward-looking coverage for query shapes enabled by SQL Server's JSON/vector
+ /// types: the array values live inside object-shaped rows, so this case did not actually
+ /// trigger the pre-refactor shape sentinel — but it documents the supported shape and
+ /// guards against future regressions in extra-field stripping or envelope construction.
+ ///
+ [TestMethod]
+ public void FormatFindResult_RowWithArrayColumn_RoundTripsUnchanged()
+ {
+ // Two rows with array-valued "tags" column. first=5 so HasNext=false.
+ JsonElement input = ParseJson(@"[
+ { ""id"": 1, ""tags"": [ ""sci-fi"", ""classic"" ] },
+ { ""id"": 2, ""tags"": [ ""fantasy"" ] }
+ ]");
+ FindRequestContext context = CreateContext(
+ fieldsToBeReturned: new List { "id", "tags" },
+ first: 5);
+
+ OkObjectResult result = SqlResponseHelpers.FormatFindResult(
+ findOperationResponse: input,
+ context: context,
+ sqlMetadataProvider: Mock.Of(),
+ runtimeConfig: CreateRuntimeConfig(),
+ httpContext: new DefaultHttpContext());
+
+ JsonElement envelope = SerializeValue(result);
+ AssertHasNoPaginationFields(envelope);
+
+ JsonElement value = envelope.GetProperty("value");
+ Assert.AreEqual(2, value.GetArrayLength(), "Both rows must be returned, including the array-valued column.");
+
+ // Pin that the array column survived intact and the row count is correct.
+ Assert.AreEqual(2, value[0].GetProperty("tags").GetArrayLength());
+ Assert.AreEqual("sci-fi", value[0].GetProperty("tags")[0].GetString());
+ Assert.AreEqual(1, value[1].GetProperty("tags").GetArrayLength());
+ Assert.AreEqual("fantasy", value[1].GetProperty("tags")[0].GetString());
+ }
+
+ ///
+ /// Regression guard for the actual shape-sentinel failure mode: when the result list's
+ /// last top-level element is itself a JSON array (a non-object row, as could be produced
+ /// by future query shapes that project array-typed values at the row level), the response
+ /// must be returned verbatim under value. Pre-refactor,
+ /// inspected JsonValueKind.Array on the last element and would have attempted to
+ /// unpack it as a { "nextLink" } / { "after" } sentinel, producing an
+ /// incorrect envelope. With shape-based detection removed, the array element is now
+ /// correctly treated as ordinary data.
+ ///
+ [TestMethod]
+ public void FormatFindResult_TopLevelArrayTailRow_IsNotMisclassifiedAsPaginationSentinel()
+ {
+ // Last top-level element is a JSON array — the exact shape the old in-band sentinel
+ // detection used as its trigger. first=10 keeps HasNext=false so the no-pagination
+ // path is taken; without the refactor, OkResponse would have misfired here.
+ JsonElement input = ParseJson(@"[
+ { ""id"": 1 },
+ { ""id"": 2 },
+ [ 1, 2, 3 ]
+ ]");
+ FindRequestContext context = CreateContext(
+ fieldsToBeReturned: new List { "id" },
+ first: 10);
+
+ OkObjectResult result = SqlResponseHelpers.FormatFindResult(
+ findOperationResponse: input,
+ context: context,
+ sqlMetadataProvider: Mock.Of(),
+ runtimeConfig: CreateRuntimeConfig(),
+ httpContext: new DefaultHttpContext());
+
+ JsonElement envelope = SerializeValue(result);
+ AssertHasNoPaginationFields(envelope);
+
+ JsonElement value = envelope.GetProperty("value");
+ Assert.AreEqual(3, value.GetArrayLength(),
+ "All three top-level elements must be preserved; the trailing array must NOT be unpacked as a pagination sentinel.");
+ Assert.AreEqual(JsonValueKind.Object, value[0].ValueKind);
+ Assert.AreEqual(JsonValueKind.Object, value[1].ValueKind);
+ Assert.AreEqual(JsonValueKind.Array, value[2].ValueKind);
+ Assert.AreEqual(3, value[2].GetArrayLength());
+ }
+
+ #endregion
+
+ #region Helpers
+
+ private static JsonElement ParseJson(string json)
+ {
+ return JsonDocument.Parse(json).RootElement.Clone();
+ }
+
+ ///
+ /// Serializes the OkObjectResult.Value (an anonymous envelope object) to JSON and
+ /// returns it as a JsonElement so individual fields can be asserted.
+ ///
+ private static JsonElement SerializeValue(OkObjectResult result)
+ {
+ string json = JsonSerializer.Serialize(result.Value);
+ return JsonDocument.Parse(json).RootElement.Clone();
+ }
+
+ private static void AssertHasNoPaginationFields(JsonElement envelope)
+ {
+ Assert.IsFalse(envelope.TryGetProperty("nextLink", out _), "Envelope unexpectedly contains 'nextLink'.");
+ Assert.IsFalse(envelope.TryGetProperty("after", out _), "Envelope unexpectedly contains 'after'.");
+ }
+
+ private static FindRequestContext CreateContext(List fieldsToBeReturned, int? first = null)
+ {
+ SourceDefinition sourceDef = new() { PrimaryKey = new List { "id" } };
+ sourceDef.SourceEntityRelationshipMap.Add(ENTITY_NAME, new());
+ DatabaseObject dbObject = new DatabaseTable(schemaName: "dbo", tableName: ENTITY_NAME)
+ {
+ TableDefinition = sourceDef
+ };
+
+ FindRequestContext context = new(entityName: ENTITY_NAME, dbo: dbObject, isList: true)
+ {
+ First = first,
+ FieldsToBeReturned = fieldsToBeReturned
+ };
+ return context;
+ }
+
+ ///
+ /// Builds a metadata provider that maps any (entity, "id") to the exposed column "id"
+ /// so that can produce a cursor.
+ ///
+ private static ISqlMetadataProvider CreateMetadataProviderWithIdPrimaryKey()
+ {
+ Mock mock = new();
+
+ SourceDefinition sourceDef = new() { PrimaryKey = new List { "id" } };
+ mock.Setup(m => m.GetSourceDefinition(It.IsAny())).Returns(sourceDef);
+
+ string exposedName = "id";
+ mock.Setup(m => m.TryGetExposedColumnName(It.IsAny(), It.IsAny(), out exposedName))
+ .Returns(true);
+
+ return mock.Object;
+ }
+
+ private static RuntimeConfig CreateRuntimeConfig()
+ {
+ return new RuntimeConfig(
+ Schema: "test-schema",
+ DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null),
+ Entities: new RuntimeEntities(new Dictionary()),
+ Runtime: new RuntimeOptions(
+ Rest: new(),
+ GraphQL: new(),
+ Mcp: null,
+ Host: new(Cors: null, Authentication: null, Mode: HostMode.Development)));
+ }
+
+ #endregion
+ }
+}