HHH-18604 Add json_array_insert

This commit is contained in:
Christian Beikov 2024-09-18 12:40:10 +02:00
parent 8dfc2a5a91
commit 0e5846b805
20 changed files with 439 additions and 1 deletions

View File

@ -1643,6 +1643,7 @@ it is necessary to enable the `hibernate.query.hql.json_functions_enabled` confi
| `json_remove()` | Removes a value by JSON path within a JSON document
| `json_mergepatch()` | Merges JSON documents by performing an https://tools.ietf.org/html/rfc7396[RFC 7396] compliant merge
| `json_array_append()` | Appends to a JSON array of a JSON document by JSON path
| `json_array_insert()` | Inserts a value by JSON path to a JSON array within a JSON document
|===
@ -2134,6 +2135,29 @@ include::{json-example-dir-hql}/JsonArrayAppendTest.java[tags=hql-json-array-app
WARNING: SAP HANA, DB2, H2 and HSQLDB do not support this function.
[[hql-json-array-insert-function]]
===== `json_array_insert()`
Inserts a value by JSON path to a JSON array within a JSON document.
The function takes 3 arguments, the json document, the json path and the value to append.
Although the exact behavior is database dependent, usually an error will be triggered if
the JSON path does not end with an array index access i.e. `$.a[0]`.
The zero based array index represents the position at which an element should be inserted in an array.
If the JSON path without the index does not resolve to a JSON array within the JSON document,
the document is not changed.
[[hql-json-array-insert-example]]
====
[source, java, indent=0]
----
include::{json-example-dir-hql}/JsonArrayInsertTest.java[tags=hql-json-array-insert-example]
----
====
WARNING: SAP HANA, DB2, H2 and HSQLDB do not support this function.
[[hql-user-defined-functions]]
==== Native and user-defined functions

View File

@ -514,6 +514,7 @@ public class CockroachLegacyDialect extends Dialect {
functionFactory.jsonInsert_postgresql();
functionFactory.jsonMergepatch_postgresql();
functionFactory.jsonArrayAppend_postgresql();
functionFactory.jsonArrayInsert_postgresql();
// Postgres uses # instead of ^ for XOR
functionContributions.getFunctionRegistry().patternDescriptorBuilder( "bitxor", "(?1#?2)" )

View File

@ -666,6 +666,7 @@ public class MySQLLegacyDialect extends Dialect {
functionFactory.jsonInsert_mysql();
functionFactory.jsonMergepatch_mysql();
functionFactory.jsonArrayAppend_mysql();
functionFactory.jsonArrayInsert_mysql();
}
}

View File

@ -323,6 +323,7 @@ public class OracleLegacyDialect extends Dialect {
functionFactory.jsonInsert_oracle();
functionFactory.jsonMergepatch_oracle();
functionFactory.jsonArrayAppend_oracle();
functionFactory.jsonArrayInsert_oracle();
}
}

View File

