Skip to content

Commit ecf1340

Browse files
committed
Implement ExecuteUpdate partial update in JSON
Closes #3608
1 parent 6be6679 commit ecf1340

4 files changed

Lines changed: 531 additions & 650 deletions

File tree

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

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,23 @@ protected override void GenerateTop(SelectExpression selectExpression)
159159
// No TOP() in PostgreSQL, see GenerateLimitOffset
160160
}
161161

162+
/// <summary>
163+
/// Generates SQL for a constant.
164+
/// </summary>
165+
/// <param name="sqlConstantExpression">The <see cref="SqlConstantExpression" /> for which to generate SQL.</param>
166+
protected override Expression VisitSqlConstant(SqlConstantExpression sqlConstantExpression)
167+
{
168+
// Certain JSON functions (e.g. jsonb_set()) accept a JSONPATH argument - this is (currently) flown here as a
169+
// SqlConstantExpression over IReadOnlyList<PathSegment>. Render that to a string here.
170+
if (sqlConstantExpression is { Value: IReadOnlyList<PathSegment> path })
171+
{
172+
GenerateJsonPath(ConvertJsonPathSegments(path));
173+
return sqlConstantExpression;
174+
}
175+
176+
return base.VisitSqlConstant(sqlConstantExpression);
177+
}
178+
162179
/// <summary>
163180
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
164181
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
@@ -1058,31 +1075,33 @@ protected virtual Expression VisitILike(PgILikeExpression likeExpression, bool n
10581075
protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExpression)
10591076
{
10601077
// TODO: Stop producing empty JsonScalarExpressions, #30768
1061-
var path = jsonScalarExpression.Path;
1062-
if (path.Count == 0)
1078+
var segmentsPath = jsonScalarExpression.Path;
1079+
if (segmentsPath.Count == 0)
10631080
{
10641081
Visit(jsonScalarExpression.Json);
10651082
return jsonScalarExpression;
10661083
}
10671084

1085+
var path = ConvertJsonPathSegments(segmentsPath);
1086+
10681087
switch (jsonScalarExpression.TypeMapping)
10691088
{
10701089
// This case is for when a nested JSON entity is being accessed. We want the json/jsonb fragment in this case (not text),
10711090
// so we can perform further JSON operations on it.
10721091
case NpgsqlStructuralJsonTypeMapping:
1073-
GenerateJsonPath(returnsText: false);
1092+
GenerateJsonPath(jsonScalarExpression.Json, returnsText: false, path);
10741093
break;
10751094

10761095
// No need to cast the output when we expect a string anyway
10771096
case StringTypeMapping:
1078-
GenerateJsonPath(returnsText: true);
1097+
GenerateJsonPath(jsonScalarExpression.Json, returnsText: true, path);
10791098
break;
10801099

10811100
// bytea requires special handling, since we encode the binary data as base64 inside the JSON, but that requires a special
10821101
// conversion function to be extracted out to a PG bytea.
10831102
case NpgsqlByteArrayTypeMapping:
10841103
Sql.Append("decode(");
1085-
GenerateJsonPath(returnsText: true);
1104+
GenerateJsonPath(jsonScalarExpression.Json, returnsText: true, path);
10861105
Sql.Append(", 'base64')");
10871106
break;
10881107

@@ -1092,33 +1111,20 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp
10921111
case NpgsqlArrayTypeMapping arrayMapping:
10931112
Sql.Append("(ARRAY(SELECT CAST(element AS ").Append(arrayMapping.ElementTypeMapping.StoreType)
10941113
.Append(") FROM jsonb_array_elements_text(");
1095-
GenerateJsonPath(returnsText: false);
1114+
GenerateJsonPath(jsonScalarExpression.Json, returnsText: false, path);
10961115
Sql.Append(") WITH ORDINALITY AS t(element) ORDER BY ordinality))");
10971116
break;
10981117

10991118
default:
11001119
Sql.Append("CAST(");
1101-
GenerateJsonPath(returnsText: true);
1120+
GenerateJsonPath(jsonScalarExpression.Json, returnsText: true, path);
11021121
Sql.Append(" AS ");
11031122
Sql.Append(jsonScalarExpression.TypeMapping!.StoreType);
11041123
Sql.Append(")");
11051124
break;
11061125
}
11071126

11081127
return jsonScalarExpression;
1109-
1110-
void GenerateJsonPath(bool returnsText)
1111-
=> this.GenerateJsonPath(
1112-
jsonScalarExpression.Json,
1113-
returnsText: returnsText,
1114-
jsonScalarExpression.Path.Select(
1115-
s => s switch
1116-
{
1117-
{ PropertyName: string propertyName }
1118-
=> new SqlConstantExpression(propertyName, _textTypeMapping ??= _typeMappingSource.FindMapping(typeof(string))),
1119-
{ ArrayIndex: SqlExpression arrayIndex } => arrayIndex,
1120-
_ => throw new UnreachableException()
1121-
}).ToList());
11221128
}
11231129

