HHH-18800 Add XML aggregate support for PostgreSQL

This commit is contained in:
Christian Beikov 2024-11-20 13:02:41 +01:00
parent eeba7edf32
commit 57142a86dd
5 changed files with 371 additions and 10 deletions

View File

@ -53,6 +53,8 @@ import org.hibernate.exception.spi.SQLExceptionConversionDelegate;
import org.hibernate.exception.spi.TemplatedViolatedConstraintNameExtractor;
import org.hibernate.exception.spi.ViolatedConstraintNameExtractor;
import org.hibernate.internal.util.JdbcExceptionHelper;
import org.hibernate.mapping.AggregateColumn;
import org.hibernate.mapping.Table;
import org.hibernate.metamodel.mapping.EntityMappingType;
import org.hibernate.metamodel.spi.RuntimeModelCreationContext;
import org.hibernate.procedure.internal.PostgreSQLCallableStatementSupport;
@ -76,6 +78,8 @@ import org.hibernate.sql.ast.spi.StandardSqlAstTranslatorFactory;
import org.hibernate.sql.ast.tree.Statement;
import org.hibernate.sql.exec.spi.JdbcOperation;
import org.hibernate.tool.schema.extract.spi.ColumnTypeInformation;
import org.hibernate.tool.schema.internal.StandardTableExporter;
import org.hibernate.tool.schema.spi.Exporter;
import org.hibernate.type.JavaObjectType;
import org.hibernate.type.descriptor.java.PrimitiveByteArrayJavaType;
import org.hibernate.type.descriptor.jdbc.BlobJdbcType;
@ -143,6 +147,17 @@ public class PostgreSQLLegacyDialect extends Dialect {
protected final PostgreSQLDriverKind driverKind;
private final UniqueDelegate uniqueDelegate = new CreateTableUniqueDelegate(this);
private final StandardTableExporter postgresqlTableExporter = new StandardTableExporter( this ) {
@Override
protected void applyAggregateColumnCheck(StringBuilder buf, AggregateColumn aggregateColumn) {
final JdbcType jdbcType = aggregateColumn.getType().getJdbcType();
if ( jdbcType.isXml() ) {
// Requires the use of xmltable which is not supported in check constraints
return;
}
super.applyAggregateColumnCheck( buf, aggregateColumn );
}
};
public PostgreSQLLegacyDialect() {
this( DatabaseVersion.make( 8, 0 ) );
@ -1536,6 +1551,11 @@ public class PostgreSQLLegacyDialect extends Dialect {
return uniqueDelegate;
}
@Override
public Exporter<Table> getTableExporter() {
return postgresqlTableExporter;
}
/**
* @return {@code true}, but only because we can "batch" truncate
*/

View File

@ -50,6 +50,8 @@ import org.hibernate.exception.spi.SQLExceptionConversionDelegate;
import org.hibernate.exception.spi.TemplatedViolatedConstraintNameExtractor;
import org.hibernate.exception.spi.ViolatedConstraintNameExtractor;
import org.hibernate.internal.util.JdbcExceptionHelper;
import org.hibernate.mapping.AggregateColumn;
import org.hibernate.mapping.Table;
import org.hibernate.metamodel.mapping.EntityMappingType;
import org.hibernate.metamodel.spi.RuntimeModelCreationContext;
import org.hibernate.persister.entity.mutation.EntityMutationTarget;
@ -78,6 +80,8 @@ import org.hibernate.sql.model.MutationOperation;
import org.hibernate.sql.model.internal.OptionalTableUpdate;
import org.hibernate.sql.model.jdbc.OptionalTableUpdateOperation;
import org.hibernate.tool.schema.extract.spi.ColumnTypeInformation;
import org.hibernate.tool.schema.internal.StandardTableExporter;
import org.hibernate.tool.schema.spi.Exporter;
import org.hibernate.type.JavaObjectType;
import org.hibernate.type.descriptor.java.PrimitiveByteArrayJavaType;
import org.hibernate.type.descriptor.jdbc.BlobJdbcType;
@ -145,6 +149,17 @@ public class PostgreSQLDialect extends Dialect {
protected final static DatabaseVersion MINIMUM_VERSION = DatabaseVersion.make( 12 );
private final UniqueDelegate uniqueDelegate = new CreateTableUniqueDelegate(this);
private final StandardTableExporter postgresqlTableExporter = new StandardTableExporter( this ) {
@Override
protected void applyAggregateColumnCheck(StringBuilder buf, AggregateColumn aggregateColumn) {
final JdbcType jdbcType = aggregateColumn.getType().getJdbcType();
if ( jdbcType.isXml() ) {
// Requires the use of xmltable which is not supported in check constraints
return;
}
super.applyAggregateColumnCheck( buf, aggregateColumn );
}
};
protected final PostgreSQLDriverKind driverKind;
private final OptionalTableUpdateStrategy optionalTableUpdateStrategy;
@ -1474,6 +1489,11 @@ public class PostgreSQLDialect extends Dialect {
return uniqueDelegate;
}
@Override
public Exporter<Table> getTableExporter() {
return postgresqlTableExporter;
}
/**
* @return {@code true}, but only because we can "batch" truncate
*/

View File

@ -8,8 +8,10 @@ import java.util.LinkedHashMap;
import java.util.Map;
import org.hibernate.dialect.Dialect;
import org.hibernate.dialect.XmlHelper;
import org.hibernate.internal.util.StringHelper;
import org.hibernate.mapping.Column;
import org.hibernate.metamodel.mapping.EmbeddableMappingType;
import org.hibernate.metamodel.mapping.JdbcMapping;
import org.hibernate.metamodel.mapping.SelectableMapping;
import org.hibernate.metamodel.mapping.SelectablePath;
@ -18,6 +20,8 @@ import org.hibernate.sql.ast.SqlAstNodeRenderingMode;
import org.hibernate.sql.ast.SqlAstTranslator;
import org.hibernate.sql.ast.spi.SqlAppender;
import org.hibernate.type.BasicPluralType;
import org.hibernate.type.descriptor.jdbc.AggregateJdbcType;
import org.hibernate.type.descriptor.jdbc.JdbcType;
import org.hibernate.type.spi.TypeConfiguration;
import static org.hibernate.type.SqlTypes.ARRAY;
@ -31,16 +35,25 @@ import static org.hibernate.type.SqlTypes.JSON;
import static org.hibernate.type.SqlTypes.JSON_ARRAY;
import static org.hibernate.type.SqlTypes.LONG32VARBINARY;
import static org.hibernate.type.SqlTypes.SMALLINT;
import static org.hibernate.type.SqlTypes.SQLXML;
import static org.hibernate.type.SqlTypes.STRUCT;
import static org.hibernate.type.SqlTypes.STRUCT_ARRAY;
import static org.hibernate.type.SqlTypes.STRUCT_TABLE;
import static org.hibernate.type.SqlTypes.TINYINT;
import static org.hibernate.type.SqlTypes.VARBINARY;
import static org.hibernate.type.SqlTypes.XML_ARRAY;
public class PostgreSQLAggregateSupport extends AggregateSupportImpl {
private static final AggregateSupport INSTANCE = new PostgreSQLAggregateSupport();
private static final String XML_EXTRACT_START = "xmlelement(name \"" + XmlHelper.ROOT_TAG + "\",(select xmlagg(t.v) from xmltable(";
private static final String XML_EXTRACT_SEPARATOR = "/*' passing ";
private static final String XML_EXTRACT_END = " columns v xml path '.')t))";
private static final String XML_QUERY_START = "(select xmlagg(t.v) from xmltable(";
private static final String XML_QUERY_SEPARATOR = "' passing ";
private static final String XML_QUERY_END = " columns v xml path '.')t)";
public static AggregateSupport valueOf(Dialect dialect) {
return PostgreSQLAggregateSupport.INSTANCE;
}
@ -109,6 +122,40 @@ public class PostgreSQLAggregateSupport extends AggregateSupportImpl {
"cast(" + aggregateParentReadExpression + "->>'" + columnExpression + "' as " + column.getColumnDefinition() + ')'
);
}
case XML_ARRAY:
case SQLXML:
switch ( column.getJdbcMapping().getJdbcType().getDefaultSqlTypeCode() ) {
case SQLXML:
return template.replace(
placeholder,
XML_EXTRACT_START + xmlExtractArguments( aggregateParentReadExpression, columnExpression + "/*" ) + XML_EXTRACT_END
);
case XML_ARRAY:
if ( typeConfiguration.getCurrentBaseSqlTypeIndicators().isXmlFormatMapperLegacyFormatEnabled() ) {
throw new IllegalArgumentException( "XML array '" + columnExpression + "' in '" + aggregateParentReadExpression + "' is not supported with legacy format enabled." );
}
else {
return template.replace(
placeholder,
"xmlelement(name \"Collection\",(select xmlagg(t.v order by t.i) from xmltable(" + xmlExtractArguments( aggregateParentReadExpression, columnExpression + "/*" ) + " columns v xml path '.', i for ordinality)t))"
);
}
case BINARY:
case VARBINARY:
case LONG32VARBINARY:
// We encode binary data as hex, so we have to decode here
return template.replace(
placeholder,
"decode((select t.v from xmltable(" + xmlExtractArguments( aggregateParentReadExpression, columnExpression )+ " columns v text path '.') t),'hex')"
);
case ARRAY:
throw new UnsupportedOperationException( "Transforming XML_ARRAY to native arrays is not supported on PostgreSQL!" );
default:
return template.replace(
placeholder,
"(select t.v from xmltable(" + xmlExtractArguments( aggregateParentReadExpression, columnExpression ) + " columns v " + column.getColumnDefinition() + " path '.') t)"
);
}
case STRUCT:
case STRUCT_ARRAY:
case STRUCT_TABLE:
@ -117,6 +164,35 @@ public class PostgreSQLAggregateSupport extends AggregateSupportImpl {
throw new IllegalArgumentException( "Unsupported aggregate SQL type: " + aggregateColumnTypeCode );
}
private static String xmlExtractArguments(String aggregateParentReadExpression, String xpathFragment) {
final String extractArguments;
int separatorIndex;
if ( aggregateParentReadExpression.startsWith( XML_EXTRACT_START )
&& aggregateParentReadExpression.endsWith( XML_EXTRACT_END )
&& (separatorIndex = aggregateParentReadExpression.indexOf( XML_EXTRACT_SEPARATOR )) != -1 ) {
final StringBuilder sb = new StringBuilder( aggregateParentReadExpression.length() - XML_EXTRACT_START.length() + xpathFragment.length() );
sb.append( aggregateParentReadExpression, XML_EXTRACT_START.length(), separatorIndex );
sb.append( '/' );
sb.append( xpathFragment );
sb.append( aggregateParentReadExpression, separatorIndex + 2, aggregateParentReadExpression.length() - XML_EXTRACT_END.length() );
extractArguments = sb.toString();
}
else if ( aggregateParentReadExpression.startsWith( XML_QUERY_START )
&& aggregateParentReadExpression.endsWith( XML_QUERY_END )
&& (separatorIndex = aggregateParentReadExpression.indexOf( XML_QUERY_SEPARATOR )) != -1 ) {
final StringBuilder sb = new StringBuilder( aggregateParentReadExpression.length() - XML_QUERY_START.length() + xpathFragment.length() );
sb.append( aggregateParentReadExpression, XML_QUERY_START.length(), separatorIndex );
sb.append( '/' );
sb.append( xpathFragment );
sb.append( aggregateParentReadExpression, separatorIndex, aggregateParentReadExpression.length() - XML_QUERY_END.length() );
extractArguments = sb.toString();
}
else {
extractArguments = "'/" + XmlHelper.ROOT_TAG + "/" + xpathFragment + "' passing " + aggregateParentReadExpression;
}
return extractArguments;
}
private static String jsonCustomWriteExpression(String customWriteExpression, JdbcMapping jdbcMapping) {
final int sqlTypeCode = jdbcMapping.getJdbcType().getDefaultSqlTypeCode();
switch ( sqlTypeCode ) {
@ -141,6 +217,30 @@ public class PostgreSQLAggregateSupport extends AggregateSupportImpl {
}
}
private static String xmlCustomWriteExpression(String customWriteExpression, JdbcMapping jdbcMapping) {
final int sqlTypeCode = jdbcMapping.getJdbcType().getDefaultSqlTypeCode();
switch ( sqlTypeCode ) {
case BINARY:
case VARBINARY:
case LONG32VARBINARY:
// We encode binary data as hex
return "encode(" + customWriteExpression + ",'hex')";
// case ARRAY:
// final BasicPluralType<?, ?> pluralType = (BasicPluralType<?, ?>) jdbcMapping;
// switch ( pluralType.getElementType().getJdbcType().getDefaultSqlTypeCode() ) {
// case BINARY:
// case VARBINARY:
// case LONG32VARBINARY:
// // We encode binary data as hex
// return "to_jsonb(array(select encode(unnest(" + customWriteExpression + "),'hex')))";
// default:
// return "to_jsonb(" + customWriteExpression + ")";
// }
default:
return customWriteExpression;
}
}
@Override
public String aggregateComponentAssignmentExpression(
String aggregateParentAssignmentExpression,
@ -150,7 +250,9 @@ public class PostgreSQLAggregateSupport extends AggregateSupportImpl {
switch ( aggregateColumnTypeCode ) {
case JSON:
case JSON_ARRAY:
// For JSON we always have to replace the whole object
case SQLXML:
case XML_ARRAY:
// For JSON/XML we always have to replace the whole object
return aggregateParentAssignmentExpression;
case STRUCT:
case STRUCT_ARRAY:
@ -164,6 +266,7 @@ public class PostgreSQLAggregateSupport extends AggregateSupportImpl {
public boolean requiresAggregateCustomWriteExpressionRenderer(int aggregateSqlTypeCode) {
switch ( aggregateSqlTypeCode ) {
case JSON:
case SQLXML:
return true;
}
return false;
@ -183,17 +286,13 @@ public class PostgreSQLAggregateSupport extends AggregateSupportImpl {
final int aggregateSqlTypeCode = aggregateColumn.getJdbcMapping().getJdbcType().getDefaultSqlTypeCode();
switch ( aggregateSqlTypeCode ) {
case JSON:
return jsonAggregateColumnWriter( aggregateColumn, columnsToUpdate );
return new RootJsonWriteExpression( aggregateColumn, columnsToUpdate );
case SQLXML:
return new RootXmlWriteExpression( aggregateColumn, columnsToUpdate );
}
throw new IllegalArgumentException( "Unsupported aggregate SQL type: " + aggregateSqlTypeCode );
}
private WriteExpressionRenderer jsonAggregateColumnWriter(
SelectableMapping aggregateColumn,
SelectableMapping[] columns) {
return new RootJsonWriteExpression( aggregateColumn, columns );
}
interface JsonWriteExpression {
void append(
SqlAppender sb,
@ -329,4 +428,198 @@ public class PostgreSQLAggregateSupport extends AggregateSupportImpl {
}
}
interface XmlWriteExpression {
void append(
SqlAppender sb,
String path,
SqlAstTranslator<?> translator,
AggregateColumnWriteExpression expression);
}
private static class AggregateXmlWriteExpression implements XmlWriteExpression {
private final SelectableMapping selectableMapping;
private final String columnDefinition;
private final LinkedHashMap<String, XmlWriteExpression> subExpressions = new LinkedHashMap<>();
private AggregateXmlWriteExpression(SelectableMapping selectableMapping, String columnDefinition) {
this.selectableMapping = selectableMapping;
this.columnDefinition = columnDefinition;
}
protected void initializeSubExpressions(SelectableMapping aggregateColumn, SelectableMapping[] columns) {
for ( SelectableMapping column : columns ) {
final SelectablePath selectablePath = column.getSelectablePath();
final SelectablePath[] parts = selectablePath.getParts();
AggregateXmlWriteExpression currentAggregate = this;
for ( int i = 1; i < parts.length - 1; i++ ) {
final AggregateJdbcType aggregateJdbcType = (AggregateJdbcType) currentAggregate.selectableMapping.getJdbcMapping().getJdbcType();
final EmbeddableMappingType embeddableMappingType = aggregateJdbcType.getEmbeddableMappingType();
final int selectableIndex = embeddableMappingType.getSelectableIndex( parts[i].getSelectableName() );
currentAggregate = (AggregateXmlWriteExpression) currentAggregate.subExpressions.computeIfAbsent(
parts[i].getSelectableName(),
k -> new AggregateXmlWriteExpression( embeddableMappingType.getJdbcValueSelectable( selectableIndex ), columnDefinition )
);
}
final String customWriteExpression = column.getWriteExpression();
currentAggregate.subExpressions.put(
parts[parts.length - 1].getSelectableName(),
new BasicXmlWriteExpression(
column,
xmlCustomWriteExpression( customWriteExpression, column.getJdbcMapping() )
)
);
}
passThroughUnsetSubExpressions( aggregateColumn );
}
protected void passThroughUnsetSubExpressions(SelectableMapping aggregateColumn) {
final AggregateJdbcType aggregateJdbcType = (AggregateJdbcType) aggregateColumn.getJdbcMapping().getJdbcType();
final EmbeddableMappingType embeddableMappingType = aggregateJdbcType.getEmbeddableMappingType();
final int jdbcValueCount = embeddableMappingType.getJdbcValueCount();
for ( int i = 0; i < jdbcValueCount; i++ ) {
final SelectableMapping selectableMapping = embeddableMappingType.getJdbcValueSelectable( i );
final XmlWriteExpression xmlWriteExpression = subExpressions.get( selectableMapping.getSelectableName() );
if ( xmlWriteExpression == null ) {
subExpressions.put(
selectableMapping.getSelectableName(),
new PassThroughXmlWriteExpression( selectableMapping )
);
}
else if ( xmlWriteExpression instanceof AggregateXmlWriteExpression writeExpression ) {
writeExpression.passThroughUnsetSubExpressions( selectableMapping );
}
}
}
protected String getTagName() {
return selectableMapping.getSelectableName();
}
@Override
public void append(
SqlAppender sb,
String path,
SqlAstTranslator<?> translator,
AggregateColumnWriteExpression expression) {
sb.append( "xmlelement(name " );
sb.appendDoubleQuoteEscapedString( getTagName() );
sb.append( ",xmlconcat" );
char separator = '(';
for ( Map.Entry<String, XmlWriteExpression> entry : subExpressions.entrySet() ) {
sb.append( separator );
final XmlWriteExpression value = entry.getValue();
if ( value instanceof AggregateXmlWriteExpression ) {
final String subPath = XML_QUERY_START + xmlExtractArguments( path, entry.getKey() ) + XML_QUERY_END;
value.append( sb, subPath, translator, expression );
}
else {
value.append( sb, path, translator, expression );
}
separator = ',';
}
sb.append( "))" );
}
}
private static class RootXmlWriteExpression extends AggregateXmlWriteExpression
implements WriteExpressionRenderer {
private final String path;
RootXmlWriteExpression(SelectableMapping aggregateColumn, SelectableMapping[] columns) {
super( aggregateColumn, aggregateColumn.getColumnDefinition() );
path = aggregateColumn.getSelectionExpression();
initializeSubExpressions( aggregateColumn, columns );
}
@Override
protected String getTagName() {
return XmlHelper.ROOT_TAG;
}
@Override
public void render(
SqlAppender sqlAppender,
SqlAstTranslator<?> translator,
AggregateColumnWriteExpression aggregateColumnWriteExpression,
String qualifier) {
final String basePath;
if ( qualifier == null || qualifier.isBlank() ) {
basePath = path;
}
else {
basePath = qualifier + "." + path;
}
append( sqlAppender, XML_QUERY_START + "'/" + getTagName() + "' passing " + basePath + XML_QUERY_END, translator, aggregateColumnWriteExpression );
}
}
private static class BasicXmlWriteExpression implements XmlWriteExpression {
private final SelectableMapping selectableMapping;
private final String[] customWriteExpressionParts;
BasicXmlWriteExpression(SelectableMapping selectableMapping, String customWriteExpression) {
this.selectableMapping = selectableMapping;
if ( customWriteExpression.equals( "?" ) ) {
this.customWriteExpressionParts = new String[]{ "", "" };
}
else {
assert !customWriteExpression.startsWith( "?" );
final String[] parts = StringHelper.split( "?", customWriteExpression );
assert parts.length == 2 || (parts.length & 1) == 1;
this.customWriteExpressionParts = parts;
}
}
@Override
public void append(
SqlAppender sb,
String path,
SqlAstTranslator<?> translator,
AggregateColumnWriteExpression expression) {
final JdbcType jdbcType = selectableMapping.getJdbcMapping().getJdbcType();
final boolean isArray = jdbcType.getDefaultSqlTypeCode() == XML_ARRAY;
sb.append( "xmlelement(name " );
sb.appendDoubleQuoteEscapedString( selectableMapping.getSelectableName() );
sb.append( ',' );
if ( isArray ) {
// Remove the <Collection> tag to wrap the value into the selectable specific tag
sb.append( "(select xmlagg(t.v order by t.i) from xmltable('/Collection/*' passing " );
}
sb.append( customWriteExpressionParts[0] );
for ( int i = 1; i < customWriteExpressionParts.length; i++ ) {
// We use NO_UNTYPED here so that expressions which require type inference are casted explicitly,
// since we don't know how the custom write expression looks like where this is embedded,
// so we have to be pessimistic and avoid ambiguities
translator.render( expression.getValueExpression( selectableMapping ), SqlAstNodeRenderingMode.NO_UNTYPED );
sb.append( customWriteExpressionParts[i] );
}
if ( isArray ) {
sb.append( " columns v xml path '.', i for ordinality)t)" );
}
sb.append( ')' );
}
}
private static class PassThroughXmlWriteExpression implements XmlWriteExpression {
private final SelectableMapping selectableMapping;
PassThroughXmlWriteExpression(SelectableMapping selectableMapping) {
this.selectableMapping = selectableMapping;
}
@Override
public void append(
SqlAppender sb,
String path,
SqlAstTranslator<?> translator,
AggregateColumnWriteExpression expression) {
sb.append( XML_QUERY_START );
sb.append( xmlExtractArguments( path, selectableMapping.getSelectableName() ) );
sb.append( XML_QUERY_END );
}
}
}

View File

@ -5,6 +5,7 @@
package org.hibernate.dialect.function.array;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.hibernate.dialect.XmlHelper;
import org.hibernate.dialect.aggregate.AggregateSupport;
import org.hibernate.metamodel.mapping.CollectionPart;
import org.hibernate.metamodel.mapping.SqlTypedMapping;
@ -14,6 +15,7 @@ import org.hibernate.sql.ast.spi.SqlAppender;
import org.hibernate.sql.ast.tree.expression.Expression;
import org.hibernate.type.BasicPluralType;
import org.hibernate.type.SqlTypes;
import org.hibernate.type.descriptor.java.BasicPluralJavaType;
/**
@ -71,4 +73,27 @@ public class PostgreSQLUnnestFunction extends UnnestFunction {
sqlAppender.appendSql( " t(v))" );
}
}
protected void renderXmlTable(
SqlAppender sqlAppender,
Expression array,
BasicPluralType<?, ?> pluralType,
@Nullable SqlTypedMapping sqlTypedMapping,
AnonymousTupleTableGroupProducer tupleType,
String tableIdentifierVariable,
SqlAstTranslator<?> walker) {
final XmlHelper.CollectionTags collectionTags = XmlHelper.determineCollectionTags(
(BasicPluralJavaType<?>) pluralType.getJavaTypeDescriptor(), walker.getSessionFactory()
);
sqlAppender.appendSql( "xmltable('/" );
sqlAppender.appendSql( collectionTags.rootName() );
sqlAppender.appendSql( '/' );
sqlAppender.appendSql( collectionTags.elementName() );
sqlAppender.appendSql( "' passing " );
array.accept( walker );
sqlAppender.appendSql( " columns" );
renderXmlTableColumns( sqlAppender, tupleType, walker );
sqlAppender.appendSql( ')' );
}
}

View File

@ -162,6 +162,11 @@ public class UnnestFunction extends AbstractSqmSelfRenderingSetReturningFunction
sqlAppender.appendSql( "' passing " );
array.accept( walker );
sqlAppender.appendSql( " as \"d\" columns" );
renderXmlTableColumns( sqlAppender, tupleType, walker );
sqlAppender.appendSql( ')' );
}
protected void renderXmlTableColumns(SqlAppender sqlAppender, AnonymousTupleTableGroupProducer tupleType, SqlAstTranslator<?> walker) {
if ( tupleType.findSubPart( CollectionPart.Nature.ELEMENT.getName(), null ) == null ) {
tupleType.forEachSelectable( 0, (selectionIndex, selectableMapping) -> {
if ( selectionIndex == 0 ) {
@ -205,8 +210,6 @@ public class UnnestFunction extends AbstractSqmSelfRenderingSetReturningFunction
}
} );
}
sqlAppender.appendSql( ')' );
}
protected void renderUnnest(