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