Skip to content

Commit d17d1d8

Browse files
authored
Merge branch 'main' into Unflaking
2 parents 0259fad + 654a010 commit d17d1d8

8 files changed

Lines changed: 305 additions & 79 deletions

File tree

src/EFCore.SqlServer/Storage/Internal/SqlServerStringTypeMapping.cs

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Data;
5-
using System.Data.SqlTypes;
65
using System.Text;
7-
using System.Xml;
86
using Microsoft.Data.SqlClient;
97
using Microsoft.EntityFrameworkCore.Storage.Json;
108

@@ -21,15 +19,6 @@ public class SqlServerStringTypeMapping : StringTypeMapping
2119
private const int UnicodeMax = 4000;
2220
private const int AnsiMax = 8000;
2321

24-
// DTD processing is explicitly prohibited and no external resolver is used so that a malicious
25-
// payload is rejected rather than processed.
26-
private static readonly XmlReaderSettings XmlFragmentSettings = new()
27-
{
28-
ConformanceLevel = ConformanceLevel.Fragment,
29-
DtdProcessing = DtdProcessing.Prohibit,
30-
XmlResolver = null
31-
};
32-
3322
private static readonly CaseInsensitiveValueComparer CaseInsensitiveValueComparer = new();
3423

3524
private readonly bool _isUtf16;
@@ -169,15 +158,17 @@ protected override void ConfigureParameter(DbParameter parameter)
169158

170159
if (_sqlDbType == SqlDbType.Xml)
171160
{
172-
// SqlClient only sends a parameter as 'xml' (rather than 'nvarchar(max)') when its value is a SqlXml.
173-
// Sending it as 'xml' lets SQL Server honor any encoding declared in the XML prolog (e.g. utf-8), which
174-
// otherwise fails with "unable to switch the encoding". A fragment-conformant reader is used so that the
175-
// content forms that the 'xml' store type accepts - empty strings, text, and multiple top-level nodes -
176-
// continue to round-trip.
161+
// xml is a max-length type; pin the size to unbounded so SqlClient does not infer it from the
162+
// value length (which would otherwise vary per value in logs and the query cache).
163+
parameter.Size = -1;
164+
165+
// SqlClient sends a string parameter as 'nvarchar(max)' even when its SqlDbType is 'xml', which makes
166+
// SQL Server reject an XML prolog that declares a non-UTF-16 encoding (e.g. utf-8) with "unable to switch
167+
// the encoding". Only the XML declaration conflicts, and it can only appear at the very start, so it is
168+
// sliced off and the rest of the value is sent verbatim.
177169
if (value is string xml)
178170
{
179-
using var reader = XmlReader.Create(new StringReader(xml), XmlFragmentSettings);
180-
parameter.Value = new SqlXml(reader);
171+
parameter.Value = RemoveXmlProlog(xml);
181172
}
182173

183174
return;
@@ -220,6 +211,30 @@ protected override void ConfigureParameter(DbParameter parameter)
220211
}
221212
}
222213