@ -664,6 +664,7 @@ public class PostgreSQLLegacyDialect extends Dialect {
functionFactory.jsonInsert_postgresql();
functionFactory.jsonMergepatch_postgresql();
functionFactory.jsonArrayAppend_postgresql();
functionFactory.jsonArrayInsert_postgresql();
if ( getVersion().isSameOrAfter( 9, 4 ) ) {
functionFactory.makeDateTimeTimestamp();

View File

@ -411,6 +411,7 @@ public class SQLServerLegacyDialect extends AbstractTransactSQLDialect {
functionFactory.jsonReplace_sqlserver();
functionFactory.jsonInsert_sqlserver();
functionFactory.jsonArrayAppend_sqlserver();
functionFactory.jsonArrayInsert_sqlserver();
}
if ( getVersion().isSameOrAfter( 14 ) ) {
functionFactory.listagg_stringAggWithinGroup( "varchar(max)" );

View File

@ -481,6 +481,7 @@ public class CockroachDialect extends Dialect {
functionFactory.jsonInsert_postgresql();
functionFactory.jsonMergepatch_postgresql();
functionFactory.jsonArrayAppend_postgresql();
functionFactory.jsonArrayInsert_postgresql();
// Postgres uses # instead of ^ for XOR
functionContributions.getFunctionRegistry().patternDescriptorBuilder( "bitxor", "(?1#?2)" )

View File

@ -651,6 +651,7 @@ public class MySQLDialect extends Dialect {
functionFactory.jsonInsert_mysql();
functionFactory.jsonMergepatch_mysql();
functionFactory.jsonArrayAppend_mysql();
functionFactory.jsonArrayInsert_mysql();
}
@Override

View File

@ -414,6 +414,7 @@ public class OracleDialect extends Dialect {
functionFactory.jsonInsert_oracle();
functionFactory.jsonMergepatch_oracle();
functionFactory.jsonArrayAppend_oracle();
functionFactory.jsonArrayInsert_oracle();
}
@Override

View File

@ -625,6 +625,7 @@ public class PostgreSQLDialect extends Dialect {
functionFactory.jsonInsert_postgresql();
functionFactory.jsonMergepatch_postgresql();
functionFactory.jsonArrayAppend_postgresql();
functionFactory.jsonArrayInsert_postgresql();
functionFactory.makeDateTimeTimestamp();
// Note that PostgreSQL doesn't support the OVER clause for ordered set-aggregate functions

View File

@ -429,6 +429,7 @@ public class SQLServerDialect extends AbstractTransactSQLDialect {
functionFactory.jsonReplace_sqlserver();
functionFactory.jsonInsert_sqlserver();
functionFactory.jsonArrayAppend_sqlserver();
functionFactory.jsonArrayInsert_sqlserver();
}
if ( getVersion().isSameOrAfter( 14 ) ) {
functionFactory.listagg_stringAggWithinGroup( "varchar(max)" );

View File

@ -118,6 +118,7 @@ import org.hibernate.dialect.function.json.MySQLJsonValueFunction;
import org.hibernate.dialect.function.json.OracleJsonArrayAggFunction;
import org.hibernate.dialect.function.json.OracleJsonArrayAppendFunction;
import org.hibernate.dialect.function.json.OracleJsonArrayFunction;
import org.hibernate.dialect.function.json.OracleJsonArrayInsertFunction;
import org.hibernate.dialect.function.json.OracleJsonInsertFunction;
import org.hibernate.dialect.function.json.OracleJsonMergepatchFunction;
import org.hibernate.dialect.function.json.OracleJsonObjectAggFunction;
@ -128,6 +129,7 @@ import org.hibernate.dialect.function.json.OracleJsonSetFunction;
import org.hibernate.dialect.function.json.PostgreSQLJsonArrayAggFunction;
import org.hibernate.dialect.function.json.PostgreSQLJsonArrayAppendFunction;
import org.hibernate.dialect.function.json.PostgreSQLJsonArrayFunction;
import org.hibernate.dialect.function.json.PostgreSQLJsonArrayInsertFunction;
import org.hibernate.dialect.function.json.PostgreSQLJsonExistsFunction;
import org.hibernate.dialect.function.json.PostgreSQLJsonInsertFunction;
import org.hibernate.dialect.function.json.PostgreSQLJsonMergepatchFunction;
@ -141,6 +143,7 @@ import org.hibernate.dialect.function.json.PostgreSQLJsonValueFunction;
import org.hibernate.dialect.function.json.SQLServerJsonArrayAggFunction;
import org.hibernate.dialect.function.json.SQLServerJsonArrayAppendFunction;
import org.hibernate.dialect.function.json.SQLServerJsonArrayFunction;
import org.hibernate.dialect.function.json.SQLServerJsonArrayInsertFunction;
import org.hibernate.dialect.function.json.SQLServerJsonExistsFunction;
import org.hibernate.dialect.function.json.SQLServerJsonInsertFunction;
import org.hibernate.dialect.function.json.SQLServerJsonObjectAggFunction;
@ -4056,4 +4059,42 @@ public class CommonFunctionFactory {
public void jsonArrayAppend_sqlserver() {
functionRegistry.register( "json_array_append", new SQLServerJsonArrayAppendFunction( typeConfiguration ) );
}
/**
* PostgreSQL json_array_insert() function
*/
public void jsonArrayInsert_postgresql() {
functionRegistry.register( "json_array_insert", new PostgreSQLJsonArrayInsertFunction( typeConfiguration ) );
}
/**
* MySQL json_array_insert() function
*/
public void jsonArrayInsert_mysql() {
functionRegistry.namedDescriptorBuilder( "json_array_insert" )
.setArgumentsValidator( new ArgumentTypesValidator(
StandardArgumentsValidators.exactly( 3 ),
FunctionParameterType.IMPLICIT_JSON,
FunctionParameterType.STRING,
FunctionParameterType.ANY
) )
.setReturnTypeResolver( StandardFunctionReturnTypeResolvers.invariant(
typeConfiguration.getBasicTypeRegistry().resolve( String.class, SqlTypes.JSON )
) )
.register();
}
/**
* Oracle json_array_insert() function
*/
public void jsonArrayInsert_oracle() {
functionRegistry.register( "json_array_insert", new OracleJsonArrayInsertFunction( typeConfiguration ) );
}
/**
* SQL server json_array_insert() function
*/
public void jsonArrayInsert_sqlserver() {
functionRegistry.register( "json_array_insert", new SQLServerJsonArrayInsertFunction( typeConfiguration ) );
}
}

View File

@ -0,0 +1,38 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* Copyright Red Hat Inc. and Hibernate Authors
*/
package org.hibernate.dialect.function.json;
import org.hibernate.query.sqm.function.AbstractSqmSelfRenderingFunctionDescriptor;
import org.hibernate.query.sqm.function.FunctionKind;
import org.hibernate.query.sqm.produce.function.ArgumentTypesValidator;
import org.hibernate.query.sqm.produce.function.FunctionParameterType;
import org.hibernate.query.sqm.produce.function.StandardArgumentsValidators;
import org.hibernate.query.sqm.produce.function.StandardFunctionReturnTypeResolvers;
import org.hibernate.type.SqlTypes;
import org.hibernate.type.spi.TypeConfiguration;
/**
* Standard json_array_insert function.
*/
public abstract class AbstractJsonArrayInsertFunction extends AbstractSqmSelfRenderingFunctionDescriptor {
public AbstractJsonArrayInsertFunction(TypeConfiguration typeConfiguration) {
super(
"json_array_insert",
FunctionKind.NORMAL,
new ArgumentTypesValidator(
StandardArgumentsValidators.exactly( 3 ),
FunctionParameterType.IMPLICIT_JSON,
FunctionParameterType.STRING,
FunctionParameterType.ANY
),
StandardFunctionReturnTypeResolvers.invariant(
typeConfiguration.getBasicTypeRegistry().resolve( String.class, SqlTypes.JSON )
),
null
);
}
}

View File

@ -0,0 +1,43 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* Copyright Red Hat Inc. and Hibernate Authors
*/
package org.hibernate.dialect.function.json;
import java.util.List;
import org.hibernate.query.ReturnableType;
import org.hibernate.sql.ast.SqlAstTranslator;
import org.hibernate.sql.ast.spi.SqlAppender;
import org.hibernate.sql.ast.tree.SqlAstNode;
import org.hibernate.sql.ast.tree.expression.Expression;
import org.hibernate.type.spi.TypeConfiguration;
/**
* Oracle json_array_insert function.
*/
public class OracleJsonArrayInsertFunction extends AbstractJsonArrayInsertFunction {
public OracleJsonArrayInsertFunction(TypeConfiguration typeConfiguration) {
super( typeConfiguration );
}
@Override
public void render(
SqlAppender sqlAppender,
List<? extends SqlAstNode> arguments,
ReturnableType<?> returnType,
SqlAstTranslator<?> translator) {
final Expression json = (Expression) arguments.get( 0 );
final String jsonPath = translator.getLiteralValue( (Expression) arguments.get( 1 ) );
final SqlAstNode value = arguments.get( 2 );
sqlAppender.appendSql( "json_transform(" );
json.accept( translator );
sqlAppender.appendSql( ",insert " );
sqlAppender.appendSingleQuoteEscapedString( jsonPath );
sqlAppender.appendSql( '=' );
value.accept( translator );
sqlAppender.appendSql( " ignore on existing)" );
}
}

View File

@ -0,0 +1,87 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* Copyright Red Hat Inc. and Hibernate Authors
*/
package org.hibernate.dialect.function.json;
import java.util.List;
import org.hibernate.QueryException;
import org.hibernate.metamodel.mapping.JdbcMappingContainer;
import org.hibernate.query.ReturnableType;
import org.hibernate.sql.ast.SqlAstTranslator;
import org.hibernate.sql.ast.spi.SqlAppender;
import org.hibernate.sql.ast.tree.SqlAstNode;
import org.hibernate.sql.ast.tree.expression.Expression;
import org.hibernate.sql.ast.tree.expression.Literal;
import org.hibernate.type.spi.TypeConfiguration;
/**
* PostgreSQL json_array_insert function.
*/
public class PostgreSQLJsonArrayInsertFunction extends AbstractJsonArrayInsertFunction {
public PostgreSQLJsonArrayInsertFunction(TypeConfiguration typeConfiguration) {
super( typeConfiguration );
}
@Override
public void render(
SqlAppender sqlAppender,
List<? extends SqlAstNode> arguments,
ReturnableType<?> returnType,
SqlAstTranslator<?> translator) {
final Expression json = (Expression) arguments.get( 0 );
final Expression jsonPath = (Expression) arguments.get( 1 );
final SqlAstNode value = arguments.get( 2 );
sqlAppender.appendSql( "jsonb_insert(" );
final boolean needsCast = !isJsonType( json );
if ( needsCast ) {
sqlAppender.appendSql( "cast(" );
}
json.accept( translator );
if ( needsCast ) {
sqlAppender.appendSql( " as jsonb)" );
}
sqlAppender.appendSql( ',' );
List<JsonPathHelper.JsonPathElement> jsonPathElements =
JsonPathHelper.parseJsonPathElements( translator.getLiteralValue( jsonPath ) );
sqlAppender.appendSql( "array" );
char separator = '[';
for ( JsonPathHelper.JsonPathElement pathElement : jsonPathElements ) {
sqlAppender.appendSql( separator );
if ( pathElement instanceof JsonPathHelper.JsonAttribute attribute ) {
sqlAppender.appendSingleQuoteEscapedString( attribute.attribute() );
}
else if ( pathElement instanceof JsonPathHelper.JsonParameterIndexAccess ) {
final String parameterName = ( (JsonPathHelper.JsonParameterIndexAccess) pathElement ).parameterName();
throw new QueryException( "JSON path [" + jsonPath + "] uses parameter [" + parameterName + "] that is not passed" );
}
else {
sqlAppender.appendSql( '\'' );
sqlAppender.appendSql( ( (JsonPathHelper.JsonIndexAccess) pathElement ).index() );
sqlAppender.appendSql( '\'' );
}
separator = ',';
}
sqlAppender.appendSql( "]::text[]," );
if ( value instanceof Literal && ( (Literal) value ).getLiteralValue() == null ) {
sqlAppender.appendSql( "null::jsonb" );
}
else {
sqlAppender.appendSql( "to_jsonb(" );
value.accept( translator );
if ( value instanceof Literal literal && literal.getJdbcMapping().getJdbcType().isString() ) {
// PostgreSQL until version 16 is not smart enough to infer the type of a string literal
sqlAppender.appendSql( "::text" );
}
sqlAppender.appendSql( ')' );
}
sqlAppender.appendSql( ')' );
}
private static boolean isJsonType(Expression expression) {
final JdbcMappingContainer expressionType = expression.getExpressionType();
return expressionType != null && expressionType.getSingleJdbcMapping().getJdbcType().isJson();
}
}

View File

@ -34,7 +34,7 @@ public class PostgreSQLJsonInsertFunction extends AbstractJsonInsertFunction {
final Expression json = (Expression) arguments.get( 0 );
final Expression jsonPath = (Expression) arguments.get( 1 );
final SqlAstNode value = arguments.get( 2 );
sqlAppender.appendSql( "(select case when t.d#>>t.p is not null then t.d else jsonb_insert(t.d,t.p," );
sqlAppender.appendSql( "(select case when (t.d)#>>t.p is not null then t.d else jsonb_insert(t.d,t.p," );
if ( value instanceof Literal && ( (Literal) value ).getLiteralValue() == null ) {
sqlAppender.appendSql( "null::jsonb" );
}

View File

@ -0,0 +1,88 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* Copyright Red Hat Inc. and Hibernate Authors
*/
package org.hibernate.dialect.function.json;
import java.util.List;
import org.hibernate.QueryException;
import org.hibernate.query.ReturnableType;
import org.hibernate.sql.ast.SqlAstTranslator;
import org.hibernate.sql.ast.spi.SqlAppender;
import org.hibernate.sql.ast.tree.SqlAstNode;
import org.hibernate.sql.ast.tree.expression.Expression;
import org.hibernate.type.spi.TypeConfiguration;
/**
* SQL Server json_array_insert function.
*/
public class SQLServerJsonArrayInsertFunction extends AbstractJsonArrayInsertFunction {
public SQLServerJsonArrayInsertFunction(TypeConfiguration typeConfiguration) {
super( typeConfiguration );
}
@Override
public void render(
SqlAppender sqlAppender,
List<? extends SqlAstNode> arguments,
ReturnableType<?> returnType,
SqlAstTranslator<?> translator) {
final String jsonPath = translator.<String>getLiteralValue( (Expression) arguments.get( 1 ) ).trim();
final int bracketEndIndex = jsonPath.lastIndexOf( ']' );
final int bracketStartIndex = jsonPath.lastIndexOf( '[' );
if ( jsonPath.isEmpty()
|| bracketEndIndex != jsonPath.length() - 1
|| bracketStartIndex == -1 ) {
throw new QueryException( "JSON path does not end with an array index: " + jsonPath );
}
final int index;
try {
index = Integer.parseInt( jsonPath.substring( bracketStartIndex + 1, bracketEndIndex ) );
}
catch ( NumberFormatException e ) {
throw new QueryException( "JSON path does not point to a valid array index: " + jsonPath );
}
final Expression json = (Expression) arguments.get( 0 );
final SqlAstNode value = arguments.get( 2 );
// Only replace data if this is an array
sqlAppender.appendSql( "(select case when left(json_query(x.d,x.p),1)='[' then " );
// Replace the array
sqlAppender.appendSql( "json_modify(x.d,x.p,json_query((" );
// Aggregate a new JSON array based on element rows
sqlAppender.appendSql( "select '['+string_agg(t.v,',') within group (order by t.k)+']' from (" );
sqlAppender.appendSql( "select x.i k,x.v v union all " );
sqlAppender.appendSql( "select case when cast(t.[key] as int)>=x.i then cast(t.[key] as int)+1 " );
sqlAppender.appendSql( "else cast(t.[key] as int) end," );
// type 0 is a null literal
sqlAppender.appendSql( "case t.type when 0 then 'null' when 1 then ");
// type 1 is a string literal. to quote it, we use for json path and trim the string down to just the value
sqlAppender.appendSql(
"(select substring(a.v,6,len(a.v)-6) from (select t.value a for json path,without_array_wrapper) a(v))" );
sqlAppender.appendSql( " else t.value end from openjson(x.d,x.p) t) t))) " );
sqlAppender.appendSql( " else x.d end " );
// Push args into a values clause since we are going to refer to them multiple times
sqlAppender.appendSql( "from (values(" );
json.accept( translator );
sqlAppender.append( ',' );
sqlAppender.appendSingleQuoteEscapedString( jsonPath.substring( 0, bracketStartIndex ) );
sqlAppender.append( ',' );
sqlAppender.appendSql( index );
sqlAppender.append( ',' );
value.accept( translator );
sqlAppender.append( ")) x(d,p,i,v))" );
}
protected void renderArgument(
SqlAppender sqlAppender,
SqlAstNode arg,
SqlAstTranslator<?> translator) {
sqlAppender.appendSql( "substring(json_array(" );
arg.accept( translator );
sqlAppender.appendSql( " null on null),2,len(json_array(" );
arg.accept( translator );
sqlAppender.appendSql( " null on null))-2)" );
}
}

View File

@ -0,0 +1,37 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* Copyright Red Hat Inc. and Hibernate Authors
*/
package org.hibernate.orm.test.function.json;
import org.hibernate.cfg.QuerySettings;
import org.hibernate.testing.orm.domain.StandardDomainModel;
import org.hibernate.testing.orm.junit.DialectFeatureChecks;
import org.hibernate.testing.orm.junit.DomainModel;
import org.hibernate.testing.orm.junit.RequiresDialectFeature;
import org.hibernate.testing.orm.junit.ServiceRegistry;
import org.hibernate.testing.orm.junit.SessionFactory;
import org.hibernate.testing.orm.junit.SessionFactoryScope;
import org.hibernate.testing.orm.junit.Setting;
import org.junit.jupiter.api.Test;
/**
* @author Christian Beikov
*/
@DomainModel(standardModels = StandardDomainModel.GAMBIT)
@SessionFactory
@ServiceRegistry(settings = @Setting(name = QuerySettings.JSON_FUNCTIONS_ENABLED, value = "true"))
@RequiresDialectFeature( feature = DialectFeatureChecks.SupportsJsonInsert.class)
public class JsonArrayInsertTest {
@Test
public void testSimple(SessionFactoryScope scope) {
scope.inSession( em -> {
//tag::hql-json-array-insert-example[]
em.createQuery( "select json_array_insert('{\"a\":[1]}', '$.a[0]', 2)" ).getResultList();
//end::hql-json-array-insert-example[]
} );
}
}

View File

@ -704,6 +704,70 @@ public class JsonFunctionTests {
);
}
@Test
@RequiresDialectFeature(feature = DialectFeatureChecks.SupportsJsonArrayInsert.class)
public void testJsonArrayInsert(SessionFactoryScope scope) {
scope.inTransaction(
session -> {
String json = session.createQuery(
"select json_array_insert('{\"b\":[2]}', '$.b[0]', 1)",
String.class
).getSingleResult();
Map<String, Object> object = parseObject( json );
assertEquals( 1, object.size() );
assertEquals( Arrays.asList( 1, 2 ), object.get( "b" ) );
}
);
}
@Test
@RequiresDialectFeature(feature = DialectFeatureChecks.SupportsJsonArrayInsert.class)
public void testJsonArrayInsertNonExisting(SessionFactoryScope scope) {
scope.inTransaction(
session -> {
String json = session.createQuery(
"select json_array_insert('{\"b\":[2]}', '$.c[0]', 1)",
String.class
).getSingleResult();
Map<String, Object> object = parseObject( json );
assertEquals( 1, object.size() );
assertEquals( List.of( 2 ), object.get( "b" ) );
}
);
}
@Test
@RequiresDialectFeature(feature = DialectFeatureChecks.SupportsJsonArrayInsert.class)
public void testJsonArrayInsertNonArray(SessionFactoryScope scope) {
scope.inTransaction(
session -> {
String json = session.createQuery(
"select json_array_insert('{\"b\":2}', '$.b[0]', 1)",
String.class
).getSingleResult();
Map<String, Object> object = parseObject( json );
assertEquals( 1, object.size() );
assertEquals( 2, object.get( "b" ) );
}
);
}
@Test
@RequiresDialectFeature(feature = DialectFeatureChecks.SupportsJsonArrayInsert.class)
public void testJsonArrayInsertToNull(SessionFactoryScope scope) {
scope.inTransaction(
session -> {
String json = session.createQuery(
"select json_array_insert('{\"b\":null}', '$.b[0]', 1)",
String.class
).getSingleResult();
Map<String, Object> object = parseObject( json );
assertEquals( 1, object.size() );
assertNull( object.get( "b" ) );
}
);
}
private static final ObjectMapper MAPPER = new ObjectMapper();
private static Map<String, Object> parseObject(String json) {

View File

@ -829,6 +829,12 @@ abstract public class DialectFeatureChecks {
}
}
public static class SupportsJsonArrayInsert implements DialectFeatureCheck {
public boolean apply(Dialect dialect) {
return definesFunction( dialect, "json_array_insert" );
}
}
public static class IsJtds implements DialectFeatureCheck {
public boolean apply(Dialect dialect) {
return dialect instanceof SybaseDialect && ( (SybaseDialect) dialect ).getDriverKind() == SybaseDriverKind.JTDS;