@@ -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 }
0 commit comments