214+
private static string RemoveXmlProlog(string xml)
215+
{
216+
var start = 0;
217+
while (start < xml.Length && char.IsWhiteSpace(xml[start]))
218+
{
219+
start++;
220+
}
221+
222+
// An XML declaration starts with "<?xml" followed by mandatory whitespace. If present, remove everything
223+
// up to and including the closing "?>" and return the remainder verbatim.
224+
if (start + 5 < xml.Length
225+
&& string.CompareOrdinal(xml, start, "<?xml", 0, 5) == 0
226+
&& char.IsWhiteSpace(xml[start + 5]))
227+
{
228+
var end = xml.IndexOf("?>", start + 6, StringComparison.Ordinal);
229+
if (end >= 0)
230+
{
231+
return xml[(end + 2)..];
232+
}
233+
}
234+
235+
return xml;
236+
}
237+
223238
/// <summary>
224239
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
225240
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.EntityFrameworkCore.Query.Internal;
5+
6+
public partial class NavigationExpandingExpressionVisitor
7+
{
8+
private NavigationExpansionExpression LiftSingleResultSubqueries(NavigationExpansionExpression source)
9+
{
10+
var selectorBody = source.PendingSelector;
11+
12+
var collector = new SingleResultMemberAccessCollector();
13+
collector.Visit(selectorBody);
14+
15+
foreach (var subquery in collector.Liftable)
16+
{
17+
var collection = BuildSingleResultCollection(subquery);
18+
var innerParameter = Expression.Parameter(collection.Type.GetSequenceType(), "e");
19+
var rewrittenBody = new ReplacingExpressionVisitor([subquery], [innerParameter]).Visit(selectorBody);
20+
21+
// The collection already references the outer element via source.PendingSelector; this parameter is unused.
22+
source = ProcessSelectMany(
23+
source,
24+
Expression.Lambda(collection, Expression.Parameter(source.SourceElementType, "o")),
25+
Expression.Lambda(rewrittenBody, Expression.Parameter(source.SourceElementType, "o"), innerParameter));
26+
selectorBody = source.PendingSelector;
27+
}
28+
29+
return source;
30+
}
31+
32+
private static Expression BuildSingleResultCollection(MethodCallExpression subqueryMethod)
33+
{
34+
var method = subqueryMethod.Method.GetGenericMethodDefinition();
35+
var source = subqueryMethod.Arguments[0];
36+
var elementType = source.Type.GetSequenceType();
37+
38+
Expression Where(Expression c)
39+
=> Expression.Call(QueryableMethods.Where.MakeGenericMethod(elementType), c, subqueryMethod.Arguments[1]);
40+
Expression Skip(Expression c)
41+
=> Expression.Call(QueryableMethods.Skip.MakeGenericMethod(elementType), c, subqueryMethod.Arguments[1]);
42+
Expression Reverse(Expression c)
43+
=> Expression.Call(QueryableMethods.Reverse.MakeGenericMethod(elementType), c);
44+
45+
var oneRow = method switch
46+
{
47+
_ when method == QueryableMethods.FirstWithPredicate || method == QueryableMethods.FirstOrDefaultWithPredicate
48+
|| method == QueryableMethods.SingleWithPredicate || method == QueryableMethods.SingleOrDefaultWithPredicate
49+
=> Where(source),
50+
_ when method == QueryableMethods.LastWithPredicate || method == QueryableMethods.LastOrDefaultWithPredicate
51+
=> Reverse(Where(source)),
52+
_ when method == QueryableMethods.LastWithoutPredicate || method == QueryableMethods.LastOrDefaultWithoutPredicate
53+
=> Reverse(source),
54+
_ when method == QueryableMethods.ElementAt || method == QueryableMethods.ElementAtOrDefault
55+
=> Skip(source),
56+
_ => source
57+
};
58+
59+
var firstRow = Expression.Call(QueryableMethods.Take.MakeGenericMethod(elementType), oneRow, Expression.Constant(1));
60+
61+
return Expression.Call(QueryableMethods.DefaultIfEmptyWithoutArgument.MakeGenericMethod(elementType), firstRow);
62+
}
63+
64+
private sealed class SingleResultMemberAccessCollector : ExpressionVisitor
65+
{
66+
private static readonly HashSet<MethodInfo> SingleResultMethods =
67+
[
68+
QueryableMethods.FirstWithPredicate, QueryableMethods.FirstWithoutPredicate,
69+
QueryableMethods.FirstOrDefaultWithPredicate, QueryableMethods.FirstOrDefaultWithoutPredicate,
70+
QueryableMethods.SingleWithPredicate, QueryableMethods.SingleWithoutPredicate,
71+
QueryableMethods.SingleOrDefaultWithPredicate, QueryableMethods.SingleOrDefaultWithoutPredicate,
72+
QueryableMethods.LastWithPredicate, QueryableMethods.LastWithoutPredicate,
73+
QueryableMethods.LastOrDefaultWithPredicate, QueryableMethods.LastOrDefaultWithoutPredicate,
74+
QueryableMethods.ElementAt, QueryableMethods.ElementAtOrDefault
75+
];
76+
77+
private readonly Dictionary<MethodCallExpression, int> _memberAccessCount = new(ReferenceEqualityComparer.Instance);
78+
79+
public IEnumerable<MethodCallExpression> Liftable
80+
=> _memberAccessCount.Where(e => e.Value > 1).Select(e => e.Key);
81+
82+
protected override Expression VisitMember(MemberExpression memberExpression)
83+
{
84+
if (memberExpression.Expression is MethodCallExpression { Method.IsGenericMethod: true } subquery
85+
&& SingleResultMethods.Contains(subquery.Method.GetGenericMethodDefinition()))
86+
{
87+
if (_memberAccessCount.TryGetValue(subquery, out var count))
88+
{
89+
_memberAccessCount[subquery] = count + 1;
90+
}
91+
else
92+
{
93+
// First encounter: count it and visit its arguments once (a later access duplicates the count)
94+
_memberAccessCount[subquery] = 1;
95+
96+
foreach (var argument in subquery.Arguments)
97+
{
98+
Visit(argument);
99+
}
100+
}
101+
102+
return memberExpression;
103+
}
104+
105+
return base.VisitMember(memberExpression);
106+
}
107+
}
108+
}

