Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/Core/Resolvers/MsSqlQueryBuilder.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
));
}

/// <summary>
/// Builds a top-level (non-subquery) result column.
/// A SQL Server native <c>json</c> 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.
/// </summary>
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);
}

/// <summary>
/// Returns true when the given column is backed by a SQL Server native <c>json</c> column.
/// </summary>
private static bool IsJsonColumn(LabelledColumn column, SqlQueryStructure structure)
{
return structure.GetUnderlyingSourceDefinition().Columns.TryGetValue(column.ColumnName, out ColumnDefinition? columnDefinition)
&& columnDefinition.SqlDbType == SqlDbType.Json;
}

/// <summary>
/// Builds the parameter list for the stored procedure execute call
/// paramKeys are the user-generated procedure parameter names
Expand Down
80 changes: 80 additions & 0 deletions src/Service.Tests/OpenApiDocumentor/JsonTypeSchemaTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Validates how a SQL Server 2025 native <c>json</c> column is surfaced in the generated
/// OpenAPI document. DAB treats a json column as a normal <c>string</c> for input and output,
/// so it must be described with <c>type: string</c> and no custom <c>format</c> 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
/// <see cref="SqlTests.RestApiTests.MsSqlRestJsonTypesTests"/>.
/// NOTE: The native JSON data type requires SQL Server 2025 / Azure SQL.
/// </summary>
[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;

/// <summary>
/// The <c>profiles.metadata</c> (json) column must be described as a plain string with no format
/// in the response schema and in both request-body schemas (POST => <c>Profile_NoAutoPK</c>,
/// PUT/PATCH => <c>Profile_NoPK</c>) - 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.)
/// </summary>
[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");
}

/// <summary>
/// Asserts the named component schema exposes <c>metadata</c> as a plain string with no format.
/// </summary>
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}.");
}

/// <summary>
/// Builds an OpenAPI document for a Profile entity sourced from the <c>profiles</c> table,
/// with REST + GraphQL enabled and anonymous/authenticated CRUD permissions.
/// </summary>
private static async Task<OpenApiDocument> 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<string, Entity> { { "Profile", entity } });
return await OpenApiTestBootstrap.GenerateOpenApiDocumentAsync(entities, CONFIG_FILE, DB_ENV);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// GraphQL schema-discovery (introspection) tests for a SQL Server 2025 native <c>json</c> column.
/// DAB does nothing special for a JSON column - it is treated as a normal <c>string</c>, so the
/// GraphQL schema must expose it using the built-in <c>String</c> scalar (no bespoke JSON scalar),
/// honoring the column's nullability. This is the GraphQL counterpart to the REST/OpenAPI coverage
/// in <see cref="RestApiTests.MsSqlRestJsonTypesTests"/> and <c>JsonTypeSchemaTests</c>.
/// NOTE: The native JSON data type requires SQL Server 2025 / Azure SQL.
/// </summary>
[TestClass, TestCategory(TestCategory.MSSQL)]
public class MsSqlGraphQLJsonSchemaTests : SqlTestBase
{
[ClassInitialize]
public static async Task SetupAsync(TestContext context)
{
DatabaseEngine = TestCategory.MSSQL;
await InitializeTestFixture();
}

/// <summary>
/// Introspecting the Profile type must report its json-backed <c>metadata</c> field as the
/// built-in nullable <c>String</c> scalar - proving JSON gets no custom scalar in the schema.
/// </summary>
[TestMethod]
public async Task JsonColumn_IsIntrospectedAsBuiltInStringScalar()
{
string introspectionQuery = @"{
Comment thread
souvikghosh04 marked this conversation as resolved.
__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).");
}

/// <summary>
/// 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.
/// </summary>
[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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,10 @@ namespace Azure.DataApiBuilder.Service.Tests.SqlTests.RestApiTests
{
/// <summary>
/// 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.
Expand All @@ -38,7 +36,7 @@ public static async Task SetupAsync(TestContext context)

/// <summary>
/// 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).
/// </summary>
[TestMethod]
public async Task GetJsonTypeList()
Expand All @@ -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}.");
}
}

Expand Down Expand Up @@ -248,19 +246,19 @@ private static async Task<JsonElement> GetRecordByIdAsync(int id)
}

/// <summary>
/// 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.
/// </summary>
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
Expand Down