From e0f785a25942f338fffa8b1578755f2ed9b24d75 Mon Sep 17 00:00:00 2001 From: Marco Belladelli Date: Wed, 21 May 2025 13:52:00 +0200 Subject: [PATCH] HBX-2996 New module containing natural language building blocks --- language/README.md | 25 + language/pom.xml | 82 + .../tool/language/HibernateAssistant.java | 90 + .../tool/language/internal/JsonHelper.java | 1823 +++++++++++++++++ .../internal/MetamodelJsonSerializerImpl.java | 192 ++ .../internal/ResultsJsonSerializerImpl.java | 197 ++ .../language/spi/MetamodelSerializer.java | 39 + .../tool/language/spi/ResultsSerializer.java | 40 + .../language/MetamodelJsonSerializerTest.java | 312 +++ .../language/ResultsJsonSerializerTest.java | 433 ++++ .../tool/language/domain/Address.java | 52 + .../tool/language/domain/Company.java | 76 + .../tool/language/domain/Employee.java | 89 + .../src/test/resources/hibernate.properties | 10 + language/src/test/resources/log4j2.properties | 20 + pom.xml | 6 + 16 files changed, 3486 insertions(+) create mode 100644 language/README.md create mode 100644 language/pom.xml create mode 100644 language/src/main/java/org/hibernate/tool/language/HibernateAssistant.java create mode 100644 language/src/main/java/org/hibernate/tool/language/internal/JsonHelper.java create mode 100644 language/src/main/java/org/hibernate/tool/language/internal/MetamodelJsonSerializerImpl.java create mode 100644 language/src/main/java/org/hibernate/tool/language/internal/ResultsJsonSerializerImpl.java create mode 100644 language/src/main/java/org/hibernate/tool/language/spi/MetamodelSerializer.java create mode 100644 language/src/main/java/org/hibernate/tool/language/spi/ResultsSerializer.java create mode 100644 language/src/test/java/org/hibernate/tool/language/MetamodelJsonSerializerTest.java create mode 100644 language/src/test/java/org/hibernate/tool/language/ResultsJsonSerializerTest.java create mode 100644 language/src/test/java/org/hibernate/tool/language/domain/Address.java create mode 100644 language/src/test/java/org/hibernate/tool/language/domain/Company.java create mode 100644 language/src/test/java/org/hibernate/tool/language/domain/Employee.java create mode 100644 language/src/test/resources/hibernate.properties create mode 100644 language/src/test/resources/log4j2.properties diff --git a/language/README.md b/language/README.md new file mode 100644 index 0000000000..669772176c --- /dev/null +++ b/language/README.md @@ -0,0 +1,25 @@ + + +[![Hibernate](https://static.jboss.org/hibernate/images/hibernate_200x150.png)](https://tools.hibernate.org) + +# Hibernate Tools for Natural Language + +This project contains the `HibernateAssistant` interface, which is aimed at providing a natural language interface to Hibernate ORM's persistence capabilities, through the capabilities of modern LLMs. **Its implementation is not included here**, but different providers can implement this interface with their own logic to interact with the underlying Generative AI model, taking advantage of the building blocks and utilities that _are_ included here. + +To be able to interact with different model providers, the `MetamodelSerializer` and `ResultsSerializer` SPIs can be used to generate a textual (JSON) representation of Hibernate's mapping model and data. This text can be easily fed to an LLM that will enable interacting with your database through simple natural language interactions. + +WARNING: This entire module is currently incubating and may experience breaking changes at any time, including in a micro (patch) release. \ No newline at end of file diff --git a/language/pom.xml b/language/pom.xml new file mode 100644 index 0000000000..cc52e64ede --- /dev/null +++ b/language/pom.xml @@ -0,0 +1,82 @@ + + + + 4.0.0 + + org.hibernate.tool + hibernate-tools-parent + 7.0.1-SNAPSHOT + + + hibernate-tools-language + + Hibernate Tools Natural Language + + Tools to aid Hibernate developers through natural language, + leveraging LLMs and generative AI functionalities. + + jar + + + 3.27.1 + 2.19.0 + + + + + org.hibernate.orm + hibernate-core + + + + + org.hibernate.orm + hibernate-testing + test + + + com.h2database + h2 + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.assertj + assertj-core + ${version.org.assertj.assertj-core} + test + + + com.fasterxml.jackson.core + jackson-core + ${version.com.fasterxml.jackson.core} + test + + + com.fasterxml.jackson.core + jackson-databind + ${version.com.fasterxml.jackson.core} + + + + \ No newline at end of file diff --git a/language/src/main/java/org/hibernate/tool/language/HibernateAssistant.java b/language/src/main/java/org/hibernate/tool/language/HibernateAssistant.java new file mode 100644 index 0000000000..d34dd34b7f --- /dev/null +++ b/language/src/main/java/org/hibernate/tool/language/HibernateAssistant.java @@ -0,0 +1,90 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language; + +import org.hibernate.SharedSessionContract; +import org.hibernate.query.SelectionQuery; + +/** + * Hibernate Assistant allows interacting with an underlying LLM to help you retrieve persistent data. + * It leverages Hibernate ORM's mapping models, query language, cross-platform support and + * built-in data restrictions to make access to information stored in relational databases + * as easy as a natural language prompt. + */ +public interface HibernateAssistant { + /** + * Creates a {@link SelectionQuery} by providing the specified natural language {@code message} to the LLM + * and interpreting the obtained response. + * + * @param message the natural language prompt + * @param session Hibernate session + * + * @return the {@link SelectionQuery} generated by the LLM + */ + default SelectionQuery createAiQuery(String message, SharedSessionContract session) { + return createAiQuery( message, session, null ); + } + + /** + * Creates a {@link SelectionQuery} by providing the specified natural language {@code message} to the LLM + * and interpreting the obtained response. + * + * @param message the natural language prompt + * @param session Hibernate session + * @param resultType The {@link Class} representing the expected query result type + * + * @return the {@link SelectionQuery} generated by the LLM + */ + SelectionQuery createAiQuery(String message, SharedSessionContract session, Class resultType); + + /** + * Prompts the underlying LLM with the provided natural language message and tries to answer it with + * data extracted from the database through the persistence model. + * + * @param message the natural language request + * @param session Hibernate session + * + * @return a natural language response based on the results of the query + */ + String executeQuery(String message, SharedSessionContract session); + + /** + * Executes the given {@link SelectionQuery}, and provides a natural language + * response by passing the resulting data back to the underlying LLM. + *

+ * To directly obtain a natural language response from a natural language prompt, + * you can use {@link #executeQuery(String, SharedSessionContract)} instead. + *

+ * If you wish to execute the query manually and obtain the structured results yourself, + * you should use {@link SelectionQuery}'s direct execution methods, e.g. {@link SelectionQuery#getResultList()} + * or {@link SelectionQuery#getSingleResult()}. + * + * @param query the AI query to execute + * @param session the session in which to execute the query + * + * @return a natural language response based on the results of the query + */ + String executeQuery(SelectionQuery query, SharedSessionContract session); + + /** + * Reset the assistant's current chat context. This can be helpful when + * creating a new {@link SelectionQuery} that should not rely on the context + * of previous requests. + */ + void clear(); +} diff --git a/language/src/main/java/org/hibernate/tool/language/internal/JsonHelper.java b/language/src/main/java/org/hibernate/tool/language/internal/JsonHelper.java new file mode 100644 index 0000000000..ca73353bb6 --- /dev/null +++ b/language/src/main/java/org/hibernate/tool/language/internal/JsonHelper.java @@ -0,0 +1,1823 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language.internal; + +import org.hibernate.Internal; +import org.hibernate.bytecode.enhance.spi.LazyPropertyInitializer; +import org.hibernate.collection.spi.CollectionSemantics; +import org.hibernate.collection.spi.PersistentCollection; +import org.hibernate.collection.spi.PersistentMap; +import org.hibernate.internal.build.AllowReflection; +import org.hibernate.internal.util.CharSequenceHelper; +import org.hibernate.internal.util.collections.ArrayHelper; +import org.hibernate.internal.util.collections.IdentitySet; +import org.hibernate.metamodel.mapping.CollectionPart; +import org.hibernate.metamodel.mapping.CompositeIdentifierMapping; +import org.hibernate.metamodel.mapping.EmbeddableMappingType; +import org.hibernate.metamodel.mapping.EntityIdentifierMapping; +import org.hibernate.metamodel.mapping.EntityMappingType; +import org.hibernate.metamodel.mapping.EntityValuedModelPart; +import org.hibernate.metamodel.mapping.JdbcMapping; +import org.hibernate.metamodel.mapping.ManagedMappingType; +import org.hibernate.metamodel.mapping.MappingType; +import org.hibernate.metamodel.mapping.PluralAttributeMapping; +import org.hibernate.metamodel.mapping.SelectableMapping; +import org.hibernate.metamodel.mapping.ValuedModelPart; +import org.hibernate.metamodel.mapping.internal.BasicValuedCollectionPart; +import org.hibernate.metamodel.mapping.internal.EmbeddedAttributeMapping; +import org.hibernate.metamodel.mapping.internal.SingleAttributeIdentifierMapping; +import org.hibernate.sql.ast.spi.SqlAppender; +import org.hibernate.type.BasicPluralType; +import org.hibernate.type.BasicType; +import org.hibernate.type.SqlTypes; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.BasicPluralJavaType; +import org.hibernate.type.descriptor.java.EnumJavaType; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.java.JdbcDateJavaType; +import org.hibernate.type.descriptor.java.JdbcTimeJavaType; +import org.hibernate.type.descriptor.java.JdbcTimestampJavaType; +import org.hibernate.type.descriptor.java.OffsetDateTimeJavaType; +import org.hibernate.type.descriptor.java.PrimitiveByteArrayJavaType; +import org.hibernate.type.descriptor.jdbc.AggregateJdbcType; +import org.hibernate.type.descriptor.jdbc.ArrayJdbcType; +import org.hibernate.type.descriptor.jdbc.JdbcType; +import org.hibernate.type.descriptor.jdbc.StructAttributeValues; +import org.hibernate.type.descriptor.jdbc.StructHelper; + +import java.io.OutputStream; +import java.lang.reflect.Array; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.util.AbstractCollection; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import static org.hibernate.Hibernate.isInitialized; +import static org.hibernate.type.descriptor.jdbc.StructHelper.instantiate; + +/** + * A Helper for serializing JSON, based on the {@link org.hibernate.metamodel.mapping mapping model}. + * + * @implNote This is a subset of the functionalities of {@link org.hibernate.type.descriptor.jdbc.JsonHelper}, + * extracted from ORM as that is being worked on at the moment. The goal is to align the implementations, + * and have a single place for the JSON serialization/deserialization logic within Hibernate core. + */ +@Internal +public class JsonHelper { + + private static void managedTypeToString( + Object object, + ManagedMappingType managedMappingType, + WrapperOptions options, + JsonAppender appender, + char separator) { + final Object[] values = managedMappingType.getValues( object ); + for ( int i = 0; i < values.length; i++ ) { + final ValuedModelPart subPart = getSubPart( managedMappingType, i ); + final Object value = values[i]; + separator = toString( value, subPart, options, appender, separator ); + } + } + + static ValuedModelPart getSubPart(ManagedMappingType type, int position) { + if ( position == type.getNumberOfAttributeMappings() ) { + assert type instanceof EmbeddableMappingType; + return ( (EmbeddableMappingType) type ).getDiscriminatorMapping(); + } + return type.getAttributeMapping( position ); + } + + public static Character toString( + Object value, + ValuedModelPart modelPart, + WrapperOptions options, + JsonAppender appender, + Character separator) { + if ( modelPart instanceof SelectableMapping selectable ) { + separateAndQuote( + () -> appender.expandProperties() ? modelPart.getPartName() : selectable.getSelectableName(), + separator, + appender + ); + toString( value, modelPart.getMappedType(), options, appender ); + return ','; + } + else if ( modelPart instanceof EmbeddedAttributeMapping embeddedAttribute ) { + if ( appender.expandProperties() ) { + separateAndQuote( embeddedAttribute::getAttributeName, separator, appender ); + toString( value, embeddedAttribute.getMappedType(), options, appender ); + } + else { + if ( value == null ) { + // Skipping the update of the separator is on purpose + return separator; + } + + final EmbeddableMappingType mappingType = embeddedAttribute.getMappedType(); + final SelectableMapping aggregateMapping = mappingType.getAggregateMapping(); + if ( aggregateMapping == null ) { + managedTypeToString( value, mappingType, options, appender, separator ); + } + else { + separateAndQuote( aggregateMapping::getSelectableName, separator, appender ); + toString( value, mappingType, options, appender ); + } + } + return ','; + } + else if ( appender.expandProperties() ) { + if ( modelPart instanceof EntityValuedModelPart entityPart ) { + separateAndQuote( entityPart::getPartName, separator, appender ); + toString( value, entityPart.getEntityMappingType(), options, appender ); + return ','; + } + else if ( modelPart instanceof PluralAttributeMapping plural ) { + separateAndQuote( plural::getPartName, separator, appender ); + pluralAttributeToString( value, plural, options, appender ); + return ','; + } + } + + // could not handle model part, throw exception + throw new UnsupportedOperationException( + "Support for model part type not yet implemented: " + + ( modelPart != null ? modelPart.getClass().getName() : "null" ) + ); + } + + private static void separateAndQuote(Supplier nameSupplier, Character separator, JsonAppender appender) { + if ( separator != null ) { + final String name = nameSupplier.get(); + appender.append( separator ).append( '"' ).append( name ).append( "\":" ); + } + } + + private static void entityToString( + Object value, + EntityMappingType entityType, + WrapperOptions options, + JsonAppender appender) { + final EntityIdentifierMapping identifierMapping = entityType.getIdentifierMapping(); + appender.trackingEntity( value, entityType, shouldProcessEntity -> { + if ( shouldProcessEntity ) { + appender.append( "{\"" ).append( identifierMapping.getAttributeName() ).append( "\":" ); + entityIdentifierToString( value, identifierMapping, options, appender ); + managedTypeToString( value, entityType, options, appender, ',' ); + appender.append( '}' ); + } + else { + // if it was already encountered, only append the identity string + appender.append( '\"' ).append( entityType.getEntityName() ).append( '#' ); + entityIdentifierToString( value, identifierMapping, options, appender ); + appender.append( '\"' ); + } + } ); + } + + private static void entityIdentifierToString( + Object value, + EntityIdentifierMapping identifierMapping, + WrapperOptions options, + JsonAppender appender) { + final Object identifier = identifierMapping.getIdentifier( value ); + if ( identifierMapping instanceof SingleAttributeIdentifierMapping singleAttribute ) { + //noinspection unchecked + convertedValueToString( + (JavaType) singleAttribute.getJavaType(), + singleAttribute.getSingleJdbcMapping().getJdbcType(), + identifier, + options, + appender + ); + } + else if ( identifier instanceof CompositeIdentifierMapping composite ) { + toString( identifier, composite.getMappedType(), options, appender ); + } + else { + throw new UnsupportedOperationException( "Unsupported identifier type: " + identifier.getClass().getName() ); + } + } + + private static void pluralAttributeToString( + Object value, + PluralAttributeMapping plural, + WrapperOptions options, + JsonAppender appender) { + if ( handleNullOrLazy( value, appender ) ) { + // nothing left to do + return; + } + + final CollectionPart element = plural.getElementDescriptor(); + final CollectionSemantics collectionSemantics = plural.getMappedType().getCollectionSemantics(); + switch ( collectionSemantics.getCollectionClassification() ) { + case MAP: + case SORTED_MAP: + case ORDERED_MAP: + final PersistentMap pm = (PersistentMap) value; + persistentMapToString( pm, plural.getIndexDescriptor(), element, options, appender ); + break; + default: + final PersistentCollection pc = (PersistentCollection) value; + final Iterator entries = pc.entries( plural.getCollectionDescriptor() ); + char separator = '['; + while ( entries.hasNext() ) { + appender.append( separator ); + collectionPartToString( entries.next(), element, options, appender ); + separator = ','; + } + appender.append( ']' ); + } + } + + private static void persistentMapToString( + PersistentMap map, + CollectionPart key, + CollectionPart value, + WrapperOptions options, + JsonAppender appender) { + char separator = '{'; + for ( final Map.Entry entry : map.entrySet() ) { + appender.append( separator ); + collectionPartToString( entry.getKey(), key, options, appender ); + appender.append( ':' ); + collectionPartToString( entry.getValue(), value, options, appender ); + separator = ','; + } + appender.append( '}' ); + } + + private static void collectionPartToString( + Object value, + CollectionPart collectionPart, + WrapperOptions options, + JsonAppender appender) { + if ( collectionPart instanceof BasicValuedCollectionPart basic ) { + // special case for basic values as they use lambdas as mapping type + //noinspection unchecked + convertedValueToString( + (JavaType) basic.getJavaType(), + basic.getJdbcMapping().getJdbcType(), + value, + options, + appender + ); + } + else { + toString( value, collectionPart.getMappedType(), options, appender ); + } + } + + public static void toString(Object value, MappingType mappedType, WrapperOptions options, JsonAppender appender) { + if ( handleNullOrLazy( value, appender ) ) { + // nothing left to do + return; + } + + if ( mappedType instanceof EntityMappingType entityType ) { + entityToString( value, entityType, options, appender ); + } + else if ( mappedType instanceof ManagedMappingType managedMappingType ) { + managedTypeToString( value, managedMappingType, options, appender, '{' ); + appender.append( '}' ); + } + else if ( mappedType instanceof BasicType type ) { + //noinspection unchecked + convertedBasicValueToString( + type.convertToRelationalValue( value ), + options, + appender, + (JavaType) type.getJdbcJavaType(), + type.getJdbcType() + ); + } + else { + throw new UnsupportedOperationException( + "Support for mapping type not yet implemented: " + mappedType.getClass().getName() + ); + } + } + + /** + * Checks the provided {@code value} is either null or a lazy property. + * + * @param value the value to check + * @param appender the current {@link JsonAppender} + * + * @return {@code true} if it was, indicating no further processing of the value is needed, {@code false otherwise}. + */ + private static boolean handleNullOrLazy(Object value, JsonAppender appender) { + if ( value == null ) { + appender.append( "null" ); + return true; + } + else if ( appender.expandProperties() ) { + // avoid force-initialization when serializing all properties + if ( value == LazyPropertyInitializer.UNFETCHED_PROPERTY ) { + appender.append( '"' ).append( value.toString() ).append( '"' ); + return true; + } + else if ( !isInitialized( value ) ) { + appender.append( '"' ).append( "" ).append( '"' ); + return true; + } + } + return false; + } + + private static void convertedValueToString( + JavaType javaType, + JdbcType jdbcType, + Object value, + WrapperOptions options, + JsonAppender appender) { + if ( value == null ) { + appender.append( "null" ); + } + else if ( jdbcType instanceof AggregateJdbcType aggregateJdbcType ) { + toString( value, aggregateJdbcType.getEmbeddableMappingType(), options, appender ); + } + else { + convertedBasicValueToString( value, options, appender, javaType, jdbcType ); + } + } + + private static void convertedBasicValueToString( + Object value, + WrapperOptions options, + JsonAppender appender, + JavaType javaType, + JdbcType jdbcType) { + switch ( jdbcType.getDefaultSqlTypeCode() ) { + case SqlTypes.TINYINT: + case SqlTypes.SMALLINT: + case SqlTypes.INTEGER: + if ( value instanceof Boolean booleanValue ) { + // BooleanJavaType has this as an implicit conversion + appender.append( booleanValue ? '1' : '0' ); + break; + } + if ( value instanceof Enum enumValue ) { + appender.appendSql( enumValue.ordinal() ); + break; + } + case SqlTypes.BOOLEAN: + case SqlTypes.BIT: + case SqlTypes.BIGINT: + case SqlTypes.FLOAT: + case SqlTypes.REAL: + case SqlTypes.DOUBLE: + // These types fit into the native representation of JSON, so let's use that + javaType.appendEncodedString( appender, value ); + break; + case SqlTypes.CHAR: + case SqlTypes.NCHAR: + case SqlTypes.VARCHAR: + case SqlTypes.NVARCHAR: + if ( value instanceof Boolean booleanValue ) { + // BooleanJavaType has this as an implicit conversion + appender.append( '"' ); + appender.append( booleanValue ? 'Y' : 'N' ); + appender.append( '"' ); + break; + } + case SqlTypes.LONGVARCHAR: + case SqlTypes.LONGNVARCHAR: + case SqlTypes.LONG32VARCHAR: + case SqlTypes.LONG32NVARCHAR: + case SqlTypes.CLOB: + case SqlTypes.MATERIALIZED_CLOB: + case SqlTypes.NCLOB: + case SqlTypes.MATERIALIZED_NCLOB: + case SqlTypes.ENUM: + case SqlTypes.NAMED_ENUM: + // These literals can contain the '"' character, so we need to escape it + appender.append( '"' ); + appender.startEscaping(); + javaType.appendEncodedString( appender, value ); + appender.endEscaping(); + appender.append( '"' ); + break; + case SqlTypes.DATE: + appender.append( '"' ); + JdbcDateJavaType.INSTANCE.appendEncodedString( + appender, + javaType.unwrap( value, java.sql.Date.class, options ) + ); + appender.append( '"' ); + break; + case SqlTypes.TIME: + case SqlTypes.TIME_WITH_TIMEZONE: + case SqlTypes.TIME_UTC: + appender.append( '"' ); + JdbcTimeJavaType.INSTANCE.appendEncodedString( + appender, + javaType.unwrap( value, java.sql.Time.class, options ) + ); + appender.append( '"' ); + break; + case SqlTypes.TIMESTAMP: + appender.append( '"' ); + JdbcTimestampJavaType.INSTANCE.appendEncodedString( + appender, + javaType.unwrap( value, java.sql.Timestamp.class, options ) + ); + appender.append( '"' ); + break; + case SqlTypes.TIMESTAMP_WITH_TIMEZONE: + case SqlTypes.TIMESTAMP_UTC: + appender.append( '"' ); + DateTimeFormatter.ISO_OFFSET_DATE_TIME.formatTo( + javaType.unwrap( value, OffsetDateTime.class, options ), + appender + ); + appender.append( '"' ); + break; + case SqlTypes.DECIMAL: + case SqlTypes.NUMERIC: + case SqlTypes.DURATION: + case SqlTypes.UUID: + // These types need to be serialized as JSON string, but don't have a need for escaping + appender.append( '"' ); + javaType.appendEncodedString( appender, value ); + appender.append( '"' ); + break; + case SqlTypes.BINARY: + case SqlTypes.VARBINARY: + case SqlTypes.LONGVARBINARY: + case SqlTypes.LONG32VARBINARY: + case SqlTypes.BLOB: + case SqlTypes.MATERIALIZED_BLOB: + // These types need to be serialized as JSON string, and for efficiency uses appendString directly + appender.append( '"' ); + appender.write( javaType.unwrap( value, byte[].class, options ) ); + appender.append( '"' ); + break; + case SqlTypes.ARRAY: + case SqlTypes.JSON_ARRAY: + final int length = Array.getLength( value ); + appender.append( '[' ); + if ( length != 0 ) { + //noinspection unchecked + final JavaType elementJavaType = ( (BasicPluralJavaType) javaType ).getElementJavaType(); + final JdbcType elementJdbcType = ( (ArrayJdbcType) jdbcType ).getElementJdbcType(); + Object arrayElement = Array.get( value, 0 ); + convertedValueToString( elementJavaType, elementJdbcType, arrayElement, options, appender ); + for ( int i = 1; i < length; i++ ) { + arrayElement = Array.get( value, i ); + appender.append( ',' ); + convertedValueToString( elementJavaType, elementJdbcType, arrayElement, options, appender ); + } + } + appender.append( ']' ); + break; + default: + throw new UnsupportedOperationException( "Unsupported JdbcType nested in JSON: " + jdbcType ); + } + } + + public static X fromString( + EmbeddableMappingType embeddableMappingType, + String string, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + if ( string == null ) { + return null; + } + + final int jdbcValueCount = embeddableMappingType.getJdbcValueCount(); + final Object[] values = new Object[jdbcValueCount + ( embeddableMappingType.isPolymorphic() ? 1 : 0 )]; + final int end = fromString( embeddableMappingType, string, 0, string.length(), values, returnEmbeddable, options ); + assert string.substring( end ).isBlank(); + if ( returnEmbeddable ) { + final StructAttributeValues attributeValues = StructHelper.getAttributeValues( + embeddableMappingType, + values, + options + ); + //noinspection unchecked + return (X) instantiate( embeddableMappingType, attributeValues ); + } + //noinspection unchecked + return (X) values; + } + + // This is also used by Hibernate Reactive + public static X arrayFromString( + JavaType javaType, + JdbcType elementJdbcType, + String string, + WrapperOptions options) throws SQLException { + if ( string == null ) { + return null; + } + final JavaType elementJavaType = ((BasicPluralJavaType) javaType).getElementJavaType(); + final Class preferredJavaTypeClass = elementJdbcType.getPreferredJavaTypeClass( options ); + final JavaType jdbcJavaType; + if ( preferredJavaTypeClass == null || preferredJavaTypeClass == elementJavaType.getJavaTypeClass() ) { + jdbcJavaType = elementJavaType; + } + else { + jdbcJavaType = options.getTypeConfiguration().getJavaTypeRegistry().resolveDescriptor( preferredJavaTypeClass ); + } + final CustomArrayList arrayList = new CustomArrayList(); + final int i = fromArrayString( + string, + false, + options, + 0, + arrayList, + elementJavaType, + jdbcJavaType, + elementJdbcType + ); + assert string.charAt( i - 1 ) == ']'; + return javaType.wrap( arrayList, options ); + } + + private static int fromString( + EmbeddableMappingType embeddableMappingType, + String string, + int begin, + int end, + Object[] values, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + boolean hasEscape = false; + assert string.charAt( begin ) == '{'; + int start = begin + 1; + State s = State.KEY_START; + int selectableIndex = -1; + // The following parsing logic assumes JSON is well-formed, + // but for the sake of the Java compiler's flow analysis + // and hopefully also for a better understanding, contains throws for some syntax errors + for ( int i = start; i < string.length(); i++ ) { + final char c = string.charAt( i ); + switch ( c ) { + case '\\': + assert s == State.KEY_QUOTE || s == State.VALUE_QUOTE; + hasEscape = true; + i++; + break; + case '"': + switch ( s ) { + case KEY_START: + s = State.KEY_QUOTE; + selectableIndex = -1; + start = i + 1; + hasEscape = false; + break; + case KEY_QUOTE: + s = State.KEY_END; + selectableIndex = getSelectableMapping( + embeddableMappingType, + string, + start, + i, + hasEscape + ); + start = -1; + hasEscape = false; + break; + case VALUE_START: + s = State.VALUE_QUOTE; + start = i + 1; + hasEscape = false; + break; + case VALUE_QUOTE: + s = State.VALUE_END; + values[selectableIndex] = fromString( + embeddableMappingType.getJdbcValueSelectable( selectableIndex ).getJdbcMapping(), + string, + start, + i, + hasEscape, + returnEmbeddable, + options + ); + selectableIndex = -1; + start = -1; + hasEscape = false; + break; + default: + throw syntaxError( string, s, i ); + } + break; + case ':': + switch ( s ) { + case KEY_QUOTE: + // I guess it's ok to have a ':' in the key.. + case VALUE_QUOTE: + // In the value it's fine + break; + case KEY_END: + s = State.VALUE_START; + break; + default: + throw syntaxError( string, s, i ); + } + break; + case ',': + switch ( s ) { + case KEY_QUOTE: + // I guess it's ok to have a ',' in the key.. + case VALUE_QUOTE: + // In the value it's fine + break; + case VALUE_END: + s = State.KEY_START; + break; + default: + throw syntaxError( string, s, i ); + } + break; + case '{': + switch ( s ) { + case KEY_QUOTE: + // I guess it's ok to have a '{' in the key.. + case VALUE_QUOTE: + // In the value it's fine + break; + case VALUE_START: + final SelectableMapping selectable = embeddableMappingType.getJdbcValueSelectable( + selectableIndex + ); + if ( !( selectable.getJdbcMapping().getJdbcType() + instanceof AggregateJdbcType aggregateJdbcType) ) { + throw new IllegalArgumentException( + String.format( + "JSON starts sub-object for a non-aggregate type at index %d. Selectable [%s] is of type [%s]", + i, + selectable.getSelectableName(), + selectable.getJdbcMapping().getJdbcType().getClass().getName() + ) + ); + } + final EmbeddableMappingType subMappingType = aggregateJdbcType.getEmbeddableMappingType(); + // This encoding is only possible if the JDBC type is JSON again + assert aggregateJdbcType.getJdbcTypeCode() == SqlTypes.JSON + || aggregateJdbcType.getDefaultSqlTypeCode() == SqlTypes.JSON; + final Object[] subValues = new Object[subMappingType.getJdbcValueCount()]; + i = fromString( subMappingType, string, i, end, subValues, returnEmbeddable, options ) - 1; + assert string.charAt( i ) == '}'; + if ( returnEmbeddable ) { + final StructAttributeValues attributeValues = StructHelper.getAttributeValues( + subMappingType, + subValues, + options + ); + values[selectableIndex] = instantiate( embeddableMappingType, attributeValues ); + } + else { + values[selectableIndex] = subValues; + } + s = State.VALUE_END; + selectableIndex = -1; + break; + default: + throw syntaxError( string, s, i ); + } + break; + case '[': + switch ( s ) { + case KEY_QUOTE: + // I guess it's ok to have a '[' in the key.. + case VALUE_QUOTE: + // In the value it's fine + break; + case VALUE_START: + final SelectableMapping selectable = embeddableMappingType.getJdbcValueSelectable( + selectableIndex + ); + final JdbcMapping jdbcMapping = selectable.getJdbcMapping(); + if ( !(jdbcMapping instanceof BasicPluralType pluralType) ) { + throw new IllegalArgumentException( + String.format( + "JSON starts array for a non-plural type at index %d. Selectable [%s] is of type [%s]", + i, + selectable.getSelectableName(), + jdbcMapping.getJdbcType().getClass().getName() + ) + ); + } + final BasicType elementType = pluralType.getElementType(); + final CustomArrayList arrayList = new CustomArrayList(); + i = fromArrayString( string, returnEmbeddable, options, i, arrayList, elementType ) - 1; + assert string.charAt( i ) == ']'; + values[selectableIndex] = pluralType.getJdbcJavaType().wrap( arrayList, options ); + s = State.VALUE_END; + selectableIndex = -1; + break; + default: + throw syntaxError( string, s, i ); + } + break; + case '}': + switch ( s ) { + case KEY_QUOTE: + // I guess it's ok to have a '}' in the key.. + case VALUE_QUOTE: + // In the value it's fine + break; + case VALUE_END: + // At this point, we are done + return i + 1; + default: + throw syntaxError( string, s, i ); + } + break; + default: + switch ( s ) { + case KEY_QUOTE: + case VALUE_QUOTE: + // In keys and values, all chars are fine + break; + case VALUE_START: + // Skip whitespace + if ( Character.isWhitespace( c ) ) { + break; + } + // Here we also allow certain literals + final int endIdx = consumeLiteral( + string, + i, + values, + embeddableMappingType.getJdbcValueSelectable( selectableIndex ).getJdbcMapping(), + selectableIndex, + returnEmbeddable, + options + ); + if ( endIdx != -1 ) { + i = endIdx; + s = State.VALUE_END; + selectableIndex = -1; + start = -1; + break; + } + throw syntaxError( string, s, i ); + case KEY_START: + case KEY_END: + case VALUE_END: + // Only whitespace is allowed here + if ( Character.isWhitespace( c ) ) { + break; + } + default: + throw syntaxError( string, s, i ); + } + break; + } + } + + throw new IllegalArgumentException( "JSON not properly formed: " + string.subSequence( start, end ) ); + } + + private static int fromArrayString( + String string, + boolean returnEmbeddable, + WrapperOptions options, + int begin, + CustomArrayList arrayList, + BasicType elementType) throws SQLException { + return fromArrayString( + string, + returnEmbeddable, + options, + begin, + arrayList, + elementType.getMappedJavaType(), + elementType.getJdbcJavaType(), + elementType.getJdbcType() + ); + } + + private static int fromArrayString( + String string, + boolean returnEmbeddable, + WrapperOptions options, + int begin, + CustomArrayList arrayList, + JavaType javaType, + JavaType jdbcJavaType, + JdbcType jdbcType) throws SQLException { + if ( string.length() == begin + 2 ) { + return begin + 2; + } + boolean hasEscape = false; + assert string.charAt( begin ) == '['; + int start = begin + 1; + State s = State.VALUE_START; + // The following parsing logic assumes JSON is well-formed, + // but for the sake of the Java compiler's flow analysis + // and hopefully also for a better understanding, contains throws for some syntax errors + for ( int i = start; i < string.length(); i++ ) { + final char c = string.charAt( i ); + switch ( c ) { + case '\\': + assert s == State.VALUE_QUOTE; + hasEscape = true; + i++; + break; + case '"': + switch ( s ) { + case VALUE_START: + s = State.VALUE_QUOTE; + start = i + 1; + hasEscape = false; + break; + case VALUE_QUOTE: + s = State.VALUE_END; + arrayList.add( + fromString( + javaType, + jdbcJavaType, + jdbcType, + string, + start, + i, + hasEscape, + returnEmbeddable, + options + ) + ); + start = -1; + hasEscape = false; + break; + default: + throw syntaxError( string, s, i ); + } + break; + case ',': + switch ( s ) { + case VALUE_QUOTE: + // In the value it's fine + break; + case VALUE_END: + s = State.VALUE_START; + break; + default: + throw syntaxError( string, s, i ); + } + break; + case '{': + switch ( s ) { + case VALUE_QUOTE: + // In the value it's fine + break; +// case VALUE_START: +// final SelectableMapping selectable = embeddableMappingType.getJdbcValueSelectable( +// selectableIndex +// ); +// if ( !( selectable.getJdbcMapping().getJdbcType() instanceof AggregateJdbcType ) ) { +// throw new IllegalArgumentException( +// String.format( +// "JSON starts sub-object for a non-aggregate type at index %d. Selectable [%s] is of type [%s]", +// i, +// selectable.getSelectableName(), +// selectable.getJdbcMapping().getJdbcType().getClass().getName() +// ) +// ); +// } +// final AggregateJdbcType aggregateJdbcType = (AggregateJdbcType) selectable.getJdbcMapping().getJdbcType(); +// final EmbeddableMappingType subMappingType = aggregateJdbcType.getEmbeddableMappingType(); +// // This encoding is only possible if the JDBC type is JSON again +// assert aggregateJdbcType.getJdbcTypeCode() == SqlTypes.JSON +// || aggregateJdbcType.getDefaultSqlTypeCode() == SqlTypes.JSON; +// final Object[] subValues = new Object[subMappingType.getJdbcValueCount()]; +// i = fromString( subMappingType, string, i, end, subValues, returnEmbeddable, options ) - 1; +// assert string.charAt( i ) == '}'; +// if ( returnEmbeddable ) { +// final Object[] attributeValues = StructHelper.getAttributeValues( +// subMappingType, +// subValues, +// options +// ); +// values[selectableIndex] = embeddableMappingType.getRepresentationStrategy() +// .getInstantiator() +// .instantiate( +// () -> attributeValues, +// options.getSessionFactory() +// ); +// } +// else { +// values[selectableIndex] = subValues; +// } +// s = State.VALUE_END; +// selectableIndex = -1; +// break; + default: + throw syntaxError( string, s, i ); + } + break; + case ']': + switch ( s ) { + case VALUE_QUOTE: + // In the value it's fine + break; + case VALUE_END: + // At this point, we are done + return i + 1; + default: + throw syntaxError( string, s, i ); + } + break; + default: + switch ( s ) { + case VALUE_QUOTE: + // In keys and values, all chars are fine + break; + case VALUE_START: + // Skip whitespace + if ( Character.isWhitespace( c ) ) { + break; + } + final int elementIndex = arrayList.size(); + arrayList.add( null ); + // Here we also allow certain literals + final int endIdx = consumeLiteral( + string, + i, + arrayList.getUnderlyingArray(), + javaType, + jdbcJavaType, + jdbcType, + elementIndex, + returnEmbeddable, + options + ); + if ( endIdx != -1 ) { + i = endIdx; + s = State.VALUE_END; + start = -1; + break; + } + throw syntaxError( string, s, i ); + case VALUE_END: + // Only whitespace is allowed here + if ( Character.isWhitespace( c ) ) { + break; + } + default: + throw syntaxError( string, s, i ); + } + break; + } + } + + throw new IllegalArgumentException( "JSON not properly formed: " + string.subSequence( start, string.length() ) ); + } + + private static int consumeLiteral( + String string, + int start, + Object[] values, + JdbcMapping jdbcMapping, + int selectableIndex, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + return consumeLiteral( + string, + start, + values, + jdbcMapping.getMappedJavaType(), + jdbcMapping.getJdbcJavaType(), + jdbcMapping.getJdbcType(), + selectableIndex, + returnEmbeddable, + options + ); + } + + private static int consumeLiteral( + String string, + int start, + Object[] values, + JavaType javaType, + JavaType jdbcJavaType, + JdbcType jdbcType, + int selectableIndex, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + final char c = string.charAt( start ); + switch ( c ) { + case 'n': + // only null is possible + values[selectableIndex] = null; + return consume(string, start, "null"); + case 'f': + // only false is possible + values[selectableIndex] = false; + return consume(string, start, "false"); + case 't': + // only false is possible + values[selectableIndex] = true; + return consume(string, start, "true"); + case '0': + switch ( string.charAt( start + 1 ) ) { + case '.': + return consumeFractional( + string, + start, + start + 1, + values, + javaType, + jdbcJavaType, + jdbcType, + selectableIndex, + returnEmbeddable, + options + ); + case 'E': + case 'e': + return consumeExponential( + string, + start, + start + 1, + values, + javaType, + jdbcJavaType, + jdbcType, + selectableIndex, + returnEmbeddable, + options + ); + } + values[selectableIndex] = fromString( + javaType, + jdbcJavaType, + jdbcType, + string, + start, + start + 1, + returnEmbeddable, + options + ); + return start; + case '-': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + // number = [ minus ] int [ frac ] [ exp ] + // decimal-point = %x2E ; . + // digit1-9 = %x31-39 ; 1-9 + // e = %x65 / %x45 ; e E + // exp = e [ minus / plus ] 1*DIGIT + // frac = decimal-point 1*DIGIT + // int = zero / ( digit1-9 *DIGIT ) + // minus = %x2D ; - + // plus = %x2B ; + + // zero = %x30 ; 0 + for (int i = start + 1; i < string.length(); i++) { + final char digit = string.charAt( i ); + switch ( digit ) { + case '.': + return consumeFractional( + string, + start, + i, + values, + javaType, + jdbcJavaType, + jdbcType, + selectableIndex, + returnEmbeddable, + options + ); + case 'E': + case 'e': + return consumeExponential( + string, + start, + i, + values, + javaType, + jdbcJavaType, + jdbcType, + selectableIndex, + returnEmbeddable, + options + ); + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + break; + default: + values[selectableIndex] = fromString( + javaType, + jdbcJavaType, + jdbcType, + string, + start, + i, + returnEmbeddable, + options + ); + return i - 1; + } + } + } + + return -1; + } + + private static int consumeFractional( + String string, + int start, + int dotIndex, + Object[] values, + JavaType javaType, + JavaType jdbcJavaType, + JdbcType jdbcType, + int selectableIndex, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + for (int i = dotIndex + 1; i < string.length(); i++) { + final char digit = string.charAt( i ); + switch ( digit ) { + case 'E': + case 'e': + return consumeExponential( + string, + start, + i, + values, + javaType, + jdbcJavaType, + jdbcType, + selectableIndex, + returnEmbeddable, + options + ); + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + break; + default: + values[selectableIndex] = fromString( + javaType, + jdbcJavaType, + jdbcType, + string, + start, + i, + returnEmbeddable, + options + ); + return i - 1; + } + } + return start; + } + + private static int consumeExponential( + String string, + int start, + int eIndex, + Object[] values, + JavaType javaType, + JavaType jdbcJavaType, + JdbcType jdbcType, + int selectableIndex, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + int i = eIndex + 1; + switch ( string.charAt( i ) ) { + case '-': + case '+': + i++; + break; + } + for (; i < string.length(); i++) { + final char digit = string.charAt( i ); + switch ( digit ) { + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': + break; + default: + values[selectableIndex] = fromString( + javaType, + jdbcJavaType, + jdbcType, + string, + start, + i, + returnEmbeddable, + options + ); + return i - 1; + } + } + return start; + } + + private static int consume(String string, int start, String text) { + if ( !string.regionMatches( start + 1, text, 1, text.length() - 1 ) ) { + throw new IllegalArgumentException( + String.format( + "Syntax error at position %d. Unexpected char [%s]. Expecting [%s]", + start + 1, + string.charAt( start + 1 ), + text + ) + ); + } + return start + text.length() - 1; + } + + private static IllegalArgumentException syntaxError(String string, State s, int charIndex) { + return new IllegalArgumentException( + String.format( + "Syntax error at position %d. Unexpected char [%s]. Expecting one of [%s]", + charIndex, + string.charAt( charIndex ), + s.expectedChars() + ) + ); + } + + private static int getSelectableMapping( + EmbeddableMappingType embeddableMappingType, + String string, + int start, + int end, + boolean hasEscape) { + final String name = hasEscape + ? unescape( string, start, end ) + : string.substring( start, end ); + final int selectableIndex = embeddableMappingType.getSelectableIndex( name ); + if ( selectableIndex == -1 ) { + throw new IllegalArgumentException( + String.format( + "Could not find selectable [%s] in embeddable type [%s] for JSON processing.", + name, + embeddableMappingType.getMappedJavaType().getJavaTypeClass().getName() + ) + ); + } + return selectableIndex; + } + + private static Object fromString( + JdbcMapping jdbcMapping, + String string, + int start, + int end, + boolean hasEscape, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + return fromString( + jdbcMapping.getMappedJavaType(), + jdbcMapping.getJdbcJavaType(), + jdbcMapping.getJdbcType(), + string, + start, + end, + hasEscape, + returnEmbeddable, + options + ); + } + + private static Object fromString( + JavaType javaType, + JavaType jdbcJavaType, + JdbcType jdbcType, + String string, + int start, + int end, + boolean hasEscape, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + if ( hasEscape ) { + final String unescaped = unescape( string, start, end ); + return fromString( + javaType, + jdbcJavaType, + jdbcType, + unescaped, + 0, + unescaped.length(), + returnEmbeddable, + options + ); + } + return fromString( + javaType, + jdbcJavaType, + jdbcType, + string, + start, + end, + returnEmbeddable, + options + ); + } + + private static Object fromString( + JavaType javaType, + JavaType jdbcJavaType, + JdbcType jdbcType, + String string, + int start, + int end, + boolean returnEmbeddable, + WrapperOptions options) throws SQLException { + switch ( jdbcType.getDefaultSqlTypeCode() ) { + case SqlTypes.BINARY: + case SqlTypes.VARBINARY: + case SqlTypes.LONGVARBINARY: + case SqlTypes.LONG32VARBINARY: + return jdbcJavaType.wrap( + PrimitiveByteArrayJavaType.INSTANCE.fromEncodedString( + string, + start, + end + ), + options + ); + case SqlTypes.UUID: + return jdbcJavaType.wrap( + PrimitiveByteArrayJavaType.INSTANCE.fromString( + string.substring( start, end ).replace( "-", "" ) + ), + options + ); + case SqlTypes.DATE: + return jdbcJavaType.wrap( + JdbcDateJavaType.INSTANCE.fromEncodedString( + string, + start, + end + ), + options + ); + case SqlTypes.TIME: + case SqlTypes.TIME_WITH_TIMEZONE: + case SqlTypes.TIME_UTC: + return jdbcJavaType.wrap( + JdbcTimeJavaType.INSTANCE.fromEncodedString( + string, + start, + end + ), + options + ); + case SqlTypes.TIMESTAMP: + return jdbcJavaType.wrap( + JdbcTimestampJavaType.INSTANCE.fromEncodedString( + string, + start, + end + ), + options + ); + case SqlTypes.TIMESTAMP_WITH_TIMEZONE: + case SqlTypes.TIMESTAMP_UTC: + return jdbcJavaType.wrap( + OffsetDateTimeJavaType.INSTANCE.fromEncodedString( + string, + start, + end + ), + options + ); + case SqlTypes.TINYINT: + case SqlTypes.SMALLINT: + case SqlTypes.INTEGER: + if ( jdbcJavaType.getJavaTypeClass() == Boolean.class ) { + return jdbcJavaType.wrap( Integer.parseInt( string, start, end, 10 ), options ); + } + else if ( jdbcJavaType instanceof EnumJavaType ) { + return jdbcJavaType.wrap( Integer.parseInt( string, start, end, 10 ), options ); + } + case SqlTypes.CHAR: + case SqlTypes.NCHAR: + case SqlTypes.VARCHAR: + case SqlTypes.NVARCHAR: + if ( jdbcJavaType.getJavaTypeClass() == Boolean.class && end == start + 1 ) { + return jdbcJavaType.wrap( string.charAt( start ), options ); + } + default: + if ( jdbcType instanceof AggregateJdbcType aggregateJdbcType ) { + final Object[] subValues = aggregateJdbcType.extractJdbcValues( + CharSequenceHelper.subSequence( + string, + start, + end + ), + options + ); + if ( returnEmbeddable ) { + final StructAttributeValues subAttributeValues = StructHelper.getAttributeValues( + aggregateJdbcType.getEmbeddableMappingType(), + subValues, + options + ); + return instantiate( aggregateJdbcType.getEmbeddableMappingType(), subAttributeValues ) ; + } + return subValues; + } + + return jdbcJavaType.fromEncodedString( string, start, end ); + } + } + + private static String unescape(String string, int start, int end) { + final StringBuilder sb = new StringBuilder( end - start ); + for ( int i = start; i < end; i++ ) { + final char c = string.charAt( i ); + if ( c == '\\' ) { + i++; + final char cNext = string.charAt( i ); + switch ( cNext ) { + case '\\': + case '"': + case '/': + sb.append( cNext ); + break; + case 'b': + sb.append( '\b' ); + break; + case 'f': + sb.append( '\f' ); + break; + case 'n': + sb.append( '\n' ); + break; + case 'r': + sb.append( '\r' ); + break; + case 't': + sb.append( '\t' ); + break; + case 'u': + sb.append( (char) Integer.parseInt( string, i + 1, i + 5, 16 ) ); + i += 4; + break; + } + continue; + } + sb.append( c ); + } + return sb.toString(); + } + + enum State { + KEY_START( "\"\\s" ), + KEY_QUOTE( "" ), + KEY_END( ":\\s" ), + VALUE_START( "\"\\s" ), + VALUE_QUOTE( "" ), + VALUE_END( ",}\\s" ); + + final String expectedChars; + + State(String expectedChars) { + this.expectedChars = expectedChars; + } + + String expectedChars() { + return expectedChars; + } + } + + public static class JsonAppender extends OutputStream implements SqlAppender { + + private final static char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + + private final StringBuilder sb; + private final boolean expandProperties; + + private boolean escape; + private Map> circularityTracker; + + public JsonAppender(StringBuilder sb, boolean expandProperties) { + this.sb = sb; + this.expandProperties = expandProperties; + } + + public boolean expandProperties() { + return expandProperties; + } + + @Override + public void appendSql(String fragment) { + append( fragment ); + } + + @Override + public void appendSql(char fragment) { + append( fragment ); + } + + @Override + public void appendSql(int value) { + sb.append( value ); + } + + @Override + public void appendSql(long value) { + sb.append( value ); + } + + @Override + public void appendSql(boolean value) { + sb.append( value ); + } + + @Override + public String toString() { + return sb.toString(); + } + + public void startEscaping() { + assert !escape; + escape = true; + } + + public void endEscaping() { + assert escape; + escape = false; + } + + @Override + public JsonAppender append(char fragment) { + if ( escape ) { + appendEscaped( fragment ); + } + else { + sb.append( fragment ); + } + return this; + } + + @Override + public JsonAppender append(CharSequence csq) { + return append( csq, 0, csq.length() ); + } + + @Override + public JsonAppender append(CharSequence csq, int start, int end) { + if ( escape ) { + int len = end - start; + sb.ensureCapacity( sb.length() + len ); + for ( int i = start; i < end; i++ ) { + appendEscaped( csq.charAt( i ) ); + } + } + else { + sb.append( csq, start, end ); + } + return this; + } + + @Override + public void write(int v) { + final String hex = Integer.toHexString( v ); + sb.ensureCapacity( sb.length() + hex.length() + 1 ); + if ( ( hex.length() & 1 ) == 1 ) { + sb.append( '0' ); + } + sb.append( hex ); + } + + @Override + public void write(byte[] bytes) { + write(bytes, 0, bytes.length); + } + + @Override + public void write(byte[] bytes, int off, int len) { + sb.ensureCapacity( sb.length() + ( len << 1 ) ); + for ( int i = 0; i < len; i++ ) { + final int v = bytes[off + i] & 0xFF; + sb.append( HEX_ARRAY[v >>> 4] ); + sb.append( HEX_ARRAY[v & 0x0F] ); + } + } + + /** + * Tracks the provided {@code entity} instance and invokes the {@code action} with either + * {@code true} if the entity was not already encountered or {@code false} otherwise. + * + * @param entity the entity instance to track + * @param entityType the type of the entity instance + * @param action the action to invoke while tracking the entity + */ + public void trackingEntity(Object entity, EntityMappingType entityType, Consumer action) { + if ( circularityTracker == null ) { + circularityTracker = new HashMap<>(); + } + final IdentitySet entities = circularityTracker.computeIfAbsent( + entityType.getEntityName(), + k -> new IdentitySet<>() + ); + final boolean added = entities.add( entity ); + action.accept( added ); + if ( added ) { + entities.remove( entity ); + } + } + + private void appendEscaped(char fragment) { + switch ( fragment ) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + // 8 is '\b' + // 9 is '\t' + // 10 is '\n' + case 11: + // 12 is '\f' + // 13 is '\r' + case 14: + case 15: + case 16: + case 17: + case 18: + case 19: + case 20: + case 21: + case 22: + case 23: + case 24: + case 25: + case 26: + case 27: + case 28: + case 29: + case 30: + case 31: + sb.append( "\\u" ).append( Integer.toHexString( fragment ) ); + break; + case '\b': + sb.append("\\b"); + break; + case '\t': + sb.append("\\t"); + break; + case '\n': + sb.append("\\n"); + break; + case '\f': + sb.append("\\f"); + break; + case '\r': + sb.append("\\r"); + break; + case '"': + sb.append( "\\\"" ); + break; + case '\\': + sb.append( "\\\\" ); + break; + default: + sb.append( fragment ); + break; + } + } + } + + private static class CustomArrayList extends AbstractCollection implements Collection { + Object[] array = ArrayHelper.EMPTY_OBJECT_ARRAY; + int size; + + public void ensureCapacity(int minCapacity) { + int oldCapacity = array.length; + if ( minCapacity > oldCapacity ) { + int newCapacity = oldCapacity + ( oldCapacity >> 1 ); + newCapacity = Math.max( Math.max( newCapacity, minCapacity ), 10 ); + array = Arrays.copyOf( array, newCapacity ); + } + } + + public Object[] getUnderlyingArray() { + return array; + } + + @Override + public int size() { + return size; + } + + @Override + public boolean add(Object o) { + if ( size == array.length ) { + ensureCapacity( size + 1 ); + } + array[size++] = o; + return true; + } + + @Override + public boolean isEmpty() { + return size == 0; + } + + @Override + public boolean contains(Object o) { + for ( int i = 0; i < size; i++ ) { + if ( Objects.equals(o, array[i] ) ) { + return true; + } + } + return false; + } + + @Override + public Iterator iterator() { + return new Iterator<>() { + int index; + @Override + public boolean hasNext() { + return index != size; + } + + @Override + public Object next() { + if ( index == size ) { + throw new NoSuchElementException(); + } + return array[index++]; + } + }; + } + + @Override + public Object[] toArray() { + return Arrays.copyOf( array, size ); + } + + @Override + @AllowReflection // We need the ability to create arrays of requested types dynamically. + public T[] toArray(T[] a) { + //noinspection unchecked + final T[] r = a.length >= size + ? a + : (T[]) Array.newInstance( a.getClass().getComponentType(), size ); + for (int i = 0; i < size; i++) { + //noinspection unchecked + r[i] = (T) array[i]; + } + return null; + } + } + +} diff --git a/language/src/main/java/org/hibernate/tool/language/internal/MetamodelJsonSerializerImpl.java b/language/src/main/java/org/hibernate/tool/language/internal/MetamodelJsonSerializerImpl.java new file mode 100644 index 0000000000..bedee63ddf --- /dev/null +++ b/language/src/main/java/org/hibernate/tool/language/internal/MetamodelJsonSerializerImpl.java @@ -0,0 +1,192 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language.internal; + +import org.hibernate.metamodel.model.domain.ManagedDomainType; +import org.hibernate.tool.language.spi.MetamodelSerializer; + +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.EmbeddableType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.IdentifiableType; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.MapAttribute; +import jakarta.persistence.metamodel.MappedSuperclassType; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.metamodel.PluralAttribute; +import jakarta.persistence.metamodel.SingularAttribute; +import jakarta.persistence.metamodel.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.hibernate.tool.language.internal.JsonHelper.JsonAppender; + +/** + * Implementation of {@link MetamodelSerializer} that represents the {@link Metamodel} as a JSON array of mapped objects. + */ +public class MetamodelJsonSerializerImpl implements MetamodelSerializer { + public static MetamodelJsonSerializerImpl INSTANCE = new MetamodelJsonSerializerImpl(); + + /** + * Utility method that generates a JSON string representation of the mapping information + * contained in the provided {@link Metamodel metamodel} instance. The representation + * does not follow a strict scheme, and is more akin to natural language, as it's + * mainly meant for consumption by a LLM. + * + * @param metamodel the metamodel instance containing information on the persistence structures + * + * @return the JSON representation of the provided {@link Metamodel metamodel} + */ + @Override + public String toString(Metamodel metamodel) { + final List entities = new ArrayList<>(); + final List embeddables = new ArrayList<>(); + final List mappedSupers = new ArrayList<>(); + for ( ManagedType managedType : metamodel.getManagedTypes() ) { + switch ( managedType.getPersistenceType() ) { + case ENTITY -> entities.add( getEntityTypeDescription( (EntityType) managedType ) ); + case EMBEDDABLE -> embeddables.add( getEmbeddableTypeDescription( (EmbeddableType) managedType ) ); + case MAPPED_SUPERCLASS -> mappedSupers.add( getMappedSuperclassTypeDescription( (MappedSuperclassType) managedType ) ); + default -> + throw new IllegalStateException( "Unexpected persistence type for managed type [" + managedType + "]" ); + } + } + return toJson( Map.of( + "entities", entities, + "mappedSuperclasses", mappedSupers, + "embeddables", embeddables + ) ); + } + + private static String toJson(Collection strings) { + return strings.isEmpty() ? "[]" : "[" + String.join( ",", strings ) + "]"; + } + + private static String toJson(Map map) { + if ( map.isEmpty() ) { + return "{}"; + } + final StringBuilder sb = new StringBuilder( "{" ); + final JsonAppender appender = new JsonAppender( sb, false ); + for ( final var entry : map.entrySet() ) { + appender.append( "\"" ).append( entry.getKey() ).append( "\":" ); + final Object value = entry.getValue(); + if ( value instanceof String strValue ) { + appender.append( "\"" ); + appender.startEscaping(); + appender.append( strValue ); + appender.endEscaping(); + appender.append( "\"" ); + } + else if ( value instanceof Collection collection ) { + //noinspection unchecked + appender.append( toJson( (Collection) collection ) ); + } + else if ( value instanceof Number || value instanceof Boolean ) { + appender.append( value.toString() ); + } + else if ( value == null ) { + appender.append( "null" ); + } + else { + throw new IllegalArgumentException( "Unsupported value type: " + value.getClass().getName() ); + } + appender.append( "," ); + } + return sb.deleteCharAt( sb.length() - 1 ).append( '}' ).toString(); + } + + private static void putIfNotNull(Map map, String key, Object value) { + if ( value != null ) { + map.put( key, value ); + } + } + + private static String getEntityTypeDescription(EntityType entityType) { + final Map map = new HashMap<>( 5 ); + map.put( "name", entityType.getName() ); + map.put( "class", entityType.getJavaType().getTypeName() ); + putIfNotNull( map, "superType", superTypeDescriptor( (ManagedDomainType) entityType ) ); + putIfNotNull( map, "identifierAttribute", identifierDescriptor( entityType ) ); + map.put( "attributes", attributeArray( entityType.getAttributes() ) ); + return toJson( map ); + } + + private static String superTypeDescriptor(ManagedDomainType managedType) { + final ManagedDomainType superType = managedType.getSuperType(); + return superType != null ? superType.getJavaType().getTypeName() : null; + } + + private static String getMappedSuperclassTypeDescription(MappedSuperclassType mappedSuperclass) { + final Class javaType = mappedSuperclass.getJavaType(); + final Map map = new HashMap<>( 5 ); + map.put( "name", javaType.getSimpleName() ); + map.put( "class", javaType.getTypeName() ); + putIfNotNull( map, "superType", superTypeDescriptor( (ManagedDomainType) mappedSuperclass ) ); + putIfNotNull( map, "identifierAttribute", identifierDescriptor( mappedSuperclass ) ); + map.put( "attributes", attributeArray( mappedSuperclass.getAttributes() ) ); + return toJson( map ); + } + + private static String identifierDescriptor(IdentifiableType identifiableType) { + final Type idType = identifiableType.getIdType(); + if ( idType != null ) { + final SingularAttribute id = identifiableType.getId( idType.getJavaType() ); + return id.getName(); + } + else { + return null; + } + } + + private static String getEmbeddableTypeDescription(EmbeddableType embeddableType) { + final Class javaType = embeddableType.getJavaType(); + final Map map = new HashMap<>( 4 ); + map.put( "name", javaType.getSimpleName() ); + map.put( "class", javaType.getTypeName() ); + putIfNotNull( map, "superType", superTypeDescriptor( (ManagedDomainType) embeddableType ) ); + map.put( "attributes", attributeArray( embeddableType.getAttributes() ) ); + return toJson( map ); + } + + private static List attributeArray(Set> attributes) { + if ( attributes.isEmpty() ) { + return List.of(); + } + + final ArrayList result = new ArrayList<>( attributes.size() ); + for ( final Attribute attribute : attributes ) { + String attributeDescription = "{\"name\":\"" + attribute.getName() + + "\",\"type\":\"" + attribute.getJavaType().getTypeName(); + // add key and element types for plural attributes + if ( attribute instanceof PluralAttribute pluralAttribute ) { + attributeDescription += "<"; + final PluralAttribute.CollectionType collectionType = pluralAttribute.getCollectionType(); + if ( collectionType == PluralAttribute.CollectionType.MAP ) { + attributeDescription += ( (MapAttribute) pluralAttribute ).getKeyJavaType().getTypeName() + ","; + } + attributeDescription += pluralAttribute.getElementType().getJavaType().getTypeName() + ">"; + } + result.add( attributeDescription + "\"}" ); + } + return result; + } +} diff --git a/language/src/main/java/org/hibernate/tool/language/internal/ResultsJsonSerializerImpl.java b/language/src/main/java/org/hibernate/tool/language/internal/ResultsJsonSerializerImpl.java new file mode 100644 index 0000000000..228ad363d6 --- /dev/null +++ b/language/src/main/java/org/hibernate/tool/language/internal/ResultsJsonSerializerImpl.java @@ -0,0 +1,197 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language.internal; + +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.metamodel.mapping.CollectionPart; +import org.hibernate.metamodel.mapping.EmbeddableValuedModelPart; +import org.hibernate.metamodel.mapping.EntityValuedModelPart; +import org.hibernate.metamodel.mapping.PluralAttributeMapping; +import org.hibernate.metamodel.mapping.ValuedModelPart; +import org.hibernate.persister.entity.EntityPersister; +import org.hibernate.query.SelectionQuery; +import org.hibernate.query.sqm.SqmExpressible; +import org.hibernate.query.sqm.SqmSelectionQuery; +import org.hibernate.query.sqm.tree.SqmExpressibleAccessor; +import org.hibernate.query.sqm.tree.SqmStatement; +import org.hibernate.query.sqm.tree.domain.SqmPath; +import org.hibernate.query.sqm.tree.from.SqmRoot; +import org.hibernate.query.sqm.tree.select.SqmJpaCompoundSelection; +import org.hibernate.query.sqm.tree.select.SqmSelectStatement; +import org.hibernate.query.sqm.tree.select.SqmSelection; +import org.hibernate.tool.language.internal.JsonHelper.JsonAppender; +import org.hibernate.tool.language.spi.ResultsSerializer; + +import jakarta.persistence.Tuple; +import jakarta.persistence.criteria.Selection; +import java.util.List; + +import static org.hibernate.internal.util.NullnessUtil.castNonNull; + +/** + * Utility class to serialize query results into a JSON string format. + */ +public class ResultsJsonSerializerImpl implements ResultsSerializer { + private final SessionFactoryImplementor factory; + + public ResultsJsonSerializerImpl(SessionFactoryImplementor factory) { + this.factory = factory; + } + + @Override + public String toString(List values, SelectionQuery query) { + if ( values.isEmpty() ) { + return "[]"; + } + + final StringBuilder sb = new StringBuilder(); + final JsonAppender jsonAppender = new JsonAppender( sb, true ); + char separator = '['; + for ( final T value : values ) { + sb.append( separator ); + renderValue( value, (SqmSelectionQuery) query, jsonAppender ); + separator = ','; + } + sb.append( ']' ); + return sb.toString(); + } + + private void renderValue(T value, SqmSelectionQuery query, JsonAppender jsonAppender) { + final SqmStatement sqm = query.getSqmStatement(); + if ( !( sqm instanceof SqmSelectStatement sqmSelect ) ) { + throw new IllegalArgumentException( "Query is not a select statement." ); + } + final List> selections = sqmSelect.getQuerySpec().getSelectClause().getSelections(); + assert !selections.isEmpty(); + if ( selections.size() == 1 ) { + renderValue( value, selections.get( 0 ).getSelectableNode(), jsonAppender ); + } + else { + // wrap each result tuple in square brackets + char separator = '['; + for ( int i = 0; i < selections.size(); i++ ) { + jsonAppender.append( separator ); + final SqmSelection selection = selections.get( i ); + if ( value instanceof Object[] array ) { + renderValue( array[i], selection.getSelectableNode(), jsonAppender ); + } + else if ( value instanceof Tuple tuple ) { + renderValue( tuple.get( i ), selection.getSelectableNode(), jsonAppender ); + } + else { + // todo : might it be a compound selection ? + renderValue( value, selection.getSelectableNode(), jsonAppender ); + } + separator = ','; + } + jsonAppender.append( ']' ); + } + } + + private void renderValue(Object value, Selection selection, JsonAppender jsonAppender) { + if ( selection instanceof SqmRoot root ) { + final EntityPersister persister = factory.getMappingMetamodel() + .getEntityDescriptor( root.getEntityName() ); + JsonHelper.toString( + value, + persister.getEntityMappingType(), + factory.getWrapperOptions(), + jsonAppender + ); + } + else if ( selection instanceof SqmPath path ) { + // extract the attribute from the path + final ValuedModelPart subPart = getSubPart( path.getLhs(), path.getNavigablePath().getLocalName() ); + if ( subPart != null ) { + JsonHelper.toString( value, subPart, factory.getWrapperOptions(), jsonAppender, null ); + } + else { + jsonAppender.append( expressibleToString( path, value ) ); + } + } + else if ( selection instanceof SqmJpaCompoundSelection compoundSelection ) { + final List> compoundSelectionItems = compoundSelection.getCompoundSelectionItems(); + assert compoundSelectionItems.size() > 1; + char separator = '['; + for ( int j = 0; j < compoundSelectionItems.size(); j++ ) { + jsonAppender.append( separator ); + renderValue( getValue( value, j ), compoundSelectionItems.get( j ), jsonAppender ); + separator = ','; + } + jsonAppender.append( ']' ); + } + else if ( selection instanceof SqmExpressibleAccessor node ) { + jsonAppender.append( expressibleToString( node, value ) ); + } + else { + jsonAppender.append( "\"" ).append( value.toString() ).append( "\"" ); // best effort + } + } + + private static String expressibleToString(SqmExpressibleAccessor node, Object value) { + //noinspection unchecked + final SqmExpressible expressible = (SqmExpressible) node.getExpressible(); + final String result = expressible != null ? + expressible.getExpressibleJavaType().toString( value ) : + value.toString(); // best effort + // avoid quoting numbers as they can be represented in JSON + return value instanceof Number ? result : "\"" + result + "\""; + } + + private static Object getValue(Object value, int index) { + if ( value.getClass().isArray() ) { + return ( (Object[]) value )[index]; + } + else if ( value instanceof Tuple tuple ) { + return tuple.get( index ); + } + else { + if ( index > 0 ) { + throw new IllegalArgumentException( "Index out of range: " + index ); + } + return value; + } + } + + private ValuedModelPart getSubPart(SqmPath path, String propertyName) { + if ( path instanceof SqmRoot root ) { + final EntityPersister entityDescriptor = factory.getMappingMetamodel() + .getEntityDescriptor( root.getEntityName() ); + return entityDescriptor.findAttributeMapping( propertyName ); + } + else { + // try to derive the subpart from the lhs + final ValuedModelPart subPart = getSubPart( path.getLhs(), path.getNavigablePath().getLocalName() ); + if ( subPart instanceof EmbeddableValuedModelPart embeddable ) { + return embeddable.getEmbeddableTypeDescriptor().findAttributeMapping( propertyName ); + } + else if ( subPart instanceof EntityValuedModelPart entity ) { + return entity.getEntityMappingType().findAttributeMapping( propertyName ); + } + else if ( subPart instanceof PluralAttributeMapping plural ) { + final CollectionPart.Nature nature = castNonNull( CollectionPart.Nature.fromNameExact( propertyName ) ); + return switch ( nature ) { + case ELEMENT -> plural.getElementDescriptor(); + case ID -> plural.getIdentifierDescriptor(); + case INDEX -> plural.getIndexDescriptor(); + }; + } + } + return null; + } +} \ No newline at end of file diff --git a/language/src/main/java/org/hibernate/tool/language/spi/MetamodelSerializer.java b/language/src/main/java/org/hibernate/tool/language/spi/MetamodelSerializer.java new file mode 100644 index 0000000000..f70fc1f99a --- /dev/null +++ b/language/src/main/java/org/hibernate/tool/language/spi/MetamodelSerializer.java @@ -0,0 +1,39 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language.spi; + +import jakarta.persistence.metamodel.Metamodel; + +/** + * Contract used to provide the LLM with a textual representation of the + * Hibernate metamodel, that is, the classes and mapping information + * that constitute the persistence layer. + */ +public interface MetamodelSerializer { + /** + * Utility method that generates a textual representation of the mapping information + * contained in the provided {@link Metamodel metamodel} instance. The representation + * does not need to follow a strict scheme, and is more akin to natural language, + * as it's mainly meant for consumption by a LLM. + * + * @param metamodel the metamodel instance containing information on the persistence structures + * + * @return the textual representation of the provided {@link Metamodel metamodel} + */ + String toString(Metamodel metamodel); +} diff --git a/language/src/main/java/org/hibernate/tool/language/spi/ResultsSerializer.java b/language/src/main/java/org/hibernate/tool/language/spi/ResultsSerializer.java new file mode 100644 index 0000000000..28663cfa1b --- /dev/null +++ b/language/src/main/java/org/hibernate/tool/language/spi/ResultsSerializer.java @@ -0,0 +1,40 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language.spi; + +import org.hibernate.query.SelectionQuery; + +import java.util.List; + +/** + * Contract used to serialize query results into a JSON string format, + * with special care towards Hibernate-specific complexities like + * laziness and circular associations. + */ +public interface ResultsSerializer { + /** + * Serialize the given list of {@code values}, that have been returned by the provided {@code query} into a JSON string format. + * + * @param values list of values returned by the query + * @param query query object, used to determine the type of the values + * @param the type of objects returned by the query + * + * @return JSON string representation of the values + */ + String toString(List values, SelectionQuery query); +} diff --git a/language/src/test/java/org/hibernate/tool/language/MetamodelJsonSerializerTest.java b/language/src/test/java/org/hibernate/tool/language/MetamodelJsonSerializerTest.java new file mode 100644 index 0000000000..081e39ec21 --- /dev/null +++ b/language/src/test/java/org/hibernate/tool/language/MetamodelJsonSerializerTest.java @@ -0,0 +1,312 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.Metadata; +import org.hibernate.boot.MetadataSources; +import org.hibernate.tool.language.domain.Address; +import org.hibernate.tool.language.domain.Company; +import org.hibernate.tool.language.domain.Employee; +import org.hibernate.tool.language.internal.MetamodelJsonSerializerImpl; + +import org.hibernate.testing.orm.domain.animal.AnimalDomainModel; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import jakarta.persistence.AccessType; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.IdentifiableType; +import jakarta.persistence.metamodel.Metamodel; +import java.lang.reflect.Modifier; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Fail.fail; + +public class MetamodelJsonSerializerTest { + private static final ObjectMapper mapper = new ObjectMapper(); + + static { + mapper.enable( SerializationFeature.INDENT_OUTPUT ); + } + + private static final boolean DEBUG = false; // set to true to enable debug output of the generated JSON + + @Test + public void testSimpleDomainModel() { + final Metadata metadata = new MetadataSources().addAnnotatedClass( Address.class ) + .addAnnotatedClass( Company.class ) + .addAnnotatedClass( Employee.class ) + .buildMetadata(); + try (final SessionFactory sf = metadata.buildSessionFactory()) { + try { + final JsonNode root = toJson( sf.getMetamodel() ); + + // Entities + + final JsonNode entities = root.get( "entities" ); + assertThat( entities.isArray() ).isTrue(); + assertThat( entities.size() ).isEqualTo( 2 ); + + JsonNode companyNode = findByName( entities, Company.class.getSimpleName() ); + assertThat( companyNode ).isNotNull(); + assertThat( companyNode.get( "class" ).asText() ).isEqualTo( Company.class.getName() ); + assertThat( companyNode.get( "identifierAttribute" ).asText() ).isEqualTo( "id" ); + assertAttributes( Company.class, companyNode.get( "attributes" ), AccessType.FIELD ); + + JsonNode employeeNode = findByName( entities, Employee.class.getSimpleName() ); + assertThat( employeeNode ).isNotNull(); + assertThat( employeeNode.get( "class" ).asText() ).isEqualTo( Employee.class.getName() ); + assertAttributes( Employee.class, employeeNode.get( "attributes" ), AccessType.PROPERTY ); + + // Embeddables + + final JsonNode embeddables = root.get( "embeddables" ); + assertThat( embeddables.isArray() ).isTrue(); + assertThat( embeddables.size() ).isEqualTo( 1 ); + + JsonNode addressNode = findByName( embeddables, Address.class.getSimpleName() ); + assertThat( addressNode ).isNotNull(); + assertAttributes( Address.class, addressNode.get( "attributes" ), AccessType.FIELD ); + + + // Mapped superclasses + + final JsonNode superclasses = root.get( "mappedSuperclasses" ); + assertThat( superclasses.isArray() ).isTrue(); + assertThat( superclasses.isEmpty() ).isTrue(); + } + catch (JsonProcessingException e) { + fail( "Encountered an exception during JSON processing", e ); + } + } + } + + @Test + public void testMappedSuperclasses() { + // We need entities that extend mapped-superclasses, otherwise they will just be ignored + final Metadata metadata = new MetadataSources().addAnnotatedClass( MappedSuperWithEmbeddedId.class ) + .addAnnotatedClass( Entity1.class ) + .addAnnotatedClass( MappedSuperWithoutId.class ) + .addAnnotatedClass( Entity2.class ) + .buildMetadata(); + try (final SessionFactory sf = metadata.buildSessionFactory()) { + try { + System.out.printf( "JSON: " + toJson( sf.getMetamodel() ) ); + final JsonNode root = toJson( sf.getMetamodel() ); + + final JsonNode superclasses = root.get( "mappedSuperclasses" ); + assertThat( superclasses.isArray() ).isTrue(); + assertThat( superclasses.size() ).isEqualTo( 2 ); + + JsonNode withId = findByName( superclasses, MappedSuperWithEmbeddedId.class.getSimpleName() ); + assertThat( withId ).isNotNull(); + assertThat( withId.get( "class" ).asText() ).isEqualTo( MappedSuperWithEmbeddedId.class.getName() ); + assertThat( withId.get( "identifierAttribute" ).asText() ).isEqualTo( "embeddedId" ); + assertAttributes( MappedSuperWithEmbeddedId.class, withId.get( "attributes" ), AccessType.FIELD ); + + JsonNode withoutId = findByName( superclasses, MappedSuperWithoutId.class.getSimpleName() ); + assertThat( withoutId ).isNotNull(); + assertThat( withoutId.get( "class" ).asText() ).isEqualTo( MappedSuperWithoutId.class.getName() ); + assertThat( withoutId.has( "identifierAttribute" ) ).isFalse(); + + // double check entities.superClass contains the mapped superclasses + assertThat( root.get( "entities" ) + .findValues( "superType" ) + .stream() + .map( JsonNode::asText ) ).containsOnly( + MappedSuperWithEmbeddedId.class.getTypeName(), + MappedSuperWithoutId.class.getTypeName() + ); + } + catch (JsonProcessingException e) { + fail( "Encountered an exception during JSON processing", e ); + } + } + } + + @Test + public void testStandardDomainModelInheritance() { + final Class[] annotatedClasses = AnimalDomainModel.INSTANCE.getAnnotatedClasses(); + final Metadata metadata = new MetadataSources().addAnnotatedClasses( annotatedClasses ).buildMetadata(); + try (final SessionFactory sf = metadata.buildSessionFactory()) { + try { + final Metamodel metamodel = sf.getMetamodel(); + final JsonNode root = toJson( metamodel ); + + final Set> metamodelEntities = metamodel.getEntities(); + + final JsonNode entities = root.get( "entities" ); + assertThat( entities.isArray() ).isTrue(); + assertThat( entities.size() ).isEqualTo( metamodelEntities.size() ); + + for ( EntityType entity : metamodelEntities ) { + final String name = entity.getName(); + final JsonNode entityNode = findByName( entities, name ); + assertThat( entityNode ).isNotNull(); + assertThat( entityNode.get( "class" ).asText() ).isEqualTo( entity.getJavaType().getTypeName() ); + assertThat( + entityNode.get( "identifierAttribute" ).asText() + ).isEqualTo( entity.getId( entity.getIdType().getJavaType() ).getName() ); + + final IdentifiableType superType = entity.getSupertype(); + if ( superType != null ) { + assertThat( entityNode.get( "superType" ).asText() ) + .isEqualTo( superType.getJavaType().getTypeName() ); + } + else { + assertThat( entityNode.has( "superType" ) ).isFalse(); + } + + assertAttributes( entity.getJavaType(), entityNode.get( "attributes" ), AccessType.PROPERTY ); + } + } + catch (JsonProcessingException e) { + fail( "Encountered an exception during JSON processing", e ); + } + } + } + + private static JsonNode toJson(Metamodel metamodel) throws JsonProcessingException { + final String result = MetamodelJsonSerializerImpl.INSTANCE.toString( metamodel ); + + System.out.println( "JSON: " + result ); + + final JsonNode jsonNode; + try { + jsonNode = mapper.readTree( result ); + if ( DEBUG ) { + System.out.println( mapper.writeValueAsString( jsonNode ) ); + } + return jsonNode; + } + catch (JsonProcessingException e) { + if ( DEBUG ) { + System.out.println( result ); + } + throw e; + } + } + + // Helper to find node by name in a JSON array node + private static JsonNode findByName(JsonNode array, String name) { + assertThat( array.isArray() ).isTrue(); + for ( JsonNode n : array ) { + if ( n.get( "name" ).asText().equals( name ) ) { + return n; + } + } + return null; + } + + // Helper to check attributes + static void assertAttributes(Class clazz, JsonNode attributesNode, AccessType accessType) { + final Set jsonAttrs = attributesNode.findValues( "name" ).stream().map( JsonNode::asText ).collect( + Collectors.toSet() ); + for ( MemberInfo member : getPersistentMembers( clazz, accessType ) ) { + final String attrName = member.name(); + assertThat( jsonAttrs ).contains( attrName ); + final JsonNode attrNode = findByName( attributesNode, attrName ); + assertThat( attrNode ).isNotNull(); + assertType( attrNode.get( "type" ).asText(), member.type() ); + } + } + + static void assertType(String actual, Class expected) { + // some types are implicitly converted when mapping to the database + if ( expected == java.util.Date.class ) { + expected = java.sql.Date.class; + } + + // using startsWith as plural attributes also contain the element name in brackets + assertThat( actual ).startsWith( expected.getTypeName() ); + } + + // Very simple helper to derive persistent members from a clazz (good enough but not be feature-complete) + static MemberInfo[] getPersistentMembers(Class clazz, AccessType accessType) { + if ( accessType == AccessType.FIELD ) { + return Arrays.stream( clazz.getDeclaredFields() ) + .filter( field -> !Modifier.isStatic( field.getModifiers() ) ) + .map( field -> { + final String name = field.getName(); + return new MemberInfo( name, field.getType() ); + } ) + .toArray( MemberInfo[]::new ); + } + else { + return Arrays.stream( clazz.getDeclaredMethods() ) + .filter( method -> !Modifier.isStatic( method.getModifiers() ) ) + .filter( method -> method.getParameterCount() == 0 ) + .filter( method -> method.getName().startsWith( "get" ) || method.getName().startsWith( "is" ) ) + .map( method -> { + final String name = method.getName(); + final String fieldName = getJavaBeansFieldName( name.startsWith( "get" ) ? + name.substring( 3 ) : + name.substring( 2 ) ); + return new MemberInfo( fieldName, method.getReturnType() ); + } ) + .toArray( MemberInfo[]::new ); + } + } + + record MemberInfo(String name, Class type) { + } + + static String getJavaBeansFieldName(String name) { + if ( name.length() > 1 && Character.isUpperCase( name.charAt( 1 ) ) && Character.isUpperCase( name.charAt( 0 ) ) ) { + return name; + } + final char[] chars = name.toCharArray(); + chars[0] = Character.toLowerCase( chars[0] ); + return new String( chars ); + } + + @MappedSuperclass + static class MappedSuperWithEmbeddedId { + @EmbeddedId + private Address embeddedId; + } + + @Entity + static class Entity1 extends MappedSuperWithEmbeddedId { + } + + @MappedSuperclass + static class MappedSuperWithoutId { + private String createdBy; + + private LocalDateTime createdAt; + } + + @Entity + static class Entity2 extends MappedSuperWithoutId { + @Id + private Long id; + } +} diff --git a/language/src/test/java/org/hibernate/tool/language/ResultsJsonSerializerTest.java b/language/src/test/java/org/hibernate/tool/language/ResultsJsonSerializerTest.java new file mode 100644 index 0000000000..f13a80e884 --- /dev/null +++ b/language/src/test/java/org/hibernate/tool/language/ResultsJsonSerializerTest.java @@ -0,0 +1,433 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language; + +import org.hibernate.Hibernate; +import org.hibernate.Session; +import org.hibernate.engine.spi.SessionFactoryImplementor; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.query.SelectionQuery; +import org.hibernate.tool.language.domain.Address; +import org.hibernate.tool.language.domain.Company; +import org.hibernate.tool.language.domain.Employee; +import org.hibernate.tool.language.internal.ResultsJsonSerializerImpl; + +import org.hibernate.testing.orm.domain.StandardDomainModel; +import org.hibernate.testing.orm.domain.animal.Cat; +import org.hibernate.testing.orm.domain.animal.Human; +import org.hibernate.testing.orm.domain.animal.Name; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.Tuple; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@DomainModel(annotatedClasses = { + Address.class, Company.class, Employee.class, +}, standardModels = { + StandardDomainModel.ANIMAL +}) +@SessionFactory +public class ResultsJsonSerializerTest { + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + public void testEmbedded(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery
q = query( + "select address from Company where id = 1", + Address.class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.get( "city" ).textValue() ).isEqualTo( "Milan" ); + assertThat( jsonNode.get( "street" ).textValue() ).isEqualTo( "Via Gustavo Fara" ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testEmbeddedSubPart(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( + "select address.city from Company where id = 1", + String.class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.textValue() ).isEqualTo( "Milan" ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testNumericFunction(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( "select max(id) from Company", Long.class, session ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.intValue() ).isEqualTo( 4 ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testStringyFunction(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( + "select upper(name) from Company where id = 1", + String.class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.textValue() ).isEqualTo( "RED HAT" ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testCompany(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( "from Company where id = 1", Company.class, session ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.get( "id" ).intValue() ).isEqualTo( 1 ); + assertThat( jsonNode.get( "name" ).textValue() ).isEqualTo( "Red Hat" ); + assertThat( jsonNode.get( "employees" ).textValue() ).isEqualTo( "" ); + + final JsonNode address = jsonNode.get( "address" ); + assertThat( address.get( "city" ).textValue() ).isEqualTo( "Milan" ); + assertThat( address.get( "street" ).textValue() ).isEqualTo( "Via Gustavo Fara" ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testMultipleSelectionsArray(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( + "SELECT e.firstName, e.lastName FROM Employee e JOIN e.company c WHERE c.name = 'IBM'", + Object[].class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + System.out.println(result); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.isArray() ).isTrue(); + assertThat( jsonNode.get( 0 ).asText() ).isEqualTo( "Andrea" ); + assertThat( jsonNode.get( 1 ).asText() ).isEqualTo( "Boriero" ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testMultipleSelectionsTuple(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( + "SELECT e.firstName, e.lastName FROM Employee e where e.company.id = 1 ORDER BY e.lastName, e.firstName", + Tuple.class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + System.out.println(result); + + final JsonNode jsonNode = mapper.readTree( result ); + assertThat( jsonNode.isArray() ).isTrue(); + assertThat( jsonNode.size() ).isEqualTo( 2 ); + + final JsonNode first = jsonNode.get( 0 ); + assertThat( first.isArray() ).isTrue(); + assertThat( first.size() ).isEqualTo( 2 ); + assertThat( first.get( 0 ).asText() ).isEqualTo( "Marco" ); + assertThat( first.get( 1 ).asText() ).isEqualTo( "Belladelli" ); + + final JsonNode second = jsonNode.get( 1 ); + assertThat( second.isArray() ).isTrue(); + assertThat( second.size() ).isEqualTo( 2 ); + assertThat( second.get( 0 ).asText() ).isEqualTo( "Matteo" ); + assertThat( second.get( 1 ).asText() ).isEqualTo( "Cauzzi" ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testCompanyFetchEmployees(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( + "from Company c join fetch c.employees where c.id = 1", + Company.class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.get( "id" ).intValue() ).isEqualTo( 1 ); + assertThat( jsonNode.get( "name" ).textValue() ).isEqualTo( "Red Hat" ); + + final JsonNode employees = jsonNode.get( "employees" ); + assertThat( employees.isArray() ).isTrue(); + employees.forEach( employee -> { + assertDoesNotThrow( () -> UUID.fromString( employee.get( "uniqueIdentifier" ).asText() ) ); + assertThat( employee.get( "firstName" ).textValue() ).startsWith( "Ma" ); + assertThat( employee.get( "company" ).textValue() ).isEqualTo( Company.class.getName() + "#1" ); + } ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testSelectCollection(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( + "select c.employees from Company c where c.id = 1", + Employee.class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + System.out.println( result ); + + final JsonNode jsonNode = mapper.readTree( result ); + assertThat( jsonNode.isArray() ).isTrue(); + assertThat( jsonNode.size() ).isEqualTo( 2 ); + + final JsonNode first = jsonNode.get( 0 ); + assertThat( first.isObject() ).isTrue(); + assertDoesNotThrow( () -> UUID.fromString( first.get( "uniqueIdentifier" ).asText() ) ); + assertThat( first.get( "company" ).get( "name" ).textValue() ).isEqualTo( "Red Hat" ); + + final JsonNode second = jsonNode.get( 1 ); + assertThat( second.isObject() ).isTrue(); + assertDoesNotThrow( () -> UUID.fromString( second.get( "uniqueIdentifier" ).asText() ) ); + assertThat( second.get( "company" ).get( "name" ).textValue() ).isEqualTo( "Red Hat" ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testSelectCollectionProperty(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( + "select element(c.employees).firstName from Company c where c.id = 1", + String.class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + System.out.println( result ); + + final JsonNode jsonNode = mapper.readTree( result ); + assertThat( jsonNode.isArray() ).isTrue(); + assertThat( jsonNode.size() ).isEqualTo( 2 ); + assertThat( Set.of( jsonNode.get( 0 ).textValue(), jsonNode.get( 1 ).textValue() ) ).containsOnly( + "Marco", + "Matteo" + ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @Test + public void testComplexInheritance(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( "from Human h where h.id = 1", Human.class, session ); + + try { + final Human human = q.getSingleResult(); + + Hibernate.initialize( human.getFamily() ); + assertThat( human.getFamily() ).hasSize( 1 ); + Hibernate.initialize( human.getPets() ); + assertThat( human.getPets() ).hasSize( 1 ); + Hibernate.initialize( human.getNickNames() ); + assertThat( human.getNickNames() ).hasSize( 2 ); + + final String result = toString( List.of( human ), q, scope.getSessionFactory() ); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.get( "id" ).intValue() ).isEqualTo( 1 ); + + final JsonNode family = jsonNode.get( "family" ); + assertThat( family.isObject() ).isTrue(); + assertThat( family.get( "sister" ).get( "description" ).textValue() ).isEqualTo( "Marco's sister" ); + + final JsonNode pets = jsonNode.get( "pets" ); + assertThat( pets.isArray() ).isTrue(); + assertThat( pets.size() ).isEqualTo( 1 ); + final JsonNode cat = pets.get( 0 ); + assertThat( cat.isObject() ).isTrue(); + assertThat( cat.get( "id" ).intValue() ).isEqualTo( 2 ); + assertThat( cat.get( "description" ).textValue() ).isEqualTo( "Gatta" ); + assertThat( cat.get( "owner" ).isTextual() ).isTrue(); // circular relationship + assertThat( cat.get( "owner" ).textValue() ).isEqualTo( Human.class.getName() + "#1" ); + + final JsonNode nickNames = jsonNode.get( "nickNames" ); + assertThat( nickNames.isArray() ).isTrue(); + assertThat( nickNames.size() ).isEqualTo( 2 ); + assertThat( Set.of( nickNames.get( 0 ).textValue(), nickNames.get( 1 ).textValue() ) ).containsOnly( + "Bella", + "Eskimo Joe" + ); + } + catch (JsonProcessingException e) { + fail( "Serialization failed with exception", e ); + } + } ); + } + + @BeforeAll + public void beforeAll(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final Company rh = new Company( 1L, "Red Hat", new Address( "Milan", "Via Gustavo Fara" ) ); + session.persist( rh ); + final Company ibm = new Company( 2L, "IBM", new Address( "Segrate", "Circonvallazione Idroscalo" ) ); + session.persist( ibm ); + session.persist( new Company( 3L, "Belladelli Giovanni", new Address( "Pegognaga", "Via Roma" ) ) ); + session.persist( new Company( 4L, "Another Company", null ) ); + + session.persist( new Employee( UUID.randomUUID(), "Marco", "Belladelli", 100_000, rh ) ); + session.persist( new Employee( UUID.randomUUID(), "Matteo", "Cauzzi", 50_000, rh ) ); + session.persist( new Employee( UUID.randomUUID(), "Andrea", "Boriero", 200_000, ibm ) ); + + final Human human = human( 1L, session ); + cat( 2L, human, session ); + } ); + } + + @AfterAll + public void tearDown(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncateMappedObjects(); + } + + static SelectionQuery query(String hql, Class resultType, SharedSessionContractImplementor session) { + return session.createSelectionQuery( hql, resultType ); + } + + static String toString( + List values, + SelectionQuery query, + SessionFactoryImplementor sessionFactory) { + return new ResultsJsonSerializerImpl( sessionFactory ).toString( values, query ); + } + + static JsonNode getSingleValue(JsonNode jsonNode) { + assertThat( jsonNode.isArray() ).isTrue(); + assertThat( jsonNode.size() ).isEqualTo( 1 ); + return jsonNode.get( 0 ); + } + + private static Human human(Long id, Session session) { + final Human human = new Human(); + human.setId( id ); + human.setName( new Name( "Marco", 'M', "Belladelli" ) ); + human.setBirthdate( new Date() ); + human.setNickNames( new TreeSet<>( Set.of( "Bella", "Eskimo Joe" ) ) ); + final Human sister = new Human(); + sister.setId( 99L ); + sister.setName( new Name( "Sister", 'S', "Belladelli" ) ); + sister.setDescription( "Marco's sister" ); + human.setFamily( Map.of( "sister", sister ) ); + session.persist( sister ); + session.persist( human ); + return human; + } + + private static Cat cat(Long id, Human owner, Session session) { + final Cat cat = new Cat(); + cat.setId( id ); + cat.setDescription( "Gatta" ); + cat.setOwner( owner ); + session.persist( cat ); + return cat; + } +} diff --git a/language/src/test/java/org/hibernate/tool/language/domain/Address.java b/language/src/test/java/org/hibernate/tool/language/domain/Address.java new file mode 100644 index 0000000000..96baf94aef --- /dev/null +++ b/language/src/test/java/org/hibernate/tool/language/domain/Address.java @@ -0,0 +1,52 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language.domain; + +import jakarta.persistence.Embeddable; +import java.io.Serializable; + +@Embeddable +public class Address implements Serializable { + private String city; + + private String street; + + public Address() { + } + + public Address(String city, String street) { + this.city = city; + this.street = street; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } +} diff --git a/language/src/test/java/org/hibernate/tool/language/domain/Company.java b/language/src/test/java/org/hibernate/tool/language/domain/Company.java new file mode 100644 index 0000000000..a4d08c9345 --- /dev/null +++ b/language/src/test/java/org/hibernate/tool/language/domain/Company.java @@ -0,0 +1,76 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +@Entity +public class Company implements Serializable { + @Id + private long id; + + @Column(nullable = false) + private String name; + + @Embedded + private Address address; + + @OneToMany(mappedBy="company") + private List employees; + + public Company() { + } + + public Company(long id, String name, Address address) { + this.id = id; + this.name = name; + this.address = address; + this.employees = new ArrayList<>(); + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Address getAddress() { + return address; + } + + public void setAddress(Address address) { + this.address = address; + } +} diff --git a/language/src/test/java/org/hibernate/tool/language/domain/Employee.java b/language/src/test/java/org/hibernate/tool/language/domain/Employee.java new file mode 100644 index 0000000000..52b78caccf --- /dev/null +++ b/language/src/test/java/org/hibernate/tool/language/domain/Employee.java @@ -0,0 +1,89 @@ +/* + * Hibernate Tools, Tooling for your Hibernate Projects + * + * Copyright 2023-2025 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.hibernate.tool.language.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; +import java.util.UUID; + +@Entity(name = "Employee") +public class Employee { + private UUID uniqueIdentifier; + + private String firstName; + + private String lastName; + + private float salary; + + private Company company; + + public Employee() { + } + + public Employee(UUID uniqueIdentifier, String firstName, String lastName, float salary, Company company) { + this.uniqueIdentifier = uniqueIdentifier; + this.firstName = firstName; + this.lastName = lastName; + this.salary = salary; + this.company = company; + } + + @Id + public UUID getUniqueIdentifier() { + return uniqueIdentifier; + } + + public void setUniqueIdentifier(UUID uniqueIdentifier) { + this.uniqueIdentifier = uniqueIdentifier; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public float getSalary() { + return salary; + } + + public void setSalary(float salary) { + this.salary = salary; + } + + @ManyToOne + public Company getCompany() { + return company; + } + + public void setCompany(Company company) { + this.company = company; + } +} diff --git a/language/src/test/resources/hibernate.properties b/language/src/test/resources/hibernate.properties new file mode 100644 index 0000000000..bfe2323b36 --- /dev/null +++ b/language/src/test/resources/hibernate.properties @@ -0,0 +1,10 @@ +hibernate.dialect org.hibernate.dialect.H2Dialect +hibernate.connection.driver_class org.h2.Driver +#hibernate.connection.url jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1;MVCC=TRUE +hibernate.connection.url jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1 +hibernate.connection.username sa +hibernate.connection.password + +hibernate.show_sql true +hibernate.format_sql true +hibernate.highlight_sql true diff --git a/language/src/test/resources/log4j2.properties b/language/src/test/resources/log4j2.properties new file mode 100644 index 0000000000..941cf49064 --- /dev/null +++ b/language/src/test/resources/log4j2.properties @@ -0,0 +1,20 @@ +# Set to debug or trace if log4j initialization is failing +status = warn + +# Console appender configuration +appender.console.type = Console +appender.console.name = consoleLogger +appender.console.layout.type = PatternLayout +appender.console.layout.pattern = %d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n + +# Root logger level +rootLogger.level = info + +# Root logger referring to console appender +rootLogger.appenderRef.stdout.ref = consoleLogger + +logger.jdbc-bind.name=org.hibernate.orm.jdbc.bind +logger.jdbc-bind.level=trace + +logger.jdbc-extract.name=org.hibernate.orm.jdbc.extract +logger.jdbc-extract.level=trace diff --git a/pom.xml b/pom.xml index 6972123596..a86698ce17 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,7 @@ ant test utils + language @@ -195,6 +196,11 @@ hibernate-core ${hibernate-orm.version} + + org.hibernate.orm + hibernate-testing + ${hibernate-orm.version} + org.hibernate.orm hibernate-ant