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",