Skip to content

Commit

Permalink
Negated syntax for regex and ILIKE (#3276)
Browse files Browse the repository at this point in the history
Closes #2589
  • Loading branch information
roji authored Sep 14, 2024
1 parent b11d279 commit f7d7910
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 16 deletions.
50 changes: 35 additions & 15 deletions src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -907,29 +925,25 @@ protected virtual Expression VisitArraySlice(PgArraySliceExpression expression)
}

/// <summary>
/// Visits the children of a <see cref="PgRegexMatchExpression" />.
/// Produces SQL for PostgreSQL regex matching.
/// </summary>
/// <param name="expression">The expression.</param>
/// <returns>
/// An <see cref="Expression" />.
/// </returns>
/// <remarks>
/// See: http://www.postgresql.org/docs/current/static/functions-matching.html
/// </remarks>
protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression)
protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression, bool negated = false)
{
var options = expression.Options;

Visit(expression.Match);

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
Expand Down Expand Up @@ -1012,16 +1026,22 @@ protected virtual Expression VisitRowValue(PgRowValueExpression rowValueExpressi
}

/// <summary>
/// Visits the children of an <see cref="PgILikeExpression" />.
/// 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.
/// </summary>
/// <param name="likeExpression">The expression.</param>
/// <returns>
/// An <see cref="Expression" />.
/// </returns>
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
""");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,22 @@ await AssertQuery(
""");
}

[Theory]
[MemberData(nameof(IsAsyncData))]
public async Task Regex_IsMatch_negated(bool async)
{
await AssertQuery(
async,
cs => cs.Set<Customer>().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)
Expand Down Expand Up @@ -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<Customer>().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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Customer>().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)
Expand Down Expand Up @@ -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<Customer>().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)
Expand Down

0 comments on commit f7d7910

Please sign in to comment.