diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index 99e138b846..df03b27a31 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -18,6 +18,8 @@ add WebsiteUser_MM --config "dab-config.MsSql.json" --source website_users_mm -- add SupportedType --config "dab-config.MsSql.json" --source type_table --permissions "anonymous:create,read,delete,update" add VectorType --config "dab-config.MsSql.json" --source vector_type_table --rest true --graphql false --permissions "anonymous:create,read,delete,update" update VectorType --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update" +add Profile --config "dab-config.MsSql.json" --source profiles --rest true --graphql true --permissions "anonymous:create,read,delete,update" +update Profile --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update" add stocks_price --config "dab-config.MsSql.json" --source stocks_price --permissions "authenticated:create,read,update,delete" update stocks_price --config "dab-config.MsSql.json" --permissions "anonymous:read" update stocks_price --config "dab-config.MsSql.json" --permissions "TestNestedFilterFieldIsNull_ColumnForbidden:read" --fields.exclude "price" diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 39c796e3dc..c34f961aab 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -41,6 +41,7 @@ DROP TABLE IF EXISTS comics; DROP TABLE IF EXISTS brokers; DROP TABLE IF EXISTS type_table; DROP TABLE IF EXISTS vector_type_table; +DROP TABLE IF EXISTS profiles; DROP TABLE IF EXISTS trees; DROP TABLE IF EXISTS fungi; DROP TABLE IF EXISTS empty_table; @@ -241,6 +242,11 @@ CREATE TABLE vector_type_table( vector_data_max vector(1998) ); +CREATE TABLE profiles( + id int IDENTITY(5001, 1) PRIMARY KEY, + metadata json NULL +); + CREATE TABLE trees ( treeId int PRIMARY KEY, species varchar(max), @@ -640,6 +646,16 @@ VALUES (7, CAST('[' + ( ) + ']' AS vector(1998))); SET IDENTITY_INSERT vector_type_table OFF +SET IDENTITY_INSERT profiles ON +INSERT INTO profiles(id, metadata) +VALUES + (1, N'{"role":"admin","tier":3}'), + (2, N'{"tags":["a","b","c"]}'), + (3, N'{"nested":{"key":{"deep":true}}}'), + (4, N'{"unicode":"éü😀"}'), + (5, NULL); +SET IDENTITY_INSERT profiles OFF + SET IDENTITY_INSERT sales ON INSERT INTO sales(id, item_name, subtotal, tax) VALUES (1, 'Watch', 249.00, 20.59), (2, 'Montior', 120.50, 11.12); SET IDENTITY_INSERT sales OFF diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index 6e43d2599a..2482d40c29 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -1765,6 +1765,58 @@ ] } }, + { + Profile: { + Source: { + Object: profiles, + Type: Table + }, + GraphQL: { + Singular: Profile, + Plural: Profiles, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Create + }, + { + Action: Read + }, + { + Action: Delete + }, + { + Action: Update + } + ] + }, + { + Role: authenticated, + Actions: [ + { + Action: Create + }, + { + Action: Read + }, + { + Action: Delete + }, + { + Action: Update + } + ] + } + ] + } + }, { stocks_price: { Source: { diff --git a/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestJsonTypesTests.cs b/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestJsonTypesTests.cs new file mode 100644 index 0000000000..7a5a41734d --- /dev/null +++ b/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestJsonTypesTests.cs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +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. + /// 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. + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class MsSqlRestJsonTypesTests : SqlTestBase + { + private const string JSON_TYPE_REST_PATH = "api/Profile"; + + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + #region Read Tests + + /// + /// 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). + /// + [TestMethod] + public async Task GetJsonTypeList() + { + HttpResponseMessage response = await HttpClient.GetAsync(JSON_TYPE_REST_PATH); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + JsonElement items = JsonDocument.Parse(await response.Content.ReadAsStringAsync()) + .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. + 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}."); + } + } + + /// + /// GET /api/Profile/id/1 - Verify a simple object payload round-trips (verbatim value-equivalence). + /// + [TestMethod] + public async Task GetJsonTypeByPrimaryKey() + { + JsonElement metadata = ParseMetadata(await GetRecordByIdAsync(1)); + Assert.AreEqual("admin", metadata.GetProperty("role").GetString()); + Assert.AreEqual(3, metadata.GetProperty("tier").GetInt32()); + } + + /// + /// GET /api/Profile/id/5 - Verify a SQL NULL metadata value is rendered as JSON null. + /// + [TestMethod] + public async Task GetJsonTypeWithNull() + { + JsonElement record = await GetRecordByIdAsync(5); + Assert.AreEqual(JsonValueKind.Null, record.GetProperty("metadata").ValueKind); + } + + /// + /// GET /api/Profile/id/2 - Verify an array-bearing payload is preserved. + /// + [TestMethod] + public async Task GetJsonTypeWithArrayPayload() + { + JsonElement metadata = ParseMetadata(await GetRecordByIdAsync(2)); + JsonElement tags = metadata.GetProperty("tags"); + Assert.AreEqual(JsonValueKind.Array, tags.ValueKind); + Assert.AreEqual(3, tags.GetArrayLength()); + Assert.AreEqual("a", tags[0].GetString()); + Assert.AreEqual("b", tags[1].GetString()); + Assert.AreEqual("c", tags[2].GetString()); + } + + /// + /// GET /api/Profile/id/3 - Verify a deeply nested object payload is preserved. + /// + [TestMethod] + public async Task GetJsonTypeWithNestedPayload() + { + JsonElement metadata = ParseMetadata(await GetRecordByIdAsync(3)); + Assert.IsTrue(metadata.GetProperty("nested").GetProperty("key").GetProperty("deep").GetBoolean()); + } + + /// + /// GET /api/Profile/id/4 - Verify unicode (including a multi-byte emoji) round-trips intact. + /// + [TestMethod] + public async Task GetJsonTypeWithUnicode() + { + JsonElement metadata = ParseMetadata(await GetRecordByIdAsync(4)); + Assert.AreEqual("éü😀", metadata.GetProperty("unicode").GetString()); + } + + #endregion + + #region Write Tests + + /// + /// POST /api/Profile - Verify a new record with a JSON payload can be inserted, the value + /// echoes back, and it is persisted (read-back). Also covers inserting a null payload. + /// + [DataTestMethod] + [DataRow("{ \"metadata\": \"{\\\"role\\\":\\\"guest\\\"}\" }", false, DisplayName = "Insert profile with valid JSON object")] + [DataRow("{ \"metadata\": null }", true, DisplayName = "Insert profile with null metadata")] + public async Task InsertJsonType(string requestBody, bool expectNull) + { + HttpResponseMessage postResponse = await HttpClient.PostAsync( + JSON_TYPE_REST_PATH, + new StringContent(requestBody, Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.Created, postResponse.StatusCode); + + JsonElement postElement = JsonDocument.Parse(await postResponse.Content.ReadAsStringAsync()) + .RootElement.GetProperty("value")[0]; + int newId = postElement.GetProperty("id").GetInt32(); + + JsonElement readBack = await GetRecordByIdAsync(newId); + if (expectNull) + { + Assert.AreEqual(JsonValueKind.Null, readBack.GetProperty("metadata").ValueKind); + } + else + { + Assert.AreEqual("guest", ParseMetadata(readBack).GetProperty("role").GetString()); + } + + await DeleteProfile(newId); + } + + /// + /// PUT /api/Profile/id/1 - Verify a full update replaces the metadata payload, then restore it. + /// + [TestMethod] + public async Task PutJsonType_Update() + { + HttpResponseMessage response = await HttpClient.PutAsync( + $"{JSON_TYPE_REST_PATH}/id/1", + new StringContent("{ \"metadata\": \"{\\\"role\\\":\\\"owner\\\"}\" }", Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("owner", ParseMetadata(await GetRecordByIdAsync(1)).GetProperty("role").GetString()); + + // Restore original value. + HttpResponseMessage restore = await HttpClient.PutAsync( + $"{JSON_TYPE_REST_PATH}/id/1", + new StringContent("{ \"metadata\": \"{\\\"role\\\":\\\"admin\\\",\\\"tier\\\":3}\" }", Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode); + Assert.AreEqual("admin", ParseMetadata(await GetRecordByIdAsync(1)).GetProperty("role").GetString()); + } + + /// + /// PATCH /api/Profile/id/1 - Verify a partial update sets a new metadata payload, then restore it. + /// + [TestMethod] + public async Task PatchJsonType_Update() + { + HttpResponseMessage response = await HttpClient.PatchAsync( + $"{JSON_TYPE_REST_PATH}/id/1", + new StringContent("{ \"metadata\": \"{\\\"role\\\":\\\"editor\\\"}\" }", Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual("editor", ParseMetadata(await GetRecordByIdAsync(1)).GetProperty("role").GetString()); + + // Restore original value. + HttpResponseMessage restore = await HttpClient.PutAsync( + $"{JSON_TYPE_REST_PATH}/id/1", + new StringContent("{ \"metadata\": \"{\\\"role\\\":\\\"admin\\\",\\\"tier\\\":3}\" }", Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode); + } + + /// + /// PATCH /api/Profile/id/2 - Verify metadata can be cleared to null. + /// + [TestMethod] + public async Task PatchJsonType_ToNull() + { + HttpResponseMessage response = await HttpClient.PatchAsync( + $"{JSON_TYPE_REST_PATH}/id/2", + new StringContent("{ \"metadata\": null }", Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual(JsonValueKind.Null, (await GetRecordByIdAsync(2)).GetProperty("metadata").ValueKind); + + // Restore original array payload. + HttpResponseMessage restore = await HttpClient.PutAsync( + $"{JSON_TYPE_REST_PATH}/id/2", + new StringContent("{ \"metadata\": \"{\\\"tags\\\":[\\\"a\\\",\\\"b\\\",\\\"c\\\"]}\" }", Encoding.UTF8, "application/json")); + Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode); + } + + #endregion + + #region Helpers + + /// + /// DELETE /api/Profile/id/{id} - Verify a record can be deleted. + /// + private static async Task DeleteProfile(int id) + { + HttpResponseMessage deleteResponse = await HttpClient.DeleteAsync($"{JSON_TYPE_REST_PATH}/id/{id}"); + Assert.AreEqual(HttpStatusCode.NoContent, deleteResponse.StatusCode); + } + + /// + /// Fetches a single Profile record by its primary key and returns the record element. + /// + private static async Task GetRecordByIdAsync(int id) + { + HttpResponseMessage response = await HttpClient.GetAsync($"{JSON_TYPE_REST_PATH}/id/{id}"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + string body = await response.Content.ReadAsStringAsync(); + return JsonDocument.Parse(body).RootElement.GetProperty("value")[0].Clone(); + } + + /// + /// 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. + /// + private static JsonElement ParseMetadata(JsonElement record) + { + JsonElement metadata = record.GetProperty("metadata"); + Assert.AreEqual( + JsonValueKind.Object, + metadata.ValueKind, + "A native JSON column is inlined as a JSON object at the REST boundary via FOR JSON PATH."); + + return metadata.Clone(); + } + + #endregion + } +} diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 4de4f52b5f..6e1beafefa 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -1854,6 +1854,58 @@ } ] }, + "Profile": { + "source": { + "object": "profiles", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Profile", + "plural": "Profiles" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "delete" + }, + { + "action": "update" + } + ] + }, + { + "role": "authenticated", + "actions": [ + { + "action": "create" + }, + { + "action": "read" + }, + { + "action": "delete" + }, + { + "action": "update" + } + ] + } + ] + }, "stocks_price": { "source": { "object": "stocks_price",