Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config-generators/mssql-commands.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Models/SqlTypeConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions src/Core/Resolvers/MsSqlDbExceptionParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
{
// Transient error codes compiled from:
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 0 additions & 2 deletions src/Core/Resolvers/MsSqlQueryBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,6 @@ AND ty.name IN
N'geometry',
N'hierarchyid',
N'sql_variant',
N'xml',
N'rowversion'
)
) THEN 1
Expand Down Expand Up @@ -744,7 +743,6 @@ AND ty.name IN
N'geometry',
N'hierarchyid',
N'sql_variant',
N'xml',
N'rowversion'
)
)
Expand Down
3 changes: 2 additions & 1 deletion src/Core/Services/TypeHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
JerryNixon marked this conversation as resolved.
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to handle XML explicitly in filtering and ordering during queries? Do we have supporting tests covering those?


private static readonly Dictionary<SqlDbType, DbType> _sqlDbDateTimeTypeToDbType = new()
Expand Down
32 changes: 25 additions & 7 deletions src/Service.Tests/DatabaseSchema-MsSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -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, '<doc><type>report</type></doc>'),
(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, '<author>admin</author>'),
(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, '<metadata><key>value</key></metadata>'),
(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, '<config><setting enabled="true"/></config>'),
(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

Expand Down Expand Up @@ -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'<order id="100"><item>Book</item><qty>2</qty></order>'),
(2, N'<config><setting enabled="true" name="cache"/></config>'),
(3, N'<note><to>User</to><from>Admin</from></note>'),
(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
Expand Down
187 changes: 187 additions & 0 deletions src/Service.Tests/Mcp/MsSqlMcpXmlTypesTests.cs
Original file line number Diff line number Diff line change
@@ -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 = "<order id=\"200\"><item>Pen</item></order>";

[ClassInitialize]
public static async Task SetupAsync(TestContext context)
{
DatabaseEngine = TestCategory.MSSQL;
await InitializeTestFixture();
}

/// <summary>Verifies MCP reads preserve XML attributes and SQL null values.</summary>
[DataTestMethod]
[DataRow(1, "<order id=\"100\"><item>Book</item><qty>2</qty></order>", DisplayName = "Read XML with a quoted attribute")]
[DataRow(2, "<config><setting enabled=\"true\" name=\"cache\"/></config>", 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);
}

/// <summary>Verifies an MCP collection returns valid XML strings.</summary>
[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()!);
}
}
}

/// <summary>Verifies filtering an XML column excludes records with null content.</summary>
[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());
}
}

/// <summary>Verifies MCP create/read round trips preserve XML or null, and deletion removes the record.</summary>
[DataTestMethod]
[DataRow(ORIGINAL_XML, DisplayName = "Create and delete XML with a quoted attribute")]
[DataRow("<config><setting enabled=\"true\" name=\"cache\"/></config>", 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());
}

/// <summary>Verifies MCP updates preserve XML attributes and escaped text, or clear content to null.</summary>
[DataTestMethod]
[DataRow("<order id=\"999\"><item>Updated &amp; quoted</item></order>", DisplayName = "Update XML with a quoted attribute and escaped text")]
[DataRow("<config><setting enabled=\"false\" name=\"updated\"/></config>", 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);
}
}

/// <summary>Verifies MCP creation rejects malformed XML at the database boundary.</summary>
[DataTestMethod]
[DataRow("<order><item>Book</order>", DisplayName = "Reject XML creation with a mismatched closing tag")]
[DataRow("<order id=unquoted></order>", DisplayName = "Reject XML creation with an unquoted attribute")]
public async Task CreateMalformedXml_ReturnsError(string xml)
{
AssertError(await CreateXmlAsync(xml));
}

/// <summary>Verifies rejected malformed XML updates leave the stored content unchanged.</summary>
[DataTestMethod]
[DataRow("<order><item>Book</order>", DisplayName = "Reject XML update with a mismatched closing tag and preserve content")]
[DataRow("<order id=unquoted></order>", 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<JsonElement> ReadXmlAsync(string filter)
{
CallToolResult result = await ExecuteToolAsync(new ReadRecordsTool(), BuildQueryServiceProvider(),
new Dictionary<string, object?> { { "entity", ENTITY_NAME }, { "filter", filter }, { "select", "id,content" } });
AssertSuccess(result, "Reading XML content should succeed.");
return GetRecordsArray(ParseResultRoot(result));
}

private static Task<CallToolResult> CreateXmlAsync(string xml)
{
return ExecuteToolAsync(new CreateRecordTool(), BuildMutationServiceProvider(),
new Dictionary<string, object?>
{
{ "entity", ENTITY_NAME },
{ "data", new Dictionary<string, object?> { { "content", xml } } }
});
}

private static Task<CallToolResult> UpdateXmlAsync(int id, string xml)
{
return ExecuteToolAsync(new UpdateRecordTool(), BuildMutationServiceProvider(),
new Dictionary<string, object?>
{
{ "entity", ENTITY_NAME },
{ "keys", new Dictionary<string, object> { { "id", id } } },
{ "fields", new Dictionary<string, object?> { { "content", xml } } }
});
}

private static async Task DeleteXmlAsync(int id)
{
CallToolResult result = await ExecuteToolAsync(new DeleteRecordTool(), BuildMutationServiceProvider(),
new Dictionary<string, object?>
{
{ "entity", ENTITY_NAME },
{ "keys", new Dictionary<string, object> { { "id", id } } }
});
AssertSuccess(result, "Deleting the XML fixture should succeed.");
}

}
}
Loading
Loading