src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -671,9 +671,10 @@ when QueryableMethods.IsSumWithSelector(method):
671671

672672
case nameof(Queryable.Select)
673673
when genericMethod == QueryableMethods.Select:
674-
return ProcessSelect(
675-
source,
676-
methodCallExpression.Arguments[1].UnwrapLambdaFromQuote());
674+
return LiftSingleResultSubqueries(
675+
ProcessSelect(
676+
source,
677+
methodCallExpression.Arguments[1].UnwrapLambdaFromQuote()));
677678

678679
case nameof(Queryable.Where)
679680
when genericMethod == QueryableMethods.Where:

test/EFCore.Cosmos.FunctionalTests/Query/NorthwindSelectQueryCosmosTest.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,13 @@ public override async Task Projection_in_a_subquery_should_be_liftable(bool asyn
695695
public override Task Projection_containing_DateTime_subtraction(bool async)
696696
=> Assert.ThrowsAsync<InvalidOperationException>(() => base.Projection_containing_DateTime_subtraction(async));
697697

698+
public override async Task Multiple_members_of_correlated_single_result_subquery_lift_to_single_join(bool async, string method)
699+
{
700+
await AssertTranslationFailed(() => base.Multiple_members_of_correlated_single_result_subquery_lift_to_single_join(async, method));
701+
702+
AssertSql();
703+
}
704+
698705
public override async Task Project_single_element_from_collection_with_OrderBy_Take_and_FirstOrDefault(bool async)
699706
{
700707
// Cosmos client evaluation. Issue #17246.

test/EFCore.Specification.Tests/Query/NorthwindSelectQueryTestBase.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2303,4 +2303,55 @@ from o2 in ss.Set<Order>()
23032303
}).Take(5),
23042304
assertOrder: true,
23052305
elementAsserter: (e, a) => AssertCollection(e.OrderIds, a.OrderIds, elementSorter: ee => ee));
2306+
2307+
public static TheoryData<bool, string> SingleResultMethodData()
2308+
=> new(
2309+
from async in new[] { true, false }
2310+
from method in new[]
2311+
{
2312+
nameof(Queryable.First), nameof(Queryable.FirstOrDefault),
2313+
nameof(Queryable.Single), nameof(Queryable.SingleOrDefault),
2314+
nameof(Queryable.Last), nameof(Queryable.LastOrDefault),
2315+
nameof(Queryable.ElementAt), nameof(Queryable.ElementAtOrDefault)
2316+
}
2317+
select (async, method));
2318+
2319+
[Theory, MemberData(nameof(SingleResultMethodData))]
2320+
public virtual Task Multiple_members_of_correlated_single_result_subquery_lift_to_single_join(bool async, string method)
2321+
=> AssertQuery(
2322+
async,
2323+
ss =>
2324+
{
2325+
var customers = ss.Set<Customer>();
2326+
var orders = ss.Set<Order>().Where(o => o.CustomerID != null);
2327+
return method switch
2328+
{
2329+
nameof(Queryable.First) => from o in orders
2330+
let c = customers.First(c => c.CustomerID == o.CustomerID)
2331+
select new { o.OrderID, c.City, c.Country, c.ContactName },
2332+
nameof(Queryable.FirstOrDefault) => from o in orders
2333+
let c = customers.FirstOrDefault(c => c.CustomerID == o.CustomerID)
2334+
select new { o.OrderID, c.City, c.Country, c.ContactName },
2335+
nameof(Queryable.Single) => from o in orders
2336+
let c = customers.Single(c => c.CustomerID == o.CustomerID)
2337+
select new { o.OrderID, c.City, c.Country, c.ContactName },
2338+
nameof(Queryable.SingleOrDefault) => from o in orders
2339+
let c = customers.SingleOrDefault(c => c.CustomerID == o.CustomerID)
2340+
select new { o.OrderID, c.City, c.Country, c.ContactName },
2341+
nameof(Queryable.Last) => from o in orders
2342+
let c = customers.OrderBy(c => c.CustomerID).Last(c => c.CustomerID == o.CustomerID)
2343+
select new { o.OrderID, c.City, c.Country, c.ContactName },
2344+
nameof(Queryable.LastOrDefault) => from o in orders
2345+
let c = customers.OrderBy(c => c.CustomerID).LastOrDefault(c => c.CustomerID == o.CustomerID)
2346+
select new { o.OrderID, c.City, c.Country, c.ContactName },
2347+
nameof(Queryable.ElementAt) => from o in orders
2348+
let c = customers.Where(c => c.CustomerID == o.CustomerID).OrderBy(c => c.CustomerID).ElementAt(0)
2349+
select new { o.OrderID, c.City, c.Country, c.ContactName },
2350+
nameof(Queryable.ElementAtOrDefault) => from o in orders
2351+
let c = customers.Where(c => c.CustomerID == o.CustomerID).OrderBy(c => c.CustomerID).ElementAtOrDefault(0)
2352+
select new { o.OrderID, c.City, c.Country, c.ContactName },
2353+
_ => throw new InvalidOperationException(method)
2354+
};
2355+
},
2356+
elementSorter: e => e.OrderID);
23062357
}

