diff --git a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs index 27927ad1ae7..ec8ddff2e8b 100644 --- a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs +++ b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs @@ -2055,6 +2055,19 @@ private SqlExpression ProcessNullNotNull(SqlExpression sqlExpression, bool opera sqlUnaryExpression.TypeMapping); } + case CollateExpression collate: + { + // a COLLATE collation == null -> a == null + // a COLLATE collation != null -> a != null + return ProcessNullNotNull( + _sqlExpressionFactory.MakeUnary( + sqlUnaryExpression.OperatorType, + collate.Operand, + typeof(bool), + sqlUnaryExpression.TypeMapping)!, + operandNullable); + } + case SqlUnaryExpression sqlUnaryOperand: switch (sqlUnaryOperand.OperatorType) { @@ -2080,6 +2093,35 @@ private SqlExpression ProcessNullNotNull(SqlExpression sqlExpression, bool opera break; + case AtTimeZoneExpression atTimeZone: + { + // a AT TIME ZONE b == null -> a == null || b == null + // a AT TIME ZONE b != null -> a != null && b != null + var left = ProcessNullNotNull( + _sqlExpressionFactory.MakeUnary( + sqlUnaryExpression.OperatorType, + atTimeZone.Operand, + typeof(bool), + sqlUnaryExpression.TypeMapping)!, + operandNullable); + + var right = ProcessNullNotNull( + _sqlExpressionFactory.MakeUnary( + sqlUnaryExpression.OperatorType, + atTimeZone.TimeZone, + typeof(bool), + sqlUnaryExpression.TypeMapping)!, + operandNullable); + + return _sqlExpressionFactory.MakeBinary( + sqlUnaryExpression.OperatorType == ExpressionType.Equal + ? ExpressionType.OrElse + : ExpressionType.AndAlso, + left, + right, + sqlUnaryExpression.TypeMapping)!; + } + case SqlBinaryExpression sqlBinaryOperand when sqlBinaryOperand.OperatorType != ExpressionType.AndAlso && sqlBinaryOperand.OperatorType != ExpressionType.OrElse: diff --git a/test/EFCore.Relational.Specification.Tests/Query/OperatorsProceduralQueryTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/OperatorsProceduralQueryTestBase.cs index f35a2a8679a..f9529d0c361 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/OperatorsProceduralQueryTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/OperatorsProceduralQueryTestBase.cs @@ -118,6 +118,7 @@ protected OperatorsProceduralQueryTestBase() { typeof(bool), typeof(OperatorEntityBool) }, { typeof(bool?), typeof(OperatorEntityNullableBool) }, { typeof(DateTimeOffset), typeof(OperatorEntityDateTimeOffset) }, + { typeof(DateTimeOffset?), typeof(OperatorEntityNullableDateTimeOffset) }, }; ExpectedData = OperatorsData.Instance; @@ -136,6 +137,7 @@ protected virtual async Task SeedAsync(OperatorsContext ctx) ctx.Set().AddRange(ExpectedData.OperatorEntitiesBool); ctx.Set().AddRange(ExpectedData.OperatorEntitiesNullableBool); ctx.Set().AddRange(ExpectedData.OperatorEntitiesDateTimeOffset); + ctx.Set().AddRange(ExpectedData.OperatorEntitiesNullableDateTimeOffset); await ctx.SaveChangesAsync(); } diff --git a/test/EFCore.Relational.Specification.Tests/Query/OperatorsQueryTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/OperatorsQueryTestBase.cs index b3b9631ecd7..7b6e12634ba 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/OperatorsQueryTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/OperatorsQueryTestBase.cs @@ -26,6 +26,7 @@ protected virtual Task Seed(OperatorsContext ctx) ctx.Set().AddRange(ExpectedData.OperatorEntitiesBool); ctx.Set().AddRange(ExpectedData.OperatorEntitiesNullableBool); ctx.Set().AddRange(ExpectedData.OperatorEntitiesDateTimeOffset); + ctx.Set().AddRange(ExpectedData.OperatorEntitiesNullableDateTimeOffset); return ctx.SaveChangesAsync(); } diff --git a/test/EFCore.Relational.Specification.Tests/Query/RelationalNorthwindDbFunctionsQueryTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/RelationalNorthwindDbFunctionsQueryTestBase.cs index 682df514e6a..00badc19641 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/RelationalNorthwindDbFunctionsQueryTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/RelationalNorthwindDbFunctionsQueryTestBase.cs @@ -49,6 +49,16 @@ public virtual Task Collate_case_sensitive_constant(bool async) c => c.ContactName == EF.Functions.Collate("maria anders", CaseSensitiveCollation), c => c.ContactName.Equals("maria anders", StringComparison.Ordinal)); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Collate_is_null(bool async) + => AssertCount( + async, + ss => ss.Set(), + ss => ss.Set(), + c => EF.Functions.Collate(c.Region, CaseSensitiveCollation) == null, + c => c.Region == null); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Least(bool async) diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorEntityNullableDateTimeOffset.cs b/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorEntityNullableDateTimeOffset.cs new file mode 100644 index 00000000000..19cb8b2679e --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorEntityNullableDateTimeOffset.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.TestModels.Operators; + +#nullable disable + +public class OperatorEntityNullableDateTimeOffset : OperatorEntityBase +{ + public DateTimeOffset? Value { get; set; } +} diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorsContext.cs b/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorsContext.cs index 2169d29e8b9..3e6f248ec98 100644 --- a/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorsContext.cs +++ b/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorsContext.cs @@ -16,5 +16,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().Property(x => x.Id).ValueGeneratedNever(); modelBuilder.Entity().Property(x => x.Id).ValueGeneratedNever(); modelBuilder.Entity().Property(x => x.Id).ValueGeneratedNever(); + modelBuilder.Entity().Property(x => x.Id).ValueGeneratedNever(); } } diff --git a/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorsData.cs b/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorsData.cs index 93662df3ec8..5a898e2442d 100644 --- a/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorsData.cs +++ b/test/EFCore.Relational.Specification.Tests/TestModels/Operators/OperatorsData.cs @@ -56,6 +56,13 @@ public class OperatorsData : ISetSource () => new DateTimeOffset(new DateTime(2000, 1, 1, 9, 0, 0), new TimeSpan(13, 0, 0)) ]; + private readonly List>> _nullableDateTimeOffsetValues = + [ + () => null, + () => new DateTimeOffset(new DateTime(2000, 1, 1, 10, 0, 0), new TimeSpan(-8, 0, 0)), + () => new DateTimeOffset(new DateTime(2000, 1, 1, 9, 0, 0), new TimeSpan(13, 0, 0)) + ]; + public IReadOnlyList OperatorEntitiesString { get; } public IReadOnlyList OperatorEntitiesInt { get; } public IReadOnlyList OperatorEntitiesNullableInt { get; } @@ -63,6 +70,7 @@ public class OperatorsData : ISetSource public IReadOnlyList OperatorEntitiesBool { get; } public IReadOnlyList OperatorEntitiesNullableBool { get; } public IReadOnlyList OperatorEntitiesDateTimeOffset { get; } + public IReadOnlyList OperatorEntitiesNullableDateTimeOffset { get; } public IDictionary> ConstantExpressionsPerType { get; } private OperatorsData() @@ -74,6 +82,7 @@ private OperatorsData() OperatorEntitiesBool = CreateBools(); OperatorEntitiesNullableBool = CreateNullableBools(); OperatorEntitiesDateTimeOffset = CreateDateTimeOffsets(); + OperatorEntitiesNullableDateTimeOffset = CreateNullableDateTimeOffsets(); ConstantExpressionsPerType = new Dictionary> { @@ -84,6 +93,7 @@ private OperatorsData() { typeof(bool), _boolValues.Select(x => x.Body).ToList() }, { typeof(bool?), _nullableBoolValues.Select(x => x.Body).ToList() }, { typeof(DateTimeOffset), _dateTimeOffsetValues.Select(x => x.Body).ToList() }, + { typeof(DateTimeOffset?), _nullableDateTimeOffsetValues.Select(x => x.Body).ToList() }, }; } @@ -125,6 +135,11 @@ public virtual IQueryable Set() return (IQueryable)OperatorEntitiesDateTimeOffset.AsQueryable(); } + if (typeof(TEntity) == typeof(OperatorEntityNullableDateTimeOffset)) + { + return (IQueryable)OperatorEntitiesNullableDateTimeOffset.AsQueryable(); + } + throw new InvalidOperationException("Invalid entity type: " + typeof(TEntity)); } @@ -151,4 +166,8 @@ public IReadOnlyList CreateNullableBools() public IReadOnlyList CreateDateTimeOffsets() => _dateTimeOffsetValues .Select((x, i) => new OperatorEntityDateTimeOffset { Id = i + 1, Value = _dateTimeOffsetValues[i].Compile()() }).ToList(); + + public IReadOnlyList CreateNullableDateTimeOffsets() + => _nullableDateTimeOffsetValues.Select((x, i) => new OperatorEntityNullableDateTimeOffset { Id = i + 1, Value = _nullableDateTimeOffsetValues[i].Compile()() }) + .ToList(); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs index 37d00a9cd21..64fab4f8a91 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindDbFunctionsQuerySqlServerTest.cs @@ -121,6 +121,18 @@ FROM [Customers] AS [c] """); } + public override async Task Collate_is_null(bool async) + { + await base.Collate_is_null(async); + + AssertSql( + """ +SELECT COUNT(*) +FROM [Customers] AS [c] +WHERE [c].[Region] IS NULL +"""); + } + [SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] public override async Task Least(bool async) { diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/OperatorsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/OperatorsQuerySqlServerTest.cs index dc16cf1b9dd..bca2a6b634e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/OperatorsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/OperatorsQuerySqlServerTest.cs @@ -250,6 +250,38 @@ where EF.Functions.AtTimeZone(e1.Value, "UTC") == e2.Value FROM [OperatorEntityDateTimeOffset] AS [o] CROSS JOIN [OperatorEntityDateTimeOffset] AS [o0] WHERE [o].[Value] AT TIME ZONE 'UTC' = [o0].[Value] +"""); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [SqlServerCondition(SqlServerCondition.SupportsSqlClr)] + public virtual async Task Where_AtTimeZone_is_null(bool async) + { + var contextFactory = await InitializeAsync(seed: Seed); + using var context = contextFactory.CreateContext(); + + var expected = (from e in ExpectedData.OperatorEntitiesNullableDateTimeOffset + where e.Value == null + select e.Id).ToList(); + + var actual = (from e in context.Set() +#pragma warning disable CS8073 // The result of the expression is always the same since a value of this type is never equal to 'null' + where EF.Functions.AtTimeZone(e.Value.Value, "UTC") == null +#pragma warning restore CS8073 // The result of the expression is always the same since a value of this type is never equal to 'null' + select e.Id).ToList(); + + Assert.Equal(expected.Count, actual.Count); + for (var i = 0; i < expected.Count; i++) + { + Assert.Equal(expected[i], actual[i]); + } + + AssertSql( + """ +SELECT [o].[Id] +FROM [OperatorEntityNullableDateTimeOffset] AS [o] +WHERE [o].[Value] IS NULL """); } } diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindDbFunctionsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindDbFunctionsQuerySqliteTest.cs index 1f8487aaef5..f409eff864f 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindDbFunctionsQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindDbFunctionsQuerySqliteTest.cs @@ -56,6 +56,18 @@ SELECT COUNT(*) """); } + public override async Task Collate_is_null(bool async) + { + await base.Collate_is_null(async); + + AssertSql( + """ +SELECT COUNT(*) +FROM "Customers" AS "c" +WHERE "c"."Region" IS NULL +"""); + } + protected override string CaseInsensitiveCollation => "NOCASE";