From cd71aff342ed876c2f8852181e4b1cd2015336c9 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Wed, 22 Jul 2026 17:34:55 -0700 Subject: [PATCH 1/3] Fix LIKE escape type mapping with value converters (#3889) Keep LIKE and ILIKE escape expressions independent from match and pattern type mapping so property converters cannot rewrite provider-generated escape values. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5b861c2-ea5b-411c-8efe-3d925d29c52e --- .../Query/NpgsqlSqlExpressionFactory.cs | 25 ++++++--- .../AdHocMiscellaneousQueryNpgsqlTest.cs | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs index 3131ed4470..692502e78c 100644 --- a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs +++ b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs @@ -394,6 +394,7 @@ public virtual PgFunctionExpression AggregateFunction( sqlExpression = sqlExpression switch { SqlBinaryExpression e => ApplyTypeMappingOnSqlBinary(e, typeMapping), + LikeExpression e => ApplyTypeMappingOnLike(e), // PostgreSQL-specific expression types PgAnyExpression e => ApplyTypeMappingOnAny(e), @@ -710,20 +711,30 @@ private SqlExpression ApplyTypeMappingOnArraySlice( array.TypeMapping); } + // The base implementation is private, so duplicate it here to keep the escape character's type mapping independent. + private SqlExpression ApplyTypeMappingOnLike(LikeExpression likeExpression) + { + var inferredTypeMapping = ExpressionExtensions.InferTypeMapping(likeExpression.Match, likeExpression.Pattern) + ?? _typeMappingSource.FindMapping(likeExpression.Match.Type, Dependencies.Model); + + return new LikeExpression( + ApplyTypeMapping(likeExpression.Match, inferredTypeMapping), + ApplyTypeMapping(likeExpression.Pattern, inferredTypeMapping), + // The escape character must not inherit the match or pattern's value converter (#3888). + ApplyDefaultTypeMapping(likeExpression.EscapeChar), + _boolTypeMapping); + } + private SqlExpression ApplyTypeMappingOnILike(PgILikeExpression ilikeExpression) { - var inferredTypeMapping = (ilikeExpression.EscapeChar is null - ? ExpressionExtensions.InferTypeMapping( - ilikeExpression.Match, ilikeExpression.Pattern) - : ExpressionExtensions.InferTypeMapping( - ilikeExpression.Match, ilikeExpression.Pattern, - ilikeExpression.EscapeChar)) + var inferredTypeMapping = ExpressionExtensions.InferTypeMapping(ilikeExpression.Match, ilikeExpression.Pattern) ?? _typeMappingSource.FindMapping(ilikeExpression.Match.Type, Dependencies.Model); return new PgILikeExpression( ApplyTypeMapping(ilikeExpression.Match, inferredTypeMapping), ApplyTypeMapping(ilikeExpression.Pattern, inferredTypeMapping), - ApplyTypeMapping(ilikeExpression.EscapeChar, inferredTypeMapping), + // The escape character must not inherit the match or pattern's value converter (#3888). + ApplyDefaultTypeMapping(ilikeExpression.EscapeChar), _boolTypeMapping); } diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocMiscellaneousQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocMiscellaneousQueryNpgsqlTest.cs index aa9f5700e9..ede8585945 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocMiscellaneousQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocMiscellaneousQueryNpgsqlTest.cs @@ -36,4 +36,57 @@ public override Task Subquery_first_member_compared_to_null(bool async) [ConditionalTheory(Skip = "https://github.com/dotnet/efcore/pull/27995/files#r874038747")] public override Task StoreType_for_UDF_used(bool async) => base.StoreType_for_UDF_used(async); + + [ConditionalFact] + public virtual async Task Like_with_implicit_escape_does_not_apply_value_converter() + { + var contextFactory = await InitializeNonSharedTest( + seed: async context => + { + context.Entities.Add(new Context3888.Entity { Value = "ABC" }); + await context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateDbContext(); + + var pattern = "%ABC%"; + + Assert.Equal(1, await context.Entities.CountAsync(e => EF.Functions.Like(e.Value!, pattern))); + Assert.Equal(1, await context.Entities.CountAsync(e => EF.Functions.ILike(e.Value!, pattern))); + + AssertSql( + """ +@pattern='%ABC%' + +SELECT count(*)::int +FROM "Entities" AS e +WHERE e."Value" LIKE @pattern ESCAPE '' +""", + // + """ +@pattern='%ABC%' + +SELECT count(*)::int +FROM "Entities" AS e +WHERE e."Value" ILIKE @pattern ESCAPE '' +"""); + } + + protected class Context3888(DbContextOptions options) : DbContext(options) + { + public DbSet Entities { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity() + .Property(e => e.Value) + .HasConversion( + value => string.IsNullOrWhiteSpace(value) ? null : value, + value => value ?? string.Empty); + + public class Entity + { + public int Id { get; set; } + public string? Value { get; set; } + } + } } From b325f0ab16f358bd50f7c3963e0b9fdfe3c01ecd Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Thu, 23 Jul 2026 10:02:22 -0700 Subject: [PATCH 2/3] Handle absent LIKE escape expressions explicitly Preserve null escape expressions before applying default type mapping for LIKE and ILIKE. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5b861c2-ea5b-411c-8efe-3d925d29c52e --- src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs index 692502e78c..f2ec14ec3c 100644 --- a/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs +++ b/src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs @@ -721,7 +721,7 @@ private SqlExpression ApplyTypeMappingOnLike(LikeExpression likeExpression) ApplyTypeMapping(likeExpression.Match, inferredTypeMapping), ApplyTypeMapping(likeExpression.Pattern, inferredTypeMapping), // The escape character must not inherit the match or pattern's value converter (#3888). - ApplyDefaultTypeMapping(likeExpression.EscapeChar), + likeExpression.EscapeChar is null ? null : ApplyDefaultTypeMapping(likeExpression.EscapeChar), _boolTypeMapping); } @@ -734,7 +734,7 @@ private SqlExpression ApplyTypeMappingOnILike(PgILikeExpression ilikeExpression) ApplyTypeMapping(ilikeExpression.Match, inferredTypeMapping), ApplyTypeMapping(ilikeExpression.Pattern, inferredTypeMapping), // The escape character must not inherit the match or pattern's value converter (#3888). - ApplyDefaultTypeMapping(ilikeExpression.EscapeChar), + ilikeExpression.EscapeChar is null ? null : ApplyDefaultTypeMapping(ilikeExpression.EscapeChar), _boolTypeMapping); } From df3bc557342caedad9603689afc1eecb47474de8 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Thu, 23 Jul 2026 10:23:12 -0700 Subject: [PATCH 3/3] Adapt LIKE regression test to 10.0 infrastructure Use the hotfix branch's InitializeAsync and CreateContext helpers for the backported regression test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e5b861c2-ea5b-411c-8efe-3d925d29c52e --- .../Query/AdHocMiscellaneousQueryNpgsqlTest.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocMiscellaneousQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocMiscellaneousQueryNpgsqlTest.cs index ede8585945..34cf2f82ef 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocMiscellaneousQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocMiscellaneousQueryNpgsqlTest.cs @@ -40,14 +40,14 @@ public override Task StoreType_for_UDF_used(bool async) [ConditionalFact] public virtual async Task Like_with_implicit_escape_does_not_apply_value_converter() { - var contextFactory = await InitializeNonSharedTest( + var contextFactory = await InitializeAsync( seed: async context => { context.Entities.Add(new Context3888.Entity { Value = "ABC" }); await context.SaveChangesAsync(); }); - await using var context = contextFactory.CreateDbContext(); + await using var context = contextFactory.CreateContext(); var pattern = "%ABC%";