test/EFCore.SqlServer.FunctionalTests/BuiltInDataTypesSqlServerTest.cs

Lines changed: 28 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3820,20 +3820,31 @@ FROM INFORMATION_SCHEMA.COLUMNS
38203820
// The grinning-face emoji is outside the BMP (a UTF-16 surrogate pair, four UTF-8 bytes) and the euro sign
38213821
// is a single UTF-16 code unit but three UTF-8 bytes; both are represented differently in UTF-16 than in
38223822
// UTF-8 and are lost when an xml value is sent to the server as a non-Unicode string, which makes them good
3823-
// probes for the SqlXml/SqlDbType.Xml parameter path.
3823+
// probes for the SqlDbType.Xml parameter path.
38243824
private const string XmlEmoji = "\U0001F600";
38253825
private const string XmlEuro = "\u20AC";
38263826

38273827
[Theory]
3828-
[InlineData("<root>" + XmlEmoji + XmlEuro + "</root>", "<root>" + XmlEmoji + XmlEuro + "</root>")]
3829-
// An explicit non-UTF-16 prolog is accepted because the value is sent as 'xml', not 'nvarchar(max)'.
3830-
[InlineData("<?xml version=\"1.0\" encoding=\"utf-8\"?><root>" + XmlEmoji + "</root>", "<root>" + XmlEmoji + "</root>")]
3831-
[InlineData("<?xml version=\"1.0\" encoding=\"utf-16\"?><root>" + XmlEuro + "</root>", "<root>" + XmlEuro + "</root>")]
3828+
[InlineData(
3829+
"<root>" + XmlEmoji + XmlEuro + "</root>",
3830+
"<root>" + XmlEmoji + XmlEuro + "</root>",
3831+
"<root>" + XmlEmoji + XmlEuro + "</root>")]
3832+
// Only the XML declaration is removed; a following stylesheet PI and the rest of the value are sent verbatim.
3833+
[InlineData(
3834+
"<?xml version=\"1.0\" encoding=\"utf-8\" standalone='yes' ?> <?xml-stylesheet href=\"style.xsl\" type=\"text/xml\"?> <root>" + XmlEmoji + "</root>",
3835+
" <?xml-stylesheet href=\"style.xsl\" type=\"text/xml\"?> <root>" + XmlEmoji + "</root>",
3836+
"<?xml-stylesheet href=\"style.xsl\" type=\"text/xml\"?><root>" + XmlEmoji + "</root>")]
3837+
// The leading whitespace and the declaration are removed when the value is sent.
3838+
[InlineData(
3839+
" <?xml version=\"1.1\" encoding=\"utf-16\"?> <root>" + XmlEuro + "</root>",
3840+
" <root>" + XmlEuro + "</root>",
3841+
"<root>" + XmlEuro + "</root>")]
38323842
// Content forms that the 'xml' store type accepts beyond a single well-formed document.
3833-
[InlineData("", "")]
3834-
[InlineData("text fragment", "text fragment")]
3835-
[InlineData("<a/><b/>", "<a /><b />")]
3836-
public async Task Xml_value_round_trips(string value, string expected)
3843+
[InlineData("", "", "")]
3844+
[InlineData("text fragment", "text fragment", "text fragment")]
3845+
// The content is sent verbatim, but the server expands self-closing tags when the xml column is read back.
3846+
[InlineData("<a/><b/>", "<a/><b/>", "<a /><b />")]
3847+
public async Task Xml_value_round_trips(string value, string expected, string roundTripped)
38373848
{
38383849
await using var context = CreateContext();
38393850

@@ -3845,7 +3856,7 @@ public async Task Xml_value_round_trips(string value, string expected)
38453856
context.ChangeTracker.Clear();
38463857

38473858
// xml columns cannot be compared directly in a WHERE clause, so the row is fetched by its key. Coalescing
3848-
// the column with the original value sends that value as an 'xml' parameter, exercising the SqlXml
3859+
// the column with the original value sends that value as an 'xml' parameter, exercising the prolog-removal
38493860
// parameter path in a query in addition to the insert above.
38503861
var query = context.Set<XmlTestDocument>()
38513862
.Where(d => d.Id == id)
@@ -3859,15 +3870,16 @@ public async Task Xml_value_round_trips(string value, string expected)
38593870
SELECT COALESCE([x].[Content], @value)
38603871
FROM [XmlTestDocument] AS [x]
38613872
WHERE [x].[Id] = @id
3862-
""".ReplaceLineEndings(),
3863-
query.ToQueryString().ReplaceLineEndings());
3873+
""",
3874+
query.ToQueryString(),
3875+
ignoreLineEndingDifferences: true);
38643876

