Skip to content

Commit 7c25d80

Browse files
committed
test(mssql-json): add profiles fixture + REST CRUD tests (Phase 3)
Mirrors the merged vector-type feature (#3677). CI now runs SQL Server 2025 (#3697), so the native json column is created unconditionally - no server-version gating needed. - profiles table (id, metadata json) + 5 seed rows (simple/array/nested/unicode/null) (T002) - Profile entity in dab-config.MsSql.json + config-generator command, REST + GraphQL enabled, anon/authenticated CRUD (T003) - MsSqlRestJsonTypesTests: GET list/by-pk/null/array/nested/unicode, POST, PUT, PATCH, PATCH-to-null, DELETE (T011,T013,T015,T017,T019)
1 parent 15fcfa5 commit 7c25d80

4 files changed

Lines changed: 314 additions & 0 deletions

File tree

config-generators/mssql-commands.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ add WebsiteUser_MM --config "dab-config.MsSql.json" --source website_users_mm --
1818
add SupportedType --config "dab-config.MsSql.json" --source type_table --permissions "anonymous:create,read,delete,update"
1919
add VectorType --config "dab-config.MsSql.json" --source vector_type_table --rest true --graphql false --permissions "anonymous:create,read,delete,update"
2020
update VectorType --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update"
21+
add Profile --config "dab-config.MsSql.json" --source profiles --rest true --graphql true --permissions "anonymous:create,read,delete,update"
22+
update Profile --config "dab-config.MsSql.json" --permissions "authenticated:create,read,delete,update"
2123
add stocks_price --config "dab-config.MsSql.json" --source stocks_price --permissions "authenticated:create,read,update,delete"
2224
update stocks_price --config "dab-config.MsSql.json" --permissions "anonymous:read"
2325
update stocks_price --config "dab-config.MsSql.json" --permissions "TestNestedFilterFieldIsNull_ColumnForbidden:read" --fields.exclude "price"

src/Service.Tests/DatabaseSchema-MsSql.sql

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ DROP TABLE IF EXISTS comics;
4141
DROP TABLE IF EXISTS brokers;
4242
DROP TABLE IF EXISTS type_table;
4343
DROP TABLE IF EXISTS vector_type_table;
44+
DROP TABLE IF EXISTS profiles;
4445
DROP TABLE IF EXISTS trees;
4546
DROP TABLE IF EXISTS fungi;
4647
DROP TABLE IF EXISTS empty_table;
@@ -241,6 +242,11 @@ CREATE TABLE vector_type_table(
241242
vector_data_max vector(1998)
242243
);
243244

245+
CREATE TABLE profiles(
246+
id int IDENTITY(1, 1) PRIMARY KEY,
247+
metadata json NULL
248+
);
249+
244250
CREATE TABLE trees (
245251
treeId int PRIMARY KEY,
246252
species varchar(max),
@@ -638,6 +644,16 @@ VALUES (7, CAST('[' + (
638644
SELECT STRING_AGG(CAST(value AS NVARCHAR(MAX)), ',') WITHIN GROUP (ORDER BY value)
639645
FROM GENERATE_SERIES(1, 1998)
640646
) + ']' AS vector(1998)));
647+
648+
SET IDENTITY_INSERT profiles ON
649+
INSERT INTO profiles(id, metadata)
650+
VALUES
651+
(1, N'{"role":"admin","tier":3}'),
652+
(2, N'{"tags":["a","b","c"]}'),
653+
(3, N'{"nested":{"key":{"deep":true}}}'),
654+
(4, N'{"unicode":"éü😀"}'),
655+
(5, NULL);
656+
SET IDENTITY_INSERT profiles OFF
641657
SET IDENTITY_INSERT vector_type_table OFF
642658

643659
SET IDENTITY_INSERT sales ON
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Net;
5+
using System.Net.Http;
6+
using System.Text;
7+
using System.Text.Json;
8+
using System.Threading.Tasks;
9+
using Microsoft.VisualStudio.TestTools.UnitTesting;
10+
11+
namespace Azure.DataApiBuilder.Service.Tests.SqlTests.RestApiTests
12+
{
13+
/// <summary>
14+
/// Tests for SQL Server native JSON column support via REST endpoints (read and write).
15+
/// DAB treats a JSON column exactly like a string column: the raw JSON text round-trips
16+
/// through create/read/update/delete without any special handling, new scalar, or format.
17+
/// Assertions parse the returned metadata semantically so they are robust to any
18+
/// whitespace / key-order normalization the engine applies to the JSON type.
19+
/// NOTE: The native JSON data type requires SQL Server 2025 / Azure SQL.
20+
/// </summary>
21+
[TestClass, TestCategory(TestCategory.MSSQL)]
22+
public class MsSqlRestJsonTypesTests : SqlTestBase
23+
{
24+
private const string JSON_TYPE_REST_PATH = "api/Profile";
25+
26+
[ClassInitialize]
27+
public static async Task SetupAsync(TestContext context)
28+
{
29+
DatabaseEngine = TestCategory.MSSQL;
30+
await InitializeTestFixture();
31+
}
32+
33+
#region Read Tests
34+
35+
/// <summary>
36+
/// GET /api/Profile - Verify the whole collection (5 seeded rows) is returned and that
37+
/// each metadata value renders either as its JSON payload or null (row 5).
38+
/// </summary>
39+
[TestMethod]
40+
public async Task GetJsonTypeList()
41+
{
42+
HttpResponseMessage response = await HttpClient.GetAsync(JSON_TYPE_REST_PATH);
43+
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
44+
45+
JsonElement items = JsonDocument.Parse(await response.Content.ReadAsStringAsync())
46+
.RootElement.GetProperty("value");
47+
Assert.AreEqual(5, items.GetArrayLength(), "Expected the 5 seeded profile rows.");
48+
}
49+
50+
/// <summary>
51+
/// GET /api/Profile/id/1 - Verify a simple object payload round-trips (verbatim value-equivalence).
52+
/// </summary>
53+
[TestMethod]
54+
public async Task GetJsonTypeByPrimaryKey()
55+
{
56+
JsonElement metadata = ParseMetadata(await GetRecordByIdAsync(1));
57+
Assert.AreEqual("admin", metadata.GetProperty("role").GetString());
58+
Assert.AreEqual(3, metadata.GetProperty("tier").GetInt32());
59+
}
60+
61+
/// <summary>
62+
/// GET /api/Profile/id/5 - Verify a SQL NULL metadata value is rendered as JSON null.
63+
/// </summary>
64+
[TestMethod]
65+
public async Task GetJsonTypeWithNull()
66+
{
67+
JsonElement record = await GetRecordByIdAsync(5);
68+
Assert.AreEqual(JsonValueKind.Null, record.GetProperty("metadata").ValueKind);
69+
}
70+
71+
/// <summary>
72+
/// GET /api/Profile/id/2 - Verify an array-bearing payload is preserved.
73+
/// </summary>
74+
[TestMethod]
75+
public async Task GetJsonTypeWithArrayPayload()
76+
{
77+
JsonElement metadata = ParseMetadata(await GetRecordByIdAsync(2));
78+
JsonElement tags = metadata.GetProperty("tags");
79+
Assert.AreEqual(JsonValueKind.Array, tags.ValueKind);
80+
Assert.AreEqual(3, tags.GetArrayLength());
81+
Assert.AreEqual("a", tags[0].GetString());
82+
Assert.AreEqual("b", tags[1].GetString());
83+
Assert.AreEqual("c", tags[2].GetString());
84+
}
85+
86+
/// <summary>
87+
/// GET /api/Profile/id/3 - Verify a deeply nested object payload is preserved.
88+
/// </summary>
89+
[TestMethod]
90+
public async Task GetJsonTypeWithNestedPayload()
91+
{
92+
JsonElement metadata = ParseMetadata(await GetRecordByIdAsync(3));
93+
Assert.IsTrue(metadata.GetProperty("nested").GetProperty("key").GetProperty("deep").GetBoolean());
94+
}
95+
96+
/// <summary>
97+
/// GET /api/Profile/id/4 - Verify unicode (including a multi-byte emoji) round-trips intact.
98+
/// </summary>
99+
[TestMethod]
100+
public async Task GetJsonTypeWithUnicode()
101+
{
102+
JsonElement metadata = ParseMetadata(await GetRecordByIdAsync(4));
103+
Assert.AreEqual("éü😀", metadata.GetProperty("unicode").GetString());
104+
}
105+
106+
#endregion
107+
108+
#region Write Tests
109+
110+
/// <summary>
111+
/// POST /api/Profile - Verify a new record with a JSON payload can be inserted, the value
112+
/// echoes back, and it is persisted (read-back). Also covers inserting a null payload.
113+
/// </summary>
114+
[DataTestMethod]
115+
[DataRow("{ \"metadata\": \"{\\\"role\\\":\\\"guest\\\"}\" }", false, DisplayName = "Insert profile with valid JSON object")]
116+
[DataRow("{ \"metadata\": null }", true, DisplayName = "Insert profile with null metadata")]
117+
public async Task InsertJsonType(string requestBody, bool expectNull)
118+
{
119+
HttpResponseMessage postResponse = await HttpClient.PostAsync(
120+
JSON_TYPE_REST_PATH,
121+
new StringContent(requestBody, Encoding.UTF8, "application/json"));
122+
Assert.AreEqual(HttpStatusCode.Created, postResponse.StatusCode);
123+
124+
JsonElement postElement = JsonDocument.Parse(await postResponse.Content.ReadAsStringAsync())
125+
.RootElement.GetProperty("value")[0];
126+
int newId = postElement.GetProperty("id").GetInt32();
127+
128+
JsonElement readBack = await GetRecordByIdAsync(newId);
129+
if (expectNull)
130+
{
131+
Assert.AreEqual(JsonValueKind.Null, readBack.GetProperty("metadata").ValueKind);
132+
}
133+
else
134+
{
135+
Assert.AreEqual("guest", ParseMetadata(readBack).GetProperty("role").GetString());
136+
}
137+
138+
await DeleteProfile(newId);
139+
}
140+
141+
/// <summary>
142+
/// PUT /api/Profile/id/1 - Verify a full update replaces the metadata payload, then restore it.
143+
/// </summary>
144+
[TestMethod]
145+
public async Task PutJsonType_Update()
146+
{
147+
HttpResponseMessage response = await HttpClient.PutAsync(
148+
$"{JSON_TYPE_REST_PATH}/id/1",
149+
new StringContent("{ \"metadata\": \"{\\\"role\\\":\\\"owner\\\"}\" }", Encoding.UTF8, "application/json"));
150+
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
151+
Assert.AreEqual("owner", ParseMetadata(await GetRecordByIdAsync(1)).GetProperty("role").GetString());
152+
153+
// Restore original value.
154+
HttpResponseMessage restore = await HttpClient.PutAsync(
155+
$"{JSON_TYPE_REST_PATH}/id/1",
156+
new StringContent("{ \"metadata\": \"{\\\"role\\\":\\\"admin\\\",\\\"tier\\\":3}\" }", Encoding.UTF8, "application/json"));
157+
Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode);
158+
Assert.AreEqual("admin", ParseMetadata(await GetRecordByIdAsync(1)).GetProperty("role").GetString());
159+
}
160+
161+
/// <summary>
162+
/// PATCH /api/Profile/id/1 - Verify a partial update sets a new metadata payload, then restore it.
163+
/// </summary>
164+
[TestMethod]
165+
public async Task PatchJsonType_Update()
166+
{
167+
HttpResponseMessage response = await HttpClient.PatchAsync(
168+
$"{JSON_TYPE_REST_PATH}/id/1",
169+
new StringContent("{ \"metadata\": \"{\\\"role\\\":\\\"editor\\\"}\" }", Encoding.UTF8, "application/json"));
170+
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
171+
Assert.AreEqual("editor", ParseMetadata(await GetRecordByIdAsync(1)).GetProperty("role").GetString());
172+
173+
// Restore original value.
174+
HttpResponseMessage restore = await HttpClient.PutAsync(
175+
$"{JSON_TYPE_REST_PATH}/id/1",
176+
new StringContent("{ \"metadata\": \"{\\\"role\\\":\\\"admin\\\",\\\"tier\\\":3}\" }", Encoding.UTF8, "application/json"));
177+
Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode);
178+
}
179+
180+
/// <summary>
181+
/// PATCH /api/Profile/id/1 - Verify metadata can be cleared to null.
182+
/// </summary>
183+
[TestMethod]
184+
public async Task PatchJsonType_ToNull()
185+
{
186+
HttpResponseMessage response = await HttpClient.PatchAsync(
187+
$"{JSON_TYPE_REST_PATH}/id/2",
188+
new StringContent("{ \"metadata\": null }", Encoding.UTF8, "application/json"));
189+
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
190+
Assert.AreEqual(JsonValueKind.Null, (await GetRecordByIdAsync(2)).GetProperty("metadata").ValueKind);
191+
192+
// Restore original array payload.
193+
HttpResponseMessage restore = await HttpClient.PutAsync(
194+
$"{JSON_TYPE_REST_PATH}/id/2",
195+
new StringContent("{ \"metadata\": \"{\\\"tags\\\":[\\\"a\\\",\\\"b\\\",\\\"c\\\"]}\" }", Encoding.UTF8, "application/json"));
196+
Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode);
197+
}
198+
199+
#endregion
200+
201+
#region Helpers
202+
203+
/// <summary>
204+
/// DELETE /api/Profile/id/{id} - Verify a record can be deleted.
205+
/// </summary>
206+
private static async Task DeleteProfile(int id)
207+
{
208+
HttpResponseMessage deleteResponse = await HttpClient.DeleteAsync($"{JSON_TYPE_REST_PATH}/id/{id}");
209+
Assert.AreEqual(HttpStatusCode.NoContent, deleteResponse.StatusCode);
210+
}
211+
212+
/// <summary>
213+
/// Fetches a single Profile record by its primary key and returns the record element.
214+
/// </summary>
215+
private static async Task<JsonElement> GetRecordByIdAsync(int id)
216+
{
217+
HttpResponseMessage response = await HttpClient.GetAsync($"{JSON_TYPE_REST_PATH}/id/{id}");
218+
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
219+
string body = await response.Content.ReadAsStringAsync();
220+
return JsonDocument.Parse(body).RootElement.GetProperty("value")[0].Clone();
221+
}
222+
223+
/// <summary>
224+
/// Returns the metadata field parsed as a JSON element. DAB treats a JSON column as a string,
225+
/// so the value is expected to arrive as a JSON string carrying the payload; this helper also
226+
/// tolerates the value arriving as a raw JSON object so the assertions remain robust.
227+
/// </summary>
228+
private static JsonElement ParseMetadata(JsonElement record)
229+
{
230+
JsonElement metadata = record.GetProperty("metadata");
231+
if (metadata.ValueKind == JsonValueKind.String)
232+
{
233+
return JsonDocument.Parse(metadata.GetString()).RootElement.Clone();
234+
}
235+
236+
return metadata.Clone();
237+
}
238+
239+
#endregion
240+
}
241+
}

