diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ColumnBinder.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ColumnBinder.java index 1a399e2230f..a4e6f1ffb0a 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ColumnBinder.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/ColumnBinder.java @@ -119,7 +119,7 @@ public void bindColumn( stringColumnConstraintsBinder.bindStringColumnConstraints(column, mappedForm); } else if (type != null && Number.class.isAssignableFrom(type)) { PropertyConfig mappedForm = property.getHibernateMappedForm(); - numericColumnConstraintsBinder.bindNumericColumnConstraints(column, cc, mappedForm); + numericColumnConstraintsBinder.bindNumericColumnConstraints(column, cc, mappedForm, type); } } diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/NumericColumnConstraintsBinder.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/NumericColumnConstraintsBinder.java index 90589265b2c..44ad6053dc6 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/NumericColumnConstraintsBinder.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/domainbinding/binder/NumericColumnConstraintsBinder.java @@ -25,7 +25,6 @@ import org.hibernate.dialect.Dialect; import org.hibernate.dialect.H2Dialect; -import org.hibernate.dialect.OracleDialect; import org.hibernate.mapping.Column; import org.grails.orm.hibernate.cfg.ColumnConfig; @@ -44,7 +43,8 @@ public NumericColumnConstraintsBinder(Dialect dialect) { this.dialect = dialect; } - public void bindNumericColumnConstraints(Column column, ColumnConfig cc, PropertyConfig constrainedProperty) { + public void bindNumericColumnConstraints( + Column column, ColumnConfig cc, PropertyConfig constrainedProperty, Class propertyType) { int scale = determineScale(cc, constrainedProperty); if (scale > -1) { column.setScale(scale); @@ -53,25 +53,33 @@ public void bindNumericColumnConstraints(Column column, ColumnConfig cc, Propert } if (cc != null && cc.getPrecision() > -1) { column.setPrecision(cc.getPrecision()); - } else { + } else if (!isApproximateFloatingPoint(propertyType)) { int minConstraintValueLength = getConstraintValueLength(constrainedProperty.getMin(), scale); int maxConstraintValueLength = getConstraintValueLength(constrainedProperty.getMax(), scale); - int defaultPrecision; - if (dialect instanceof OracleDialect) { - defaultPrecision = 126; - } else { - // Default to 15 decimal digits which maps to ~50-53 bits in Hibernate 7 - // This avoids float(64) DDL errors in H2 and PostgreSQL - defaultPrecision = 15; - } - int precision = minConstraintValueLength > 0 && maxConstraintValueLength > 0 ? Math.max(minConstraintValueLength, maxConstraintValueLength) : - DefaultGroovyMethods.max( - new Integer[] {defaultPrecision, minConstraintValueLength, maxConstraintValueLength}); + DefaultGroovyMethods.max(new Integer[] { + dialect.getDefaultDecimalPrecision(), minConstraintValueLength, maxConstraintValueLength + }); column.setPrecision(precision); } + // else: leave Float/Double precision unset. Hibernate renders FLOAT/DOUBLE DDL as + // float(precision) where precision is a *bit* count (IEEE-754), converted internally + // from whatever decimal-digit value column.setPrecision() is given (n * log2(10)). Any + // decimal-oriented default - Hibernate's own 19/38, or a dialect's getFloatPrecision()/ + // getDoublePrecision(), which are already bit counts and would be converted a second + // time - overflows H2/PostgreSQL's 53-bit ceiling and produces DDL those dialects reject + // at execution time (silently: Hibernate only logs the failure, it doesn't throw, so the + // table is never created - see GH numeric-precision issue). Leaving precision unset lets + // Hibernate fall back to the dialect's own correct float/double DDL type directly. + } + + private boolean isApproximateFloatingPoint(Class propertyType) { + return Float.class.equals(propertyType) || + float.class.equals(propertyType) || + Double.class.equals(propertyType) || + double.class.equals(propertyType); } private int getConstraintValueLength(Comparable min, int scale) { diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/GormNumericTypeColumnIntegrationSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/GormNumericTypeColumnIntegrationSpec.groovy new file mode 100644 index 00000000000..32f38a807a6 --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/GormNumericTypeColumnIntegrationSpec.groovy @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.orm.hibernate + +import grails.gorm.annotation.Entity +import grails.gorm.hibernate.HibernateEntity +import grails.gorm.tests.HibernateGormDatastoreSpec +import org.testcontainers.mariadb.MariaDBContainer +import org.testcontainers.mysql.MySQLContainer +import org.testcontainers.postgresql.PostgreSQLContainer +import org.testcontainers.spock.Testcontainers +import spock.lang.Requires +import spock.lang.Shared + +/** + * Extends the H2-only coverage in {@link GormNumericTypeColumnPrecisionSpec} across every + * externally-run dialect this module tests against (see {@link grails.gorm.tests.RLikeHibernate7Spec} + * for the same H2/Postgres/MySQL/MariaDB precedent): a Float/Double/BigDecimal property with no + * explicit precision must produce creatable DDL, not a silently-dropped table. Oracle is + * intentionally excluded - its Testcontainers image is too flaky in CI to gate this spec on. + */ +@Testcontainers +@Requires({ isDockerAvailable() }) +class GormNumericTypeColumnIntegrationSpec extends HibernateGormDatastoreSpec { + + @Shared PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16") + @Shared MySQLContainer mysql = new MySQLContainer("mysql:8.0") + @Shared MariaDBContainer mariadb = new MariaDBContainer("mariadb:10.11") + + void setupSpec() { + manager.registerDomainClasses(NumericTypeReading) + } + + void "a Float/Double/BigDecimal property with no explicit precision produces creatable DDL on #db"() { + given: + // Ensure a completely fresh datastore per dialect, as in RLikeHibernate7Spec. + manager.destroy() + manager.grailsConfig = [ + 'dataSource.url' : container.jdbcUrl, + 'dataSource.driverClassName' : container.driverClassName, + 'dataSource.username' : container.username, + 'dataSource.password' : container.password, + 'dataSource.dbCreate' : 'create-drop', + 'hibernate.hbm2ddl.auto' : 'create', + ] + // 'hibernate.dialect' is intentionally omitted - Hibernate 7 auto-detects it from + // JDBC metadata, avoiding a hardcoded dialect string per database. + manager.setup(this.class) + + when: + Map columns = [:] + datastore.dataSource.connection.withCloseable { conn -> + conn.createStatement().withCloseable { stmt -> + stmt.executeQuery(''' + select column_name, numeric_precision + from information_schema.columns + where upper(table_name) = 'NUMERIC_TYPE_READING' + '''.stripIndent()).with { rs -> + while (rs.next()) { + columns[rs.getString('column_name').toUpperCase()] = rs.getInt('numeric_precision') + } + } + } + } + + then: 'the table was not silently dropped, and every numeric column has a valid precision' + columns.keySet().containsAll(['FLOAT_VALUE', 'DOUBLE_VALUE', 'BIG_DECIMAL_VALUE']) + columns['FLOAT_VALUE'] > 0 + columns['DOUBLE_VALUE'] > 0 + columns['BIG_DECIMAL_VALUE'] > 0 + + where: + db | container + "Postgres" | postgres + "MySQL" | mysql + "MariaDB" | mariadb + } +} + +@Entity +class NumericTypeReading implements HibernateEntity { + Float floatValue + Double doubleValue + BigDecimal bigDecimalValue +} diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/GormNumericTypeColumnPrecisionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/GormNumericTypeColumnPrecisionSpec.groovy new file mode 100644 index 00000000000..2fcfdce9ed3 --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/GormNumericTypeColumnPrecisionSpec.groovy @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.orm.hibernate + +import grails.gorm.annotation.Entity +import grails.gorm.hibernate.HibernateEntity +import grails.gorm.tests.HibernateGormDatastoreSpec +import org.hibernate.dialect.H2Dialect +import org.hibernate.mapping.PersistentClass + +/** + * Covers a regression where a Float/Double property with no explicit precision produced + * invalid DDL (Hibernate rendered {@code float(64)}, which H2/PostgreSQL reject since their + * FLOAT bit-precision ceiling is 53) that {@code hibernate.hbm2ddl.auto} logs but does not + * throw for - the table is silently never created. FLOAT/DOUBLE precision is a *bit* count, + * while NUMERIC/DECIMAL (BigDecimal) precision is a *decimal digit* count; applying one default + * to every Number subtype conflates the two, and Hibernate itself converts a decimal-digit + * precision into a bit count when rendering float(n) DDL - so NumericColumnConstraintsBinder now + * leaves Float/Double precision unset (letting Hibernate fall back to the dialect's own correct + * float/double DDL type directly) and only computes a decimal-digit default, from the dialect's + * Dialect.getDefaultDecimalPrecision(), for BigDecimal/BigInteger. No per-dialect (e.g. Oracle) + * special case is needed for either family. + * + * Runs against the default H2 datastore, so it needs no container - if schema creation in + * setupSpec() silently drops the table, the live-DDL test below fails to find its columns. The + * Testcontainers-backed multi-dialect DDL check lives separately in + * {@link GormNumericTypeColumnIntegrationSpec}. + */ +class GormNumericTypeColumnPrecisionSpec extends HibernateGormDatastoreSpec { + + void setupSpec() { + manager.registerDomainClasses(NumericTypeMessage) + } + + void "a Float property with no explicit precision is left unset for Hibernate's own dialect default"() { + when: + PersistentClass persistentClass = datastore.getMetadata().getEntityBinding(NumericTypeMessage.name) + def column = persistentClass.getProperty('floatValue').getColumns().first() + + then: + column.getPrecision() == null + } + + void "a Double property with no explicit precision is left unset for Hibernate's own dialect default"() { + when: + PersistentClass persistentClass = datastore.getMetadata().getEntityBinding(NumericTypeMessage.name) + def column = persistentClass.getProperty('doubleValue').getColumns().first() + + then: + column.getPrecision() == null + } + + void "a BigDecimal property with no explicit precision defaults to the dialect's decimal precision"() { + when: + PersistentClass persistentClass = datastore.getMetadata().getEntityBinding(NumericTypeMessage.name) + def column = persistentClass.getProperty('bigDecimalValue').getColumns().first() + + then: + column.getPrecision() == new H2Dialect().getDefaultDecimalPrecision() + } + + void "the numeric_type_message table is actually created with valid DDL on H2"() { + when: + Map columns = [:] + datastore.dataSource.connection.withCloseable { conn -> + conn.createStatement().withCloseable { stmt -> + stmt.executeQuery(''' + select column_name, numeric_precision + from information_schema.columns + where upper(table_name) = 'NUMERIC_TYPE_MESSAGE' + '''.stripIndent()).with { rs -> + while (rs.next()) { + columns[rs.getString('column_name').toUpperCase()] = rs.getInt('numeric_precision') + } + } + } + } + + then: 'the table was not silently dropped, and every numeric column has a valid precision' + columns.keySet().containsAll(['FLOAT_VALUE', 'DOUBLE_VALUE', 'BIG_DECIMAL_VALUE']) + columns['FLOAT_VALUE'] > 0 + columns['DOUBLE_VALUE'] > 0 + columns['BIG_DECIMAL_VALUE'] > 0 + } +} + +@Entity +class NumericTypeMessage implements HibernateEntity { + Float floatValue + Double doubleValue + BigDecimal bigDecimalValue +} diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/ColumnBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/ColumnBinderSpec.groovy index 793fd66914f..096c3f9409d 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/ColumnBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/ColumnBinderSpec.groovy @@ -107,7 +107,7 @@ class ColumnBinderSpec extends HibernateGormDatastoreSpec { column.getCustomRead() == "r" column.getCustomWrite() == "w" - 1 * numericBinder.bindNumericColumnConstraints(column, cc, _) + 1 * numericBinder.bindNumericColumnConstraints(column, cc, _, Integer) 1 * keyCreator.createKeyForProps(prop, "p", table, "num_col") 1 * indexBinder.bindIndex("num_col", column, cc, table) } diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/NumericColumnConstraintsBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/NumericColumnConstraintsBinderSpec.groovy index 0a3efd4f694..0d940f7d6f6 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/NumericColumnConstraintsBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/NumericColumnConstraintsBinderSpec.groovy @@ -20,6 +20,10 @@ package org.grails.orm.hibernate.cfg.domainbinding import org.grails.orm.hibernate.cfg.ColumnConfig import org.grails.orm.hibernate.cfg.PropertyConfig +import org.hibernate.dialect.H2Dialect +import org.hibernate.dialect.MySQLDialect +import org.hibernate.dialect.OracleDialect +import org.hibernate.dialect.PostgreSQLDialect import org.hibernate.mapping.Column import spock.lang.Specification import org.grails.orm.hibernate.cfg.domainbinding.binder.NumericColumnConstraintsBinder @@ -36,7 +40,7 @@ class NumericColumnConstraintsBinderSpec extends Specification { cc.scale = 2 when: - binder.bindNumericColumnConstraints(column, cc, new PropertyConfig()) + binder.bindNumericColumnConstraints(column, cc, new PropertyConfig(), BigDecimal) then: column.precision == 10 @@ -52,36 +56,97 @@ class NumericColumnConstraintsBinderSpec extends Specification { pc.max = 1000 when: - binder.bindNumericColumnConstraints(column, cc, pc) + binder.bindNumericColumnConstraints(column, cc, pc, BigDecimal) then: column.precision == 8 // 4 digits + 4 scale column.scale == 4 } - def "should use default precision 15 for non-Oracle when no constraints"() { + def "should default BigDecimal precision from the dialect's decimal default when no constraints"() { given: - def nonOracleBinder = new NumericColumnConstraintsBinder(new org.hibernate.dialect.H2Dialect()) + def h2Binder = new NumericColumnConstraintsBinder(new H2Dialect()) def cc = new ColumnConfig() def pc = new PropertyConfig() when: - nonOracleBinder.bindNumericColumnConstraints(column, cc, pc) + h2Binder.bindNumericColumnConstraints(column, cc, pc, BigDecimal) then: - column.precision == 15 + column.precision == new H2Dialect().getDefaultDecimalPrecision() } - def "should use default precision 126 for Oracle when no constraints"() { + def "should default decimal precision consistently across dialects without a per-dialect special case"() { given: - def oracleBinder = new NumericColumnConstraintsBinder(new org.hibernate.dialect.OracleDialect()) + def binderFor = new NumericColumnConstraintsBinder(dialect) def cc = new ColumnConfig() def pc = new PropertyConfig() + def col = new Column("test") when: - oracleBinder.bindNumericColumnConstraints(column, cc, pc) + binderFor.bindNumericColumnConstraints(col, cc, pc, BigDecimal) then: - column.precision == 126 + col.precision == dialect.getDefaultDecimalPrecision() + + where: + dialect << [new H2Dialect(), new PostgreSQLDialect(), new MySQLDialect(), new OracleDialect()] + } + + def "should leave Float precision unset when no constraints, avoiding invalid float(n) DDL"() { + given: + def cc = new ColumnConfig() + def pc = new PropertyConfig() + + when: + binder.bindNumericColumnConstraints(column, cc, pc, Float) + + then: + column.precision == null + } + + def "should leave Double precision unset when no constraints, avoiding invalid float(n) DDL"() { + given: + def cc = new ColumnConfig() + def pc = new PropertyConfig() + + when: + binder.bindNumericColumnConstraints(column, cc, pc, Double) + + then: + column.precision == null + } + + def "should leave Float/Double precision unset even when min/max constraints are present"() { + given: + def cc = new ColumnConfig() + def pc = new PropertyConfig() + pc.min = -100 + pc.max = 1000 + + when: + binder.bindNumericColumnConstraints(column, cc, pc, propertyType) + + then: 'min/max only drives decimal precision - it must not leak into float/double precision' + column.precision == null + + where: + propertyType << [Float, Double] + } + + def "should still honor an explicit column-config precision for Float/Double"() { + given: + def cc = new ColumnConfig() + cc.precision = 10 + def pc = new PropertyConfig() + + when: + binder.bindNumericColumnConstraints(column, cc, pc, propertyType) + + then: + column.precision == 10 + + where: + propertyType << [Float, Double] } } diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/SimpleValueBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/SimpleValueBinderSpec.groovy index 18a2df24d96..68772241dc8 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/SimpleValueBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/SimpleValueBinderSpec.groovy @@ -44,7 +44,11 @@ class SimpleValueBinderSpec extends Specification { } def namingStrategy = Mock(PersistentEntityNamingStrategy) - def jdbcEnvironment = Mock(org.hibernate.engine.jdbc.env.spi.JdbcEnvironment) + def jdbcEnvironment = Mock(org.hibernate.engine.jdbc.env.spi.JdbcEnvironment) { + // NumericColumnConstraintsBinder needs a real Dialect - a real Hibernate bootstrap never + // produces a null one, so ColumnBinder(namingStrategy, dialect) isn't null-guarded for it. + getDialect() >> new org.hibernate.dialect.H2Dialect() + } def metadataBuildingContext = Mock(org.hibernate.boot.spi.MetadataBuildingContext) def binder = new SimpleValueBinder(metadataBuildingContext, namingStrategy, jdbcEnvironment)