11241130
/// <summary>
@@ -1148,6 +1154,11 @@ private void GenerateJsonPath(SqlExpression expression, bool returnsText, IReadO
11481154
// Multiple path components
11491155
Sql.Append(returnsText ? " #>> " : " #> ");
11501156

1157+
GenerateJsonPath(path);
1158+
}
1159+
1160+
private void GenerateJsonPath(IReadOnlyList<SqlExpression> path)
1161+
{
11511162
// Use simplified array literal syntax if all path components are constants for cleaner SQL
11521163
if (path.All(p => p is SqlConstantExpression { Value: var pathSegment }
11531164
&& (pathSegment is not string s || s.All(char.IsAsciiLetterOrDigit))))
@@ -1173,6 +1184,23 @@ private void GenerateJsonPath(SqlExpression expression, bool returnsText, IReadO
11731184
}
11741185
}
11751186

1187+
/// <summary>
1188+
/// Converts the standard EF <see cref="IReadOnlyList{PathSegment}" /> to an <see cref="IReadOnlyList{SqlExpression}" />
1189+
/// (the EF built-in <see cref="JsonScalarExpression" /> and <see cref="JsonQueryExpression" /> don't support non-constant
1190+
/// property names, but we do via the Npgsql-specific JSON DOM support).
1191+
/// </summary>
1192+
private IReadOnlyList<SqlExpression> ConvertJsonPathSegments(IReadOnlyList<PathSegment> path)
1193+
=> path
1194+
.Select(
1195+
s => s switch
1196+
{
1197+
{ PropertyName: string propertyName }
1198+
=> new SqlConstantExpression(propertyName, _textTypeMapping ??= _typeMappingSource.FindMapping(typeof(string))),
1199+
{ ArrayIndex: SqlExpression arrayIndex } => arrayIndex,
1200+
_ => throw new UnreachableException()
1201+
})
1202+
.ToList();
1203+
11761204
/// <summary>
11771205
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
11781206
/// the same compatibility standards as public APIs. It may be changed or removed without notice in

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,8 @@ protected override bool IsNaturallyOrdered(SelectExpression selectExpression)
10491049
[{ Expression: ColumnExpression { Name: "ordinality", TableAlias: var orderingTableAlias } }]
10501050
&& orderingTableAlias == unnest.Alias);
10511051

1052+
#region ExecuteUpdate
1053+
10521054
/// <summary>
10531055
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
10541056
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
@@ -1103,6 +1105,121 @@ protected override bool IsValidSelectExpressionForExecuteUpdate(
11031105
return true;
11041106
}
11051107

