diff --git a/src/Core/Resolvers/MsSqlQueryBuilder.cs b/src/Core/Resolvers/MsSqlQueryBuilder.cs
index 7769dcf94b..118857e701 100644
--- a/src/Core/Resolvers/MsSqlQueryBuilder.cs
+++ b/src/Core/Resolvers/MsSqlQueryBuilder.cs
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
+using System.Data;
using System.Data.Common;
using System.Text;
using System.Text.RegularExpressions;
@@ -456,10 +457,36 @@ private string WrappedColumns(SqlQueryStructure structure)
structure.Columns.Select(
c => structure.IsSubqueryColumn(c) ?
WrapSubqueryColumn(c, structure.JoinQueries[c.TableAlias!]) + $" AS {QuoteIdentifier(c.Label)}" :
- Build(c)
+ BuildResultColumn(c, structure)
));
}
+ ///
+ /// Builds a top-level (non-subquery) result column.
+ /// A SQL Server native json column is cast to NVARCHAR(MAX) so that the trailing
+ /// FOR JSON PATH clause emits it as an escaped JSON string instead of inlining it as a nested
+ /// JSON value. DAB treats a json column as a normal string, so its raw JSON text must
+ /// round-trip as a string at the REST/GraphQL boundary.
+ ///
+ private string BuildResultColumn(LabelledColumn column, SqlQueryStructure structure)
+ {
+ if (IsJsonColumn(column, structure))
+ {
+ return $"CAST({Build(column as Column)} AS NVARCHAR(MAX)) AS {QuoteIdentifier(column.Label)}";
+ }
+
+ return Build(column);
+ }
+
+ ///
+ /// Returns true when the given column is backed by a SQL Server native json column.
+ ///
+ private static bool IsJsonColumn(LabelledColumn column, SqlQueryStructure structure)
+ {
+ return structure.GetUnderlyingSourceDefinition().Columns.TryGetValue(column.ColumnName, out ColumnDefinition? columnDefinition)
+ && columnDefinition.SqlDbType == SqlDbType.Json;
+ }
+
///
/// Builds the parameter list for the stored procedure execute call
/// paramKeys are the user-generated procedure parameter names
diff --git a/src/Service.Tests/OpenApiDocumentor/JsonTypeSchemaTests.cs b/src/Service.Tests/OpenApiDocumentor/JsonTypeSchemaTests.cs
new file mode 100644
index 0000000000..914a63cef7
--- /dev/null
+++ b/src/Service.Tests/OpenApiDocumentor/JsonTypeSchemaTests.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Microsoft.OpenApi.Models;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.OpenApiIntegration
+{
+ ///
+ /// Validates how a SQL Server 2025 native json column is surfaced in the generated
+ /// OpenAPI document. DAB treats a json column as a normal string for input and output,
+ /// so it must be described with type: string and no custom format in the response
+ /// schema as well as the POST / PUT / PATCH request-body schemas. This is the schema-discovery
+ /// counterpart to the REST round-trip coverage in
+ /// .
+ /// NOTE: The native JSON data type requires SQL Server 2025 / Azure SQL.
+ ///
+ [TestCategory(TestCategory.MSSQL)]
+ [TestClass]
+ public class JsonTypeSchemaTests
+ {
+ private const string CONFIG_FILE = "json-type-openapi-config.MsSql.json";
+ private const string DB_ENV = TestCategory.MSSQL;
+
+ ///
+ /// The profiles.metadata (json) column must be described as a plain string with no format
+ /// in the response schema and in both request-body schemas (POST => Profile_NoAutoPK,
+ /// PUT/PATCH => Profile_NoPK) - proving json is treated like a normal string for both input
+ /// and output, with no bespoke scalar/format. (DAB does not express column nullability on the
+ /// property schema for any type, so that is not asserted here.)
+ ///
+ [TestMethod]
+ public async Task JsonColumn_IsDescribedAsStringWithoutFormat_InResponseAndRequestBodies()
+ {
+ OpenApiDocument doc = await GenerateProfileDocumentAsync();
+
+ // Response body schema, plus the POST and PUT/PATCH request-body schemas.
+ AssertMetadataIsPlainString(doc, "Profile");
+ AssertMetadataIsPlainString(doc, "Profile_NoAutoPK");
+ AssertMetadataIsPlainString(doc, "Profile_NoPK");
+ }
+
+ ///
+ /// Asserts the named component schema exposes metadata as a plain string with no format.
+ ///
+ private static void AssertMetadataIsPlainString(OpenApiDocument doc, string schemaName)
+ {
+ Assert.IsTrue(doc.Components.Schemas.ContainsKey(schemaName), $"Schema {schemaName} should exist.");
+
+ OpenApiSchema schema = doc.Components.Schemas[schemaName];
+ Assert.IsTrue(schema.Properties.ContainsKey("metadata"), $"The json metadata column should be present in {schemaName}.");
+
+ OpenApiSchema metadataSchema = schema.Properties["metadata"];
+ Assert.AreEqual("string", metadataSchema.Type, $"A json column must be described as a plain string in {schemaName}.");
+ Assert.IsTrue(string.IsNullOrEmpty(metadataSchema.Format), $"A json column must not carry a bespoke OpenAPI format in {schemaName}.");
+ }
+
+ ///
+ /// Builds an OpenAPI document for a Profile entity sourced from the profiles table,
+ /// with REST + GraphQL enabled and anonymous/authenticated CRUD permissions.
+ ///
+ private static async Task GenerateProfileDocumentAsync()
+ {
+ Entity entity = new(
+ Source: new("profiles", EntitySourceType.Table, null, null),
+ Fields: null,
+ GraphQL: new("Profile", "Profiles", true),
+ Rest: new(EntityRestOptions.DEFAULT_SUPPORTED_VERBS),
+ Permissions: OpenApiTestBootstrap.CreateBasicPermissions(),
+ Mappings: null,
+ Relationships: null);
+
+ RuntimeEntities entities = new(new Dictionary { { "Profile", entity } });
+ return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV);
+ }
+ }
+}
diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLJsonSchemaTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLJsonSchemaTests.cs
new file mode 100644
index 0000000000..66ee6156a7
--- /dev/null
+++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLJsonSchemaTests.cs
@@ -0,0 +1,86 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLQueryTests
+{
+ ///
+ /// GraphQL schema-discovery (introspection) tests for a SQL Server 2025 native json column.
+ /// DAB does nothing special for a JSON column - it is treated as a normal string, so the
+ /// GraphQL schema must expose it using the built-in String scalar (no bespoke JSON scalar),
+ /// honoring the column's nullability. This is the GraphQL counterpart to the REST/OpenAPI coverage
+ /// in and JsonTypeSchemaTests.
+ /// NOTE: The native JSON data type requires SQL Server 2025 / Azure SQL.
+ ///
+ [TestClass, TestCategory(TestCategory.MSSQL)]
+ public class MsSqlGraphQLJsonSchemaTests : SqlTestBase
+ {
+ [ClassInitialize]
+ public static async Task SetupAsync(TestContext context)
+ {
+ DatabaseEngine = TestCategory.MSSQL;
+ await InitializeTestFixture();
+ }
+
+ ///
+ /// Introspecting the Profile type must report its json-backed metadata field as the
+ /// built-in nullable String scalar - proving JSON gets no custom scalar in the schema.
+ ///
+ [TestMethod]
+ public async Task JsonColumn_IsIntrospectedAsBuiltInStringScalar()
+ {
+ string introspectionQuery = @"{
+ __type(name: ""Profile"") {
+ name
+ fields {
+ name
+ type { kind name ofType { kind name } }
+ }
+ }
+ }";
+
+ JsonElement type = await ExecuteGraphQLRequestAsync(introspectionQuery, "__type", isAuthenticated: false);
+
+ Assert.AreEqual("Profile", type.GetProperty("name").GetString(), "Introspection should resolve the Profile GraphQL type.");
+
+ JsonElement metadataField = type.GetProperty("fields").EnumerateArray()
+ .Single(f => f.GetProperty("name").GetString() == "metadata");
+
+ // A nullable column surfaces as the bare scalar (no NON_NULL wrapper), so type.kind/name
+ // describe the scalar directly.
+ JsonElement metadataType = metadataField.GetProperty("type");
+ Assert.AreEqual("SCALAR", metadataType.GetProperty("kind").GetString(), "A json column must map to a scalar, not a custom object/scalar type.");
+ Assert.AreEqual("String", metadataType.GetProperty("name").GetString(), "A json column must use the built-in String scalar (no bespoke JSON scalar).");
+ }
+
+ ///
+ /// profile_by_pk(id: 1) { metadata } - Verify that reading the json-backed field through GraphQL
+ /// succeeds and returns the payload as a JSON string. The String leaf resolver calls
+ /// JsonElement.GetString(), which only works because the engine casts the json column to
+ /// NVARCHAR(MAX) so it is emitted as an escaped string rather than an inlined JSON object.
+ /// This guards against the introspection test passing while a real read throws a GraphQLMapping error.
+ ///
+ [TestMethod]
+ public async Task JsonColumn_GraphQLRead_ReturnsPayloadAsString()
+ {
+ string query = @"{
+ profile_by_pk(id: 1) {
+ metadata
+ }
+ }";
+
+ JsonElement result = await ExecuteGraphQLRequestAsync(query, "profile_by_pk", isAuthenticated: false);
+
+ JsonElement metadata = result.GetProperty("metadata");
+ Assert.AreEqual(JsonValueKind.String, metadata.ValueKind, "A json column must be returned as a JSON string through GraphQL (treated as a normal string).");
+
+ JsonElement parsed = JsonDocument.Parse(metadata.GetString()!).RootElement;
+ Assert.AreEqual("admin", parsed.GetProperty("role").GetString());
+ Assert.AreEqual(3, parsed.GetProperty("tier").GetInt32());
+ }
+ }
+}
diff --git a/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestJsonTypesTests.cs b/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestJsonTypesTests.cs
index 8ead7d4d15..8e8c20417d 100644
--- a/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestJsonTypesTests.cs
+++ b/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestJsonTypesTests.cs
@@ -12,12 +12,10 @@ namespace Azure.DataApiBuilder.Service.Tests.SqlTests.RestApiTests
{
///
/// Tests for SQL Server native JSON column support via REST endpoints (read and write).
- /// DAB does nothing special for a JSON column - it is written from the request payload and
- /// read back with no custom handling, new scalar, or format. On read, SQL Server's
- /// FOR JSON PATH projection (which DAB uses to shape every result) inlines a native json
- /// column as a nested JSON value, so the metadata surfaces at the REST boundary as a JSON
- /// object rather than an escaped string. Writes still supply the payload as a JSON string,
- /// exactly as a normal string column would be written.
+ /// DAB treats a JSON column exactly like a string: the raw JSON text is written from the
+ /// request payload and read back as a JSON string (the MSSQL query builder casts the native
+ /// json column to NVARCHAR(MAX) so FOR JSON PATH emits it as an escaped string rather than
+ /// inlining it as a nested object). No new scalar or format is involved.
/// Assertions compare the returned metadata semantically so they are robust to any
/// whitespace / key-order normalization the engine applies to the JSON type.
/// NOTE: The native JSON data type requires SQL Server 2025 / Azure SQL.
@@ -38,7 +36,7 @@ public static async Task SetupAsync(TestContext context)
///
/// GET /api/Profile - Verify the whole collection (5 seeded rows) is returned and that
- /// each metadata value renders either as a native JSON object payload or null (row 5).
+ /// each metadata value renders either as a JSON string payload or null (row 5).
///
[TestMethod]
public async Task GetJsonTypeList()
@@ -50,13 +48,13 @@ public async Task GetJsonTypeList()
.RootElement.GetProperty("value");
Assert.AreEqual(5, items.GetArrayLength(), "Expected the 5 seeded profile rows.");
- // Rows 1-4 carry a JSON payload (inlined as a native JSON object); row 5 is null.
+ // Rows 1-4 carry a JSON payload (returned as an escaped JSON string); row 5 is null.
foreach (JsonElement record in items.EnumerateArray())
{
JsonValueKind metadataKind = record.GetProperty("metadata").ValueKind;
Assert.IsTrue(
- metadataKind is JsonValueKind.Object or JsonValueKind.Null,
- $"Expected metadata to be a JSON object or null, but was {metadataKind}.");
+ metadataKind is JsonValueKind.String or JsonValueKind.Null,
+ $"Expected metadata to be a JSON string or null, but was {metadataKind}.");
}
}
@@ -248,19 +246,19 @@ private static async Task GetRecordByIdAsync(int id)
}
///
- /// Returns the metadata field as a JSON element. DAB applies no special handling to a JSON
- /// column, so SQL Server's FOR JSON PATH projection inlines it as a native JSON object at the
- /// REST boundary. This helper asserts the value is a JSON object and returns it for inspection.
+ /// Returns the metadata field parsed as a JSON element. DAB treats a JSON column as a string,
+ /// so a non-null metadata value arrives at the REST boundary as a JSON string carrying the
+ /// payload. This helper asserts that and parses the string payload for semantic inspection.
///
private static JsonElement ParseMetadata(JsonElement record)
{
JsonElement metadata = record.GetProperty("metadata");
Assert.AreEqual(
- JsonValueKind.Object,
+ JsonValueKind.String,
metadata.ValueKind,
- "A native JSON column is inlined as a JSON object at the REST boundary via FOR JSON PATH.");
+ "A json column must be returned as a JSON string at the REST boundary (treated as a normal string).");
- return metadata.Clone();
+ return JsonDocument.Parse(metadata.GetString()!).RootElement.Clone();
}
#endregion