diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 3a64c11..df87897 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -19,40 +19,13 @@ jobs: if: ${{ !contains(github.event.head_commit.message, '[skip tests]') }} name: "Test" runs-on: ubuntu-24.04 - container: ubuntu - services: - postgres: - image: postgres - env: - POSTGRES_DB: pg_extensions_test - POSTGRES_USER: postgres_extensions - POSTGRES_PASSWORD: postgres_extensions - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres_extensions -d pg_extensions_test" - --health-interval 10s - --health-timeout 5s - --health-retries 5 steps: - - name: Install psql client - env: - DEBIAN_FRONTEND: noninteractive - run: | - apt-get update - apt-get install -y --no-install-recommends postgresql-client - - name: Create extensions - env: - PGPASSWORD: postgres_extensions - run: | - until pg_isready -h postgres -p 5432 -U postgres_extensions -d pg_extensions_test; do sleep 1; done - psql -h postgres -U postgres_extensions -d pg_extensions_test -c "CREATE EXTENSION IF NOT EXISTS hstore;" - name: "📥 Checkout repository" uses: actions/checkout@v5 - name: "☕️ Setup JDK" uses: actions/setup-java@v5 with: - java-version: 17 + java-version: 21 distribution: liberica - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v4 @@ -60,9 +33,6 @@ jobs: develocity-access-key: ${{ secrets.DEVELOCITY_ACCESS_KEY }} - name: "🏃‍♂️ Run Checks" id: build - env: - POSTGRES_HOST: postgres - POSTGRES_PORT: 5432 run: ./gradlew check --continue publish_snapshot: needs: tests @@ -76,7 +46,7 @@ jobs: - name: "☕️ Setup JDK" uses: actions/setup-java@v4 with: - java-version: 17 + java-version: 21 distribution: liberica - name: "🐘 Setup Gradle" uses: gradle/actions/setup-gradle@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9cc7c12..1481381 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: types: [ published ] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - JAVA_VERSION: '17.0.15' # this must be a specific version for reproducible builds + JAVA_VERSION: '21.0.12' # this must be a specific version for reproducible builds RELEASE_TAG_PREFIX: 'v' jobs: publish: diff --git a/.sdkmanrc b/.sdkmanrc index 48cc71b..277b213 100644 --- a/.sdkmanrc +++ b/.sdkmanrc @@ -1,2 +1,2 @@ # Enable auto-env through the sdkman_auto_env config - https://sdkman.io/usage#env -java=17.0.15-librca +java=21.0.12-librca diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 76557cd..0000000 --- a/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM postgres - -ENV POSTGRES_PASSWORD=postgres_extensions -ENV POSTGRES_USER=postgres_extensions -ENV POSTGRES_DB=pg_extensions_test - -RUN echo "CREATE EXTENSION hstore" >> /docker-entrypoint-initdb.d/hstore.sql \ No newline at end of file diff --git a/README.md b/README.md index 17ffe9a..48cb982 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,10 @@ More native types and query methods will be added in the future. ## Installation -The Grails 7 version of this plugin supports Hibernate 5. -In `build.gradle` add the following dependencies to install the plugin: +The Grails 8 version of this plugin supports both Hibernate 5 and Hibernate 7, with a plugin for each. +In `build.gradle` add the dependencies for the Hibernate version of your application. + +Hibernate 5: ```groovy dependencies { @@ -59,6 +61,51 @@ dependencies { } ``` +Hibernate 7: + +```groovy +dependencies { + //... + implementation 'io.github.gpc:grails-postgresql-extensions-hibernate7:' + implementation 'org.apache.grails:grails-data-hibernate7' + //... +} +``` + +Both plugins provide the same user types, dialect, criteria and orders, in the same packages, so the domain classes +and queries of an application do not change when it moves from Hibernate 5 to Hibernate 7. + +> [!NOTE] +> With GORM 8.0.0 for Hibernate 7, the default `native` id generator does not create its sequences on PostgreSQL +> ([apache/grails-core#16561](https://github.com/apache/grails-core/issues/16561)). +> Until this is fixed in GORM, use the `sequence` generator in `grails-app/conf/application.groovy`: +> +> ```groovy +> grails.gorm.default.mapping = { +> id generator: 'sequence' +> } +> ``` + +### Upgrading to 9.0.0 + +- The Hibernate independent classes, like `HstoreHelper` and `PgArrayUtils`, moved to the + `grails-postgresql-extensions-core` module, which both plugins depend on. Their packages did not change. +- `PgArrayUtils.getValueAsArrayOfType` now throws an `IllegalArgumentException` instead of a `HibernateException` for + values of an unsupported type. The array criteria still throw a `HibernateException`. + +### Hibernate 7 limitations + +GORM 8.0.0 for Hibernate 7 has no API for custom criteria to use the joins of the query, or to order by an SQL formula +([apache/grails-core#16563](https://github.com/apache/grails-core/issues/16563)), so the plugin works around it: + +- A criterion on an alias created with `createAlias` uses the same join as the GORM criteria on the alias, when the + alias uses an inner join. GORM 8.0.0 creates the join of an alias as an inner join even when `createAlias` asks for an + outer join ([apache/grails-core#16562](https://github.com/apache/grails-core/issues/16562)), so for an outer join alias the criteria of the plugin use an + outer join, but GORM criteria on the same alias do not. +- `order(sqlFormula(...))` and `order(byRandom())` are added to the orders when the query is created, in the position + they were called in. Calling them inside `or {}` or `not {}` does not change the result of the junction. + An `or {}` that only contains such an order is empty, which matches no rows. + ## Configuration After installing the plugin you have to use a new PostgreSQL Hibernate Dialect in your application. Add it to the @@ -655,6 +702,8 @@ if not you'll get a sql error during runtime. ## Release Notes +From 8.0.0 and forward release notes can be found on [GitHub](https://github.com/gpc/grails-postgresql-extensions/releases) + Version | Date | Comments ------- | ------------| --------- 7.0.0 | 29/Jul/2019 | Grails 4 (Hibernate 5.4): Add support for Grails 4 and Hibernate 5.4. Thanks to [James Hardwick](https://github.com/jamesdh) and [Zhuravskiy Vitaliy](https://github.com/zhuravskiy). diff --git a/build.gradle b/build.gradle index 5538ee8..fe433fe 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,43 @@ version = projectVersion group = 'io.github.gpc' +def publishedProjects = [ + 'grails-postgresql-extensions-core', + 'grails-postgresql-extensions', + 'grails-postgresql-extensions-hibernate7', +] + +ext.pgextDevelopers = [ + ilopmar: 'Iván López', + alotor: 'Alonso Torres', + mattfeury: 'Matt Feury', + alvarosanchez: 'Alvaro Sanchez-Mariscal', + aeischeid: 'Aaron Eischeid', + mkobel: 'Moritz Kobel', + manuelvio: 'Manuel Unno Vio', + bameda: 'David Barragán Merino', + nobeans: 'Yasuharu Nakano', + burtbeckwith: 'Burt Beckwith', + timic: 'Timur Salyakhutdinov', + zlegein: 'Zach Legein', + tmarthal: 'Tom Marthaler', + pabloalba: 'Pablo Alba', + cornercase: 'Eamon Doyle', + poundex: 'Adam Pounder', + gregopet: 'Gregor Petrin', + unknown: 'Tom Potts', + sabst: 'Sabst', + donbeave: 'Alexey Zhokov', + jglapa: 'Jakub Glapa', + jamesdh: 'James Hardwick', + butters16: 'John Keith', + erichelgeson: 'Eric Helgeson', + albertop19: 'albertop19', + gtors: 'Andrey T', + matrei: 'Mattias Reichel', + sbglasius: 'Søren Berg Glasius' +] + subprojects { repositories { @@ -17,7 +54,7 @@ subprojects { } } - if (name == 'grails-postgresql-extensions') { + if (name in publishedProjects) { // This has to be applied here in the root project due to the nexus plugin requirements apply plugin: 'org.apache.grails.gradle.grails-publish' } diff --git a/gradle.properties b/gradle.properties index f5a1317..0d92f8c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ -projectVersion=8.0.1-SNAPSHOT +projectVersion=9.0.0-SNAPSHOT -grailsVersion=7.0.0 -javaVersion=17 +grailsVersion=8.0.0 +javaVersion=21 hibernate5Version=5.6.15.Final diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da..299848b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/plugin-core/build.gradle b/plugin-core/build.gradle new file mode 100644 index 0000000..43d65b0 --- /dev/null +++ b/plugin-core/build.gradle @@ -0,0 +1,41 @@ +plugins { + id 'groovy' + id 'java-library' +} + +version = projectVersion +group = 'io.github.gpc' + +grailsPublish { + artifactId = 'grails-postgresql-extensions-core' + githubSlug = 'gpc/grails-postgresql-extensions' + license.name = 'Apache-2.0' + title = 'Grails PostgreSQL Extensions Core' + desc = 'Hibernate-independent support code shared by the Grails PostgreSQL Extensions plugins.' + developers = rootProject.ext.pgextDevelopers +} + +dependencies { + + implementation platform("org.apache.grails:grails-bom:$grailsVersion") + + compileOnly 'org.springframework:spring-core', { // Provided + // impl: Assert + } + + implementation 'org.apache.groovy:groovy' + implementation 'org.slf4j:slf4j-api' + api 'org.postgresql:postgresql' + + testImplementation 'org.spockframework:spock-core' + + testRuntimeOnly 'org.springframework:spring-core', { + // Assert + } +} + +compileJava.options.release = javaVersion.toInteger() + +apply { + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') +} diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/usertype/BidiEnumMap.groovy b/plugin-core/src/main/groovy/gpc/pgext/hibernate/usertype/BidiEnumMap.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/usertype/BidiEnumMap.groovy rename to plugin-core/src/main/groovy/gpc/pgext/hibernate/usertype/BidiEnumMap.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreHelper.groovy b/plugin-core/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreHelper.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreHelper.groovy rename to plugin-core/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreHelper.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreParseException.groovy b/plugin-core/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreParseException.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreParseException.groovy rename to plugin-core/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreParseException.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreParser.java b/plugin-core/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreParser.java similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreParser.java rename to plugin-core/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreParser.java diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/utils/PgArrayUtils.groovy b/plugin-core/src/main/groovy/gpc/pgext/hibernate/utils/PgArrayUtils.groovy similarity index 92% rename from plugin/src/main/groovy/gpc/pgext/hibernate/utils/PgArrayUtils.groovy rename to plugin-core/src/main/groovy/gpc/pgext/hibernate/utils/PgArrayUtils.groovy index 930c6a5..faad120 100644 --- a/plugin/src/main/groovy/gpc/pgext/hibernate/utils/PgArrayUtils.groovy +++ b/plugin-core/src/main/groovy/gpc/pgext/hibernate/utils/PgArrayUtils.groovy @@ -4,8 +4,6 @@ import java.lang.reflect.Array import groovy.transform.CompileStatic -import org.hibernate.HibernateException - /** * Utils for the different criteria queries. */ @@ -43,7 +41,7 @@ class PgArrayUtils { if (mapFunction) { return mapFunction.map(o) } - throw new HibernateException("criteria doesn't support values of type: ${o?.class?.name}. Try: $expectedType or List<$expectedType> instead") + throw new IllegalArgumentException("criteria doesn't support values of type: ${o?.class?.name}. Try: $expectedType or List<$expectedType> instead") } converted.toArray((Object[]) Array.newInstance(expectedType, converted.size())) diff --git a/plugin/src/test/groovy/gpc/pgext/hibernate/usertype/HstoreHelperSpec.groovy b/plugin-core/src/test/groovy/gpc/pgext/hibernate/usertype/HstoreHelperSpec.groovy similarity index 100% rename from plugin/src/test/groovy/gpc/pgext/hibernate/usertype/HstoreHelperSpec.groovy rename to plugin-core/src/test/groovy/gpc/pgext/hibernate/usertype/HstoreHelperSpec.groovy diff --git a/plugin/src/test/groovy/gpc/pgext/hibernate/usertype/HstoreParserSpec.groovy b/plugin-core/src/test/groovy/gpc/pgext/hibernate/usertype/HstoreParserSpec.groovy similarity index 100% rename from plugin/src/test/groovy/gpc/pgext/hibernate/usertype/HstoreParserSpec.groovy rename to plugin-core/src/test/groovy/gpc/pgext/hibernate/usertype/HstoreParserSpec.groovy diff --git a/plugin-hibernate5/build.gradle b/plugin-hibernate5/build.gradle new file mode 100644 index 0000000..78f1309 --- /dev/null +++ b/plugin-hibernate5/build.gradle @@ -0,0 +1,36 @@ +plugins { + id 'java-library' + id 'org.apache.grails.gradle.grails-plugin' +} + +version = projectVersion +group = 'io.github.gpc' + +grailsPublish { + artifactId = 'grails-postgresql-extensions' + githubSlug = 'gpc/grails-postgresql-extensions' + license.name = 'Apache-2.0' + title = 'Grails PostgreSQL Extensions Plugin' + desc = 'A Grails plugin that adds Hibernate UserTypes for PostgreSQL native types, ' + + 'like Array, HStore, JSON, JSONB, ..., and GORM criteria extensions to query them.' + developers = rootProject.ext.pgextDevelopers +} + +dependencies { + + implementation platform("org.apache.grails:grails-bom:$grailsVersion") + + api project(':grails-postgresql-extensions-core') + + compileOnly 'org.apache.grails:grails-core', { // Provided + // impl: Plugin + } + compileOnly 'org.apache.grails:grails-data-hibernate5', { + // Must be provided by the Grails application + } + + implementation 'com.google.code.gson:gson' + implementation "org.hibernate:hibernate-core-jakarta:$hibernate5Version" +} + +compileJava.options.release = javaVersion.toInteger() diff --git a/plugin/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy similarity index 97% rename from plugin/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy index b578b83..ea88d36 100644 --- a/plugin/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy +++ b/plugin-hibernate5/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy @@ -46,6 +46,7 @@ class GrailsPostgresqlExtensionsGrailsPlugin extends Plugin { [name: 'albertop19'], [name: 'Andrey T'], [name: 'Mattias Reichel'], + [name: 'Søren Berg Glasius'], ] def issueManagement = [ system: 'GITHUB', diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/PostgresqlExtensionsDialect.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/PostgresqlExtensionsDialect.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/PostgresqlExtensionsDialect.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/PostgresqlExtensionsDialect.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayExpression.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayExpression.groovy similarity index 82% rename from plugin/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayExpression.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayExpression.groovy index d9c38df..d9309bb 100644 --- a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayExpression.groovy +++ b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayExpression.groovy @@ -24,11 +24,10 @@ class PgArrayExpression implements Criterion { private static final PgArrayUtils.MapFunction MAP_TO_ENUM = new PgArrayUtils.MapFunction() { @Override Object map(Object o) { - try { + if (o instanceof Enum) { return ((Enum) o).ordinal() - } catch (ClassCastException e) { - throw new HibernateException("Unable to cast object $o to Enum", e) } + throw new HibernateException("Unable to cast object $o to Enum") } } @@ -50,9 +49,14 @@ class PgArrayExpression implements Criterion { @Override TypedValue[] getTypedValues(Criteria criteria, CriteriaQuery criteriaQuery) throws HibernateException { def arrayType = checkAndGetArrayType(criteria, criteriaQuery, propertyName) - def arrValue = arrayType.typeClass.isEnum() ? - PgArrayUtils.getValueAsArrayOfType(value, Integer, MAP_TO_ENUM) : - PgArrayUtils.getValueAsArrayOfType(value, arrayType.typeClass) + Object[] arrValue + try { + arrValue = arrayType.typeClass.isEnum() ? + PgArrayUtils.getValueAsArrayOfType(value, Integer, MAP_TO_ENUM) : + PgArrayUtils.getValueAsArrayOfType(value, arrayType.typeClass) + } catch (IllegalArgumentException e) { + throw new HibernateException(e.message, e) + } criteriaQuery.getTypedValue(criteria, propertyName, arrValue) as TypedValue[] } diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayILikeFunction.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayILikeFunction.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayILikeFunction.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgArrayILikeFunction.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgEmptinessExpression.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgEmptinessExpression.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgEmptinessExpression.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/array/PgEmptinessExpression.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreILikeValueFunction.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreILikeValueFunction.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreILikeValueFunction.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreILikeValueFunction.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreOperatorExpression.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreOperatorExpression.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreOperatorExpression.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreOperatorExpression.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreValueFunction.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreValueFunction.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreValueFunction.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/hstore/PgHstoreValueFunction.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/json/PgJsonExpression.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/json/PgJsonExpression.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/criterion/json/PgJsonExpression.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/json/PgJsonExpression.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/criterion/json/PgJsonbOperator.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/json/PgJsonbOperator.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/criterion/json/PgJsonbOperator.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/criterion/json/PgJsonbOperator.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/order/OrderByRandom.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/order/OrderByRandom.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/order/OrderByRandom.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/order/OrderByRandom.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/order/OrderBySqlFormula.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/order/OrderBySqlFormula.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/order/OrderBySqlFormula.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/order/OrderBySqlFormula.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/ArrayCriterias.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/ArrayCriterias.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/ArrayCriterias.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/ArrayCriterias.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/HstoreCriterias.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/HstoreCriterias.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/HstoreCriterias.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/HstoreCriterias.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/JsonCriterias.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/JsonCriterias.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/JsonCriterias.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/JsonCriterias.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/usertype/ArrayType.java b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/usertype/ArrayType.java similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/usertype/ArrayType.java rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/usertype/ArrayType.java diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreMapType.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreMapType.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreMapType.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreMapType.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/usertype/JsonMapType.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/usertype/JsonMapType.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/usertype/JsonMapType.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/usertype/JsonMapType.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/usertype/JsonbMapType.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/usertype/JsonbMapType.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/usertype/JsonbMapType.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/usertype/JsonbMapType.groovy diff --git a/plugin/src/main/groovy/gpc/pgext/hibernate/utils/CriteriaUtils.groovy b/plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/utils/CriteriaUtils.groovy similarity index 100% rename from plugin/src/main/groovy/gpc/pgext/hibernate/utils/CriteriaUtils.groovy rename to plugin-hibernate5/src/main/groovy/gpc/pgext/hibernate/utils/CriteriaUtils.groovy diff --git a/plugin/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule b/plugin-hibernate5/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule similarity index 100% rename from plugin/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule rename to plugin-hibernate5/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule diff --git a/plugin-hibernate7/build.gradle b/plugin-hibernate7/build.gradle new file mode 100644 index 0000000..fea0b33 --- /dev/null +++ b/plugin-hibernate7/build.gradle @@ -0,0 +1,35 @@ +plugins { + id 'java-library' + id 'org.apache.grails.gradle.grails-plugin' +} + +version = projectVersion +group = 'io.github.gpc' + +grailsPublish { + artifactId = 'grails-postgresql-extensions-hibernate7' + githubSlug = 'gpc/grails-postgresql-extensions' + license.name = 'Apache-2.0' + title = 'Grails PostgreSQL Extensions Plugin for Hibernate 7' + desc = 'A Grails plugin that adds Hibernate 7 UserTypes for PostgreSQL native types, ' + + 'like Array, HStore, JSON, JSONB, ..., and GORM criteria extensions to query them.' + developers = rootProject.ext.pgextDevelopers +} + +dependencies { + + implementation platform("org.apache.grails:grails-bom:$grailsVersion") + + api project(':grails-postgresql-extensions-core') + + compileOnly 'org.apache.grails:grails-core', { // Provided + // impl: Plugin + } + compileOnly 'org.apache.grails:grails-data-hibernate7', { + // Must be provided by the Grails application + } + + implementation 'com.google.code.gson:gson' +} + +compileJava.options.release = javaVersion.toInteger() diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy new file mode 100644 index 0000000..a6dbb91 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/GrailsPostgresqlExtensionsGrailsPlugin.groovy @@ -0,0 +1,58 @@ +package gpc.pgext + +import groovy.transform.CompileStatic + +import grails.plugins.Plugin + +@CompileStatic +class GrailsPostgresqlExtensionsGrailsPlugin extends Plugin { + + def grailsVersion = '8.0.0 > *' + def title = 'Grails PostgreSQL Extensions Plugin for Hibernate 7' + def author = 'Iván López' + def description = 'Provides Hibernate user types supporting PostgreSQL Native Types ' + + 'like Array, HStore, JSON, JSONB,... as well as new criteria to query ' + + 'these native types.' + def documentation = 'https://github.com/gpc/grails-postgresql-extensions' + def license = 'APACHE' + def organization = [ + name: 'GPC', + url: 'https://github.com/gpc' + ] + def developers = [ + [name: 'Alonso Torres'], + [name: 'Matt Feury'], + [name: 'Alvaro Sanchez-Mariscal'], + [name: 'Aaron Eischeid'], + [name: 'Moritz Kobel'], + [name: 'Manuel Unno Vio'], + [name: 'David Barragán Merino'], + [name: 'Yasuharu Nakano'], + [name: 'Burt Beckwith'], + [name: 'Timur Salyakhutdinov'], + [name: 'Zach Legein'], + [name: 'Tom Marthaler'], + [name: 'Pablo Alba'], + [name: 'Eamon Doyle'], + [name: 'Adam Pounder'], + [name: 'Gregor Petrin'], + [name: 'Tom Potts'], + [name: 'Sabst'], + [name: 'Alexey Zhokov'], + [name: 'Jakub Glapa'], + [name: 'James Hardwick'], + [name: 'John Keith'], + [name: 'Eric Helgeson'], + [name: 'albertop19'], + [name: 'Andrey T'], + [name: 'Mattias Reichel'], + [name: 'Søren Berg Glasius'], + ] + def issueManagement = [ + system: 'GITHUB', + url: 'https://github.com/gpc/grails-postgresql-extensions/issues' + ] + def scm = [ + url: 'https://github.com/gpc/grails-postgresql-extensions' + ] +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/PostgresqlExtensionsDialect.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/PostgresqlExtensionsDialect.groovy new file mode 100644 index 0000000..f88f768 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/PostgresqlExtensionsDialect.groovy @@ -0,0 +1,85 @@ +package gpc.pgext.hibernate + +import groovy.transform.CompileStatic + +import org.hibernate.boot.model.TypeContributions +import org.hibernate.dialect.PostgreSQLDialect +import org.hibernate.service.ServiceRegistry +import org.hibernate.type.descriptor.java.JavaType +import org.hibernate.type.descriptor.jdbc.JdbcType +import org.hibernate.type.descriptor.sql.DdlType + +import gpc.pgext.hibernate.usertype.ArrayType +import gpc.pgext.hibernate.usertype.HstoreMapType +import gpc.pgext.hibernate.usertype.JsonMapType +import gpc.pgext.hibernate.usertype.JsonbMapType + +@CompileStatic +class PostgresqlExtensionsDialect extends PostgreSQLDialect { + + private static final Map COLUMN_TYPES = [ + (ArrayType.LONG_ARRAY) : '_int8', + (ArrayType.INTEGER_ARRAY) : '_int4', + (ArrayType.ENUM_INTEGER_ARRAY): '_int4', + (ArrayType.STRING_ARRAY) : '_varchar', + (ArrayType.DOUBLE_ARRAY) : '_float8', + (ArrayType.FLOAT_ARRAY) : '_float4', + (ArrayType.UUID_ARRAY) : '_uuid', + (HstoreMapType.SQLTYPE) : 'hstore', + (JsonMapType.SQLTYPE) : 'json', + (JsonbMapType.SQLTYPE) : 'jsonb', + ] as Map + + /** + * Register the column types of the postgresql user types + */ + @Override + void contributeTypes(TypeContributions typeContributions, ServiceRegistry serviceRegistry) { + super.contributeTypes(typeContributions, serviceRegistry) + def ddlTypeRegistry = typeContributions.typeConfiguration.ddlTypeRegistry + COLUMN_TYPES.each { Integer sqlType, String typeName -> + ddlTypeRegistry.addDescriptor(new FixedDdlType(sqlType, typeName)) + } + } + + /** + * The column type of a user type, which is always the same type name + */ + private static class FixedDdlType implements DdlType { + + private static final long serialVersionUID = 1L + + private final int sqlTypeCode + private final String typeName + + FixedDdlType(int sqlTypeCode, String typeName) { + this.sqlTypeCode = sqlTypeCode + this.typeName = typeName + } + + @Override + int getSqlTypeCode() { + sqlTypeCode + } + + @Override + String getRawTypeName() { + typeName + } + + @Override + String getTypeName(Long size, Integer precision, Integer scale) { + typeName + } + + @Override + String getCastTypeName(JdbcType jdbcType, JavaType javaType) { + typeName + } + + @Override + String getCastTypeName(JdbcType jdbcType, JavaType javaType, Long length, Integer precision, Integer scale) { + typeName + } + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgCriterion.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgCriterion.groovy new file mode 100644 index 0000000..bc12bbc --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgCriterion.groovy @@ -0,0 +1,117 @@ +package gpc.pgext.hibernate.criterion + +import groovy.transform.CompileStatic + +import jakarta.persistence.criteria.AbstractQuery +import jakarta.persistence.criteria.Expression +import jakarta.persistence.criteria.From +import jakarta.persistence.criteria.Join +import jakarta.persistence.criteria.JoinType +import jakarta.persistence.criteria.Path +import jakarta.persistence.criteria.Predicate +import org.hibernate.query.criteria.HibernateCriteriaBuilder +import org.hibernate.query.sqm.tree.from.SqmFrom + +import org.grails.datastore.mapping.query.Query + +/** + * A GORM criterion that is turned into a JPA predicate by {@link PgCriterionHandlerProvider}. + * + * Hibernate 7 has no Criteria API to extend, so the criteria of this plugin are added to the GORM query + * and rendered as PostgreSQL SQL fragments through {@link PgSqlFunction}. + */ +@CompileStatic +class PgCriterion implements Query.Criterion { + + /** + * Creates the predicate of a criterion + */ + interface PredicateFactory { + + /** + * @param query The query the predicate is created for + * @param root The root (or association join) of the query + * @param cb The criteria builder + * @param property The path of the property of the criterion, or null if it has none + * @return The predicate, or null if the criterion does not restrict the query + */ + Predicate create(AbstractQuery query, From root, HibernateCriteriaBuilder cb, Path property) + } + + /** The property path, with any alias replaced by its association path */ + final String propertyPath + + /** Whether the criterion is an order by an SQL formula, see {@code OrderBySqlFormula} */ + final boolean order + + /** The join type of the alias the property is on, or null if it is not on an alias */ + JoinType joinType + + private final PredicateFactory factory + + PgCriterion(String propertyPath, PredicateFactory factory) { + this(propertyPath, false, factory) + } + + private PgCriterion(String propertyPath, boolean order, PredicateFactory factory) { + this.propertyPath = propertyPath + this.order = order + this.factory = factory + } + + /** + * Creates a criterion that orders the query, and returns no predicate + */ + static PgCriterion order(PredicateFactory factory) { + new PgCriterion(null, true, factory) + } + + Predicate toPredicate(AbstractQuery query, From root, HibernateCriteriaBuilder cb) { + factory.create(query, root, cb, propertyPath ? resolvePath(root, propertyPath, joinType) : null) + } + + /** + * Creates a predicate from a PostgreSQL SQL fragment, where each {@code ?} is replaced by the next argument + */ + static Predicate sql(HibernateCriteriaBuilder cb, String sql, Expression... arguments) { + List> functionArguments = [cb.literal(sql)] as List> + functionArguments.addAll(arguments) + cb.isTrue(cb.function(PgSqlFunction.PREDICATE, Boolean, functionArguments as Expression[])) + } + + /** + * Binds the value as text, or as SQL NULL when it is null, like Hibernate 5 did + */ + static Expression text(HibernateCriteriaBuilder cb, Object value) { + value == null ? cb.nullLiteral(String) : cb.value(value.toString()) + } + + /** + * Resolves a property path like {@code like.favoriteNumbers}, reusing the joins already on the query. + * GORM may join an association more than once (for {@code createAlias} and for the alias itself), so the + * latest join with the join type of the alias is used (see {@code CriteriaUtils.addToCriteria}) + */ + private static Path resolvePath(From root, String propertyPath, JoinType joinType) { + String[] segments = propertyPath.split(/\./) + From from = root + for (int i = 0; i < segments.length - 1; i++) { + String segment = segments[i] + Join join = joinsInCreationOrder(from).reverse().find { Join j -> + j.attribute?.name == segment && (joinType == null || j.joinType == joinType) + } + from = join ?: from.join(segment, joinType ?: JoinType.INNER) + } + from.get(segments[segments.length - 1]) + } + + private static List> joinsInCreationOrder(From from) { + // The joins of a JPA From are a Set, the joins of a Hibernate SqmFrom are kept in creation order + Collection joins = from instanceof SqmFrom ? ((SqmFrom) from).sqmJoins : from.joins + joins.findAll { it instanceof Join } as List> + } + + @Override + String toString() { + "PgCriterion($propertyPath)" + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgCriterionHandlerProvider.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgCriterionHandlerProvider.groovy new file mode 100644 index 0000000..0c950c6 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgCriterionHandlerProvider.groovy @@ -0,0 +1,34 @@ +package gpc.pgext.hibernate.criterion + +import groovy.transform.CompileStatic + +import jakarta.persistence.criteria.AbstractQuery +import jakarta.persistence.criteria.From +import jakarta.persistence.criteria.Predicate +import org.hibernate.query.criteria.HibernateCriteriaBuilder + +import org.grails.datastore.mapping.query.Query +import org.grails.orm.hibernate.query.PredicateGenerator + +/** + * Lets GORM turn a {@link PgCriterion} into a JPA predicate. Registered through + * {@code META-INF/services}. + */ +@CompileStatic +class PgCriterionHandlerProvider implements PredicateGenerator.CriterionHandlerProvider { + + @Override + Class criterionType() { + PgCriterion + } + + @Override + PredicateGenerator.CriterionHandler criterionHandler() { + new PredicateGenerator.CriterionHandler() { + @Override + Predicate handle(AbstractQuery query, From root, HibernateCriteriaBuilder cb, Query.Criterion criterion) { + ((PgCriterion) criterion).toPredicate(query, root, cb) + } + } + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgSqlFunction.java b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgSqlFunction.java new file mode 100644 index 0000000..7e491b9 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgSqlFunction.java @@ -0,0 +1,92 @@ +package gpc.pgext.hibernate.criterion; + +import java.util.List; + +import org.hibernate.metamodel.model.domain.ReturnableType; +import org.hibernate.query.sqm.function.AbstractSqmSelfRenderingFunctionDescriptor; +import org.hibernate.query.sqm.produce.function.StandardArgumentsValidators; +import org.hibernate.query.sqm.produce.function.StandardFunctionReturnTypeResolvers; +import org.hibernate.sql.ast.SqlAstTranslator; +import org.hibernate.sql.ast.spi.SqlAppender; +import org.hibernate.sql.ast.tree.SqlAstNode; +import org.hibernate.sql.ast.tree.expression.Literal; +import org.hibernate.type.BasicTypeReference; +import org.hibernate.type.StandardBasicTypes; +import org.hibernate.type.spi.TypeConfiguration; + +/** + * Renders a PostgreSQL SQL fragment given as the first (literal) argument. Every {@code ?} in the fragment + * is replaced by the rendering of the next argument, which is either a property path (rendered as its + * column) or a bound parameter. A {@code ?} inside quotes is not a placeholder. + */ +public class PgSqlFunction extends AbstractSqmSelfRenderingFunctionDescriptor { + + /** Renders a fragment that is a predicate, used by the criteria */ + public static final String PREDICATE = "pgext_sql_predicate"; + + /** Renders a fragment that is an expression, used by the orders */ + public static final String EXPRESSION = "pgext_sql_expression"; + + private final boolean predicate; + + public PgSqlFunction(String name, BasicTypeReference returnType, boolean predicate, TypeConfiguration typeConfiguration) { + super( + name, + StandardArgumentsValidators.min(1), + StandardFunctionReturnTypeResolvers.invariant(typeConfiguration.getBasicTypeRegistry().resolve(returnType)), + null + ); + this.predicate = predicate; + } + + static PgSqlFunction predicate(TypeConfiguration typeConfiguration) { + return new PgSqlFunction(PREDICATE, StandardBasicTypes.BOOLEAN, true, typeConfiguration); + } + + static PgSqlFunction expression(TypeConfiguration typeConfiguration) { + return new PgSqlFunction(EXPRESSION, StandardBasicTypes.OBJECT_TYPE, false, typeConfiguration); + } + + @Override + public boolean isPredicate() { + return predicate; + } + + @Override + public void render( + SqlAppender sqlAppender, + List arguments, + ReturnableType returnType, + SqlAstTranslator walker) { + if (!(arguments.get(0) instanceof Literal literal) || !(literal.getLiteralValue() instanceof String sql)) { + throw new IllegalArgumentException("The first argument of " + getName() + " must be the SQL as a literal"); + } + // A predicate is wrapped so it combines safely with other predicates. An order formula is not, + // as it may end with its own direction, like "(data->'name') desc" + if (predicate) { + sqlAppender.append('('); + } + int argument = 1; + boolean quoted = false; + for (int i = 0; i < sql.length(); i++) { + char c = sql.charAt(i); + if (c == '\'') { + quoted = !quoted; + } + if (c == '?' && !quoted) { + if (argument >= arguments.size()) { + throw new IllegalArgumentException("Missing argument for placeholder " + argument + " in: " + sql); + } + arguments.get(argument++).accept(walker); + } else { + sqlAppender.append(c); + } + } + if (argument != arguments.size()) { + throw new IllegalArgumentException("Expected " + (argument - 1) + " arguments, got " + (arguments.size() - 1) + " in: " + sql); + } + if (predicate) { + sqlAppender.append(')'); + } + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgSqlFunctionContributor.java b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgSqlFunctionContributor.java new file mode 100644 index 0000000..98fabb0 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/criterion/PgSqlFunctionContributor.java @@ -0,0 +1,18 @@ +package gpc.pgext.hibernate.criterion; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; + +/** + * Registers the {@link PgSqlFunction}s used by the criteria and orders. + */ +public class PgSqlFunctionContributor implements FunctionContributor { + + @Override + public void contributeFunctions(FunctionContributions functionContributions) { + var typeConfiguration = functionContributions.getTypeConfiguration(); + var registry = functionContributions.getFunctionRegistry(); + registry.register(PgSqlFunction.PREDICATE, PgSqlFunction.predicate(typeConfiguration)); + registry.register(PgSqlFunction.EXPRESSION, PgSqlFunction.expression(typeConfiguration)); + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/order/OrderByRandom.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/order/OrderByRandom.groovy new file mode 100644 index 0000000..d493f51 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/order/OrderByRandom.groovy @@ -0,0 +1,17 @@ +package gpc.pgext.hibernate.order + +import groovy.transform.CompileStatic + +@CompileStatic +class OrderByRandom extends OrderBySqlFormula { + + private static final String ORDER_RANDOM = 'random()' + + protected OrderByRandom() { + super(ORDER_RANDOM) + } + + static OrderBySqlFormula byRandom() { + new OrderByRandom() + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/order/OrderBySqlFormula.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/order/OrderBySqlFormula.groovy new file mode 100644 index 0000000..d9a81b6 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/order/OrderBySqlFormula.groovy @@ -0,0 +1,72 @@ +package gpc.pgext.hibernate.order + +import groovy.transform.CompileStatic + +import jakarta.persistence.criteria.AbstractQuery +import jakarta.persistence.criteria.CriteriaQuery +import jakarta.persistence.criteria.From +import jakarta.persistence.criteria.Order +import jakarta.persistence.criteria.Path +import org.hibernate.query.criteria.HibernateCriteriaBuilder as JpaCriteriaBuilder + +import gpc.pgext.hibernate.criterion.PgCriterion +import gpc.pgext.hibernate.criterion.PgSqlFunction + +/** + * Allows ordering by an SQL formula passed by the user, with {@code order(sqlFormula('...'))} in a criteria. + * It simply appends the sqlFormula passed by the user to the resulting SQL query, + * without any verification. + * + * GORM for Hibernate 7 only orders by properties, so the order is added to the query as a {@link PgCriterion} + * that inserts the formula among the orders of the query when the criteria is turned into SQL. This relies on GORM + * adding the property orders to the query before its predicates, which the order tests of the test apps check. + * See https://github.com/apache/grails-core/issues/16563 + */ +@CompileStatic +class OrderBySqlFormula { + + final String sqlFormula + + /** + * Constructor for Order. + * @param sqlFormula an SQL formula that will be appended to the resulting SQL query + */ + protected OrderBySqlFormula(String sqlFormula) { + this.sqlFormula = sqlFormula + } + + @Override + String toString() { + sqlFormula + } + + /** + * @param position The position of this order among the orders of the criteria + * @return The criterion that adds this order to the query + */ + PgCriterion toCriterion(int position) { + String formula = sqlFormula + PgCriterion.order({ AbstractQuery query, From root, JpaCriteriaBuilder cb, Path property -> + // Only queries for the entity are ordered, not count queries or other projections + if (query instanceof CriteriaQuery && ((CriteriaQuery) query).resultType == root.javaType) { + def criteriaQuery = (CriteriaQuery) query + List orders = new ArrayList<>(criteriaQuery.orderList) + orders.add(Math.min(position, orders.size()), cb.asc(cb.function(PgSqlFunction.EXPRESSION, Object, cb.literal(formula)))) + criteriaQuery.orderBy(orders) + } + // No predicate, so the order does not change the result of an or {} or not {} it is called in. + // An or {} with only an order is empty though, which matches no rows + null + } as PgCriterion.PredicateFactory) + } + + /** + * Custom order + * + * @param sqlFormula an SQL formula that will be appended to the resulting SQL query + * @return Order + */ + static OrderBySqlFormula sqlFormula(String sqlFormula) { + new OrderBySqlFormula(sqlFormula) + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/ArrayCriterias.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/ArrayCriterias.groovy new file mode 100644 index 0000000..c9e1565 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/ArrayCriterias.groovy @@ -0,0 +1,168 @@ +package gpc.pgext.hibernate.postgresql.criteria + +import groovy.transform.CompileStatic + +import jakarta.persistence.criteria.AbstractQuery +import jakarta.persistence.criteria.From +import jakarta.persistence.criteria.Path +import jakarta.persistence.criteria.Predicate +import org.hibernate.HibernateException +import org.hibernate.query.criteria.HibernateCriteriaBuilder as JpaCriteriaBuilder + +import grails.orm.HibernateCriteriaBuilder +import org.grails.datastore.mapping.query.api.Criteria + +import gpc.pgext.hibernate.criterion.PgCriterion +import gpc.pgext.hibernate.utils.PgArrayUtils + +import static gpc.pgext.hibernate.criterion.PgCriterion.sql +import static gpc.pgext.hibernate.criterion.PgCriterion.text +import static gpc.pgext.hibernate.utils.CriteriaUtils.addToCriteria +import static gpc.pgext.hibernate.utils.CriteriaUtils.calculatePropertyName + +@CompileStatic +class ArrayCriterias { + + private static final PgArrayUtils.MapFunction MAP_TO_ENUM = new PgArrayUtils.MapFunction() { + @Override + Object map(Object o) { + if (o instanceof Enum) { + return ((Enum) o).ordinal() + } + throw new HibernateException("Unable to cast object $o to Enum") + } + } + + /** + * Creates a "contains in native array" Criterion based on the specified property name and value + * @param propertyName The property name + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgArrayContains(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, arrayExpression(calculatePropertyName(self, propertyName), propertyValue, '@>')) + } + + /** + * Creates a "is contained by in native array" Criterion based on the specified property name and value + * @param propertyName The property name + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgArrayIsContainedBy(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, arrayExpression(calculatePropertyName(self, propertyName), propertyValue, '<@')) + } + + /** + * Creates a "overlap in native array" Criterion based on the specified property name and value + * @param propertyName The property name + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgArrayOverlaps(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, arrayExpression(calculatePropertyName(self, propertyName), propertyValue, '&&')) + } + + /** + * Creates an "is empty array" Criterion based on the specified property name + * @param propertyName The property name + * @return The criteria + */ + static Criteria pgArrayIsEmpty(HibernateCriteriaBuilder self, String propertyName) { + addToCriteria(self, propertyName, emptinessExpression(calculatePropertyName(self, propertyName), '=')) + } + + /** + * Creates an "is not empty array" Criterion based on the specified property name + * @param propertyName The property name + * @return The criteria + */ + static Criteria pgArrayIsNotEmpty(HibernateCriteriaBuilder self, String propertyName) { + addToCriteria(self, propertyName, emptinessExpression(calculatePropertyName(self, propertyName), '<>')) + } + + /** + * Creates a "contains in native array" or "is empty native array" Criterion based on the specified property name and value + * If the propertyValue is empty, the 'contains' operator is used and if the propertyValue is not empty, the 'isEmpty' + * operator is used. + * + * @param propertyName The property name + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgArrayIsEmptyOrContains(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + def name = calculatePropertyName(self, propertyName) + propertyValue ? + addToCriteria(self, propertyName, arrayExpression(name, propertyValue, '@>')) : + addToCriteria(self, propertyName, emptinessExpression(name, '=')) + } + + /** + * Creates a "equals in native array" Criterion based on the specified property name and value + * @param propertyName The property name + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgArrayEquals(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, arrayExpression(calculatePropertyName(self, propertyName), propertyValue, '=')) + } + + /** + * Creates a "not equals in native array" Criterion based on the specified property name and value + * @param propertyName The property name + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgArrayNotEquals(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, arrayExpression(calculatePropertyName(self, propertyName), propertyValue, '<>')) + } + + /** + * Creates a "ilike in native array" Criterion based on the specified property name and value + * @param propertyName The property name + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgArrayILike(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + String value = (String) propertyValue + addToCriteria(self, propertyName, new PgCriterion(calculatePropertyName(self, propertyName), { AbstractQuery query, From root, JpaCriteriaBuilder cb, Path property -> + sql(cb, 'text(?) ilike ?', property, text(cb, value)) + } as PgCriterion.PredicateFactory)) + } + + private static PgCriterion arrayExpression(String propertyName, Object value, String op) { + new PgCriterion(propertyName, { AbstractQuery query, From root, JpaCriteriaBuilder cb, Path property -> + Class typeClass = arrayTypeClass(property) + Object[] arrayValue = toArrayOfType(value, typeClass) + sql(cb, "? $op CAST(? as ${PgArrayUtils.getNativeSqlType(typeClass)}[])", property, cb.value(arrayValue)) + } as PgCriterion.PredicateFactory) + } + + private static PgCriterion emptinessExpression(String propertyName, String op) { + new PgCriterion(propertyName, { AbstractQuery query, From root, JpaCriteriaBuilder cb, Path property -> + arrayTypeClass(property) + sql(cb, "? $op '{}'", property) + } as PgCriterion.PredicateFactory) + } + + private static Object[] toArrayOfType(Object value, Class typeClass) { + try { + if (typeClass.isEnum()) { + // Enums are stored by their id, so arrays of enums are converted too + def values = value instanceof Object[] ? ((Object[]) value).toList() : value + return PgArrayUtils.getValueAsArrayOfType(values, Integer, MAP_TO_ENUM) + } + PgArrayUtils.getValueAsArrayOfType(value, typeClass) + } catch (IllegalArgumentException e) { + throw new HibernateException(e.message, e) + } + } + + private static Class arrayTypeClass(Path property) { + Class javaType = property.javaType + if (!javaType?.isArray()) { + throw new HibernateException("Property is not an instance of the postgres type ArrayType. Type is: $javaType") + } + javaType.componentType + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/HstoreCriterias.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/HstoreCriterias.groovy new file mode 100644 index 0000000..b6ba3e2 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/HstoreCriterias.groovy @@ -0,0 +1,52 @@ +package gpc.pgext.hibernate.postgresql.criteria + +import groovy.transform.CompileStatic + +import jakarta.persistence.criteria.AbstractQuery +import jakarta.persistence.criteria.From +import jakarta.persistence.criteria.Path +import org.hibernate.query.criteria.HibernateCriteriaBuilder as JpaCriteriaBuilder + +import grails.orm.HibernateCriteriaBuilder +import org.grails.datastore.mapping.query.api.Criteria + +import gpc.pgext.hibernate.criterion.PgCriterion +import gpc.pgext.hibernate.usertype.HstoreHelper + +import static gpc.pgext.hibernate.criterion.PgCriterion.sql +import static gpc.pgext.hibernate.criterion.PgCriterion.text +import static gpc.pgext.hibernate.utils.CriteriaUtils.addToCriteria +import static gpc.pgext.hibernate.utils.CriteriaUtils.calculatePropertyName + +@CompileStatic +class HstoreCriterias { + + static Criteria pgHstoreContainsKey(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, valueFunction(calculatePropertyName(self, propertyName), 'exist(?, ?)', propertyValue)) + } + + static Criteria pgHstoreContains(HibernateCriteriaBuilder self, String propertyName, Map values) { + addToCriteria(self, propertyName, operatorExpression(calculatePropertyName(self, propertyName), values, '@>')) + } + + static Criteria pgHstoreIsContained(HibernateCriteriaBuilder self, String propertyName, Map values) { + addToCriteria(self, propertyName, operatorExpression(calculatePropertyName(self, propertyName), values, '<@')) + } + + static Criteria pgHstoreILikeValue(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, valueFunction(calculatePropertyName(self, propertyName), 'text(avals(?)) ilike ?', propertyValue)) + } + + private static PgCriterion valueFunction(String propertyName, String sqlFunction, Object value) { + new PgCriterion(propertyName, { AbstractQuery query, From root, JpaCriteriaBuilder cb, Path property -> + sql(cb, sqlFunction, property, text(cb, value)) + } as PgCriterion.PredicateFactory) + } + + private static PgCriterion operatorExpression(String propertyName, Map values, String operator) { + String hstore = HstoreHelper.toString(values as Map) + new PgCriterion(propertyName, { AbstractQuery query, From root, JpaCriteriaBuilder cb, Path property -> + sql(cb, "? $operator CAST(? AS hstore)", property, cb.value(hstore)) + } as PgCriterion.PredicateFactory) + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/JsonCriterias.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/JsonCriterias.groovy new file mode 100644 index 0000000..5212a23 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/JsonCriterias.groovy @@ -0,0 +1,81 @@ +package gpc.pgext.hibernate.postgresql.criteria + +import groovy.transform.CompileStatic + +import jakarta.persistence.criteria.AbstractQuery +import jakarta.persistence.criteria.From +import jakarta.persistence.criteria.Path +import org.hibernate.query.criteria.HibernateCriteriaBuilder as JpaCriteriaBuilder + +import grails.orm.HibernateCriteriaBuilder +import org.grails.datastore.mapping.query.api.Criteria + +import gpc.pgext.hibernate.criterion.PgCriterion +import gpc.pgext.hibernate.utils.JsonUtils + +import static gpc.pgext.hibernate.criterion.PgCriterion.sql +import static gpc.pgext.hibernate.criterion.PgCriterion.text +import static gpc.pgext.hibernate.utils.CriteriaUtils.addToCriteria +import static gpc.pgext.hibernate.utils.CriteriaUtils.calculatePropertyName + +@CompileStatic +class JsonCriterias { + + /** + * Creates a "json has field value" Criterion based on the specified property name and value + * @param propertyName The property name (json field) + * @param jsonAttribute The json attribute + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgJsonHasFieldValue(HibernateCriteriaBuilder self, String propertyName, String jsonAttribute, Object propertyValue) { + addToCriteria(self, propertyName, jsonExpression(calculatePropertyName(self, propertyName), '->>', jsonAttribute, '=', propertyValue)) + } + + /** + * Creates a "json contains another json" Criterion based on the specified property name and value + * @param propertyName The property name (jsonb field) + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgJsonbContains(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, jsonbOperator(calculatePropertyName(self, propertyName), propertyValue, '@>')) + } + + /** + * Creates a "json is contained in another json" Criterion based on the specified property name and value + * @param propertyName The property name (jsonb field) + * @param propertyValue The property value + * @return The criteria + */ + static Criteria pgJsonbIsContained(HibernateCriteriaBuilder self, String propertyName, Object propertyValue) { + addToCriteria(self, propertyName, jsonbOperator(calculatePropertyName(self, propertyName), propertyValue, '<@')) + } + + /** + * Creates a "json on field value" Criterion based on the specified property name and value + * @param propertyName The property name (json field) + * @param jsonAttribute The json attribute + * @param jsonOp The json operator (->>, #>, ...) + * @param propertyValue The property value + * @param sqlOp The sql operator (=, <>, ilike, ...) + * @return The criteria + */ + static Criteria pgJson(HibernateCriteriaBuilder self, String propertyName, String jsonOp, String jsonAttribute, String sqlOp, Object propertyValue) { + addToCriteria(self, propertyName, jsonExpression(calculatePropertyName(self, propertyName), jsonOp, jsonAttribute, sqlOp, propertyValue)) + } + + private static PgCriterion jsonExpression(String propertyName, String jsonOp, String jsonAttribute, String sqlOp, Object value) { + String attribute = jsonAttribute.replace("'", "''") + new PgCriterion(propertyName, { AbstractQuery query, From root, JpaCriteriaBuilder cb, Path property -> + sql(cb, "?$jsonOp'$attribute' $sqlOp ?", property, text(cb, value)) + } as PgCriterion.PredicateFactory) + } + + private static PgCriterion jsonbOperator(String propertyName, Object value, String op) { + String json = JsonUtils.toJson(value) + new PgCriterion(propertyName, { AbstractQuery query, From root, JpaCriteriaBuilder cb, Path property -> + sql(cb, "? $op CAST(? AS jsonb)", property, cb.value(json)) + } as PgCriterion.PredicateFactory) + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/OrderCriterias.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/OrderCriterias.groovy new file mode 100644 index 0000000..400c14f --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/postgresql/criteria/OrderCriterias.groovy @@ -0,0 +1,34 @@ +package gpc.pgext.hibernate.postgresql.criteria + +import groovy.transform.CompileStatic + +import grails.orm.HibernateCriteriaBuilder +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.mapping.query.api.Criteria + +import gpc.pgext.hibernate.criterion.PgCriterion +import gpc.pgext.hibernate.order.OrderBySqlFormula + +import static gpc.pgext.hibernate.utils.CriteriaUtils.addToCriteria + +@CompileStatic +class OrderCriterias { + + /** + * Orders by an SQL formula, created with {@code sqlFormula('...')} or {@code byRandom()} + * @param order The order + * @return The criteria + */ + static Criteria order(HibernateCriteriaBuilder self, OrderBySqlFormula order) { + def detachedCriteria = self.hibernateQuery.detachedCriteria + int position = detachedCriteria.orders.size() + countOrders(detachedCriteria.criteria) + addToCriteria(self, order.toCriterion(position)) + } + + private static int countOrders(List criteria) { + (int) criteria.sum(0) { Query.Criterion criterion -> + criterion instanceof PgCriterion && ((PgCriterion) criterion).order ? 1 : + criterion instanceof Query.Junction ? countOrders(((Query.Junction) criterion).criteria) : 0 + } + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/ArrayType.java b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/ArrayType.java new file mode 100644 index 0000000..23d45e2 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/ArrayType.java @@ -0,0 +1,194 @@ +package gpc.pgext.hibernate.usertype; + +import java.io.Serializable; +import java.sql.Array; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; + +import org.hibernate.HibernateException; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.usertype.ParameterizedType; +import org.hibernate.usertype.UserType; + +import gpc.pgext.hibernate.utils.PgArrayUtils; + +public class ArrayType implements UserType, ParameterizedType { + + public static final int INTEGER_ARRAY = 90001; + public static final int LONG_ARRAY = 90002; + public static final int STRING_ARRAY = 90003; + public static final int ENUM_INTEGER_ARRAY = 90004; + public static final int FLOAT_ARRAY = 90005; + public static final int DOUBLE_ARRAY = 90006; + public static final int UUID_ARRAY = 90007; + + private static final Map, Integer> CLASS_TO_SQL_CODE = new HashMap<>(); + + static { + CLASS_TO_SQL_CODE.put(Integer.class, INTEGER_ARRAY); + CLASS_TO_SQL_CODE.put(Long.class, LONG_ARRAY); + CLASS_TO_SQL_CODE.put(String.class, STRING_ARRAY); + CLASS_TO_SQL_CODE.put(Float.class, FLOAT_ARRAY); + CLASS_TO_SQL_CODE.put(Double.class, DOUBLE_ARRAY); + CLASS_TO_SQL_CODE.put(UUID.class, UUID_ARRAY); + } + + private Class typeClass; + private BidiEnumMap bidiMap; + + @Override + public Object assemble(Serializable cached, Object owner) throws HibernateException { + return cached; + } + + @Override + public Serializable disassemble(Object value) throws HibernateException { + return (Serializable) value; + } + + @Override + public boolean equals(Object x, Object y) throws HibernateException { + if (x == y) { + return true; + } + if (x == null || y == null) { + return false; + } + if (x instanceof Object[] && y instanceof Object[]) { + return Arrays.deepEquals((Object[]) x, (Object[]) y); + } + return x.equals(y); + } + + @Override + public int hashCode(Object value) throws HibernateException { + return value == null ? 0 : value.hashCode(); + } + + @Override + public boolean isMutable() { + return true; + } + + @Override + public Object deepCopy(Object value) throws HibernateException { + return value; + } + + @Override + public Object replace(Object original, Object target, Object owner) throws HibernateException { + return original; + } + + @Override + public void setParameterValues(Properties parameters) { + Object type = parameters.get("type"); + if (type instanceof Class clazz) { + typeClass = clazz; + } else if (type != null) { + try { + typeClass = Class.forName(type.toString(), true, Thread.currentThread().getContextClassLoader()); + } catch (ClassNotFoundException e) { + throw new HibernateException("Unable to load the configured array type " + type, e); + } + } + if (typeClass == null) { + throw new RuntimeException("The user type needs to be configured with the type. None provided"); + } + } + + @Override + @SuppressWarnings("unchecked") + public Class returnedClass() { + return (Class) java.lang.reflect.Array.newInstance(typeClass, 0).getClass(); + } + + @Override + public int getSqlType() { + Integer type = CLASS_TO_SQL_CODE.get(typeClass); + if (type != null) { + return type; + } + if (typeClass.isEnum()) { + return ENUM_INTEGER_ARRAY; + } + throw new RuntimeException("The type " + typeClass + " is not a valid type"); + } + + @Override + public Object nullSafeGet(ResultSet rs, int position, WrapperOptions options) throws SQLException { + Array sqlArray = rs.getArray(position); + if (rs.wasNull() || sqlArray == null) { + return null; + } + Object array = sqlArray.getArray(); + if (typeClass.isEnum()) { + int length = array != null ? java.lang.reflect.Array.getLength(array) : 0; + Object result = java.lang.reflect.Array.newInstance(typeClass, length); + for (int i = 0; i < length; i++) { + java.lang.reflect.Array.set(result, i, idToEnum((Integer) java.lang.reflect.Array.get(array, i))); + } + return result; + } + return returnedClass().cast(array); + } + + @Override + public void nullSafeSet(PreparedStatement st, Object value, int index, WrapperOptions options) throws SQLException { + if (value == null) { + st.setNull(index, Types.ARRAY); + return; + } + + Object[] valueToSet = (Object[]) value; + Class typeArrayClass = returnedClass(); + + if (typeClass.isEnum()) { + typeArrayClass = Integer[].class; + Integer[] converted = new Integer[valueToSet.length]; + for (int i = 0; i < valueToSet.length; i++) { + if (valueToSet[i] instanceof Integer) { + converted[i] = (Integer) valueToSet[i]; + } else { + converted[i] = enumToId(valueToSet[i]); + } + } + valueToSet = converted; + } + + Array array = st.getConnection().createArrayOf(PgArrayUtils.getNativeSqlType(typeClass), (Object[]) typeArrayClass.cast(valueToSet)); + st.setArray(index, array); + } + + public Class getTypeClass() { + return typeClass; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void ensureBidiMapInitialized() throws HibernateException { + try { + if (bidiMap == null) { + bidiMap = new BidiEnumMap((Class) typeClass); + } + } catch (Exception e) { + throw new HibernateException("Unable to create bidirectional enum map for " + typeClass, e); + } + } + + private Object idToEnum(int id) throws HibernateException { + ensureBidiMapInitialized(); + return bidiMap.getEnumValue(id); + } + + private int enumToId(Object enumValue) throws HibernateException { + ensureBidiMapInitialized(); + return bidiMap.getKey(enumValue); + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreMapType.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreMapType.groovy new file mode 100644 index 0000000..ce5d99c --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/HstoreMapType.groovy @@ -0,0 +1,80 @@ +package gpc.pgext.hibernate.usertype + +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.SQLException +import java.sql.Types + +import groovy.transform.CompileStatic + +import org.hibernate.HibernateException +import org.hibernate.type.descriptor.WrapperOptions +import org.hibernate.usertype.UserType + +@CompileStatic +class HstoreMapType implements UserType { + + static int SQLTYPE = 90011 + + @Override + int getSqlType() { + SQLTYPE + } + + @Override + Class returnedClass() { + Map + } + + @Override + boolean equals(Map x, Map y) throws HibernateException { + if (x == null) { + return y == null + } + x == y + } + + @Override + int hashCode(Map x) throws HibernateException { + x ? x.hashCode() : 0 + } + + @Override + Map nullSafeGet(ResultSet rs, int position, WrapperOptions options) throws SQLException { + HstoreHelper.toMap(rs.getString(position)) + } + + @Override + void nullSafeSet(PreparedStatement ps, Map value, int index, WrapperOptions options) throws SQLException { + ps.setObject( + index, + HstoreHelper.toString(value), + Types.OTHER + ) + } + + @Override + Map deepCopy(Map value) throws HibernateException { + value == null ? null : new HashMap(value) + } + + @Override + boolean isMutable() { + true + } + + @Override + Serializable disassemble(Map value) throws HibernateException { + value as Serializable + } + + @Override + Map assemble(Serializable cached, Object owner) throws HibernateException { + cached as Map + } + + @Override + Map replace(Map original, Map target, Object owner) throws HibernateException { + original + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/JsonMapType.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/JsonMapType.groovy new file mode 100644 index 0000000..1ad0dc8 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/JsonMapType.groovy @@ -0,0 +1,86 @@ +package gpc.pgext.hibernate.usertype + +import java.lang.reflect.Type +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.SQLException +import java.sql.Types + +import groovy.transform.CompileStatic + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import org.hibernate.HibernateException +import org.hibernate.type.descriptor.WrapperOptions +import org.hibernate.usertype.UserType +import org.postgresql.util.PGobject + +@CompileStatic +class JsonMapType implements UserType { + + static int SQLTYPE = 90021 + + private final Type userType = Map + private final Gson gson = new GsonBuilder().serializeNulls().create() + + @Override + int getSqlType() { + SQLTYPE + } + + @Override + Class returnedClass() { + Map + } + + @Override + boolean equals(Map x, Map y) throws HibernateException { + Objects.equals(x, y) + } + + @Override + int hashCode(Map x) throws HibernateException { + x ? x.hashCode() : 0 + } + + @Override + Map nullSafeGet(ResultSet rs, int position, WrapperOptions options) throws SQLException { + PGobject o = rs.getObject(position) as PGobject + String jsonString = o?.value + gson.fromJson(jsonString, userType) as Map + } + + @Override + void nullSafeSet(PreparedStatement st, Map value, int index, WrapperOptions options) throws SQLException { + if (value == null) { + st.setNull(index, Types.OTHER) + } else { + st.setObject(index, gson.toJson(value, userType), Types.OTHER) + } + } + + @Override + Map deepCopy(Map value) throws HibernateException { + value == null ? null : new HashMap(value) + } + + @Override + boolean isMutable() { + true + } + + @Override + Serializable disassemble(Map value) throws HibernateException { + gson.toJson(value, userType) + } + + @Override + Map assemble(Serializable cached, Object owner) throws HibernateException { + gson.fromJson((String) cached, userType) as Map + } + + @Override + Map replace(Map original, Map target, Object owner) throws HibernateException { + original + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/JsonbMapType.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/JsonbMapType.groovy new file mode 100644 index 0000000..a2fcd8a --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/usertype/JsonbMapType.groovy @@ -0,0 +1,14 @@ +package gpc.pgext.hibernate.usertype + +import groovy.transform.CompileStatic + +@CompileStatic +class JsonbMapType extends JsonMapType { + + static int SQLTYPE = 90022 + + @Override + int getSqlType() { + SQLTYPE + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/utils/CriteriaUtils.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/utils/CriteriaUtils.groovy new file mode 100644 index 0000000..c78dba0 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/utils/CriteriaUtils.groovy @@ -0,0 +1,89 @@ +package gpc.pgext.hibernate.utils + +import groovy.transform.CompileStatic + +import jakarta.persistence.criteria.JoinType + +import grails.orm.HibernateCriteriaBuilder +import org.grails.datastore.gorm.query.criteria.DetachedAssociationCriteria +import org.grails.datastore.mapping.query.Query +import org.grails.datastore.mapping.query.api.Criteria + +import gpc.pgext.hibernate.criterion.PgCriterion + +@CompileStatic +class CriteriaUtils { + + /** + * Adds the criterion to the query of the builder + */ + static Criteria addToCriteria(HibernateCriteriaBuilder self, PgCriterion criterion) { + self.hibernateQuery.add(criterion) + self + } + + /** + * Adds the criterion on the property to the query of the builder. + * + * GORM resolves an alias created with {@code createAlias} to a join of its own, which a criterion handler + * cannot look up. When the property is on an alias with an inner join, an always true restriction on the + * property ({@code l.favoriteNumbers is null or l.favoriteNumbers is not null}) is added before the criterion, + * so GORM creates that join before the criterion uses it, and the criteria of GORM and of this plugin on the + * alias apply to the same join. + * + * GORM 8.0.0 always creates the join of an alias as an inner join, so for an alias with an outer join the + * criterion uses the join of the association with the join type of the alias instead. + * + * See https://github.com/apache/grails-core/issues/16562 and https://github.com/apache/grails-core/issues/16563 + */ + static Criteria addToCriteria(HibernateCriteriaBuilder self, String propertyName, PgCriterion criterion) { + def association = findAssociation(self, propertyName) + if (!association) { + return addToCriteria(self, criterion) + } + criterion.joinType = self.hibernateQuery.detachedCriteria.joinTypes[association.associationPath] ?: JoinType.INNER + if (criterion.joinType != JoinType.INNER) { + return addToCriteria(self, criterion) + } + def resolveAlias = new Query.Disjunction([new Query.IsNull(propertyName), new Query.IsNotNull(propertyName)] as List) + self.hibernateQuery.add(new Query.Conjunction([resolveAlias, criterion] as List)) + self + } + + /** + * Replaces an alias created with {@code createAlias} at the start of the property name by its association path, + * so {@code l.favoriteNumbers} becomes {@code like.favoriteNumbers} + */ + static String calculatePropertyName(HibernateCriteriaBuilder self, String propertyName) { + int dot = propertyName.indexOf('.') + if (dot < 0) { + return propertyName + } + String alias = propertyName.substring(0, dot) + String associationPath = self.hibernateQuery.aliases.find { it.alias() == alias }?.path() ?: + findAssociation(self, propertyName)?.associationPath + associationPath ? associationPath + propertyName.substring(dot) : propertyName + } + + private static DetachedAssociationCriteria findAssociation(HibernateCriteriaBuilder self, String propertyName) { + int dot = propertyName.indexOf('.') + dot < 0 ? null : findAssociation(self.hibernateQuery.detachedCriteria.criteria, propertyName.substring(0, dot)) + } + + private static DetachedAssociationCriteria findAssociation(List criteria, String alias) { + for (Query.Criterion criterion : criteria) { + if (criterion instanceof DetachedAssociationCriteria) { + def associationCriteria = (DetachedAssociationCriteria) criterion + if (associationCriteria.alias == alias) { + return associationCriteria + } + } else if (criterion instanceof Query.Junction) { + def associationCriteria = findAssociation(((Query.Junction) criterion).criteria, alias) + if (associationCriteria) { + return associationCriteria + } + } + } + null + } +} diff --git a/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/utils/JsonUtils.groovy b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/utils/JsonUtils.groovy new file mode 100644 index 0000000..ad18856 --- /dev/null +++ b/plugin-hibernate7/src/main/groovy/gpc/pgext/hibernate/utils/JsonUtils.groovy @@ -0,0 +1,20 @@ +package gpc.pgext.hibernate.utils + +import groovy.transform.CompileStatic + +import com.google.gson.Gson +import com.google.gson.GsonBuilder + +/** + * Converts values to json. Keeps Gson out of the signatures of the criteria extension classes, which Groovy + * loads when it compiles the application, so Gson does not have to be on its compile classpath. + */ +@CompileStatic +class JsonUtils { + + private static final Gson GSON = new GsonBuilder().serializeNulls().create() + + static String toJson(Object value) { + GSON.toJson(value) + } +} diff --git a/plugin-hibernate7/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule b/plugin-hibernate7/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule new file mode 100644 index 0000000..867060d --- /dev/null +++ b/plugin-hibernate7/src/main/resources/META-INF/groovy/org.codehaus.groovy.runtime.ExtensionModule @@ -0,0 +1,7 @@ +moduleName=grails-postgresql-extensions-hibernate7 +moduleVersion=1.0 +extensionClasses=\ + gpc.pgext.hibernate.postgresql.criteria.ArrayCriterias,\ + gpc.pgext.hibernate.postgresql.criteria.HstoreCriterias,\ + gpc.pgext.hibernate.postgresql.criteria.JsonCriterias,\ + gpc.pgext.hibernate.postgresql.criteria.OrderCriterias diff --git a/plugin-hibernate7/src/main/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$CriterionHandlerProvider b/plugin-hibernate7/src/main/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$CriterionHandlerProvider new file mode 100644 index 0000000..20c6cb7 --- /dev/null +++ b/plugin-hibernate7/src/main/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$CriterionHandlerProvider @@ -0,0 +1 @@ +gpc.pgext.hibernate.criterion.PgCriterionHandlerProvider diff --git a/plugin-hibernate7/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor b/plugin-hibernate7/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor new file mode 100644 index 0000000..06a7524 --- /dev/null +++ b/plugin-hibernate7/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor @@ -0,0 +1 @@ +gpc.pgext.hibernate.criterion.PgSqlFunctionContributor diff --git a/plugin/build.gradle b/plugin/build.gradle deleted file mode 100644 index de5f5d3..0000000 --- a/plugin/build.gradle +++ /dev/null @@ -1,76 +0,0 @@ -plugins { - id 'java-library' - id 'org.apache.grails.gradle.grails-plugin' -} - -version = projectVersion -group = 'io.github.gpc' - -grailsPublish { - artifactId = 'grails-postgresql-extensions' - githubSlug = 'gpc/grails-postgresql-extensions' - license.name = 'Apache-2.0' - title = 'Grails PostgreSQL Extensions Plugin' - desc = 'A Grails plugin that adds Hibernate UserTypes for PostgreSQL native types, ' + - 'like Array, HStore, JSON, JSONB, ..., and GORM criteria extensions to query them.' - developers = [ - ilopmar: 'Iván López', - alotor: 'Alonso Torres', - mattfeury: 'Matt Feury', - alvarosanchez: 'Alvaro Sanchez-Mariscal', - aeischeid: 'Aaron Eischeid', - mkobel: 'Moritz Kobel', - manuelvio: 'Manuel Unno Vio', - bameda: 'David Barragán Merino', - nobeans: 'Yasuharu Nakano', - burtbeckwith: 'Burt Beckwith', - timic: 'Timur Salyakhutdinov', - zlegein: 'Zach Legein', - tmarthal: 'Tom Marthaler', - pabloalba: 'Pablo Alba', - cornercase: 'Eamon Doyle', - poundex: 'Adam Pounder', - gregopet: 'Gregor Petrin', - unknown: 'Tom Potts', - sabst: 'Sabst', - donbeave: 'Alexey Zhokov', - jglapa: 'Jakub Glapa', - jamesdh: 'James Hardwick', - butters16: 'John Keith', - erichelgeson: 'Eric Helgeson', - albertop19: 'albertop19', - gtors: 'Andrey T', - matrei: 'Mattias Reichel', - ] -} - -dependencies { - - implementation platform("org.apache.grails:grails-bom:$grailsVersion") - - compileOnly 'org.apache.grails:grails-core', { // Provided - // impl: Plugin - } - compileOnly 'org.apache.grails:grails-data-hibernate5', { - // Must be provided by the Grails application - } - compileOnly 'org.springframework:spring-core', { // Provided - // impl: Assert - } - - implementation 'com.google.code.gson:gson' - implementation "org.hibernate:hibernate-core-jakarta:$hibernate5Version" - implementation 'org.postgresql:postgresql' - - testImplementation 'org.spockframework:spock-core' - - testRuntimeOnly 'org.springframework:spring-core', { - // Assert - } -} - -compileJava.options.release = javaVersion.toInteger() - -apply { - from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') -} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 8e1c6b3..d901650 100644 --- a/settings.gradle +++ b/settings.gradle @@ -33,8 +33,17 @@ buildCache { rootProject.name = 'postgresql-extensions.ROOT' -include('plugin') -findProject(':plugin').name = 'grails-postgresql-extensions' +include('plugin-core') +findProject(':plugin-core').name = 'grails-postgresql-extensions-core' + +include('plugin-hibernate5') +findProject(':plugin-hibernate5').name = 'grails-postgresql-extensions' + +include('plugin-hibernate7') +findProject(':plugin-hibernate7').name = 'grails-postgresql-extensions-hibernate7' include('test-app1') project(':test-app1').projectDir = new File(settingsDir, 'test-apps/app1') + +include('test-app2') +project(':test-app2').projectDir = new File(settingsDir, 'test-apps/app2') diff --git a/test-apps/app1/build.gradle b/test-apps/app1/build.gradle index 02cc950..1b1c57f 100644 --- a/test-apps/app1/build.gradle +++ b/test-apps/app1/build.gradle @@ -12,6 +12,7 @@ dependencies { implementation 'org.apache.grails:grails-dependencies-starter-web' integrationTestImplementation 'org.apache.grails:grails-dependencies-test' + integrationTestRuntimeOnly 'org.testcontainers:testcontainers-postgresql' } compileJava.options.release = javaVersion.toInteger() diff --git a/test-apps/app1/grails-app/conf/application.yml b/test-apps/app1/grails-app/conf/application.yml index 466169f..29a51b8 100644 --- a/test-apps/app1/grails-app/conf/application.yml +++ b/test-apps/app1/grails-app/conf/application.yml @@ -13,3 +13,9 @@ dataSource: dbCreate: create-drop hibernate: dialect: gpc.pgext.hibernate.PostgresqlExtensionsDialect +environments: + test: + dataSource: + # Each test run gets its own PostgreSQL container with the hstore extension + driverClassName: org.testcontainers.jdbc.ContainerDatabaseDriver + url: 'jdbc:tc:postgresql:17:///pg_extensions_test?TC_INITSCRIPT=init-pg-extensions.sql&TC_DAEMON=true' diff --git a/test-apps/app1/grails-app/domain/app/criteria/array/Team.groovy b/test-apps/app1/grails-app/domain/app/criteria/array/Team.groovy new file mode 100644 index 0000000..b5fbf5f --- /dev/null +++ b/test-apps/app1/grails-app/domain/app/criteria/array/Team.groovy @@ -0,0 +1,21 @@ +package app.criteria.array + +import groovy.transform.ToString + +import grails.compiler.GrailsCompileStatic + +/** + * Has a to-many association to {@link Like}, to test criteria on an alias of a collection + */ +@ToString +@GrailsCompileStatic +class Team { + + String name + + static hasMany = [likes: Like] + + static mapping = { + table('pg_extensions_team') + } +} diff --git a/test-apps/app1/src/integration-test/groovy/test/array/PgArrayCriteriaWithAliasIntegrationSpec.groovy b/test-apps/app1/src/integration-test/groovy/test/array/PgArrayCriteriaWithAliasIntegrationSpec.groovy new file mode 100644 index 0000000..605d8ce --- /dev/null +++ b/test-apps/app1/src/integration-test/groovy/test/array/PgArrayCriteriaWithAliasIntegrationSpec.groovy @@ -0,0 +1,83 @@ +package test.array + +import app.criteria.array.Like +import app.criteria.array.Team +import app.criteria.array.User +import spock.lang.Specification +import spock.lang.Unroll + +import grails.gorm.transactions.Rollback +import grails.testing.mixin.integration.Integration + +@Rollback +@Integration +class PgArrayCriteriaWithAliasIntegrationSpec extends Specification { + + private static final int LEFT_JOIN = 1 + + def setup() { + Team.executeUpdate('delete from Team') + User.executeUpdate('delete from User') + Like.executeUpdate('delete from Like') + } + + @Unroll + void 'a pg criterion and a GORM criterion on the same alias of a collection apply to the same element (pg criterion first: #pgFirst)'() { + setup: + def three = new Like(favoriteNumbers: [3]).save(flush: true, failOnError: true) + def five = new Like(favoriteNumbers: [5]).save(flush: true, failOnError: true) + new Team(name: 'team').addToLikes(three).addToLikes(five).save(flush: true, failOnError: true) + + when: 'no single like both contains 3 and is the like with 5' + def result = Team.withCriteria { + createAlias('likes', 'l') + if (pgFirst) { + pgArrayContains('l.favoriteNumbers', 3) + eq('l.id', five.id) + } else { + eq('l.id', five.id) + pgArrayContains('l.favoriteNumbers', 3) + } + } + + then: + result.empty + + when: 'the like with 3 both contains 3 and is the like with 3' + result = Team.withCriteria { + createAlias('likes', 'l') + if (pgFirst) { + pgArrayContains('l.favoriteNumbers', 3) + eq('l.id', three.id) + } else { + eq('l.id', three.id) + pgArrayContains('l.favoriteNumbers', 3) + } + } + + then: 'the team is found once' + result*.name == ['team'] + + where: + pgFirst << [true, false] + } + + void 'a pg criterion on an alias honours the join type of the alias'() { + setup: + new User(name: 'without like').save(flush: true, failOnError: true) + new User(name: 'with 3', like: new Like(favoriteNumbers: [3])).save(flush: true, failOnError: true) + new User(name: 'with 5', like: new Like(favoriteNumbers: [5])).save(flush: true, failOnError: true) + + when: + def result = User.withCriteria { + createAlias('like', 'l', LEFT_JOIN) + or { + isNull('like') + pgArrayContains('l.favoriteNumbers', 3) + } + } + + then: + result*.name.sort() == ['with 3', 'without like'] + } +} diff --git a/test-apps/app1/src/integration-test/groovy/test/hstore/PgHstoreContainsKeyIntegrationSpec.groovy b/test-apps/app1/src/integration-test/groovy/test/hstore/PgHstoreContainsKeyIntegrationSpec.groovy index 9e7043e..4c755ea 100644 --- a/test-apps/app1/src/integration-test/groovy/test/hstore/PgHstoreContainsKeyIntegrationSpec.groovy +++ b/test-apps/app1/src/integration-test/groovy/test/hstore/PgHstoreContainsKeyIntegrationSpec.groovy @@ -51,4 +51,17 @@ class PgHstoreContainsKeyIntegrationSpec extends Specification { then: result.size() == 0 } + + void 'Test a null key matches no key, not even the text null'() { + setup: + new TestHstoreMap(testAttributes: ['null': 'a']).save(flush: true, failOnError: true) + + when: + def result = TestHstoreMap.withCriteria { + pgHstoreContainsKey('testAttributes', null) + } + + then: + result.empty + } } diff --git a/test-apps/app1/src/integration-test/groovy/test/json/PgJsonValuesIntegrationSpec.groovy b/test-apps/app1/src/integration-test/groovy/test/json/PgJsonValuesIntegrationSpec.groovy index 0162b67..19ce78d 100644 --- a/test-apps/app1/src/integration-test/groovy/test/json/PgJsonValuesIntegrationSpec.groovy +++ b/test-apps/app1/src/integration-test/groovy/test/json/PgJsonValuesIntegrationSpec.groovy @@ -76,4 +76,18 @@ class PgJsonValuesIntegrationSpec extends Specification { 'Iván' || 1 'John' || 3 } + + void 'Test a null value matches no json value, not even the text null'() { + setup: + new TestMapJson(data: [name: 'null']).save(flush: true) + new TestMapJson(data: [name: 'Iván']).save(flush: true) + + when: + def result = TestMapJson.withCriteria { + pgJsonHasFieldValue('data', 'name', null) + } + + then: + result.empty + } } diff --git a/test-apps/app1/src/integration-test/groovy/test/order/PgOrderIntegrationSpec.groovy b/test-apps/app1/src/integration-test/groovy/test/order/PgOrderIntegrationSpec.groovy index d1d9798..bb6229f 100644 --- a/test-apps/app1/src/integration-test/groovy/test/order/PgOrderIntegrationSpec.groovy +++ b/test-apps/app1/src/integration-test/groovy/test/order/PgOrderIntegrationSpec.groovy @@ -6,6 +6,8 @@ import spock.lang.Specification import grails.gorm.transactions.Rollback import grails.testing.mixin.integration.Integration +import static gpc.pgext.hibernate.order.OrderBySqlFormula.sqlFormula + @Rollback @Integration class PgOrderIntegrationSpec extends Specification { @@ -43,4 +45,67 @@ class PgOrderIntegrationSpec extends Specification { result result.size() == 3 } -} \ No newline at end of file + + void 'An order by a sql formula keeps its position among the property orders'() { + setup: + def first = save('first', 'A') + def second = save('second', 'B') + def third = save('third', 'A') + + when: 'ordered by group and then by id descending' + def result = TestMapJsonb.withCriteria { + order(sqlFormula("(data->>'group')")) + order('id', 'desc') + } + + then: + result*.id == [third.id, first.id, second.id] + + when: 'ordered by id descending and then by group' + result = TestMapJsonb.withCriteria { + order('id', 'desc') + order(sqlFormula("(data->>'group')")) + } + + then: + result*.id == [third.id, second.id, first.id] + } + + void 'An order by a sql formula is applied to a paginated list'() { + setup: + save('Iván', 'A') + save('Alonso', 'A') + save('Ernesto', 'A') + + when: + def result = TestMapJsonb.createCriteria().list(max: 2, offset: 1) { + order(sqlFormula("(data->>'name')")) + } + + then: + result*.data*.name == ['Ernesto', 'Iván'] + result.totalCount == 3 + } + + void 'An order by a sql formula in an or does not change the result of the or'() { + setup: + save('Iván', 'A') + save('Alonso', 'B') + save('Ernesto', 'A') + + when: + def result = TestMapJsonb.withCriteria { + or { + pgJsonHasFieldValue('data', 'group', 'A') + order(sqlFormula("(data->>'name')")) + } + } + + then: + result*.data*.name == ['Ernesto', 'Iván'] + } + + private static TestMapJsonb save(String name, String group) { + new TestMapJsonb(data: [name: name, group: group]).save(flush: true, failOnError: true) + } +} diff --git a/test-apps/app1/src/integration-test/resources/init-pg-extensions.sql b/test-apps/app1/src/integration-test/resources/init-pg-extensions.sql new file mode 100644 index 0000000..5ed9f15 --- /dev/null +++ b/test-apps/app1/src/integration-test/resources/init-pg-extensions.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS hstore; diff --git a/test-apps/app2/build.gradle b/test-apps/app2/build.gradle new file mode 100644 index 0000000..c5a77c3 --- /dev/null +++ b/test-apps/app2/build.gradle @@ -0,0 +1,45 @@ +version = projectVersion +group = 'test' + +apply plugin: 'org.apache.grails.gradle.grails-web' + +dependencies { + + implementation platform("org.apache.grails:grails-bom:$grailsVersion") + + implementation project(':grails-postgresql-extensions-hibernate7') + implementation 'org.apache.grails:grails-data-hibernate7' + implementation 'org.apache.grails:grails-dependencies-starter-web' + + integrationTestImplementation 'org.apache.grails:grails-dependencies-test' + integrationTestRuntimeOnly 'org.testcontainers:testcontainers-postgresql' +} + +// test-app2 runs the domain classes, services and integration specs of test-app1 on Hibernate 7 +def app1 = rootProject.layout.projectDirectory.dir('test-apps/app1') +sourceSets { + main { + groovy { + srcDir app1.dir('grails-app/domain') + srcDir app1.dir('grails-app/services') + srcDir app1.dir('grails-app/init') + } + resources { + srcDir app1.dir('grails-app/conf/spring') + } + } + integrationTest { + groovy { + srcDir app1.dir('src/integration-test/groovy') + } + resources { + srcDir app1.dir('src/integration-test/resources') + } + } +} + +compileJava.options.release = javaVersion.toInteger() + +apply { + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') +} diff --git a/test-apps/app2/grails-app/conf/application.groovy b/test-apps/app2/grails-app/conf/application.groovy new file mode 100644 index 0000000..0c69018 --- /dev/null +++ b/test-apps/app2/grails-app/conf/application.groovy @@ -0,0 +1,6 @@ +// GORM 8.0.0 for Hibernate 7 never creates the sequence of the default 'native' generator on PostgreSQL +// (GrailsNativeGenerator swallows the initialization of its SequenceStyleGenerator delegate), so use 'sequence'. +// See https://github.com/apache/grails-core/issues/16561 +grails.gorm.default.mapping = { + id generator: 'sequence' +} diff --git a/test-apps/app2/grails-app/conf/application.yml b/test-apps/app2/grails-app/conf/application.yml new file mode 100644 index 0000000..29a51b8 --- /dev/null +++ b/test-apps/app2/grails-app/conf/application.yml @@ -0,0 +1,21 @@ +info: + app: + name: '@info.app.name@' + version: '@info.app.version@' + grailsVersion: '@info.app.grailsVersion@' +dataSource: + pooled: true + jmxExport: true + driverClassName: org.postgresql.Driver + username: postgres_extensions + password: postgres_extensions + url: 'jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/pg_extensions_test' + dbCreate: create-drop +hibernate: + dialect: gpc.pgext.hibernate.PostgresqlExtensionsDialect +environments: + test: + dataSource: + # Each test run gets its own PostgreSQL container with the hstore extension + driverClassName: org.testcontainers.jdbc.ContainerDatabaseDriver + url: 'jdbc:tc:postgresql:17:///pg_extensions_test?TC_INITSCRIPT=init-pg-extensions.sql&TC_DAEMON=true' diff --git a/test-apps/app2/grails-app/conf/logback-spring.xml b/test-apps/app2/grails-app/conf/logback-spring.xml new file mode 100644 index 0000000..e3b8d89 --- /dev/null +++ b/test-apps/app2/grails-app/conf/logback-spring.xml @@ -0,0 +1,25 @@ + + + + + + + false + + ${CONSOLE_LOG_THRESHOLD} + + + ${CONSOLE_LOG_PATTERN} + ${CONSOLE_LOG_CHARSET} + + + + + + + + + + + + \ No newline at end of file