Skip to content

Commit 89eeef6

Browse files
committed
Implement ExecuteUpdate partial update in JSON
Against dotnet/efcore#36659
1 parent 3a4c6a2 commit 89eeef6

File tree

5 files changed

+913
-324
lines changed

5 files changed

+913
-324
lines changed

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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,8 @@ protected override bool IsNaturallyOrdered(SelectExpression selectExpression)
10461046
[{ Expression: ColumnExpression { Name: "ordinality", TableAlias: var orderingTableAlias } }]
10471047
&& orderingTableAlias == unnest.Alias);
10481048

1049+
#region ExecuteUpdate
1050+
10491051
/// <summary>
10501052
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
10511053
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
@@ -1100,6 +1102,119 @@ protected override bool IsValidSelectExpressionForExecuteUpdate(
11001102
return true;
11011103
}
11021104

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

0 commit comments

Comments
 (0)