diff --git a/documentation/src/main/asciidoc/userguide/chapters/assistant/Assistant.adoc b/documentation/src/main/asciidoc/userguide/chapters/assistant/Assistant.adoc new file mode 100644 index 000000000000..ce2750ccc63c --- /dev/null +++ b/documentation/src/main/asciidoc/userguide/chapters/assistant/Assistant.adoc @@ -0,0 +1,52 @@ +[[hibernate-assistant]] +== Hibernate Assistant +:assistant-project-dir: {root-project-dir}/hibernate-assistant + +[WARNING] +==== +This entire module is currently incubating and may experience breaking changes at any time, including in a micro (patch) release. +==== + +[[assistant-overview]] +=== Overview + +The Hibernate Assistant module serves as a bridge between your existing Hibernate ORM application and generative AI services. It provides the foundational components needed to expose your domain model and database operations to Large Language Models (LLMs), enabling natural language interactions with your data layer. Rather than prescribing a specific AI provider or implementation, this module focuses on providing flexible, reusable building blocks that can be integrated with any LLM service or framework. + +This module contains: + +1. The `HibernateAssistant` interface: to provide a simple, provider-agnostic, natural-language focused API to Hibernate ORM's persistence capabilities. +2. Serialization utilities: to ease the use of Hibernate ORM in the context of generative AI, for example when implementing the above. + +No implementation is included, but the above provides the building blocks for integration with generative AI services/APIs. + +[[assistant-gen-ai]] +==== Generative AI integration considerations + +Hibernate ORM comes with several advantages when interfacing with an LLM and accessing underlying RDBMS data, mainly: + +Access to data is *constrained to the mapped domain model*:: +The only tables the LLM will be able to access are the ones that have a corresponding entity class, and only columns listed as fields in your objects can be read. Custom filters and SQL restrictions can be applied to further restrict the scope of the data exposed through these tools. You don’t have to worry about creating custom database-level users or permissions only to ensure sensitive information is not exposed to AI services; + +Easy results consumption:: +Natively maps results to *Java objects* for direct application consumption, but can also be serialized and passed back to the model to obtain an *informed natural language response based on your existing data*; + +Type-safety and query validation:: +Hibernate’s query language parsing can identify the *type of query* being executed and prevent accidental data modifications when the user only meant to read data; + +*Fail-early* in case the generated statements are incorrect:: +Thanks to Hibernate’s advanced query validation and type-safety features, we don’t need to make a round-trip to the database before noticing a problem, increasing both reliability and overall performance. It’s also easy to understand what the problem with the generated query is thanks to clear error messages, and attempt to solve it either manually or with subsequent prompts; + +Bridge the gap with natural language:: +With HQL it’s easier to write more *complex queries* involving multiple entities (i.e. tables) thanks to associations, embeddable values and inheritance. LLMs have an easier time generating valid queries that provide useful information to the user when compared to plain SQL, since Hibernate's query language is closer to natural language. + + +[[assistant-serialization]] +==== Serialization Components + +To facilitate communication between Hibernate and LLM providers, the module includes two key Service Provider Interfaces (SPIs): + +`MetamodelSerializer`:: Generates a structured textual representation of your Hibernate mapping model, including entity classes, relationships, properties, and constraints. This allows the LLM to understand your domain model's structure and semantics. + +`ResultsSerializer`:: Converts query results and data into a structured textual format suitable for LLM consumption and interpretation. This enables the AI to reason about actual data from your database. + +Default JSON-based implementations of both serializers are provided, offering a ready-to-use foundation for most integration scenarios. \ No newline at end of file diff --git a/documentation/src/main/asciidoc/userguide/index.adoc b/documentation/src/main/asciidoc/userguide/index.adoc index b438b52ab2c4..c5144accff72 100644 --- a/documentation/src/main/asciidoc/userguide/index.adoc +++ b/documentation/src/main/asciidoc/userguide/index.adoc @@ -45,6 +45,7 @@ include::chapters/beans/Beans.adoc[] include::chapters/portability/Portability.adoc[] include::chapters/statistics/Statistics.adoc[] include::chapters/tooling/Tooling.adoc[] +include::chapters/assistant/Assistant.adoc[] include::appendices/BestPractices.adoc[] include::Credits.adoc[] diff --git a/hibernate-assistant/hibernate-assistant.gradle b/hibernate-assistant/hibernate-assistant.gradle new file mode 100644 index 000000000000..9c080c3d396c --- /dev/null +++ b/hibernate-assistant/hibernate-assistant.gradle @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ + +plugins { + id "local.publishing-java-module" + id "local.publishing-group-relocation" +} + +description = 'Tools to integrate Hibernate with LLMs and generative AI functionalities.' + +dependencies { + api project( ':hibernate-core' ) + + testImplementation project( ':hibernate-testing' ) + testImplementation libs.jackson +} diff --git a/hibernate-assistant/src/main/java/org/hibernate/tool/language/HibernateAssistant.java b/hibernate-assistant/src/main/java/org/hibernate/tool/language/HibernateAssistant.java new file mode 100644 index 000000000000..6e028002bfb8 --- /dev/null +++ b/hibernate-assistant/src/main/java/org/hibernate/tool/language/HibernateAssistant.java @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.tool.language; + +import org.hibernate.Incubating; +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. + */ +@Incubating +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/hibernate-assistant/src/main/java/org/hibernate/tool/language/internal/MetamodelJsonSerializerImpl.java b/hibernate-assistant/src/main/java/org/hibernate/tool/language/internal/MetamodelJsonSerializerImpl.java new file mode 100644 index 000000000000..ebc97cf61ea1 --- /dev/null +++ b/hibernate-assistant/src/main/java/org/hibernate/tool/language/internal/MetamodelJsonSerializerImpl.java @@ -0,0 +1,184 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.tool.language.internal; + +import org.hibernate.metamodel.model.domain.ManagedDomainType; +import org.hibernate.tool.language.spi.MetamodelSerializer; +import org.hibernate.type.format.StringJsonDocumentWriter; + +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.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; + +/** + * 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(Map map) { + if ( map.isEmpty() ) { + return "{}"; + } + + final StringJsonDocumentWriter writer = new StringJsonDocumentWriter( new StringBuilder() ); + toJson( map, writer ); + return writer.toString(); + } + + private static void toJson(Object value, StringJsonDocumentWriter writer) { + if ( value instanceof String strValue ) { + writer.stringValue( strValue ); + } + else if ( value instanceof Boolean boolValue ) { + writer.booleanValue( boolValue ); + } + else if ( value instanceof Number numValue ) { + writer.numericValue( numValue ); + } + else if ( value instanceof Map map ) { + writer.startObject(); + for ( final var entry : map.entrySet() ) { + writer.objectKey( entry.getKey().toString() ); + toJson( entry.getValue(), writer ); + } + writer.endObject(); + } + else if ( value instanceof Collection collection ) { + writer.startArray(); + for ( final var item : collection ) { + toJson( item, writer ); + } + writer.endArray(); + } + else if ( value == null ) { + writer.nullValue(); + } + else { + throw new IllegalArgumentException( "Unsupported value type: " + value.getClass().getName() ); + } + } + + private static void putIfNotNull(Map map, String key, Object value) { + if ( value != null ) { + map.put( key, value ); + } + } + + private static Map 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 map; + } + + private static String superTypeDescriptor(ManagedDomainType managedType) { + final var superType = managedType.getSuperType(); + return superType != null ? superType.getJavaType().getTypeName() : null; + } + + private static Map 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 map; + } + + private static String identifierDescriptor(IdentifiableType identifiableType) { + final Type idType = identifiableType.getIdType(); + if ( idType != null ) { + final var id = identifiableType.getId( idType.getJavaType() ); + return id.getName(); + } + else { + return null; + } + } + + private static Map 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 map; + } + + private static List> attributeArray(Set> attributes) { + if ( attributes.isEmpty() ) { + return List.of(); + } + + return attributes.stream().map( attribute -> { + final String name = attribute.getName(); + String type = attribute.getJavaType().getTypeName(); + // add key and element types for plural attributes + if ( attribute instanceof PluralAttribute pluralAttribute ) { + type += "<"; + final var collectionType = pluralAttribute.getCollectionType(); + if ( collectionType == PluralAttribute.CollectionType.MAP ) { + type += ( (MapAttribute) pluralAttribute ).getKeyJavaType().getTypeName() + ","; + } + type += pluralAttribute.getElementType().getJavaType().getTypeName() + ">"; + } + return Map.of( + "type", type, + "name", name + ); + } ).toList(); + } +} diff --git a/hibernate-assistant/src/main/java/org/hibernate/tool/language/internal/ResultsJsonSerializerImpl.java b/hibernate-assistant/src/main/java/org/hibernate/tool/language/internal/ResultsJsonSerializerImpl.java new file mode 100644 index 000000000000..87864b8769c7 --- /dev/null +++ b/hibernate-assistant/src/main/java/org/hibernate/tool/language/internal/ResultsJsonSerializerImpl.java @@ -0,0 +1,195 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +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.spi.SqmQuery; +import org.hibernate.query.sqm.SqmExpressible; +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.spi.ResultsSerializer; +import org.hibernate.type.descriptor.jdbc.spi.DescriptiveJsonGeneratingVisitor; +import org.hibernate.type.format.StringJsonDocumentWriter; + +import jakarta.persistence.Tuple; +import jakarta.persistence.criteria.Selection; +import java.io.IOException; +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 static final DescriptiveJsonGeneratingVisitor JSON_VISITOR = new DescriptiveJsonGeneratingVisitor(); + + private final SessionFactoryImplementor factory; + + public ResultsJsonSerializerImpl(SessionFactoryImplementor factory) { + this.factory = factory; + } + + @Override + public String toString(List values, SelectionQuery query) throws IOException { + if ( values.isEmpty() ) { + return "[]"; + } + + final StringBuilder sb = new StringBuilder(); + final StringJsonDocumentWriter writer = new StringJsonDocumentWriter( sb ); + char separator = '['; + for ( final T value : values ) { + sb.append( separator ); + //noinspection unchecked + renderValue( value, (SqmQuery) query, writer ); + separator = ','; + } + sb.append( ']' ); + return sb.toString(); + } + + private void renderValue(T value, SqmQuery query, StringJsonDocumentWriter writer) + throws IOException { + 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(), writer ); + } + else { + // wrap each result tuple in square brackets + writer.startArray(); + for ( int i = 0; i < selections.size(); i++ ) { + final SqmSelection selection = selections.get( i ); + if ( value instanceof Object[] array ) { + renderValue( array[i], selection.getSelectableNode(), writer ); + } + else if ( value instanceof Tuple tuple ) { + renderValue( tuple.get( i ), selection.getSelectableNode(), writer ); + } + else { + renderValue( value, selection.getSelectableNode(), writer ); + } + } + writer.endArray(); + } + } + + private void renderValue(Object value, Selection selection, StringJsonDocumentWriter writer) throws IOException { + if ( selection instanceof SqmRoot root ) { + final EntityPersister persister = factory.getMappingMetamodel() + .getEntityDescriptor( root.getEntityName() ); + JSON_VISITOR.visit( persister.getEntityMappingType(), value, factory.getWrapperOptions(), writer ); + } + else if ( selection instanceof SqmPath path ) { + // extract the attribute from the path + final ValuedModelPart subPart = getSubPart( path.getLhs(), path.getNavigablePath().getLocalName() ); + if ( subPart != null ) { + JSON_VISITOR.visit( subPart.getMappedType(), value, factory.getWrapperOptions(), writer ); + } + else { + expressibleToString( path, value, writer ); + } + } + else if ( selection instanceof SqmJpaCompoundSelection compoundSelection ) { + final List> compoundSelectionItems = compoundSelection.getCompoundSelectionItems(); + assert compoundSelectionItems.size() > 1; + writer.startArray(); + for ( int j = 0; j < compoundSelectionItems.size(); j++ ) { + renderValue( getValue( value, j ), compoundSelectionItems.get( j ), writer ); + } + writer.endArray(); + } + else if ( selection instanceof SqmExpressibleAccessor node ) { + expressibleToString( node, value, writer ); + } + else { + writer.stringValue( String.valueOf( value ) ); + } + } + + private static void expressibleToString( + SqmExpressibleAccessor node, + Object value, + StringJsonDocumentWriter writer) { + //noinspection unchecked + final SqmExpressible expressible = (SqmExpressible) node.getExpressible(); + final String result = expressible != null ? + expressible.getExpressibleJavaType().toString( value ) : + value.toString(); // best effort + // avoid quoting numeric and boolean values as they can be represented in JSON + if ( value instanceof Boolean boolValue ) { + writer.booleanValue( boolValue ); + } + else if ( value instanceof Number numValue ) { + writer.numericValue( numValue ); + } + else if ( result == null ) { + writer.nullValue(); + } + else { + writer.stringValue( 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; + } +} diff --git a/hibernate-assistant/src/main/java/org/hibernate/tool/language/spi/MetamodelSerializer.java b/hibernate-assistant/src/main/java/org/hibernate/tool/language/spi/MetamodelSerializer.java new file mode 100644 index 000000000000..ae75ce1b0daa --- /dev/null +++ b/hibernate-assistant/src/main/java/org/hibernate/tool/language/spi/MetamodelSerializer.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.tool.language.spi; + +import jakarta.persistence.metamodel.Metamodel; +import org.hibernate.Incubating; + +/** + * 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. + */ +@Incubating +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/hibernate-assistant/src/main/java/org/hibernate/tool/language/spi/ResultsSerializer.java b/hibernate-assistant/src/main/java/org/hibernate/tool/language/spi/ResultsSerializer.java new file mode 100644 index 000000000000..a8010a26505d --- /dev/null +++ b/hibernate-assistant/src/main/java/org/hibernate/tool/language/spi/ResultsSerializer.java @@ -0,0 +1,30 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.tool.language.spi; + +import org.hibernate.Incubating; +import org.hibernate.query.SelectionQuery; + +import java.io.IOException; +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. + */ +@Incubating +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) throws IOException; +} diff --git a/hibernate-assistant/src/test/java/org/hibernate/tool/language/MetamodelJsonSerializerTest.java b/hibernate-assistant/src/test/java/org/hibernate/tool/language/MetamodelJsonSerializerTest.java new file mode 100644 index 000000000000..2e159a46299a --- /dev/null +++ b/hibernate-assistant/src/test/java/org/hibernate/tool/language/MetamodelJsonSerializerTest.java @@ -0,0 +1,295 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +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 { + 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 ); + 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(); + // Convert "getFoo" or "isFoo" to "foo" + final String fieldName = name.startsWith( "get" ) ? + name.substring( 3 ) : + name.substring( 2 ); + return new MemberInfo( getJavaBeansFieldName( 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/hibernate-assistant/src/test/java/org/hibernate/tool/language/ResultsJsonSerializerTest.java b/hibernate-assistant/src/test/java/org/hibernate/tool/language/ResultsJsonSerializerTest.java new file mode 100644 index 000000000000..137519f6e0eb --- /dev/null +++ b/hibernate-assistant/src/test/java/org/hibernate/tool/language/ResultsJsonSerializerTest.java @@ -0,0 +1,456 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +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.io.IOException; +import java.io.UncheckedIOException; +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 testNullFunction(SessionFactoryScope scope) { + scope.inTransaction( session -> { + final SelectionQuery q = query( + "select lower(address.street) from Company where id = 4", + String.class, + session + ); + + try { + final String result = toString( q.getResultList(), q, scope.getSessionFactory() ); + + final JsonNode jsonNode = getSingleValue( mapper.readTree( result ) ); + assertThat( jsonNode.isNull() ).isTrue(); + } + 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" ); + final JsonNode company = employee.get( "company" ); + assertThat( company.get( "id" ).intValue() ).isEqualTo( 1 ); + assertThat( company.properties().stream().map( Map.Entry::getKey ) ) + .containsOnly( "id" ); // circular relationship + } ); + } + 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.isArray() ).isTrue(); + final JsonNode mapNode = getSingleValue( family ); + assertThat( mapNode.isObject() ).isTrue(); + assertThat( mapNode.get( "key" ).textValue() ).isEqualTo( "sister" ); + assertThat( mapNode.get( "value" ).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" ); + final JsonNode owner = cat.get( "owner" ); + assertThat( owner.get( "id" ).intValue() ).isEqualTo( 1 ); + assertThat( owner.properties().stream().map( Map.Entry::getKey ) ) + .containsOnly( "id" ); // circular relationship + + 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.dropData(); + } + + static SelectionQuery query(String hql, Class resultType, SharedSessionContractImplementor session) { + return session.createSelectionQuery( hql, resultType ); + } + + static String toString( + List values, + SelectionQuery query, + SessionFactoryImplementor sessionFactory) { + try { + return new ResultsJsonSerializerImpl( sessionFactory ).toString( values, query ); + } + catch (IOException e) { + throw new UncheckedIOException( "Error during result serialization", e ); + } + } + + 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/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Address.java b/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Address.java new file mode 100644 index 000000000000..d34c21f0bc1b --- /dev/null +++ b/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Address.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +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/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Company.java b/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Company.java new file mode 100644 index 000000000000..69e80a62b4ef --- /dev/null +++ b/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Company.java @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +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/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Employee.java b/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Employee.java new file mode 100644 index 000000000000..9fab27cc5b34 --- /dev/null +++ b/hibernate-assistant/src/test/java/org/hibernate/tool/language/domain/Employee.java @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +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/hibernate-assistant/src/test/resources/hibernate.properties b/hibernate-assistant/src/test/resources/hibernate.properties new file mode 100644 index 000000000000..bfe2323b3697 --- /dev/null +++ b/hibernate-assistant/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/hibernate-assistant/src/test/resources/log4j2.properties b/hibernate-assistant/src/test/resources/log4j2.properties new file mode 100644 index 000000000000..941cf490645c --- /dev/null +++ b/hibernate-assistant/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/settings.gradle b/settings.gradle index 663f7881db6d..12b173d2200e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -359,6 +359,8 @@ project(':hibernate-maven-plugin').projectDir = new File(rootProject.projectDir, include 'hibernate-ant' project(':hibernate-ant').projectDir = new File(rootProject.projectDir, "tooling/hibernate-ant") +include 'hibernate-assistant' + rootProject.children.each { project -> project.buildFileName = "${project.name}.gradle" assert project.projectDir.isDirectory()