Skip to content

Commit df417e1

Browse files
committed
Fully implement element nullability for primitive collections
Closes dotnet#31416
1 parent c68ea72 commit df417e1

12 files changed

+541
-334
lines changed

src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs

+14-14
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ when entityQueryRootExpression.GetType() == typeof(EntityQueryRootExpression)
219219
Check.DebugAssert(sqlParameterExpression is not null, "sqlParameterExpression is not null");
220220
return TranslateCollection(
221221
sqlParameterExpression,
222-
elementTypeMapping: null,
222+
property: null,
223223
char.ToLowerInvariant(sqlParameterExpression.Name.First(c => c != '_')).ToString())
224224
?? base.VisitExtension(extensionExpression);
225225

@@ -265,20 +265,22 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
265265
if (translated == QueryCompilationContext.NotTranslatedExpression)
266266
{
267267
// Attempt to translate access into a primitive collection property (i.e. array column)
268-
if (_sqlTranslator.TryTranslatePropertyAccess(methodCallExpression, out var propertyAccessExpression)
269-
&& propertyAccessExpression is SqlExpression
270-
{
271-
TypeMapping.ElementTypeMapping: RelationalTypeMapping elementTypeMapping
272-
} collectionPropertyAccessExpression)
268+
if (_sqlTranslator.TryTranslatePropertyAccess(methodCallExpression, out var translatedExpression, out var property)
269+
&& property is IProperty regularProperty
270+
&& regularProperty.GetElementType() is not null)
273271
{
274-
var tableAlias = collectionPropertyAccessExpression switch
272+
Check.DebugAssert(
273+
translatedExpression is SqlExpression { TypeMapping.ElementTypeMapping: not null },
274+
"Translated property access for a primitive collection property isn't a SqlExpression with a type mapping");
275+
276+
var tableAlias = translatedExpression switch
275277
{
276278
ColumnExpression c => c.Name[..1].ToLowerInvariant(),
277279
JsonScalarExpression { Path: [.., { PropertyName: string propertyName }] } => propertyName[..1].ToLowerInvariant(),
278280
_ => "j"
279281
};
280282

281-
if (TranslateCollection(collectionPropertyAccessExpression, elementTypeMapping, tableAlias) is
283+
if (TranslateCollection((SqlExpression)translatedExpression, regularProperty, tableAlias) is
282284
{ } primitiveCollectionTranslation)
283285
{
284286
return primitiveCollectionTranslation;
@@ -316,17 +318,15 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp
316318
/// collections.
317319
/// </remarks>
318320
/// <param name="sqlExpression">The expression to try to translate as a primitive collection expression.</param>
319-
/// <param name="elementTypeMapping">
320-
/// The type mapping of the collection's element, or <see langword="null" /> when it's not known (i.e. for parameters).
321+
/// <param name="property">
322+
/// If the primitive collection is a property, contains the <see cref="IProperty" /> for that property. Otherwise, the collection
323+
/// represents a parameter, and this contains <see langword="null" />.
321324
/// </param>
322325
/// <param name="tableAlias">
323326
/// Provides an alias to be used for the table returned from translation, which will represent the collection.
324327
/// </param>
325328
/// <returns>A <see cref="ShapedQueryExpression" /> if the translation was successful, otherwise <see langword="null" />.</returns>
326-
protected virtual ShapedQueryExpression? TranslateCollection(
327-
SqlExpression sqlExpression,
328-
RelationalTypeMapping? elementTypeMapping,
329-
string tableAlias)
329+
protected virtual ShapedQueryExpression? TranslateCollection(SqlExpression sqlExpression, IProperty? property, string tableAlias)
330330
=> null;
331331

332332
/// <summary>

src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs

+135-118
Large diffs are not rendered by default.

src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs

+12-4
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ protected override Expression VisitExtension(Expression extensionExpression)
129129
/// </summary>
130130
protected override ShapedQueryExpression? TranslateCollection(
131131
SqlExpression sqlExpression,
132-
RelationalTypeMapping? elementTypeMapping,
132+
IProperty? property,
133133
string tableAlias)
134134
{
135135
if (_sqlServerCompatibilityLevel < 130)
@@ -145,6 +145,8 @@ protected override Expression VisitExtension(Expression extensionExpression)
145145
// (i.e. with a columnInfo), which determines the type conversion to apply to the JSON elements coming out.
146146
// For parameter collections, the element type mapping will only be inferred and applied later (see
147147
// SqlServerInferredTypeMappingApplier below), at which point the we'll apply it to add the WITH clause.
148+
var elementTypeMapping = (RelationalTypeMapping?)sqlExpression.TypeMapping?.ElementTypeMapping;
149+
148150
var openJsonExpression = elementTypeMapping is null
149151
? new SqlServerOpenJsonExpression(tableAlias, sqlExpression)
150152
: new SqlServerOpenJsonExpression(
@@ -159,17 +161,23 @@ protected override Expression VisitExtension(Expression extensionExpression)
159161
}
160162
});
161163

162-
// TODO: This is a temporary CLR type-based check; when we have proper metadata to determine if the element is nullable, use it here
163164
var elementClrType = sqlExpression.Type.GetSequenceType();
164-
var isColumnNullable = elementClrType.IsNullableType();
165+
166+
// If this is a collection property, get the element's nullability out of metadata. Otherwise, this is a parameter property, in
167+
// which case we only have the CLR type (note that we cannot produce different SQLs based on the nullability of an *element* in
168+
// a parameter collection - our caching mechanism only supports varying by the nullability of the parameter itself (i.e. the
169+
// collection).
170+
var isElementNullable = property is null
171+
? elementClrType.IsNullableType()
172+
: property.GetElementType()!.IsNullable;
165173

166174
#pragma warning disable EF1001 // Internal EF Core API usage.
167175
var selectExpression = new SelectExpression(
168176
openJsonExpression,
169177
columnName: "value",
170178
columnType: elementClrType,
171179
columnTypeMapping: elementTypeMapping,
172-
isColumnNullable,
180+
isElementNullable,
173181
identifierColumnName: "key",
174182
identifierColumnType: typeof(string),
175183
identifierColumnTypeMapping: _typeMappingSource.FindMapping("nvarchar(4000)"));

src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs

+10-4
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVis
203203
/// </summary>
204204
protected override ShapedQueryExpression? TranslateCollection(
205205
SqlExpression sqlExpression,
206-
RelationalTypeMapping? elementTypeMapping,
206+
IProperty? property,
207207
string tableAlias)
208208
{
209209
// Support for JSON functions (e.g. json_each) was added in Sqlite 3.38.0 (2022-02-22, see https://www.sqlite.org/json1.html).
@@ -215,19 +215,25 @@ protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVis
215215
return null;
216216
}
217217

218+
var elementTypeMapping = (RelationalTypeMapping?)sqlExpression.TypeMapping?.ElementTypeMapping;
218219
var elementClrType = sqlExpression.Type.GetSequenceType();
219220
var jsonEachExpression = new JsonEachExpression(tableAlias, sqlExpression);
220221

221-
// TODO: This is a temporary CLR type-based check; when we have proper metadata to determine if the element is nullable, use it here
222-
var isColumnNullable = elementClrType.IsNullableType();
222+
// If this is a collection property, get the element's nullability out of metadata. Otherwise, this is a parameter property, in
223+
// which case we only have the CLR type (note that we cannot produce different SQLs based on the nullability of an *element* in
224+
// a parameter collection - our caching mechanism only supports varying by the nullability of the parameter itself (i.e. the
225+
// collection).
226+
var isElementNullable = property is null
227+
? elementClrType.IsNullableType()
228+
: property.GetElementType()!.IsNullable;
223229

224230
#pragma warning disable EF1001 // Internal EF Core API usage.
225231
var selectExpression = new SelectExpression(
226232
jsonEachExpression,
227233
columnName: "value",
228234
columnType: elementClrType,
229235
columnTypeMapping: elementTypeMapping,
230-
isColumnNullable,
236+
isElementNullable,
231237
identifierColumnName: "key",
232238
identifierColumnType: typeof(int),
233239
identifierColumnTypeMapping: _typeMappingSource.FindMapping(typeof(int)));

src/EFCore/Metadata/Conventions/NonNullableConventionBase.cs

+12-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics.CodeAnalysis;
5+
46
namespace Microsoft.EntityFrameworkCore.Metadata.Conventions;
57

68
/// <summary>
@@ -33,13 +35,19 @@ protected NonNullableConventionBase(ProviderConventionSetBuilderDependencies dep
3335
/// </summary>
3436
/// <param name="modelBuilder">The model builder used to build the model.</param>
3537
/// <param name="memberInfo">The member info.</param>
38+
/// <param name="nullabilityInfo">
39+
/// The nullability info for the <paramref name="memberInfo" />, or <see langword="null" /> if it does not represent a valid reference
40+
/// type.
41+
/// </param>
3642
/// <returns><see langword="true" /> if the member type is a non-nullable reference type.</returns>
37-
protected virtual bool IsNonNullableReferenceType(
43+
protected virtual bool TryGetNullabilityInfo(
3844
IConventionModelBuilder modelBuilder,
39-
MemberInfo memberInfo)
45+
MemberInfo memberInfo,
46+
[NotNullWhen(true)] out NullabilityInfo? nullabilityInfo)
4047
{
4148
if (memberInfo.GetMemberType().IsValueType)
4249
{
50+
nullabilityInfo = null;
4351
return false;
4452
}
4553

@@ -49,14 +57,14 @@ protected virtual bool IsNonNullableReferenceType(
4957

5058
var nullabilityInfoContext = (NullabilityInfoContext)annotation.Value!;
5159

52-
var nullabilityInfo = memberInfo switch
60+
nullabilityInfo = memberInfo switch
5361
{
5462
PropertyInfo propertyInfo => nullabilityInfoContext.Create(propertyInfo),
5563
FieldInfo fieldInfo => nullabilityInfoContext.Create(fieldInfo),
5664
_ => null
5765
};
5866

59-
return nullabilityInfo?.ReadState == NullabilityState.NotNull;
67+
return nullabilityInfo is not null;
6068
}
6169

6270
/// <inheritdoc />

src/EFCore/Metadata/Conventions/NonNullableNavigationConvention.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,6 @@ private void ProcessNavigation(IConventionNavigationBuilder navigationBuilder)
7777

7878
private bool IsNonNullable(IConventionModelBuilder modelBuilder, IConventionNavigation navigation)
7979
=> navigation.DeclaringEntityType.GetRuntimeProperties().Find(navigation.Name) is PropertyInfo propertyInfo
80-
&& IsNonNullableReferenceType(modelBuilder, propertyInfo);
80+
&& TryGetNullabilityInfo(modelBuilder, propertyInfo, out var nullabilityInfo)
81+
&& nullabilityInfo.ReadState == NullabilityState.NotNull;
8182
}

src/EFCore/Metadata/Conventions/NonNullableReferencePropertyConvention.cs

+15-3
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,28 @@ public NonNullableReferencePropertyConvention(ProviderConventionSetBuilderDepend
2929
private void Process(IConventionPropertyBuilder propertyBuilder)
3030
{
3131
if (propertyBuilder.Metadata.GetIdentifyingMemberInfo() is MemberInfo memberInfo
32-
&& IsNonNullableReferenceType(propertyBuilder.ModelBuilder, memberInfo))
32+
&& TryGetNullabilityInfo(propertyBuilder.ModelBuilder, memberInfo, out var nullabilityInfo))
3333
{
34-
propertyBuilder.IsRequired(true);
34+
if (nullabilityInfo.ReadState == NullabilityState.NotNull)
35+
{
36+
propertyBuilder.IsRequired(true);
37+
}
38+
39+
// If there's an element type, this is a primitive collection; check and apply the element's nullability as well.
40+
if (propertyBuilder.Metadata.GetElementType() is IConventionElementType elementType
41+
&& (nullabilityInfo.ElementType is { ReadState: NullabilityState.NotNull }
42+
|| nullabilityInfo.GenericTypeArguments is [{ ReadState: NullabilityState.NotNull }]))
43+
{
44+
elementType.SetIsNullable(false);
45+
}
3546
}
3647
}
3748

3849
private void Process(IConventionComplexPropertyBuilder propertyBuilder)
3950
{
4051
if (propertyBuilder.Metadata.GetIdentifyingMemberInfo() is MemberInfo memberInfo
41-
&& IsNonNullableReferenceType(propertyBuilder.ModelBuilder, memberInfo))
52+
&& TryGetNullabilityInfo(propertyBuilder.ModelBuilder, memberInfo, out var nullabilityInfo)
53+
&& nullabilityInfo.ReadState == NullabilityState.NotNull)
4254
{
4355
propertyBuilder.IsRequired(true);
4456
}

0 commit comments

Comments
 (0)