Skip to content

Commit 8f817ca

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

File tree

5 files changed

+4063
-500
lines changed

5 files changed

+4063
-500
lines changed

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

Lines changed: 136 additions & 19 deletions
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,166 @@ 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+
// No need for RETURNING for text
1096+
case { StoreType: "text" }:
1097+
GenerateJsonValueQuery(isJsonQuery: false, jsonScalarExpression.Json, jsonScalarExpression.Path, returningType: null);
1098+
return jsonScalarExpression;
1099+
1100+
default:
1101+
GenerateJsonValueQuery(
1102+
isJsonQuery: false, jsonScalarExpression.Json, jsonScalarExpression.Path,
1103+
jsonScalarExpression.TypeMapping!.StoreType);
1104+
return jsonScalarExpression;
1105+
}
1106+
}
1107+
1108+
// We're targeting a PostgreSQL version under 17, so JSON_VALUE() doesn't exist yet. We need to use the legacy JSON path syntax.
10681109
switch (jsonScalarExpression.TypeMapping)
10691110
{
10701111
// This case is for when a nested JSON entity is being accessed. We want the json/jsonb fragment in this case (not text),
10711112
// so we can perform further JSON operations on it.
10721113
case NpgsqlOwnedJsonTypeMapping:
1073-
GenerateJsonPath(returnsText: false);
1074-
break;
1114+
GenerateLegacyJsonPath(returnsText: false);
1115+
return jsonScalarExpression;
10751116

10761117
// No need to cast the output when we expect a string anyway
10771118
case StringTypeMapping:
1078-
GenerateJsonPath(returnsText: true);
1079-
break;
1119+
GenerateLegacyJsonPath(returnsText: true);
1120+
return jsonScalarExpression;
10801121

10811122
// bytea requires special handling, since we encode the binary data as base64 inside the JSON, but that requires a special
10821123
// conversion function to be extracted out to a PG bytea.
10831124
case NpgsqlByteArrayTypeMapping:
10841125
Sql.Append("decode(");
1085-
GenerateJsonPath(returnsText: true);
1126+
GenerateLegacyJsonPath(returnsText: true);
10861127
Sql.Append(", 'base64')");
1087-
break;
1128+
return jsonScalarExpression;
10881129

10891130
// 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
10901131
// representation). We use jsonb_array_elements_text to extract the array elements as a set, cast them to their PG element type
10911132
// and then build an array from that.
10921133
case NpgsqlArrayTypeMapping arrayMapping:
10931134
Sql.Append("(ARRAY(SELECT CAST(element AS ").Append(arrayMapping.ElementTypeMapping.StoreType)
10941135
.Append(") FROM jsonb_array_elements_text(");
1095-
GenerateJsonPath(returnsText: false);
1136+
GenerateLegacyJsonPath(returnsText: false);
10961137
Sql.Append(") WITH ORDINALITY AS t(element) ORDER BY ordinality))");
1097-
break;
1138+
return jsonScalarExpression;
10981139

10991140
default:
11001141
Sql.Append("CAST(");
1101-
GenerateJsonPath(returnsText: true);
1142+
GenerateLegacyJsonPath(returnsText: true);
11021143
Sql.Append(" AS ");
11031144
Sql.Append(jsonScalarExpression.TypeMapping!.StoreType);
11041145
Sql.Append(")");
1105-
break;
1146+
return jsonScalarExpression;
11061147
}
11071148

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

1110-
void GenerateJsonPath(bool returnsText)
1111-
=> this.GenerateJsonPath(
1184+
default:
1185+
throw new ArgumentOutOfRangeException();
1186+
}
1187+
}
1188+
1189+
Sql.Append("'");
1190+
1191+
if (parameters is not null)
1192+
{
1193+
Sql.Append(" PASSING ");
1194+
1195+
var isFirst = true;
1196+
foreach (var (name, expression) in parameters)
1197+
{
1198+
if (isFirst)
1199+
{
1200+
isFirst = false;
1201+
}
1202+
else
1203+
{
1204+
Sql.Append(", ");
1205+
}
1206+
1207+
Visit(expression);
1208+
Sql.Append(" AS ").Append(name);
1209+
}
1210+
}
1211+
1212+
if (returningType is not null)
1213+
{
1214+
Sql.Append(" RETURNING ").Append(returningType);
1215+
}
1216+
1217+
Sql.Append(")");
1218+
}
1219+
1220+
void GenerateLegacyJsonPath(bool returnsText)
1221+
=> this.GenerateLegacyJsonPath(
11121222
jsonScalarExpression.Json,
11131223
returnsText: returnsText,
11141224
jsonScalarExpression.Path.Select(
@@ -1130,11 +1240,12 @@ void GenerateJsonPath(bool returnsText)
11301240
/// </returns>
11311241
protected virtual Expression VisitJsonPathTraversal(PgJsonTraversalExpression expression)
11321242
{
1133-
GenerateJsonPath(expression.Expression, expression.ReturnsText, expression.Path);
1243+
// TODO: Consider also implementing via JsonValueExpression and using JSON_VALUE?
1244+
GenerateLegacyJsonPath(expression.Expression, expression.ReturnsText, expression.Path);
11341245
return expression;
11351246
}
11361247

1137-
private void GenerateJsonPath(SqlExpression expression, bool returnsText, IReadOnlyList<SqlExpression> path)
1248+
private void GenerateLegacyJsonPath(SqlExpression expression, bool returnsText, IReadOnlyList<SqlExpression> path)
11381249
{
11391250
Visit(expression);
11401251

@@ -1451,6 +1562,12 @@ protected override bool RequiresParentheses(SqlExpression outerExpression, SqlEx
14511562
case PgUnknownBinaryExpression:
14521563
return true;
14531564

1565+
// In PG 17 or above, we translate JsonScalarExpression to JSON_VALUE() which does not require parentheses.
1566+
// Before that, we translate to x ->> y which does.
1567+
// Note that we also add parentheses when the outer is an index operation, since e.g. JSON_QUERY(...)[0] is invalid.
1568+
case JsonScalarExpression when outerExpression is not PgArrayIndexExpression and not PgArraySliceExpression:
1569+
return !_useNewJsonFunctions;
1570+
14541571
default:
14551572
return base.RequiresParentheses(outerExpression, innerExpression);
14561573
}

src/EFCore.PG/Storage/Internal/NpgsqlSqlGenerationHelper.cs

Lines changed: 6 additions & 0 deletions
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)