Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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

/// <summary>
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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);
}

/// <summary>
/// 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
Expand Down
66 changes: 66 additions & 0 deletions test/EFCore.PG.FunctionalTests/Query/AdHocJsonQueryNpgsqlTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TemporalJsonDbContext>(
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<TypesContainerEntity> Entities { get; set; }
Expand All @@ -96,6 +142,26 @@ public class TypesJsonEntity
public TimeSpan Interval { get; set; }
}

protected class TemporalJsonDbContext(DbContextOptions options) : DbContext(options)
{
public DbSet<TemporalJsonEntity> Rows { get; set; }

protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<TemporalJsonEntity>().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<DateTime> Dates { get; set; }
public List<TimeOnly> Times { get; set; }
}

protected void AssertSql(params string[] expected)
=> ((TestSqlLoggerFactory)ListLoggerFactory).AssertBaseline(expected);
}