diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs index 61583bb4e..3418d5045 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs @@ -614,11 +614,29 @@ protected override Expression VisitSqlUnary(SqlUnaryExpression sqlUnaryExpressio Visit(sqlUnaryExpression.Operand); return sqlUnaryExpression; - // Not operation on full-text queries + // NOT operation on full-text queries case ExpressionType.Not when sqlUnaryExpression.Operand.TypeMapping.ClrType == typeof(NpgsqlTsQuery): Sql.Append("!!"); Visit(sqlUnaryExpression.Operand); return sqlUnaryExpression; + + // NOT over expression types which have fancy embedded negation + case ExpressionType.Not + when sqlUnaryExpression.Type == typeof(bool): + { + switch (sqlUnaryExpression.Operand) + { + case PgRegexMatchExpression regexMatch: + VisitRegexMatch(regexMatch, negated: true); + return sqlUnaryExpression; + + case PgILikeExpression iLike: + VisitILike(iLike, negated: true); + return sqlUnaryExpression; + } + + break; + } } return base.VisitSqlUnary(sqlUnaryExpression); @@ -907,16 +925,12 @@ protected virtual Expression VisitArraySlice(PgArraySliceExpression expression) } /// - /// Visits the children of a . + /// Produces SQL for PostgreSQL regex matching. /// - /// The expression. - /// - /// An . - /// /// /// See: http://www.postgresql.org/docs/current/static/functions-matching.html /// - protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression) + protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression, bool negated = false) { var options = expression.Options; @@ -924,12 +938,12 @@ protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression) if (options.HasFlag(RegexOptions.IgnoreCase)) { - Sql.Append(" ~* "); + Sql.Append(negated ? " !~* " : " ~* "); options &= ~RegexOptions.IgnoreCase; } else { - Sql.Append(" ~ "); + Sql.Append(negated ? " !~ " : " ~ "); } // PG regexps are single-line by default @@ -1012,16 +1026,22 @@ protected virtual Expression VisitRowValue(PgRowValueExpression rowValueExpressi } /// - /// Visits the children of an . + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. /// - /// The expression. - /// - /// An . - /// - protected virtual Expression VisitILike(PgILikeExpression likeExpression) + protected virtual Expression VisitILike(PgILikeExpression likeExpression, bool negated = false) { Visit(likeExpression.Match); + + if (negated) + { + Sql.Append(" NOT"); + } + Sql.Append(" ILIKE "); + Visit(likeExpression.Pattern); if (likeExpression.EscapeChar is not null) diff --git a/test/EFCore.PG.FunctionalTests/Query/NorthwindDbFunctionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NorthwindDbFunctionsQueryNpgsqlTest.cs index 0b13fc070..425b5782e 100644 --- a/test/EFCore.PG.FunctionalTests/Query/NorthwindDbFunctionsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/NorthwindDbFunctionsQueryNpgsqlTest.cs @@ -117,7 +117,7 @@ public void String_ILike_negated() """ SELECT count(*)::int FROM "Customers" AS c -WHERE NOT (c."ContactName" ILIKE '%M%') OR c."ContactName" IS NULL +WHERE c."ContactName" NOT ILIKE '%M%' OR c."ContactName" IS NULL """); } diff --git a/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs index c8fd85fe5..f522b5ac4 100644 --- a/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs @@ -131,6 +131,22 @@ await AssertQuery( """); } + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_IsMatch_negated(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Where(c => !Regex.IsMatch(c.CompanyName, "^A"))); + + AssertSql( + """ +SELECT c."CustomerID", c."Address", c."City", c."CompanyName", c."ContactName", c."ContactTitle", c."Country", c."Fax", c."Phone", c."PostalCode", c."Region" +FROM "Customers" AS c +WHERE c."CompanyName" !~ '(?p)^A' +"""); + } + [Theory] [MemberData(nameof(IsAsyncData))] public async Task Regex_IsMatchOptionsNone(bool async) @@ -163,6 +179,22 @@ await AssertQuery( """); } + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_IsMatch_with_IgnoreCase_negated(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Where(c => !Regex.IsMatch(c.CompanyName, "^a", RegexOptions.IgnoreCase))); + + AssertSql( + """ +SELECT c."CustomerID", c."Address", c."City", c."CompanyName", c."ContactName", c."ContactTitle", c."Country", c."Fax", c."Phone", c."PostalCode", c."Region" +FROM "Customers" AS c +WHERE c."CompanyName" !~* '(?p)^a' +"""); + } + [Theory] [MemberData(nameof(IsAsyncData))] public async Task Regex_IsMatch_with_Multiline(bool async) diff --git a/test/EFCore.PG.FunctionalTests/Query/NorthwindMiscellaneousQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NorthwindMiscellaneousQueryNpgsqlTest.cs index cc8aa9153..521f2ff6c 100644 --- a/test/EFCore.PG.FunctionalTests/Query/NorthwindMiscellaneousQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/NorthwindMiscellaneousQueryNpgsqlTest.cs @@ -355,6 +355,28 @@ public async Task Array_All_Like(bool async) """); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Array_All_Like_negated(bool async) + { + await using var context = CreateContext(); + + var collection = new[] { "A%", "B%", "C%" }; + var query = context.Set().Where(c => !collection.All(y => EF.Functions.Like(c.Address, y))); + var result = async ? await query.ToListAsync() : query.ToList(); + + Assert.NotEmpty(result); + + AssertSql( + """ +@__collection_1={ 'A%', 'B%', 'C%' } (DbType = Object) + +SELECT c."CustomerID", c."Address", c."City", c."CompanyName", c."ContactName", c."ContactTitle", c."Country", c."Fax", c."Phone", c."PostalCode", c."Region" +FROM "Customers" AS c +WHERE NOT (c."Address" LIKE ALL (@__collection_1)) +"""); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public async Task Array_Any_ILike(bool async) @@ -401,6 +423,35 @@ public async Task Array_Any_ILike(bool async) """); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public async Task Array_Any_ILike_negated(bool async) + { + await using var context = CreateContext(); + + var collection = new[] { "a%", "b%", "c%" }; + var query = context.Set().Where(c => !collection.Any(y => EF.Functions.ILike(c.Address, y))); + var result = async ? await query.ToListAsync() : query.ToList(); + + Assert.Equal( + [ + "ALFKI", + "ANTON", + "AROUT", + "BLAUS", + "BLONP" + ], result.Select(e => e.CustomerID).Order().Take(5)); + + AssertSql( + """ +@__collection_1={ 'a%', 'b%', 'c%' } (DbType = Object) + +SELECT c."CustomerID", c."Address", c."City", c."CompanyName", c."ContactName", c."ContactTitle", c."Country", c."Fax", c."Phone", c."PostalCode", c."Region" +FROM "Customers" AS c +WHERE NOT (c."Address" ILIKE ANY (@__collection_1)) +"""); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public async Task Array_All_ILike(bool async)