diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/AbstractTransactSQLDialect.java b/hibernate-core/src/main/java/org/hibernate/dialect/AbstractTransactSQLDialect.java index 6f65384719a2..f8e9dbc27150 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/AbstractTransactSQLDialect.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/AbstractTransactSQLDialect.java @@ -7,6 +7,7 @@ import org.hibernate.LockMode; import org.hibernate.LockOptions; import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.TypeContributions; import org.hibernate.dialect.function.CaseLeastGreatestEmulation; import org.hibernate.dialect.function.CastingConcatFunction; import org.hibernate.dialect.function.TransactSQLStrFunction; @@ -26,6 +27,7 @@ import org.hibernate.query.sqm.mutation.internal.temptable.LocalTemporaryTableMutationStrategy; import org.hibernate.query.sqm.mutation.spi.SqmMultiTableInsertStrategy; import org.hibernate.query.sqm.mutation.spi.SqmMultiTableMutationStrategy; +import org.hibernate.service.ServiceRegistry; import org.hibernate.sql.ast.SqlAstNodeRenderingMode; import org.hibernate.sql.ast.internal.TransactSQLLockingClauseStrategy; import org.hibernate.sql.ast.spi.LockingClauseStrategy; @@ -33,6 +35,8 @@ import org.hibernate.sql.ast.tree.select.QuerySpec; import org.hibernate.type.descriptor.java.PrimitiveByteArrayJavaType; import org.hibernate.type.descriptor.jdbc.JdbcType; +import org.hibernate.type.descriptor.jdbc.JsonAsStringJdbcType; +import org.hibernate.type.descriptor.jdbc.XmlAsStringJdbcType; import org.hibernate.type.descriptor.jdbc.spi.JdbcTypeRegistry; import java.sql.CallableStatement; @@ -374,4 +378,12 @@ public void appendBinaryLiteral(SqlAppender appender, byte[] bytes) { appender.appendSql( "0x" ); PrimitiveByteArrayJavaType.INSTANCE.appendString( appender, bytes ); } + + @Override + protected void registerColumnTypes(TypeContributions typeContributions, ServiceRegistry serviceRegistry) { + super.registerColumnTypes( typeContributions, serviceRegistry ); + final var jdbcTypeRegistry = typeContributions.getTypeConfiguration().getJdbcTypeRegistry(); + jdbcTypeRegistry.addDescriptor( JsonAsStringJdbcType.NVARCHAR_INSTANCE ); + jdbcTypeRegistry.addDescriptor( XmlAsStringJdbcType.NVARCHAR_INSTANCE ); + } } diff --git a/hibernate-core/src/main/java/org/hibernate/dialect/SQLServerDialect.java b/hibernate-core/src/main/java/org/hibernate/dialect/SQLServerDialect.java index 96a6b4dad05a..fa24f813e62a 100644 --- a/hibernate-core/src/main/java/org/hibernate/dialect/SQLServerDialect.java +++ b/hibernate-core/src/main/java/org/hibernate/dialect/SQLServerDialect.java @@ -1272,5 +1272,4 @@ public boolean supportsRowValueConstructorSyntaxInQuantifiedPredicates() { public boolean supportsRowValueConstructorSyntaxInInList() { return false; } - } diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/SqlServerNationalizedMappingTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/SqlServerNationalizedMappingTests.java new file mode 100644 index 000000000000..23e3168a3229 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/SqlServerNationalizedMappingTests.java @@ -0,0 +1,140 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.mapping.basic; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.Nationalized; +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.metamodel.mapping.JdbcMapping; +import org.hibernate.metamodel.mapping.internal.BasicAttributeMapping; +import org.hibernate.metamodel.spi.MappingMetamodelImplementor; +import org.hibernate.persister.entity.EntityPersister; +import org.hibernate.type.SqlTypes; + +import org.hibernate.testing.orm.junit.DomainModel; +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.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +/** + * SQL Server 환경에서 JSON 타입 컬럼의 Nationalized 매핑 여부를 확인하는 테스트. + * 기본적으로 SQL Server는 JSON에 대해 NVARCHAR를 사용해야 합니다. + */ +public class SqlServerNationalizedMappingTests { + + private static final String SQL_SERVER_COMPATIBILITY_SETTING = "hibernate.dialect.sqlserver.compatibility_level"; + private static final String UNICODE_JSON = "{\"name\":\"🦑 Unicode Test 🦀\", \"note\":\"한글 테스트\"}"; + + @Nested + @DomainModel(annotatedClasses = { PlainJsonEntity.class }) + @ServiceRegistry(settings = { + @Setting(name = AvailableSettings.DIALECT, value = "org.hibernate.dialect.SQLServerDialect"), + @Setting(name = SQL_SERVER_COMPATIBILITY_SETTING, value = "150") + }) + @SessionFactory + public class PlainJsonTests { + + @Test + public void testDefaultJsonIsNvarchar(SessionFactoryScope scope) { + // Nationalized 설정이 없어도 기본적으로 NVARCHAR(-9)여야 함 + verifyMapping( scope, PlainJsonEntity.class, "jsonData", SqlTypes.NVARCHAR ); + } + } + + @Nested + @DomainModel(annotatedClasses = { NationalizedJsonEntity.class }) + @ServiceRegistry(settings = { + @Setting(name = AvailableSettings.DIALECT, value = "org.hibernate.dialect.SQLServerDialect"), + @Setting(name = SQL_SERVER_COMPATIBILITY_SETTING, value = "150") + }) + @SessionFactory + public class NationalizedAnnotationTests { + + @Test + public void testNationalizedJsonMappingAndIntegrity(SessionFactoryScope scope) { + verifyMapping( scope, NationalizedJsonEntity.class, "jsonData", SqlTypes.NVARCHAR ); + + scope.inTransaction( session -> { + NationalizedJsonEntity entity = new NationalizedJsonEntity(); + entity.id = 1; + entity.jsonData = UNICODE_JSON; + session.persist( entity ); + } ); + + scope.inSession( session -> { + NationalizedJsonEntity retrieved = session.get( NationalizedJsonEntity.class, 1 ); + assertThat( retrieved.jsonData, is( UNICODE_JSON ) ); + } ); + } + } + + @Nested + @DomainModel(annotatedClasses = { JsonEntity.class }) + @ServiceRegistry(settings = { + @Setting(name = AvailableSettings.DIALECT, value = "org.hibernate.dialect.SQLServerDialect"), + @Setting(name = SQL_SERVER_COMPATIBILITY_SETTING, value = "150"), + @Setting(name = AvailableSettings.USE_NATIONALIZED_CHARACTER_DATA, value = "true") + }) + @SessionFactory + public class GlobalNationalizedSettingsTests { + + @Test + public void testGlobalNationalizedJsonMappingAndIntegrity(SessionFactoryScope scope) { + verifyMapping( scope, JsonEntity.class, "jsonData", SqlTypes.NVARCHAR ); + + scope.inTransaction( session -> { + JsonEntity entity = new JsonEntity(); + entity.id = 1; + entity.jsonData = UNICODE_JSON; + session.persist( entity ); + } ); + + scope.inSession( session -> { + JsonEntity retrieved = session.get( JsonEntity.class, 1 ); + assertThat( retrieved.jsonData, is( UNICODE_JSON ) ); + } ); + } + } + + private static void verifyMapping(SessionFactoryScope scope, Class entityClass, String attributeName, int expectedTypeCode) { + final MappingMetamodelImplementor mappingMetamodel = scope.getSessionFactory() + .getRuntimeMetamodels() + .getMappingMetamodel(); + final EntityPersister entityDescriptor = mappingMetamodel.findEntityDescriptor( entityClass ); + + final BasicAttributeMapping attribute = (BasicAttributeMapping) entityDescriptor.findAttributeMapping( attributeName ); + final JdbcMapping jdbcMapping = attribute.getJdbcMapping(); + + assertThat( "JDBC Type code should be " + expectedTypeCode, + jdbcMapping.getJdbcType().getJdbcTypeCode(), is( expectedTypeCode ) ); + } + + @Entity(name = "PlainJsonEntity") + public static class PlainJsonEntity { + @Id Integer id; + @JdbcTypeCode(SqlTypes.JSON) String jsonData; + } + + @Entity(name = "NationalizedJsonEntity") + public static class NationalizedJsonEntity { + @Id Integer id; + @JdbcTypeCode(SqlTypes.JSON) @Nationalized String jsonData; + } + + @Entity(name = "JsonEntity") + public static class JsonEntity { + @Id Integer id; + @JdbcTypeCode(SqlTypes.JSON) String jsonData; + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/SybaseNationalizedMappingTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/SybaseNationalizedMappingTests.java new file mode 100644 index 000000000000..3660c2a99719 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/SybaseNationalizedMappingTests.java @@ -0,0 +1,170 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.mapping.basic; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.Nationalized; +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.dialect.SybaseASEDialect; +import org.hibernate.metamodel.mapping.JdbcMapping; +import org.hibernate.metamodel.mapping.internal.BasicAttributeMapping; +import org.hibernate.metamodel.spi.MappingMetamodelImplementor; +import org.hibernate.persister.entity.EntityPersister; +import org.hibernate.testing.orm.junit.DomainModel; +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.Nested; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +public class SybaseNationalizedMappingTests { + + private static final String UNICODE_JSON = "{\"name\":\"Quantity 🦑 Sybase 🦀\"}"; + + private static void verifyMapping(SessionFactoryScope scope, Class entityClass, String attributeName, int expectedTypeCode) { + final MappingMetamodelImplementor mappingMetamodel = scope.getSessionFactory() + .getRuntimeMetamodels() + .getMappingMetamodel(); + final EntityPersister entityDescriptor = mappingMetamodel.findEntityDescriptor( entityClass ); + + final BasicAttributeMapping attribute = (BasicAttributeMapping) entityDescriptor.findAttributeMapping( + attributeName ); + final JdbcMapping jdbcMapping = attribute.getJdbcMapping(); + + assertThat( "JDBC Type code should be " + expectedTypeCode, + jdbcMapping.getJdbcType().getJdbcTypeCode(), is( expectedTypeCode ) ); + } + + public static class TestSybaseDialect extends SybaseASEDialect { + @Override + public String getTableTypeString() { + return ""; + } + + @Override + protected String columnType(int sqlTypeCode) { + if ( sqlTypeCode == SqlTypes.NVARCHAR || sqlTypeCode == SqlTypes.JSON || sqlTypeCode == SqlTypes.NCLOB ) { + return "nvarchar(max)"; + } + return super.columnType( sqlTypeCode ); + } + } + + @Entity(name = "PlainJsonEntity") + public static class PlainJsonEntity { + @Id + Integer id; + @JdbcTypeCode(SqlTypes.JSON) + String jsonData; + } + + @Entity(name = "NationalizedJsonEntity") + @Table(name = "NationalizedJsonEntity") + public static class NationalizedJsonEntity { + @Id + Integer id; + @JdbcTypeCode(SqlTypes.JSON) + @Nationalized + String jsonData; + } + + @Entity(name = "JsonEntity") + @Table(name = "JsonEntity") + public static class JsonEntity { + @Id + Integer id; + @JdbcTypeCode(SqlTypes.JSON) + String jsonData; + } + + @Nested + @DomainModel(annotatedClasses = {PlainJsonEntity.class}) + @ServiceRegistry(settings = { + @Setting(name = AvailableSettings.DIALECT, + value = "org.hibernate.orm.test.mapping.basic.SybaseNationalizedMappingTests$TestSybaseDialect") + }) + @SessionFactory + public class PlainJsonTests { + + @Test + public void testDefaultJsonIsNvarchar(SessionFactoryScope scope) { + verifyMapping( scope, PlainJsonEntity.class, "jsonData", SqlTypes.NVARCHAR ); + scope.inTransaction( session -> { + PlainJsonEntity entity = new PlainJsonEntity(); + entity.id = 1; + entity.jsonData = UNICODE_JSON; + session.persist( entity ); + } ); + + scope.inSession( session -> { + PlainJsonEntity retrieved = session.find( PlainJsonEntity.class, 1 ); + assertThat( retrieved.jsonData, is( UNICODE_JSON ) ); + } ); + } + } + + @Nested + @DomainModel(annotatedClasses = {NationalizedJsonEntity.class}) + @ServiceRegistry(settings = { + @Setting(name = AvailableSettings.DIALECT, + value = "org.hibernate.orm.test.mapping.basic.SybaseNationalizedMappingTests$TestSybaseDialect") + }) + @SessionFactory + public class NationalizedAnnotationTests { + + @Test + public void testNationalizedJsonMappingAndIntegrity(SessionFactoryScope scope) { + verifyMapping( scope, NationalizedJsonEntity.class, "jsonData", SqlTypes.NVARCHAR ); + + scope.inTransaction( session -> { + NationalizedJsonEntity entity = new NationalizedJsonEntity(); + entity.id = 1; + entity.jsonData = UNICODE_JSON; + session.persist( entity ); + } ); + + scope.inSession( session -> { + NationalizedJsonEntity retrieved = session.find( NationalizedJsonEntity.class, 1 ); + assertThat( retrieved.jsonData, is( UNICODE_JSON ) ); + } ); + } + } + + @Nested + @DomainModel(annotatedClasses = {JsonEntity.class}) + @ServiceRegistry(settings = { + @Setting(name = AvailableSettings.DIALECT, + value = "org.hibernate.orm.test.mapping.basic.SybaseNationalizedMappingTests$TestSybaseDialect"), + @Setting(name = AvailableSettings.USE_NATIONALIZED_CHARACTER_DATA, value = "true") + }) + @SessionFactory + public class GlobalNationalizedSettingsTests { + + @Test + public void testGlobalNationalizedJsonMappingAndIntegrity(SessionFactoryScope scope) { + verifyMapping( scope, JsonEntity.class, "jsonData", SqlTypes.NVARCHAR ); + + scope.inTransaction( session -> { + JsonEntity entity = new JsonEntity(); + entity.id = 1; + entity.jsonData = UNICODE_JSON; + session.persist( entity ); + } ); + + scope.inSession( session -> { + JsonEntity retrieved = session.find( JsonEntity.class, 1 ); + assertThat( retrieved.jsonData, is( UNICODE_JSON ) ); + } ); + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/schemavalidation/SQLServerJsonValidationTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/schemavalidation/SQLServerJsonValidationTest.java new file mode 100644 index 000000000000..315199d5f4f1 --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/schemavalidation/SQLServerJsonValidationTest.java @@ -0,0 +1,81 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.schemavalidation; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.dialect.SQLServerDialect; +import org.hibernate.testing.jdbc.JdbcUtils; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.DomainModelScope; +import org.hibernate.testing.orm.junit.JiraKey; +import org.hibernate.testing.orm.junit.RequiresDialect; +import org.hibernate.testing.orm.junit.ServiceRegistry; +import org.hibernate.testing.orm.junit.ServiceRegistryScope; +import org.hibernate.tool.hbm2ddl.SchemaValidator; +import org.hibernate.type.SqlTypes; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@JiraKey("HHH-20092") +@RequiresDialect(SQLServerDialect.class) +@ServiceRegistry( + settings = @org.hibernate.testing.orm.junit.Setting( + name = "hibernate.use_nationalized_character_data", + value = "true" + ) +) +public class SQLServerJsonValidationTest { + + @BeforeEach + void setUp(ServiceRegistryScope registryScope) { + JdbcUtils.withConnection( registryScope.getRegistry(), connection -> { + try (var statement = connection.createStatement()) { + try { + statement.execute( "drop table Foo" ); + } + catch (Exception ignore) { + } + statement.execute( + """ + create table Foo ( + id integer not null, + jsonField nvarchar(max), + primary key (id) + ) + """ + ); + } + } ); + } + + @AfterEach + void tearDown(ServiceRegistryScope registryScope) { + JdbcUtils.withConnection( registryScope.getRegistry(), connection -> { + try (var statement = connection.createStatement()) { + statement.execute( "drop table Foo" ); + } + } ); + } + + @Test + @DomainModel(annotatedClasses = SQLServerJsonValidationTest.Foo.class) + public void testSchemaValidation(DomainModelScope modelScope) { + new SchemaValidator().validate( modelScope.getDomainModel() ); + } + + @Entity(name = "Foo") + @Table(name = "Foo") + public static class Foo { + @Id + public Integer id; + + @JdbcTypeCode(SqlTypes.JSON) + public String jsonField; + } +} \ No newline at end of file