HHH-17375 Support casting array to string and add optional third argument to array_to_string for null element handling

This commit is contained in:
Christian Beikov 2024-05-07 12:23:43 +02:00
parent 8ec90b8fb1
commit 64dd9e657c
14 changed files with 217 additions and 29 deletions

View File

@ -1202,7 +1202,7 @@ The following functions deal with SQL array types, which are not supported on ev
| `array_trim()` | Creates array copy trimming the last _N_ elements
| `array_fill()` | Creates array filled with the same element _N_ times
| `array_fill_list()` | Like `array_fill`, but returns the result as `List<?>`
| `array_to_string()` | String representation of non-null array elements
| `array_to_string()` | String representation of array
|===
[[hql-array-constructor-functions]]
@ -1546,9 +1546,11 @@ include::{array-example-dir-hql}/ArrayFillTest.java[tags=hql-array-fill-example]
====
[[hql-array-to-string-functions]]
===== `array_to_string()`
===== `array_to_string()` or `cast(array as String)`
Concatenates the non-null array elements with a separator, as specified by the arguments.
Concatenates the array elements with a separator, as specified by the arguments.
Null values are filtered, but the optional third argument can be specified to define a default value to use
when a `null` array element is encountered.
Returns `null` if the first argument is `null`.
[[hql-array-to-string-example]]
@ -1559,6 +1561,17 @@ include::{array-example-dir-hql}/ArrayToStringTest.java[tags=hql-array-to-string
----
====
Alternatively, it is also possible to use `cast(array as String)`,
which is a short version of `concat('[', array_to_string(array, ',', 'null'), ']')`.
[[hql-array-to-string-hql-example]]
====
[source, JAVA, indent=0]
----
include::{array-example-dir-hql}/ArrayToStringTest.java[tags=hql-array-to-string-hql-example]
----
====
[[hql-user-defined-functions]]
==== Native and user-defined functions

View File

@ -104,6 +104,11 @@ public class PostgresPlusLegacyDialect extends PostgreSQLLegacyDialect {
return super.timestampdiffPattern( unit, fromTemporalType, toTemporalType );
}
@Override
public boolean isEmptyStringTreatedAsNull() {
return true;
}
@Override
public int registerResultSetOutParameter(CallableStatement statement, int col) throws SQLException {
statement.registerOutParameter( col, Types.REF );

View File

@ -248,13 +248,16 @@ public class OracleUserDefinedTypeExporter extends StandardUserDefinedTypeExport
"return res; " +
"end;",
"create or replace function " + arrayTypeName + "_to_string(arr in " + arrayTypeName +
", sep in varchar2) return varchar2 deterministic is " +
", sep in varchar2, nullVal in varchar2) return varchar2 deterministic is " +
"res varchar2(4000):=''; begin " +
"if arr is null or sep is null then return null; end if; " +
"for i in 1 .. arr.count loop " +
"if arr(i) is not null then " +
"if length(res)<>0 then res:=res||sep; end if; " +
"res:=res||arr(i); " +
"elsif nullVal is not null then " +
"if length(res)<>0 then res:=res||sep; end if; " +
"res:=res||nullVal; " +
"end if; " +
"end loop; " +
"return res; " +

View File

@ -104,6 +104,11 @@ public class PostgresPlusDialect extends PostgreSQLDialect {
return super.timestampdiffPattern( unit, fromTemporalType, toTemporalType );
}
@Override
public boolean isEmptyStringTreatedAsNull() {
return true;
}
@Override
public int registerResultSetOutParameter(CallableStatement statement, int col) throws SQLException {
statement.registerOutParameter( col, Types.REF );

View File

@ -10,10 +10,14 @@ import java.sql.Types;
import java.util.List;
import org.hibernate.dialect.Dialect;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.metamodel.mapping.JdbcMapping;
import org.hibernate.query.ReturnableType;
import org.hibernate.query.sqm.CastType;
import org.hibernate.query.sqm.function.AbstractSqmSelfRenderingFunctionDescriptor;
import org.hibernate.query.sqm.function.SelfRenderingFunctionSqlAstExpression;
import org.hibernate.query.sqm.function.SqmFunctionDescriptor;
import org.hibernate.query.sqm.function.SqmFunctionRegistry;
import org.hibernate.query.sqm.produce.function.StandardArgumentsValidators;
import org.hibernate.query.sqm.produce.function.StandardFunctionArgumentTypeResolvers;
import org.hibernate.query.sqm.produce.function.StandardFunctionReturnTypeResolvers;
@ -23,6 +27,8 @@ import org.hibernate.sql.ast.spi.SqlAppender;
import org.hibernate.sql.ast.tree.SqlAstNode;
import org.hibernate.sql.ast.tree.expression.CastTarget;
import org.hibernate.sql.ast.tree.expression.Expression;
import org.hibernate.sql.ast.tree.expression.QueryLiteral;
import org.hibernate.type.BasicType;
/**
* ANSI SQL-inspired {@code cast()} function, where the target types
@ -71,9 +77,59 @@ public class CastFunction extends AbstractSqmSelfRenderingFunctionDescriptor {
final JdbcMapping targetJdbcMapping = castTarget.getExpressionType().getSingleJdbcMapping();
final CastType targetType = getCastType( targetJdbcMapping );
String cast = dialect.castPattern( sourceType, targetType );
if ( sourceType == CastType.OTHER && targetType == CastType.STRING
&& sourceMapping.getJdbcType().isArray() ) {
renderCastArrayToString( sqlAppender, arguments.get( 0 ), dialect, walker );
}
else {
String cast = dialect.castPattern( sourceType, targetType );
new PatternRenderer( cast ).render( sqlAppender, arguments, walker );
new PatternRenderer( cast ).render( sqlAppender, arguments, walker );
}
}
public static void renderCastArrayToString(
SqlAppender sqlAppender,
SqlAstNode arrayArgument,
Dialect dialect,
SqlAstTranslator<?> walker) {
final SessionFactoryImplementor sessionFactory = walker.getSessionFactory();
final BasicType<?> stringType = sessionFactory.getTypeConfiguration()
.getBasicTypeForJavaType( String.class );
final SqmFunctionRegistry functionRegistry = sessionFactory.getQueryEngine()
.getSqmFunctionRegistry();
final SqmFunctionDescriptor concatDescriptor = functionRegistry.findFunctionDescriptor( "concat" );
final SqmFunctionDescriptor arrayToStringDescriptor = functionRegistry.findFunctionDescriptor( "array_to_string" );
final boolean caseWhen = dialect.isEmptyStringTreatedAsNull();
if ( caseWhen ) {
sqlAppender.append( "case when " );
arrayArgument.accept( walker );
sqlAppender.append( " is null then null else " );
}
( (AbstractSqmSelfRenderingFunctionDescriptor) concatDescriptor ).render(
sqlAppender,
List.of(
new QueryLiteral<>( "[", stringType ),
new SelfRenderingFunctionSqlAstExpression(
"array_to_string",
( (AbstractSqmSelfRenderingFunctionDescriptor) arrayToStringDescriptor ),
List.of(
arrayArgument,
new QueryLiteral<>( ",", stringType ),
new QueryLiteral<>( "null", stringType )
),
stringType,
stringType
),
new QueryLiteral<>( "]", stringType )
),
stringType,
walker
);
if ( caseWhen ) {
sqlAppender.append( " end" );
}
}
private CastType getCastType(JdbcMapping sourceMapping) {

View File

@ -28,6 +28,7 @@ import org.hibernate.type.SqlTypes;
import org.hibernate.type.StandardBasicTypes;
import org.hibernate.type.spi.TypeConfiguration;
import static org.hibernate.dialect.function.CastFunction.renderCastArrayToString;
import static org.hibernate.query.sqm.produce.function.FunctionParameterType.STRING;
public class CastingConcatFunction extends AbstractSqmSelfRenderingFunctionDescriptor {
@ -97,12 +98,16 @@ public class CastingConcatFunction extends AbstractSqmSelfRenderingFunctionDescr
private void renderAsString(SqlAppender sqlAppender, SqlAstTranslator<?> translator, Expression expression) {
final JdbcMapping sourceMapping = expression.getExpressionType().getSingleJdbcMapping();
final CastType sourceType = sourceMapping.getCastType();
// No need to cast if we already have a string
if ( sourceMapping.getCastType() == CastType.STRING ) {
if ( sourceType == CastType.STRING ) {
translator.render( expression, argumentRenderingMode );
}
else if ( sourceType == CastType.OTHER && sourceMapping.getJdbcType().isArray() ) {
renderCastArrayToString( sqlAppender, expression, dialect, translator );
}
else {
final String cast = dialect.castPattern( sourceMapping.getCastType(), CastType.STRING );
final String cast = dialect.castPattern( sourceType, CastType.STRING );
new PatternRenderer( cast.replace( "?2", concatArgumentCastType ), argumentRenderingMode )
.render( sqlAppender, Collections.singletonList( expression ), translator );
}

View File

@ -39,6 +39,8 @@ import org.hibernate.sql.ast.tree.select.QuerySpec;
import org.hibernate.type.StandardBasicTypes;
import org.hibernate.type.spi.TypeConfiguration;
import static org.hibernate.dialect.function.CastFunction.renderCastArrayToString;
/**
* @author Christian Beikov
*/
@ -401,13 +403,18 @@ public class CountFunction extends AbstractSqmSelfRenderingFunctionDescriptor {
}
else {
final JdbcMapping sourceMapping = realArg.getExpressionType().getSingleJdbcMapping();
final CastType sourceType = sourceMapping.getCastType();
// No need to cast if we already have a string
if ( sourceMapping.getCastType() == CastType.STRING ) {
if ( sourceType == CastType.STRING ) {
translator.render( realArg, defaultArgumentRenderingMode );
return false;
}
else if ( sourceType == CastType.OTHER && sourceMapping.getJdbcType().isArray() ) {
renderCastArrayToString( sqlAppender, realArg, dialect, translator );
return false;
}
else {
final String cast = dialect.castPattern( sourceMapping.getCastType(), CastType.STRING );
final String cast = dialect.castPattern( sourceType, CastType.STRING );
new PatternRenderer( cast.replace( "?2", concatArgumentCastType ) )
.render( sqlAppender, Collections.singletonList( realArg ), translator );
return false;

View File

@ -36,18 +36,20 @@ public class ArrayAndElementArgumentValidator extends ArrayArgumentValidator {
TypeConfiguration typeConfiguration) {
final BasicType<?> expectedElementType = getElementType( arguments, functionName, typeConfiguration );
for ( int elementIndex : elementIndexes ) {
final SqmTypedNode<?> elementArgument = arguments.get( elementIndex );
final SqmExpressible<?> elementType = elementArgument.getExpressible().getSqmType();
if ( expectedElementType != null && elementType != null && expectedElementType != elementType ) {
throw new FunctionArgumentException(
String.format(
"Parameter %d of function '%s()' has type %s, but argument is of type '%s'",
elementIndex,
functionName,
expectedElementType.getJavaTypeDescriptor().getTypeName(),
elementType.getTypeName()
)
);
if ( elementIndex < arguments.size() ) {
final SqmTypedNode<?> elementArgument = arguments.get( elementIndex );
final SqmExpressible<?> elementType = elementArgument.getExpressible().getSqmType();
if ( expectedElementType != null && elementType != null && expectedElementType != elementType ) {
throw new FunctionArgumentException(
String.format(
"Parameter %d of function '%s()' has type %s, but argument is of type '%s'",
elementIndex,
functionName,
expectedElementType.getJavaTypeDescriptor().getTypeName(),
elementType.getTypeName()
)
);
}
}
}
}

View File

@ -34,13 +34,16 @@ public class ArrayToStringFunction extends AbstractSqmSelfRenderingFunctionDescr
"array_to_string",
FunctionKind.NORMAL,
StandardArgumentsValidators.composite(
new ArgumentTypesValidator( null, ANY, STRING ),
ArrayArgumentValidator.DEFAULT_INSTANCE
new ArgumentTypesValidator( StandardArgumentsValidators.between( 2, 3 ), ANY, STRING, ANY ),
new ArrayAndElementArgumentValidator( 0, 2 )
),
StandardFunctionReturnTypeResolvers.invariant(
typeConfiguration.getBasicTypeRegistry().resolve( StandardBasicTypes.STRING )
),
StandardFunctionArgumentTypeResolvers.invariant( typeConfiguration, ANY, STRING )
StandardFunctionArgumentTypeResolvers.composite(
new ArrayAndElementArgumentTypeResolver( 0, 2 ),
StandardFunctionArgumentTypeResolvers.invariant( typeConfiguration, ANY, STRING )
)
);
}
@ -54,6 +57,10 @@ public class ArrayToStringFunction extends AbstractSqmSelfRenderingFunctionDescr
sqlAstArguments.get( 0 ).accept( walker );
sqlAppender.appendSql( ',' );
sqlAstArguments.get( 1 ).accept( walker );
if ( sqlAstArguments.size() > 2 ) {
sqlAppender.appendSql( ',' );
sqlAstArguments.get( 2 ).accept( walker );
}
sqlAppender.appendSql( ')' );
}

View File

@ -37,11 +37,22 @@ public class H2ArrayToStringFunction extends ArrayToStringFunction {
SqlAstTranslator<?> walker) {
final Expression arrayExpression = (Expression) sqlAstArguments.get( 0 );
final Expression separatorExpression = (Expression) sqlAstArguments.get( 1 );
final Expression defaultExpression = sqlAstArguments.size() > 2 ? (Expression) sqlAstArguments.get( 2 ) : null;
sqlAppender.append( "case when " );
arrayExpression.accept( walker );
sqlAppender.append( " is not null then coalesce((select listagg(array_get(" );
sqlAppender.append( " is not null then coalesce((select listagg(" );
if ( defaultExpression != null ) {
sqlAppender.append( "coalesce(" );
}
sqlAppender.append( "array_get(" );
arrayExpression.accept( walker );
sqlAppender.append(",i.idx)," );
sqlAppender.append(",i.idx)" );
if ( defaultExpression != null ) {
sqlAppender.append( "," );
defaultExpression.accept( walker );
sqlAppender.append( ")" );
}
sqlAppender.append("," );
separatorExpression.accept( walker );
sqlAppender.append( ") within group (order by i.idx) from system_range(1,");
sqlAppender.append( Integer.toString( maximumArraySize ) );

View File

@ -33,9 +33,20 @@ public class HSQLArrayToStringFunction extends ArrayToStringFunction {
SqlAstTranslator<?> walker) {
final Expression arrayExpression = (Expression) sqlAstArguments.get( 0 );
final Expression separatorExpression = (Expression) sqlAstArguments.get( 1 );
final Expression defaultExpression = sqlAstArguments.size() > 2 ? (Expression) sqlAstArguments.get( 2 ) : null;
sqlAppender.append( "case when " );
arrayExpression.accept( walker );
sqlAppender.append( " is not null then coalesce((select group_concat(t.val order by t.idx separator " );
sqlAppender.append( " is not null then coalesce((select group_concat(" );
if ( defaultExpression != null ) {
sqlAppender.append( "coalesce(" );
}
sqlAppender.append( "t.val" );
if ( defaultExpression != null ) {
sqlAppender.append( "," );
defaultExpression.accept( walker );
sqlAppender.append( ")" );
}
sqlAppender.append( " order by t.idx separator " );
// HSQLDB doesn't like non-literals as separator
walker.render( separatorExpression, SqlAstNodeRenderingMode.INLINE_PARAMETERS );
sqlAppender.append( ") from unnest(");

View File

@ -46,6 +46,13 @@ public class OracleArrayToStringFunction extends ArrayToStringFunction {
sqlAstArguments.get( 0 ).accept( walker );
sqlAppender.append( ',' );
sqlAstArguments.get( 1 ).accept( walker );
if ( sqlAstArguments.size() > 2 ) {
sqlAppender.append( ',' );
sqlAstArguments.get( 2 ).accept( walker );
}
else {
sqlAppender.append( ",null" );
}
sqlAppender.append( ')' );
}
}

View File

@ -293,6 +293,21 @@ public interface JdbcType extends Serializable {
|| isIntervalType( ddlTypeCode );
}
default boolean isArray() {
return isArray( getDdlTypeCode() );
}
static boolean isArray(int jdbcTypeCode) {
switch ( jdbcTypeCode ) {
case ARRAY:
case STRUCT_ARRAY:
case JSON_ARRAY:
case XML_ARRAY:
return true;
}
return false;
}
default CastType getCastType() {
return getCastType( getDdlTypeCode() );
}

View File

@ -26,6 +26,8 @@ import org.junit.jupiter.api.Test;
import jakarta.persistence.Tuple;
import org.assertj.core.api.Assertions;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.isOneOf;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@ -59,16 +61,29 @@ public class ArrayToStringTest {
public void test(SessionFactoryScope scope) {
scope.inSession( em -> {
//tag::hql-array-to-string-example[]
List<String> results = em.createQuery( "select array_to_string(e.theArray, ',') from EntityWithArrays e", String.class )
List<String> results = em.createQuery( "select array_to_string(e.theArray, ',') from EntityWithArrays e order by e.id", String.class )
.getResultList();
//end::hql-array-to-string-example[]
assertEquals( 3, results.size() );
// We expect an empty string, but Oracle returns NULL instead of empty strings
Assertions.assertThat( results.get( 0 ) ).isNullOrEmpty();
assertEquals( "abc,def", results.get( 1 ) );
assertNull( results.get( 2 ) );
} );
}
@Test
public void testNullValue(SessionFactoryScope scope) {
scope.inSession( em -> {
List<String> results = em.createQuery( "select array_to_string(e.theArray, ',', 'null') from EntityWithArrays e order by e.id", String.class )
.getResultList();
assertEquals( 3, results.size() );
Assertions.assertThat( results.get( 0 ) ).isNullOrEmpty();
assertEquals( "abc,null,def", results.get( 1 ) );
assertNull( results.get( 2 ) );
} );
}
@Test
public void testNodeBuilderArray(SessionFactoryScope scope) {
scope.inSession( em -> {
@ -99,4 +114,30 @@ public class ArrayToStringTest {
} );
}
@Test
public void testCast(SessionFactoryScope scope) {
scope.inSession( em -> {
//tag::hql-array-to-string-hql-example[]
List<String> results = em.createQuery( "select cast(e.theArray as String) from EntityWithArrays e order by e.id", String.class )
.getResultList();
//end::hql-array-to-string-hql-example[]
assertEquals( 3, results.size() );
assertEquals( "[]", results.get( 0 ) );
assertEquals( "[abc,null,def]", results.get( 1 ) );
assertNull( results.get( 2 ) );
} );
}
@Test
public void testStr(SessionFactoryScope scope) {
scope.inSession( em -> {
List<String> results = em.createQuery( "select str(e.theArray) from EntityWithArrays e order by e.id", String.class )
.getResultList();
assertEquals( 3, results.size() );
assertEquals( "[]", results.get( 0 ) );
assertEquals( "[abc,null,def]", results.get( 1 ) );
assertNull( results.get( 2 ) );
} );
}
}