Ниссан jooq

Это небольшая история о том как я настраивал jooq генератор с gradle и postgis. По большому счету в моем пэт проекте это было не особо нужно, но хотелось повторить стэк, с которым работаю, но с нуля.

Для начала все что касаеся бд я вынес в библиотеку. Тут лежат грэдл файл, миграции и сгенерированные жук классы. Грэдл файл выглядит как-то так.

plugins {
    val kotlinVersion = "1.9.20"
    id("java")
    id("org.flywaydb.flyway") version "9.10.2"
    id("nu.studer.jooq") version ("6.0.1")
    kotlin("jvm") version kotlinVersion
}

group = "ru.mapgramm"
version = "0.0.1-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    jooqGenerator("org.postgresql:postgresql")
    implementation("org.jooq:jooq")
    implementation("org.postgis:postgis-jdbc")
    compileOnly("org.postgresql:postgresql")
    jooqGenerator("org.postgresql:postgresql")
}

Сначала я решил просто прокатывать локально миграции и запускать генератор на локально запущенной базе данных.

Собственно, приступаем к настройке генератора. Быстрым поиском по интернету нахожу настройку, адаптирую ее по котлин грэдл и запускаю.

jooq {
    edition.set(nu.studer.gradle.jooq.JooqEdition.OSS)
    configurations {
        create("main") {
            jooqConfiguration.apply {
                jdbc.apply {
                    driver = "org.postgresql.Driver"
                    url = 
                    user = 
                    password = 
                }
                generator.apply {
                    name = "org.jooq.codegen.DefaultGenerator"
                    generate.apply {
                        isDeprecated = false
                        isRecords = true
                        isImmutablePojos = false
                        isFluentSetters = false
                        isJavaBeansGettersAndSetters = false
                    }
                    database.apply {
                        name = "org.jooq.meta.postgres.PostgresDatabase"
                        inputSchema = "public"
                    }
                    target.apply {
                        packageName = "ru.keykeeper.core.api.jooq.generated"
                        directory = "$projectDir/src/main/java/"
                    }
                    strategy.name = "org.jooq.codegen.DefaultGeneratorStrategy"
                }
            }
        }
    }
}

Как итог — получаем какое-то огромное количество классов. Они появились из-за плагина постгис и пока не очень понятно, как от них избавиться. Хотя они не очень то и мешают.

Попробуем написать какой-никакой запрос. Вот, например, запрос на получение все групп с джоином к постам и фильтром по зуму

fun findAllByZoom(zoom: Int, dsl: DSLContext = jooq): List {
    val postGroupRecords = dsl
        .select(*POST_GROUP_VIEW.fields())
        .select(*POST.fields())
        .from(POST_GROUP_VIEW)
        .join(POST_TO_GROUP).on(POST_TO_GROUP.GROUP_ID.eq(POST_GROUP_VIEW.ID))
        .join(POST).on(POST_TO_GROUP.POST_ID.eq(POST.ID))
        .where(POST_GROUP_VIEW.ZOOM.eq(zoom))
        .fetchGroups(
            { r -> r.into(PostGroupViewRecord::class.java) },
            { r -> r.into(PostRecord::class.java) },
        )

    return postGroupRecords.entries.map { entry ->
        entry.key.toDto(entry.value.map { it.toDto() })
    }
}

В целом оно работает и то что это все джава классы совершенно не мешает, но есть некоторые другие проблемы.

Здесь вставка строк запрещена.

7e200e8d8d5086c51ddfdc8efb0d195d.png

Я не могу сохранить дату. И jsonb. И geometry. И вообще, что-то не так с сохранением, если это не строка или число. Выглядит это так:

org.postgresql.util.PSQLException: ERROR: column "payload" is of type jsonb but expression is of type character varying

И вот на это я трачу какое-то неприличное количество времени. За это время я нашел как генерировать котлин классы и как нормально генерировать во время сборки, но об этом чуть позже. А решением проблемы был простой советский…

@Bean
fun configuration(connectionProvider: DataSourceConnectionProvider): DefaultConfiguration {
    val jooqConfiguration = DefaultConfiguration()
    jooqConfiguration.set(connectionProvider)
 -> jooqConfiguration.setSQLDialect(SQLDialect.POSTGRES);
    return jooqConfiguration
}

setSQLDialect(SQLDialect.POSTGRES), как оказалось, о настройках самого жука тоже не стоит забывать.

@Configuration
class JooqConfiguration {
    @Bean(value = ["jdbcDSLContext"])
    fun jdbcDSLContext(configuration: DefaultConfiguration): DSLContext {
        return DefaultDSLContext(configuration)
    }

    @Bean
    fun connectionProvider(dataSource: DataSource): DataSourceConnectionProvider {
        return DataSourceConnectionProvider(
            TransactionAwareDataSourceProxy(dataSource),
        )
    }

    @Bean
    fun configuration(connectionProvider: DataSourceConnectionProvider): DefaultConfiguration {
        val jooqConfiguration = DefaultConfiguration()
        jooqConfiguration.set(connectionProvider)
        jooqConfiguration.setSQLDialect(SQLDialect.POSTGRES);
        return jooqConfiguration
    }
}

Генерируй правильно

Пока я искал решение проблемы выше, я нашел как можно удачно генерировать классы жука с flyway и testcontainers. Я примерно так и представлял это себе, но тут уже кто-то придумал за меня, так что воспользуемся.