3865-
var roundTripped = await query.SingleAsync();
3866-
Assert.Equal(expected, roundTripped);
3877+
var actual = await query.SingleAsync();
3878+
Assert.Equal(roundTripped, actual);
38673879

38683880
AssertSql(
38693881
$"""
3870-
@p0='{expected}' (DbType = Xml)
3882+
@p0='{expected}' (Size = -1) (DbType = Xml)
38713883
38723884
SET IMPLICIT_TRANSACTIONS OFF;
38733885
SET NOCOUNT ON;
@@ -3877,7 +3889,7 @@ OUTPUT INSERTED.[Id]
38773889
""",
38783890
//
38793891
$"""
3880-
@value='{expected}' (DbType = Xml)
3892+
@value='{expected}' (Size = -1) (DbType = Xml)
38813893
@id='{id}'
38823894
38833895
SELECT TOP(2) COALESCE([x].[Content], @value)
@@ -3886,40 +3898,6 @@ FROM [XmlTestDocument] AS [x]
38863898
""");
38873899
}
38883900

3889-
[Fact]
3890-
public async Task Xml_value_with_dtd_payload_is_rejected()
3891-
{
3892-
await using var context = CreateContext();
3893-
3894-
// A "billion laughs" entity-expansion payload: the reader must reject the DTD rather than expand it.
3895-
const string maliciousXml =
3896-
"<?xml version=\"1.0\"?>"
3897-
+ "<!DOCTYPE lolz [<!ENTITY lol \"lol\">"
3898-
+ "<!ENTITY lol2 \"&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;\">"
3899-
+ "<!ENTITY lol3 \"&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;\">]>"
3900-
+ "<lolz>&lol3;</lolz>";
3901-
3902-
context.Add(new XmlTestDocument { Content = maliciousXml });
3903-
3904-
var exception = await Assert.ThrowsAnyAsync<Exception>(() => context.SaveChangesAsync());
3905-
Assert.True(
3906-
HasXmlException(exception),
3907-
$"Expected an {nameof(XmlException)} in the exception chain but found: {exception}");
3908-
3909-
static bool HasXmlException(Exception exception)
3910-
{
3911-
for (var current = exception; current is not null; current = current.InnerException)
3912-
{
3913-
if (current is XmlException)
3914-
{
3915-
return true;
3916-
}
3917-
}
3918-
3919-
return false;
3920-
}
3921-
}
3922-
39233901
private class XmlTestDocument
39243902
{
39253903
public int Id { get; set; }

0 commit comments

Comments
 (0)