From c94dd113775b2c2cfaf37996a829fde71ed4cefd Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sun, 4 Oct 2026 11:37:27 +0200 Subject: [PATCH 1/2] Fix regex operand parentheses based on SQL precedence (#3927) Fixes #3923 (cherry picked from commit b1233717a6b1f0a382a01c730e0d4bb9f8b0dfa5) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Query/Internal/NpgsqlQuerySqlGenerator.cs | 51 +++++++- .../Query/JsonDomQueryTest.cs | 111 +++++++++++++++++- .../Query/JsonPocoQueryTest.cs | 6 +- .../Query/JsonQueryNpgsqlTest.cs | 17 +++ .../NorthwindFunctionsQueryNpgsqlTest.cs | 32 +++++ 5 files changed, 209 insertions(+), 8 deletions(-) diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs index 8b9f0587b3..3ec02f5139 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs @@ -955,9 +955,20 @@ protected virtual Expression VisitArraySlice(PgArraySliceExpression expression) protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression, bool negated = false) { var options = expression.Options; + var requiresParentheses = RequiresParentheses(expression, expression.Match); + + if (requiresParentheses) + { + Sql.Append("("); + } Visit(expression.Match); + if (requiresParentheses) + { + Sql.Append(")"); + } + if (options.HasFlag(RegexOptions.IgnoreCase)) { Sql.Append(negated ? " !~* " : " ~* "); @@ -968,10 +979,24 @@ protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression, Sql.Append(negated ? " !~ " : " ~ "); } + // Regex matching and concatenation have the same precedence, including after the option prefix below. + requiresParentheses = RequiresParentheses(expression, expression.Pattern); + // PG regexps are single-line by default if (options == RegexOptions.Singleline) { + if (requiresParentheses) + { + Sql.Append("("); + } + Visit(expression.Pattern); + + if (requiresParentheses) + { + Sql.Append(")"); + } + return expression; } @@ -1004,7 +1029,19 @@ protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression, if (constantPattern is null) { Sql.Append("' || "); + + if (requiresParentheses) + { + Sql.Append("("); + } + Visit(expression.Pattern); + + if (requiresParentheses) + { + Sql.Append(")"); + } + Sql.Append(")"); } else @@ -1485,6 +1522,13 @@ protected override bool RequiresParentheses(SqlExpression outerExpression, SqlEx return true; } + case PgJsonTraversalExpression when outerExpression is not SqlUnaryExpression { OperatorType: ExpressionType.Convert }: + { + return !TryGetOperatorInfo(outerExpression, out var outerPrecedence, out _) + || !TryGetOperatorInfo(innerExpression, out var innerPrecedence, out _) + || outerPrecedence >= innerPrecedence; + } + // PG requires function calls to be wrapped in parentheses before indexing on the returned array: // (string_to_array(c."ContactName", ' '))[1] case SqlFunctionExpression when outerExpression is PgArrayIndexExpression: @@ -1516,6 +1560,9 @@ protected override bool TryGetOperatorInfo(SqlExpression expression, out int pre ExpressionType.Divide => (1200, false), ExpressionType.Modulo => (1200, false), + // String, array and tsvector concatenation + ExpressionType.Add when GetOperator(sqlBinaryExpression) == " || " => (1000, true), + // Addition, subtraction (binary) ExpressionType.Add => (1100, true), ExpressionType.Subtract => (1100, false), @@ -1557,7 +1604,7 @@ protected override bool TryGetOperatorInfo(SqlExpression expression, out int pre // There's an "any other operator" category in the PG operator precedence table, we assign that a numeric value of 1000. // TODO: Some operators here may be associative - PgBinaryExpression => (1000, false), + PgBinaryExpression or PgRegexMatchExpression => (1000, false), CollateExpression => (1000, false), AtTimeZoneExpression => (1100, false), @@ -1565,7 +1612,7 @@ protected override bool TryGetOperatorInfo(SqlExpression expression, out int pre PgJsonTraversalExpression => (1000, false), PgArrayIndexExpression => (1500, false), PgAllExpression or PgAnyExpression => (800, false), - LikeExpression or PgILikeExpression or PgRegexMatchExpression => (900, false), + LikeExpression or PgILikeExpression => (900, false), _ => default, }; diff --git a/test/EFCore.PG.FunctionalTests/Query/JsonDomQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/JsonDomQueryTest.cs index cfb8de9089..560bb71f3a 100644 --- a/test/EFCore.PG.FunctionalTests/Query/JsonDomQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/JsonDomQueryTest.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json; +using System.Text.RegularExpressions; namespace Microsoft.EntityFrameworkCore.Query; @@ -512,7 +513,7 @@ public void JsonExists() """ SELECT count(*)::int FROM "JsonbEntities" AS j -WHERE j."CustomerElement" -> 'Statistics' ? 'Visits' +WHERE (j."CustomerElement" -> 'Statistics') ? 'Visits' """); } @@ -529,7 +530,7 @@ public void JsonExistAny() """ SELECT count(*)::int FROM "JsonbEntities" AS j -WHERE j."CustomerElement" -> 'Statistics' ?| ARRAY['foo','Visits']::text[] +WHERE (j."CustomerElement" -> 'Statistics') ?| ARRAY['foo','Visits']::text[] """); } @@ -546,7 +547,7 @@ public void JsonExistAll() """ SELECT count(*)::int FROM "JsonbEntities" AS j -WHERE j."CustomerElement" -> 'Statistics' ?& ARRAY['foo','Visits']::text[] +WHERE (j."CustomerElement" -> 'Statistics') ?& ARRAY['foo','Visits']::text[] """); } @@ -586,6 +587,110 @@ WHERE json_typeof(j."CustomerElement" #> '{Statistics,Visits}') = 'number' #endregion Functions + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Regex_with_json_pattern(bool jsonb) + { + using var ctx = CreateContext(); + + var query = jsonb + ? ctx.JsonbEntities.Select(e => new { e.Id, e.CustomerElement }) + : ctx.JsonEntities.Select(e => new { e.Id, e.CustomerElement }); + + var ids = query.Where(e => Regex.IsMatch("Joe", e.CustomerElement.GetProperty("Name").GetString()!)) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([1], ids); + AssertSql( + $""" +SELECT j."Id" +FROM "{(jsonb ? "JsonbEntities" : "JsonEntities")}" AS j +WHERE 'Joe' ~ ('(?p)' || (j."CustomerElement" ->> 'Name')) +"""); + } + + [Theory] + [InlineData(RegexOptions.Singleline, false, "~ (j.\"CustomerElement\" ->> 'Name')")] + [InlineData(RegexOptions.IgnoreCase, false, "~* ('(?p)' || (j.\"CustomerElement\" ->> 'Name'))")] + [InlineData(RegexOptions.Singleline | RegexOptions.IgnoreCase, false, "~* (j.\"CustomerElement\" ->> 'Name')")] + [InlineData(RegexOptions.None, true, "!~ ('(?p)' || (j.\"CustomerElement\" ->> 'Name'))")] + [InlineData(RegexOptions.Singleline, true, "!~ (j.\"CustomerElement\" ->> 'Name')")] + [InlineData(RegexOptions.IgnoreCase, true, "!~* ('(?p)' || (j.\"CustomerElement\" ->> 'Name'))")] + [InlineData(RegexOptions.Singleline | RegexOptions.IgnoreCase, true, "!~* (j.\"CustomerElement\" ->> 'Name')")] + public void Regex_with_json_pattern_options(RegexOptions options, bool negated, string expectedOperatorAndPattern) + { + using var ctx = CreateContext(); + + var query = ctx.JsonbEntities.Where(e => e.Id == 1); + var matches = options switch + { + RegexOptions.None + => query.Select(e => Regex.IsMatch("Joe", e.CustomerElement.GetProperty("Name").GetString()!)), + RegexOptions.Singleline + => query.Select(e => Regex.IsMatch("Joe", e.CustomerElement.GetProperty("Name").GetString()!, RegexOptions.Singleline)), + RegexOptions.IgnoreCase + => query.Select(e => Regex.IsMatch("Joe", e.CustomerElement.GetProperty("Name").GetString()!, RegexOptions.IgnoreCase)), + RegexOptions.Singleline | RegexOptions.IgnoreCase + => query.Select(e => Regex.IsMatch( + "Joe", e.CustomerElement.GetProperty("Name").GetString()!, RegexOptions.Singleline | RegexOptions.IgnoreCase)), + _ => throw new ArgumentOutOfRangeException(nameof(options)) + }; + var result = (negated ? matches.Select(m => !m) : matches).Single(); + + Assert.Equal(!negated, result); + AssertSql( + $""" +SELECT 'Joe' {expectedOperatorAndPattern} +FROM "JsonbEntities" AS j +WHERE j."Id" = 1 +LIMIT 2 +"""); + } + + [Fact] + public void Regex_with_json_input() + { + using var ctx = CreateContext(); + + var ids = ctx.JsonbEntities + .Where(e => Regex.IsMatch(e.CustomerElement.GetProperty("Name").GetString()!, "^J")) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([1], ids); + AssertSql( + """ +SELECT j."Id" +FROM "JsonbEntities" AS j +WHERE (j."CustomerElement" ->> 'Name') ~ '(?p)^J' +"""); + } + + [Fact] + public void Regex_with_nested_json_operands() + { + using var ctx = CreateContext(); + + var ids = ctx.JsonbEntities + .Where(e => Regex.IsMatch( + e.CustomerElement.GetProperty("VariousTypes").GetProperty("String").GetString()!, + e.CustomerElement.GetProperty("VariousTypes").GetProperty("String").GetString()!)) + .OrderBy(e => e.Id) + .Select(e => e.Id) + .ToList(); + + Assert.Equal([1, 2], ids); + AssertSql( + """ +SELECT j."Id" +FROM "JsonbEntities" AS j +WHERE (j."CustomerElement" #>> '{VariousTypes,String}') ~ ('(?p)' || (j."CustomerElement" #>> '{VariousTypes,String}')) +ORDER BY j."Id" NULLS FIRST +"""); + } + #region Support protected JsonDomQueryContext CreateContext() diff --git a/test/EFCore.PG.FunctionalTests/Query/JsonPocoQueryTest.cs b/test/EFCore.PG.FunctionalTests/Query/JsonPocoQueryTest.cs index ffc1ae259b..515ea79e7d 100644 --- a/test/EFCore.PG.FunctionalTests/Query/JsonPocoQueryTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/JsonPocoQueryTest.cs @@ -621,7 +621,7 @@ public void JsonExists() """ SELECT count(*)::int FROM "JsonbEntities" AS j -WHERE j."Customer" -> 'Statistics' ? 'Visits' +WHERE (j."Customer" -> 'Statistics') ? 'Visits' """); } @@ -638,7 +638,7 @@ public void JsonExistAny() """ SELECT count(*)::int FROM "JsonbEntities" AS j -WHERE j."Customer" -> 'Statistics' ?| ARRAY['foo','Visits']::text[] +WHERE (j."Customer" -> 'Statistics') ?| ARRAY['foo','Visits']::text[] """); } @@ -655,7 +655,7 @@ public void JsonExistAll() """ SELECT count(*)::int FROM "JsonbEntities" AS j -WHERE j."Customer" -> 'Statistics' ?& ARRAY['foo','Visits']::text[] +WHERE (j."Customer" -> 'Statistics') ?& ARRAY['foo','Visits']::text[] """); } diff --git a/test/EFCore.PG.FunctionalTests/Query/JsonQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/JsonQueryNpgsqlTest.cs index ce65575c4c..2d60c6bb9e 100644 --- a/test/EFCore.PG.FunctionalTests/Query/JsonQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/JsonQueryNpgsqlTest.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore.TestModels.JsonQuery; namespace Microsoft.EntityFrameworkCore.Query; @@ -3127,6 +3128,22 @@ public override async Task Json_projection_second_element_projected_before_owner """); } + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_with_json_operands(bool async) + { + await AssertQuery( + async, + ss => ss.Set() + .Select(e => Regex.IsMatch(e.OwnedReferenceRoot.Name, e.OwnedReferenceRoot.Name))); + + AssertSql( + """ +SELECT (j."OwnedReferenceRoot" ->> 'Name') ~ ('(?p)' || (j."OwnedReferenceRoot" ->> 'Name')) +FROM "JsonEntitiesBasic" AS j +"""); + } + [ConditionalFact] public virtual void Check_all_tests_overridden() => TestHelpers.AssertAllMethodsOverridden(GetType()); diff --git a/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs index fe360fda2d..0ac5c79759 100644 --- a/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs @@ -105,6 +105,38 @@ await AssertQuery( """); } + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_IsMatch_with_concatenated_input(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Where(c => Regex.IsMatch(c.CustomerID + "x", "^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."CustomerID" || 'x') ~ '(?p)^A' +"""); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_IsMatch_with_concatenated_pattern_Singleline(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Where(c => Regex.IsMatch(c.CustomerID, c.CustomerID + "$", RegexOptions.Singleline))); + + 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."CustomerID" ~ (c."CustomerID" || '$') +"""); + } + [Theory] [MemberData(nameof(IsAsyncData))] public async Task Regex_IsMatch_negated(bool async) From 0df2046fe6ea63607dcf0eaf5a56f1edce872d1e Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sun, 4 Oct 2026 11:52:10 +0200 Subject: [PATCH 2/2] Update SourceLink to 10.0.303 Use the patched Microsoft.Build.Tasks.Git dependency to unblock CI restore without suppressing NuGet audit warnings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index c6123aba9a..12b0768fd2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,7 +25,7 @@ - +