Сперва сделал для себя класс с контейнером postgis, так как в тестконтейнерах такой не нашел. Плюс, настроил миграцию flyway в этот контейнер. Плюс, проставил эти проперти в генераторе.

abstract class PostgresService : BuildService, AutoCloseable {
    private var image = org.testcontainers.utility.DockerImageName.parse("postgis/postgis:12-3.0")
        .asCompatibleSubstituteFor("postgres")
    private val container = org.testcontainers.containers.PostgreSQLContainer(image)
    init { container.start() }
    override fun close() = container.stop()
    fun getContainer() = container
}

val dbContainerProvider: Provider = project.gradle.sharedServices
    .registerIfAbsent("postgres", PostgresService::class) {}

flyway {
    val dbContainer = dbContainerProvider.get().getContainer()
    url = dbContainer.jdbcUrl
    user = dbContainer.username
    password = dbContainer.password
    locations = arrayOf("classpath:db/migration")
    sqlMigrationPrefix = "V"
}

jooq {
  val dbContainer = dbContainerProvider.get().getContainer()
  ...
              jdbc.apply {
                  driver = "org.postgresql.Driver"
                  url = dbContainer.jdbcUrl
                  user = dbContainer.username
                  password = dbContainer.password
              }
  ...
}

И тут же нужно проставить зависимость задачи на генерацию классов от миграции бд.

val generateJooq by project.tasks
generateJooq.dependsOn("flywayMigrate")

Очень важный заголовок

generator.apply {
  name = "org.jooq.codegen.KotlinGenerator"
}

Скрытый текст

Для генерация котлин классов надо поменять значение name генератора и перезапустить генерацию. В целом очевидно. Но я же сказал что покажу как я это сделал.

Раз параграф короткий, вот мем

d4fcc92d4ee078db3c4fa251e639eb2a.png

Проблема postgis

Остается последняя проблема, я не могу писать запросы с функциями postgis с помощью jooqDsl, а мне надо!

Для jooq-генератора можно настраивать биндинги, чтобы для типов в базе использовались заданные kotlin-типы. Например, вот настройки для jsonb и даты-времени

...
database.apply {
  ...
  withForcedTypes(
      org.jooq.meta.jaxb.ForcedType()
          .withName(org.jooq.impl.SQLDataType.INSTANT.typeName)
          .withIncludeTypes("(?i:TIMESTAMP\\ (WITH|WITHOUT)\\ TIME\\ ZONE)"),
      org.jooq.meta.jaxb.ForcedType()
          .withName(org.jooq.impl.SQLDataType.JSONB.typeName)
          .withIncludeTypes("jsonb"),
  )
}
...

Так вот, такие биндинги можно писать самостоятельно для своих типов. Но он получается довольно большой и для всех классов postgis-а делать это не хотелось бы. И как оказалось, есть проект, в котором это сделали за меня.

implementation("io.github.oshai:jooq-postgis-spatial:1.0")

Теперь добавляем биндинг в настройки генератора:

...
database.apply {
    ...
    withCustomTypes(
        org.jooq.meta.jaxb.CustomType()
            .withName("Geometry")
            .withBinding("net.dmitry.jooq.postgis.spatial.binding.JTSGeometryBinding")
            .withType("com.vividsolutions.jts.geom.Geometry")
    )
    withForcedTypes(
    ...
        org.jooq.meta.jaxb.ForcedType()
            .withName("Geometry")
            .withIncludeTypes("(geometry|GEOMETRY)"),
    )
}
...

И вот с ними странная вещь, функция withCustomTypes — устаревшая и нужно использовать только withForcedTypes. Но когда я настроил все по-новому, оно перестало работать. Меня устраивает и вариант выше, но если вы знаете, что с этим не так, буду рад совету)

withForcedTypes(
  ...
  org.jooq.meta.jaxb.ForcedType()
      .withName("Geometry")
      .withTypes("com.vividsolutions.jts.geom.Geometry")
      .withBinding("net.dmitry.jooq.postgis.spatial.binding.JTSGeometryBinding")
      .withIncludeTypes("(geometry|GEOMETRY)")
)

Ну и сразу пробую сделать запрос с новенькими функциями

dsl
.selectFrom(POST_GROUP)
.where(
    POST_GROUP.ZOOM.eq(zoom)
        .and(
            stContains(
                geom1 = JTS.getDefaultGeomFactory().createPolygon(
                    arrayOf(
                        Coordinate(bounds.upLeft.x, bounds.upLeft.y),
                        Coordinate(bounds.upRight.x, bounds.upRight.y),
                        Coordinate(bounds.downRight.x, bounds.downRight.y),
                        Coordinate(bounds.downLeft.x, bounds.downLeft.y),
                        Coordinate(bounds.upLeft.x, bounds.upLeft.y),
                    )
                ),
                geom2 = POST_GROUP.POINT,
            )
    )
)
.fetch(mapper)

Итог

В целом все получилось, есть генератор, есть dsl и это все успешно применяется. Хотя для пэт-проектов разница небольшая, жук в использовании для меня намного удобнее.
Вот кстати приложение, для которого я это все делал
Подписывайтесь на телегу, я там иногда пощщу что-то по теме.

На этом все.

© Habrahabr.ru