diff --git a/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs b/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs index 4b463fb2c..8390bf7db 100644 --- a/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs +++ b/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs @@ -11,6 +11,18 @@ namespace Microsoft.EntityFrameworkCore; /// public static class NpgsqlFuzzyStringMatchDbFunctionsExtensions { + /// + /// Returns the Daitch-Mokotoff Soundex codes for a string. + /// + /// The soundex codes, or if the input contains no encodable characters. + /// + /// The method call is translated to daitch_mokotoff(text). + /// Requires PostgreSQL 16 or later and the fuzzystrmatch extension. + /// See https://www.postgresql.org/docs/current/fuzzystrmatch.html. + /// + public static string[]? FuzzyStringMatchDaitchMokotoff(this DbFunctions _, string text) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FuzzyStringMatchDaitchMokotoff))); + /// /// The soundex function converts a string to its Soundex code. /// diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs index d66d596c3..ecdb7d8fe 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs @@ -6,7 +6,10 @@ /// 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 class NpgsqlFuzzyStringMatchMethodTranslator(NpgsqlSqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator +public class NpgsqlFuzzyStringMatchMethodTranslator( + IRelationalTypeMappingSource typeMappingSource, + NpgsqlSqlExpressionFactory sqlExpressionFactory, + bool supportsDaitchMokotoff) : IMethodCallTranslator { private static readonly bool[][] TrueArrays = [ @@ -33,6 +36,8 @@ public class NpgsqlFuzzyStringMatchMethodTranslator(NpgsqlSqlExpressionFactory s var function = method.Name switch { + nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDaitchMokotoff) when supportsDaitchMokotoff + => "daitch_mokotoff", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchSoundex) => "soundex", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDifference) => "difference", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchLevenshtein) => "levenshtein", @@ -49,7 +54,9 @@ public class NpgsqlFuzzyStringMatchMethodTranslator(NpgsqlSqlExpressionFactory s function, arguments.Skip(1), nullable: true, - argumentsPropagateNullability: TrueArrays[arguments.Count - 1], - method.ReturnType); + // daitch_mokotoff can return NULL for non-null inputs such as the empty string. + argumentsPropagateNullability: function == "daitch_mokotoff" ? [false] : TrueArrays[arguments.Count - 1], + method.ReturnType, + typeMappingSource.FindMapping(method.ReturnType)); } } diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs index e2573d4ae..ab60c87e6 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs @@ -34,6 +34,7 @@ public NpgsqlMethodCallTranslatorProvider( var npgsqlOptions = contextOptions.FindExtension() ?? new NpgsqlOptionsExtension(); var supportsMultiranges = npgsqlOptions.PostgresVersion.AtLeast(14); var supportRegexCount = npgsqlOptions.PostgresVersion.AtLeast(15); + var supportsDaitchMokotoff = npgsqlOptions.PostgresVersion.AtLeast(16); var sqlExpressionFactory = (NpgsqlSqlExpressionFactory)dependencies.SqlExpressionFactory; var typeMappingSource = (NpgsqlTypeMappingSource)dependencies.RelationalTypeMappingSource; @@ -47,7 +48,7 @@ public NpgsqlMethodCallTranslatorProvider( new NpgsqlConvertTranslator(sqlExpressionFactory), new NpgsqlDateTimeMethodTranslator(typeMappingSource, sqlExpressionFactory), new NpgsqlFullTextSearchMethodTranslator(typeMappingSource, sqlExpressionFactory, model), - new NpgsqlFuzzyStringMatchMethodTranslator(sqlExpressionFactory), + new NpgsqlFuzzyStringMatchMethodTranslator(typeMappingSource, sqlExpressionFactory, supportsDaitchMokotoff), new NpgsqlJsonDomTranslator(typeMappingSource, sqlExpressionFactory, model), new NpgsqlJsonDbFunctionsTranslator(typeMappingSource, sqlExpressionFactory, model), new NpgsqlJsonPocoTranslator(typeMappingSource, sqlExpressionFactory, model), diff --git a/src/EFCore.PG/Query/Internal/NpgsqlEvaluatableExpressionFilter.cs b/src/EFCore.PG/Query/Internal/NpgsqlEvaluatableExpressionFilter.cs index 3326215fc..54bd77ae7 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlEvaluatableExpressionFilter.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlEvaluatableExpressionFilter.cs @@ -47,6 +47,7 @@ public override bool IsEvaluatableExpression(Expression expression, IModel model || declaringType == typeof(NpgsqlDbFunctionsExtensions) || declaringType == typeof(NpgsqlFullTextSearchDbFunctionsExtensions) || declaringType == typeof(NpgsqlFullTextSearchLinqExtensions) + || declaringType == typeof(NpgsqlFuzzyStringMatchDbFunctionsExtensions) || declaringType == typeof(NpgsqlNetworkDbFunctionsExtensions) || declaringType == typeof(NpgsqlJsonDbFunctionsExtensions) || declaringType == typeof(NpgsqlRangeDbFunctionsExtensions) diff --git a/test/EFCore.PG.FunctionalTests/Query/FuzzyStringMatchQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/FuzzyStringMatchQueryNpgsqlTest.cs index 647336f0f..608658e34 100644 --- a/test/EFCore.PG.FunctionalTests/Query/FuzzyStringMatchQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/FuzzyStringMatchQueryNpgsqlTest.cs @@ -22,6 +22,62 @@ public FuzzyStringMatchQueryNpgsqlTest(FuzzyStringMatchQueryNpgsqlFixture fixtur #region FunctionTests + [Fact] + [MinimumPostgresVersion(16, 0)] + public async Task FuzzyStringMatchDaitchMokotoff() + { + await using var context = CreateContext(); + var results = await context.FuzzyStringMatchTestEntities + .Select(x => EF.Functions.FuzzyStringMatchDaitchMokotoff(x.Text)) + .ToArrayAsync(); + + Assert.Equal(9, results.Length); + Assert.All(results, result => Assert.Equal(new[] { "463543" }, result)); + + AssertSql( + """ +SELECT daitch_mokotoff(f."Text") +FROM "FuzzyStringMatchTestEntities" AS f +"""); + } + + [Theory] + [InlineData("George", new[] { "595000" })] + [InlineData("John", new[] { "160000", "460000" })] + [InlineData("", null)] + [InlineData(null, null)] + [MinimumPostgresVersion(16, 0)] + public async Task FuzzyStringMatchDaitchMokotoff_parameter(string? text, string[]? expected) + { + await using var context = CreateContext(); + var results = await context.FuzzyStringMatchTestEntities + .Select(x => EF.Functions.FuzzyStringMatchDaitchMokotoff(text!)) + .ToArrayAsync(); + + Assert.Equal(9, results.Length); + Assert.All(results, result => Assert.Equal(expected, result)); + + AssertContainsSql(text is null ? "daitch_mokotoff(NULL)" : "daitch_mokotoff(@text)"); + } + + [Fact] + [MinimumPostgresVersion(16, 0)] + public async Task FuzzyStringMatchDaitchMokotoff_null_result() + { + await using var context = CreateContext(); + var count = await context.FuzzyStringMatchTestEntities + .CountAsync(x => EF.Functions.FuzzyStringMatchDaitchMokotoff(x.Text.Substring(0, 0)) == null); + + Assert.Equal(9, count); + + AssertSql( + """ +SELECT count(*)::int +FROM "FuzzyStringMatchTestEntities" AS f +WHERE daitch_mokotoff(substring(f."Text", 1, 0)) IS NULL +"""); + } + [Fact] public void FuzzyStringMatchSoundex() { diff --git a/test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs b/test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs new file mode 100644 index 000000000..e571a6319 --- /dev/null +++ b/test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs @@ -0,0 +1,55 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query; + +public class NpgsqlFuzzyStringMatchMethodTranslatorTest +{ + [Theory] + [InlineData(16)] + [InlineData(18)] + public void Daitch_mokotoff_returns_mapped_text_array(int postgresVersion) + { + using var context = new DbContext( + new DbContextOptionsBuilder().UseNpgsql(o => o.SetPostgresVersion(postgresVersion, 0)).Options); + var sqlExpressionFactory = context.GetService(); + var translator = context.GetService(); + var method = typeof(NpgsqlFuzzyStringMatchDbFunctionsExtensions) + .GetMethod(nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDaitchMokotoff))!; + + var translation = Assert.IsType( + translator.Translate( + context.Model, + null, + method, + [sqlExpressionFactory.Constant(EF.Functions), sqlExpressionFactory.Constant("John")], + context.GetService>())); + + Assert.Equal("daitch_mokotoff", translation.Name); + Assert.Equal(typeof(string[]), translation.Type); + Assert.Same(context.GetService().FindMapping(typeof(string[])), translation.TypeMapping); + Assert.Equal("text[]", translation.TypeMapping!.StoreType); + Assert.Single(translation.Arguments!); + Assert.True(translation.IsNullable); + Assert.Equal(new[] { false }, translation.ArgumentsPropagateNullability); + } + + [Fact] + public void Daitch_mokotoff_is_not_translated_before_PostgreSQL_16() + { + using var context = new DbContext(new DbContextOptionsBuilder().UseNpgsql(o => o.SetPostgresVersion(15, 0)).Options); + var sqlExpressionFactory = context.GetService(); + var translator = context.GetService(); + var method = typeof(NpgsqlFuzzyStringMatchDbFunctionsExtensions) + .GetMethod(nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDaitchMokotoff))!; + + Assert.Null( + translator.Translate( + context.Model, + null, + method, + [sqlExpressionFactory.Constant(EF.Functions), sqlExpressionFactory.Constant("John")], + context.GetService>())); + } + + [Fact] + public void Daitch_mokotoff_throws_on_client() + => Assert.Throws(() => EF.Functions.FuzzyStringMatchDaitchMokotoff("John")); +}