Skip to content

Commit 25f5469

Browse files
committed
Revamp support for primitive collections within JSON
Closes #3122
1 parent 6f94f23 commit 25f5469

24 files changed

+878
-495
lines changed

src/EFCore.PG/Extensions/Internal/NpgsqlShapedQueryExpressionExtensions.cs

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public static bool TryExtractArray(
5656
Offset: null
5757
} select
5858
&& (ignorePredicate || select.Predicate is null)
59-
// We can only apply the indexing if the JSON array is ordered by its natural ordered, i.e. by the "ordinality" column that
59+
// We can only apply the indexing if the array is ordered by its natural ordered, i.e. by the "ordinality" column that
6060
// we created in TranslatePrimitiveCollection. For example, if another ordering has been applied (e.g. by the array elements
6161
// themselves), we can no longer simply index into the original array.
6262
&& (ignoreOrderings
@@ -76,6 +76,78 @@ public static bool TryExtractArray(
7676
return false;
7777
}
7878

79+
/// <summary>
80+
/// If the given <paramref name="source" /> wraps a JSON-array-returning expression without any additional clauses (e.g. filter,
81+
/// ordering...), returns that expression.
82+
/// </summary>
83+
/// <remarks>
84+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
85+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
86+
/// any release. You should only use it directly in your code with extreme caution and knowing that
87+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
88+
/// </remarks>
89+
public static bool TryExtractJsonArray(
90+
this ShapedQueryExpression source,
91+
[NotNullWhen(true)] out SqlExpression? jsonArray,
92+
[NotNullWhen(true)] out SqlExpression? projectedElement,
93+
out bool isElementNullable,
94+
bool ignoreOrderings = false,
95+
bool ignorePredicate = false)
96+
{
97+
if (source.QueryExpression is SelectExpression
98+
{
99+
Tables:
100+
[
101+
TableValuedFunctionExpression
102+
{
103+
Name: "jsonb_array_elements_text" or "json_array_elements_text",
104+
Arguments: [var json]
105+
} tvf
106+
],
107+
GroupBy: [],
108+
Having: null,
109+
IsDistinct: false,
110+
Limit: null,
111+
Offset: null
112+
} select
113+
&& (ignorePredicate || select.Predicate is null)
114+
// We can only apply the indexing if the array is ordered by its natural ordered, i.e. by the "ordinality" column that
115+
// we created in TranslatePrimitiveCollection. For example, if another ordering has been applied (e.g. by the array elements
116+
// themselves), we can no longer simply index into the original array.
117+
&& (ignoreOrderings
118+
|| select.Orderings is []
119+
|| (select.Orderings is [{ Expression: ColumnExpression { Name: "ordinality", TableAlias: var orderingTableAlias } }]
120+
&& orderingTableAlias == tvf.Alias))
121+
&& TryGetScalarProjection(source, out var projectedScalar))
122+
{
123+
jsonArray = json;
124+
125+
// The projected ColumnExpression is wrapped in a Convert to apply the element type mapping - unless it happens to be text.
126+
switch (projectedScalar)
127+
{
128+
case SqlUnaryExpression
129+
{
130+
OperatorType: ExpressionType.Convert,
131+
Operand: ColumnExpression { IsNullable: var isNullable }
132+
} convert:
133+
projectedElement = convert;
134+
isElementNullable = isNullable;
135+
return true;
136+
case ColumnExpression { IsNullable: var isNullable } column:
137+
projectedElement = column;
138+
isElementNullable = isNullable;
139+
return true;
140+
default:
141+
throw new UnreachableException();
142+
}
143+
}
144+
145+
jsonArray = null;
146+
projectedElement = null;
147+
isElementNullable = false;
148+
return false;
149+
}
150+
79151
/// <summary>
80152
/// If the given <paramref name="source" /> wraps a <see cref="ValuesExpression" /> without any additional clauses (e.g. filter,
81153
/// ordering...), converts that to a <see cref="NewArrayExpression" /> and returns that.
@@ -86,7 +158,7 @@ public static bool TryExtractArray(
86158
/// any release. You should only use it directly in your code with extreme caution and knowing that
87159
/// doing so can result in application failures when updating to a new Entity Framework Core release.
88160
/// </remarks>
89-
public static bool TryConvertValuesToArray(
161+
public static bool TryConvertToArray(
90162
this ShapedQueryExpression source,
91163
[NotNullWhen(true)] out SqlExpression? array,
92164
bool ignoreOrderings = false,
@@ -128,13 +200,28 @@ private static bool IsPostgresArray(SqlExpression expression)
128200
{
129201
{ TypeMapping: NpgsqlArrayTypeMapping } => true,
130202
{ TypeMapping: NpgsqlMultirangeTypeMapping } => false,
203+
{ TypeMapping: NpgsqlJsonTypeMapping } => false,
131204
{ Type: var type } when type.IsMultirange() => false,
132205
_ => true
133206
};
134207

135208
private static bool TryGetProjectedColumn(
136209
ShapedQueryExpression shapedQueryExpression,
137210
[NotNullWhen(true)] out ColumnExpression? projectedColumn)
211+
{
212+
if (TryGetScalarProjection(shapedQueryExpression, out var scalar) && scalar is ColumnExpression column)
213+
{
214+
projectedColumn = column;
215+
return true;
216+
}
217+
218+
projectedColumn = null;
219+
return false;
220+
}
221+
222+
private static bool TryGetScalarProjection(
223+
ShapedQueryExpression shapedQueryExpression,
224+
[NotNullWhen(true)] out SqlExpression? projectedScalar)
138225
{
139226
var shaperExpression = shapedQueryExpression.ShaperExpression;
140227
if (shaperExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression
@@ -146,13 +233,13 @@ private static bool TryGetProjectedColumn(
146233

147234
if (shaperExpression is ProjectionBindingExpression projectionBindingExpression
148235
&& shapedQueryExpression.QueryExpression is SelectExpression selectExpression
149-
&& selectExpression.GetProjection(projectionBindingExpression) is ColumnExpression c)
236+
&& selectExpression.GetProjection(projectionBindingExpression) is SqlExpression scalar)
150237
{
151-
projectedColumn = c;
238+
projectedScalar = scalar;
152239
return true;
153240
}
154241

155-
projectedColumn = null;
242+
projectedScalar = null;
156243
return false;
157244
}
158245
}

src/EFCore.PG/Metadata/Conventions/NpgsqlConventionSetBuilder.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal;
2+
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal;
23

34
namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions;
45

@@ -17,7 +18,7 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions;
1718
/// </remarks>
1819
public class NpgsqlConventionSetBuilder : RelationalConventionSetBuilder
1920
{
20-
private readonly IRelationalTypeMappingSource _typeMappingSource;
21+
private readonly NpgsqlTypeMappingSource _typeMappingSource;
2122
private readonly Version _postgresVersion;
2223
private readonly IReadOnlyList<EnumDefinition> _enumDefinitions;
2324

@@ -35,7 +36,7 @@ public NpgsqlConventionSetBuilder(
3536
INpgsqlSingletonOptions npgsqlSingletonOptions)
3637
: base(dependencies, relationalDependencies)
3738
{
38-
_typeMappingSource = typeMappingSource;
39+
_typeMappingSource = (NpgsqlTypeMappingSource)typeMappingSource;
3940
_postgresVersion = npgsqlSingletonOptions.PostgresVersion;
4041
_enumDefinitions = npgsqlSingletonOptions.EnumDefinitions;
4142
}

src/EFCore.PG/Metadata/Conventions/NpgsqlPostgresModelFinalizingConvention.cs

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure.Internal;
2+
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal;
3+
using Npgsql.EntityFrameworkCore.PostgreSQL.Storage.Internal.Mapping;
24

35
namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions;
46

@@ -8,24 +10,10 @@ namespace Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.Conventions;
810
/// <remarks>
911
/// See <see href="https://aka.ms/efcore-docs-conventions">Model building conventions</see>.
1012
/// </remarks>
11-
public class NpgsqlPostgresModelFinalizingConvention : IModelFinalizingConvention
13+
public class NpgsqlPostgresModelFinalizingConvention(
14+
NpgsqlTypeMappingSource typeMappingSource,
15+
IReadOnlyList<EnumDefinition> enumDefinitions) : IModelFinalizingConvention
1216
{
13-
private readonly IRelationalTypeMappingSource _typeMappingSource;
14-
private readonly IReadOnlyList<EnumDefinition> _enumDefinitions;
15-
16-
/// <summary>
17-
/// Creates a new instance of <see cref="NpgsqlPostgresModelFinalizingConvention" />.
18-
/// </summary>
19-
/// <param name="typeMappingSource">The type mapping source to use.</param>
20-
/// <param name="enumDefinitions"></param>
21-
public NpgsqlPostgresModelFinalizingConvention(
22-
IRelationalTypeMappingSource typeMappingSource,
23-
IReadOnlyList<EnumDefinition> enumDefinitions)
24-
{
25-
_typeMappingSource = typeMappingSource;
26-
_enumDefinitions = enumDefinitions;
27-
}
28-
2917
/// <inheritdoc />
3018
public virtual void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
3119
{
@@ -34,7 +22,7 @@ public virtual void ProcessModelFinalizing(IConventionModelBuilder modelBuilder,
3422
foreach (var property in entityType.GetDeclaredProperties())
3523
{
3624
var typeMapping = (RelationalTypeMapping?)property.FindTypeMapping()
37-
?? _typeMappingSource.FindMapping((IProperty)property);
25+
?? typeMappingSource.FindMapping((IProperty)property);
3826

3927
if (typeMapping is not null)
4028
{
@@ -52,7 +40,7 @@ public virtual void ProcessModelFinalizing(IConventionModelBuilder modelBuilder,
5240
/// </summary>
5341
protected virtual void SetupEnums(IConventionModelBuilder modelBuilder)
5442
{
55-
foreach (var enumDefinition in _enumDefinitions)
43+
foreach (var enumDefinition in enumDefinitions)
5644
{
5745
modelBuilder.HasPostgresEnum(
5846
enumDefinition.StoreTypeSchema,

src/EFCore.PG/Query/Expressions/Internal/PgTableValuedFunctionExpression.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ public PgTableValuedFunctionExpression(
5555
string alias,
5656
string name,
5757
IReadOnlyList<SqlExpression> arguments,
58-
IReadOnlyList<ColumnInfo>? columnInfos,
58+
IReadOnlyList<ColumnInfo>? columnInfos = null,
5959
bool withOrdinality = true)
6060
: base(alias, name, schema: null, builtIn: true, arguments)
6161
{

src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,12 +1086,19 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp
10861086

10871087
switch (jsonScalarExpression.TypeMapping)
10881088
{
1089-
// This case is for when a nested JSON entity is being accessed. We want the json/jsonb fragment in this case (not text),
1090-
// so we can perform further JSON operations on it.
1089+
// Nested JSON structural type. We want the json/jsonb fragment in this case (not text), so we can perform further JSON
1090+
// operations on it.
10911091
case NpgsqlStructuralJsonTypeMapping:
10921092
GenerateJsonPath(jsonScalarExpression.Json, returnsText: false, path);
10931093
break;
10941094

1095+
// Scalar collection mapped to JSON (either because it's nested within a JSON document, or because the user explicitly
1096+
// opted for this rather than the default PG array mapping).
1097+
case NpgsqlJsonTypeMapping typeMapping:
1098+
Check.DebugAssert(typeMapping.ElementTypeMapping is not null);
1099+
GenerateJsonPath(jsonScalarExpression.Json, returnsText: false, path);
1100+
break;
1101+
10951102
// No need to cast the output when we expect a string anyway
10961103
case StringTypeMapping:
10971104
GenerateJsonPath(jsonScalarExpression.Json, returnsText: true, path);
@@ -1105,15 +1112,10 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp
11051112
Sql.Append(", 'base64')");
11061113
break;
11071114

1108-
// Arrays require special handling; we cannot simply cast a JSON array (as text) to a PG array ([1,2,3] isn't a valid PG array
1109-
// representation). We use jsonb_array_elements_text to extract the array elements as a set, cast them to their PG element type
1110-
// and then build an array from that.
1111-
case NpgsqlArrayTypeMapping arrayMapping:
1112-
Sql.Append("(ARRAY(SELECT CAST(element AS ").Append(arrayMapping.ElementTypeMapping.StoreType)
1113-
.Append(") FROM jsonb_array_elements_text(");
1114-
GenerateJsonPath(jsonScalarExpression.Json, returnsText: false, path);
1115-
Sql.Append(") WITH ORDINALITY AS t(element) ORDER BY ordinality))");
1116-
break;
1115+
// We should never have an NpgsqlArrayTypeMapping within a JSON document; scalar collections should be represented as an
1116+
// NpgsqlJsonTypeMapping with the appropriate ElementTypeMapping, just like in other providers.
1117+
case NpgsqlArrayTypeMapping:
1118+
throw new UnreachableException();
11171119

11181120
default:
11191121
Sql.Append("CAST(");

0 commit comments

Comments
 (0)