Skip to content

Commit a5d283e

Browse files
committed
Use JSON_VALUE() and JSON_QUERY() in PG17 and above
Closes npgsql#3304
1 parent 86e9948 commit a5d283e

File tree

5 files changed

+4058
-500
lines changed

5 files changed

+4058
-500
lines changed

Diff for: src/EFCore.PG/Query/Internal/NpgsqlQuerySqlGenerator.cs

+131-19
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,12 @@ public class NpgsqlQuerySqlGenerator : QuerySqlGenerator
2222
/// </summary>
2323
private readonly bool _reverseNullOrderingEnabled;
2424

25+
private readonly Version _postgresVersion;
26+
2527
/// <summary>
26-
/// The backend version to target. If null, it means the user hasn't set a compatibility version, and the
27-
/// latest should be targeted.
28+
/// True for PG17 and above (JSON_VALUE, JSON_QUERY)
2829
/// </summary>
29-
private readonly Version _postgresVersion;
30+
private readonly bool _useNewJsonFunctions;
3031

3132
/// <inheritdoc />
3233
public NpgsqlQuerySqlGenerator(
@@ -40,6 +41,7 @@ public NpgsqlQuerySqlGenerator(
4041
_typeMappingSource = typeMappingSource;
4142
_reverseNullOrderingEnabled = reverseNullOrderingEnabled;
4243
_postgresVersion = postgresVersion;
44+
_useNewJsonFunctions = postgresVersion >= new Version(17, 0);
4345
}
4446

4547
/// <summary>
@@ -1057,58 +1059,161 @@ protected virtual Expression VisitILike(PgILikeExpression likeExpression, bool n
10571059
/// </summary>
10581060
protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExpression)
10591061
{
1060-
// TODO: Stop producing empty JsonScalarExpressions, #30768
1062+
// TODO: Stop producing empty JsonValueExpressions, #30768
10611063
var path = jsonScalarExpression.Path;
10621064
if (path.Count == 0)
10631065
{
10641066
Visit(jsonScalarExpression.Json);
10651067
return jsonScalarExpression;
10661068
}
10671069

1070+
if (_useNewJsonFunctions)
1071+
{
1072+
switch (jsonScalarExpression.TypeMapping)
1073+
{
1074+
case NpgsqlOwnedJsonTypeMapping:
1075+
GenerateJsonValueQuery(isJsonQuery: true, jsonScalarExpression.Json, jsonScalarExpression.Path, returningType: null);
1076+
return jsonScalarExpression;
1077+
1078+
// Arrays cannot be extracted with JSON_VALUE(), JSON_QUERY() must be used; but we still use the RETURNING clause
1079+
// to get the value out as a PostgreSQL array rather than as a jsonb.
1080+
case NpgsqlArrayTypeMapping:
1081+
GenerateJsonValueQuery(
1082+
isJsonQuery: true, jsonScalarExpression.Json, jsonScalarExpression.Path,
1083+
jsonScalarExpression.TypeMapping!.StoreType);
1084+
return jsonScalarExpression;
1085+
1086+
// Unfortunately, JSON_VALUE() with RETURNING bytea doesn't seem to perform base64 decoding,
1087+
// see https://www.postgresql.org/message-id/CADT4RqB9y5A58CAxMgWQpKG2QA1pzk3dzAUmNH8bJ9SwMP%3DZnA%40mail.gmail.com
1088+
// So we manually add decoding.
1089+
case NpgsqlByteArrayTypeMapping:
1090+
Sql.Append("decode(");
1091+
GenerateJsonValueQuery(isJsonQuery: false, jsonScalarExpression.Json, jsonScalarExpression.Path, returningType: null);
1092+
Sql.Append(", 'base64')");
1093+
return jsonScalarExpression;
1094+
1095+
default:
1096+
GenerateJsonValueQuery(
1097+
isJsonQuery: false, jsonScalarExpression.Json, jsonScalarExpression.Path,
1098+
jsonScalarExpression.TypeMapping!.StoreType);
1099+
return jsonScalarExpression;
1100+
}
1101+
}
1102+
1103+
// We're targeting a PostgreSQL version under 17, so JSON_VALUE() doesn't exist yet. We need to use the legacy JSON path syntax.
10681104
switch (jsonScalarExpression.TypeMapping)
10691105
{
10701106
// This case is for when a nested JSON entity is being accessed. We want the json/jsonb fragment in this case (not text),
10711107
// so we can perform further JSON operations on it.
10721108
case NpgsqlOwnedJsonTypeMapping:
1073-
GenerateJsonPath(returnsText: false);
1074-
break;
1109+
GenerateLegacyJsonPath(returnsText: false);
1110+
return jsonScalarExpression;
10751111

10761112
// No need to cast the output when we expect a string anyway
10771113
case StringTypeMapping:
1078-
GenerateJsonPath(returnsText: true);
1079-
break;
1114+
GenerateLegacyJsonPath(returnsText: true);
1115+
return jsonScalarExpression;
10801116

10811117
// bytea requires special handling, since we encode the binary data as base64 inside the JSON, but that requires a special
10821118
// conversion function to be extracted out to a PG bytea.
10831119
case NpgsqlByteArrayTypeMapping:
10841120
Sql.Append("decode(");
1085-
GenerateJsonPath(returnsText: true);
1121+
GenerateLegacyJsonPath(returnsText: true);
10861122
Sql.Append(", 'base64')");
1087-
break;
1123+
return jsonScalarExpression;
10881124

10891125
// 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
10901126
// representation). We use jsonb_array_elements_text to extract the array elements as a set, cast them to their PG element type
10911127
// and then build an array from that.
10921128
case NpgsqlArrayTypeMapping arrayMapping:
10931129
Sql.Append("(ARRAY(SELECT CAST(element AS ").Append(arrayMapping.ElementTypeMapping.StoreType)
10941130
.Append(") FROM jsonb_array_elements_text(");
1095-
GenerateJsonPath(returnsText: false);
1131+
GenerateLegacyJsonPath(returnsText: false);
10961132
Sql.Append(") WITH ORDINALITY AS t(element) ORDER BY ordinality))");
1097-
break;
1133+
return jsonScalarExpression;
10981134

10991135
default:
11001136
Sql.Append("CAST(");
1101-
GenerateJsonPath(returnsText: true);
1137+
GenerateLegacyJsonPath(returnsText: true);
11021138
Sql.Append(" AS ");
11031139
Sql.Append(jsonScalarExpression.TypeMapping!.StoreType);
11041140
Sql.Append(")");
1105-
break;
1141+
return jsonScalarExpression;
11061142
}
11071143

1108-
return jsonScalarExpression;
1144+
void GenerateJsonValueQuery(bool isJsonQuery, SqlExpression json, IReadOnlyList<PathSegment> path, string? returningType)
1145+
{
1146+
List<(string Name, Expression Expression)>? parameters = null;
1147+
var unnamedParameterIndex = 0;
1148+
1149+
Sql.Append(isJsonQuery ? "JSON_QUERY(" : "JSON_VALUE(");
1150+
Visit(json);
1151+
Sql.Append(", '$");
1152+
1153+
foreach (var pathSegment in path)
1154+
{
1155+
switch (pathSegment)
1156+
{
1157+
case { PropertyName: string propertyName }:
1158+
Sql.Append(".").Append(Dependencies.SqlGenerationHelper.DelimitJsonPathElement(propertyName));
1159+
break;
1160+
1161+
case { ArrayIndex: SqlExpression arrayIndex }:
1162+
Sql.Append("[");
1163+
1164+
if (arrayIndex is SqlConstantExpression)
1165+
{
1166+
Visit(arrayIndex);
1167+
}
1168+
else
1169+
{
1170+
parameters ??= new();
1171+
var parameterName = arrayIndex is SqlParameterExpression p ? p.InvariantName : ("p" + ++unnamedParameterIndex);
1172+
parameters.Add((parameterName, arrayIndex));
1173+
Sql.Append("$").Append(parameterName);
1174+
}
1175+
1176+
Sql.Append("]");
1177+
break;
11091178

1110-
void GenerateJsonPath(bool returnsText)
1111-
=> this.GenerateJsonPath(
1179+
default:
1180+
throw new ArgumentOutOfRangeException();
1181+
}
1182+
}
1183+
1184+
Sql.Append("'");
1185+
1186+
if (parameters is not null)
1187+
{
1188+
Sql.Append(" PASSING ");
1189+
1190+
var isFirst = true;
1191+
foreach (var (name, expression) in parameters)
1192+
{
1193+
if (isFirst)
1194+
{
1195+
isFirst = false;
1196+
}
1197+
else
1198+
{
1199+
Sql.Append(", ");
1200+
}
1201+
1202+
Visit(expression);
1203+
Sql.Append(" AS ").Append(name);
1204+
}
1205+
}
1206+
1207+
if (returningType is not null)
1208+
{
1209+
Sql.Append(" RETURNING ").Append(returningType);
1210+
}
1211+
1212+
Sql.Append(")");
1213+
}
1214+
1215+
void GenerateLegacyJsonPath(bool returnsText)
1216+
=> this.GenerateLegacyJsonPath(
11121217
jsonScalarExpression.Json,
11131218
returnsText: returnsText,
11141219
jsonScalarExpression.Path.Select(
@@ -1130,11 +1235,12 @@ void GenerateJsonPath(bool returnsText)
11301235
/// </returns>
11311236
protected virtual Expression VisitJsonPathTraversal(PgJsonTraversalExpression expression)
11321237
{
1133-
GenerateJsonPath(expression.Expression, expression.ReturnsText, expression.Path);
1238+
// TODO: Consider also implementing via JsonValueExpression and using JSON_VALUE?
1239+
GenerateLegacyJsonPath(expression.Expression, expression.ReturnsText, expression.Path);
11341240
return expression;
11351241
}
11361242

1137-
private void GenerateJsonPath(SqlExpression expression, bool returnsText, IReadOnlyList<SqlExpression> path)
1243+
private void GenerateLegacyJsonPath(SqlExpression expression, bool returnsText, IReadOnlyList<SqlExpression> path)
11381244
{
11391245
Visit(expression);
11401246

@@ -1451,6 +1557,12 @@ protected override bool RequiresParentheses(SqlExpression outerExpression, SqlEx
14511557
case PgUnknownBinaryExpression:
14521558
return true;
14531559

1560+
// In PG 17 or above, we translate JsonScalarExpression to JSON_VALUE() which does not require parentheses.
1561+
// Before that, we translate to x ->> y which does.
1562+
// Note that we also add parentheses when the outer is an index operation, since e.g. JSON_QUERY(...)[0] is invalid.
1563+
case JsonScalarExpression when outerExpression is not PgArrayIndexExpression and not PgArraySliceExpression:
1564+
return !_useNewJsonFunctions;
1565+
14541566
default:
14551567
return base.RequiresParentheses(outerExpression, innerExpression);
14561568
}

Diff for: src/EFCore.PG/Storage/Internal/NpgsqlSqlGenerationHelper.cs

+6
Original file line numberDiff line numberDiff line change
@@ -110,4 +110,10 @@ private static bool RequiresQuoting(string identifier)
110110

111111
return false;
112112
}
113+
114+
/// <inheritdoc />
115+
public override string DelimitJsonPathElement(string pathElement)
116+
=> !char.IsAsciiLetter(pathElement[0])
117+
? $"\"{EscapeJsonPathElement(pathElement)}\""
118+
: base.DelimitJsonPathElement(pathElement);
113119
}

0 commit comments

Comments
 (0)