diff --git a/AUTHORS.txt b/AUTHORS.txt index c6e19d08e3b3..234330ef0bf7 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -5,6 +5,7 @@ # Corporate contributors Red Hat, Inc. +Oracle, Corporation. # Individual contributors diff --git a/hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java b/hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java index b6e451464448..642449a502fa 100644 --- a/hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java +++ b/hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java @@ -79,6 +79,7 @@ import org.hibernate.resource.transaction.spi.TransactionCoordinatorBuilder; import org.hibernate.stat.Statistics; import org.hibernate.type.format.FormatMapper; +import org.hibernate.type.format.jackson.JacksonIntegration; import org.hibernate.type.format.jaxb.JaxbXmlFormatMapper; import jakarta.persistence.criteria.Nulls; @@ -93,6 +94,7 @@ import static org.hibernate.cfg.CacheSettings.JPA_SHARED_CACHE_RETRIEVE_MODE; import static org.hibernate.cfg.CacheSettings.JPA_SHARED_CACHE_STORE_MODE; import static org.hibernate.cfg.CacheSettings.QUERY_CACHE_LAYOUT; +import static org.hibernate.cfg.DialectSpecificSettings.ORACLE_OSON_DISABLED; import static org.hibernate.cfg.PersistenceSettings.UNOWNED_ASSOCIATION_TRANSIENT_CHECK; import static org.hibernate.cfg.QuerySettings.DEFAULT_NULL_ORDERING; import static org.hibernate.cfg.QuerySettings.JSON_FUNCTIONS_ENABLED; @@ -112,6 +114,7 @@ import static org.hibernate.jpa.internal.util.CacheModeHelper.interpretCacheMode; import static org.hibernate.jpa.internal.util.ConfigurationHelper.getFlushMode; import static org.hibernate.type.format.jackson.JacksonIntegration.getJsonJacksonFormatMapperOrNull; +import static org.hibernate.type.format.jackson.JacksonIntegration.getOsonJacksonFormatMapperOrNull; import static org.hibernate.type.format.jackson.JacksonIntegration.getXMLJacksonFormatMapperOrNull; import static org.hibernate.type.format.jakartajson.JakartaJsonIntegration.getJakartaJsonBFormatMapperOrNull; @@ -305,8 +308,10 @@ public SessionFactoryOptionsBuilder(StandardServiceRegistry serviceRegistry, Boo jsonFormatMapper = determineJsonFormatMapper( settings.get( AvailableSettings.JSON_FORMAT_MAPPER ), + !getBoolean( ORACLE_OSON_DISABLED ,settings), strategySelector ); + xmlFormatMapper = determineXmlFormatMapper( settings.get( AvailableSettings.XML_FORMAT_MAPPER ), strategySelector, @@ -787,12 +792,16 @@ private PhysicalConnectionHandlingMode interpretConnectionHandlingMode( .getDefaultConnectionHandlingMode(); } - private static FormatMapper determineJsonFormatMapper(Object setting, StrategySelector strategySelector) { + private static FormatMapper determineJsonFormatMapper(Object setting, boolean osonExtensionEnabled, StrategySelector strategySelector) { return strategySelector.resolveDefaultableStrategy( FormatMapper.class, setting, (Callable) () -> { - final FormatMapper jsonJacksonFormatMapper = getJsonJacksonFormatMapperOrNull(); + // Prefer the OSON Jackson FormatMapper by default if available + final FormatMapper jsonJacksonFormatMapper = + (osonExtensionEnabled && JacksonIntegration.isJacksonOsonExtensionAvailable()) + ? getOsonJacksonFormatMapperOrNull() + : getJsonJacksonFormatMapperOrNull(); return jsonJacksonFormatMapper != null ? jsonJacksonFormatMapper : getJakartaJsonBFormatMapperOrNull(); } ); diff --git a/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorBuilder.java b/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorBuilder.java index f2e4c3381516..af0eda071fda 100644 --- a/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorBuilder.java +++ b/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorBuilder.java @@ -45,7 +45,9 @@ import org.hibernate.resource.transaction.backend.jta.internal.JtaTransactionCoordinatorBuilderImpl; import org.hibernate.resource.transaction.spi.TransactionCoordinatorBuilder; import org.hibernate.type.format.FormatMapper; +import org.hibernate.type.format.jackson.JacksonIntegration; import org.hibernate.type.format.jackson.JacksonJsonFormatMapper; +import org.hibernate.type.format.jackson.JacksonOsonFormatMapper; import org.hibernate.type.format.jackson.JacksonXmlFormatMapper; import org.hibernate.type.format.jaxb.JaxbXmlFormatMapper; import org.hibernate.type.format.jakartajson.JsonBJsonFormatMapper; @@ -303,14 +305,21 @@ private static void addCacheKeysFactories(StrategySelectorImpl strategySelector) private static void addJsonFormatMappers(StrategySelectorImpl strategySelector) { strategySelector.registerStrategyImplementor( FormatMapper.class, - JacksonJsonFormatMapper.SHORT_NAME, - JacksonJsonFormatMapper.class + JsonBJsonFormatMapper.SHORT_NAME, + JsonBJsonFormatMapper.class ); strategySelector.registerStrategyImplementor( FormatMapper.class, - JsonBJsonFormatMapper.SHORT_NAME, - JsonBJsonFormatMapper.class + JacksonJsonFormatMapper.SHORT_NAME, + JacksonJsonFormatMapper.class ); + if ( JacksonIntegration.isJacksonOsonExtensionAvailable() ) { + strategySelector.registerStrategyImplementor( + FormatMapper.class, + JacksonOsonFormatMapper.SHORT_NAME, + JacksonOsonFormatMapper.class + ); + } } private static void addXmlFormatMappers(StrategySelectorImpl strategySelector) { diff --git a/hibernate-core/src/main/java/org/hibernate/cfg/DialectSpecificSettings.java b/hibernate-core/src/main/java/org/hibernate/cfg/DialectSpecificSettings.java index d7dca4889247..114faabf6303 100644 --- a/hibernate-core/src/main/java/org/hibernate/cfg/DialectSpecificSettings.java +++ b/hibernate-core/src/main/java/org/hibernate/cfg/DialectSpecificSettings.java @@ -52,6 +52,21 @@ public interface DialectSpecificSettings { */ String ORACLE_USE_BINARY_FLOATS = "hibernate.dialect.oracle.use_binary_floats"; + /** + * Specifies whether usage of the Oracle JSON binary format (also known as OSON) should be disabled. + *