src/Service.Tests/dab-config.MsSql.json

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1854,6 +1854,61 @@
18541854
}
18551855
]
18561856
},
1857+
"Profile": {
1858+
"source": {
1859+
"object": "profiles",
1860+
"type": "table",
1861+
"key-fields": [
1862+
"id"
1863+
]
1864+
},
1865+
"graphql": {
1866+
"enabled": true,
1867+
"type": {
1868+
"singular": "Profile",
1869+
"plural": "Profiles"
1870+
}
1871+
},
1872+
"rest": {
1873+
"enabled": true
1874+
},
1875+
"permissions": [
1876+
{
1877+
"role": "anonymous",
1878+
"actions": [
1879+
{
1880+
"action": "create"
1881+
},
1882+
{
1883+
"action": "read"
1884+
},
1885+
{
1886+
"action": "delete"
1887+
},
1888+
{
1889+
"action": "update"
1890+
}
1891+
]
1892+
},
1893+
{
1894+
"role": "authenticated",
1895+
"actions": [
1896+
{
1897+
"action": "create"
1898+
},
1899+
{
1900+
"action": "read"
1901+
},
1902+
{
1903+
"action": "delete"
1904+
},
1905+
{
1906+
"action": "update"
1907+
}
1908+
]
1909+
}
1910+
]
1911+
},
18571912
"stocks_price": {
18581913
"source": {
18591914
"object": "stocks_price",

0 commit comments

Comments
 (0)