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 super T, ?> 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 super T, ?> 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 extends T> 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 super T>) query, jsonAppender );
+ separator = ',';
+ }
+ sb.append( ']' );
+ return sb.toString();
+ }
+
+ private void renderValue(T value, SqmSelectionQuery super T> 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 extends T> 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 extends T> 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