+ * Starting in 21c, if the ojdbc-provider-jackson-oson extension is available, JSON data in an oracle + * database is stored using the OSON binary format. This setting can be used to fallback to the old implementation + * based on String serialization. + * + * @settingDefault {@code false} + * @since 7.0 + * + * @see Orace OSON format + * @see Jackson OSON provider + */ + String ORACLE_OSON_DISABLED = "hibernate.dialect.oracle.oson_format_disabled"; + /** * Specifies whether the {@code ansinull} setting is enabled on Sybase. *

diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/OracleDialect.java b/hibernate-core/src/main/java/org/hibernate/dialect/OracleDialect.java index 5378f5a01bb7..cd49ce4ba52d 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/OracleDialect.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/OracleDialect.java @@ -10,6 +10,7 @@ import org.hibernate.Length; import org.hibernate.QueryTimeoutException; import org.hibernate.Timeouts; +import org.hibernate.QueryTimeoutException; import org.hibernate.boot.model.FunctionContributions; import org.hibernate.boot.model.TypeContributions; import org.hibernate.dialect.aggregate.AggregateSupport; @@ -115,15 +116,19 @@ import java.util.TimeZone; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.jboss.logging.Logger; import static java.util.regex.Pattern.CASE_INSENSITIVE; import static org.hibernate.cfg.DialectSpecificSettings.ORACLE_USE_BINARY_FLOATS; +import static org.hibernate.cfg.DialectSpecificSettings.ORACLE_OSON_DISABLED; import static org.hibernate.dialect.type.OracleJdbcHelper.getArrayJdbcTypeConstructor; import static org.hibernate.dialect.type.OracleJdbcHelper.getNestedTableJdbcTypeConstructor; +import static org.hibernate.dialect.DialectLogging.DIALECT_LOGGER; import static org.hibernate.exception.spi.TemplatedViolatedConstraintNameExtractor.extractUsingTemplate; import static org.hibernate.internal.util.JdbcExceptionHelper.extractErrorCode; import static org.hibernate.internal.util.StringHelper.isEmpty; import static org.hibernate.internal.util.StringHelper.isNotEmpty; +import static org.hibernate.internal.util.config.ConfigurationHelper.getBoolean; import static org.hibernate.query.common.TemporalUnit.DAY; import static org.hibernate.query.common.TemporalUnit.HOUR; import static org.hibernate.query.common.TemporalUnit.MINUTE; @@ -213,6 +218,7 @@ protected void applyAggregateColumnCheck(StringBuilder buf, AggregateColumn aggr // Is the database accessed using a database service protected by Application Continuity. protected final boolean applicationContinuity; + protected final int driverMajorVersion; protected final int driverMinorVersion; private boolean useBinaryFloat; @@ -234,13 +240,13 @@ public OracleDialect(DialectResolutionInfo info) { this( info, OracleServerConfiguration.fromDialectResolutionInfo( info ) ); } - public OracleDialect(DialectResolutionInfo info, OracleServerConfiguration configuration) { + public OracleDialect(DialectResolutionInfo info, OracleServerConfiguration serverConfiguration) { super( info ); - autonomous = configuration.isAutonomous(); - extended = configuration.isExtended(); - applicationContinuity = configuration.isApplicationContinuity(); - driverMinorVersion = configuration.getDriverMinorVersion(); - driverMajorVersion = configuration.getDriverMajorVersion(); + autonomous = serverConfiguration.isAutonomous(); + extended = serverConfiguration.isExtended(); + applicationContinuity = serverConfiguration.isApplicationContinuity(); + this.driverMinorVersion = serverConfiguration.getDriverMinorVersion(); + this.driverMajorVersion = serverConfiguration.getDriverMajorVersion(); } public boolean isAutonomous() { @@ -278,11 +284,10 @@ public void appendBooleanValueString(SqlAppender appender, boolean bool) { @Override public void initializeFunctionRegistry(FunctionContributions functionContributions) { - super.initializeFunctionRegistry( functionContributions ); + super.initializeFunctionRegistry(functionContributions); final TypeConfiguration typeConfiguration = functionContributions.getTypeConfiguration(); - final var functionFactory = new CommonFunctionFactory( functionContributions ); - + CommonFunctionFactory functionFactory = new CommonFunctionFactory(functionContributions); functionFactory.ascii(); functionFactory.char_chr(); functionFactory.cosh(); @@ -839,6 +844,7 @@ protected void registerColumnTypes(TypeContributions typeContributions, ServiceR } // We need the DDL type during runtime to produce the proper encoding in certain functions ddlTypeRegistry.addDescriptor( new DdlTypeImpl( BIT, "number(1,0)", this ) ); + } @Override @@ -1002,8 +1008,24 @@ public void contributeTypes(TypeContributions typeContributions, ServiceRegistry } if ( getVersion().isSameOrAfter( 21 ) ) { - typeContributions.contributeJdbcType( OracleJsonJdbcType.INSTANCE ); - typeContributions.contributeJdbcTypeConstructor( OracleJsonArrayJdbcTypeConstructor.NATIVE_INSTANCE ); + final boolean osonDisabled = getBoolean( ORACLE_OSON_DISABLED, configurationService.getSettings() ); + if ( !osonDisabled && OracleJdbcHelper.isOsonAvailable( serviceRegistry ) ) { + // We must check that that extension is available and actually used. + typeContributions.contributeJdbcType( OracleOsonJdbcType.INSTANCE ); + typeContributions.contributeJdbcTypeConstructor( OracleOsonArrayJdbcTypeConstructor.INSTANCE ); + + DIALECT_LOGGER.log( Logger.Level.DEBUG, "Oracle OSON extension enabled" ); + } + else { + if ( osonDisabled ) { + DIALECT_LOGGER.log( Logger.Level.DEBUG, "Oracle OSON extension disabled" ); + } + else { + DIALECT_LOGGER.log( Logger.Level.DEBUG, "Oracle OSON extension not available" ); + } + typeContributions.contributeJdbcType( OracleJsonJdbcType.INSTANCE ); + typeContributions.contributeJdbcTypeConstructor( OracleJsonArrayJdbcTypeConstructor.NATIVE_INSTANCE ); + } } else { typeContributions.contributeJdbcType( OracleJsonBlobJdbcType.INSTANCE ); @@ -1442,7 +1464,7 @@ public RowLockStrategy getWriteRowLockStrategy() { } @Override - public String getForUpdateNowaitString() { + public String getForUpdateNowaitString(){ return " for update nowait"; } @@ -1671,10 +1693,10 @@ public String generatedAs(String generatedAs) { } @Override - public IdentifierHelper buildIdentifierHelper(IdentifierHelperBuilder builder, DatabaseMetaData metadata) + public IdentifierHelper buildIdentifierHelper(IdentifierHelperBuilder builder, DatabaseMetaData dbMetaData) throws SQLException { builder.setAutoQuoteInitialUnderscore( true ); - return super.buildIdentifierHelper( builder, metadata ); + return super.buildIdentifierHelper( builder, dbMetaData ); } @Override diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonArrayJdbcType.java b/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonArrayJdbcType.java new file mode 100644 index 000000000000..ee241225562d --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonArrayJdbcType.java @@ -0,0 +1,279 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.dialect; + +import oracle.jdbc.OracleType; +import oracle.jdbc.driver.DatabaseError; +import oracle.sql.json.OracleJsonDatum; +import oracle.sql.json.OracleJsonGenerator; + +import org.hibernate.dialect.type.OracleJsonArrayJdbcType; +import org.hibernate.internal.CoreLogging; +import org.hibernate.internal.CoreMessageLogger; +import org.hibernate.metamodel.mapping.EmbeddableMappingType; +import org.hibernate.type.descriptor.ValueBinder; +import org.hibernate.type.descriptor.ValueExtractor; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.BasicPluralJavaType; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.java.spi.UnknownBasicJavaType; +import org.hibernate.type.descriptor.jdbc.AggregateJdbcType; +import org.hibernate.type.descriptor.jdbc.BasicBinder; +import org.hibernate.type.descriptor.jdbc.BasicExtractor; +import org.hibernate.type.descriptor.jdbc.JdbcType; +import org.hibernate.type.descriptor.jdbc.JsonHelper; +import org.hibernate.type.descriptor.jdbc.JsonJdbcType; +import org.hibernate.type.format.OsonDocumentReader; +import org.hibernate.type.format.OsonDocumentWriter; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +import static org.hibernate.dialect.OracleOsonJdbcType.OSON_JSON_FACTORY; + +/** + * + * Type mapping of (JSON) array of JSON SQL data type for Oracle database. + * This implementation is used when Jackson mapper is used and that the JDBC OSON extension + * is available. + * + * @author Emmanuel Jannetti + * @author Bidyadhar Mohanty + */ +public class OracleOsonArrayJdbcType extends OracleJsonArrayJdbcType { + + private static final CoreMessageLogger LOG = CoreLogging.messageLogger( OracleOsonArrayJdbcType.class ); + + public OracleOsonArrayJdbcType(JdbcType elementJdbcType) { + super(elementJdbcType); + } + + @Override + public String toString() { + return "OracleOsonArrayJdbcType"; + } + + @Override + public ValueBinder getBinder(JavaType javaType) { + + return new BasicBinder<>( javaType, this ) { + + private byte[] toOsonStream(T value, JavaType javaType, WrapperOptions options) throws Exception { + final Object[] domainObjects = javaType.unwrap( value, Object[].class, options ); + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (OracleJsonGenerator generator = OSON_JSON_FACTORY.createJsonBinaryGenerator( out )) { + final JavaType elementJavaType = ((BasicPluralJavaType) javaType).getElementJavaType(); + if ( elementJavaType instanceof UnknownBasicJavaType ) { + try (Closeable osonGen = OracleOsonJacksonHelper.createWriteTarget( out )) { + options.getJsonFormatMapper().writeToTarget( value, javaType, osonGen, options ); + } + } + else { + final OsonDocumentWriter writer = new OsonDocumentWriter( generator ); + if ( getElementJdbcType() instanceof JsonJdbcType jsonElementJdbcType ) { + final EmbeddableMappingType embeddableMappingType = jsonElementJdbcType.getEmbeddableMappingType(); + JsonHelper.serializeArray( embeddableMappingType, domainObjects, options, writer ); + } + else { + assert !(getElementJdbcType() instanceof AggregateJdbcType); + JsonHelper.serializeArray( + elementJavaType, + getElementJdbcType(), + domainObjects, + options, + writer + ); + } + } + } + return out.toByteArray(); + } + + private boolean useUtf8(WrapperOptions options) { + final JavaType elementJavaType = ((BasicPluralJavaType) getJavaType()).getElementJavaType(); + return elementJavaType instanceof UnknownBasicJavaType + && !options.getJsonFormatMapper().supportsTargetType( OracleOsonJacksonHelper.WRITER_CLASS ); + } + + @Override + protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options) + throws SQLException { + try { + if ( useUtf8( options ) ) { + final String json = OracleOsonArrayJdbcType.this.toString( + value, + getJavaType(), + options + ); + st.setBytes( index, json.getBytes( StandardCharsets.UTF_8 ) ); + } + else { + st.setObject( index, toOsonStream( value, getJavaType(), options ), OracleType.JSON ); + } + } + catch (Exception e) { + throw new SQLException( e ); + } + } + + @Override + protected void doBind(CallableStatement st, X value, String name, WrapperOptions options) + throws SQLException { + try { + if ( useUtf8( options ) ) { + final String json = OracleOsonArrayJdbcType.this.toString( + value, + getJavaType(), + options + ); + st.setBytes( name, json.getBytes( StandardCharsets.UTF_8 ) ); + } + else { + st.setObject( name, toOsonStream( value, getJavaType(), options ), OracleType.JSON ); + } + } + catch (Exception e) { + throw new SQLException( e ); + } + } + }; + } + + @Override + public ValueExtractor getExtractor(JavaType javaType) { + + return new BasicExtractor<>( javaType, this ) { + + private X fromOson(InputStream osonBytes, WrapperOptions options) throws Exception { + if ( ((BasicPluralJavaType) getJavaType()).getElementJavaType() instanceof UnknownBasicJavaType ) { + try (Closeable oParser = OracleOsonJacksonHelper.createReadSource( osonBytes )) { + return options.getJsonFormatMapper().readFromSource( getJavaType(), oParser, options ); + } + } + else { + // embeddable array case. + return JsonHelper.deserializeArray( + javaType, + getElementJdbcType(), + new OsonDocumentReader( OSON_JSON_FACTORY.createJsonBinaryParser( osonBytes ) ), + options + ); + } + } + + private X doExtraction(OracleJsonDatum datum, WrapperOptions options) throws SQLException { + if ( datum == null ) { + return null; + } + InputStream osonBytes = datum.getStream(); + try { + return fromOson( osonBytes ,options); + } + catch (Exception e) { + throw new SQLException( e ); + } + } + + private boolean useUtf8(WrapperOptions options) { + final JavaType elementJavaType = ((BasicPluralJavaType) getJavaType()).getElementJavaType(); + return elementJavaType instanceof UnknownBasicJavaType + && !options.getJsonFormatMapper().supportsTargetType( OracleOsonJacksonHelper.READER_CLASS ); + } + + private X fromString(byte[] json, WrapperOptions options) throws SQLException { + if ( json == null ) { + return null; + } + return OracleOsonArrayJdbcType.this.fromString( + new String( json, StandardCharsets.UTF_8 ), + getJavaType(), + options + ); + } + + @Override + protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException { + try { + if ( useUtf8( options ) ) { + return fromString( rs.getBytes( paramIndex ), options ); + } + else { + OracleJsonDatum ojd = rs.getObject( paramIndex, OracleJsonDatum.class ); + return doExtraction( ojd, options ); + } + } + catch (SQLException exc) { + if ( exc.getErrorCode() == DatabaseError.JDBC_ERROR_BASE + DatabaseError.EOJ_INVALID_COLUMN_TYPE) { + // This may happen if we are fetching data from an existing schema + // that uses BLOB for JSON column In that case we assume bytes are + // UTF-8 bytes (i.e not OSON) and we fall back to previous String-based implementation + LOG.invalidJSONColumnType( OracleType.CLOB.getName(), OracleType.JSON.getName() ); + return fromString( rs.getBytes( paramIndex ), options ); + } + else { + throw exc; + } + } + } + + @Override + protected X doExtract(CallableStatement statement, int index, WrapperOptions options) throws SQLException { + try { + if ( useUtf8( options ) ) { + return fromString( statement.getBytes( index ), options ); + } + else { + OracleJsonDatum ojd = statement.getObject( index, OracleJsonDatum.class ); + return doExtraction( ojd, options ); + } + } + catch (SQLException exc) { + if ( exc.getErrorCode() == DatabaseError.JDBC_ERROR_BASE + DatabaseError.EOJ_INVALID_COLUMN_TYPE) { + // This may happen if we are fetching data from an existing schema + // that uses BLOB for JSON column In that case we assume bytes are + // UTF-8 bytes (i.e not OSON) and we fall back to previous String-based implementation + LOG.invalidJSONColumnType( OracleType.CLOB.getName(), OracleType.JSON.getName() ); + return fromString( statement.getBytes( index ), options ); + } + else { + throw exc; + } + } + } + + @Override + protected X doExtract(CallableStatement statement, String name, WrapperOptions options) + throws SQLException { + try { + if ( useUtf8( options ) ) { + return fromString( statement.getBytes( name ), options ); + } + else { + OracleJsonDatum ojd = statement.getObject( name, OracleJsonDatum.class ); + return doExtraction( ojd, options ); + } + } + catch (SQLException exc) { + if ( exc.getErrorCode() == DatabaseError.JDBC_ERROR_BASE + DatabaseError.EOJ_INVALID_COLUMN_TYPE) { + // This may happen if we are fetching data from an existing schema + // that uses BLOB for JSON column In that case we assume bytes are + // UTF-8 bytes (i.e not OSON) and we fall back to previous String-based implementation + LOG.invalidJSONColumnType( OracleType.CLOB.getName(), OracleType.JSON.getName() ); + return fromString( statement.getBytes( name ), options ); + } + else { + throw exc; + } + } + } + }; + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonArrayJdbcTypeConstructor.java b/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonArrayJdbcTypeConstructor.java new file mode 100644 index 000000000000..0aa867c820bc --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonArrayJdbcTypeConstructor.java @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.dialect; + +import org.hibernate.tool.schema.extract.spi.ColumnTypeInformation; +import org.hibernate.type.BasicType; +import org.hibernate.type.SqlTypes; +import org.hibernate.type.descriptor.jdbc.JdbcType; +import org.hibernate.type.descriptor.jdbc.JdbcTypeConstructor; +import org.hibernate.type.spi.TypeConfiguration; + +/** + * Factory for {@link OracleOsonArrayJdbcType}. + * @author Emmanuel Jannetti + */ +public class OracleOsonArrayJdbcTypeConstructor implements JdbcTypeConstructor { + public static final JdbcTypeConstructor INSTANCE = new OracleOsonArrayJdbcTypeConstructor(); + + @Override + public JdbcType resolveType( + TypeConfiguration typeConfiguration, + Dialect dialect, + BasicType elementType, + ColumnTypeInformation columnTypeInformation) { + return resolveType( typeConfiguration, dialect, elementType.getJdbcType(), columnTypeInformation ); + } + + @Override + public JdbcType resolveType( + TypeConfiguration typeConfiguration, + Dialect dialect, + JdbcType elementType, + ColumnTypeInformation columnTypeInformation) { + return new OracleOsonArrayJdbcType( elementType ); + } + + @Override + public int getDefaultSqlTypeCode() { + return SqlTypes.JSON_ARRAY; + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonJacksonHelper.java b/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonJacksonHelper.java new file mode 100644 index 000000000000..72c03f7557eb --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonJacksonHelper.java @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.dialect; + +import com.fasterxml.jackson.core.JsonFactory; +import oracle.jdbc.provider.oson.OsonFactory; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.hibernate.type.format.jackson.JacksonIntegration; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import static org.hibernate.type.format.jackson.JacksonIntegration.isJacksonOsonExtensionAvailable; + +public class OracleOsonJacksonHelper { + + public static final @Nullable Class READER_CLASS = loadOrNull( "com.fasterxml.jackson.core.JsonParser" ); + public static final @Nullable Class WRITER_CLASS = loadOrNull( "com.fasterxml.jackson.core.JsonGenerator" ); + + private static @Nullable Class loadOrNull(String name) { + try { + //N.B. intentionally not using the context classloader + // as we're storing these in static references; + // IMO it's reasonable to expect that such dependencies are made reachable from the ORM classloader. + // (we can change this if it's more problematic than expected). + //noinspection unchecked + return (Class) JacksonIntegration.class.getClassLoader().loadClass( name ); + } + catch (ClassNotFoundException | LinkageError e) { + return null; + } + } + + private OracleOsonJacksonHelper() { + } + + public static Closeable createWriteTarget(OutputStream out) throws IOException { + return FactoryHolder.JACKSON_FACTORY.createGenerator( out ); + } + + + public static Closeable createReadSource(InputStream osonBytes) throws IOException { + return FactoryHolder.JACKSON_FACTORY.createParser( osonBytes ); + } + + private static final class FactoryHolder { + // Intentionally storing the jackson typed factory in a different class, + // to avoid linkage errors for the outer class if Jackson is not available + private static final JsonFactory JACKSON_FACTORY = isJacksonOsonExtensionAvailable() + ? new OsonFactory() + : new JsonFactory(); + + } + +} diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonJdbcType.java b/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonJdbcType.java new file mode 100644 index 000000000000..93e2ebe9aa4a --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/dialect/OracleOsonJdbcType.java @@ -0,0 +1,312 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.dialect; + +import oracle.jdbc.OracleType; +import oracle.jdbc.driver.DatabaseError; +import oracle.sql.json.OracleJsonDatum; +import oracle.sql.json.OracleJsonFactory; +import oracle.sql.json.OracleJsonGenerator; +import org.hibernate.dialect.type.OracleJsonJdbcType; +import org.hibernate.internal.CoreLogging; +import org.hibernate.internal.CoreMessageLogger; +import org.hibernate.metamodel.mapping.EmbeddableMappingType; +import org.hibernate.metamodel.spi.RuntimeModelCreationContext; +import org.hibernate.type.descriptor.ValueBinder; +import org.hibernate.type.descriptor.ValueExtractor; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.jdbc.AggregateJdbcType; +import org.hibernate.type.descriptor.jdbc.BasicBinder; +import org.hibernate.type.descriptor.jdbc.BasicExtractor; +import org.hibernate.type.descriptor.jdbc.JsonHelper; +import org.hibernate.type.format.OsonDocumentReader; +import org.hibernate.type.format.OsonDocumentWriter; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; + +/** + * Type mapping JSON SQL data type for Oracle database. + * This implementation is used when the JDBC OSON extension is available. + * + * @author Emmanuel Jannetti + */ +public class OracleOsonJdbcType extends OracleJsonJdbcType { + public static final OracleOsonJdbcType INSTANCE = new OracleOsonJdbcType( null ); + + private static final CoreMessageLogger LOG = CoreLogging.messageLogger( OracleOsonJdbcType.class ); + + static final OracleJsonFactory OSON_JSON_FACTORY = new OracleJsonFactory(); + + private OracleOsonJdbcType(EmbeddableMappingType embeddableMappingType) { + super( embeddableMappingType ); + } + + @Override + public String toString() { + return "OracleOsonJdbcType"; + } + + @Override + public AggregateJdbcType resolveAggregateJdbcType( + EmbeddableMappingType mappingType, + String sqlType, + RuntimeModelCreationContext creationContext) { + return new OracleOsonJdbcType( mappingType ); + } + + @Override + public ValueBinder getBinder(JavaType javaType) { + + if ( javaType.getJavaType() == String.class || javaType.getJavaType() == Object.class ) { + return super.getBinder( javaType ); + } + + return new BasicBinder<>( javaType, this ) { + + private byte[] toOson(T value, JavaType javaType, WrapperOptions options) throws Exception { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + if ( getEmbeddableMappingType() != null ) { + // OracleJsonFactory is used and not OracleOsonFactory as Jackson is not involved here + try (OracleJsonGenerator generator = OSON_JSON_FACTORY.createJsonBinaryGenerator( out )) { + JsonHelper.serialize( + getEmbeddableMappingType(), + value, + options, + new OsonDocumentWriter( generator ) + ); + } + } + else { + try (Closeable osonGen = OracleOsonJacksonHelper.createWriteTarget( out )) { + options.getJsonFormatMapper().writeToTarget( value, javaType, osonGen, options ); + } + } + return out.toByteArray(); + } + + private boolean useUtf8(WrapperOptions options) { + return getEmbeddableMappingType() == null + && !options.getJsonFormatMapper().supportsTargetType( OracleOsonJacksonHelper.WRITER_CLASS ); + } + + @Override + protected void doBind(PreparedStatement st, X value, int index, WrapperOptions options) + throws SQLException { + try { + if ( useUtf8( options ) ) { + final String json = OracleOsonJdbcType.this.toString( + value, + getJavaType(), + options + ); + st.setBytes( index, json.getBytes( StandardCharsets.UTF_8 ) ); + } + else { + st.setObject( index, toOson( value, getJavaType(), options ), OracleType.JSON ); + } + } + catch (Exception e) { + throw new SQLException( e ); + } + } + + @Override + protected void doBind(CallableStatement st, X value, String name, WrapperOptions options) + throws SQLException { + try { + if ( useUtf8( options ) ) { + final String json = OracleOsonJdbcType.this.toString( + value, + getJavaType(), + options + ); + st.setBytes( name, json.getBytes( StandardCharsets.UTF_8 ) ); + } + else { + st.setObject( name, toOson( value, getJavaType(), options ), OracleType.JSON ); + } + } + catch (Exception e) { + throw new SQLException( e ); + } + } + }; + } + + @Override + public ValueExtractor getExtractor(JavaType javaType) { + + if ( javaType.getJavaType() == String.class || javaType.getJavaType() == Object.class ) { + return super.getExtractor( javaType ); + } + + return new BasicExtractor<>( javaType, this ) { + + private X fromOson(InputStream osonBytes, WrapperOptions options) throws Exception { + if ( getEmbeddableMappingType() != null ) { + return JsonHelper.deserialize( + getEmbeddableMappingType(), + new OsonDocumentReader( OSON_JSON_FACTORY.createJsonBinaryParser( osonBytes ) ), + javaType.getJavaTypeClass() != Object[].class, + options + ); + } + else { + try (Closeable osonParser = OracleOsonJacksonHelper.createReadSource( osonBytes )) { + return options.getJsonFormatMapper().readFromSource( getJavaType(), osonParser, options ); + } + } + } + + private boolean useUtf8(WrapperOptions options) { + return getEmbeddableMappingType() == null + && !options.getJsonFormatMapper().supportsSourceType( OracleOsonJacksonHelper.READER_CLASS ); + } + + private X doExtraction(OracleJsonDatum datum, WrapperOptions options) throws SQLException { + if ( datum == null ) { + return null; + } + InputStream osonBytes = datum.getStream(); + try { + return fromOson( osonBytes, options ); + } + catch (Exception e) { + throw new SQLException( e ); + } + } + + private X fromString(byte[] json, WrapperOptions options) throws SQLException { + if ( json == null ) { + return null; + } + return OracleOsonJdbcType.this.fromString( + new String( json, StandardCharsets.UTF_8 ), + getJavaType(), + options + ); + } + + private byte[] getBytesFromResultSetByIndex(ResultSet rs, int index) throws SQLException { + // This can be a BLOB or a CLOB. getBytes is not supported on CLOB + // and getString is not supported on BLOB. W have to try both + try { + return rs.getBytes( index ); + } + catch (SQLFeatureNotSupportedException nse) { + return rs.getString( index ).getBytes(); + } + } + + private byte[] getBytesFromStatementByIndex(CallableStatement st, int index) throws SQLException { + // This can be a BLOB or a CLOB. getBytes is not supported on CLOB + // and getString is not supported on BLOB. W have to try both + try { + return st.getBytes( index ); + } + catch (SQLFeatureNotSupportedException nse) { + + return st.getString( index ).getBytes(); + } + } + + private byte[] getBytesFromStatementByName(CallableStatement st, String columnName) throws SQLException { + // This can be a BLOB or a CLOB. getBytes is not supported on CLOB + // and getString is not supported on BLOB. W have to try both + try { + return st.getBytes( columnName ); + } + catch (SQLFeatureNotSupportedException nse) { + return st.getString( columnName ).getBytes(); + } + } + + @Override + protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException { + if ( useUtf8( options ) ) { + return fromString( getBytesFromResultSetByIndex( rs, paramIndex ), options ); + } + else { + try { + OracleJsonDatum ojd = rs.getObject( paramIndex, OracleJsonDatum.class ); + return doExtraction( ojd, options ); + } + catch (SQLException exc) { + if ( exc.getErrorCode() == DatabaseError.JDBC_ERROR_BASE + DatabaseError.EOJ_INVALID_COLUMN_TYPE ) { + // This may happen if we are fetching data from an existing schema + // that uses BLOB for JSON column. In that case we assume bytes are + // UTF-8 bytes (i.e not OSON) and we fall back to previous String-based implementation + LOG.invalidJSONColumnType( OracleType.BLOB.getName(), OracleType.JSON.getName() ); + return fromString( getBytesFromResultSetByIndex( rs, paramIndex ), options ); + } + else { + throw exc; + } + } + } + } + + @Override + protected X doExtract(CallableStatement statement, int index, WrapperOptions options) throws SQLException { + if ( useUtf8( options ) ) { + return fromString( getBytesFromStatementByIndex( statement, index ), options ); + } + else { + try { + OracleJsonDatum ojd = statement.getObject( index, OracleJsonDatum.class ); + return doExtraction( ojd, options ); + } + catch (SQLException exc) { + if ( exc.getErrorCode() == DatabaseError.JDBC_ERROR_BASE + DatabaseError.EOJ_INVALID_COLUMN_TYPE ) { + // This may happen if we are fetching data from an existing schema + // that uses BLOB for JSON column In that case we assume bytes are + // UTF-8 bytes (i.e not OSON) and we fall back to previous String-based implementation + LOG.invalidJSONColumnType( OracleType.CLOB.getName(), OracleType.JSON.getName() ); + return fromString( getBytesFromStatementByIndex( statement, index ), options ); + } + else { + throw exc; + } + } + } + } + + @Override + protected X doExtract(CallableStatement statement, String name, WrapperOptions options) + throws SQLException { + if ( useUtf8( options ) ) { + return fromString( getBytesFromStatementByName( statement, name ), options ); + } + else { + try { + OracleJsonDatum ojd = statement.getObject( name, OracleJsonDatum.class ); + return doExtraction( ojd, options ); + } + catch (SQLException exc) { + if ( exc.getErrorCode() == DatabaseError.JDBC_ERROR_BASE + DatabaseError.EOJ_INVALID_COLUMN_TYPE ) { + // This may happen if we are fetching data from an existing schema + // that uses BLOB for JSON column In that case we assume bytes are + // UTF-8 bytes (i.e not OSON) and we fall back to previous String-based implementation + LOG.invalidJSONColumnType( OracleType.CLOB.getName(), OracleType.JSON.getName() ); + return fromString( getBytesFromStatementByName( statement, name ), options ); + } + else { + throw exc; + } + } + } + } + }; + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/OracleServerConfiguration.java b/hibernate-core/src/main/java/org/hibernate/dialect/OracleServerConfiguration.java index e0f748e8add5..a9caf5c387b1 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/OracleServerConfiguration.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/OracleServerConfiguration.java @@ -92,6 +92,7 @@ public static OracleServerConfiguration fromDialectResolutionInfo(DialectResolut boolean extended; boolean autonomous; boolean applicationContinuity; + int majorVersion; int minorVersion; final DatabaseMetaData databaseMetaData = info.getDatabaseMetadata(); diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/aggregate/OracleAggregateSupport.java b/hibernate-core/src/main/java/org/hibernate/dialect/aggregate/OracleAggregateSupport.java index 8aac39ab3bf1..55f290752e0b 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/aggregate/OracleAggregateSupport.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/aggregate/OracleAggregateSupport.java @@ -49,12 +49,13 @@ public class OracleAggregateSupport extends AggregateSupportImpl { - private static final AggregateSupport V23_INSTANCE = new OracleAggregateSupport( true, JsonSupport.OSON ); - private static final AggregateSupport V21_INSTANCE = new OracleAggregateSupport( false, JsonSupport.OSON ); - private static final AggregateSupport V19_INSTANCE = new OracleAggregateSupport( false, JsonSupport.MERGEPATCH ); - private static final AggregateSupport V18_INSTANCE = new OracleAggregateSupport( false, JsonSupport.QUERY_AND_PATH ); - private static final AggregateSupport V12_INSTANCE = new OracleAggregateSupport( false, JsonSupport.QUERY ); - private static final AggregateSupport LEGACY_INSTANCE = new OracleAggregateSupport( false, JsonSupport.NONE ); + protected static final AggregateSupport V23_INSTANCE = new OracleAggregateSupport( true, JsonSupport.OSON ); + // Special instance used when an Oracle OSON extension is available and used + protected static final AggregateSupport V21_INSTANCE = new OracleAggregateSupport( false, JsonSupport.OSON ); + protected static final AggregateSupport V19_INSTANCE = new OracleAggregateSupport( false, JsonSupport.MERGEPATCH ); + protected static final AggregateSupport V18_INSTANCE = new OracleAggregateSupport( false, JsonSupport.QUERY_AND_PATH ); + protected static final AggregateSupport V12_INSTANCE = new OracleAggregateSupport( false, JsonSupport.QUERY ); + protected static final AggregateSupport LEGACY_INSTANCE = new OracleAggregateSupport( false, JsonSupport.NONE ); private static final String JSON_QUERY_START = "json_query("; private static final String JSON_QUERY_JSON_END = "' returning json)"; @@ -87,6 +88,11 @@ public static AggregateSupport valueOf(Dialect dialect) { }; } + private boolean supportsOson() { + // OSON is supported when check constraints are supported + return checkConstraintSupport; + } + @Override public String aggregateComponentCustomReadExpression( String template, @@ -138,27 +144,55 @@ public String aggregateComponentCustomReadExpression( placeholder, "json_value(" + parentPartExpression + columnExpression + "' returning " + column.getColumnDefinition() + ')' ); + case DATE: - return template.replace( - placeholder, - "to_date(json_value(" + parentPartExpression + columnExpression + "'),'YYYY-MM-DD')" - ); + if (supportsOson()) { + // Oracle OSON extension is used, value is not stored as string + return template.replace( + placeholder, + "json_value(" + parentPartExpression + columnExpression + "' returning date)" + ); + } + else { + return template.replace( + placeholder, + "to_date(substr(json_value(" + parentPartExpression + columnExpression + "'),1,10),'YYYY-MM-DD')" + ); + } + case TIME: return template.replace( placeholder, "to_timestamp(json_value(" + parentPartExpression + columnExpression + "'),'hh24:mi:ss')" ); case TIMESTAMP: - return template.replace( - placeholder, - "to_timestamp(json_value(" + parentPartExpression + columnExpression + "'),'YYYY-MM-DD\"T\"hh24:mi:ss.FF9')" - ); + if (supportsOson()) { + return template.replace( + placeholder, + "json_value(" + parentPartExpression + columnExpression + "' returning timestamp(9))" + ); + } + else { + return template.replace( + placeholder, + "to_timestamp(json_value(" + parentPartExpression + columnExpression + "'),'YYYY-MM-DD\"T\"hh24:mi:ss.FF9')" + ); + } case TIMESTAMP_WITH_TIMEZONE: case TIMESTAMP_UTC: - return template.replace( - placeholder, - "to_timestamp_tz(json_value(" + parentPartExpression + columnExpression + "'),'YYYY-MM-DD\"T\"hh24:mi:ss.FF9TZH:TZM')" - ); + if (supportsOson()) { + // Oracle OSON extension is used, value is not stored as string + return template.replace( + placeholder, + "json_value(" + parentPartExpression + columnExpression + "' returning timestamp(9) with time zone)" + ); + } + else { + return template.replace( + placeholder, + "to_timestamp_tz(json_value(" + parentPartExpression + columnExpression + "'),'YYYY-MM-DD\"T\"hh24:mi:ss.FF9TZH:TZM')" + ); + } case UUID: return template.replace( placeholder, @@ -218,6 +252,7 @@ public String aggregateComponentCustomReadExpression( placeholder, "cast(json_value(" + parentPartExpression + columnExpression + "') as " + column.getColumnDefinition() + ')' ); + } case NONE: throw new UnsupportedOperationException( "The Oracle version doesn't support JSON aggregates!" ); diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/type/OracleJdbcHelper.java b/hibernate-core/src/main/java/org/hibernate/dialect/type/OracleJdbcHelper.java index c37ccbe2ad58..e800b215dfbe 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/type/OracleJdbcHelper.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/type/OracleJdbcHelper.java @@ -31,6 +31,16 @@ public static boolean isUsable(ServiceRegistry serviceRegistry) { return false; } } + public static boolean isOsonAvailable(ServiceRegistry serviceRegistry) { + final ClassLoaderService classLoaderService = serviceRegistry.requireService( ClassLoaderService.class ); + try { + classLoaderService.classForName( "oracle.jdbc.provider.oson.OsonFactory" ); + return true; + } + catch (ClassLoadingException ex) { + return false; + } + } public static JdbcTypeConstructor getArrayJdbcTypeConstructor(ServiceRegistry serviceRegistry) { return create( serviceRegistry, "org.hibernate.dialect.type.OracleArrayJdbcTypeConstructor" ); diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/type/OracleJsonJdbcType.java b/hibernate-core/src/main/java/org/hibernate/dialect/type/OracleJsonJdbcType.java index 1d1c3055d190..943ccb9bbd8d 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/type/OracleJsonJdbcType.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/type/OracleJsonJdbcType.java @@ -24,7 +24,7 @@ public class OracleJsonJdbcType extends OracleJsonBlobJdbcType { */ public static final OracleJsonJdbcType INSTANCE = new OracleJsonJdbcType( null ); - private OracleJsonJdbcType(EmbeddableMappingType embeddableMappingType) { + protected OracleJsonJdbcType(EmbeddableMappingType embeddableMappingType) { super( embeddableMappingType ); } diff --git a/hibernate-core/src/main/java/org/hibernate/internal/CoreMessageLogger.java b/hibernate-core/src/main/java/org/hibernate/internal/CoreMessageLogger.java index f4e02ec60ee9..e4c891cec5c7 100644 --- a/hibernate-core/src/main/java/org/hibernate/internal/CoreMessageLogger.java +++ b/hibernate-core/src/main/java/org/hibernate/internal/CoreMessageLogger.java @@ -760,6 +760,13 @@ void unableToLocateStaticMetamodelField( ) void duplicatedPersistenceUnitName(String name); + @LogMessage(level = WARN) + @Message( + id = 15019, + value = "Invalid JSON column type [%s], was expecting [%s]; for efficiency schema should be migrate to JSON DDL type" + ) + void invalidJSONColumnType(String actual, String expected); + @LogMessage(level = DEBUG) @Message( id = 455, diff --git a/hibernate-core/src/main/java/org/hibernate/internal/util/CharSequenceHelper.java b/hibernate-core/src/main/java/org/hibernate/internal/util/CharSequenceHelper.java index 041013f5ad68..6dafe79e662a 100644 --- a/hibernate-core/src/main/java/org/hibernate/internal/util/CharSequenceHelper.java +++ b/hibernate-core/src/main/java/org/hibernate/internal/util/CharSequenceHelper.java @@ -24,6 +24,10 @@ else if ( sequence instanceof SubSequence ) { } } + public static CharSequence subSequence(CharSequence sequence) { + return subSequence(sequence, 0, sequence.length()); + } + public static boolean isEmpty(CharSequence string) { return string == null || string.length() == 0; } diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/BooleanPrimitiveArrayJavaType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/BooleanPrimitiveArrayJavaType.java index c955f68a49cc..182ca0db7c6d 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/BooleanPrimitiveArrayJavaType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/BooleanPrimitiveArrayJavaType.java @@ -81,7 +81,7 @@ public boolean[] fromString(CharSequence charSequence) { final char lastChar = charSequence.charAt( charSequence.length() - 1 ); final char firstChar = charSequence.charAt( 0 ); if ( firstChar != '{' || lastChar != '}' ) { - throw new IllegalArgumentException( "Cannot parse given string into array of strings. First and last character must be { and }" ); + throw new IllegalArgumentException( "Cannot parse given string into array of Booleans. First and last character must be { and }" ); } final int len = charSequence.length(); int elementStart = 1; diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/DoublePrimitiveArrayJavaType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/DoublePrimitiveArrayJavaType.java index 8317b4caf391..0ed6cc8f997f 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/DoublePrimitiveArrayJavaType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/DoublePrimitiveArrayJavaType.java @@ -81,7 +81,7 @@ public double[] fromString(CharSequence charSequence) { final char lastChar = charSequence.charAt( charSequence.length() - 1 ); final char firstChar = charSequence.charAt( 0 ); if ( firstChar != '{' || lastChar != '}' ) { - throw new IllegalArgumentException( "Cannot parse given string into array of strings. First and last character must be { and }" ); + throw new IllegalArgumentException( "Cannot parse given string into array of Doubles. First and last character must be { and }" ); } final int len = charSequence.length(); int elementStart = 1; diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/FloatPrimitiveArrayJavaType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/FloatPrimitiveArrayJavaType.java index ad795cea243f..25f431b6a114 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/FloatPrimitiveArrayJavaType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/FloatPrimitiveArrayJavaType.java @@ -81,7 +81,7 @@ public float[] fromString(CharSequence charSequence) { final char lastChar = charSequence.charAt( charSequence.length() - 1 ); final char firstChar = charSequence.charAt( 0 ); if ( firstChar != '{' || lastChar != '}' ) { - throw new IllegalArgumentException( "Cannot parse given string into array of strings. First and last character must be { and }" ); + throw new IllegalArgumentException( "Cannot parse given string into array of Floats. First and last character must be { and }" ); } final int len = charSequence.length(); int elementStart = 1; diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/IntegerPrimitiveArrayJavaType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/IntegerPrimitiveArrayJavaType.java index 693a60a5e0f4..703e12cf1fbd 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/IntegerPrimitiveArrayJavaType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/IntegerPrimitiveArrayJavaType.java @@ -81,7 +81,7 @@ public int[] fromString(CharSequence charSequence) { final char lastChar = charSequence.charAt( charSequence.length() - 1 ); final char firstChar = charSequence.charAt( 0 ); if ( firstChar != '{' || lastChar != '}' ) { - throw new IllegalArgumentException( "Cannot parse given string into array of strings. First and last character must be { and }" ); + throw new IllegalArgumentException( "Cannot parse given string into array of integers. First and last character must be { and }" ); } final int len = charSequence.length(); int elementStart = 1; diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/JavaType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/JavaType.java index 4a1df2c08b79..be7453a46d61 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/JavaType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/JavaType.java @@ -268,6 +268,10 @@ default T fromEncodedString(CharSequence charSequence, int start, int end) { return fromString( CharSequenceHelper.subSequence( charSequence, start, end ) ); } + default T fromEncodedString(CharSequence charSequence) { + return fromEncodedString( charSequence, 0, charSequence.length() ); + } + /** * Unwrap an instance of our handled Java type into the requested type. *

diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/LongPrimitiveArrayJavaType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/LongPrimitiveArrayJavaType.java index 5331dc3f59b1..9ba2fb465034 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/LongPrimitiveArrayJavaType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/LongPrimitiveArrayJavaType.java @@ -81,7 +81,7 @@ public long[] fromString(CharSequence charSequence) { final char lastChar = charSequence.charAt( charSequence.length() - 1 ); final char firstChar = charSequence.charAt( 0 ); if ( firstChar != '{' || lastChar != '}' ) { - throw new IllegalArgumentException( "Cannot parse given string into array of strings. First and last character must be { and }" ); + throw new IllegalArgumentException( "Cannot parse given string into array of Long. First and last character must be { and }" ); } final int len = charSequence.length(); int elementStart = 1; diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/ShortPrimitiveArrayJavaType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/ShortPrimitiveArrayJavaType.java index 05175ddba8de..37b7cf758aac 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/ShortPrimitiveArrayJavaType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/java/ShortPrimitiveArrayJavaType.java @@ -81,7 +81,7 @@ public short[] fromString(CharSequence charSequence) { final char lastChar = charSequence.charAt( charSequence.length() - 1 ); final char firstChar = charSequence.charAt( 0 ); if ( firstChar != '{' || lastChar != '}' ) { - throw new IllegalArgumentException( "Cannot parse given string into array of strings. First and last character must be { and }" ); + throw new IllegalArgumentException( "Cannot parse given string into array of Shorts. First and last character must be { and }" ); } final int len = charSequence.length(); int elementStart = 1; diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonArrayJdbcType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonArrayJdbcType.java index 1b64926ad1f0..5784a5efd18d 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonArrayJdbcType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonArrayJdbcType.java @@ -16,6 +16,8 @@ import org.hibernate.type.descriptor.WrapperOptions; import org.hibernate.type.descriptor.java.BasicPluralJavaType; import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.java.spi.UnknownBasicJavaType; +import org.hibernate.type.format.StringJsonDocumentWriter; /** * Specialized type mapping for {@code JSON_ARRAY} and the JSON ARRAY SQL data type. @@ -58,20 +60,32 @@ protected X fromString(String string, JavaType javaType, WrapperOptions o if ( string == null ) { return null; } - return JsonHelper.arrayFromString( javaType, this.getElementJdbcType(), string, options ); + if ( ((BasicPluralJavaType) javaType).getElementJavaType() instanceof UnknownBasicJavaType ) { + return options.getJsonFormatMapper().fromString( string, javaType, options ); + } + else { + return JsonHelper.arrayFromString( javaType, this.getElementJdbcType(), string, options ); + } } protected String toString(X value, JavaType javaType, WrapperOptions options) { - final JdbcType elementJdbcType = getElementJdbcType(); - final Object[] domainObjects = javaType.unwrap( value, Object[].class, options ); - if ( elementJdbcType instanceof JsonJdbcType jsonElementJdbcType ) { - final EmbeddableMappingType embeddableMappingType = jsonElementJdbcType.getEmbeddableMappingType(); - return JsonHelper.arrayToString( embeddableMappingType, domainObjects, options ); + final JavaType elementJavaType = ( (BasicPluralJavaType) javaType ).getElementJavaType(); + if ( elementJavaType instanceof UnknownBasicJavaType ) { + return options.getJsonFormatMapper().toString( value, javaType, options); } else { - assert !( elementJdbcType instanceof AggregateJdbcType ); - final JavaType elementJavaType = ( (BasicPluralJavaType) javaType ).getElementJavaType(); - return JsonHelper.arrayToString( elementJavaType, elementJdbcType, domainObjects, options ); + final JdbcType elementJdbcType = getElementJdbcType(); + final Object[] domainObjects = javaType.unwrap( value, Object[].class, options ); + final StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + if ( elementJdbcType instanceof JsonJdbcType jsonElementJdbcType ) { + final EmbeddableMappingType embeddableMappingType = jsonElementJdbcType.getEmbeddableMappingType(); + JsonHelper.serializeArray( embeddableMappingType, domainObjects, options, writer ); + } + else { + assert !(elementJdbcType instanceof AggregateJdbcType); + JsonHelper.serializeArray( elementJavaType, elementJdbcType, domainObjects, options, writer ); + } + return writer.getJson(); } } diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonHelper.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonHelper.java index 562249a97197..5021a6e6c1f5 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonHelper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonHelper.java @@ -5,1549 +5,550 @@ package org.hibernate.type.descriptor.jdbc; -import java.io.OutputStream; +import java.io.IOException; 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.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Iterator; +import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; +import org.checkerframework.checker.nullness.qual.Nullable; import org.hibernate.Internal; 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.StandardStack; import org.hibernate.metamodel.mapping.EmbeddableMappingType; import org.hibernate.metamodel.mapping.JdbcMapping; import org.hibernate.metamodel.mapping.MappingType; import org.hibernate.metamodel.mapping.SelectableMapping; import org.hibernate.metamodel.mapping.ValuedModelPart; import org.hibernate.metamodel.mapping.internal.EmbeddedAttributeMapping; -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.format.JsonDocumentItemType; +import org.hibernate.type.format.JsonDocumentReader; +import org.hibernate.type.format.JsonDocumentWriter; import static org.hibernate.type.descriptor.jdbc.StructHelper.getEmbeddedPart; import static org.hibernate.type.descriptor.jdbc.StructHelper.instantiate; +import org.hibernate.type.format.JsonValueJDBCTypeAdapter; +import org.hibernate.type.format.JsonValueJDBCTypeAdapterFactory; +import org.hibernate.type.format.StringJsonDocumentReader; /** * A Helper for serializing and deserializing JSON, based on an {@link org.hibernate.metamodel.mapping.EmbeddableMappingType}. + * + * @author Christian Beikov + * @author Emmanuel Jannetti */ @Internal public class JsonHelper { - public static String toString(EmbeddableMappingType embeddableMappingType, Object value, WrapperOptions options) { - if ( value == null ) { - return null; - } - final StringBuilder sb = new StringBuilder(); - toString( embeddableMappingType, value, options, new JsonAppender( sb ) ); - return sb.toString(); - } - - public static String arrayToString(MappingType elementMappingType, Object[] values, WrapperOptions options) { + /** + * Serializes an array of values into JSON object/array + * @param elementMappingType the type definitions + * @param values the values to be serialized + * @param options wrapping options + * @param writer the document writer used for serialization + */ + public static void serializeArray(MappingType elementMappingType, Object[] values, WrapperOptions options, JsonDocumentWriter writer) { + writer.startArray(); if ( values.length == 0 ) { - return "[]"; + writer.endArray(); + return; } - final StringBuilder sb = new StringBuilder(); - final JsonAppender jsonAppender = new JsonAppender( sb ); - char separator = '['; for ( Object value : values ) { - sb.append( separator ); - toString( elementMappingType, value, options, jsonAppender ); - separator = ','; + try { + serialize(elementMappingType, value, options, writer); + } + catch (IOException e) { + throw new IllegalArgumentException( "Could not serialize JSON array value" , e ); + } } - sb.append( ']' ); - return sb.toString(); + writer.endArray(); } - public static String arrayToString( - JavaType elementJavaType, - JdbcType elementJdbcType, - Object[] values, - WrapperOptions options) { + /** + * Serializes an array of values into JSON object/array + * @param elementJavaType the array element type + * @param elementJdbcType the JDBC type + * @param values values to be serialized + * @param options wrapping options + * @param writer the document writer used for serialization + */ + public static void serializeArray(JavaType elementJavaType, JdbcType elementJdbcType, Object[] values, WrapperOptions options, JsonDocumentWriter writer) { + writer.startArray(); if ( values.length == 0 ) { - return "[]"; + writer.endArray(); + return; } - final StringBuilder sb = new StringBuilder(); - final JsonAppender jsonAppender = new JsonAppender( sb ); - char separator = '['; for ( Object value : values ) { - sb.append( separator ); - //noinspection unchecked - convertedValueToString( (JavaType) elementJavaType, elementJdbcType, value, options, jsonAppender ); - separator = ','; + if (value == null) { + writer.nullValue(); + } + else { + writer.serializeJsonValue( value ,(JavaType) elementJavaType,elementJdbcType,options); + } } - sb.append( ']' ); - return sb.toString(); + writer.endArray(); } - private static void toString(EmbeddableMappingType embeddableMappingType, Object value, WrapperOptions options, JsonAppender appender) { - toString( embeddableMappingType, options, appender, value, '{' ); - appender.append( '}' ); + /** + * Checks that a JDBCType is assignable to an array + * @param type the jdbc type + * @return true if types is of array kind false otherwise. + */ + private static boolean isArrayType(JdbcType type) { + return (type.getDefaultSqlTypeCode() == SqlTypes.ARRAY || + type.getDefaultSqlTypeCode() == SqlTypes.JSON_ARRAY); } - private static void toString( - EmbeddableMappingType embeddableMappingType, - WrapperOptions options, - JsonAppender appender, - Object domainValue, - char separator) { + /** + * Serialized an Object value to JSON object using a document writer. + * + * @param embeddableMappingType the embeddable mapping definition of the given value. + * @param domainValue the value to be serialized. + * @param options wrapping options + * @param writer the document writer + * @throws IOException if the underlying writer failed to serialize a mpped value or failed to perform need I/O. + */ + public static void serialize(EmbeddableMappingType embeddableMappingType, + Object domainValue, WrapperOptions options, JsonDocumentWriter writer) throws IOException { + writer.startObject(); + serializeMapping(embeddableMappingType, domainValue, options, writer); + writer.endObject(); + } + + private static void serialize(MappingType mappedType, Object value, WrapperOptions options, JsonDocumentWriter writer) + throws IOException { + if ( value == null ) { + writer.nullValue(); + } + else if ( mappedType instanceof EmbeddableMappingType ) { + serialize( (EmbeddableMappingType) mappedType, value, options, writer ); + } + else if ( mappedType instanceof BasicType basicType) { + if ( isArrayType(basicType.getJdbcType())) { + final int length = Array.getLength( value ); + writer.startArray(); + if ( length != 0 ) { + final JavaType elementJavaType = ( (BasicPluralJavaType) basicType.getJdbcJavaType() ).getElementJavaType(); + final JdbcType elementJdbcType = ( (ArrayJdbcType) basicType.getJdbcType() ).getElementJdbcType(); + final Object domainArray = basicType.convertToRelationalValue( value ); + for ( int j = 0; j < length; j++ ) { + writer.serializeJsonValue(Array.get(domainArray,j), elementJavaType, elementJdbcType, options); + } + } + writer.endArray(); + } + else { + writer.serializeJsonValue(basicType.convertToRelationalValue( value), + (JavaType)basicType.getJdbcJavaType(),basicType.getJdbcType(), options); + } + } + else { + throw new UnsupportedOperationException( "Support for mapping type not yet implemented: " + mappedType.getClass().getName() ); + } + } + + /** + * JSON object attirbute serialization + * @see #serialize(EmbeddableMappingType, Object, WrapperOptions, JsonDocumentWriter) + * @param embeddableMappingType the embeddable mapping definition of the given value. + * @param domainValue the value to be serialized. + * @param options wrapping options + * @param writer the document writer + * @throws IOException if an error occurred while writing to an underlying writer + */ + private static void serializeMapping(EmbeddableMappingType embeddableMappingType, + Object domainValue, WrapperOptions options, JsonDocumentWriter writer) throws IOException { final Object[] values = embeddableMappingType.getValues( domainValue ); for ( int i = 0; i < values.length; i++ ) { final ValuedModelPart attributeMapping = getEmbeddedPart( embeddableMappingType, i ); - if ( attributeMapping instanceof SelectableMapping selectableMapping ) { - final String name = selectableMapping.getSelectableName(); - appender.append( separator ); - appender.append( '"' ); - appender.append( name ); - appender.append( "\":" ); - toString( attributeMapping.getMappedType(), values[i], options, appender ); + if ( attributeMapping instanceof SelectableMapping ) { + final String name = ( (SelectableMapping) attributeMapping ).getSelectableName(); + writer.objectKey( name ); + + if ( attributeMapping.getMappedType() instanceof EmbeddableMappingType ) { + writer.startObject(); + serializeMapping( (EmbeddableMappingType)attributeMapping.getMappedType(), values[i], options,writer); + writer.endObject(); + } + else { + serialize(attributeMapping.getMappedType(), values[i], options, writer); + } + } - else if ( attributeMapping instanceof EmbeddedAttributeMapping embeddedAttributeMapping ) { + else if ( attributeMapping instanceof EmbeddedAttributeMapping ) { if ( values[i] == null ) { - // Skipping the update of the separator on purpose continue; } + final EmbeddableMappingType mappingType = (EmbeddableMappingType) attributeMapping.getMappedType(); + final SelectableMapping aggregateMapping = mappingType.getAggregateMapping(); + if (aggregateMapping == null) { + serializeMapping( + mappingType, + values[i], + options, + writer ); + } else { - final EmbeddableMappingType mappingType = embeddedAttributeMapping.getMappedType(); - final SelectableMapping aggregateMapping = mappingType.getAggregateMapping(); - if ( aggregateMapping == null ) { - toString( - mappingType, - options, - appender, - values[i], - separator - ); - } - else { - final String name = aggregateMapping.getSelectableName(); - appender.append( separator ); - appender.append( '"' ); - appender.append( name ); - appender.append( "\":" ); - toString( mappingType, values[i], options, appender ); - } + final String name = aggregateMapping.getSelectableName(); + writer.objectKey( name ); + writer.startObject(); + serializeMapping( + mappingType, + values[i], + options, + writer); + writer.endObject(); + } } else { throw new UnsupportedOperationException( "Support for attribute mapping type not yet implemented: " + attributeMapping.getClass().getName() ); } - separator = ','; - } - } - private static void toString(MappingType mappedType, Object value, WrapperOptions options, JsonAppender appender) { - if ( value == null ) { - appender.append( "null" ); - } - else if ( mappedType instanceof EmbeddableMappingType embeddableMappingType ) { - toString( embeddableMappingType, value, options, appender ); - } - else if ( mappedType instanceof BasicType basicType ) { - convertedBasicValueToString( basicType.convertToRelationalValue( value ), options, appender, basicType ); - } - else { - throw new UnsupportedOperationException( "Support for mapping type not yet implemented: " + mappedType.getClass().getName() ); } } - 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( aggregateJdbcType.getEmbeddableMappingType(), value, options, appender ); - } - else { - convertedCastBasicValueToString( value, options, appender, javaType, jdbcType ); - } - } - - - private static void convertedBasicValueToString( - Object value, - WrapperOptions options, - JsonAppender appender, - BasicType basicType) { - convertedCastBasicValueToString( - value, - options, - appender, - basicType.getJdbcJavaType(), - basicType.getJdbcType() - ); - } - - private static void convertedCastBasicValueToString( - Object value, - WrapperOptions options, - JsonAppender appender, - JavaType javaType, - JdbcType jdbcType) { - assert javaType.isInstance( value ); - //noinspection unchecked - convertedBasicValueToString( (T) value, options, appender, javaType, jdbcType ); - } + /** + * Consumes Json document items from a document reader and return the serialized Objects + * @param reader the document reader + * @param embeddableMappingType the type definitions + * @param returnEmbeddable do we return an Embeddable object or array of Objects ? + * @param options wrapping options + * @return serialized values + * @param + * @throws SQLException if error occured during mapping of types + */ + private static X consumeJsonDocumentItems(JsonDocumentReader reader, EmbeddableMappingType embeddableMappingType, boolean returnEmbeddable, WrapperOptions options) + throws SQLException { + record SelectableData(String selectableName, int selectableIndex, SelectableMapping selectableMapping){} + record ParseLevel( + @Nullable SelectableData selectableData, + @Nullable EmbeddableMappingType embeddableMappingType, + @Nullable BasicPluralType arrayType, + @Nullable List subArrayObjectList, + @Nullable Object [] objectArray + ) { + ParseLevel(EmbeddableMappingType embeddableMappingType) { + this(null, embeddableMappingType); + } + ParseLevel(@Nullable SelectableData selectableData, EmbeddableMappingType embeddableMappingType) { + this( + selectableData, + embeddableMappingType, + null, + null, + new Object[embeddableMappingType.getJdbcValueCount()+ ( embeddableMappingType.isPolymorphic() ? 1 : 0 )] + ); + } + ParseLevel(@Nullable SelectableData selectableData, BasicPluralType arrayType) { + this( selectableData, null, arrayType, new ArrayList<>(), null ); + } - private static void convertedBasicValueToString( - T 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; + public void addValue(@Nullable SelectableData selectableData, @Nullable Object value) { + if ( embeddableMappingType != null ) { + assert selectableData != null; + objectArray[selectableData.selectableIndex] = value; } - if ( value instanceof Enum enumValue ) { - appender.appendSql( enumValue.ordinal() ); - break; + else { + assert subArrayObjectList != null; + subArrayObjectList.add(value); } - 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; + } + + public JdbcMapping determineJdbcMapping(@Nullable SelectableData currentSelectableData) { + if ( currentSelectableData != null ) { + return currentSelectableData.selectableMapping.getJdbcMapping(); } - 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 ) { - final JavaType elementJavaType = ( (BasicPluralJavaType) javaType ).getElementJavaType(); - final JdbcType elementJdbcType = ( (ArrayJdbcType) jdbcType ).getElementJdbcType(); - final Object firstArrayElement = Array.get( value, 0 ); - convertedValueToString( elementJavaType, elementJdbcType, firstArrayElement, options, appender ); - for ( int i = 1; i < length; i++ ) { - final Object arrayElement = Array.get( value, i ); - appender.append( ',' ); - convertedValueToString( elementJavaType, elementJdbcType, arrayElement, options, appender ); - } + else if ( arrayType != null ) { + return arrayType.getElementType(); } - 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; - } + else { + assert selectableData != null; + return selectableData.selectableMapping.getJdbcMapping(); + } + } - 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; - } + public static String determineSelectablePath(StandardStack parseLevel, @Nullable SelectableData currentSelectableData) { + if ( currentSelectableData != null ) { + return currentSelectableData.selectableName; + } + else { + return determineSelectablePath( parseLevel, 0 ); + } + } - // 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 ); + private static String determineSelectablePath(StandardStack stack, int level) { + final ParseLevel parseLevel = stack.peek( level ); + assert parseLevel != null; + if ( parseLevel.selectableData != null ) { + return parseLevel.selectableData.selectableName; + } + else { + assert parseLevel.arrayType != null; + return determineSelectablePath( stack, level + 1 ) + ".{element}"; + } + } } - 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 ); + final StandardStack parseLevel = new StandardStack<>(); + final JsonValueJDBCTypeAdapter adapter = JsonValueJDBCTypeAdapterFactory.getAdapter(reader,returnEmbeddable); + + parseLevel.push(new ParseLevel( embeddableMappingType )); + SelectableData currentSelectableData = null; + + // We loop on two conditions: + // - the parser still has tokens left + // - the type stack is not empty + // Even if the reader has some tokens left, if the type stack is empty, + // that means that we have to stop parsing. That may be the case while parsing an object of object array, + // the array is not empty, but we ae done parsing that specific object. + // When we encounter OBJECT_END the current type is popped out of the stack. When parsing one object of an array we may end up + // having an empty stack. Next Objects are parsed in the next round. + while(reader.hasNext() && !parseLevel.isEmpty()) { + final ParseLevel currentLevel = parseLevel.getCurrent(); + assert currentLevel != null; + switch (reader.next()) { + case VALUE_KEY -> { + final EmbeddableMappingType currentEmbeddableMappingType = currentLevel.embeddableMappingType; + assert currentEmbeddableMappingType != null + : "Value keys are only valid for objects"; + + assert currentSelectableData == null; + + final String selectableName = reader.getObjectKeyName(); + final int selectableIndex = currentEmbeddableMappingType.getSelectableIndex( selectableName ); + if ( selectableIndex < 0 ) { + throw new IllegalArgumentException( + String.format( + "Could not find selectable [%s] in embeddable type [%s] for JSON processing.", + selectableName, + currentEmbeddableMappingType.getMappedJavaType().getJavaTypeClass().getName() + ) + ); } - 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 ); + final SelectableMapping selectableMapping = + currentEmbeddableMappingType.getJdbcValueSelectable( selectableIndex ); + currentSelectableData = new SelectableData( selectableName, selectableIndex, selectableMapping ); + } + case ARRAY_START -> { + assert currentSelectableData != null; + + if ( !(currentSelectableData.selectableMapping.getJdbcMapping() instanceof BasicPluralType pluralType) ) { + throw new IllegalArgumentException( + String.format( + "Can't parse JSON array for selectable [%s] which is not of type BasicPluralType.", + ParseLevel.determineSelectablePath( parseLevel, currentSelectableData ) + ) + ); } - 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 ); + parseLevel.push( new ParseLevel( currentSelectableData, pluralType ) ); + currentSelectableData = null; + } + case ARRAY_END -> { + assert currentLevel.arrayType != null; + assert currentLevel.selectableData != null; + + parseLevel.pop(); + final ParseLevel parentLevel = parseLevel.getCurrent(); + + assert parentLevel.embeddableMappingType != null; + // flush array values + parentLevel.addValue( + currentLevel.selectableData, + currentLevel.arrayType.getJdbcJavaType().wrap( currentLevel.subArrayObjectList, options ) + ); + } + case OBJECT_START -> { + final JdbcMapping jdbcMapping = currentLevel.determineJdbcMapping( currentSelectableData ); + + if ( !(jdbcMapping.getJdbcType() instanceof AggregateJdbcType aggregateJdbcType) ) { + throw new IllegalArgumentException( + String.format( + "Can't parse JSON object for selectable [%s] which is not of type AggregateJdbcType.", + ParseLevel.determineSelectablePath( parseLevel, currentSelectableData ) + ) + ); } - 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() - ); - } + parseLevel.push( + new ParseLevel( currentSelectableData, aggregateJdbcType.getEmbeddableMappingType() ) ); + currentSelectableData = null; + } + case OBJECT_END -> { + final EmbeddableMappingType currentEmbeddableMappingType = currentLevel.embeddableMappingType; + assert currentEmbeddableMappingType != null; - 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 ); + // go back in the mapping definition tree + parseLevel.pop(); + final Object objectValue; + if ( returnEmbeddable ) { + final StructAttributeValues attributeValues = StructHelper.getAttributeValues( + embeddableMappingType, + currentLevel.objectArray, + options + ); + objectValue = instantiate( embeddableMappingType, attributeValues ); } - 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 ); + else { + objectValue = currentLevel.objectArray; } - 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 ); + if ( parseLevel.isEmpty() ) { + //noinspection unchecked + return (X) objectValue; } - 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 ); + else { + parseLevel.getCurrent().addValue( currentLevel.selectableData, objectValue ); } - 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, + case NULL_VALUE -> { + currentLevel.addValue( currentSelectableData, null ); + currentSelectableData = null; + } + case NUMERIC_VALUE -> { + final JdbcMapping jdbcMapping = currentLevel.determineJdbcMapping( currentSelectableData ); + currentLevel.addValue( + currentSelectableData, + adapter.fromNumericValue( + jdbcMapping.getJdbcJavaType(), + jdbcMapping.getJdbcType(), + reader, options - ); - return i - 1; - } + ) + ); + currentSelectableData = null; } - } - - 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 BOOLEAN_VALUE -> { + currentLevel.addValue( + currentSelectableData, + reader.getBooleanValue() ? Boolean.TRUE : Boolean.FALSE ); - 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 + currentSelectableData = null; + } + case VALUE -> { + final JdbcMapping jdbcMapping = currentLevel.determineJdbcMapping( currentSelectableData ); + currentLevel.addValue( + currentSelectableData, + adapter.fromValue( + jdbcMapping.getJdbcJavaType(), + jdbcMapping.getJdbcType(), + reader, + options + ) ); - return i - 1; + currentSelectableData = null; + } } } - return start; + throw new IllegalArgumentException( "Expected JSON object end, but none found." ); } - private static int consumeExponential( - String string, - int start, - int eIndex, - Object[] values, - JavaType javaType, - JavaType jdbcJavaType, - JdbcType jdbcType, - int selectableIndex, + /** + * Deserialize a JSON value to Java Object + * @param embeddableMappingType the mapping type + * @param reader the JSON reader + * @param returnEmbeddable do we return an Embeddable object or array of Objects + * @param options wrappping options + * @return the deserialized value + * @param + * @throws SQLException + */ + public static X deserialize( + EmbeddableMappingType embeddableMappingType, + JsonDocumentReader reader, 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; - } + final JsonDocumentItemType event; + if ( !reader.hasNext() || ( event = reader.next() ) == JsonDocumentItemType.NULL_VALUE ) { + return null; } - 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 - ) - ); + if ( event != JsonDocumentItemType.OBJECT_START ) { + throw new IllegalArgumentException("Malformed JSON. Expected object but got: " + event); } - return start + text.length() - 1; + final X result = consumeJsonDocumentItems( reader, embeddableMappingType, returnEmbeddable, options ); + assert !reader.hasNext(); + return result; } - 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, + // This is also used by Hibernate Reactive + public static X arrayFromString( + JavaType javaType, + JdbcType elementJdbcType, 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 - ); + if ( string == null ) { + return null; } - return fromString( - javaType, - jdbcJavaType, - jdbcType, - string, - start, - end, - returnEmbeddable, - options - ); + return deserializeArray( javaType, elementJdbcType, new StringJsonDocumentReader( string ), options ); } - private static Object fromString( - JavaType javaType, - JavaType jdbcJavaType, - JdbcType jdbcType, - String string, - int start, - int end, - boolean returnEmbeddable, + public static X deserializeArray( + JavaType javaType, + JdbcType elementJdbcType, + JsonDocumentReader reader, 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; - } - } - - private static class JsonAppender extends OutputStream implements SqlAppender { - - private final static char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); - - private final StringBuilder sb; - private boolean escape; - - public JsonAppender(StringBuilder sb) { - this.sb = sb; - } - - @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() ); + final JsonDocumentItemType event; + if ( !reader.hasNext() || ( event = reader.next() ) == JsonDocumentItemType.NULL_VALUE ) { + return null; } - - @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; + if ( event != JsonDocumentItemType.ARRAY_START ) { + throw new IllegalArgumentException("Malformed JSON. Expected array but got: " + event); } - @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); + final CustomArrayList arrayList = new CustomArrayList(); + final JavaType elementJavaType = ((BasicPluralJavaType) javaType).getElementJavaType(); + final Class preferredJavaTypeClass = elementJdbcType.getPreferredJavaTypeClass( options ); + final JavaType jdbcJavaType; + if ( preferredJavaTypeClass == null || preferredJavaTypeClass == elementJavaType.getJavaTypeClass() ) { + jdbcJavaType = elementJavaType; } - - @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] ); - } + else { + jdbcJavaType = options.getTypeConfiguration().getJavaTypeRegistry().resolveDescriptor( preferredJavaTypeClass ); } - 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 ) ); + final JsonValueJDBCTypeAdapter adapter = JsonValueJDBCTypeAdapterFactory.getAdapter(reader,false); + while(reader.hasNext()) { + JsonDocumentItemType type = reader.next(); + switch ( type ) { + case ARRAY_END: + assert !reader.hasNext(); + return javaType.wrap( arrayList, options ); + case NULL_VALUE: + arrayList.add( null ); break; - case '\b': - sb.append("\\b"); + case NUMERIC_VALUE: + arrayList.add( adapter.fromNumericValue(jdbcJavaType, elementJdbcType ,reader, options) ); break; - case '\t': - sb.append("\\t"); + case BOOLEAN_VALUE: + arrayList.add( reader.getBooleanValue() ? Boolean.TRUE : Boolean.FALSE ); break; - case '\n': - sb.append("\\n"); + case VALUE: + arrayList.add( adapter.fromValue(jdbcJavaType, elementJdbcType ,reader, options) ); break; - case '\f': - sb.append("\\f"); - break; - case '\r': - sb.append("\\r"); - break; - case '"': - sb.append( "\\\"" ); - break; - case '\\': - sb.append( "\\\\" ); + case OBJECT_START: + assert elementJdbcType instanceof JsonJdbcType; + final EmbeddableMappingType embeddableMappingType = ((JsonJdbcType) elementJdbcType).getEmbeddableMappingType(); + arrayList.add( consumeJsonDocumentItems(reader, embeddableMappingType, true, options) ); break; default: - sb.append( fragment ); - break; + throw new UnsupportedOperationException( "Unexpected JSON type " + type ); } } + throw new IllegalArgumentException( "Expected JSON array end, but none found." ); } + private static class CustomArrayList extends AbstractCollection implements Collection { Object[] array = ArrayHelper.EMPTY_OBJECT_ARRAY; int size; diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonJdbcType.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonJdbcType.java index f0631db28929..59b5b70d5935 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonJdbcType.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/JsonJdbcType.java @@ -4,6 +4,7 @@ */ package org.hibernate.type.descriptor.jdbc; +import java.io.IOException; import java.sql.CallableStatement; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -16,11 +17,14 @@ import org.hibernate.type.descriptor.ValueExtractor; import org.hibernate.type.descriptor.WrapperOptions; import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.format.StringJsonDocumentReader; +import org.hibernate.type.format.StringJsonDocumentWriter; /** * Specialized type mapping for {@code JSON} and the JSON SQL data type. * * @author Christian Beikov + * @author Emmanuel Jannetti */ public class JsonJdbcType implements AggregateJdbcType { /** @@ -73,9 +77,9 @@ protected X fromString(String string, JavaType javaType, WrapperOptions o return null; } if ( embeddableMappingType != null ) { - return JsonHelper.fromString( + return JsonHelper.deserialize( embeddableMappingType, - string, + new StringJsonDocumentReader(string), javaType.getJavaTypeClass() != Object[].class, options ); @@ -86,18 +90,32 @@ protected X fromString(String string, JavaType javaType, WrapperOptions o @Override public Object createJdbcValue(Object domainValue, WrapperOptions options) throws SQLException { assert embeddableMappingType != null; - return JsonHelper.toString( embeddableMappingType, domainValue, options ); + final StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + try { + JsonHelper.serialize( embeddableMappingType, domainValue, options, writer ); + return writer.getJson(); + } + catch (IOException e) { + throw new SQLException( e ); + } } @Override public Object[] extractJdbcValues(Object rawJdbcValue, WrapperOptions options) throws SQLException { assert embeddableMappingType != null; - return JsonHelper.fromString( embeddableMappingType, (String) rawJdbcValue, false, options ); + return JsonHelper.deserialize( embeddableMappingType, new StringJsonDocumentReader( (String) rawJdbcValue ), false, options ); } protected String toString(X value, JavaType javaType, WrapperOptions options) { if ( embeddableMappingType != null ) { - return JsonHelper.toString( embeddableMappingType, value, options ); + try { + final StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + JsonHelper.serialize( embeddableMappingType, value, options, writer ); + return writer.getJson(); + } + catch (IOException e) { + throw new RuntimeException("Failed to serialize JSON mapping", e ); + } } return options.getJsonFormatMapper().toString( value, javaType, options ); } diff --git a/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/XmlHelper.java b/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/XmlHelper.java index dda672dfc016..36e494fb83b2 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/XmlHelper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/descriptor/jdbc/XmlHelper.java @@ -335,7 +335,7 @@ else if ( !string.startsWith( COLLECTION_START_TAG ) || !string.endsWith( COLLEC final ArrayList arrayList = new ArrayList<>(); final int end = fromArrayString( string, - false, + true, options, COLLECTION_START_TAG.length(), arrayList, @@ -646,7 +646,7 @@ private static int fromArrayString( else { arrayList.add( array ); } - i = end + 1; + i = end; } else { throw new IllegalArgumentException( "XML not properly formed: " + string.substring( start ) ); diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/AbstractJsonFormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/AbstractJsonFormatMapper.java index d32252791a67..eceee2682a1b 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/format/AbstractJsonFormatMapper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/format/AbstractJsonFormatMapper.java @@ -36,4 +36,5 @@ public final String toString(T value, JavaType javaType, WrapperOptions w protected abstract T fromString(CharSequence charSequence, Type type); protected abstract String toString(T value, Type type); + } diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/FormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/FormatMapper.java index e22355f6e2e3..e2f324b6719d 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/format/FormatMapper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/format/FormatMapper.java @@ -8,6 +8,8 @@ import org.hibernate.type.descriptor.WrapperOptions; import org.hibernate.type.descriptor.java.JavaType; +import java.io.IOException; + /** * A mapper for mapping objects to and from a format. *
    @@ -41,4 +43,30 @@ public interface FormatMapper { * Serializes the object to a string. */ String toString(T value, JavaType javaType, WrapperOptions wrapperOptions); + + /** + * Checks that this mapper supports a type as a source type. + * @param sourceType the source type + * @return true if the type is supported, false otherwise. + */ + default boolean supportsSourceType(Class sourceType) { + return false; + }; + + /** + * Checks that this mapper supports a type as a target type. + * @param targetType the target type + * @return true if the type is supported, false otherwise. + */ + default boolean supportsTargetType(Class targetType) { + return false; + } + + default void writeToTarget(T value, JavaType javaType, Object target, WrapperOptions options) throws IOException { + throw new UnsupportedOperationException( "Unsupportd target type " + target.getClass() ); + }; + + default T readFromSource(JavaType javaType, Object source, WrapperOptions options) throws IOException { + throw new UnsupportedOperationException( "Unsupportd source type " + source.getClass() ); + }; } diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentItemType.java b/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentItemType.java new file mode 100644 index 000000000000..1c57718619e1 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentItemType.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +/** + * Json item types + */ +public enum JsonDocumentItemType { + /** + * Start of a Json Object '{' + */ + OBJECT_START, + /** + * end of a Json Object '}' + */ + OBJECT_END, + /** + * Start of a Json array '[' + */ + ARRAY_START, + /** + * End of a Json array ']' + */ + ARRAY_END, + /** + * key of Json attribute + */ + VALUE_KEY, + /** + * boolean value within Json object or array + */ + BOOLEAN_VALUE, + /** + * null value within Json object or array + */ + NULL_VALUE, + /** + * numeric value within Json object or array + */ + NUMERIC_VALUE, + /** + * String (quoted) value within Json object or array + */ + VALUE +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentReader.java b/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentReader.java new file mode 100644 index 000000000000..f276e8d69aad --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentReader.java @@ -0,0 +1,148 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.Iterator; + +/** + * JSON document reader. + * Reads a JSON document (i.e., String or OSON bytes) and produce Json item type event. + * Calling #next() will return one of a JsonDocumentItem.JsonDocumentItemType. + * The sequence of return types follows JSON specification. + *

    + * When {@link JsonDocumentItemType.VALUE_KEY} is returned #getObjectKeyName() should be called to get the key name. + *

    + * When {@link JsonDocumentItemType.VALUE}, {@link JsonDocumentItemType.BOOLEAN_VALUE}, {@link JsonDocumentItemType.NULL_VALUE} or {@link JsonDocumentItemType.NUMERIC_VALUE} is returned one of the getxxxValue() should be called to get the value. + *

    + * example : + *

    + *	{
    + * 	  "key1": "value1",
    + * 	  "key2": ["x","y","z"],
    + * 	  "key3": {
    + *             "key4" : ["a"],
    + *             "key5" : {}
    + *            },
    + *    "key6":12,
    + *    "key7":null
    + *  }
    + *  
    + * This Json object could be read as follows + *
    + *      while (reader.hasNext()) {}
    + *         JsonDocumentItemType type = reader.next();
    + *         switch(type) {
    + *         	   case VALUE_KEY:
    + *         	       String keyName = reader.getObjectKeyName();
    + *         	       break;
    + *         	   case VALUE:
    + *         	       String value = reader.getStringValue()
    + *         	       break
    + *         	    //...
    + *         }
    + *      }
    + *  
    + * This Json object above would trigger this sequence of events + *
    + *    JsonDocumentItemType.OBJECT_START
    + *    JsonDocumentItemType.VALUE_KEY      // "key1"
    + *    JsonDocumentItemType.VALUE          // "value1"
    + *    JsonDocumentItemType.VALUE_KEY      // "key2"
    + *    JsonDocumentItemType.ARRAY_START
    + *    JsonDocumentItemType.VALUE          // "x"
    + *    JsonDocumentItemType.VALUE          // "y"
    + *    JsonDocumentItemType.VALUE          // "z"
    + *    JsonDocumentItemType.ARRAY_END
    + *    JsonDocumentItemType.VALUE_KEY      // "key3"
    + *    JsonDocumentItemType.OBJECT_START
    + *    JsonDocumentItemType.VALUE_KEY      // "key4"
    + *    JsonDocumentItemType.ARRAY_START
    + *    JsonDocumentItemType.VALUE          // "a"
    + *    JsonDocumentItemType.ARRAY_END
    + *    JsonDocumentItemType.VALUE_KEY       // "key5"
    + *    JsonDocumentItemType.OBJECT_START
    + *    JsonDocumentItemType.OBJECT_END
    + *    JsonDocumentItemType.VALUE_KEY       // "key6"
    + *    JsonDocumentItemType.NUMERIC_VALUE
    + *    JsonDocumentItemType.VALUE_KEY       // "key7"
    + *    JsonDocumentItemType.NULL_VALUE
    + *    JsonDocumentItemType.OBJECT_END
    + *    JsonDocumentItemType.OBJECT_END
    + *  
    + * + * @author Emmanuel Jannetti + */ +public interface JsonDocumentReader extends Iterator { + default void forEachRemaining() { + throw new UnsupportedOperationException("forEachRemaining"); + } + + /** + * Gets the key name once JsonDocumentItemType.VALUE_KEY has been received + * @return the name + */ + String getObjectKeyName(); + /** + * Gets value as String + * @return the value. + */ + String getStringValue(); + /** + * Gets value as BigDecimal + * @return the value. + */ + BigDecimal getBigDecimalValue(); + /** + * Gets value as BigInteger + * @return the value. + */ + BigInteger getBigIntegerValue(); + /** + * Gets value as double + * @return the value. + */ + double getDoubleValue(); + /** + * Gets value as float + * @return the value. + */ + float getFloatValue(); + /** + * Gets value as long + * @return the value. + */ + long getLongValue(); + /** + * Gets value as int + * @return the value. + */ + int getIntegerValue(); + /** + * Gets value as short + * @return the value. + */ + short getShortValue(); + /** + * Gets value as byte + * @return the value. + */ + byte getByteValue(); + /** + * Gets value as boolean + * @return the value. + */ + boolean getBooleanValue(); + + /** + * Gets value as JavaType + * @return the value. + */ + T getValue(JavaType javaType, WrapperOptions options); +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentWriter.java b/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentWriter.java new file mode 100644 index 000000000000..03bac2085eb7 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/JsonDocumentWriter.java @@ -0,0 +1,90 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + + +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.jdbc.JdbcType; + +import java.io.IOException; + +/** + * JSON document producer. + * Implementation of this inteface will used to build a JSON document. + * Implementation example is {@link StringJsonDocumentWriter } + * @author Emmanuel Jannetti + */ + +public interface JsonDocumentWriter { + /** + * Starts a new JSON Objects. + * @return this instance + */ + JsonDocumentWriter startObject(); + + /** + * Ends a new JSON Objects + * @return this instance + */ + JsonDocumentWriter endObject(); + + /** + * Starts a new JSON array. + * @return this instance + * @throws IOException an I/O error roccured while starting the object. + */ + JsonDocumentWriter startArray(); + + /** + * Ends a new JSON array. + * @return this instance + * @throws IOException an I/O error roccured while starting the object. + */ + JsonDocumentWriter endArray(); + + /** + * Adds a new JSON element name. + * @param key the element name. + * @return this instance + * @throws IllegalArgumentException key name does not follow JSON specification. + * @throws IOException an I/O error occurred while starting the object. + */ + JsonDocumentWriter objectKey(String key); + + /** + * Adds a new JSON element null value. + * @return this instance + * @throws IOException an I/O error roccured while starting the object. + */ + JsonDocumentWriter nullValue(); + + /** + * Adds a new JSON element boolean value. + * @return this instance + * @param value the element boolean name. + */ + JsonDocumentWriter booleanValue(boolean value); + + /** + * Adds a new JSON element string value. + * @return this instance + * @param value the element string name. + */ + JsonDocumentWriter stringValue(String value); + + /** + * Adds a JSON value to the document + * @param value the value to be serialized + * @param javaType the Java type of the value + * @param jdbcType the JDBC type for the value to be serialized + * @param options the wrapping options + * @return this instance + */ + JsonDocumentWriter serializeJsonValue(Object value, + JavaType javaType, + JdbcType jdbcType, + WrapperOptions options); +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/JsonValueJDBCTypeAdapter.java b/hibernate-core/src/main/java/org/hibernate/type/format/JsonValueJDBCTypeAdapter.java new file mode 100644 index 000000000000..7f292bc0e16c --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/JsonValueJDBCTypeAdapter.java @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.jdbc.JdbcType; + +import java.sql.SQLException; + +/** + * Adapter for JSON value on given JDBC types. + * @author emmanuel Jannetti + */ +public interface JsonValueJDBCTypeAdapter { + /** + * Gets an Object out of a JSON document reader according to a given types. + * @param jdbcJavaType the desired JavaType for the return Object. + * @param jdbcType the desired JdbcType for the return Object. + * @param source the JSON document reader from which to get the value to be translated. + * @param options the wrapping option + * @return the translated value. + * @throws SQLException if translation failed. + */ + Object fromValue( + JavaType jdbcJavaType, + JdbcType jdbcType, + JsonDocumentReader source, + WrapperOptions options) throws SQLException; + + /** + * Gets an Object out of a JSON document reader according to a given types. + * This method is called when the current available value in the reader is a numeric one. + * @param jdbcJavaType the desired JavaType for the return Object. + * @param jdbcType the desired JdbcType for the return Object. + * @param source the JSON document reader from which to get the value to be translated. + * @param options the wrapping option + * @return the translated value. + * @throws SQLException if translation failed. + */ + Object fromNumericValue(JavaType jdbcJavaType, + JdbcType jdbcType, + JsonDocumentReader source, + WrapperOptions options) throws SQLException; + +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/JsonValueJDBCTypeAdapterFactory.java b/hibernate-core/src/main/java/org/hibernate/type/format/JsonValueJDBCTypeAdapterFactory.java new file mode 100644 index 000000000000..92cf62eb5697 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/JsonValueJDBCTypeAdapterFactory.java @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +/** + * Factory class to get proper JsonValueJDBCTypeAdapter. + * + * @author Emmanuel Jannetti + */ +public class JsonValueJDBCTypeAdapterFactory { + /** + * Gets a type adapter for a given reader + * @param reader the JSON document reader from which the adapter gets its value from. + * @param returnEmbeddable + * @return the adapter + */ + public static JsonValueJDBCTypeAdapter getAdapter(JsonDocumentReader reader , boolean returnEmbeddable) { + assert reader != null : "reader is null"; + + if (reader instanceof StringJsonDocumentReader) { + return new StringJsonValueJDBCTypeAdapter( returnEmbeddable ); + } + if (reader instanceof OsonDocumentReader ) { + return new OsonValueJDBCTypeAdapter( ); + } + + throw new IllegalArgumentException("Unsupported type of document reader " + reader.getClass()); + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/OsonDocumentReader.java b/hibernate-core/src/main/java/org/hibernate/type/format/OsonDocumentReader.java new file mode 100644 index 000000000000..37a7c4c4e17a --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/OsonDocumentReader.java @@ -0,0 +1,214 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +import oracle.sql.json.OracleJsonParser; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.BooleanJavaType; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.java.PrimitiveByteArrayJavaType; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Date; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.NoSuchElementException; + +/** + * OSON-based implementation of JsonDocumentReader + * @author Emmanuel Jannetti + */ +public class OsonDocumentReader implements JsonDocumentReader { + + final private OracleJsonParser parser; + private String currentKeyName; + private Object currentValue; + + /** + * Creates a new OsonDocumentReader on top of a OracleJsonParser + * @param parser the parser + */ + public OsonDocumentReader(OracleJsonParser parser) { + this.parser = parser; + } + + @Override + public boolean hasNext() { + return this.parser.hasNext(); + } + + @Override + public JsonDocumentItemType next() { + if (!this.parser.hasNext()) + throw new NoSuchElementException("No more item in JSON document"); + OracleJsonParser.Event evt = this.parser.next(); + currentKeyName = null; + currentValue = null; + switch (evt) { + case START_OBJECT: + return JsonDocumentItemType.OBJECT_START; + case END_OBJECT: + return JsonDocumentItemType.OBJECT_END; + case START_ARRAY: + return JsonDocumentItemType.ARRAY_START; + case END_ARRAY: + return JsonDocumentItemType.ARRAY_END; + case KEY_NAME: + currentKeyName = this.parser.getString(); + return JsonDocumentItemType.VALUE_KEY; + case VALUE_TIMESTAMPTZ: + currentValue = this.parser.getOffsetDateTime(); + return JsonDocumentItemType.VALUE; + case VALUE_DATE: + case VALUE_TIMESTAMP: + currentValue = this.parser.getLocalDateTime(); + return JsonDocumentItemType.VALUE; + case VALUE_INTERVALDS: + currentValue = this.parser.getDuration(); + return JsonDocumentItemType.VALUE; + case VALUE_INTERVALYM: + currentValue = this.parser.getPeriod(); + return JsonDocumentItemType.VALUE; + case VALUE_STRING: + currentValue = this.parser.getString(); + return JsonDocumentItemType.VALUE; + case VALUE_TRUE: + currentValue = Boolean.TRUE; + return JsonDocumentItemType.BOOLEAN_VALUE; + case VALUE_FALSE: + currentValue = Boolean.FALSE; + return JsonDocumentItemType.BOOLEAN_VALUE; + case VALUE_NULL: + currentValue = null; + return JsonDocumentItemType.NULL_VALUE; + case VALUE_DECIMAL: + currentValue = this.parser.getBigDecimal(); + return JsonDocumentItemType.VALUE; + case VALUE_DOUBLE: + currentValue = this.parser.getDouble(); + return JsonDocumentItemType.VALUE; + case VALUE_FLOAT: + currentValue = this.parser.getFloat(); + return JsonDocumentItemType.VALUE; + case VALUE_BINARY: + currentValue = this.parser.getBytes(); + return JsonDocumentItemType.VALUE; + default : + throw new IllegalStateException( "Unknown OSON event: " + evt ); + } + } + + @Override + public String getObjectKeyName() { + if (currentKeyName == null) + throw new IllegalStateException("no object key available"); + return currentKeyName; + } + + @Override + public String getStringValue() { + return (String)currentValue; + } + + @Override + public BigDecimal getBigDecimalValue() { + return (BigDecimal)currentValue; + } + + @Override + public BigInteger getBigIntegerValue() { + return ((BigDecimal)currentValue).toBigInteger(); + } + + @Override + public double getDoubleValue() { + if (currentValue instanceof String) + return Double.parseDouble( (String)currentValue ); + return ((Double)currentValue).doubleValue(); + } + + @Override + public float getFloatValue() { + if (currentValue instanceof String) return Float.parseFloat( (String)currentValue ); + return ((Float)currentValue).floatValue(); + } + + @Override + public long getLongValue() { + if (currentValue instanceof String) return Long.parseLong( (String)currentValue ); + return ((BigDecimal)currentValue).longValue(); + } + + @Override + public int getIntegerValue() { + if (currentValue instanceof String) return Integer.parseInt( (String)currentValue ); + return ((BigDecimal)currentValue).intValue(); + } + + @Override + public short getShortValue() { + if (currentValue instanceof String) return Short.parseShort( (String)currentValue ); + return ((BigDecimal)currentValue).shortValue(); + } + + @Override + public byte getByteValue() { + if (currentValue instanceof String) return Byte.parseByte( (String)currentValue ); + return ((Byte)currentValue).byteValue(); + } + + @Override + public boolean getBooleanValue() { + if (currentValue instanceof String) return BooleanJavaType.INSTANCE.fromEncodedString((String)currentValue); + return ((Boolean)currentValue).booleanValue(); + } + + + + @Override + public T getValue(JavaType javaType, WrapperOptions options) { + if ( currentValue instanceof String ) { + if (((String)currentValue).length() == 36 && javaType == PrimitiveByteArrayJavaType.INSTANCE) { + // be sure that we have only allowed characters. + // that may happen for string representation of UUID (i.e 53886a8a-7082-4879-b430-25cb94415be8) for instance + return javaType.fromEncodedString( (((String) currentValue).replaceAll( "-","" )) ); + } + return javaType.fromEncodedString( (String) currentValue ); + } + + Object theOneToBeUsed = currentValue; + // handle special cases for Date things + if ( currentValue instanceof LocalDateTime ) { + if ( java.sql.Date.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + theOneToBeUsed = Date.valueOf( ((LocalDateTime)currentValue).toLocalDate() ); + } + else if ( java.time.LocalDate.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + theOneToBeUsed = ((LocalDateTime)currentValue).toLocalDate(); + } + else if ( java.time.LocalTime.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + theOneToBeUsed = ((LocalDateTime)currentValue).toLocalTime(); + } + else if ( java.sql.Time.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + theOneToBeUsed = Time.valueOf( ((LocalDateTime)currentValue).toLocalTime() ); + } + else if ( java.sql.Timestamp.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + theOneToBeUsed = Timestamp.valueOf( ((LocalDateTime)currentValue) ); + } + else if ( java.time.LocalTime.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + theOneToBeUsed = ((LocalDateTime)currentValue).toLocalTime(); + } + else if ( java.util.Date.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + // better way? + theOneToBeUsed = java.util.Date.from( ((LocalDateTime)currentValue).atZone( ZoneId.of( "UTC" ) ).toInstant() ); + } + } + + return javaType.wrap( theOneToBeUsed ,options ); + + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/OsonDocumentWriter.java b/hibernate-core/src/main/java/org/hibernate/type/format/OsonDocumentWriter.java new file mode 100644 index 000000000000..a88eca7a908a --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/OsonDocumentWriter.java @@ -0,0 +1,226 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +import oracle.jdbc.driver.json.tree.OracleJsonDateImpl; +import oracle.jdbc.driver.json.tree.OracleJsonTimestampImpl; +import oracle.sql.DATE; +import oracle.sql.TIMESTAMP; +import oracle.sql.json.OracleJsonDate; +import oracle.sql.json.OracleJsonGenerator; +import oracle.sql.json.OracleJsonTimestamp; +import org.hibernate.type.SqlTypes; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.jdbc.JdbcType; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Timestamp; +import java.time.OffsetDateTime; + +/** + * Implementation of JsonDocumentWriter for OSON document. + * This implementation will produce an Object Array based on + * embeddable mapping + * Once All JSON document is handle the mapped Object array can be retrieved using the + * getObjectArray() method. + * + * @author Emmanuel Jannetti + */ +public class OsonDocumentWriter implements JsonDocumentWriter { + + + private final OracleJsonGenerator generator; + + /** + * Creates a new OSON document writer + * @param generator the JSON generator. + */ + public OsonDocumentWriter(OracleJsonGenerator generator) { + this.generator = generator; + } + + + @Override + public JsonDocumentWriter startObject() { + this.generator.writeStartObject(); + return this; + } + + + @Override + public JsonDocumentWriter endObject() { + this.generator.writeEnd(); + return this; + } + + + @Override + public JsonDocumentWriter startArray() { + generator.writeStartArray(); + return this; + } + + + @Override + public JsonDocumentWriter endArray() { + generator.writeEnd(); + return this; + } + + + @Override + public JsonDocumentWriter objectKey(String key) { + this.generator.writeKey( key ); + return this; + } + + + @Override + public JsonDocumentWriter nullValue() { + this.generator.writeNull(); + return this; + } + + + @Override + public JsonDocumentWriter booleanValue(boolean value) { + this.generator.write(value); + return this; + } + + + @Override + public JsonDocumentWriter stringValue(String value) { + this.generator.write(value); + return this; + } + + @Override + public JsonDocumentWriter serializeJsonValue(Object value, JavaType javaType, JdbcType jdbcType, WrapperOptions options) { + serializeValue(value, javaType, jdbcType, options); + return this; + } + + /** + * Serializes a value according to its mapping type. + * This method serializes the value and writes it into the underlying generator + * + * @param value the value + * @param javaType the Java type of the value + * @param jdbcType the JDBC SQL type of the value + * @param options the wapping options. + */ + private void serializeValue(Object value, + JavaType javaType, + JdbcType jdbcType, + WrapperOptions options) { + switch ( jdbcType.getDefaultSqlTypeCode() ) { + case SqlTypes.TINYINT: + case SqlTypes.SMALLINT: + case SqlTypes.INTEGER: + if ( value instanceof Boolean ) { + // BooleanJavaType has this as an implicit conversion + int i = ((Boolean) value) ? 1 : 0; + generator.write( i ); + break; + } + if ( value instanceof Enum ) { + generator.write( ((Enum) value ).ordinal() ); + break; + } + generator.write( javaType.unwrap( (T)value,Integer.class,options ) ); + break; + case SqlTypes.BOOLEAN: + generator.write( javaType.unwrap( (T)value,Boolean.class,options ) ); + break; + case SqlTypes.BIT: + generator.write( javaType.unwrap( (T)value,Integer.class,options ) ); + break; + case SqlTypes.BIGINT: + generator.write( javaType.unwrap( (T)value, BigInteger.class,options ) ); + break; + case SqlTypes.FLOAT: + generator.write( javaType.unwrap( (T)value,Float.class,options ) ); + break; + case SqlTypes.REAL: + case SqlTypes.DOUBLE: + generator.write( javaType.unwrap( (T)value,Double.class,options ) ); + break; + case SqlTypes.CHAR: + case SqlTypes.NCHAR: + case SqlTypes.VARCHAR: + case SqlTypes.NVARCHAR: + if ( value instanceof Boolean ) { + String c = ((Boolean) value) ? "Y" : "N"; + generator.write( c ); + 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: + generator.write( javaType.toString( (T)value ) ); + break; + case SqlTypes.DATE: + DATE dd = new DATE(javaType.unwrap( (T)value,java.sql.Date.class,options )); + OracleJsonDate jsonDate = new OracleJsonDateImpl(dd.shareBytes()); + generator.write(jsonDate); + break; + case SqlTypes.TIME: + case SqlTypes.TIME_WITH_TIMEZONE: + case SqlTypes.TIME_UTC: + generator.write( javaType.toString( (T)value ) ); + break; + case SqlTypes.TIMESTAMP: + TIMESTAMP TS = new TIMESTAMP(javaType.unwrap( (T)value, Timestamp.class, options )); + OracleJsonTimestamp writeTimeStamp = new OracleJsonTimestampImpl(TS.shareBytes()); + generator.write(writeTimeStamp); + break; + case SqlTypes.TIMESTAMP_WITH_TIMEZONE: + OffsetDateTime dateTime = javaType.unwrap( (T)value, OffsetDateTime.class, options ); + generator.write( dateTime ); + break; + case SqlTypes.TIMESTAMP_UTC: + OffsetDateTime odt = javaType.unwrap( (T)value, OffsetDateTime.class, options ); + generator.write( odt ); + break; + case SqlTypes.NUMERIC: + case SqlTypes.DECIMAL: + BigDecimal bd = javaType.unwrap( (T)value, BigDecimal.class, options ); + generator.write( bd ); + break; + + case SqlTypes.DURATION: + case SqlTypes.UUID: + generator.write( javaType.toString( (T)value ) ); + break; + case SqlTypes.BINARY: + case SqlTypes.VARBINARY: + case SqlTypes.LONGVARBINARY: + case SqlTypes.LONG32VARBINARY: + case SqlTypes.BLOB: + case SqlTypes.MATERIALIZED_BLOB: + // how to handle + byte[] bytes = javaType.unwrap( (T)value, byte[].class, options ); + generator.write( bytes ); + break; + case SqlTypes.ARRAY: + case SqlTypes.JSON_ARRAY: + throw new IllegalStateException( "array case should be treated at upper level" ); + default: + throw new UnsupportedOperationException( "Unsupported JdbcType nested in JSON: " + jdbcType ); + } + + } + +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/OsonValueJDBCTypeAdapter.java b/hibernate-core/src/main/java/org/hibernate/type/format/OsonValueJDBCTypeAdapter.java new file mode 100644 index 000000000000..46a4c283d0ef --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/OsonValueJDBCTypeAdapter.java @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +import org.hibernate.type.SqlTypes; +import org.hibernate.type.descriptor.WrapperOptions; +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.JdbcType; + +import java.sql.SQLException; + + +/** + * JDBC type adapter for OSON-based JSON document reader. + */ +public class OsonValueJDBCTypeAdapter implements JsonValueJDBCTypeAdapter { + @Override + public Object fromValue(JavaType jdbcJavaType, JdbcType jdbcType, JsonDocumentReader source, WrapperOptions options) + throws SQLException { + Object valueToBeWrapped = null; + switch ( jdbcType.getDefaultSqlTypeCode() ) { + case SqlTypes.BINARY: + case SqlTypes.VARBINARY: + case SqlTypes.LONGVARBINARY: + case SqlTypes.LONG32VARBINARY: + valueToBeWrapped = source.getValue( PrimitiveByteArrayJavaType.INSTANCE, options ); + break; + case SqlTypes.DATE: + valueToBeWrapped = source.getValue( JdbcDateJavaType.INSTANCE , options); + break; + case SqlTypes.TIME: + case SqlTypes.TIME_WITH_TIMEZONE: + case SqlTypes.TIME_UTC: + valueToBeWrapped = source.getValue( JdbcTimeJavaType.INSTANCE , options); + break; + case SqlTypes.TIMESTAMP: + valueToBeWrapped = source.getValue( JdbcTimestampJavaType.INSTANCE , options); + break; + case SqlTypes.TIMESTAMP_WITH_TIMEZONE: + case SqlTypes.TIMESTAMP_UTC: + valueToBeWrapped = source.getValue( OffsetDateTimeJavaType.INSTANCE , options); + break; + case SqlTypes.TINYINT: + case SqlTypes.SMALLINT: + case SqlTypes.INTEGER: + if ( jdbcJavaType.getJavaTypeClass() == Boolean.class ) { + valueToBeWrapped = source.getIntegerValue(); + break; + } + else if ( jdbcJavaType instanceof EnumJavaType ) { + valueToBeWrapped = source.getIntegerValue(); + break; + } + case SqlTypes.CHAR: + case SqlTypes.NCHAR: + case SqlTypes.VARCHAR: + case SqlTypes.NVARCHAR: + if ( jdbcJavaType.getJavaTypeClass() == Boolean.class ) { + valueToBeWrapped = source.getBooleanValue(); + break; + } + } + if (valueToBeWrapped == null) { + valueToBeWrapped = source.getValue( jdbcJavaType , options); + } + return jdbcJavaType.wrap(valueToBeWrapped, options); + } + + @Override + public Object fromNumericValue(JavaType jdbcJavaType, JdbcType jdbcType, JsonDocumentReader source, WrapperOptions options) + throws SQLException { + return fromValue( jdbcJavaType, jdbcType, source, options ); + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocument.java b/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocument.java new file mode 100644 index 000000000000..74efb38c60d3 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocument.java @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +import org.hibernate.internal.util.collections.StandardStack; + +/** + * base class for JSON document String reader + * @author Emmanuel Jannetti + */ +public abstract class StringJsonDocument { + /** + * Processing states. This can be (nested)Object or Arrays. + * When processing objects, values are stored as [,]"key":"value"[,]. we add separator when adding new key + * When processing arrays, values are stored as [,]"value"[,]. we add separator when adding new value + */ + enum JsonProcessingState { + NONE, + STARTING_OBJECT, // object started but no value added + OBJECT_KEY_NAME, // We are processing an object key name + OBJECT, // object started, and we've started adding key/value pairs + ENDING_OBJECT, // we are ending an object + STARTING_ARRAY, // array started but no value added + ENDING_ARRAY, // we are ending an array + ARRAY // we are piling array values + } + // Stack of current processing states + protected final StandardStack processingStates = new StandardStack<>(); + + + +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocumentMarker.java b/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocumentMarker.java new file mode 100644 index 000000000000..4aefcbb3f6dd --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocumentMarker.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +/** + * Enum class for JSON object markers. + */ +public enum StringJsonDocumentMarker { + ARRAY_END(']'), + ARRAY_START('['), + OBJECT_END('}'), + OBJECT_START('{'), + SEPARATOR(','), + QUOTE('"'), + KEY_VALUE_SEPARATOR(':'), + OTHER(); + + private final char val; + StringJsonDocumentMarker(char val) { + this.val = val; + } + StringJsonDocumentMarker() { + this.val = 0; + } + public char getMarkerCharacter() { + return this.val; + } + + public static StringJsonDocumentMarker markerOf(char ch) { + switch (ch) { + case ']': + return ARRAY_END; + case '[': + return ARRAY_START; + case '}': + return OBJECT_END; + case '{': + return OBJECT_START; + case ',': + return SEPARATOR; + case '"': + return QUOTE; + case ':': + return KEY_VALUE_SEPARATOR; + default: + return OTHER; + } + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocumentReader.java b/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocumentReader.java new file mode 100644 index 000000000000..6d02753a05a6 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonDocumentReader.java @@ -0,0 +1,542 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.BigDecimalJavaType; +import org.hibernate.type.descriptor.java.BigIntegerJavaType; +import org.hibernate.type.descriptor.java.BooleanJavaType; +import org.hibernate.type.descriptor.java.ByteJavaType; +import org.hibernate.type.descriptor.java.DoubleJavaType; +import org.hibernate.type.descriptor.java.FloatJavaType; +import org.hibernate.type.descriptor.java.IntegerJavaType; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.java.LongJavaType; +import org.hibernate.type.descriptor.java.ShortJavaType; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.NoSuchElementException; + +/** + * Implementation of JsonDocumentReader for String representation of JSON objects. + */ +public class StringJsonDocumentReader extends StringJsonDocument implements JsonDocumentReader { + + private static final char ESCAPE_CHAR = '\\'; + + private final String jsonString; + private final int limit; + private int position; + private int jsonValueStart; + private int jsonValueEnd; + + /** + * Creates a new StringJsonDocumentReader + * @param json the JSON String. of the object to be parsed. + */ + public StringJsonDocumentReader(String json) { + if (json == null) { + throw new IllegalArgumentException( "json cannot be null" ); + } + this.jsonString = json; + this.position = 0; + this.limit = jsonString.length(); + this.jsonValueStart = 0; + this.jsonValueEnd = 0; + } + + @Override + public boolean hasNext() { + return this.position < this.limit; + } + + private void skipWhiteSpace() { + for (;this.position < this.limit; this.position++ ) { + if (!Character.isWhitespace( this.jsonString.charAt(this.position))) { + return; + } + } + } + + private void resetValueWindow() { + this.jsonValueStart = 0; + this.jsonValueEnd = 0; + } + + /** + * Moves the state machine according to the current state and the given marker + * + * @param marker the marker we just read + */ + private void moveStateMachine(StringJsonDocumentMarker marker) { + JsonProcessingState currentState = this.processingStates.getCurrent(); + switch (marker) { + case OBJECT_START: + if ( currentState == JsonProcessingState.STARTING_ARRAY ) { + // move the state machine to ARRAY as we are adding something to it + this.processingStates.push( JsonProcessingState.ARRAY); + } + this.processingStates.push( JsonProcessingState.STARTING_OBJECT ); + break; + case OBJECT_END: + assert this.processingStates.getCurrent() == JsonProcessingState.OBJECT || + this.processingStates.getCurrent() == JsonProcessingState.STARTING_OBJECT; + if ( this.processingStates.pop() == JsonProcessingState.OBJECT) { + assert this.processingStates.getCurrent() == JsonProcessingState.STARTING_OBJECT; + this.processingStates.pop(); + } + break; + case ARRAY_START: + this.processingStates.push( JsonProcessingState.STARTING_ARRAY ); + break; + case ARRAY_END: + assert this.processingStates.getCurrent() == JsonProcessingState.ARRAY || + this.processingStates.getCurrent() == JsonProcessingState.STARTING_ARRAY; + if ( this.processingStates.pop() == JsonProcessingState.ARRAY) { + assert this.processingStates.getCurrent() == JsonProcessingState.STARTING_ARRAY; + this.processingStates.pop(); + } + break; + case SEPARATOR: + // While processing an object, following SEPARATOR that will a key + if ( currentState == JsonProcessingState.OBJECT) { + this.processingStates.push( JsonProcessingState.OBJECT_KEY_NAME ); + } + break; + case KEY_VALUE_SEPARATOR: + // that's the start of an attribute value + assert this.processingStates.getCurrent() == JsonProcessingState.OBJECT_KEY_NAME; + // flush the OBJECT_KEY_NAME + this.processingStates.pop(); + assert this.processingStates.getCurrent() == JsonProcessingState.OBJECT; + break; + case QUOTE: + if (currentState == JsonProcessingState.STARTING_ARRAY) { + this.processingStates.push( JsonProcessingState.ARRAY ); + } + if (currentState == JsonProcessingState.STARTING_OBJECT) { + this.processingStates.push( JsonProcessingState.OBJECT ); + this.processingStates.push( JsonProcessingState.OBJECT_KEY_NAME ); + } + break; + case OTHER: + if ( currentState == JsonProcessingState.STARTING_ARRAY) { + this.processingStates.push( JsonProcessingState.ARRAY ); + } + break; + default: + throw new IllegalStateException( "Unexpected JsonProcessingState " + marker ); + } + } + + /** + * Returns the next item. + * @return the item + * @throws NoSuchElementException no more item available + * @throws IllegalStateException not a well-formed JSON string. + */ + @Override + public JsonDocumentItemType next() { + + if ( !hasNext()) throw new NoSuchElementException("no more elements"); + + while (hasNext()) { + skipWhiteSpace(); + StringJsonDocumentMarker marker = StringJsonDocumentMarker.markerOf( this.jsonString.charAt( this.position++ ) ); + moveStateMachine( marker ); + switch ( marker) { + case OBJECT_START: + resetValueWindow(); + return JsonDocumentItemType.OBJECT_START; + case OBJECT_END: + resetValueWindow(); + //this.processingStates.pop(); // closing an object or a nested one. + return JsonDocumentItemType.OBJECT_END; + case ARRAY_START: + resetValueWindow(); + //this.processingStates.push( JsonProcessingState.STARTING_ARRAY ); + return JsonDocumentItemType.ARRAY_START; + case ARRAY_END: + resetValueWindow(); + //this.processingStates.pop(); + return JsonDocumentItemType.ARRAY_END; + case QUOTE: // that's the start of an attribute key or a quoted value + // put back the quote + moveBufferPosition(-1); + consumeQuotedString(); + // That's a quote: + // - if we are at the beginning of an array that's a quoted value + // - if we are in the middle of an array, that's a quoted value + // - if we are at the beginning of an object that's a quoted key + // - if we are in the middle of an object : + // - if we just hit ':' that's a quoted value + // - if we just hit ',' that's a quoted key + switch ( this.processingStates.getCurrent() ) { + case STARTING_ARRAY: + //this.processingStates.push( JsonProcessingState.ARRAY ); + return JsonDocumentItemType.VALUE; + case ARRAY: + return JsonDocumentItemType.VALUE; + case STARTING_OBJECT: + //this.processingStates.push( JsonProcessingState.OBJECT ); + //this.processingStates.push( JsonProcessingState.OBJECT_KEY_NAME ); + return JsonDocumentItemType.VALUE_KEY; + case OBJECT: // we are processing object attribute value elements + return JsonDocumentItemType.VALUE; + case OBJECT_KEY_NAME: // we are processing object elements key + return JsonDocumentItemType.VALUE_KEY; + default: + throw new IllegalStateException( "unexpected quote read in current processing state " + + this.processingStates.getCurrent() ); + } + case KEY_VALUE_SEPARATOR: // that's the start of an attribute value + //assert this.processingStates.getCurrent() == JsonProcessingState.OBJECT_KEY_NAME; + // flush the OBJECT_KEY_NAME + //this.processingStates.pop(); + break; + case SEPARATOR: + // unless we are processing an array, following SEPARATOR that will a key + break; + case OTHER: + // here we are in front of a boolean, a null or a numeric value. + // if none of these cases we're going to raise IllegalStateException + // put back what we've read + moveBufferPosition(-1); + final int valueSize = consumeNonStringValue(); + if (valueSize == -1) { + throw new IllegalStateException( "Unrecognized marker: " + StringJsonDocumentMarker.markerOf( + this.jsonString.charAt( this.position ))); + } + switch ( this.processingStates.getCurrent() ) { + case ARRAY: + case OBJECT: + return getUnquotedValueType(this.jsonString.charAt( this.jsonValueStart)); + default: + throw new IllegalStateException( "unexpected read ["+ + this.jsonString.substring( this.jsonValueStart,this.jsonValueEnd )+ + "] in current processing state " + + this.processingStates.getCurrent() ); + } + default: { + throw new IllegalStateException( "unexpected marker ["+ + marker + + "] at position " + this.position ); + } + } + } + throw new IllegalStateException( "unexpected end of JSON ["+ + this.jsonString.substring( this.jsonValueStart,this.jsonValueEnd )+ + "] in current processing state " + + this.processingStates.getCurrent() ); + } + + /** + * Gets the type of unquoted value. + * We assume that the String value follows JSON specification. I.e unquoted value that starts with 't' can't be anything else + * than true + * @param jsonValueChar the value + * @return the type of the value + */ + private JsonDocumentItemType getUnquotedValueType(char jsonValueChar) { + switch(jsonValueChar) { + case 't': { + //true + return JsonDocumentItemType.BOOLEAN_VALUE; + } + case 'f': { + //false + return JsonDocumentItemType.BOOLEAN_VALUE; + } + case 'n' : { + // null + return JsonDocumentItemType.NULL_VALUE; + } + case '-': + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + case '8': + case '9': { + return JsonDocumentItemType.NUMERIC_VALUE; + } + default : + return JsonDocumentItemType.VALUE; + } + } + + private void moveBufferPosition(int shift) { + this.position += shift; + } + + /** + * Moves the current position to a given character, skipping any whitespace + * We expect the character to be the next non-blank character in the sequence + * @param character the character we should stop at. + * @throws IllegalStateException if we encounter an unexpected character other than white spaces before the desired one. + */ + + private void moveTo(char character) throws IllegalStateException { + int pointer = this.position; + while ( pointer < this.limit) { + char c = this.jsonString.charAt( pointer ); + if ( c == character) { + this.position = pointer == this.position?this.position:pointer - 1; + return; + } + if (!Character.isWhitespace(c)) { + // we did find an unexpected character + // let the exception raise + //this.json.reset(); + break; + } + pointer++; + } + throw new IllegalStateException("character [" + character + "] is not the next non-blank character"); + } + + private int nextQuote() { + int pointer = this.position; + + while ( pointer < this.limit) { + final char c = this.jsonString.charAt( pointer ); + if (c == ESCAPE_CHAR) { + pointer++; + } + else if (c == '"') { + // found + return pointer; + } + pointer++; + } + return -1; + } + + /** + * Consume a non-quoted value + * @return the length of this value. can be 0, -1 in case of error + */ + private int consumeNonStringValue() { + int newViewLimit = 0; + boolean allGood = false; + for (int i = this.position; i < this.limit; i++ ) { + char c = this.jsonString.charAt(i); + if ((StringJsonDocumentMarker.markerOf( c ) != StringJsonDocumentMarker.OTHER) || + Character.isWhitespace( c )) { + // hit a JSON marker or a space. + allGood = true; + // give that character back to the buffer + newViewLimit = i; + break; + } + } + + if (allGood) { + this.jsonValueEnd = newViewLimit; + this.jsonValueStart = position; + this.position = newViewLimit; + } + return allGood?(this.jsonValueEnd-this.jsonValueStart):-1; + } + /** + * Consumes a quoted value + * @return the length of this value. can be 0, -1 in case of error + */ + private void consumeQuotedString() { + + // be sure we are at a meaningful place + // key name are unquoted + moveTo( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + + // skip the quote we are positioned on. + this.position++; + + //locate ending quote + int endingQuote = nextQuote(); + if (endingQuote == -1) { + throw new IllegalStateException("Can't find ending quote of key name"); + } + + this.jsonValueEnd = endingQuote; + this.jsonValueStart = position; + + this.position = endingQuote + 1; + + } + + /** + * Ensures that the current state is on value. + * @throws IllegalStateException if not on "value" state + */ + private void ensureValueState() throws IllegalStateException { + if ( (this.processingStates.getCurrent() != JsonProcessingState.OBJECT ) && + this.processingStates.getCurrent() != JsonProcessingState.ARRAY) { + throw new IllegalStateException( "unexpected processing state: " + this.processingStates.getCurrent() ); + } + } + /** + * Ensures that we have a value ready to be exposed. i.e we just consume one. + * @throws IllegalStateException if no value available + */ + private void ensureAvailableValue() throws IllegalStateException { + if (this.jsonValueEnd == 0 ) { + throw new IllegalStateException( "No available value"); + } + } + + @Override + public String getObjectKeyName() { + if ( this.processingStates.getCurrent() != JsonProcessingState.OBJECT_KEY_NAME ) { + throw new IllegalStateException( "unexpected processing state: " + this.processingStates.getCurrent() ); + } + ensureAvailableValue(); + return this.jsonString.substring( this.jsonValueStart, this.jsonValueEnd); + } + + @Override + public String getStringValue() { + ensureValueState(); + ensureAvailableValue(); + if ( currentValueHasEscape()) { + return unescape(this.jsonString, this.jsonValueStart , this.jsonValueEnd); + } + return this.jsonString.substring( this.jsonValueStart, this.jsonValueEnd); + } + + + @Override + public BigDecimal getBigDecimalValue() { + ensureValueState(); + ensureAvailableValue(); + return BigDecimalJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public BigInteger getBigIntegerValue() { + ensureValueState(); + ensureAvailableValue(); + return BigIntegerJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public double getDoubleValue() { + ensureValueState(); + ensureAvailableValue(); + return DoubleJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public float getFloatValue() { + ensureValueState(); + ensureAvailableValue(); + return FloatJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public long getLongValue() { + ensureValueState(); + ensureAvailableValue(); + return LongJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public int getIntegerValue() { + ensureValueState(); + ensureAvailableValue(); + return IntegerJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public short getShortValue() { + ensureValueState(); + ensureAvailableValue(); + return ShortJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public byte getByteValue() { + ensureValueState(); + ensureAvailableValue(); + return ByteJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public boolean getBooleanValue() { + ensureValueState(); + ensureAvailableValue(); + return BooleanJavaType.INSTANCE.fromEncodedString( this.jsonString,this.jsonValueStart,this.jsonValueEnd ); + } + + @Override + public T getValue(JavaType javaType, WrapperOptions options) { + return javaType.fromEncodedString( this.jsonString.subSequence( this.jsonValueStart,this.jsonValueEnd )); + } + + /** + * Walks through JSON value currently located on JSON string and check if escape is used + * + * @return true if escape is found + */ + private boolean currentValueHasEscape() { + for (int i = this.jsonValueStart; iJsonDocumentWriter for String-based OSON document. + * This implementation will receive a {@link JsonAppender } to a serialze JSON object to it + * @author Emmanuel Jannetti + */ +public class StringJsonDocumentWriter extends StringJsonDocument implements JsonDocumentWriter { + + private final JsonAppender appender; + + /** + * Creates a new StringJsonDocumentWriter. + */ + public StringJsonDocumentWriter() { + this(new StringBuilder()); + } + + /** + * Creates a new StringJsonDocumentWriter. + * @param sb the StringBuilder to receive the serialized JSON + */ + public StringJsonDocumentWriter(StringBuilder sb) { + this.processingStates.push( JsonProcessingState.NONE ); + this.appender = new JsonAppender( sb ); + } + + /** + * Callback to be called when the start of an JSON object is encountered. + */ + @Override + public JsonDocumentWriter startObject() { + // Note: startArray and startObject must not call moveProcessingStateMachine() + if ( this.processingStates.getCurrent() == JsonProcessingState.STARTING_ARRAY) { + // are we building an array of objects? + // i.e, [{},...] + // move to JsonProcessingState.ARRAY first + this.processingStates.push( JsonProcessingState.ARRAY); + } + else if ( this.processingStates.getCurrent() == JsonProcessingState.ARRAY) { + // That means that we ae building an array of object ([{},...]) + // JSON object hee are treat as array item. + // -> add the marker first + this.appender.append(StringJsonDocumentMarker.SEPARATOR.getMarkerCharacter()); + } + this.appender.append( StringJsonDocumentMarker.OBJECT_START.getMarkerCharacter()); + this.processingStates.push( JsonProcessingState.STARTING_OBJECT ); + return this; + } + + /** + * Callback to be called when the end of an JSON object is encountered. + */ + @Override + public JsonDocumentWriter endObject() { + this.appender.append( StringJsonDocumentMarker.OBJECT_END.getMarkerCharacter() ); + this.processingStates.push( JsonProcessingState.ENDING_OBJECT); + moveProcessingStateMachine(); + return this; + } + + /** + * Callback to be called when the start of an array is encountered. + */ + @Override + public JsonDocumentWriter startArray() { + this.processingStates.push( JsonProcessingState.STARTING_ARRAY ); + // Note: startArray and startObject do not call moveProcessingStateMachine() + this.appender.append( StringJsonDocumentMarker.ARRAY_START.getMarkerCharacter() ); + return this; + } + + /** + * Callback to be called when the end of an array is encountered. + */ + @Override + public JsonDocumentWriter endArray() { + this.appender.append( StringJsonDocumentMarker.ARRAY_END.getMarkerCharacter() ); + this.processingStates.push( JsonProcessingState.ENDING_ARRAY); + moveProcessingStateMachine(); + return this; + } + + + @Override + public JsonDocumentWriter objectKey(String key) { + + if (key == null || key.length() == 0) { + throw new IllegalArgumentException( "key cannot be null or empty" ); + } + + if (JsonProcessingState.OBJECT.equals(this.processingStates.getCurrent())) { + // we have started an object, and we are adding an item key: we do add a separator. + this.appender.append( StringJsonDocumentMarker.SEPARATOR.getMarkerCharacter() ); + } + this.appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + this.appender.append( key ); + this.appender.append( "\":" ); + moveProcessingStateMachine(); + return this; + } + + /** + * Adds a separator if needed. + * The logic here is know if we have to prepend a separator + * as such, it must be called at the beginning of all methods + * Separator is to separate array items or key/value pairs in an object. + */ + private void addItemsSeparator() { + if (this.processingStates.getCurrent().equals( JsonProcessingState.ARRAY )) { + // We started to serialize an array and already added item to it:add a separator anytime. + this.appender.append( StringJsonDocumentMarker.SEPARATOR.getMarkerCharacter() ); + } + } + + /** + * Changes the current processing state. + * we are called after an item (array item or object item) has been added, + * do whatever it takes to move away from the current state by picking up the next logical one. + *

    + * We have to deal with two kinds of (possibly empty) structure + *

      + *
    • array of objects and values [{},null,{},"foo", ...]
    • + *
    • objects than have array as attribute value {k1:v1, k2:[v21,v22,..], k3:v3, k4:null, ...}
    • + *
    + *
    +	 *   NONE -> SA -> (A,...) --> SO -> O -> EO -> A
    +	 *                         --> EA -> NONE
    +	 *              -> EA  -> NONE
    +	 *
    +	 *        -> SO -> (O,...) ------------------> SA -> A -> EA -> O
    +	 *                         --> EO -> NONE         -> EA -> O
    +	 *              -> EO -> NONE
    +	 *
    +	 *    
    + * + */ + private void moveProcessingStateMachine() { + switch (this.processingStates.getCurrent()) { + case STARTING_OBJECT: + //after starting an object, we start adding key/value pairs + this.processingStates.push( JsonProcessingState.OBJECT ); + break; + case STARTING_ARRAY: + //after starting an array, we start adding value to it + this.processingStates.push( JsonProcessingState.ARRAY ); + break; + case ENDING_ARRAY: + // when ending an array, we have one or two states. + // ARRAY (unless this is an empty array) + // STARTING_ARRAY + // first pop ENDING_ARRAY + this.processingStates.pop(); + // if we have ARRAY, so that's not an empty array. pop that state + if (this.processingStates.getCurrent().equals( JsonProcessingState.ARRAY )) + this.processingStates.pop(); + assert this.processingStates.pop().equals( JsonProcessingState.STARTING_ARRAY ); + break; + case ENDING_OBJECT: + // when ending an object, we have one or two states. + // OBJECT (unless this is an empty object) + // STARTING_OBJECT + // first pop ENDING_OBJECT + this.processingStates.pop(); + // if we have OBJECT, so that's not an empty object. pop that state + if (this.processingStates.getCurrent().equals( JsonProcessingState.OBJECT )) + this.processingStates.pop(); + assert this.processingStates.pop().equals( JsonProcessingState.STARTING_OBJECT ); + break; + default: + //nothing to do for the other ones. + } + } + + @Override + public JsonDocumentWriter nullValue() { + addItemsSeparator(); + this.appender.append( "null" ); + moveProcessingStateMachine(); + return this; + } + + @Override + public JsonDocumentWriter booleanValue(boolean value) { + addItemsSeparator(); + BooleanJavaType.INSTANCE.appendEncodedString( this.appender, value); + moveProcessingStateMachine(); + return this; + } + + @Override + public JsonDocumentWriter stringValue(String value) { + addItemsSeparator(); + + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter()); + appender.startEscaping(); + appender.append( value ); + appender.endEscaping(); + appender.append(StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + + moveProcessingStateMachine(); + return this; + } + + @Override + public JsonDocumentWriter serializeJsonValue(Object value, JavaType javaType, JdbcType jdbcType, WrapperOptions options) { + addItemsSeparator(); + convertedBasicValueToString(value, options,this.appender,javaType,jdbcType); + moveProcessingStateMachine(); + return this; + } + + private void convertedCastBasicValueToString(Object value, + WrapperOptions options, + JsonAppender appender, + JavaType javaType, + JdbcType jdbcType) { + assert javaType.isInstance( value ); + //noinspection unchecked + convertedBasicValueToString( (T) value, options, appender, javaType, jdbcType ); + } + + + /** + * Converts a value to String according to its mapping type. + * This method serializes the value and writes it into the underlying appender + * + * @param value the value + * @param javaType the Java type of the value + * @param jdbcType the JDBC SQL type of the value + * @param options the wapping options. + */ + private void convertedBasicValueToString( + Object value, + WrapperOptions options, + JsonAppender appender, + JavaType javaType, + JdbcType jdbcType) { + + assert javaType.isInstance( value ); + + switch ( jdbcType.getDefaultSqlTypeCode() ) { + case SqlTypes.TINYINT: + case SqlTypes.SMALLINT: + case SqlTypes.INTEGER: + if ( value instanceof Boolean ) { + // BooleanJavaType has this as an implicit conversion + appender.append( (Boolean) value ? '1' : '0' ); + break; + } + if ( value instanceof Enum ) { + appender.appendSql( ((Enum) value ).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, (T)value ); + break; + case SqlTypes.CHAR: + case SqlTypes.NCHAR: + case SqlTypes.VARCHAR: + case SqlTypes.NVARCHAR: + if ( value instanceof Boolean ) { + // BooleanJavaType has this as an implicit conversion + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + appender.append( (Boolean) value ? 'Y' : 'N' ); + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter()); + 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( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + appender.startEscaping(); + javaType.appendEncodedString( appender, (T)value ); + appender.endEscaping(); + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + break; + case SqlTypes.DATE: + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + JdbcDateJavaType.INSTANCE.appendEncodedString( + appender, + javaType.unwrap( (T)value, java.sql.Date.class, options ) + ); + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + break; + case SqlTypes.TIME: + case SqlTypes.TIME_WITH_TIMEZONE: + case SqlTypes.TIME_UTC: + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + JdbcTimeJavaType.INSTANCE.appendEncodedString( + appender, + javaType.unwrap( (T)value, java.sql.Time.class, options ) + ); + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + break; + case SqlTypes.TIMESTAMP: + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + JdbcTimestampJavaType.INSTANCE.appendEncodedString( + appender, + javaType.unwrap( (T)value, java.sql.Timestamp.class, options ) + ); + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + break; + case SqlTypes.TIMESTAMP_WITH_TIMEZONE: + case SqlTypes.TIMESTAMP_UTC: + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + DateTimeFormatter.ISO_OFFSET_DATE_TIME.formatTo( + javaType.unwrap( (T)value, OffsetDateTime.class, options ), + appender + ); + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + 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( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + javaType.appendEncodedString( appender, (T)value ); + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + 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( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + appender.write( javaType.unwrap( (T)value, byte[].class, options ) ); + appender.append( StringJsonDocumentMarker.QUOTE.getMarkerCharacter() ); + break; + case SqlTypes.ARRAY: + case SqlTypes.JSON_ARRAY: + // Caller handles this. We should never end up here actually. + throw new IllegalStateException("unexpected JSON array type"); + default: + throw new UnsupportedOperationException( "Unsupported JdbcType nested in JSON: " + jdbcType ); + } + } + + public String getJson() { + return appender.toString(); + } + + @Override + public String toString() { + return appender.toString(); + } + + private static class JsonAppender extends OutputStream implements SqlAppender { + + private final static char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + + private final StringBuilder sb; + private boolean escape; + + public JsonAppender(StringBuilder sb) { + this.sb = sb; + } + + @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] ); + } + } + + 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; + } + } + + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonValueJDBCTypeAdapter.java b/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonValueJDBCTypeAdapter.java new file mode 100644 index 000000000000..41eba74f2911 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/StringJsonValueJDBCTypeAdapter.java @@ -0,0 +1,149 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format; + +import org.hibernate.internal.util.CharSequenceHelper; +import org.hibernate.metamodel.mapping.EmbeddableMappingType; +import org.hibernate.type.SqlTypes; +import org.hibernate.type.descriptor.WrapperOptions; +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.JdbcType; +import org.hibernate.type.descriptor.jdbc.StructAttributeValues; +import org.hibernate.type.descriptor.jdbc.StructHelper; + +import java.sql.SQLException; + +import static org.hibernate.type.descriptor.jdbc.StructHelper.instantiate; + +/** + * JDBC type adapter for String-based JSON document reader. + */ +public class StringJsonValueJDBCTypeAdapter implements JsonValueJDBCTypeAdapter { + + private boolean returnEmbeddable; + public StringJsonValueJDBCTypeAdapter(boolean returnEmbeddable) { + this.returnEmbeddable = returnEmbeddable; + } + + @Override + public Object fromValue(JavaType jdbcJavaType, JdbcType jdbcType, JsonDocumentReader source, WrapperOptions options) + throws SQLException { + return fromAnyValue (jdbcJavaType, jdbcType, source, options); + } + + private Object fromAnyValue(JavaType jdbcJavaType, JdbcType jdbcType, JsonDocumentReader source, WrapperOptions options) + throws SQLException { + + String string = source.getStringValue(); + + switch ( jdbcType.getDefaultSqlTypeCode() ) { + case SqlTypes.BINARY: + case SqlTypes.VARBINARY: + case SqlTypes.LONGVARBINARY: + case SqlTypes.LONG32VARBINARY: + return jdbcJavaType.wrap( + PrimitiveByteArrayJavaType.INSTANCE.fromEncodedString( + string, + 0, string.length()), + options + ); + case SqlTypes.UUID: + return jdbcJavaType.wrap( + PrimitiveByteArrayJavaType.INSTANCE.fromString( + string.substring( 0, string.length() ).replace( "-", "" ) + ), + options + ); + case SqlTypes.DATE: + return jdbcJavaType.wrap( + JdbcDateJavaType.INSTANCE.fromEncodedString( + string, + 0, + string.length() + ), + options + ); + case SqlTypes.TIME: + case SqlTypes.TIME_WITH_TIMEZONE: + case SqlTypes.TIME_UTC: + return jdbcJavaType.wrap( + JdbcTimeJavaType.INSTANCE.fromEncodedString( + string, + 0, + string.length() + ), + options + ); + case SqlTypes.TIMESTAMP: + return jdbcJavaType.wrap( + JdbcTimestampJavaType.INSTANCE.fromEncodedString( + string, + 0, + string.length() + ), + options + ); + case SqlTypes.TIMESTAMP_WITH_TIMEZONE: + case SqlTypes.TIMESTAMP_UTC: + return jdbcJavaType.wrap( + OffsetDateTimeJavaType.INSTANCE.fromEncodedString( + string, + 0, + string.length()), + options + ); + case SqlTypes.TINYINT: + case SqlTypes.SMALLINT: + case SqlTypes.INTEGER: + if ( jdbcJavaType.getJavaTypeClass() == Boolean.class ) { + return jdbcJavaType.wrap( Integer.parseInt( string, 0, string.length(), 10 ), options ); + } + else if ( jdbcJavaType instanceof EnumJavaType ) { + return jdbcJavaType.wrap( Integer.parseInt( string, 0, string.length(), 10 ), options ); + } + case SqlTypes.CHAR: + case SqlTypes.NCHAR: + case SqlTypes.VARCHAR: + case SqlTypes.NVARCHAR: + if ( jdbcJavaType.getJavaTypeClass() == Boolean.class && (string.length() == 1 ) ) { + return jdbcJavaType.wrap( string.charAt( 0 ), options ); + } + default: + if ( jdbcType instanceof AggregateJdbcType aggregateJdbcType ) { + final Object[] subValues = aggregateJdbcType.extractJdbcValues( + CharSequenceHelper.subSequence( + string, + 0, + string.length()), + options + ); + if ( returnEmbeddable ) { + final StructAttributeValues subAttributeValues = StructHelper.getAttributeValues( + aggregateJdbcType.getEmbeddableMappingType(), + subValues, + options + ); + final EmbeddableMappingType embeddableMappingType = aggregateJdbcType.getEmbeddableMappingType(); + return instantiate( embeddableMappingType, subAttributeValues ) ; + } + return subValues; + } + + return jdbcJavaType.fromEncodedString(string); + } + } + + @Override + public Object fromNumericValue(JavaType jdbcJavaType, JdbcType jdbcType, JsonDocumentReader source, WrapperOptions options) throws SQLException { + return fromAnyValue (jdbcJavaType, jdbcType, source, options); + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonIntegration.java b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonIntegration.java index 5b63787b881b..4d8552d59402 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonIntegration.java +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonIntegration.java @@ -12,9 +12,12 @@ public final class JacksonIntegration { // when GraalVM native image is initializing them. private static final boolean JACKSON_XML_AVAILABLE = ableToLoadJacksonXMLMapper(); private static final boolean JACKSON_JSON_AVAILABLE = ableToLoadJacksonJSONMapper(); - private static final JacksonXmlFormatMapper XML_FORMAT_MAPPER = JACKSON_XML_AVAILABLE ? new JacksonXmlFormatMapper() : null; - private static final JacksonXmlFormatMapper XML_FORMAT_MAPPER_PORTABLE = JACKSON_XML_AVAILABLE ? new JacksonXmlFormatMapper( false ) : null; - private static final JacksonJsonFormatMapper JSON_FORMAT_MAPPER = JACKSON_JSON_AVAILABLE ? new JacksonJsonFormatMapper() : null; + private static final boolean JACKSON_OSON_AVAILABLE = ableToLoadJacksonOSONFactory(); + private static final FormatMapper XML_FORMAT_MAPPER = JACKSON_XML_AVAILABLE ? new JacksonXmlFormatMapper() : null; + private static final FormatMapper XML_FORMAT_MAPPER_PORTABLE = JACKSON_XML_AVAILABLE ? new JacksonXmlFormatMapper( false ) : null; + private static final FormatMapper JSON_FORMAT_MAPPER = JACKSON_JSON_AVAILABLE ? new JacksonJsonFormatMapper() : null; + private static final FormatMapper OSON_FORMAT_MAPPER = JACKSON_OSON_AVAILABLE ? new JacksonOsonFormatMapper() : null; + private JacksonIntegration() { //To not be instantiated: static helpers only @@ -28,8 +31,14 @@ private static boolean ableToLoadJacksonXMLMapper() { return canLoad( "com.fasterxml.jackson.dataformat.xml.XmlMapper" ); } - public static FormatMapper getXMLJacksonFormatMapperOrNull() { - return XML_FORMAT_MAPPER; + /** + * Checks that Jackson is available and that we have the Oracle OSON extension available + * in the classpath. + * @return true if we can load the OSON support, false otherwise. + */ + private static boolean ableToLoadJacksonOSONFactory() { + return ableToLoadJacksonJSONMapper() && + canLoad( "oracle.jdbc.provider.oson.OsonFactory" ); } public static FormatMapper getXMLJacksonFormatMapperOrNull(boolean legacyFormat) { @@ -39,6 +48,18 @@ public static FormatMapper getXMLJacksonFormatMapperOrNull(boolean legacyFormat) public static FormatMapper getJsonJacksonFormatMapperOrNull() { return JSON_FORMAT_MAPPER; } + public static FormatMapper getOsonJacksonFormatMapperOrNull() { + return OSON_FORMAT_MAPPER; + } + + /** + * Checks that Oracle OSON extension available + * + * @return true if we can load the OSON support, false otherwise. + */ + public static boolean isJacksonOsonExtensionAvailable() { + return JACKSON_OSON_AVAILABLE; + } private static boolean canLoad(String name) { try { diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonJsonFormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonJsonFormatMapper.java index d9ecf79a9a4c..cc1f0c0aac42 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonJsonFormatMapper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonJsonFormatMapper.java @@ -4,11 +4,16 @@ */ package org.hibernate.type.format.jackson; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; import org.hibernate.type.format.AbstractJsonFormatMapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; import java.lang.reflect.Type; /** @@ -22,13 +27,35 @@ public final class JacksonJsonFormatMapper extends AbstractJsonFormatMapper { private final ObjectMapper objectMapper; public JacksonJsonFormatMapper() { - this(new ObjectMapper().findAndRegisterModules()); + this( new ObjectMapper().findAndRegisterModules() ); } public JacksonJsonFormatMapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } + @Override + public void writeToTarget(T value, JavaType javaType, Object target, WrapperOptions options) + throws IOException { + objectMapper.writerFor( objectMapper.constructType( javaType.getJavaType() ) ) + .writeValue( (JsonGenerator) target, value ); + } + + @Override + public T readFromSource(JavaType javaType, Object source, WrapperOptions options) throws IOException { + return objectMapper.readValue( (JsonParser) source, objectMapper.constructType( javaType.getJavaType() ) ); + } + + @Override + public boolean supportsSourceType(Class sourceType) { + return JsonParser.class.isAssignableFrom( sourceType ); + } + + @Override + public boolean supportsTargetType(Class targetType) { + return JsonGenerator.class.isAssignableFrom( targetType ); + } + @Override public T fromString(CharSequence charSequence, Type type) { try { diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonOsonFormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonOsonFormatMapper.java new file mode 100644 index 000000000000..e2c36ee25c84 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonOsonFormatMapper.java @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format.jackson; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import oracle.jdbc.provider.oson.OsonModule; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.format.AbstractJsonFormatMapper; + +import java.io.IOException; +import java.lang.reflect.Type; + + +/** + * Implementation of FormatMapper for Oracle OSON support + * + * @author Emmanuel Jannetti + * @author Bidyadhar Mohanty + */ +public final class JacksonOsonFormatMapper extends AbstractJsonFormatMapper { + + public static final String SHORT_NAME = "jackson-oson"; + + private final ObjectMapper objectMapper; + + /** + * Creates a new JacksonOsonFormatMapper + */ + public JacksonOsonFormatMapper() { + this( new ObjectMapper().findAndRegisterModules() ); + } + + public JacksonOsonFormatMapper(ObjectMapper objectMapper) { + objectMapper.registerModule( new OsonModule() ); + objectMapper.disable( SerializationFeature.WRITE_DATES_AS_TIMESTAMPS ); + this.objectMapper = objectMapper; + } + + @Override + public void writeToTarget(T value, JavaType javaType, Object target, WrapperOptions options) + throws IOException { + objectMapper.writerFor( objectMapper.constructType( javaType.getJavaType() ) ) + .writeValue( (JsonGenerator) target, value ); + } + + @Override + public T readFromSource(JavaType javaType, Object source, WrapperOptions options) throws IOException { + return objectMapper.readValue( (JsonParser) source, objectMapper.constructType( javaType.getJavaType() ) ); + } + + @Override + public boolean supportsSourceType(Class sourceType) { + return JsonParser.class.isAssignableFrom( sourceType ); + } + + @Override + public boolean supportsTargetType(Class targetType) { + return JsonGenerator.class.isAssignableFrom( targetType ); + } + + @Override + public T fromString(CharSequence charSequence, Type type) { + try { + return objectMapper.readValue( charSequence.toString(), objectMapper.constructType( type ) ); + } + catch (JsonProcessingException e) { + throw new IllegalArgumentException( "Could not deserialize string to java type: " + type, e ); + } + } + + @Override + public String toString(T value, Type type) { + try { + return objectMapper.writerFor( objectMapper.constructType( type ) ).writeValueAsString( value ); + } + catch (JsonProcessingException e) { + throw new IllegalArgumentException( "Could not serialize object of java type: " + type, e ); + } + } + +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonXmlFormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonXmlFormatMapper.java index 170a683d2480..a7c84e6c694c 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonXmlFormatMapper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonXmlFormatMapper.java @@ -40,6 +40,7 @@ /** * @author Christian Beikov + * @author Emmanuel Jannetti */ public final class JacksonXmlFormatMapper implements FormatMapper { diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jakartajson/JsonBJsonFormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/jakartajson/JsonBJsonFormatMapper.java index 515c3bb7fc14..67404b2becc5 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/format/jakartajson/JsonBJsonFormatMapper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jakartajson/JsonBJsonFormatMapper.java @@ -15,6 +15,7 @@ /** * @author Christian Beikov * @author Yanming Zhou + * @author Emmanuel Jannetti */ public final class JsonBJsonFormatMapper extends AbstractJsonFormatMapper { diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jaxb/JaxbXmlFormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/jaxb/JaxbXmlFormatMapper.java index c0264691252f..4aff790faf84 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/format/jaxb/JaxbXmlFormatMapper.java +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jaxb/JaxbXmlFormatMapper.java @@ -4,6 +4,7 @@ */ package org.hibernate.type.format.jaxb; +import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import java.lang.reflect.Array; @@ -497,6 +498,27 @@ else if ( javaType.getJavaTypeClass().isArray() ) { } } + @Override + public boolean supportsSourceType(Class sourceType) { + return false; + } + + @Override + public boolean supportsTargetType(Class targetType) { + return false; + } + + @Override + public void writeToTarget(T value, JavaType javaType, Object target, WrapperOptions options) + throws IOException { + + } + + @Override + public T readFromSource(JavaType javaType, Object source, WrapperOptions options) throws IOException { + return null; + } + private JAXBElementTransformer createTransformer( StringBuilderSqlAppender appender, Class elementClass, diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/dialect/unit/locktimeout/OracleLockTimeoutTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/dialect/unit/locktimeout/OracleLockTimeoutTest.java index 9b6bbc2e7646..10db83e4c285 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/dialect/unit/locktimeout/OracleLockTimeoutTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/dialect/unit/locktimeout/OracleLockTimeoutTest.java @@ -9,7 +9,7 @@ import org.hibernate.dialect.DatabaseVersion; import org.hibernate.dialect.Dialect; import org.hibernate.dialect.OracleDialect; - +import org.hibernate.testing.RequiresDialect; import org.hibernate.testing.junit4.BaseUnitTestCase; import org.junit.Test; @@ -18,6 +18,7 @@ /** * @author Vlad Mihalcea */ +@RequiresDialect(OracleDialect.class) public class OracleLockTimeoutTest extends BaseUnitTestCase { private final Dialect dialect = new OracleDialect( DatabaseVersion.make( 12 ) ); diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/EmbeddableAggregate.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/EmbeddableAggregate.java index 069ed1ac1559..4edb530ade4e 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/EmbeddableAggregate.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/EmbeddableAggregate.java @@ -292,6 +292,14 @@ public void setMutableValue(MutableValue mutableValue) { this.mutableValue = mutableValue; } + static void assertArraysEquals(EmbeddableAggregate [] a1, EmbeddableAggregate []a2) { + Assertions.assertTrue( (a1 == null && a2 == null) || (a1 != null && a2 != null) ); + Assertions.assertEquals( a1.length, a2.length ); + for (int i = 0; i < a1.length; i++) { + assertEquals(a1[i], a2[i]); + } + } + static void assertEquals(EmbeddableAggregate a1, EmbeddableAggregate a2) { Assertions.assertEquals( a1.theInt, a2.theInt ); Assertions.assertEquals( a1.theDouble, a2.theDouble ); @@ -338,6 +346,13 @@ static void assertEquals(EmbeddableAggregate a1, EmbeddableAggregate a2) { } } + public static EmbeddableAggregate[] createAggregateArray1() { + return new EmbeddableAggregate[] {createAggregate1(),createAggregate2()}; + } + public static EmbeddableAggregate[] createAggregateArray2() { + return new EmbeddableAggregate[] {createAggregate3()}; + } + public static EmbeddableAggregate createAggregate1() { final EmbeddableAggregate aggregate = new EmbeddableAggregate(); aggregate.theBoolean = true; diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/JsonEmbeddableArrayTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/JsonEmbeddableArrayTest.java new file mode 100644 index 000000000000..7f0a874dcb2d --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/JsonEmbeddableArrayTest.java @@ -0,0 +1,336 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.mapping.embeddable; + +import java.net.URL; +import java.sql.Time; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.testing.orm.junit.FailureExpected; +import org.hibernate.type.SqlTypes; + +import org.hibernate.testing.orm.domain.gambit.EntityOfBasics; +import org.hibernate.testing.orm.domain.gambit.MutableValue; +import org.hibernate.testing.orm.junit.BaseSessionFactoryFunctionalTest; +import org.hibernate.testing.orm.junit.DialectFeatureChecks; +import org.hibernate.testing.orm.junit.RequiresDialectFeature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Tuple; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@RequiresDialectFeature(feature = DialectFeatureChecks.SupportsJsonAggregate.class) +public class JsonEmbeddableArrayTest extends BaseSessionFactoryFunctionalTest { + + @Override + protected Class[] getAnnotatedClasses() { + return new Class[] { + JsonEmbeddableArrayTest.JsonArrayHolder.class + }; + } + + @BeforeEach + public void setUp() { + inTransaction( + session -> { + session.persist( new JsonArrayHolder( 1L, EmbeddableAggregate.createAggregateArray1() )); + session.persist( new JsonArrayHolder( 2L, EmbeddableAggregate.createAggregateArray2() )); + } + ); + } + + @AfterEach + protected void cleanupTest() { + inTransaction( + session -> { + session.createMutationQuery( "delete from JsonArrayHolder h" ).executeUpdate(); + } + ); + } + + @Test + public void testUpdate() { + sessionFactoryScope().inTransaction( + entityManager -> { + JsonArrayHolder jsonArrayHolder = entityManager.find( JsonArrayHolder.class, 1L ); + jsonArrayHolder.setAggregateArray( EmbeddableAggregate.createAggregateArray2() ); + entityManager.flush(); + entityManager.clear(); + EmbeddableAggregate.assertArraysEquals( + EmbeddableAggregate.createAggregateArray2(), + entityManager.find( JsonArrayHolder.class, 1L ).getAggregateArray() ); + } + ); + } + + @Test + public void testFetch() { + sessionFactoryScope().inSession( + entityManager -> { + List jsonArrayHolders = entityManager.createQuery( "from JsonArrayHolder b where b.id = 1", JsonArrayHolder.class ).getResultList(); + assertEquals( 1, jsonArrayHolders.size() ); + assertEquals( 1L, jsonArrayHolders.get( 0 ).getId() ); + EmbeddableAggregate.assertArraysEquals( EmbeddableAggregate.createAggregateArray1(), jsonArrayHolders.get( 0 ).getAggregateArray() ); + } + ); + } + + @Test + public void testFetchNull() { + sessionFactoryScope().inSession( + entityManager -> { + List jsonArrayHolders = entityManager.createQuery( "from JsonArrayHolder b where b.id = 2", JsonArrayHolder.class ).getResultList(); + assertEquals( 1, jsonArrayHolders.size() ); + assertEquals( 2L, jsonArrayHolders.get( 0 ).getId() ); + EmbeddableAggregate.assertEquals( EmbeddableAggregate.createAggregateArray2()[0], jsonArrayHolders.get( 0 ).getAggregateArray()[0] ); + } + ); + } + + @Test + public void testDomainResult() { + sessionFactoryScope().inSession( + entityManager -> { + List structs = entityManager.createQuery( "select b.aggregateArray from JsonArrayHolder b where b.id = 1", EmbeddableAggregate[].class ).getResultList(); + assertEquals( 1, structs.size() ); + EmbeddableAggregate.assertArraysEquals( EmbeddableAggregate.createAggregateArray1(), structs.get( 0 ) ); + } + ); + } + + @Test + @FailureExpected(jiraKey = "HHH-18717", reason = "Requires array functions to work with JSON_ARRAY") + public void testSelectionItems() { + sessionFactoryScope().inSession( + entityManager -> { + List tuples = entityManager.createQuery( + "select " + + "b.aggregateArray[0].theInt," + + "b.aggregateArray[0].theDouble," + + "b.aggregateArray[0].theBoolean," + + "b.aggregateArray[0].theNumericBoolean," + + "b.aggregateArray[0].theStringBoolean," + + "b.aggregateArray[0].theString," + + "b.aggregateArray[0].theInteger," + + "b.aggregateArray[0].theUrl," + + "b.aggregateArray[0].theClob," + + "b.aggregateArray[0].theBinary," + + "b.aggregateArray[0].theDate," + + "b.aggregateArray[0].theTime," + + "b.aggregateArray[0].theTimestamp," + + "b.aggregateArray[0].theInstant," + + "b.aggregateArray[0].theUuid," + + "b.aggregateArray[0].gender," + + "b.aggregateArray[0].convertedGender," + + "b.aggregateArray[0].ordinalGender," + + "b.aggregateArray[0].theDuration," + + "b.aggregateArray[0].theLocalDateTime," + + "b.aggregateArray[0].theLocalDate," + + "b.aggregateArray[0].theLocalTime," + + "b.aggregateArray[0].theZonedDateTime," + + "b.aggregateArray[0].theOffsetDateTime," + + "b.aggregateArray[0].mutableValue " + + "from JsonArrayHolder b where b.id = 1", + Tuple.class + ).getResultList(); + assertEquals( 1, tuples.size() ); + final Tuple tuple = tuples.get( 0 ); + final EmbeddableAggregate struct = new EmbeddableAggregate(); + struct.setTheInt( tuple.get( 0, int.class ) ); + struct.setTheDouble( tuple.get( 1, Double.class ) ); + struct.setTheBoolean( tuple.get( 2, Boolean.class ) ); + struct.setTheNumericBoolean( tuple.get( 3, Boolean.class ) ); + struct.setTheStringBoolean( tuple.get( 4, Boolean.class ) ); + struct.setTheString( tuple.get( 5, String.class ) ); + struct.setTheInteger( tuple.get( 6, Integer.class ) ); + struct.setTheUrl( tuple.get( 7, URL.class ) ); + struct.setTheClob( tuple.get( 8, String.class ) ); + struct.setTheBinary( tuple.get( 9, byte[].class ) ); + struct.setTheDate( tuple.get( 10, Date.class ) ); + struct.setTheTime( tuple.get( 11, Time.class ) ); + struct.setTheTimestamp( tuple.get( 12, Timestamp.class ) ); + struct.setTheInstant( tuple.get( 13, Instant.class ) ); + struct.setTheUuid( tuple.get( 14, UUID.class ) ); + struct.setGender( tuple.get( 15, EntityOfBasics.Gender.class ) ); + struct.setConvertedGender( tuple.get( 16, EntityOfBasics.Gender.class ) ); + struct.setOrdinalGender( tuple.get( 17, EntityOfBasics.Gender.class ) ); + struct.setTheDuration( tuple.get( 18, Duration.class ) ); + struct.setTheLocalDateTime( tuple.get( 19, LocalDateTime.class ) ); + struct.setTheLocalDate( tuple.get( 20, LocalDate.class ) ); + struct.setTheLocalTime( tuple.get( 21, LocalTime.class ) ); + struct.setTheZonedDateTime( tuple.get( 22, ZonedDateTime.class ) ); + struct.setTheOffsetDateTime( tuple.get( 23, OffsetDateTime.class ) ); + struct.setMutableValue( tuple.get( 24, MutableValue.class ) ); + EmbeddableAggregate.assertEquals( EmbeddableAggregate.createAggregate1(), struct ); + } + ); + } + + @Test + public void testDeleteWhere() { + sessionFactoryScope().inTransaction( + entityManager -> { + entityManager.createMutationQuery( "delete JsonArrayHolder b where b.aggregateArray is not null" ).executeUpdate(); + assertNull( entityManager.find( JsonArrayHolder.class, 1L ) ); + + } + ); + } + + @Test + public void testUpdateAggregate() { + sessionFactoryScope().inTransaction( + entityManager -> { + entityManager.createMutationQuery( "update JsonArrayHolder b set b.aggregateArray = null" ).executeUpdate(); + assertNull( entityManager.find( JsonArrayHolder.class, 1L ).getAggregateArray() ); + } + ); + } + + @Test + @FailureExpected(jiraKey = "HHH-18717", reason = "Requires array functions to work with JSON_ARRAY") + public void testUpdateAggregateMember() { + sessionFactoryScope().inTransaction( + entityManager -> { + entityManager.createMutationQuery( "update JsonArrayHolder b set b.aggregateArray[0].theString = null where b.id = 1" ).executeUpdate(); + EmbeddableAggregate[] struct = EmbeddableAggregate.createAggregateArray1(); + struct[0].setTheString( null ); + EmbeddableAggregate.assertArraysEquals( struct, entityManager.find( JsonArrayHolder.class, 1L ).getAggregateArray() ); + } + ); + } + + @Test + @FailureExpected(jiraKey = "HHH-18717", reason = "Requires array functions to work with JSON_ARRAY") + public void testUpdateMultipleAggregateMembers() { + sessionFactoryScope().inTransaction( + entityManager -> { + entityManager.createMutationQuery( "update JsonArrayHolder b set b.aggregateArray.theString = null, b.aggregateArray[0].theUuid = null" ).executeUpdate(); + EmbeddableAggregate[] struct = EmbeddableAggregate.createAggregateArray1(); + struct[0].setTheString( null ); + struct[0].setTheUuid( null ); + EmbeddableAggregate.assertArraysEquals( struct, entityManager.find( JsonArrayHolder.class, 1L ).getAggregateArray() ); + } + ); + } + + @Test + @FailureExpected(jiraKey = "HHH-18717", reason = "Requires array functions to work with JSON_ARRAY") + public void testUpdateAllAggregateMembers() { + sessionFactoryScope().inTransaction( + entityManager -> { + EmbeddableAggregate[] struct = EmbeddableAggregate.createAggregateArray1(); + entityManager.createMutationQuery( + "update JsonArrayHolder b set " + + "b.aggregateArray[0].theInt = :theInt," + + "b.aggregateArray[0].theDouble = :theDouble," + + "b.aggregateArray[0].theBoolean = :theBoolean," + + "b.aggregateArray[0].theNumericBoolean = :theNumericBoolean," + + "b.aggregateArray[0].theStringBoolean = :theStringBoolean," + + "b.aggregateArray[0].theString = :theString," + + "b.aggregateArray[0].theInteger = :theInteger," + + "b.aggregateArray[0].theUrl = :theUrl," + + "b.aggregateArray[0].theClob = :theClob," + + "b.aggregateArray[0].theBinary = :theBinary," + + "b.aggregateArray[0].theDate = :theDate," + + "b.aggregateArray[0].theTime = :theTime," + + "b.aggregateArray[0].theTimestamp = :theTimestamp," + + "b.aggregateArray[0].theInstant = :theInstant," + + "b.aggregateArray[0].theUuid = :theUuid," + + "b.aggregateArray[0].gender = :gender," + + "b.aggregateArray[0].convertedGender = :convertedGender," + + "b.aggregateArray[0].ordinalGender = :ordinalGender," + + "b.aggregateArray[0].theDuration = :theDuration," + + "b.aggregateArray[0].theLocalDateTime = :theLocalDateTime," + + "b.aggregateArray[0].theLocalDate = :theLocalDate," + + "b.aggregateArray[0].theLocalTime = :theLocalTime," + + "b.aggregateArray[0].theZonedDateTime = :theZonedDateTime," + + "b.aggregateArray[0].theOffsetDateTime = :theOffsetDateTime," + + "b.aggregateArray[0].mutableValue = :mutableValue " + + "where b.id = 2" + ) + .setParameter( "theInt", struct[0].getTheInt() ) + .setParameter( "theDouble", struct[0].getTheDouble() ) + .setParameter( "theBoolean", struct[0].isTheBoolean() ) + .setParameter( "theNumericBoolean", struct[0].isTheNumericBoolean() ) + .setParameter( "theStringBoolean", struct[0].isTheStringBoolean() ) + .setParameter( "theString", struct[0].getTheString() ) + .setParameter( "theInteger", struct[0].getTheInteger() ) + .setParameter( "theUrl", struct[0].getTheUrl() ) + .setParameter( "theClob", struct[0].getTheClob() ) + .setParameter( "theBinary", struct[0].getTheBinary() ) + .setParameter( "theDate", struct[0].getTheDate() ) + .setParameter( "theTime", struct[0].getTheTime() ) + .setParameter( "theTimestamp", struct[0].getTheTimestamp() ) + .setParameter( "theInstant", struct[0].getTheInstant() ) + .setParameter( "theUuid", struct[0].getTheUuid() ) + .setParameter( "gender", struct[0].getGender() ) + .setParameter( "convertedGender", struct[0].getConvertedGender() ) + .setParameter( "ordinalGender", struct[0].getOrdinalGender() ) + .setParameter( "theDuration", struct[0].getTheDuration() ) + .setParameter( "theLocalDateTime", struct[0].getTheLocalDateTime() ) + .setParameter( "theLocalDate", struct[0].getTheLocalDate() ) + .setParameter( "theLocalTime", struct[0].getTheLocalTime() ) + .setParameter( "theZonedDateTime", struct[0].getTheZonedDateTime() ) + .setParameter( "theOffsetDateTime", struct[0].getTheOffsetDateTime() ) + .setParameter( "mutableValue", struct[0].getMutableValue() ) + .executeUpdate(); + EmbeddableAggregate.assertArraysEquals( EmbeddableAggregate.createAggregateArray1(), entityManager.find( JsonArrayHolder.class, 2L ).getAggregateArray() ); + } + ); + } + + @Entity(name = "JsonArrayHolder") + public static class JsonArrayHolder { + + @Id + private Long id; + @JdbcTypeCode(SqlTypes.JSON_ARRAY) + private EmbeddableAggregate [] aggregateArray; + + public JsonArrayHolder() { + } + + public JsonArrayHolder(Long id, EmbeddableAggregate[] aggregateArray) { + this.id = id; + this.aggregateArray = aggregateArray; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public EmbeddableAggregate[] getAggregateArray() { + return aggregateArray; + } + + public void setAggregateArray(EmbeddableAggregate[] aggregateArray) { + this.aggregateArray = aggregateArray; + } + + } + +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/StructEmbeddableTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/StructEmbeddableTest.java index b2caa216381e..2ff564c018eb 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/StructEmbeddableTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/embeddable/StructEmbeddableTest.java @@ -76,6 +76,7 @@ @SessionFactory @RequiresDialect( PostgreSQLDialect.class ) @RequiresDialect( OracleDialect.class ) +@SkipForDialect(dialectClass = OracleDialect.class, reason = "Waiting for the fix of a bug that prevent creation of INTERVALDS from Duration") @RequiresDialect( DB2Dialect.class ) public class StructEmbeddableTest implements AdditionalMappingContributor { diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/hhh17404/OracleOsonCompatibilityTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/hhh17404/OracleOsonCompatibilityTest.java new file mode 100644 index 000000000000..c5f6e0db56dd --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/hhh17404/OracleOsonCompatibilityTest.java @@ -0,0 +1,171 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.mapping.hhh17404; + +import jakarta.persistence.Embeddable; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.cfg.DialectSpecificSettings; +import org.hibernate.dialect.OracleDialect; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.RequiresDialect; +import org.hibernate.testing.orm.junit.ServiceRegistry; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.hibernate.testing.orm.junit.Setting; +import org.hibernate.type.SqlTypes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.UUID; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +/** + * This test class is about testing that legacy schema that use BLOB or CLOB for JSON columns + * can be safely read even when Oracle Oson extension is in place. + * In Such a situation, the JSON type will expect JSON a JSON column and should + * silently fall back to String deserialization. + * + * @author Emmanuel Jannetti + */ +@DomainModel(annotatedClasses = OracleOsonCompatibilityTest.JsonEntity.class) +@SessionFactory(exportSchema = false) +@RequiresDialect( value = OracleDialect.class, majorVersion = 23 ) +public abstract class OracleOsonCompatibilityTest { + + @ServiceRegistry(settings = @Setting(name = DialectSpecificSettings.ORACLE_OSON_DISABLED, value = "true")) + public static class OracleOsonAsUtf8CompatibilityTest extends OracleOsonCompatibilityTest { + public OracleOsonAsUtf8CompatibilityTest() { + super( "JSON" ); + } + } + + public static class OracleBlobAsOsonCompatibilityTest extends OracleOsonCompatibilityTest { + public OracleBlobAsOsonCompatibilityTest() { + super( "BLOB" ); + } + } + + public static class OracleClobAsOsonCompatibilityTest extends OracleOsonCompatibilityTest { + public OracleClobAsOsonCompatibilityTest() { + super( "CLOB" ); + } + } + + + private final String jsonType; + + private final LocalDateTime theLocalDateTime = LocalDateTime.of( 2000, 1, 1, 0, 0, 0 ); + private final LocalDate theLocalDate = LocalDate.of( 2000, 1, 1 ); + private final LocalTime theLocalTime = LocalTime.of( 1, 0, 0 ); + private final UUID uuid = UUID.fromString("53886a8a-7082-4879-b430-25cb94415be8"); + private final String theString = "john"; + + public OracleOsonCompatibilityTest(String jsonType) { + this.jsonType = jsonType; + } + + @BeforeEach + public void setup(SessionFactoryScope scope) { + scope.inTransaction( + (session) -> { + // force creation of a column type by creating the table ourselves + session.createNativeMutationQuery( session.getDialect().getDropTableString( "TEST_OSON_COMPAT" ) ) + .executeUpdate(); + StringBuilder create = new StringBuilder(); + create.append("CREATE TABLE TEST_OSON_COMPAT ("); + create.append( "id NUMBER").append(','); + create.append( "payload ").append(jsonType).append(','); + create.append( "primary key (id))"); + session.createNativeMutationQuery(create.toString()).executeUpdate(); + + String insert = "INSERT INTO TEST_OSON_COMPAT (id, payload) VALUES(:id,:json)"; + + + + StringBuilder j = new StringBuilder(); + j.append( "{" ); + j.append( "\"jsonString\":\"").append(theString).append("\"," ); + j.append( "\"theUuid\":\"").append(uuid).append("\"," ); + j.append( "\"theLocalDateTime\":\"").append(theLocalDateTime).append("\"," ); + j.append( "\"theLocalDate\":\"").append(theLocalDate).append("\"," ); + j.append( "\"theLocalTime\":\"").append(theLocalTime).append("\"" ); + j.append( "}" ); + + final Object json = jsonType.equals( "BLOB" ) ? j.toString().getBytes() : j.toString(); + session.createNativeMutationQuery( insert ) + .setParameter( "id", 1 ) + .setParameter( "json", json ) + .executeUpdate(); + } + ); + } + + @AfterEach + public void tearDown(SessionFactoryScope scope) { + scope.inTransaction( + (session) -> { + session.createNativeQuery( session.getDialect().getDropTableString( "TEST_OSON_COMPAT" ) ).executeUpdate(); + } + ); + } + + @Test + public void verifyReadWorks(SessionFactoryScope scope) { + scope.inTransaction( + (session) -> { + JsonEntity entity = session.find( OracleOsonCompatibilityTest.JsonEntity.class, 1 ); + assertThat( entity.payload.jsonString, is( "john" ) ); + assertThat( entity.payload.theUuid.toString(), is( "53886a8a-7082-4879-b430-25cb94415be8" ) ); + assertThat( entity.payload.theLocalDateTime, is( theLocalDateTime ) ); + assertThat( entity.payload.theLocalTime, is( theLocalTime ) ); + assertThat( entity.payload.theLocalDate, is( theLocalDate ) ); + } + ); + } + + @Entity(name = "JsonEntity") + @Table(name = "TEST_OSON_COMPAT") + public static class JsonEntity { + @Id + private Integer id; + + @JdbcTypeCode( SqlTypes.JSON ) + private JsonEntityPayload payload; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public JsonEntityPayload getPayload() { + return payload; + } + + public void setPayload(JsonEntityPayload payload) { + this.payload = payload; + } + } + + @Embeddable + public static class JsonEntityPayload { + private String jsonString; + private UUID theUuid; + private LocalDateTime theLocalDateTime; + private LocalDate theLocalDate; + private LocalTime theLocalTime; + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/util/StringJsonDocumentReaderTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/util/StringJsonDocumentReaderTest.java new file mode 100644 index 000000000000..a2cd7b75202f --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/util/StringJsonDocumentReaderTest.java @@ -0,0 +1,419 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.util; + +import org.hibernate.type.format.JsonDocumentItemType; +import org.hibernate.type.format.StringJsonDocumentReader; +import org.junit.jupiter.api.Test; + +import java.util.NoSuchElementException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author Emmanuel Jannetti + */ +public class StringJsonDocumentReaderTest { + @Test + public void testNullDocument() { + assertThrows( IllegalArgumentException.class, () -> {new StringJsonDocumentReader( null );} ); + } + @Test + public void testEmptyDocument() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "" ); + assertFalse(reader.hasNext(), "Should not have anymore element"); + } + @Test + public void testEmptyJsonObject() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "{}" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + } + @Test + public void testEmptyJsonArray() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "[]" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.ARRAY_START, reader.next()); + assertEquals( JsonDocumentItemType.ARRAY_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + } + @Test + public void testWrongNext() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "{}" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + @Test + public void testSimpleDocument() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "{ \"key1\" :\"value1\" }" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals("key1", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE, reader.next()); + assertEquals("value1", reader.getStringValue()); + + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + @Test + public void testSimpleDoubleValueDocument() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "{ \"key1\":\"\",\"key2\" : \" x value2 x \" }" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "key1", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertEquals( "", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "key2", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertTrue( reader.getStringValue().equals(" x value2 x ")); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + @Test + public void testNonStringValueDocument() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "{ \"aNull\":null, \"aNumber\" : 12 , \"aBoolean\" : true}" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "aNull", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.NULL_VALUE,reader.next()); + assertEquals( "null", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "aNumber", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals( 12, reader.getIntegerValue()); + assertEquals( "12", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "aBoolean", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.BOOLEAN_VALUE,reader.next()); + assertEquals( true, reader.getBooleanValue()); + assertEquals( "true", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + + @Test + public void testNonAvailableValueDocument() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "{}" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertThrows( IllegalStateException.class, () -> {reader.getStringValue();} ); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + + @Test + public void testBooleanValueDocument() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( "{ \"aBoolean\" : true}" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "aBoolean", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.BOOLEAN_VALUE,reader.next()); + assertTrue( reader.getBooleanValue() ); + assertEquals( "true",reader.getStringValue()); + assertTrue(reader.getBooleanValue()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + + @Test + public void testNumericValueDocument() { + final StringJsonDocumentReader reader = + new StringJsonDocumentReader( "{ \"aInteger\" : 12, \"aDouble\" : 123.456 , \"aLong\" : 123456, \"aShort\" : 1}" ); + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "aInteger", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals( (int)12 , reader.getIntegerValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "aDouble", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals( (double)123.456 ,reader.getDoubleValue() ); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "aLong", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals( (long)123456 , reader.getLongValue() ); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "aShort", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals( (short)1, reader.getLongValue() ); + + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + + @Test + public void testArrayValueDocument() { + final StringJsonDocumentReader reader = + new StringJsonDocumentReader( "{ \"anEmptyArray\" : [], \"anArray\" : [1,2,3] }" ); + assertTrue(reader.hasNext()); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "anEmptyArray", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.ARRAY_START,reader.next()); + assertEquals( JsonDocumentItemType.ARRAY_END,reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "anArray", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.ARRAY_START,reader.next()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals(1, reader.getIntegerValue()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals(2, reader.getIntegerValue()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals(3, reader.getIntegerValue()); + assertEquals( JsonDocumentItemType.ARRAY_END,reader.next()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + @Test + public void testObjectArrayMultipleValueDocument() { + final StringJsonDocumentReader reader = + new StringJsonDocumentReader( "{ \"anArray\" : [1, null, \"2\" , {\"foo\":\"bar\"} ] \n" + + " }" ); + assertTrue(reader.hasNext()); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "anArray", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.ARRAY_START,reader.next()); + + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals("1", reader.getStringValue()); + assertEquals( JsonDocumentItemType.NULL_VALUE,reader.next()); + assertEquals("null", reader.getStringValue()); + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertEquals(2, reader.getIntegerValue()); + + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "foo", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertEquals("bar", reader.getStringValue()); + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + + assertEquals( JsonDocumentItemType.ARRAY_END,reader.next()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + @Test + public void testEscapeStringDocument() { + final StringJsonDocumentReader reader = + new StringJsonDocumentReader( "{ \"str1\" : \"abc\" , \"str2\" : \"\\\"abc\\\"\" , \"str3\" : \"a\\\"b\\\"c\" }" ); + assertTrue(reader.hasNext()); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "str1", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertEquals("abc", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "str2", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertEquals("\"abc\"", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "str3", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertEquals("a\"b\"c", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + + assertFalse(reader.hasNext(), "Should not have anymore element"); + assertThrows( NoSuchElementException.class, () -> {reader.next();} ); + } + + @Test + public void testNestedDocument() { + final StringJsonDocumentReader reader = + new StringJsonDocumentReader( """ + { + "nested": { + "converted_gender": "M", + "theInteger": -1 + }, + "doubleNested": { + "theNested": { + "theLeaf": { + "stringField": "String \\"A&B\\"" + } + } + }, + "integerField": 10 + } + """); + + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "nested", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.OBJECT_START,reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "converted_gender", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertEquals("M", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "theInteger", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals(-1, reader.getIntegerValue()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "doubleNested", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.OBJECT_START,reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "theNested", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.OBJECT_START,reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "theLeaf", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.OBJECT_START,reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "stringField", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE,reader.next()); + assertEquals("String \"A&B\"", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "integerField", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals("10", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + } + + @Test + public void testNestedArrayDocument() { + final StringJsonDocumentReader reader = + new StringJsonDocumentReader( """ + { + "nested": [ + { + "anArray": [1] + } + ] + } + """); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "nested", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.ARRAY_START,reader.next()); + + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals( "anArray", reader.getObjectKeyName()); + + assertEquals( JsonDocumentItemType.ARRAY_START,reader.next()); + assertEquals( JsonDocumentItemType.NUMERIC_VALUE,reader.next()); + assertEquals(1L, reader.getLongValue()); + assertEquals( JsonDocumentItemType.ARRAY_END,reader.next()); + + assertEquals( JsonDocumentItemType.OBJECT_END,reader.next()); + + assertEquals( JsonDocumentItemType.ARRAY_END,reader.next()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + } + + @Test + public void testUnicode() { + final StringJsonDocumentReader reader = new StringJsonDocumentReader( """ + { + "myUnicode1": "\\u0074\\u0068\\u0069\\u0073\\u005f\\u0069\\u0073\\u005f\\u0075\\u006e\\u0069\\u0063\\u006f\\u0064\\u0065", + "myUnicode2": "this_\\u0069\\u0073_unicode", + "myUnicode3": "this_is_unicode" + } + + + """); + + assertTrue(reader.hasNext(), "should have more element"); + assertEquals( JsonDocumentItemType.OBJECT_START, reader.next()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals("myUnicode1", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE, reader.next()); + assertEquals("this_is_unicode", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals("myUnicode2", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE, reader.next()); + assertEquals("this_is_unicode", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.VALUE_KEY,reader.next()); + assertEquals("myUnicode3", reader.getObjectKeyName()); + assertEquals( JsonDocumentItemType.VALUE, reader.next()); + assertEquals("this_is_unicode", reader.getStringValue()); + + assertEquals( JsonDocumentItemType.OBJECT_END, reader.next()); + + } + + +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/util/StringJsonDocumentWriterTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/util/StringJsonDocumentWriterTest.java new file mode 100644 index 000000000000..0788f86d4045 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/util/StringJsonDocumentWriterTest.java @@ -0,0 +1,167 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.util; + +import org.hibernate.type.format.StringJsonDocumentWriter; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + + +/** + * @author Emmanuel Jannetti + */ +public class StringJsonDocumentWriterTest { + + private static void assertEqualsIgnoreSpace(String expected, String actual) { + assertEquals(expected.replaceAll("\\s", ""), actual.replaceAll("\\s", "")); + } + + @Test + public void testEmptyDocument() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startObject(); + writer.endObject(); + assertEquals( "{}" , writer.toString()); + } + + @Test + public void testEmptyArray() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startArray(); + writer.endArray(); + assertEquals( "[]" , writer.toString() ); + } + @Test + public void testArray() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startArray(); + writer.booleanValue( false ); + writer.booleanValue( true ); + writer.booleanValue( false ); + writer.endArray(); + assertEquals( "[false,true,false]" , writer.toString() ); + } + + @Test + public void testMixedArrayDocument() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startArray(); + writer.nullValue(); + writer.booleanValue( false ); + writer.stringValue( "foo" ); + writer.endArray(); + assertEqualsIgnoreSpace( """ + [null,false,"foo"] + """ , writer.toString() ); + } + @Test + public void testSimpleDocument() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startObject(); + writer.objectKey( "key1" ); + writer.stringValue( "value1" ); + writer.endObject(); + + assertEqualsIgnoreSpace( + """ + { + "key1":"value1" + } + """, writer.toString()); + + } + + @Test + public void testNonStringValueDocument() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startObject(); + writer.objectKey( "aNull" ); + writer.nullValue(); + writer.objectKey( "aBoolean" ); + writer.booleanValue( true ); + writer.endObject(); + + assertEqualsIgnoreSpace( """ + { + "aNull":null, + "aBoolean" : true + } + """ , writer.toString() ); + + } + + + @Test + public void testArrayValueDocument() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startObject(); + writer.objectKey( "anEmptyArray" ); + writer.startArray(); + writer.endArray(); + writer.objectKey( "anArray" ); + writer.startArray(); + writer.stringValue( "1" ); + writer.stringValue( "2" ); + writer.stringValue( "3" ); + writer.endArray(); + writer.endObject(); + + assertEqualsIgnoreSpace( """ + { + "anEmptyArray" : [], + "anArray" : ["1","2","3"] + } + """, writer.toString() ); + } + @Test + public void testObjectArrayMultipleValueDocument() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startObject(); + writer.objectKey( "anArray" ).startArray().nullValue().stringValue( "2" ).startObject() + .objectKey( "foo" ).stringValue( "bar" ).endObject().endArray().endObject(); + + assertEqualsIgnoreSpace( """ + { + "anArray" : [null, "2" , {\"foo\":\"bar\"} ] + } + """ , writer.toString() ); + + } + + @Test + public void testNestedDocument() { + StringJsonDocumentWriter writer = new StringJsonDocumentWriter(); + writer.startObject().objectKey( "nested" ).startObject() + .objectKey( "converted_gender" ).stringValue( "M" ) + .endObject() + .objectKey( "doubleNested" ).startObject() + .objectKey( "theNested" ).startObject() + .objectKey( "theLeaf" ) + .startObject().objectKey( "stringField" ).stringValue( "String \"A&B\"" ).endObject() + .endObject() + .endObject() + .endObject(); + + assertEqualsIgnoreSpace( """ + { + "nested": { + "converted_gender": "M" + }, + "doubleNested": { + "theNested": { + "theLeaf": { + "stringField": "String \\"A&B\\"" + } + } + } + } + """,writer.toString()); + + + } + + +} diff --git a/local-build-plugins/src/main/groovy/local.java-module.gradle b/local-build-plugins/src/main/groovy/local.java-module.gradle index 40499736c718..c548f82ff3a4 100644 --- a/local-build-plugins/src/main/groovy/local.java-module.gradle +++ b/local-build-plugins/src/main/groovy/local.java-module.gradle @@ -48,6 +48,9 @@ dependencies { compileOnly libs.loggingAnnotations // Used for compiling some Oracle specific JdbcTypes compileOnly jdbcLibs.oracle + compileOnly (jdbcLibs.oracleJdbcJacksonOsonExtension) { + exclude group: 'com.oracle.database.jdbc', module: 'ojdbc8' + } // JUnit dependencies made up of: // * JUnit 5 @@ -98,6 +101,9 @@ dependencies { testRuntimeOnly jdbcLibs.oracle testRuntimeOnly jdbcLibs.oracleXml testRuntimeOnly jdbcLibs.oracleXmlParser + testRuntimeOnly (jdbcLibs.oracleJdbcJacksonOsonExtension) { + exclude group: 'com.oracle.database.jdbc', module: 'ojdbc8' + } } else if ( db.startsWith( 'altibase' ) ) { testRuntimeOnly jdbcLibs.altibase diff --git a/settings.gradle b/settings.gradle index e6363ceeda6c..48e3a3b747f6 100644 --- a/settings.gradle +++ b/settings.gradle @@ -229,6 +229,7 @@ dependencyResolutionManagement { def mssqlVersion = version "mssql", "12.8.1.jre11" def mysqlVersion = version "mysql", "9.2.0" def oracleVersion = version "oracle", "23.7.0.25.01" + def oracleJacksonOsonExtension = version "oracleJacksonOsonExtension", "1.0.4" def pgsqlVersion = version "pgsql", "42.7.4" def sybaseVersion = version "sybase", "1.3.1" def tidbVersion = version "tidb", mysqlVersion @@ -247,6 +248,7 @@ dependencyResolutionManagement { library( "oracle", "com.oracle.database.jdbc", "ojdbc17" ).versionRef( oracleVersion ) library( "oracleXml", "com.oracle.database.xml", "xdb" ).versionRef( oracleVersion ) library( "oracleXmlParser", "com.oracle.database.xml", "xmlparserv2" ).versionRef( oracleVersion ) + library( "oracleJdbcJacksonOsonExtension", "com.oracle.database.jdbc", "ojdbc-provider-jackson-oson" ).versionRef( oracleJacksonOsonExtension ) library( "mssql", "com.microsoft.sqlserver", "mssql-jdbc" ).versionRef( mssqlVersion ) library( "db2", "com.ibm.db2", "jcc" ).versionRef( db2Version ) library( "hana", "com.sap.cloud.db.jdbc", "ngdbc" ).versionRef( hanaVersion ) diff --git a/whats-new.adoc b/whats-new.adoc index b01ad852eb73..9b1f2b30f64f 100644 --- a/whats-new.adoc +++ b/whats-new.adoc @@ -374,3 +374,19 @@ Support for `hbm.xml` mappings will be removed in 8.0. We offer a transformation of `hbm.xml` files into `mapping.xml` files, which is available both at build-time (Gradle plugin) and at run-time (using `hibernate.transform_hbm_xml.enabled=true`). + +[[OSON-support]] +== OSON support + + +Starting in 21c, link:https://docs.oracle.com/en/database/oracle/oracle-database/23/adjsn/json-oracle-database.html[Oracle JSON binary format] (also known as OSON) can now be used in Hibernate to store `JSON` data. It brings a performance boost by replacing the actual to/from String conversion. +To enable the OSON support, the link:https://github.com/oracle/ojdbc-extensions/blob/main/ojdbc-provider-jackson-oson/README.md[Oracle JDBC extension] must be added to the application classpath. +Here is an example using Gradle build system +``` +runtimeOnly ('com.oracle.database.jdbc:ojdbc-provider-jackson-oson:1.0.4') +{ + exclude group: 'com.oracle.database.jdbc', module: 'ojdbc8' +} +``` + +The use of OSON can be disabled by setting the following hibernate property `hibernate.dialect.oracle.oson_format_disabled=true`