diff --git a/config-generators/mssql-commands.txt b/config-generators/mssql-commands.txt index 277b9878f0..20084d594c 100644 --- a/config-generators/mssql-commands.txt +++ b/config-generators/mssql-commands.txt @@ -24,6 +24,8 @@ update VectorOwner --config "dab-config.MsSql.json" --relationship vectors --tar update VectorType --config "dab-config.MsSql.json" --relationship owner --target.entity VectorOwner --cardinality one --relationship.fields "owner_id:id" 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 XmlProfile --config "dab-config.MsSql.json" --source xml_profiles --rest true --graphql true --permissions "anonymous:create,read,delete,update" +update XmlProfile --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/Core/Models/SqlTypeConstants.cs b/src/Core/Models/SqlTypeConstants.cs index e09fd12791..addde37790 100644 --- a/src/Core/Models/SqlTypeConstants.cs +++ b/src/Core/Models/SqlTypeConstants.cs @@ -42,7 +42,7 @@ public static class SqlTypeConstants { "varbinary", true }, // SqlDbType.VarBinary { "varchar", true }, // SqlDbType.VarChar { "sql_variant", false }, // SqlDbType.Variant (unsupported) - { "xml", false }, // SqlDbType.Xml (unsupported) + { "xml", true }, // SqlDbType.Xml { "date", true }, // SqlDbType.Date { "time", true }, // SqlDbType.Time { "datetime2", true }, // SqlDbType.DateTime2 diff --git a/src/Core/Resolvers/MsSqlDbExceptionParser.cs b/src/Core/Resolvers/MsSqlDbExceptionParser.cs index da43de3f21..1f63040b61 100644 --- a/src/Core/Resolvers/MsSqlDbExceptionParser.cs +++ b/src/Core/Resolvers/MsSqlDbExceptionParser.cs @@ -36,6 +36,13 @@ public MsSqlDbExceptionParser(RuntimeConfigProvider configProvider) : base(confi "13608", "13609", "13610", "13611", "13612", "13613", "13614" }); + // XML parsing/validation errors (SQL Server message range 9400-9480). Malformed XML supplied + // for an xml column is a client input error, mapped to HTTP 400 (mirrors the json handling above). + for (int xmlParsingErrorCode = 9400; xmlParsingErrorCode <= 9480; xmlParsingErrorCode++) + { + BadRequestExceptionCodes.Add(xmlParsingErrorCode.ToString()); + } + TransientExceptionCodes.UnionWith(new List { // Transient error codes compiled from: @@ -95,9 +102,10 @@ public override DataApiBuilderException.SubStatusCodes GetResultSubStatusCodeFor { int errorCode = ((SqlException)e).Number; // MSSQL 201 - Procedure or function '%.*ls' expects parameter '%.*ls', which was not supplied. - // SQL Server 2025+ JSON validation errors are also client input errors. + // XML parsing errors (9400-9480) and SQL Server 2025+ JSON validation errors (13608-13614) + // are also client input errors. // Pending revisions of error code classifications, this is a temporary fix to determine substatus code. - if (errorCode is 201 or 13608 or 13609 or 13610 or 13611 or 13612 or 13613 or 13614) + if (errorCode is 201 or (>= 9400 and <= 9480) or (>= 13608 and <= 13614)) { return DataApiBuilderException.SubStatusCodes.DatabaseInputError; } diff --git a/src/Core/Resolvers/MsSqlQueryBuilder.cs b/src/Core/Resolvers/MsSqlQueryBuilder.cs index d689ae0717..30ba22cf47 100644 --- a/src/Core/Resolvers/MsSqlQueryBuilder.cs +++ b/src/Core/Resolvers/MsSqlQueryBuilder.cs @@ -699,7 +699,6 @@ AND ty.name IN N'geometry', N'hierarchyid', N'sql_variant', - N'xml', N'rowversion' ) ) THEN 1 @@ -744,7 +743,6 @@ AND ty.name IN N'geometry', N'hierarchyid', N'sql_variant', - N'xml', N'rowversion' ) ) diff --git a/src/Core/Services/TypeHelper.cs b/src/Core/Services/TypeHelper.cs index 509212de94..113ea19008 100644 --- a/src/Core/Services/TypeHelper.cs +++ b/src/Core/Services/TypeHelper.cs @@ -116,7 +116,8 @@ public static class TypeHelper [SqlDbType.UniqueIdentifier] = typeof(Guid), [SqlDbType.VarBinary] = typeof(byte[]), [SqlDbType.VarChar] = typeof(string), - [SqlDbType.Vector] = typeof(float) + [SqlDbType.Vector] = typeof(float), + [SqlDbType.Xml] = typeof(string) }; private static readonly Dictionary _sqlDbDateTimeTypeToDbType = new() diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 587edb29c3..571fd72789 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -45,6 +45,7 @@ DROP TABLE IF EXISTS type_table; DROP TABLE IF EXISTS vector_type_table; DROP TABLE IF EXISTS vector_owners; DROP TABLE IF EXISTS profiles; +DROP TABLE IF EXISTS xml_profiles; DROP TABLE IF EXISTS trees; DROP TABLE IF EXISTS fungi; DROP TABLE IF EXISTS empty_table; @@ -236,7 +237,8 @@ CREATE TABLE type_table( smalldatetime_types smalldatetime, time_types time, bytearray_types varbinary(max), - uuid_types uniqueidentifier DEFAULT newid() + uuid_types uniqueidentifier DEFAULT newid(), + xml_types xml ); CREATE TABLE vector_owners( @@ -257,6 +259,11 @@ CREATE TABLE profiles( metadata json NULL ); +CREATE TABLE xml_profiles( + id int IDENTITY(5001, 1) PRIMARY KEY, + content xml NULL +); + CREATE TABLE trees ( treeId int PRIMARY KEY, species varchar(max), @@ -621,21 +628,21 @@ string_types, nvarchar_string_types, single_types, float_types, decimal_types, boolean_types, date_types, datetime_types, datetime2_types, datetimeoffset_types, smalldatetime_types, time_types, -bytearray_types) +bytearray_types, xml_types) VALUES (1, 1, 1, 1, 1, '', '', 0.33, 0.33, 0.333333, 1, '1999-01-08', '1999-01-08 10:23:54', '1999-01-08 10:23:54.9999999', '1999-01-08 10:23:54.9999999-14:00', '1999-01-08 10:23:54', '10:23:54.9999999', - 0xABCDEF0123), + 0xABCDEF0123, 'report'), (2, 0, -1, -1, -1, 'lksa;jdflasdf;alsdflksdfkldj', 'lksa;jdflasdf;alsdflksdfkldj', -9.2, -9.2, -9.292929, 0, '1999-01-08', '1999-01-08 10:23:00', '1999-01-08 10:23:00.9999999', '1999-01-08 10:23:00.9999999+13:00', '1999-01-08 10:23:00', '10:23:00.9999999', - 0x98AB7511AABB1234), + 0x98AB7511AABB1234, 'admin'), (3, 0, -32768, -2147483648, -9223372036854775808, 'null', 'null', -3.4E38, -1.7E308, 2.929292E-19, 1, '0001-01-01', '1753-01-01 00:00:00.000', '0001-01-01 00:00:00.0000000', '0001-01-01 00:00:00.0000000+0:00', '1900-01-01 00:00:00', '00:00:00.0000000', - 0x00000000), + 0x00000000, 'value'), (4, 255, 32767, 2147483647, 9223372036854775807, 'null', 'null', 3.4E38, 1.7E308, 2.929292E-14, 1, '9999-12-31', '9999-12-31 23:59:59', '9999-12-31 23:59:59.9999999', '9999-12-31 23:59:59.9999999+14:00', '2079-06-06', '23:59:59.9999999', - 0xFFFFFFFF), - (5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + 0xFFFFFFFF, ''), + (5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); INSERT INTO type_table(id, uuid_types) values(10, 'D1D021A8-47B4-4AE4-B718-98E89C41A161'); SET IDENTITY_INSERT type_table OFF @@ -666,6 +673,17 @@ VALUES (5, NULL); SET IDENTITY_INSERT profiles OFF +-- XML values deliberately contain double-quoted attributes so tests can verify the +-- quotes round-trip as valid XML (the JSON wire-escaping is removed on parse). +SET IDENTITY_INSERT xml_profiles ON +INSERT INTO xml_profiles(id, content) +VALUES + (1, N'Book2'), + (2, N''), + (3, N'UserAdmin'), + (4, NULL); +SET IDENTITY_INSERT xml_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/Mcp/MsSqlMcpXmlTypesTests.cs b/src/Service.Tests/Mcp/MsSqlMcpXmlTypesTests.cs new file mode 100644 index 0000000000..132aa06bc0 --- /dev/null +++ b/src/Service.Tests/Mcp/MsSqlMcpXmlTypesTests.cs @@ -0,0 +1,187 @@ +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Mcp.BuiltInTools; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Service.Tests.SqlTests.SqlTestHelper; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + [TestClass, TestCategory(TestCategory.MSSQL)] + public class MsSqlMcpXmlTypesTests : McpToolTestBase + { + private const string ENTITY_NAME = "XmlProfile"; + private const string ORIGINAL_XML = "Pen"; + + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + /// Verifies MCP reads preserve XML attributes and SQL null values. + [DataTestMethod] + [DataRow(1, "Book2", DisplayName = "Read XML with a quoted attribute")] + [DataRow(2, "", DisplayName = "Read XML with multiple quoted attributes")] + [DataRow(4, null, DisplayName = "Read null XML content")] + public async Task ReadXmlById_ReturnsXmlOrNull(int id, string expectedXml) + { + JsonElement records = await ReadXmlAsync($"id eq {id}"); + Assert.AreEqual(1, records.GetArrayLength()); + AssertXmlContent(records[0], expectedXml); + } + + /// Verifies an MCP collection returns valid XML strings. + [TestMethod] + public async Task ReadXmlList_ReturnsStringsAndNull() + { + JsonElement records = await ReadXmlAsync("id ge 1 and id le 4"); + Assert.AreEqual(4, records.GetArrayLength()); + foreach (JsonElement record in records.EnumerateArray()) + { + JsonElement content = record.GetProperty("content"); + if (record.GetProperty("id").GetInt32() == 4) + { + Assert.AreEqual(JsonValueKind.Null, content.ValueKind); + } + else + { + Assert.AreEqual(JsonValueKind.String, content.ValueKind); + AssertIsValidXml(content.GetString()!); + } + } + } + + /// Verifies filtering an XML column excludes records with null content. + [TestMethod] + public async Task FilterXmlNotNull_ExcludesNullContent() + { + JsonElement records = await ReadXmlAsync("id ge 1 and id le 4 and content ne null"); + Assert.AreEqual(3, records.GetArrayLength()); + foreach (JsonElement record in records.EnumerateArray()) + { + Assert.AreEqual(JsonValueKind.String, record.GetProperty("content").ValueKind); + Assert.AreNotEqual(4, record.GetProperty("id").GetInt32()); + } + } + + /// Verifies MCP create/read round trips preserve XML or null, and deletion removes the record. + [DataTestMethod] + [DataRow(ORIGINAL_XML, DisplayName = "Create and delete XML with a quoted attribute")] + [DataRow("", DisplayName = "Create and delete XML with multiple quoted attributes")] + [DataRow(null, DisplayName = "Create and delete null XML content")] + public async Task CreateXml_RoundTripsAndDeletes(string xml) + { + CallToolResult result = await CreateXmlAsync(xml); + AssertSuccess(result, "Creating XML content should succeed."); + JsonElement created = GetRecordsArray(ParseResultRoot(result))[0]; + int id = created.GetProperty("id").GetInt32(); + try + { + AssertXmlContent(created, xml); + AssertXmlContent((await ReadXmlAsync($"id eq {id}"))[0], xml); + } + finally + { + await DeleteXmlAsync(id); + } + + Assert.AreEqual(0, (await ReadXmlAsync($"id eq {id}")).GetArrayLength()); + } + + /// Verifies MCP updates preserve XML attributes and escaped text, or clear content to null. + [DataTestMethod] + [DataRow("Updated & quoted", DisplayName = "Update XML with a quoted attribute and escaped text")] + [DataRow("", DisplayName = "Update XML with multiple quoted attributes")] + [DataRow(null, DisplayName = "Update XML content to null")] + public async Task UpdateXml_RoundTrips(string xml) + { + CallToolResult created = await CreateXmlAsync(ORIGINAL_XML); + AssertSuccess(created, "Creating the update fixture should succeed."); + int id = GetRecordsArray(ParseResultRoot(created))[0].GetProperty("id").GetInt32(); + try + { + CallToolResult result = await UpdateXmlAsync(id, xml); + AssertSuccess(result, "Updating XML content should succeed."); + AssertXmlContent(ParseResultRoot(result).GetProperty("result"), xml); + AssertXmlContent((await ReadXmlAsync($"id eq {id}"))[0], xml); + } + finally + { + await DeleteXmlAsync(id); + } + } + + /// Verifies MCP creation rejects malformed XML at the database boundary. + [DataTestMethod] + [DataRow("Book", DisplayName = "Reject XML creation with a mismatched closing tag")] + [DataRow("", DisplayName = "Reject XML creation with an unquoted attribute")] + public async Task CreateMalformedXml_ReturnsError(string xml) + { + AssertError(await CreateXmlAsync(xml)); + } + + /// Verifies rejected malformed XML updates leave the stored content unchanged. + [DataTestMethod] + [DataRow("Book", DisplayName = "Reject XML update with a mismatched closing tag and preserve content")] + [DataRow("", DisplayName = "Reject XML update with an unquoted attribute and preserve content")] + public async Task UpdateMalformedXml_ReturnsErrorAndPreservesContent(string xml) + { + CallToolResult created = await CreateXmlAsync(ORIGINAL_XML); + AssertSuccess(created, "Creating the update fixture should succeed."); + int id = GetRecordsArray(ParseResultRoot(created))[0].GetProperty("id").GetInt32(); + try + { + AssertError(await UpdateXmlAsync(id, xml)); + AssertXmlContent((await ReadXmlAsync($"id eq {id}"))[0], ORIGINAL_XML); + } + finally + { + await DeleteXmlAsync(id); + } + } + + private static async Task ReadXmlAsync(string filter) + { + CallToolResult result = await ExecuteToolAsync(new ReadRecordsTool(), BuildQueryServiceProvider(), + new Dictionary { { "entity", ENTITY_NAME }, { "filter", filter }, { "select", "id,content" } }); + AssertSuccess(result, "Reading XML content should succeed."); + return GetRecordsArray(ParseResultRoot(result)); + } + + private static Task CreateXmlAsync(string xml) + { + return ExecuteToolAsync(new CreateRecordTool(), BuildMutationServiceProvider(), + new Dictionary + { + { "entity", ENTITY_NAME }, + { "data", new Dictionary { { "content", xml } } } + }); + } + + private static Task UpdateXmlAsync(int id, string xml) + { + return ExecuteToolAsync(new UpdateRecordTool(), BuildMutationServiceProvider(), + new Dictionary + { + { "entity", ENTITY_NAME }, + { "keys", new Dictionary { { "id", id } } }, + { "fields", new Dictionary { { "content", xml } } } + }); + } + + private static async Task DeleteXmlAsync(int id) + { + CallToolResult result = await ExecuteToolAsync(new DeleteRecordTool(), BuildMutationServiceProvider(), + new Dictionary + { + { "entity", ENTITY_NAME }, + { "keys", new Dictionary { { "id", id } } } + }); + AssertSuccess(result, "Deleting the XML fixture should succeed."); + } + + } +} \ No newline at end of file diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLXmlTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLXmlTypesTests.cs new file mode 100644 index 0000000000..804f4181a9 --- /dev/null +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLXmlTypesTests.cs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Threading.Tasks; +using System.Xml.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Azure.DataApiBuilder.Service.Tests.SqlTests.SqlTestHelper; + +namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLQueryTests +{ + /// + /// CRUD tests for SQL Server xml column support via GraphQL. + /// DAB exposes an xml column as the built-in String scalar: the raw XML text is written from + /// the mutation input and read back as a string. Because XML attributes use double quotes, the value + /// is JSON-escaped on the wire; these tests assert that once the GraphQL String field is read + /// through a JSON parser the escaping is gone and the result is well-formed XML - i.e. the value + /// round-trips correctly through create/read/update/delete. + /// This complements the REST coverage in + /// . + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class MsSqlGraphQLXmlTypesTests : SqlTestBase + { + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + #region Read Tests + + /// + /// Query the xml column by primary key and verify the double-quoted attribute value round-trips + /// as valid XML with no stray backslashes. + /// + [TestMethod] + public async Task QueryXmlTypeByPk() + { + JsonElement result = await GetRecordByPkAsync(1); + + Assert.AreEqual(1, result.GetProperty("id").GetInt32()); + XElement root = AssertXmlWithId(ContentOf(result), "100"); + Assert.AreEqual("order", root.Name.LocalName); + } + + /// + /// Query the xml column with multiple quoted attributes and verify they round-trip. + /// + [TestMethod] + public async Task QueryXmlTypeWithMultipleAttributes() + { + JsonElement result = await GetRecordByPkAsync(2); + XElement setting = AssertIsValidXml(ContentOf(result)).Element("setting"); + Assert.AreEqual("true", setting!.Attribute("enabled")!.Value); + Assert.AreEqual("cache", setting.Attribute("name")!.Value); + } + + /// + /// Query the xml column where the value is SQL NULL and verify null is returned. + /// + [TestMethod] + public async Task QueryXmlTypeWithNull() + { + JsonElement result = await GetRecordByPkAsync(4); + Assert.AreEqual(JsonValueKind.Null, result.GetProperty("content").ValueKind); + } + + /// + /// Query the list of xml records and verify the first record (ordered by primary key) carries + /// valid XML. + /// + [TestMethod] + public async Task QueryXmlTypeList() + { + string graphQLQueryName = "xmlProfiles"; + string graphQLQuery = @"{ + xmlProfiles(orderBy: { id: ASC }) { + items { + id + content + } + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + + JsonElement items = result.GetProperty("items"); + Assert.AreEqual(4, items.GetArrayLength(), "Expected the 4 seeded xml_profiles rows."); + + JsonElement first = items[0]; + Assert.AreEqual(1, first.GetProperty("id").GetInt32()); + AssertXmlWithId(ContentOf(first), "100"); + } + + #endregion + + #region Write Tests + + /// + /// Insert a new record with an XML value (with a quoted attribute) via a create mutation and verify + /// the persisted value is returned as valid XML and can be read back. The record is deleted afterwards. + /// + [TestMethod] + public async Task InsertXmlType() + { + string createMutationName = "createXmlProfile"; + string createMutation = @"mutation { + createXmlProfile(item: { content: ""Pen"" }) { + id + content + } + }"; + + JsonElement created = await ExecuteGraphQLRequestAsync(createMutation, createMutationName, isAuthenticated: false); + + int newId = created.GetProperty("id").GetInt32(); + AssertXmlWithId(ContentOf(created), "200"); + + // Confirm the value was persisted by reading it back. + JsonElement readBack = await GetRecordByPkAsync(newId); + AssertXmlWithId(ContentOf(readBack), "200"); + + await DeleteXmlProfileAsync(newId); + } + + /// + /// Insert a new record with a null xml value via a create mutation and verify null round-trips. + /// + [TestMethod] + public async Task InsertNullXmlType() + { + string createMutationName = "createXmlProfile"; + string createMutation = @"mutation { + createXmlProfile(item: { content: null }) { + id + content + } + }"; + + JsonElement created = await ExecuteGraphQLRequestAsync(createMutation, createMutationName, isAuthenticated: false); + + int newId = created.GetProperty("id").GetInt32(); + Assert.AreEqual(JsonValueKind.Null, created.GetProperty("content").ValueKind); + + JsonElement readBack = await GetRecordByPkAsync(newId); + Assert.AreEqual(JsonValueKind.Null, readBack.GetProperty("content").ValueKind); + + await DeleteXmlProfileAsync(newId); + } + + /// + /// Update an existing record's xml value via an update mutation and verify the new value is + /// persisted, then restore the original value. + /// + [TestMethod] + public async Task UpdateXmlType() + { + string updateMutationName = "updateXmlProfile"; + string updateMutation = @"mutation { + updateXmlProfile(id: 1, item: { content: ""Updated"" }) { + id + content + } + }"; + + try + { + JsonElement updated = await ExecuteGraphQLRequestAsync(updateMutation, updateMutationName, isAuthenticated: false); + Assert.AreEqual(1, updated.GetProperty("id").GetInt32()); + AssertXmlWithId(ContentOf(updated), "999"); + + JsonElement readBack = await GetRecordByPkAsync(1); + AssertXmlWithId(ContentOf(readBack), "999"); + } + finally + { + // Restore original value so shared row 1 is left intact for other tests. + string restoreMutation = @"mutation { + updateXmlProfile(id: 1, item: { content: ""Book2"" }) { + id + content + } + }"; + await ExecuteGraphQLRequestAsync(restoreMutation, updateMutationName, isAuthenticated: false); + } + } + + /// + /// Insert malformed XML via a create mutation and verify the mutation fails with a GraphQL error + /// rather than persisting bad data (SQL Server validates the xml type at the database boundary). + /// + [TestMethod] + public async Task InsertInvalidXmlTypeFails() + { + string createMutationName = "createXmlProfile"; + string createMutation = @"mutation { + createXmlProfile(item: { content: ""Book"" }) { + id + content + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(createMutation, createMutationName, isAuthenticated: false, expectsError: true); + + SqlTestHelper.TestForErrorInGraphQLResponse(result.ToString()); + } + + #endregion + + #region Helpers + + /// + /// Fetches a single XmlProfile record by its primary key via GraphQL and returns the record element. + /// + private async Task GetRecordByPkAsync(int id) + { + string graphQLQueryName = "xmlProfile_by_pk"; + string graphQLQuery = @"{ + xmlProfile_by_pk(id: " + id + @") { + id + content + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + return result.Clone(); + } + + /// + /// Deletes an XmlProfile record by its primary key via a delete mutation. + /// + private async Task DeleteXmlProfileAsync(int id) + { + string deleteMutationName = "deleteXmlProfile"; + string deleteMutation = @"mutation { + deleteXmlProfile(id: " + id + @") { + id + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(deleteMutation, deleteMutationName, isAuthenticated: false); + Assert.AreEqual(id, result.GetProperty("id").GetInt32()); + } + + /// + /// Returns the string value of a record's content field. + /// + private static string ContentOf(JsonElement record) => record.GetProperty("content").GetString(); + + #endregion + } +} diff --git a/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestXmlTypesTests.cs b/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestXmlTypesTests.cs new file mode 100644 index 0000000000..17fb36ef3c --- /dev/null +++ b/src/Service.Tests/SqlTests/RestApiTests/MsSqlRestXmlTypesTests.cs @@ -0,0 +1,282 @@ +// 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 System.Xml.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Azure.DataApiBuilder.Service.Tests.SqlTests.SqlTestHelper; + +namespace Azure.DataApiBuilder.Service.Tests.SqlTests.RestApiTests +{ + /// + /// CRUD tests for SQL Server xml column support via REST endpoints. + /// DAB treats an xml column exactly like a string: the raw XML text is written from the request + /// payload and read back as a JSON string. Because XML attributes use double quotes, the value + /// is JSON-escaped on the wire (e.g. id=\"100\"); these tests assert that once the field is + /// read through a JSON parser the escaping is gone and the result is well-formed XML - i.e. the + /// value round-trips correctly through create/read/update/delete. + /// This complements the GraphQL coverage in + /// . + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class MsSqlRestXmlTypesTests : SqlTestBase + { + private const string XML_TYPE_REST_PATH = "api/XmlProfile"; + + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + } + + #region Read Tests + + /// + /// GET /api/XmlProfile - Verify the whole collection is returned and that each content value + /// renders either as a JSON string carrying valid XML or as null. + /// + [TestMethod] + public async Task GetXmlTypeList() + { + HttpResponseMessage response = await HttpClient.GetAsync(XML_TYPE_REST_PATH); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + JsonElement items = JsonDocument.Parse(await response.Content.ReadAsStringAsync()) + .RootElement.GetProperty("value"); + Assert.AreEqual(4, items.GetArrayLength(), "Expected the 4 seeded xml_profiles rows."); + + foreach (JsonElement record in items.EnumerateArray()) + { + JsonElement content = record.GetProperty("content"); + Assert.IsTrue( + content.ValueKind is JsonValueKind.String or JsonValueKind.Null, + $"Expected content to be a JSON string or null, but was {content.ValueKind}."); + + // When present, the string must be well-formed XML once JSON-decoded. + if (content.ValueKind is JsonValueKind.String) + { + AssertIsValidXml(content.GetString()); + } + } + } + + /// + /// GET /api/XmlProfile/id/1 - Verify the double-quoted attribute value round-trips as valid XML + /// with no stray backslashes once the JSON field is parsed. + /// + [TestMethod] + public async Task GetXmlTypeByPrimaryKey() + { + // The raw field is a JSON string; after parsing it must be valid XML with the quotes intact. + XElement root = AssertXmlWithId(GetContentString(await GetRecordByIdAsync(1)), "100"); + Assert.AreEqual("order", root.Name.LocalName); + } + + /// + /// GET /api/XmlProfile/id/2 - Verify an element with multiple quoted attributes round-trips. + /// + [TestMethod] + public async Task GetXmlTypeWithMultipleAttributes() + { + string content = GetContentString(await GetRecordByIdAsync(2)); + XElement root = AssertIsValidXml(content); + XElement setting = root.Element("setting"); + Assert.AreEqual("true", setting!.Attribute("enabled")!.Value); + Assert.AreEqual("cache", setting.Attribute("name")!.Value); + } + + /// + /// GET /api/XmlProfile/id/4 - Verify a SQL NULL xml value is rendered as JSON null. + /// + [TestMethod] + public async Task GetXmlTypeWithNull() + { + JsonElement record = await GetRecordByIdAsync(4); + Assert.AreEqual(JsonValueKind.Null, record.GetProperty("content").ValueKind); + } + + /// + /// GET /api/XmlProfile?$filter=content ne null - Verify filtering an xml column (treated as a + /// string) passes through to SQL: the 3 non-null rows match and the null row (id 4) does not. + /// + [TestMethod] + public async Task FilterXmlColumnIsNotNull_Succeeds() + { + HttpResponseMessage response = await HttpClient.GetAsync($"{XML_TYPE_REST_PATH}?$filter=content%20ne%20null"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Filtering an xml column as a string should pass through and succeed."); + + JsonElement items = JsonDocument.Parse(await response.Content.ReadAsStringAsync()) + .RootElement.GetProperty("value"); + Assert.AreEqual(3, items.GetArrayLength(), "Only the 3 rows with non-null content should match."); + } + + #endregion + + #region Write Tests + + /// + /// POST /api/XmlProfile - Verify a new record with an XML payload (with quoted attributes) can be + /// inserted, echoes back as valid XML, and is persisted. Also covers inserting a null payload. + /// + [DataTestMethod] + [DataRow("Pen", "200", false, DisplayName = "Insert xml with quoted attribute")] + [DataRow(null, null, true, DisplayName = "Insert null xml")] + public async Task InsertXmlType(string xml, string expectedId, bool expectNull) + { + HttpResponseMessage postResponse = await HttpClient.PostAsync(XML_TYPE_REST_PATH, XmlBody(xml)); + Assert.AreEqual(HttpStatusCode.Created, postResponse.StatusCode); + + int newId = JsonDocument.Parse(await postResponse.Content.ReadAsStringAsync()) + .RootElement.GetProperty("value")[0].GetProperty("id").GetInt32(); + + JsonElement readBack = await GetRecordByIdAsync(newId); + if (expectNull) + { + Assert.AreEqual(JsonValueKind.Null, readBack.GetProperty("content").ValueKind); + } + else + { + AssertXmlWithId(GetContentString(readBack), expectedId); + } + + await DeleteXmlProfile(newId); + } + + /// + /// PUT /api/XmlProfile/id/1 - Verify a full update replaces the XML payload, then restore it. + /// + [TestMethod] + public async Task PutXmlType_Update() + { + try + { + HttpResponseMessage response = await PutXmlAsync(1, "Updated"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + AssertXmlWithId(GetContentString(await GetRecordByIdAsync(1)), "999"); + } + finally + { + // Restore original value so shared row 1 is left intact for other tests. + HttpResponseMessage restore = await PutXmlAsync(1, "Book2"); + Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode); + } + } + + /// + /// PATCH /api/XmlProfile/id/1 - Verify a partial update sets a new XML payload, then restore it. + /// + [TestMethod] + public async Task PatchXmlType_Update() + { + try + { + HttpResponseMessage response = await HttpClient.PatchAsync( + $"{XML_TYPE_REST_PATH}/id/1", + XmlBody("Patched")); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + AssertXmlWithId(GetContentString(await GetRecordByIdAsync(1)), "555"); + } + finally + { + // Restore original value so shared row 1 is left intact for other tests. + HttpResponseMessage restore = await PutXmlAsync(1, "Book2"); + Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode); + } + } + + /// + /// PATCH /api/XmlProfile/id/2 - Verify content can be cleared to null. + /// + [TestMethod] + public async Task PatchXmlType_ToNull() + { + try + { + HttpResponseMessage response = await HttpClient.PatchAsync($"{XML_TYPE_REST_PATH}/id/2", XmlBody(null)); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + Assert.AreEqual(JsonValueKind.Null, (await GetRecordByIdAsync(2)).GetProperty("content").ValueKind); + } + finally + { + // Restore original value so shared row 2 is left intact for other tests. + HttpResponseMessage restore = await PutXmlAsync(2, ""); + Assert.AreEqual(HttpStatusCode.OK, restore.StatusCode); + } + } + + /// + /// POST /api/XmlProfile - Verify that supplying malformed XML for the xml column is rejected by + /// SQL Server and surfaced as HTTP 400 (a client input error), not a 500. DAB treats the value + /// as a normal string, so XML validation happens at the database boundary. + /// + [DataTestMethod] + [DataRow("Book", DisplayName = "Mismatched closing tag")] + [DataRow("", DisplayName = "Unquoted attribute")] + public async Task InsertMalformedXml_ReturnsBadRequest(string xml) + { + HttpResponseMessage response = await HttpClient.PostAsync(XML_TYPE_REST_PATH, XmlBody(xml)); + + Assert.AreEqual( + HttpStatusCode.BadRequest, + response.StatusCode, + "SQL Server rejects malformed XML for an xml column; DAB must surface it as HTTP 400."); + } + + #endregion + + #region Helpers + + /// + /// DELETE /api/XmlProfile/id/{id} - Verify a record can be deleted. + /// + private static async Task DeleteXmlProfile(int id) + { + HttpResponseMessage deleteResponse = await HttpClient.DeleteAsync($"{XML_TYPE_REST_PATH}/id/{id}"); + Assert.AreEqual(HttpStatusCode.NoContent, deleteResponse.StatusCode); + } + + /// + /// Fetches a single XmlProfile record by its primary key and returns the record element. + /// + private static async Task GetRecordByIdAsync(int id) + { + HttpResponseMessage response = await HttpClient.GetAsync($"{XML_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 content field as a string. DAB treats an xml column as a string, so a non-null + /// value arrives at the REST boundary as a JSON string carrying the XML text. + /// + private static string GetContentString(JsonElement record) + { + JsonElement content = record.GetProperty("content"); + Assert.AreEqual( + JsonValueKind.String, + content.ValueKind, + "An xml column must be returned as a JSON string at the REST boundary (treated as a normal string)."); + return content.GetString()!; + } + + /// + /// Builds a request body { "content": <xml> } (or null) without manual escaping. + /// + private static StringContent XmlBody(string xml) + => new(JsonSerializer.Serialize(new { content = xml }), Encoding.UTF8, "application/json"); + + /// + /// PUT /api/XmlProfile/id/{id} with the given XML content. + /// + private static Task PutXmlAsync(int id, string xml) + => HttpClient.PutAsync($"{XML_TYPE_REST_PATH}/id/{id}", XmlBody(xml)); + + #endregion + } +} diff --git a/src/Service.Tests/SqlTests/SqlTestHelper.cs b/src/Service.Tests/SqlTests/SqlTestHelper.cs index 5aa7cb1fe9..84378f5e2d 100644 --- a/src/Service.Tests/SqlTests/SqlTestHelper.cs +++ b/src/Service.Tests/SqlTests/SqlTestHelper.cs @@ -13,6 +13,7 @@ using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml.Linq; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; @@ -44,6 +45,49 @@ public static RuntimeConfig RemoveAllRelationshipBetweenEntities(RuntimeConfig r }; } + /// + /// Asserts that the given string is well-formed XML and returns its root element. + /// Shared by the REST and GraphQL xml-column tests. + /// + public static XElement AssertIsValidXml(string xml) + { + try + { + return XDocument.Parse(xml).Root!; + } + catch (System.Xml.XmlException ex) + { + Assert.Fail($"Expected a valid XML value but parsing failed: {ex.Message}. Value was: {xml}"); + return null!; + } + } + + /// + /// Asserts is well-formed XML carrying no leftover JSON backslash-escaping, + /// and that its root element's id attribute equals . Returns the root. + /// Shared by the REST and GraphQL xml-column tests. + /// + public static XElement AssertXmlWithId(string content, string expectedId) + { + XElement root = AssertIsValidXml(content); + Assert.AreEqual(expectedId, root.Attribute("id")!.Value); + return root; + } + + public static void AssertXmlContent(JsonElement record, string expectedXml) + { + JsonElement content = record.GetProperty("content"); + if (expectedXml is null) + { + Assert.AreEqual(JsonValueKind.Null, content.ValueKind); + return; + } + + Assert.AreEqual(JsonValueKind.String, content.ValueKind); + Assert.IsTrue(XNode.DeepEquals(AssertIsValidXml(expectedXml), AssertIsValidXml(content.GetString()!)), + "JSON-decoded XML must preserve elements, text, and quoted attributes."); + } + /// /// Converts strings to JSON objects and does a deep compare /// diff --git a/src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs b/src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs index 843e1eab52..f509db3046 100644 --- a/src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs +++ b/src/Service.Tests/UnitTests/DbExceptionParserUnitTests.cs @@ -33,24 +33,7 @@ public class DbExceptionParserUnitTests [DataRow(false, "While processing your request the database ran into an error.")] public void VerifyCorrectErrorMessage(bool isDeveloperMode, string expected) { - RuntimeConfig mockConfig = new( - Schema: "", - DataSource: new(DatabaseType.MSSQL, "", new()), - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(null, null, isDeveloperMode ? HostMode.Development : HostMode.Production) - ), - Entities: new(new Dictionary()) - ); - // We can use any other error code here, doesn't really matter. - int connectionEstablishmentError = 53; - - MockFileSystem fileSystem = new(); - fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson())); - FileSystemRuntimeConfigLoader loader = new(fileSystem); - RuntimeConfigProvider provider = new(loader); + RuntimeConfigProvider provider = CreateMockConfigProvider(isDeveloperMode ? HostMode.Development : HostMode.Production); // Invoke base class implementation if no expectation overrides the member // (a.k.a. "Partial Mocks" in Rhino Mocks): default is false. @@ -58,7 +41,8 @@ public void VerifyCorrectErrorMessage(bool isDeveloperMode, string expected) // not be invoked and will return null. // https://github.com/devlooped/moq/wiki/Quickstart#customizing-mock-behavior Mock parser = new(provider) { CallBase = true }; - DbException e = SqlTestHelper.CreateSqlException(connectionEstablishmentError, expected); + // 53 is a connection-establishment error; any code works for this message-only check. + DbException e = SqlTestHelper.CreateSqlException(53, expected); Exception parsedException = parser.Object.Parse(e); Assert.AreEqual(expected: expected, actual: parsedException.Message); } @@ -76,30 +60,15 @@ public void VerifyCorrectErrorMessage(bool isDeveloperMode, string expected) [DataRow(false, 209, DisplayName = "Non-transient exception error code #2")] public void TestIsTransientExceptionMethod(bool expected, int number) { - RuntimeConfig mockConfig = new( - Schema: "", - DataSource: new(DatabaseType.MSSQL, "", new()), - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(null, null, HostMode.Development) - ), - Entities: new(new Dictionary()) - ); - MockFileSystem fileSystem = new(); - fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson())); - FileSystemRuntimeConfigLoader loader = new(fileSystem); - RuntimeConfigProvider provider = new(loader); - DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(provider); + DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(CreateMockConfigProvider()); Assert.AreEqual(expected, dbExceptionParser.IsTransientException(SqlTestHelper.CreateSqlException(number))); } /// - /// Validates that JSON data type validation error codes raised by SQL Server 2025+ - /// when invalid JSON is supplied for a json column are mapped to HTTP 400 Bad Request - /// and are classified as client input errors. + /// Validates that client-input error codes - SQL Server 2025+ JSON validation errors (13608-13614) + /// and XML parsing errors (9400-9480, e.g. 9413 unquoted attribute, 9436 end tag mismatch) - are + /// mapped to HTTP 400 Bad Request and classified as client input errors. /// /// SQL error code populated in SqlException.Number. [DataTestMethod] @@ -110,24 +79,13 @@ public void TestIsTransientExceptionMethod(bool expected, int number) [DataRow(13612, DisplayName = "JSON validation error code 13612 maps to 400")] [DataRow(13613, DisplayName = "JSON validation error code 13613 maps to 400")] [DataRow(13614, DisplayName = "JSON validation error code 13614 maps to 400")] - public void TestJsonValidationErrorsMapToBadRequest(int number) + [DataRow(9400, DisplayName = "XML parsing error code 9400 (unexpected end of input) maps to 400")] + [DataRow(9413, DisplayName = "XML parsing error code 9413 (string literal expected) maps to 400")] + [DataRow(9436, DisplayName = "XML parsing error code 9436 (end tag mismatch) maps to 400")] + [DataRow(9480, DisplayName = "XML parsing error code 9480 (unsupported xml) maps to 400")] + public void TestClientInputErrorsMapToBadRequest(int number) { - RuntimeConfig mockConfig = new( - Schema: "", - DataSource: new(DatabaseType.MSSQL, "", new()), - Runtime: new( - Rest: new(), - GraphQL: new(), - Mcp: new(), - Host: new(null, null, HostMode.Development) - ), - Entities: new(new Dictionary()) - ); - MockFileSystem fileSystem = new(); - fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson())); - FileSystemRuntimeConfigLoader loader = new(fileSystem); - RuntimeConfigProvider provider = new(loader); - DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(provider); + DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(CreateMockConfigProvider()); DbException sqlException = SqlTestHelper.CreateSqlException(number); @@ -138,5 +96,20 @@ public void TestJsonValidationErrorsMapToBadRequest(int number) DataApiBuilderException.SubStatusCodes.DatabaseInputError, dbExceptionParser.GetResultSubStatusCodeForException(sqlException)); } + + /// + /// Builds a minimal MSSQL backed by an in-memory config file. + /// + private static RuntimeConfigProvider CreateMockConfigProvider(HostMode hostMode = HostMode.Development) + { + RuntimeConfig mockConfig = new( + Schema: "", + DataSource: new(DatabaseType.MSSQL, "", new()), + Runtime: new(Rest: new(), GraphQL: new(), Mcp: new(), Host: new(null, null, hostMode)), + Entities: new(new Dictionary())); + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(mockConfig.ToJson())); + return new RuntimeConfigProvider(new FileSystemRuntimeConfigLoader(fileSystem)); + } } } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index 670f390d4c..6b24a22c73 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -2042,6 +2042,58 @@ } ] }, + "XmlProfile": { + "source": { + "object": "xml_profiles", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "XmlProfile", + "plural": "XmlProfiles" + } + }, + "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",