From a2c70cab0e419c69fcc7e409e2f64da69a31e98a Mon Sep 17 00:00:00 2001 From: ardaterekeci Date: Sun, 27 Sep 2026 02:11:52 +0300 Subject: [PATCH 1/2] Add Daitch-Mokotoff fuzzy string matching support Translate EF.Functions.FuzzyStringMatchDaitchMokotoff using provider array mapping and preserve null checks for empty inputs. Add PostgreSQL 16 integration and translator tests. Fixes #3524 --- ...qlFuzzyStringMatchDbFunctionsExtensions.cs | 11 ++++ .../NpgsqlFuzzyStringMatchMethodTranslator.cs | 11 +++- .../NpgsqlMethodCallTranslatorProvider.cs | 2 +- .../NpgsqlEvaluatableExpressionFilter.cs | 1 + .../Query/FuzzyStringMatchQueryNpgsqlTest.cs | 56 +++++++++++++++++++ ...sqlFuzzyStringMatchMethodTranslatorTest.cs | 34 +++++++++++ 6 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs diff --git a/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs b/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs index 4b463fb2c4..f6706991d6 100644 --- a/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs +++ b/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs @@ -11,6 +11,17 @@ namespace Microsoft.EntityFrameworkCore; /// public static class NpgsqlFuzzyStringMatchDbFunctionsExtensions { + /// + /// Returns the Daitch-Mokotoff Soundex codes for a string. + /// + /// + /// 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 d66d596c30..3d5f046bd0 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs @@ -6,7 +6,9 @@ /// 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) : IMethodCallTranslator { private static readonly bool[][] TrueArrays = [ @@ -33,6 +35,7 @@ public class NpgsqlFuzzyStringMatchMethodTranslator(NpgsqlSqlExpressionFactory s var function = method.Name switch { + nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDaitchMokotoff) => "daitch_mokotoff", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchSoundex) => "soundex", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDifference) => "difference", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchLevenshtein) => "levenshtein", @@ -49,7 +52,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 e2573d4ae4..306964b204 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs @@ -47,7 +47,7 @@ public NpgsqlMethodCallTranslatorProvider( new NpgsqlConvertTranslator(sqlExpressionFactory), new NpgsqlDateTimeMethodTranslator(typeMappingSource, sqlExpressionFactory), new NpgsqlFullTextSearchMethodTranslator(typeMappingSource, sqlExpressionFactory, model), - new NpgsqlFuzzyStringMatchMethodTranslator(sqlExpressionFactory), + new NpgsqlFuzzyStringMatchMethodTranslator(typeMappingSource, sqlExpressionFactory), 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 3326215fc1..54bd77ae78 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 647336f0f9..608658e34c 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 0000000000..3d81022d03 --- /dev/null +++ b/test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs @@ -0,0 +1,34 @@ +namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query; + +public class NpgsqlFuzzyStringMatchMethodTranslatorTest +{ + [Fact] + public void Daitch_mokotoff_returns_mapped_text_array() + { + using var context = new DbContext(new DbContextOptionsBuilder().UseNpgsql().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_throws_on_client() + => Assert.Throws(() => EF.Functions.FuzzyStringMatchDaitchMokotoff("John")); +} From b0c73a6da258234e57c93f63906fc347035939a4 Mon Sep 17 00:00:00 2001 From: ardaterekeci Date: Sun, 27 Sep 2026 02:20:08 +0300 Subject: [PATCH 2/2] Gate Daitch-Mokotoff translation on PostgreSQL 16 Follow the provider's version-aware translator pattern, test PostgreSQL 15 rejection and PostgreSQL 16/18 support, and annotate the nullable result. --- ...qlFuzzyStringMatchDbFunctionsExtensions.cs | 3 ++- .../NpgsqlFuzzyStringMatchMethodTranslator.cs | 6 +++-- .../NpgsqlMethodCallTranslatorProvider.cs | 3 ++- ...sqlFuzzyStringMatchMethodTranslatorTest.cs | 27 ++++++++++++++++--- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs b/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs index f6706991d6..8390bf7db9 100644 --- a/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs +++ b/src/EFCore.PG/Extensions/DbFunctionsExtensions/NpgsqlFuzzyStringMatchDbFunctionsExtensions.cs @@ -14,12 +14,13 @@ 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) + public static string[]? FuzzyStringMatchDaitchMokotoff(this DbFunctions _, string text) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(FuzzyStringMatchDaitchMokotoff))); /// diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs index 3d5f046bd0..ecdb7d8fed 100644 --- a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs +++ b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlFuzzyStringMatchMethodTranslator.cs @@ -8,7 +8,8 @@ /// public class NpgsqlFuzzyStringMatchMethodTranslator( IRelationalTypeMappingSource typeMappingSource, - NpgsqlSqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator + NpgsqlSqlExpressionFactory sqlExpressionFactory, + bool supportsDaitchMokotoff) : IMethodCallTranslator { private static readonly bool[][] TrueArrays = [ @@ -35,7 +36,8 @@ public class NpgsqlFuzzyStringMatchMethodTranslator( var function = method.Name switch { - nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDaitchMokotoff) => "daitch_mokotoff", + nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDaitchMokotoff) when supportsDaitchMokotoff + => "daitch_mokotoff", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchSoundex) => "soundex", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchDifference) => "difference", nameof(NpgsqlFuzzyStringMatchDbFunctionsExtensions.FuzzyStringMatchLevenshtein) => "levenshtein", diff --git a/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs b/src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlMethodCallTranslatorProvider.cs index 306964b204..ab60c87e69 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(typeMappingSource, sqlExpressionFactory), + new NpgsqlFuzzyStringMatchMethodTranslator(typeMappingSource, sqlExpressionFactory, supportsDaitchMokotoff), new NpgsqlJsonDomTranslator(typeMappingSource, sqlExpressionFactory, model), new NpgsqlJsonDbFunctionsTranslator(typeMappingSource, sqlExpressionFactory, model), new NpgsqlJsonPocoTranslator(typeMappingSource, sqlExpressionFactory, model), diff --git a/test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs b/test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs index 3d81022d03..e571a63191 100644 --- a/test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs +++ b/test/EFCore.PG.Tests/Query/NpgsqlFuzzyStringMatchMethodTranslatorTest.cs @@ -2,10 +2,13 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Query; public class NpgsqlFuzzyStringMatchMethodTranslatorTest { - [Fact] - public void Daitch_mokotoff_returns_mapped_text_array() + [Theory] + [InlineData(16)] + [InlineData(18)] + public void Daitch_mokotoff_returns_mapped_text_array(int postgresVersion) { - using var context = new DbContext(new DbContextOptionsBuilder().UseNpgsql().Options); + 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) @@ -28,6 +31,24 @@ public void Daitch_mokotoff_returns_mapped_text_array() 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"));