diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs index 63843eab32..45e794efd1 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs @@ -33,6 +33,7 @@ public NpgsqlMethodCallTranslatorProvider( { var npgsqlOptions = contextOptions.FindExtension() ?? new NpgsqlOptionsExtension(); var supportsMultiranges = npgsqlOptions.PostgresVersion.AtLeast(14); + var supportRegexCount = npgsqlOptions.PostgresVersion.AtLeast(15); var sqlExpressionFactory = (NpgsqlSqlExpressionFactory)dependencies.SqlExpressionFactory; var typeMappingSource = (NpgsqlTypeMappingSource)dependencies.RelationalTypeMappingSource; @@ -58,7 +59,7 @@ public NpgsqlMethodCallTranslatorProvider( new NpgsqlObjectToStringTranslator(typeMappingSource, sqlExpressionFactory), new NpgsqlRandomTranslator(sqlExpressionFactory), new NpgsqlRangeTranslator(typeMappingSource, sqlExpressionFactory, model, supportsMultiranges), - new NpgsqlRegexIsMatchTranslator(sqlExpressionFactory), + new NpgsqlRegexTranslator(typeMappingSource, sqlExpressionFactory, supportRegexCount), new NpgsqlRowValueTranslator(sqlExpressionFactory), new NpgsqlStringMethodTranslator(typeMappingSource, sqlExpressionFactory), new NpgsqlTrigramsMethodTranslator(typeMappingSource, sqlExpressionFactory, model) diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlRegexIsMatchTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlRegexIsMatchTranslator.cs deleted file mode 100644 index 659d27b737..0000000000 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlRegexIsMatchTranslator.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Text.RegularExpressions; -using ExpressionExtensions = Microsoft.EntityFrameworkCore.Query.ExpressionExtensions; - -namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal; - -/// -/// Translates Regex.IsMatch calls into PostgreSQL regex expressions for database-side processing. -/// -/// -/// http://www.postgresql.org/docs/current/static/functions-matching.html -/// -public class NpgsqlRegexIsMatchTranslator : IMethodCallTranslator -{ - private static readonly MethodInfo IsMatch = - typeof(Regex).GetRuntimeMethod(nameof(Regex.IsMatch), [typeof(string), typeof(string)])!; - - private static readonly MethodInfo IsMatchWithRegexOptions = - typeof(Regex).GetRuntimeMethod(nameof(Regex.IsMatch), [typeof(string), typeof(string), typeof(RegexOptions)])!; - - private const RegexOptions UnsupportedRegexOptions = RegexOptions.RightToLeft | RegexOptions.ECMAScript; - - private readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory; - - /// - /// 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. - /// - public NpgsqlRegexIsMatchTranslator(NpgsqlSqlExpressionFactory sqlExpressionFactory) - { - _sqlExpressionFactory = sqlExpressionFactory; - } - - /// - public virtual SqlExpression? Translate( - SqlExpression? instance, - MethodInfo method, - IReadOnlyList arguments, - IDiagnosticsLogger logger) - { - if (method != IsMatch && method != IsMatchWithRegexOptions) - { - return null; - } - - var (input, pattern) = (arguments[0], arguments[1]); - var typeMapping = ExpressionExtensions.InferTypeMapping(input, pattern); - - RegexOptions options; - - if (method == IsMatch) - { - options = RegexOptions.None; - } - else if (arguments[2] is SqlConstantExpression { Value: RegexOptions regexOptions }) - { - options = regexOptions; - } - else - { - return null; // We don't support non-constant regex options - } - - return (options & UnsupportedRegexOptions) == 0 - ? _sqlExpressionFactory.RegexMatch( - _sqlExpressionFactory.ApplyTypeMapping(input, typeMapping), - _sqlExpressionFactory.ApplyTypeMapping(pattern, typeMapping), - options) - : null; - } -} diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlRegexTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlRegexTranslator.cs new file mode 100644 index 0000000000..3407bef163 --- /dev/null +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlRegexTranslator.cs @@ -0,0 +1,193 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions.Internal; +using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; +using static Npgsql.EntityFrameworkCore.PostgreSQL.Utilities.Statics; + +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query.ExpressionTranslators.Internal; + +/// +/// Translates Regex method calls into their corresponding PostgreSQL equivalent for database-side processing. +/// +/// +/// http://www.postgresql.org/docs/current/static/functions-matching.html +/// +public class NpgsqlRegexTranslator : IMethodCallTranslator +{ + private const RegexOptions UnsupportedRegexOptions = RegexOptions.RightToLeft | RegexOptions.ECMAScript; + + private readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory; + private readonly bool _supportRegexCount; + private readonly NpgsqlTypeMappingSource _typeMappingSource; + + /// + /// 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. + /// + public NpgsqlRegexTranslator( + NpgsqlTypeMappingSource typeMappingSource, + NpgsqlSqlExpressionFactory sqlExpressionFactory, + bool supportRegexCount) + { + _sqlExpressionFactory = sqlExpressionFactory; + _supportRegexCount = supportRegexCount; + _typeMappingSource = typeMappingSource; + } + + /// + public SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (method.DeclaringType != typeof(Regex) || !method.IsStatic) + { + return null; + } + + return method.Name switch + { + nameof(Regex.IsMatch) when arguments.Count == 2 + && arguments[0].Type == typeof(string) + && arguments[1].Type == typeof(string) + => TranslateIsMatch(arguments), + nameof(Regex.IsMatch) when arguments.Count == 3 + && arguments[0].Type == typeof(string) + && arguments[1].Type == typeof(string) + && TryGetOptions(arguments[2], out var options) + => TranslateIsMatch(arguments, options), + + nameof(Regex.Replace) when arguments.Count == 3 + && arguments[0].Type == typeof(string) + && arguments[1].Type == typeof(string) + && arguments[2].Type == typeof(string) + => TranslateReplace(arguments), + nameof(Regex.Replace) when arguments.Count == 4 + && arguments[0].Type == typeof(string) + && arguments[1].Type == typeof(string) + && arguments[2].Type == typeof(string) + && TryGetOptions(arguments[3], out var options) + => TranslateReplace(arguments, options), + + nameof(Regex.Count) when _supportRegexCount + && arguments.Count == 2 + && arguments[0].Type == typeof(string) + && arguments[1].Type == typeof(string) + => TranslateCount(arguments), + nameof(Regex.Count) when _supportRegexCount + && arguments.Count == 3 + && arguments[0].Type == typeof(string) + && arguments[1].Type == typeof(string) + && TryGetOptions(arguments[2], out var options) + => TranslateCount(arguments, options), + + _ => null + }; + + static bool TryGetOptions(SqlExpression argument, out RegexOptions options) + { + if (argument is SqlConstantExpression { Value: RegexOptions o } && (o & UnsupportedRegexOptions) is 0) + { + options = o; + return true; + } + + options = default; + return false; + } + } + + private PgRegexMatchExpression TranslateIsMatch(IReadOnlyList arguments, RegexOptions regexOptions = RegexOptions.None) + => _sqlExpressionFactory.RegexMatch( + _sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[0]), + _sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]), + regexOptions); + + private SqlExpression TranslateReplace(IReadOnlyList arguments, RegexOptions regexOptions = RegexOptions.None) + { + var (input, pattern, replacement) = (arguments[0], arguments[1], arguments[2]); + + List passingArguments = + [ + _sqlExpressionFactory.ApplyDefaultTypeMapping(input), + _sqlExpressionFactory.ApplyDefaultTypeMapping(pattern), + _sqlExpressionFactory.ApplyDefaultTypeMapping(replacement) + ]; + + if (TranslateOptions(regexOptions) is { Length: not 0 } translatedOptions) + { + passingArguments.Add(_sqlExpressionFactory.Constant(translatedOptions)); + } + + return _sqlExpressionFactory.Function( + "regexp_replace", + passingArguments, + nullable: true, + TrueArrays[passingArguments.Count], + typeof(string), + _typeMappingSource.FindMapping(typeof(string))); + } + + private SqlExpression TranslateCount(IReadOnlyList arguments, RegexOptions regexOptions = RegexOptions.None) + { + var (input, pattern) = (arguments[0], arguments[1]); + + List passingArguments = + [ + _sqlExpressionFactory.ApplyDefaultTypeMapping(input), + _sqlExpressionFactory.ApplyDefaultTypeMapping(pattern) + ]; + + if (TranslateOptions(regexOptions) is { Length: not 0 } translatedOptions) + { + passingArguments.AddRange( + [ + _sqlExpressionFactory.Constant(1), // The starting position has to be set to use the options in PostgreSQL + _sqlExpressionFactory.Constant(translatedOptions) + ]); + } + + return _sqlExpressionFactory.Function( + "regexp_count", + passingArguments, + nullable: true, + TrueArrays[passingArguments.Count], + typeof(int), + _typeMappingSource.FindMapping(typeof(int))); + } + + private static string TranslateOptions(RegexOptions options) + { + string? result; + + switch (options) + { + case RegexOptions.Singleline: + return string.Empty; + case var _ when options.HasFlag(RegexOptions.Multiline): + result = "n"; + break; + case var _ when !options.HasFlag(RegexOptions.Singleline): + result = "p"; + break; + default: + result = string.Empty; + break; + } + + if (options.HasFlag(RegexOptions.IgnoreCase)) + { + result += "i"; + } + + if (options.HasFlag(RegexOptions.IgnorePatternWhitespace)) + { + result += "x"; + } + + return result; + } +} diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs index 5c1ed24f12..5de8f23be2 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs @@ -991,10 +991,6 @@ protected virtual Expression VisitRegexMatch(PgRegexMatchExpression expression, Sql.Append("'"); } - // Sql.Append(")' || "); - // Visit(expression.Pattern); - // Sql.Append(")"); - return expression; } diff --git a/src/EFCore.PG/Storage/Internal/NpgsqlSqlGenerationHelper.cs b/src/EFCore.PG/Storage/Internal/NpgsqlSqlGenerationHelper.cs index bbff1a515c..89eddbdc0c 100644 --- a/src/EFCore.PG/Storage/Internal/NpgsqlSqlGenerationHelper.cs +++ b/src/EFCore.PG/Storage/Internal/NpgsqlSqlGenerationHelper.cs @@ -1,5 +1,7 @@ using System.Data; +using System.Diagnostics.CodeAnalysis; using System.Text; +using System.Text.RegularExpressions; namespace Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal; diff --git a/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs index c87a047ff5..5c422ffed6 100644 --- a/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/NorthwindFunctionsQueryNpgsqlTest.cs @@ -282,6 +282,294 @@ public void Regex_IsMatch_with_unsupported_option() () => Fixture.CreateContext().Customers.Where(c => Regex.IsMatch(c.CompanyName, "^A", RegexOptions.RightToLeft)).ToList()); + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_Replace_with_constant_pattern_and_replacement(bool async) + { + await AssertQuery( + async, + source => source.Set().Select(x => Regex.Replace(x.CompanyName, "^A", "B"))); + + AssertSql( + """ +SELECT regexp_replace(c."CompanyName", '^A', 'B', 'p') +FROM "Customers" AS c +""" + ); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_Replace_with_parameter_pattern_and_replacement(bool async) + { + var pattern = "^A"; + var replacement = "B"; + + await AssertQuery( + async, + source => source.Set().Select(x => Regex.Replace(x.CompanyName, pattern, replacement))); + + AssertSql( + """ +@__pattern_0='^A' +@__replacement_1='B' + +SELECT regexp_replace(c."CompanyName", @__pattern_0, @__replacement_1, 'p') +FROM "Customers" AS c +""" + ); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_Replace_with_OptionsNone(bool async) + { + await AssertQuery( + async, + source => source.Set().Select(x => Regex.Replace(x.CompanyName, "^A", "B", RegexOptions.None))); + + AssertSql( + """ +SELECT regexp_replace(c."CompanyName", '^A', 'B', 'p') +FROM "Customers" AS c +""" + ); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_Replace_with_IgnoreCase(bool async) + { + await AssertQuery( + async, + source => source.Set().Select(x => Regex.Replace(x.CompanyName, "^a", "B", RegexOptions.IgnoreCase))); + + AssertSql( + """ +SELECT regexp_replace(c."CompanyName", '^a', 'B', 'pi') +FROM "Customers" AS c +""" + ); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_Replace_with_Multiline(bool async) + { + await AssertQuery( + async, + source => source.Set().Select(x => Regex.Replace(x.CompanyName, "^A", "B", RegexOptions.Multiline))); + + AssertSql( + """ +SELECT regexp_replace(c."CompanyName", '^A', 'B', 'n') +FROM "Customers" AS c +""" + ); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_Replace_with_Singleline(bool async) + { + await AssertQuery( + async, + source => source.Set().Select(x => Regex.Replace(x.CompanyName, "^A", "B", RegexOptions.Singleline))); + + AssertSql( + """ +SELECT regexp_replace(c."CompanyName", '^A', 'B') +FROM "Customers" AS c +""" + ); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_Replace_with_Singleline_and_IgnoreCase(bool async) + { + await AssertQuery( + async, + source => source.Set() + .Select(x => Regex.Replace(x.CompanyName, "^a", "B", RegexOptions.Singleline | RegexOptions.IgnoreCase))); + + AssertSql( + """ +SELECT regexp_replace(c."CompanyName", '^a', 'B', 'i') +FROM "Customers" AS c +""" + ); + } + + [Theory] + [MemberData(nameof(IsAsyncData))] + public async Task Regex_Replace_with_IgnorePatternWhitespace(bool async) + { + await AssertQuery( + async, + source => source.Set().Select(x => Regex.Replace(x.CompanyName, "^ A", "B", RegexOptions.IgnorePatternWhitespace))); + + AssertSql( + """ +SELECT regexp_replace(c."CompanyName", '^ A', 'B', 'px') +FROM "Customers" AS c +""" + ); + } + + [Fact] + public void Regex_Replace_with_unsupported_option() + => Assert.Throws( + () => Fixture.CreateContext().Customers + .FirstOrDefault(x => Regex.Replace(x.CompanyName, "^A", "foo", RegexOptions.RightToLeft) != null)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [MinimumPostgresVersion(15, 0)] + public async Task Regex_Count_with_constant_pattern(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Select(c => Regex.Count(c.CompanyName, "^A"))); + + AssertSql( + """ +SELECT regexp_count(c."CompanyName", '^A', 1, 'p') +FROM "Customers" AS c +""" + ); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [MinimumPostgresVersion(15, 0)] + public async Task Regex_Count_with_parameter_pattern(bool async) + { + var pattern = "^A"; + + await AssertQuery( + async, + cs => cs.Set().Select(c => Regex.Count(c.CompanyName, pattern))); + + AssertSql( + """ +@__pattern_0='^A' + +SELECT regexp_count(c."CompanyName", @__pattern_0, 1, 'p') +FROM "Customers" AS c +""" + ); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [MinimumPostgresVersion(15, 0)] + public async Task Regex_Count_with_OptionsNone(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Select(c => Regex.Count(c.CompanyName, "^A", RegexOptions.None))); + + AssertSql( + """ +SELECT regexp_count(c."CompanyName", '^A', 1, 'p') +FROM "Customers" AS c +""" + ); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [MinimumPostgresVersion(15, 0)] + public async Task Regex_Count_with_IgnoreCase(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Select(c => Regex.Count(c.CompanyName, "^a", RegexOptions.IgnoreCase))); + + AssertSql( + """ +SELECT regexp_count(c."CompanyName", '^a', 1, 'pi') +FROM "Customers" AS c +""" + ); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [MinimumPostgresVersion(15, 0)] + public async Task Regex_Count_with_Multiline(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Select(c => Regex.Count(c.CompanyName, "^A", RegexOptions.Multiline))); + + AssertSql( + """ +SELECT regexp_count(c."CompanyName", '^A', 1, 'n') +FROM "Customers" AS c +""" + ); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [MinimumPostgresVersion(15, 0)] + public async Task Regex_Count_with_Singleline(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Select(c => Regex.Count(c.CompanyName, "^A", RegexOptions.Singleline))); + + AssertSql( + """ +SELECT regexp_count(c."CompanyName", '^A') +FROM "Customers" AS c +""" + ); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [MinimumPostgresVersion(15, 0)] + public async Task Regex_Count_with_Singleline_and_IgnoreCase(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Select(c => Regex.Count(c.CompanyName, "^a", RegexOptions.Singleline | RegexOptions.IgnoreCase))); + + AssertSql( + """ +SELECT regexp_count(c."CompanyName", '^a', 1, 'i') +FROM "Customers" AS c +""" + ); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + [MinimumPostgresVersion(15, 0)] + public async Task Regex_Count_with_IgnorePatternWhitespace(bool async) + { + await AssertQuery( + async, + cs => cs.Set().Select(c => Regex.Count(c.CompanyName, "^ A", RegexOptions.IgnorePatternWhitespace))); + + AssertSql( + """ +SELECT regexp_count(c."CompanyName", '^ A', 1, 'px') +FROM "Customers" AS c +""" + ); + } + + [ConditionalFact] + [MinimumPostgresVersion(15, 0)] + public void Regex_Count_with_unsupported_option() + => Assert.Throws( + () => Fixture.CreateContext().Customers + .FirstOrDefault(x => Regex.Count(x.CompanyName, "^A", RegexOptions.RightToLeft) != 0)); + #endregion Regex #region Guid