1108+
#pragma warning disable EF9002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
1109+
/// <summary>
1110+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
1111+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
1112+
/// any release. You should only use it directly in your code with extreme caution and knowing that
1113+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
1114+
/// </summary>
1115+
protected override bool TrySerializeScalarToJson(
1116+
JsonScalarExpression target,
1117+
SqlExpression value,
1118+
[NotNullWhen(true)] out SqlExpression? jsonValue)
1119+
{
1120+
var jsonTypeMapping = ((ColumnExpression)target.Json).TypeMapping!;
1121+
1122+
if (
1123+
// The base implementation doesn't handle serializing arbitrary SQL expressions to JSON, since that's
1124+
// database-specific. In PostgreSQL we simply do this by wrapping any expression in to_jsonb().
1125+
!base.TrySerializeScalarToJson(target, value, out jsonValue)
1126+
// In addition, for string, numeric and bool, the base implementation simply returns the value as-is, since most databases allow
1127+
// passing these native types directly to their JSON partial update function. In PostgreSQL, jsonb_set() always requires jsonb,
1128+
// so we wrap those expression with to_jsonb() as well.
1129+
|| jsonValue.TypeMapping?.StoreType is not "jsonb" and not "json")
1130+
{
1131+
switch (value.TypeMapping!.StoreType)
1132+
{
1133+
case "jsonb" or "json":
1134+
jsonValue = value;
1135+
return true;
1136+
1137+
case "bytea":
1138+
value = _sqlExpressionFactory.Function(
1139+
"encode",
1140+
[value, _sqlExpressionFactory.Constant("base64")],
1141+
nullable: true,
1142+
argumentsPropagateNullability: [true, true],
1143+
typeof(string),
1144+
_typeMappingSource.FindMapping(typeof(string))!
1145+
);
1146+
break;
1147+
}
1148+
1149+
jsonValue = _sqlExpressionFactory.Function(
1150+
jsonTypeMapping.StoreType switch
1151+
{
1152+
"jsonb" => "to_jsonb",
1153+
"json" => "to_json",
1154+
_ => throw new UnreachableException()
1155+
},
1156+
// Make sure PG interprets constant values correctly by adding explicit typing based on the target property's type mapping.
1157+
// Note that we can only be here for scalar properties, for structural types we always already get a jsonb/json value
1158+
// and don't need to add to_jsonb/to_json.
1159+
[value is SqlConstantExpression ? _sqlExpressionFactory.Convert(value, target.Type, target.TypeMapping) : value],
1160+
nullable: true,
1161+
argumentsPropagateNullability: [true],
1162+
typeof(string),
1163+
jsonTypeMapping);
1164+
}
1165+
1166+
return true;
1167+
}
1168+
#pragma warning restore EF9002 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
1169+
1170+
/// <summary>
1171+
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
1172+
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
1173+
/// any release. You should only use it directly in your code with extreme caution and knowing that
1174+
/// doing so can result in application failures when updating to a new Entity Framework Core release.
1175+
/// </summary>
1176+
protected override SqlExpression? GenerateJsonPartialUpdateSetter(
1177+
Expression target,
1178+
SqlExpression value,
1179+
ref SqlExpression? existingSetterValue)
1180+
{
1181+
var (jsonColumn, path) = target switch
1182+
{
1183+
JsonScalarExpression j => ((ColumnExpression)j.Json, j.Path),
1184+
JsonQueryExpression j => (j.JsonColumn, j.Path),
1185+
1186+
_ => throw new UnreachableException(),
1187+
};
1188+
1189+
var jsonSet = _sqlExpressionFactory.Function(
1190+
jsonColumn.TypeMapping?.StoreType switch
1191+
{
1192+
"jsonb" => "jsonb_set",
1193+
"json" => "json_set",
1194+
_ => throw new UnreachableException()
1195+
},
1196+
arguments:
1197+
[
1198+
existingSetterValue ?? jsonColumn,
1199+
// Hack: Rendering of JSONPATH strings happens in value generation. We can have a special expression for modify to hold the
1200+
// IReadOnlyList<PathSegment> (just like Json{Scalar,Query}Expression), but instead we do the slight hack of packaging it
1201+
// as a constant argument; it will be unpacked and handled in SQL generation.
1202+
_sqlExpressionFactory.Constant(path, RelationalTypeMapping.NullMapping),
1203+
value
1204+
],
1205+
nullable: true,
1206+
argumentsPropagateNullability: [true, true, true],
1207+
typeof(string),
1208+
jsonColumn.TypeMapping);
1209+
1210+
if (existingSetterValue is null)
1211+
{
1212+
return jsonSet;
1213+
}
1214+
else
1215+
{
1216+
existingSetterValue = jsonSet;
1217+
return null;
1218+
}
1219+
}
1220+
1221+
#endregion ExecuteUpdate
1222+
11061223
/// <summary>
11071224
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
11081225
/// the same compatibility standards as public APIs. It may be changed or removed without notice in

0 commit comments

Comments
 (0)