diff --git a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs index ceb1f13bd..ccd5fb8ad 100644 --- a/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.PG/Query/Internal/NpgsqlQueryableMethodTranslatingExpressionVisitor.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Storage.Json; using Npgsql.EntityFrameworkCore.PostgreSQL.Extensions.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal; using Npgsql.EntityFrameworkCore.PostgreSQL.Query.Expressions; @@ -38,6 +39,10 @@ private static readonly MethodInfo ILike2MethodInfo private static readonly MethodInfo MatchesLQuery = typeof(LTree).GetRuntimeMethod(nameof(LTree.MatchesLQuery), [typeof(string)])!; + private static readonly MethodInfo JsonValueParameterSerializerMethod = + typeof(NpgsqlQueryableMethodTranslatingExpressionVisitor).GetTypeInfo() + .GetDeclaredMethod(nameof(SerializeJsonValueParameter))!; + #endregion /// @@ -788,20 +793,31 @@ ScalarSubqueryExpression subqueryExpression (translatedItem, array) = _sqlExpressionFactory.ApplyTypeMappingsOnItemAndArray(translatedItem, array); // TODO: Null semantics - need to coalesce NULL on the item side to 'null'::jsonb. - // TODO: For constant/parameter, use JsonValueReaderWriter to produce the JSON value client-side + var jsonValueReaderWriter = translatedItem.TypeMapping?.JsonValueReaderWriter; + var jsonbTypeMapping = _typeMappingSource.FindMapping("jsonb")!; + SqlExpression jsonValue = translatedItem switch + { + SqlParameterExpression parameter when jsonValueReaderWriter is not null + => CreateJsonValueParameter(parameter, jsonValueReaderWriter, jsonbTypeMapping), + + SqlConstantExpression { Value: not null } constant when jsonValueReaderWriter is not null + => _sqlExpressionFactory.Constant( + jsonValueReaderWriter.ToJsonString(constant.Value), jsonbTypeMapping), + + _ => _sqlExpressionFactory.Function( + "to_jsonb", + [translatedItem is SqlConstantExpression { Value: string } + ? _sqlExpressionFactory.Convert(translatedItem, typeof(string), _typeMappingSource.FindMapping(typeof(string))) + : translatedItem], + nullable: true, + argumentsPropagateNullability: TrueArrays[1], + typeof(string), + jsonbTypeMapping) + }; + return BuildSimplifiedShapedQuery( source, - _sqlExpressionFactory.Contains( - array, - _sqlExpressionFactory.Function( - "to_jsonb", - [translatedItem is SqlConstantExpression { Value: string } - ? _sqlExpressionFactory.Convert(translatedItem, typeof(string), _typeMappingSource.FindMapping(typeof(string))) - : translatedItem], - nullable: true, - argumentsPropagateNullability: TrueArrays[1], - typeof(string), - _typeMappingSource.FindMapping("jsonb")))); + _sqlExpressionFactory.Contains(array, jsonValue)); } } @@ -867,6 +883,34 @@ ScalarSubqueryExpression subqueryExpression return base.TranslateCount(source, predicate); } + private SqlParameterExpression CreateJsonValueParameter( + SqlParameterExpression parameter, + JsonValueReaderWriter jsonValueReaderWriter, + RelationalTypeMapping jsonbTypeMapping) + { + var runtimeParameter = _queryCompilationContext.RegisterRuntimeParameter( + $"__npgsql_json_contains_{parameter.Name}", + Expression.Lambda( + Expression.Call( + JsonValueParameterSerializerMethod, + QueryCompilationContext.QueryContextParameter, + Expression.Constant(parameter.Name), + Expression.Constant(jsonValueReaderWriter, typeof(JsonValueReaderWriter))), + QueryCompilationContext.QueryContextParameter)); + + return new SqlParameterExpression(runtimeParameter.Name!, typeof(string), jsonbTypeMapping); + } + + private static string? SerializeJsonValueParameter( + QueryContext queryContext, + string parameterName, + JsonValueReaderWriter jsonValueReaderWriter) + { + var value = queryContext.Parameters[parameterName]; + + return value is null ? null : jsonValueReaderWriter.ToJsonString(value); + } + /// /// 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 diff --git a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs index 735ce4789..eb77a2764 100644 --- a/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs +++ b/test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs @@ -76,6 +76,52 @@ LIMIT 2 } } + [ConditionalTheory, MemberData(nameof(IsAsyncData))] + public async Task Json_primitive_collection_contains_temporal_values(bool async) + { + var dateTime = new DateTime(2026, 1, 1, 10, 0, 0, DateTimeKind.Utc); + var timeOnly = new TimeOnly(10, 0); + + var contextFactory = await InitializeNonSharedTest( + seed: async context => + { + context.Rows.Add( + new TemporalJsonEntity + { + Document = new TemporalJsonDocument + { + Dates = [dateTime], + Times = [timeOnly] + } + }); + await context.SaveChangesAsync(); + }); + + await using var queryContext = contextFactory.CreateDbContext(); + + var dateTimeCount = async + ? await queryContext.Rows.CountAsync(e => e.Document.Dates.Contains(dateTime)) + : queryContext.Rows.Count(e => e.Document.Dates.Contains(dateTime)); + var timeOnlyCount = async + ? await queryContext.Rows.CountAsync(e => e.Document.Times.Contains(timeOnly)) + : queryContext.Rows.Count(e => e.Document.Times.Contains(timeOnly)); + + Assert.Equal(1, dateTimeCount); + Assert.Equal(1, timeOnlyCount); + + AssertSql( + """ +SELECT count(*)::int +FROM "Rows" AS r +WHERE (r."Document" -> 'Dates') @> @__npgsql_json_contains_dateTime +""", + """ +SELECT count(*)::int +FROM "Rows" AS r +WHERE (r."Document" -> 'Times') @> @__npgsql_json_contains_timeOnly +"""); + } + protected class TypesDbContext(DbContextOptions options) : DbContext(options) { public DbSet Entities { get; set; } @@ -96,6 +142,26 @@ public class TypesJsonEntity public TimeSpan Interval { get; set; } } + protected class TemporalJsonDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet Rows { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().ComplexProperty(e => e.Document, d => d.ToJson()); + } + + public class TemporalJsonEntity + { + public int Id { get; set; } + public TemporalJsonDocument Document { get; set; } + } + + public class TemporalJsonDocument + { + public List Dates { get; set; } + public List Times { get; set; } + } + protected void AssertSql(params string[] expected) => ((TestSqlLoggerFactory)ListLoggerFactory).AssertBaseline(expected); }