From c49d04d83fbf01cc62994d24b93a2bcbf89d20f1 Mon Sep 17 00:00:00 2001 From: Klaus Happacher Date: Sat, 23 Nov 2024 12:49:16 +0100 Subject: [PATCH] init --- .github/workflows/publish.yml | 29 ++ .gitignore | 49 +++ LICENSE | 21 ++ README.md | 117 ++++++++ build.gradle.kts | 46 +++ docker-compose.yaml | 17 ++ gradle.properties | 1 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 234 +++++++++++++++ gradlew.bat | 89 ++++++ settings.gradle.kts | 5 + src/main/kotlin/Main.kt | 76 +++++ src/main/kotlin/dsl/Poet.kt | 51 ++++ src/main/kotlin/dsl/Sql.kt | 14 + src/main/kotlin/model/Config.kt | 65 ++++ src/main/kotlin/model/sql/Common.kt | 53 ++++ src/main/kotlin/model/sql/Enum.kt | 6 + src/main/kotlin/model/sql/SqlObjectFilter.kt | 70 +++++ src/main/kotlin/model/sql/Table.kt | 62 ++++ src/main/kotlin/service/DbService.kt | 282 ++++++++++++++++++ .../kotlin/service/DirectorySyncService.kt | 84 ++++++ src/main/kotlin/service/EnvFileService.kt | 17 ++ src/main/kotlin/util/NamingUtil.kt | 67 +++++ src/main/kotlin/util/ResourceUtil.kt | 31 ++ .../kotlin/util/codegen/CodeGenContext.kt | 27 ++ src/main/kotlin/util/codegen/Column.kt | 107 +++++++ src/main/kotlin/util/codegen/Common.kt | 78 +++++ src/main/kotlin/util/codegen/Enum.kt | 28 ++ src/main/kotlin/util/codegen/Table.kt | 55 ++++ .../default_code/column_type/IntMultiRange.kt | 17 ++ .../default_code/column_type/IntRange.kt | 18 ++ .../default_code/column_type/MultiRange.kt | 7 + .../column_type/MultiRangeColumnType.kt | 31 ++ .../column_type/RangeColumnType.kt | 29 ++ .../UnconstrainedNumericColumnType.kt | 37 +++ .../default_code/column_type/UtilEnum.kt | 21 ++ .../column_type/UtilMultiRange.kt | 9 + .../default_code/column_type/UtilRange.kt | 85 ++++++ 39 files changed, 2041 insertions(+) create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 docker-compose.yaml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/Main.kt create mode 100644 src/main/kotlin/dsl/Poet.kt create mode 100644 src/main/kotlin/dsl/Sql.kt create mode 100644 src/main/kotlin/model/Config.kt create mode 100644 src/main/kotlin/model/sql/Common.kt create mode 100644 src/main/kotlin/model/sql/Enum.kt create mode 100644 src/main/kotlin/model/sql/SqlObjectFilter.kt create mode 100644 src/main/kotlin/model/sql/Table.kt create mode 100644 src/main/kotlin/service/DbService.kt create mode 100644 src/main/kotlin/service/DirectorySyncService.kt create mode 100644 src/main/kotlin/service/EnvFileService.kt create mode 100644 src/main/kotlin/util/NamingUtil.kt create mode 100644 src/main/kotlin/util/ResourceUtil.kt create mode 100644 src/main/kotlin/util/codegen/CodeGenContext.kt create mode 100644 src/main/kotlin/util/codegen/Column.kt create mode 100644 src/main/kotlin/util/codegen/Common.kt create mode 100644 src/main/kotlin/util/codegen/Enum.kt create mode 100644 src/main/kotlin/util/codegen/Table.kt create mode 100644 src/main/resources/default_code/column_type/IntMultiRange.kt create mode 100644 src/main/resources/default_code/column_type/IntRange.kt create mode 100644 src/main/resources/default_code/column_type/MultiRange.kt create mode 100644 src/main/resources/default_code/column_type/MultiRangeColumnType.kt create mode 100644 src/main/resources/default_code/column_type/RangeColumnType.kt create mode 100644 src/main/resources/default_code/column_type/UnconstrainedNumericColumnType.kt create mode 100644 src/main/resources/default_code/column_type/UtilEnum.kt create mode 100644 src/main/resources/default_code/column_type/UtilMultiRange.kt create mode 100644 src/main/resources/default_code/column_type/UtilRange.kt diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f520b6c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,29 @@ +name: Publish Gradle Plugin + +on: + push: + tags: + - '*.*.*' + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + - name: publish + env: + GIT_TAG_VERSION: ${{ github.ref_name }} + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} + run: ./gradlew publishPlugins diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0282392 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +.gradle +.idea +.kotlin +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +.env +test-schema.sql + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Kotlin ### +.kotlin + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2510c31 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 klahap + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2612eae --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# Kotlin Exposed Table Generator Gradle Plugin + +## Overview + +The **Kotlin Exposed Table Generator** is a Gradle plugin designed to generate +Kotlin [Exposed](https://github.com/JetBrains/Exposed) table definitions directly from a PostgreSQL database schema. +Simplify your workflow by automating the creation of table mappings and keeping them in sync with your database. + +## Features + +- Generate Kotlin Exposed DSL table definitions. +- Filter tables by schema for precise control. +- Keep your code synchronized with database schema changes effortlessly. + +## Installation + +Add the plugin to your `build.gradle.kts`: + +```kotlin +plugins { + id("io.github.klahap.pgen") version "$VERSION" +} +``` + +## Configuration + +To configure the plugin, add the `pgen` block to your `build.gradle.kts`: + +```kotlin +pgen { + dbConnectionConfig( + url = System.getenv("DB_URL"), // Database URL + user = System.getenv("DB_USER"), // Database username + password = System.getenv("DB_PASSWORD") // Database password + ) + packageName("io.example.db") // Target package for generated tables + tableFilter { + addSchemas("public") // Include only specific schemas (e.g., "public") + } + outputPath("./output") // Output directory for generated files +} +``` + +### Environment Variables + +Make sure to set the following environment variables: + +- `DB_URL`: The connection URL for your PostgreSQL database. +- `DB_USER`: Your database username. +- `DB_PASSWORD`: Your database password. + +## Running the Plugin + +Once configured, generate your Kotlin Exposed table definitions by running: + +```bash +./gradlew pgen +``` + +This will create Kotlin files in the specified `outputPath`. + +## Example + +### Input: Database Schema + +Assume a PostgreSQL schema with the following table: + +```sql +CREATE TYPE status AS ENUM ('ACTIVE', 'INACTIVE'); + +CREATE TABLE users +( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + status status +); +``` + +### Output: Generated Kotlin File + +The plugin will generate a Kotlin Exposed DSL table: + +```kotlin +package io.example.db + +import org.jetbrains.exposed.sql.Table + +enum class Status( + override val pgEnumLabel: String, +) : PgEnum { + ACTIVE(pgEnumLabel = "ACTIVE"), + INACTIVE(pgEnumLabel = "INACTIVE"); + + override val pgEnumTypeName: String = "public.status" +} + +object Users : Table("users") { + val id: Column = integer(name = "id") + val name: Column = text(name = "name") + val status: Column = customEnumeration( + name = "status", + sql = "status", + fromDb = { getPgEnumByLabel(it as String) }, + toDb = { it.toPgObject() }, + ) + + override val primaryKey: Table.PrimaryKey = PrimaryKey(id, name = "users_pkey") +} +``` + +## Contributing + +Contributions are welcome! Feel free to open issues or submit pull requests to improve the plugin. + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..63643ac --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +plugins { + id("com.gradle.plugin-publish") version "1.2.1" + kotlin("jvm") version "2.0.20" +} + +val groupStr = "io.github.klahap.pgen" +val gitRepo = "https://github.com/klahap/pgen" + +version = System.getenv("GIT_TAG_VERSION") ?: "1.0.0-SNAPSHOT" +group = groupStr + +repositories { + mavenCentral() +} + +dependencies { + implementation("com.squareup:kotlinpoet:2.0.0") + implementation("org.postgresql:postgresql:42.7.2") +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xjsr305=strict") + freeCompilerArgs.add("-Xcontext-receivers") + jvmTarget.set(JvmTarget.JVM_21) + languageVersion.set(KotlinVersion.KOTLIN_2_0) + } +} + +gradlePlugin { + website = gitRepo + vcsUrl = "$gitRepo.git" + + val generateFrappeDsl by plugins.creating { + id = groupStr + implementationClass = "$groupStr.Plugin" + displayName = "Generate Kotlin Exposed tables from a PostgreSQL database schema" + description = + "This Gradle plugin simplifies the development process by automatically generating Kotlin Exposed table definitions from a PostgreSQL database schema. It connects to your database, introspects the schema, and creates Kotlin code for Exposed DSL, including table definitions, column mappings, and relationships. Save time and eliminate boilerplate by keeping your Exposed models synchronized with your database schema effortlessly." + tags = + listOf("Kotlin Exposed", "PostgreSQL", "Exposed", "Kotlin DSL", "Database Integration", "Code Generation") + } +} diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..7395bb2 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,17 @@ +version: "3" + +services: + db: + image: pgvector/pgvector:0.8.0-pg17 + command: [ ] + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + POSTGRES_DB: postgres + ports: + - "5438:5432" + volumes: + - db-data:/var/lib/postgresql/default_code + +volumes: + db-data: \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..6fcfd4c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} +rootProject.name = "pgen" + diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt new file mode 100644 index 0000000..130ca02 --- /dev/null +++ b/src/main/kotlin/Main.kt @@ -0,0 +1,76 @@ +package io.github.klahap.pgen + +import io.github.klahap.pgen.model.Config.Companion.buildConfig +import io.github.klahap.pgen.model.sql.Table +import io.github.klahap.pgen.model.Config +import io.github.klahap.pgen.service.DbService +import io.github.klahap.pgen.service.DirectorySyncService.Companion.directorySync +import io.github.klahap.pgen.util.DefaultCodeFile +import io.github.klahap.pgen.service.EnvFileService +import io.github.klahap.pgen.util.codegen.CodeGenContext +import io.github.klahap.pgen.util.codegen.sync +import org.gradle.api.Project + +private fun generate(config: Config) { + val (tables, enums) = DbService(config.dbConnectionConfig).use { dbService -> + val tables = dbService.getTablesWithForeignTables(config.tableFilter) + val enumNames = tables.asSequence().flatMap { it.columns }.map { it.type } + .map { if (it is Table.Column.Type.Array) it.elementType else it } + .filterIsInstance().map { it.name }.toSet() + val enums = dbService.getEnums(enumNames) + tables to enums + } + + // TODO add/try view tables + + val context = CodeGenContext( + rootPackageName = config.packageName, + createDirectoriesForRootPackageName = config.createDirectoriesForRootPackageName, + ) + with(context) { + directorySync(config.outputPath) { + DefaultCodeFile.all().forEach { sync(it) } + enums.forEach { sync(it) } + tables.forEach { sync(it) } + cleanup() + } + } +} + +fun main() { + val envFile = EnvFileService(".env") + val config = buildConfig { + dbConnectionConfig( + url = envFile["DB_URL"], + user = envFile["DB_USER"], + password = envFile["DB_PASSWORD"], + ) + packageName("io.github.klahap.pgen_test.db") + tableFilter { + addSchemas("public") + } + outputPath("build/output") + createDirectoriesForRootPackageName(false) + } + generate(config) +} + + +class Plugin : org.gradle.api.Plugin { + + override fun apply(project: Project) { + val configBuilder = project.extensions.create("pgen", Config.Builder::class.java) + + project.task("pgen") { task -> + task.group = TASK_GROUP + task.doLast { + val config = configBuilder.build() + generate(config) + } + } + } + + companion object { + private const val TASK_GROUP = "quati tools" + } +} diff --git a/src/main/kotlin/dsl/Poet.kt b/src/main/kotlin/dsl/Poet.kt new file mode 100644 index 0000000..a50b4d9 --- /dev/null +++ b/src/main/kotlin/dsl/Poet.kt @@ -0,0 +1,51 @@ +package io.github.klahap.pgen.dsl + +import com.squareup.kotlinpoet.* + + +@JvmInline +value class PackageName(val name: String) { + override fun toString(): String = name + operator fun plus(subPackage: String) = PackageName("$name.$subPackage") +} + +fun fileSpec( + packageName: PackageName, + name: String, + block: FileSpec.Builder.() -> Unit, +) = + FileSpec.builder(packageName = packageName.name, fileName = name).apply(block).build() + +fun buildObject( + name: String, + block: TypeSpec.Builder.() -> Unit, +) = TypeSpec.objectBuilder(name).apply(block).build() + +fun buildEnum( + name: String, + block: TypeSpec.Builder.() -> Unit, +) = TypeSpec.enumBuilder(name).apply(block).build() + +fun TypeSpec.Builder.addProperty(name: String, type: TypeName, block: PropertySpec.Builder.() -> Unit) = + addProperty(PropertySpec.builder(name = name, type = type).apply(block).build()) + +fun TypeSpec.Builder.addEnumConstant( + name: String, + block: TypeSpec.Builder.() -> Unit, +) = addEnumConstant(name, TypeSpec.anonymousClassBuilder().apply(block).build()) + +fun TypeSpec.Builder.primaryConstructor( + block: FunSpec.Builder.() -> Unit, +) = primaryConstructor(FunSpec.constructorBuilder().apply(block).build()) + +fun TypeSpec.Builder.addCompanionObject( + block: TypeSpec.Builder.() -> Unit, +) = addType(TypeSpec.companionObjectBuilder().apply(block).build()) + +fun TypeSpec.Builder.addFunction( + name: String, + block: FunSpec.Builder.() -> Unit, +) = addFunction(FunSpec.builder(name).apply(block).build()) + +fun TypeSpec.Builder.addInitializerBlock(block: CodeBlock.Builder.() -> Unit) = + addInitializerBlock(CodeBlock.builder().apply(block).build()) diff --git a/src/main/kotlin/dsl/Sql.kt b/src/main/kotlin/dsl/Sql.kt new file mode 100644 index 0000000..e9b2cf9 --- /dev/null +++ b/src/main/kotlin/dsl/Sql.kt @@ -0,0 +1,14 @@ +package io.github.klahap.pgen.dsl + +import org.intellij.lang.annotations.Language +import java.sql.Connection +import java.sql.ResultSet + + +fun Connection.executeQuery(@Language("sql") query: String, mapper: (ResultSet) -> T): List { + return createStatement().use { statement -> + statement.executeQuery(query).use { rs -> + buildList { while (rs.next()) add(mapper(rs)) } + } + } +} diff --git a/src/main/kotlin/model/Config.kt b/src/main/kotlin/model/Config.kt new file mode 100644 index 0000000..3448ca0 --- /dev/null +++ b/src/main/kotlin/model/Config.kt @@ -0,0 +1,65 @@ +package io.github.klahap.pgen.model + +import io.github.klahap.pgen.dsl.PackageName +import io.github.klahap.pgen.model.sql.SqlObjectFilter +import java.nio.file.Path +import kotlin.io.path.Path + +data class Config( + val dbConnectionConfig: DbConnectionConfig, + val packageName: PackageName, + val tableFilter: SqlObjectFilter, + val outputPath: Path, + val createDirectoriesForRootPackageName: Boolean, +) { + data class DbConnectionConfig( + val url: String, + val user: String, + val password: String, + ) + + open class Builder { + private var dbConnectionConfig: DbConnectionConfig? = null + private var packageName: String? = null + private var tableFilter: SqlObjectFilter? = null + private var outputPath: Path? = null + private var createDirectoriesForRootPackageName: Boolean = true + + fun dbConnectionConfig( + url: String, + user: String, + password: String, + ) { + dbConnectionConfig = DbConnectionConfig(url = url, user = user, password = password) + } + + fun packageName(name: String) { + packageName = name + } + + fun outputPath(path: String) = outputPath(Path(path)) + fun outputPath(path: Path) { + outputPath = path + } + + fun tableFilter(block: SqlObjectFilter.Builder.() -> Unit) { + tableFilter = SqlObjectFilter.Builder().apply(block).build() + } + + fun createDirectoriesForRootPackageName(value: Boolean) { + createDirectoriesForRootPackageName = value + } + + fun build() = Config( + dbConnectionConfig = dbConnectionConfig ?: error("no DB connection config defined"), + packageName = packageName?.let { PackageName(it) } ?: error("no output package defined"), + tableFilter = tableFilter ?: error("no table filter defined"), + outputPath = outputPath ?: error("no output path defined"), + createDirectoriesForRootPackageName = createDirectoriesForRootPackageName, + ) + } + + companion object { + fun buildConfig(block: Builder.() -> Unit) = Builder().apply(block).build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/model/sql/Common.kt b/src/main/kotlin/model/sql/Common.kt new file mode 100644 index 0000000..ad26389 --- /dev/null +++ b/src/main/kotlin/model/sql/Common.kt @@ -0,0 +1,53 @@ +package io.github.klahap.pgen.model.sql + +import com.squareup.kotlinpoet.ClassName +import io.github.klahap.pgen.util.codegen.CodeGenContext +import io.github.klahap.pgen.dsl.PackageName +import io.github.klahap.pgen.util.kotlinKeywords +import io.github.klahap.pgen.util.makeDifferent +import io.github.klahap.pgen.util.toCamelCase + + +@JvmInline +value class SchemaName(val name: String) { + override fun toString() = name + + companion object { + val PG_CATALOG = SchemaName("pg_catalog") + } +} + +sealed interface SqlObject { + val name: SqlObjectName +} + +sealed interface SqlObjectName { + val schema: SchemaName + val name: String + val prettyName get() = name.toCamelCase(capitalized = true) + + context(CodeGenContext) + val packageName + get(): PackageName { + val path = when (this) { + is SqlEnumName -> "enumeration" + is SqlTableName -> "table" + } + return PackageName("$rootPackageName.$path.${schema.name.makeDifferent(kotlinKeywords)}") + } + + context(CodeGenContext) + val typeName + get() = ClassName(packageName.name, prettyName) + +} + +data class SqlTableName( + override val schema: SchemaName, + override val name: String, +) : SqlObjectName + +data class SqlEnumName( + override val schema: SchemaName, + override val name: String, +) : SqlObjectName diff --git a/src/main/kotlin/model/sql/Enum.kt b/src/main/kotlin/model/sql/Enum.kt new file mode 100644 index 0000000..ea61a2c --- /dev/null +++ b/src/main/kotlin/model/sql/Enum.kt @@ -0,0 +1,6 @@ +package io.github.klahap.pgen.model.sql + +data class Enum( + override val name: SqlEnumName, + val fields: List, +) : SqlObject diff --git a/src/main/kotlin/model/sql/SqlObjectFilter.kt b/src/main/kotlin/model/sql/SqlObjectFilter.kt new file mode 100644 index 0000000..1277aa0 --- /dev/null +++ b/src/main/kotlin/model/sql/SqlObjectFilter.kt @@ -0,0 +1,70 @@ +package io.github.klahap.pgen.model.sql + +sealed interface SqlObjectFilter { + fun toFilterString(schemaField: String, tableField: String): String + fun isEmpty(): Boolean + fun isNotEmpty(): Boolean = !isEmpty() + fun exactSizeOrNull(): Int? + + data class Schemas(val schemaNames: Set) : SqlObjectFilter { + override fun toFilterString(schemaField: String, tableField: String): String { + val schemasStr = schemaNames.toSet().joinToString(",") { "'$it'" } + return "$schemaField IN ($schemasStr)" + } + + override fun isEmpty(): Boolean = schemaNames.isEmpty() + override fun exactSizeOrNull(): Int? = if (isEmpty()) 0 else null + } + + data class Objects(val objectNames: Set) : SqlObjectFilter { + override fun toFilterString(schemaField: String, tableField: String): String { + val objectsStr = objectNames.joinToString(",") { "('${it.schema}','${it.name}')" } + return "($schemaField, $tableField) IN ($objectsStr)" + } + + override fun isEmpty(): Boolean = objectNames.isEmpty() + override fun exactSizeOrNull(): Int? = objectNames.size + } + + data class Multi( + val filters: List, + ) : SqlObjectFilter { + + override fun toFilterString(schemaField: String, tableField: String): String { + val filterStrings = filters + .filter { it.isNotEmpty() } + .map { it.toFilterString(schemaField = schemaField, tableField = tableField) } + return when (filterStrings.size) { + 0 -> error("cannot create sql filter for empty generic filter") + 1 -> filterStrings.single() + else -> "(" + filterStrings.joinToString(" OR ") + ")" + } + } + + override fun isEmpty(): Boolean = filters.isEmpty() || filters.all { it.isEmpty() } + override fun exactSizeOrNull(): Int? = filters.map { it.exactSizeOrNull() } + .fold(initial = 0) { a, b -> + if (a != null && b != null) a + b else null + } + } + + class Builder( + val schemas: MutableSet = mutableSetOf(), + val tables: MutableSet = mutableSetOf(), + ) { + fun addSchema(name: String) = schemas.add(SchemaName(name)) + fun addSchemas(vararg names: String) = schemas.addAll(names.map { SchemaName(it) }) + fun addTable(schema: String, table: String) = tables.add(SqlTableName(SchemaName(schema), table)) + fun build(): SqlObjectFilter { + val schemaFilter = Schemas(schemas).takeIf { it.isNotEmpty() } + val tableFilter = Objects(tables).takeIf { it.isNotEmpty() } + if (schemaFilter != null && tableFilter != null) + return Multi(listOf(schemaFilter, tableFilter)) + if (tableFilter != null) + return tableFilter + if (schemaFilter != null) + return schemaFilter + error("cannot build empty sql filter") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/model/sql/Table.kt b/src/main/kotlin/model/sql/Table.kt new file mode 100644 index 0000000..80eb3de --- /dev/null +++ b/src/main/kotlin/model/sql/Table.kt @@ -0,0 +1,62 @@ +package io.github.klahap.pgen.model.sql + +import io.github.klahap.pgen.util.toCamelCase + +data class Table( + override val name: SqlObjectName, + val columns: List, + val primaryKey: PrimaryKey?, + val foreignKeys: List, +) : SqlObject { + @JvmInline + value class ColumnName(val value: String) { + override fun toString() = value + val pretty get() = value.toCamelCase(capitalized = false) + } + + data class Column( + val name: ColumnName, + val type: Type, + val isNullable: Boolean, + ) { + val prettyName get() = name.pretty + + sealed interface Type { + data class Array(val elementType: Type) : Type + data class Enum(val name: SqlEnumName) : Type + data object Int2 : Type + data object Int4 : Type + data object Int8 : Type + data object Bool : Type + data object VarChar : Type + data object Date : Type + data object Interval : Type + data object Int4Range : Type + data object Int8Range : Type + data object Int4MultiRange : Type + data object Int8MultiRange : Type + data object Json : Type + data object Jsonb : Type + data class Numeric(val precision: Int, val scale: Int) : Type + data object UnconstrainedNumeric : Type + data object Text : Type + data object Time : Type + data object Timestamp : Type + data object TimestampWithTimeZone : Type + data object Uuid : Type + } + } + + data class PrimaryKey(val keyName: String, val columnNames: List) + + data class ForeignKey( + val name: String, + val targetTable: SqlTableName, + val references: Set + ) { + data class Reference( + val sourceColumn: ColumnName, + val targetColumn: ColumnName, + ) + } +} diff --git a/src/main/kotlin/service/DbService.kt b/src/main/kotlin/service/DbService.kt new file mode 100644 index 0000000..cc2fa1d --- /dev/null +++ b/src/main/kotlin/service/DbService.kt @@ -0,0 +1,282 @@ +package io.github.klahap.pgen.service + +import io.github.klahap.pgen.dsl.executeQuery +import io.github.klahap.pgen.model.* +import io.github.klahap.pgen.model.sql.* +import io.github.klahap.pgen.model.sql.Enum +import io.github.klahap.pgen.model.sql.Table.Column.Type +import io.github.klahap.pgen.model.sql.Table.Column.Type.* +import io.github.klahap.pgen.model.sql.Table.Column.Type.Array +import java.io.Closeable +import java.sql.DriverManager +import java.sql.ResultSet + +class DbService( + config: Config.DbConnectionConfig +) : Closeable { + private val connection = + DriverManager.getConnection("${config.url}?prepareThreshold=0", config.user, config.password) + + fun getTablesWithForeignTables(filter: SqlObjectFilter): List { + return buildList { + var currentFilter = filter + while (!currentFilter.isEmpty()) { + addAll(getTables(currentFilter)) + val tablesNames = map { it.name }.toSet() + val foreignTableNames = flatMap { t -> t.foreignKeys.map { it.targetTable } }.toSet() + val missingTableNames = foreignTableNames - tablesNames + currentFilter = SqlObjectFilter.Objects(missingTableNames) + } + } + } + + private fun getTables(filter: SqlObjectFilter): List
{ + if (filter.isEmpty()) return emptyList() + val columns = getColumns(filter) + val primaryKeys = getPrimaryKeys(filter) + val foreignKeys = getForeignKeys(filter) + val tableNames = columns.keys + primaryKeys.keys + foreignKeys.keys + + return tableNames.map { tableName -> + Table( + name = tableName, + columns = columns[tableName] ?: emptyList(), + primaryKey = primaryKeys[tableName], + foreignKeys = foreignKeys[tableName] ?: emptyList(), + ) + } + } + + private fun ResultSet.getColumnType( + udtNameOverride: String? = null, + columnTypeCategoryOverride: String? = null, + ): Type { + val schema = SchemaName(getString("column_type_schema")!!) + val columnName = udtNameOverride ?: getString("column_type_name")!! + val columnTypeCategory = columnTypeCategoryOverride ?: getString("column_type_category")!! + if (columnName.startsWith("_")) return Array( + getColumnType( + udtNameOverride = columnName.removePrefix("_"), + columnTypeCategoryOverride = getString("column_element_type_category")!! + ) + ) + if (schema != SchemaName.PG_CATALOG) return when (columnTypeCategory) { + "E" -> Type.Enum(SqlEnumName(schema = schema, name = columnName)) + else -> error("Unknown column type '$columnTypeCategory' for column_type column type '$schema:$columnName'") + } + return when (columnName) { + "bool" -> Bool + "date" -> Date + "int2" -> Int2 + "int4" -> Int4 + "int8" -> Int8 + "int4range" -> Int4Range + "int8range" -> Int8Range + "int4multirange" -> Int4MultiRange + "int8multirange" -> Int8MultiRange + "interval" -> Interval + "json" -> Json + "jsonb" -> Jsonb + "numeric" -> { + val precision = getInt("numeric_precision").takeIf { !wasNull() } + val scale = getInt("numeric_scale").takeIf { !wasNull() } + if (precision != null && scale != null) + Numeric(precision = precision, scale = scale) + else if (precision == null && scale == null) + UnconstrainedNumeric + else + error("invalid numeric type, precision: $precision, scale: $scale") + } + + "text" -> Text + "time" -> Time + "timestamp" -> Timestamp + "timestamptz" -> TimestampWithTimeZone + "uuid" -> Uuid + "varchar" -> VarChar + else -> error("undefined udt_name '$columnName'") + } + } + + private fun getColumns(filter: SqlObjectFilter): Map> { + if (filter.isEmpty()) return emptyMap() + return connection.executeQuery( + """ + SELECT + c.table_schema AS table_schema, + c.table_name AS table_name, + c.column_name AS column_name, + c.is_nullable AS is_nullable, + c.udt_schema AS column_type_schema, + c.udt_name AS column_type_name, + c.numeric_precision AS numeric_precision, + c.numeric_scale AS numeric_scale, + ty.typcategory AS column_type_category, + tye.typcategory AS column_element_type_category + FROM information_schema.columns AS c + JOIN pg_catalog.pg_namespace AS na + ON c.udt_schema = na.nspname + JOIN pg_catalog.pg_type AS ty + ON ty.typnamespace = na.oid + AND ty.typname = c.udt_name + LEFT JOIN pg_catalog.pg_type AS tye + ON ty.typelem != 0 + AND tye.oid = ty.typelem + WHERE ${filter.toFilterString(schemaField = "c.table_schema", tableField = "c.table_name")}; + """ + ) { resultSet -> + val tableName = SqlTableName( + schema = SchemaName(resultSet.getString("table_schema")!!), + name = resultSet.getString("table_name")!!, + ) + tableName to Table.Column( + name = Table.ColumnName(resultSet.getString("column_name")!!), + type = resultSet.getColumnType(), + isNullable = resultSet.getBoolean("is_nullable"), + ) + }.groupBy({ it.first }, { it.second }) + } + + private fun getPrimaryKeys(filter: SqlObjectFilter): Map { + data class PrimaryKeyColumn(val keyName: String, val columnName: Table.ColumnName, val idx: Int) + + if (filter.isEmpty()) return emptyMap() + return connection.executeQuery( + """ + SELECT + tc.table_schema, + tc.table_name, + kcu.column_name, + kcu.constraint_name, + kcu.ordinal_position + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND ${filter.toFilterString(schemaField = "tc.table_schema", tableField = "tc.table_name")}; + """ + ) { resultSet -> + val table = SqlTableName( + schema = SchemaName(resultSet.getString("table_schema")!!), + name = resultSet.getString("table_name")!!, + ) + table to PrimaryKeyColumn( + keyName = resultSet.getString("constraint_name")!!, + columnName = Table.ColumnName(resultSet.getString("column_name")!!), + idx = resultSet.getInt("ordinal_position") + ) + }.groupBy({ it.first }, { it.second }) + .mapValues { (table, columns) -> + Table.PrimaryKey( + keyName = columns.map { it.keyName }.distinct().singleOrNull() + ?: error("multiple primary keys for table $table"), + columnNames = columns.sortedBy { it.idx }.map { it.columnName }, + ) + } + } + + private fun getForeignKeys(filter: SqlObjectFilter): Map> { + data class ForeignKeyMetaData( + val name: String, + val sourceTable: SqlTableName, + val targetTable: SqlTableName, + ) + + if (filter.isEmpty()) return emptyMap() + return connection.executeQuery( + """ + SELECT + tc.constraint_name as constraint_name, + tc.table_schema AS source_schema, + tc.table_name AS source_table, + kcu.column_name AS source_column, + ccu.table_schema AS target_schema, + ccu.table_name AS target_table, + ccu.column_name AS target_column + FROM information_schema.table_constraints AS tc + JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage AS ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.table_schema = ccu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND ${filter.toFilterString(schemaField = "tc.table_schema", tableField = "tc.table_name")}; + """ + ) { resultSet -> + val meta = ForeignKeyMetaData( + name = resultSet.getString("constraint_name")!!, + sourceTable = SqlTableName( + schema = SchemaName(resultSet.getString("source_schema")!!), + name = resultSet.getString("source_table")!!, + ), + targetTable = SqlTableName( + schema = SchemaName(resultSet.getString("target_schema")!!), + name = resultSet.getString("target_table")!!, + ), + ) + val ref = Table.ForeignKey.Reference( + sourceColumn = Table.ColumnName(resultSet.getString("source_column")!!), + targetColumn = Table.ColumnName(resultSet.getString("target_column")!!), + ) + meta to ref + } + .groupBy({ it.first }, { it.second }) + .map { (meta, refs) -> + meta.sourceTable to Table.ForeignKey( + name = meta.name, + targetTable = meta.targetTable, + references = refs.toSet(), + ) + }.groupBy({ it.first }, { it.second }) + } + + fun getEnums(enumNames: Set): List { + data class EnumField(val order: UInt, val label: String) + + val filter = SqlObjectFilter.Objects(enumNames) + if (filter.isEmpty()) return emptyList() + val enums = connection.executeQuery( + """ + SELECT + na.nspname as enum_schema, + ty.typname as enum_name, + en.enumsortorder as enum_value_order, + en.enumlabel as enum_value_label + FROM pg_catalog.pg_type as ty + JOIN pg_catalog.pg_namespace as na + ON ty.typnamespace = na.oid + JOIN pg_catalog.pg_enum as en + ON en.enumtypid = ty.oid + WHERE typcategory = 'E' + AND ${filter.toFilterString(schemaField = "na.nspname", tableField = "ty.typname")}; + """ + ) { resultSet -> + val name = SqlEnumName( + schema = SchemaName(resultSet.getString("enum_schema")!!), + name = resultSet.getString("enum_name"), + ) + val field = EnumField( + order = resultSet.getInt("enum_value_order").takeIf { it > 0 }!!.toUInt(), + label = resultSet.getString("enum_value_label")!!, + ) + name to field + } + .groupBy({ it.first }, { it.second }) + .map { (name, fields) -> + Enum( + name = name, + fields = fields.sortedBy { it.order }.map { it.label }, + ) + } + val missingEnums = enumNames - enums.map { it.name }.toSet() + if (missingEnums.isNotEmpty()) + throw Exception("enums not found: $missingEnums") + return enums + } + + override fun close() { + connection.close() + } +} \ No newline at end of file diff --git a/src/main/kotlin/service/DirectorySyncService.kt b/src/main/kotlin/service/DirectorySyncService.kt new file mode 100644 index 0000000..b36cb2a --- /dev/null +++ b/src/main/kotlin/service/DirectorySyncService.kt @@ -0,0 +1,84 @@ +package io.github.klahap.pgen.service + +import com.squareup.kotlinpoet.FileSpec +import java.io.Closeable +import java.nio.file.Path +import kotlin.io.path.* + +class DirectorySyncService( + private val directory: Path +) : Closeable { + private var filesCreated = mutableSetOf() + private var filesUpdated = mutableSetOf() + private var filesUnchanged = mutableSetOf() + private var filesDeleted = mutableSetOf() + + fun sync(relativePath: String, content: String) = sync( + path = directory.resolve(relativePath).absolute(), + content = content, + ) + + fun sync( + relativePath: String, + content: FileSpec, + ) = sync(relativePath = relativePath, content = content.toString()) + + fun cleanup() { + @OptIn(ExperimentalPathApi::class) + val actualFiles = directory.walk().map { it.absolute() }.toSet() + val filesToDelete = actualFiles - (filesCreated + filesUpdated + filesUnchanged) + filesToDelete.forEach { it.deleteExisting() } + filesDeleted += filesToDelete + } + + private fun checkFilePath(path: Path) { + val inDirectory = null != path.absolute().relativeToOrNull(directory.absolute()) + if (!inDirectory) throw Exception("path '$path' is not in output directory '$directory'") + } + + private fun sync(path: Path, content: String) { + checkFilePath(path) + val type = DirectorySyncService.sync(path = path, content = content) + when (type) { + FileSyncType.UNCHANGED -> filesUnchanged.add(path) + FileSyncType.UPDATED -> filesUpdated.add(path) + FileSyncType.CREATED -> filesCreated.add(path) + } + } + + enum class FileSyncType { + UNCHANGED, UPDATED, CREATED + } + + override fun close() { + fun Set<*>.printSize() = size.toString().padStart(3) + println("#files unchanged = ${filesUnchanged.printSize()}") + println("#files created = ${filesCreated.printSize()}") + println("#files updated = ${filesUpdated.printSize()}") + println("#files deleted = ${filesDeleted.printSize()}") + } + + companion object { + fun directorySync(directory: Path, block: DirectorySyncService.() -> Unit) = + DirectorySyncService(directory).use(block) + + fun sync( + path: Path, + content: String, + ): FileSyncType { + if (!path.isAbsolute) return sync(path = path.absolute(), content = content) + return if (path.exists()) { + if (path.readText() == content) { + FileSyncType.UNCHANGED + } else { + path.writeText(content) + FileSyncType.UPDATED + } + } else { + path.parent.createDirectories() + path.writeText(content) + FileSyncType.CREATED + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/service/EnvFileService.kt b/src/main/kotlin/service/EnvFileService.kt new file mode 100644 index 0000000..5464942 --- /dev/null +++ b/src/main/kotlin/service/EnvFileService.kt @@ -0,0 +1,17 @@ +package io.github.klahap.pgen.service + +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.readLines + +class EnvFileService(private val path: Path) { + constructor(path: String) : this(Path(path)) + + private val allEnvs = path.readLines() + .filter { it.isNotBlank() } + .filter { !it.startsWith("#") } + .associate { it.substringBefore('=') to it.substringAfter('=') } + + operator fun get(name: String) = allEnvs[name]?.takeIf { it.isNotBlank() } + ?: throw Exception("'$name' not set in '$path'") +} \ No newline at end of file diff --git a/src/main/kotlin/util/NamingUtil.kt b/src/main/kotlin/util/NamingUtil.kt new file mode 100644 index 0000000..b673424 --- /dev/null +++ b/src/main/kotlin/util/NamingUtil.kt @@ -0,0 +1,67 @@ +package io.github.klahap.pgen.util + +private val VALID_CHAR_REGEX = Regex("[^a-zA-Z0-9_ -]") + +private fun String.toNameParts() = + replace(VALID_CHAR_REGEX) { + when (it.value) { + "ä" -> "ae" + "ö" -> "oe" + "ü" -> "ue" + "Ä" -> "Ae" + "Ö" -> "oe" + "Ü" -> "Ue" + "ß" -> "ss" + else -> "" + } + } + .split(' ', '_', '-') + .filter { it.isNotEmpty() } + +private fun String.toValidName() = if (isEmpty()) + "_empty" +else if (first().isDigit()) + "_$this" +else + this + +fun String.toCamelCase(capitalized: Boolean) = toNameParts() + .mapIndexed { idx, s -> + s.replaceFirstChar { + if (idx == 0 && !capitalized) + it.lowercaseChar() + else + it.titlecaseChar() + } + }.joinToString(separator = "") + .toValidName() + +fun String.toSnakeCase(uppercase: Boolean = false) = toNameParts() + .joinToString(separator = "_") { if (uppercase) it.uppercase() else it.lowercase() } + .toValidName() + +fun String.toHyphenated() = toNameParts() + .joinToString(separator = "-") { it.lowercase() } + .toValidName() + +fun String.makeDifferent(blackList: Iterable): String { + val blackListSet = blackList.toSet() + var name = this + while (name in blackListSet) { + name = "_$name" + } + return name +} + +val kotlinKeywords = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", + "if", "in", "interface", "is", "null", "object", "package", "return", + "super", "this", "throw", "true", "try", "typealias", "val", "var", + "when", "while", "by", "catch", "constructor", "delegate", "dynamic", + "field", "file", "finally", "get", "import", "init", "param", "property", + "receiver", "set", "setparam", "where", "actual", "abstract", "annotation", + "companion", "const", "crossinline", "data", "enum", "expect", "external", + "final", "infix", "inline", "inner", "internal", "lateinit", "noinline", + "open", "operator", "out", "override", "private", "protected", "public", + "reified", "sealed", "suspend", "tailrec", "vararg" +) diff --git a/src/main/kotlin/util/ResourceUtil.kt b/src/main/kotlin/util/ResourceUtil.kt new file mode 100644 index 0000000..6836d1b --- /dev/null +++ b/src/main/kotlin/util/ResourceUtil.kt @@ -0,0 +1,31 @@ +package io.github.klahap.pgen.util + +import io.github.klahap.pgen.util.codegen.CodeGenContext + +data class DefaultCodeFile( + val relativePackageName: String, + val fileName: String, +) { + context(CodeGenContext) + fun getContent(): String { + val relativePath = relativePackageName.replace(".", "/") + "/" + fileName + return this::class.java.getResourceAsStream("/default_code/$relativePath")!! + .readAllBytes().decodeToString() + .replaceFirst("package default_code", "package $rootPackageName") + .replace("import default_code", "import $rootPackageName") + } + + companion object { + fun all() = setOf( + DefaultCodeFile("column_type", "IntMultiRange.kt"), + DefaultCodeFile("column_type", "IntRange.kt"), + DefaultCodeFile("column_type", "MultiRange.kt"), + DefaultCodeFile("column_type", "MultiRangeColumnType.kt"), + DefaultCodeFile("column_type", "RangeColumnType.kt"), + DefaultCodeFile("column_type", "UnconstrainedNumericColumnType.kt"), + DefaultCodeFile("column_type", "UtilMultiRange.kt"), + DefaultCodeFile("column_type", "UtilRange.kt"), + DefaultCodeFile("column_type", "UtilEnum.kt"), + ) + } +} diff --git a/src/main/kotlin/util/codegen/CodeGenContext.kt b/src/main/kotlin/util/codegen/CodeGenContext.kt new file mode 100644 index 0000000..6944350 --- /dev/null +++ b/src/main/kotlin/util/codegen/CodeGenContext.kt @@ -0,0 +1,27 @@ +package io.github.klahap.pgen.util.codegen + +import com.squareup.kotlinpoet.ClassName +import io.github.klahap.pgen.dsl.PackageName + +data class CodeGenContext( + val rootPackageName: PackageName, + val createDirectoriesForRootPackageName: Boolean, +) { + private val packageCustomColumn = PackageName("$rootPackageName.column_type") + + val typeNameMultiRange + get() = ClassName(packageCustomColumn.name, "MultiRange") + val typeNameInt4RangeColumnType + get() = ClassName(packageCustomColumn.name, "Int4RangeColumnType") + val typeNameInt8RangeColumnType + get() = ClassName(packageCustomColumn.name, "Int8RangeColumnType") + val typeNameInt4MultiRangeColumnType + get() = ClassName(packageCustomColumn.name, "Int4MultiRangeColumnType") + val typeNameInt8MultiRangeColumnType + get() = ClassName(packageCustomColumn.name, "Int8MultiRangeColumnType") + val typeNameUnconstrainedNumericColumnType + get() = ClassName(packageCustomColumn.name, "UnconstrainedNumericColumnType") + + val typeNamePgEnum get() = ClassName(packageCustomColumn.name, "PgEnum") + val typeNameGetPgEnumByLabel get() = ClassName(packageCustomColumn.name, "getPgEnumByLabel") +} \ No newline at end of file diff --git a/src/main/kotlin/util/codegen/Column.kt b/src/main/kotlin/util/codegen/Column.kt new file mode 100644 index 0000000..5e94038 --- /dev/null +++ b/src/main/kotlin/util/codegen/Column.kt @@ -0,0 +1,107 @@ +package io.github.klahap.pgen.util.codegen + +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.asTypeName +import io.github.klahap.pgen.model.sql.Table +import java.math.BigDecimal +import java.util.* + + +context(CodeGenContext) +fun Table.Column.Type.getTypeName(): TypeName { + return when (this) { + is Table.Column.Type.Array -> elementType.getTypeName() + is Table.Column.Type.Enum -> name.typeName + Table.Column.Type.Int8 -> Long::class.asTypeName() + Table.Column.Type.Bool -> Boolean::class.asTypeName() + Table.Column.Type.VarChar -> String::class.asTypeName() + Table.Column.Type.Date -> Poet.localDate + Table.Column.Type.Interval -> Poet.duration + Table.Column.Type.Int4Range -> IntRange::class.asTypeName() + Table.Column.Type.Int8Range -> LongRange::class.asTypeName() + Table.Column.Type.Int4MultiRange -> typeNameMultiRange.parameterizedBy(Int::class.asTypeName()) + Table.Column.Type.Int8MultiRange -> typeNameMultiRange.parameterizedBy(Long::class.asTypeName()) + Table.Column.Type.Int4 -> Int::class.asTypeName() + Table.Column.Type.Json -> Poet.jsonElement + Table.Column.Type.Jsonb -> Poet.jsonElement + is Table.Column.Type.Numeric -> BigDecimal::class.asTypeName() + Table.Column.Type.Int2 -> Short::class.asTypeName() + Table.Column.Type.Text -> String::class.asTypeName() + Table.Column.Type.Time -> Poet.localTime + Table.Column.Type.Timestamp -> Poet.instant + Table.Column.Type.TimestampWithTimeZone -> Poet.offsetDateTime + Table.Column.Type.Uuid -> UUID::class.asTypeName() + Table.Column.Type.UnconstrainedNumeric -> BigDecimal::class.asTypeName() + } +} + +context(CodeGenContext) +fun PropertySpec.Builder.initializer(column: Table.Column) { + val columnName = column.name.value + when (val type = column.type) { + is Table.Column.Type.Array -> initializer("array<%T>(name = %S)", type.getTypeName(), columnName) + is Table.Column.Type.Enum -> initializer( + """ + customEnumeration( + name = %S, + sql = %S, + fromDb = { %T(it as String) }, + toDb = { it.toPgObject() }, + )""".trimIndent(), columnName, type.name.name, typeNameGetPgEnumByLabel + ) + + Table.Column.Type.Int8 -> initializer("long(name = %S)", columnName) + Table.Column.Type.Bool -> initializer("bool(name = %S)", columnName) + Table.Column.Type.VarChar -> initializer("text(name = %S)", columnName) + Table.Column.Type.Date -> initializer("%T(name = %S)", Poet.date, columnName) + Table.Column.Type.Interval -> initializer("duration(name = %S)", columnName) + Table.Column.Type.Int4Range -> initializer( + "registerColumn(name = %S, type = %T())", + columnName, typeNameInt4RangeColumnType + ) + + Table.Column.Type.Int8Range -> initializer( + "registerColumn(name = %S, type = %T())", + columnName, typeNameInt8RangeColumnType + ) + + Table.Column.Type.Int4MultiRange -> initializer( + "registerColumn(name = %S, type = %T())", + columnName, typeNameInt4MultiRangeColumnType + ) + + Table.Column.Type.Int8MultiRange -> initializer( + "registerColumn(name = %S, type = %T())", + columnName, typeNameInt8MultiRangeColumnType + ) + + Table.Column.Type.Int4 -> initializer("integer(name = %S)", columnName) + Table.Column.Type.Json -> initializer( + "%T<%T>(name = %S, serialize = %T)", + Poet.jsonColumn, Poet.jsonElement, columnName, Poet.json, + ) + + Table.Column.Type.Jsonb -> initializer( + "%T<%T>(name = %S, jsonConfig = %T)", + Poet.jsonColumn, Poet.jsonElement, columnName, Poet.json, + ) + + is Table.Column.Type.Numeric -> initializer( + "decimal(name = %S, precision = ${type.precision}, scale = ${type.scale})", + columnName, + ) + + Table.Column.Type.Int2 -> initializer("short(name = %S)", columnName) + Table.Column.Type.Text -> initializer("text(name = %S)", columnName) + Table.Column.Type.Time -> initializer("%T(name = %S)", Poet.time, columnName) + Table.Column.Type.Timestamp -> initializer("%T(name = %S)", Poet.timestamp, columnName) + Table.Column.Type.TimestampWithTimeZone -> initializer("%T(name = %S)", Poet.timestampWithTimeZone, columnName) + Table.Column.Type.Uuid -> initializer("uuid(name = %S)", columnName) + Table.Column.Type.UnconstrainedNumeric -> initializer( + "registerColumn(name = %S, type = %T())", + columnName, typeNameUnconstrainedNumericColumnType + ) + } +} diff --git a/src/main/kotlin/util/codegen/Common.kt b/src/main/kotlin/util/codegen/Common.kt new file mode 100644 index 0000000..1dc3917 --- /dev/null +++ b/src/main/kotlin/util/codegen/Common.kt @@ -0,0 +1,78 @@ +package io.github.klahap.pgen.util.codegen + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.asTypeName +import io.github.klahap.pgen.dsl.PackageName +import io.github.klahap.pgen.dsl.fileSpec +import io.github.klahap.pgen.model.sql.Enum +import io.github.klahap.pgen.model.sql.SqlObject +import io.github.klahap.pgen.model.sql.Table +import io.github.klahap.pgen.service.DirectorySyncService +import io.github.klahap.pgen.util.DefaultCodeFile +import java.time.OffsetDateTime + +object Poet { + val table = ClassName("org.jetbrains.exposed.sql", "Table") + val primaryKey = ClassName("org.jetbrains.exposed.sql", "Table", "PrimaryKey") + val column = ClassName("org.jetbrains.exposed.sql", "Column") + val date = ClassName("org.jetbrains.exposed.sql.kotlin.datetime", "date") + val time = ClassName("org.jetbrains.exposed.sql.kotlin.datetime", "time") + val timestamp = ClassName("org.jetbrains.exposed.sql.kotlin.datetime", "timestamp") + val timestampWithTimeZone = ClassName("org.jetbrains.exposed.sql.kotlin.datetime", "timestampWithTimeZone") + val jsonColumn = ClassName("org.jetbrains.exposed.sql.json", "json") + + val json = ClassName("kotlinx.serialization.json", "Json") + val jsonElement = ClassName("kotlinx.serialization.json", "JsonElement") + + val instant = ClassName("kotlinx.datetime", "Instant") + val duration = ClassName("kotlinx.datetime", "Duration") + val localTime = ClassName("kotlinx.datetime", "LocalTime") + val localDate = ClassName("kotlinx.datetime", "LocalDate") + val offsetDateTime = OffsetDateTime::class.asTypeName() +} + +context(CodeGenContext) +fun SqlObject.toTypeSpec() = when (this) { + is Enum -> toTypeSpecInternal() + is Table -> toTypeSpecInternal() +} + +context(CodeGenContext) +fun FileSpec.Builder.add(obj: SqlObject) { + addType(obj.toTypeSpec()) +} + +context(CodeGenContext) +fun DirectorySyncService.sync( + obj: SqlObject, + block: FileSpec.Builder.() -> Unit = {}, +) { + val fileName = "${obj.name.prettyName}.kt" + sync( + relativePath = obj.name.packageName.toRelativePath() + "/$fileName", + content = fileSpec( + packageName = obj.name.packageName, + name = fileName, + block = { + add(obj) + block() + } + ) + ) +} + +context(CodeGenContext) +fun DirectorySyncService.sync(codeFile: DefaultCodeFile) { + val packageName = rootPackageName + codeFile.relativePackageName + sync( + relativePath = packageName.toRelativePath() + "/" + codeFile.fileName, + content = codeFile.getContent() + ) +} + +context(CodeGenContext) +fun PackageName.toRelativePath() = if (createDirectoriesForRootPackageName) + name.replace(".", "/") +else + name.removePrefix(rootPackageName.name).trimStart('.').replace(".", "/") diff --git a/src/main/kotlin/util/codegen/Enum.kt b/src/main/kotlin/util/codegen/Enum.kt new file mode 100644 index 0000000..2919f63 --- /dev/null +++ b/src/main/kotlin/util/codegen/Enum.kt @@ -0,0 +1,28 @@ +package io.github.klahap.pgen.util.codegen + +import com.squareup.kotlinpoet.* +import io.github.klahap.pgen.dsl.* +import io.github.klahap.pgen.model.sql.Enum +import io.github.klahap.pgen.util.toSnakeCase + +context(CodeGenContext) +internal fun Enum.toTypeSpecInternal() = buildEnum(this@toTypeSpecInternal.name.prettyName) { + addSuperinterface(typeNamePgEnum) + primaryConstructor { + addParameter("pgEnumLabel", String::class) + addProperty(name = "pgEnumLabel", type = String::class.asTypeName()) { + addModifiers(KModifier.OVERRIDE) + initializer("pgEnumLabel") + } + } + this@toTypeSpecInternal.fields.forEach { field -> + addEnumConstant(field.toSnakeCase(uppercase = true)) { + addSuperclassConstructorParameter("pgEnumLabel = %S", field) + } + } + val pgEnumTypeNameValue = "${this@toTypeSpecInternal.name.schema}.${this@toTypeSpecInternal.name.name}" + addProperty(name = "pgEnumTypeName", type = String::class.asTypeName()) { + initializer("%S", pgEnumTypeNameValue) + addModifiers(KModifier.OVERRIDE) + } +} diff --git a/src/main/kotlin/util/codegen/Table.kt b/src/main/kotlin/util/codegen/Table.kt new file mode 100644 index 0000000..9d8ac68 --- /dev/null +++ b/src/main/kotlin/util/codegen/Table.kt @@ -0,0 +1,55 @@ +package io.github.klahap.pgen.util.codegen + +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.asTypeName +import io.github.klahap.pgen.dsl.addInitializerBlock +import io.github.klahap.pgen.dsl.addProperty +import io.github.klahap.pgen.dsl.buildObject +import io.github.klahap.pgen.model.sql.Table +import io.github.klahap.pgen.util.makeDifferent + + +context(CodeGenContext) +internal fun Table.toTypeSpecInternal() = buildObject(this@toTypeSpecInternal.name.prettyName) { + superclass(Poet.table) + addSuperclassConstructorParameter("%S", this@toTypeSpecInternal.name.name) + this@toTypeSpecInternal.columns.forEach { column -> + addProperty( + name = column.prettyName, + type = Poet.column.parameterizedBy( + when (column.type) { + is Table.Column.Type.Array -> List::class.asTypeName() + .parameterizedBy(column.type.getTypeName()) + + else -> column.type.getTypeName() + } + ), + ) { + initializer(column) + } + } + if (this@toTypeSpecInternal.primaryKey != null) { + val columnNames = this@toTypeSpecInternal.columns.map { it.prettyName } + addProperty(name = "primaryKey".makeDifferent(columnNames), type = Poet.primaryKey) { + addModifiers(KModifier.OVERRIDE) + initializer( + "PrimaryKey(%L, name = %S)", + this@toTypeSpecInternal.primaryKey.columnNames.joinToString(", ") { it.pretty }, + this@toTypeSpecInternal.primaryKey.keyName, + ) + } + } + + addInitializerBlock { + this@toTypeSpecInternal.foreignKeys.forEach { foreignKey -> + val foreignKeyStrFormat = foreignKey.references.joinToString(", ") { ref -> + "${ref.sourceColumn.pretty} to %T.${ref.targetColumn.pretty}" + } + val foreignKeyStrValues = foreignKey.references.map { + foreignKey.targetTable.typeName + }.toTypedArray() + addStatement("foreignKey($foreignKeyStrFormat)", *foreignKeyStrValues) + } + } +} diff --git a/src/main/resources/default_code/column_type/IntMultiRange.kt b/src/main/resources/default_code/column_type/IntMultiRange.kt new file mode 100644 index 0000000..3de5a74 --- /dev/null +++ b/src/main/resources/default_code/column_type/IntMultiRange.kt @@ -0,0 +1,17 @@ +package default_code.column_type + +import org.jetbrains.exposed.sql.* + + +class Int4MultiRangeColumnType : MultiRangeColumnType() { + override fun sqlType(): String = "INT4MULTIRANGE" + override fun String.parse(): MultiRange = parseMultiRange().toInt4MultiRange() +} + +class Int8MultiRangeColumnType : MultiRangeColumnType() { + override fun sqlType(): String = "INT8MULTIRANGE" + override fun String.parse(): MultiRange = parseMultiRange().toInt8MultiRange() +} + +fun Table.int4MultiRange(name: String): Column> = registerColumn(name, Int4MultiRangeColumnType()) +fun Table.int8MultiRange(name: String): Column> = registerColumn(name, Int8MultiRangeColumnType()) diff --git a/src/main/resources/default_code/column_type/IntRange.kt b/src/main/resources/default_code/column_type/IntRange.kt new file mode 100644 index 0000000..f9d324b --- /dev/null +++ b/src/main/resources/default_code/column_type/IntRange.kt @@ -0,0 +1,18 @@ +package default_code.column_type + +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.Table + + +class Int4RangeColumnType : RangeColumnType() { + override fun sqlType(): String = "INT4RANGE" + override fun String.parse(): IntRange = parseRange().toInt4Range() +} + +class Int8RangeColumnType : RangeColumnType() { + override fun sqlType(): String = "INT8RANGE" + override fun String.parse(): LongRange = parseRange().toInt8Range() +} + +fun Table.int4Range(name: String): Column = registerColumn(name, Int4RangeColumnType()) +fun Table.int8Range(name: String): Column = registerColumn(name, Int8RangeColumnType()) diff --git a/src/main/resources/default_code/column_type/MultiRange.kt b/src/main/resources/default_code/column_type/MultiRange.kt new file mode 100644 index 0000000..7589410 --- /dev/null +++ b/src/main/resources/default_code/column_type/MultiRange.kt @@ -0,0 +1,7 @@ +package default_code.column_type + + +@JvmInline +value class MultiRange>( + private val ranges: Set> +) : Set> by ranges diff --git a/src/main/resources/default_code/column_type/MultiRangeColumnType.kt b/src/main/resources/default_code/column_type/MultiRangeColumnType.kt new file mode 100644 index 0000000..c4177fe --- /dev/null +++ b/src/main/resources/default_code/column_type/MultiRangeColumnType.kt @@ -0,0 +1,31 @@ +package default_code.column_type + +import org.jetbrains.exposed.sql.ColumnType +import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi +import org.postgresql.util.PGobject + + +abstract class MultiRangeColumnType> : ColumnType>() { + abstract fun String.parse(): MultiRange + + override fun nonNullValueToString(value: MultiRange): String = + value.joinToString(separator = ",", prefix = "{", postfix = "}") { it.toPgRangeString() } + + override fun nonNullValueAsDefaultString(value: MultiRange): String = + "'${nonNullValueToString(value)}'" + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + val parameterValue: PGobject? = value?.let { + PGobject().apply { + type = sqlType() + this.value = @Suppress("UNCHECKED_CAST") nonNullValueToString(it as MultiRange) + } + } + super.setParameter(stmt, index, parameterValue) + } + + override fun valueFromDB(value: Any): MultiRange? = when (value) { + is PGobject -> value.value?.takeIf { it.isNotBlank() }?.parse() + else -> error("Retrieved unexpected value of type ${value::class.simpleName}") + } +} \ No newline at end of file diff --git a/src/main/resources/default_code/column_type/RangeColumnType.kt b/src/main/resources/default_code/column_type/RangeColumnType.kt new file mode 100644 index 0000000..ee4a5bd --- /dev/null +++ b/src/main/resources/default_code/column_type/RangeColumnType.kt @@ -0,0 +1,29 @@ +package default_code.column_type + +import org.jetbrains.exposed.sql.ColumnType +import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi +import org.postgresql.util.PGobject + +abstract class RangeColumnType, R : ClosedRange> : ColumnType() { + abstract fun String.parse(): R + + override fun nonNullValueToString(value: R): String = value.toPgRangeString() + + override fun nonNullValueAsDefaultString(value: R): String = + "'${nonNullValueToString(value)}'" + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + val parameterValue: PGobject? = value?.let { + PGobject().apply { + type = sqlType() + this.value = @Suppress("UNCHECKED_CAST") nonNullValueToString(it as R) + } + } + super.setParameter(stmt, index, parameterValue) + } + + override fun valueFromDB(value: Any): R? = when (value) { + is PGobject -> value.value?.takeIf { it.isNotBlank() }?.parse() + else -> error("Retrieved unexpected value of type ${value::class.simpleName}") + } +} diff --git a/src/main/resources/default_code/column_type/UnconstrainedNumericColumnType.kt b/src/main/resources/default_code/column_type/UnconstrainedNumericColumnType.kt new file mode 100644 index 0000000..c628066 --- /dev/null +++ b/src/main/resources/default_code/column_type/UnconstrainedNumericColumnType.kt @@ -0,0 +1,37 @@ +package default_code.column_type + +import org.jetbrains.exposed.sql.ColumnType +import java.math.BigDecimal +import java.sql.ResultSet +import java.sql.SQLException + + +class UnconstrainedNumericColumnType : ColumnType() { + override fun sqlType(): String = "NUMERIC" + + override fun readObject(rs: ResultSet, index: Int): Any? { + return rs.getObject(index) + } + + override fun valueFromDB(value: Any): BigDecimal = when (value) { + is BigDecimal -> value + is Double -> { + if (value.isNaN()) + throw SQLException("Unexpected value of type Double: NaN of ${value::class.qualifiedName}") + else + value.toBigDecimal() + } + + is Float -> { + if (value.isNaN()) + error("Unexpected value of type Float: NaN of ${value::class.qualifiedName}") + else + value.toBigDecimal() + } + + is Long -> value.toBigDecimal() + is Int -> value.toBigDecimal() + is Short -> value.toLong().toBigDecimal() + else -> error("Unexpected value of type Numeric: $value of ${value::class.qualifiedName}") + } +} \ No newline at end of file diff --git a/src/main/resources/default_code/column_type/UtilEnum.kt b/src/main/resources/default_code/column_type/UtilEnum.kt new file mode 100644 index 0000000..f9ca822 --- /dev/null +++ b/src/main/resources/default_code/column_type/UtilEnum.kt @@ -0,0 +1,21 @@ +package default_code.column_type + +import org.postgresql.util.PGobject +import kotlin.enums.enumEntries + +interface PgEnum { + val pgEnumTypeName: String + val pgEnumLabel: String + + fun toPgObject() = PGobject().apply { + value = pgEnumLabel + type = pgEnumTypeName + } +} + +inline fun getPgEnumByLabel(label: String): T + where T : Enum, + T : PgEnum { + return enumEntries().singleOrNull { e -> e.pgEnumLabel == label } + ?: error("enum with label '$label' not found in '${T::class.qualifiedName}'") +} diff --git a/src/main/resources/default_code/column_type/UtilMultiRange.kt b/src/main/resources/default_code/column_type/UtilMultiRange.kt new file mode 100644 index 0000000..2b8085b --- /dev/null +++ b/src/main/resources/default_code/column_type/UtilMultiRange.kt @@ -0,0 +1,9 @@ +package default_code.column_type + +internal fun List.toInt4MultiRange(): MultiRange = MultiRange(map { it.toInt4Range() }.toSet()) +internal fun List.toInt8MultiRange(): MultiRange = MultiRange(map { it.toInt8Range() }.toSet()) + +internal fun String.parseMultiRange(): List = trimStart('{').trimEnd('}') + .split(',').chunked(2) + .map { borders -> borders.joinToString(",") } + .map { it.parseRange() } diff --git a/src/main/resources/default_code/column_type/UtilRange.kt b/src/main/resources/default_code/column_type/UtilRange.kt new file mode 100644 index 0000000..d56106f --- /dev/null +++ b/src/main/resources/default_code/column_type/UtilRange.kt @@ -0,0 +1,85 @@ +package default_code.column_type + +import default_code.column_type.RawRange.Empty +import default_code.column_type.RawRange.Normal +import default_code.column_type.RawRangeBorder.Infinity + + +internal sealed interface RawRange { + data object Empty : RawRange + data class Normal(val start: RawRangeBorder, val end: RawRangeBorder) : RawRange +} + +internal sealed interface RawRangeBorder { + data object Infinity : RawRangeBorder + data class Normal( + val value: String, + val inclusive: Boolean, + ) : RawRangeBorder +} + +internal fun RawRange.toInt4Range(): IntRange = when (this) { + Empty -> IntRange.EMPTY + is Normal -> IntRange( + start = when (start) { + RawRangeBorder.Infinity -> Int.MIN_VALUE + is RawRangeBorder.Normal -> start.value.toInt().let { if (start.inclusive) it else it + 1 } + }, + endInclusive = when (end) { + RawRangeBorder.Infinity -> Int.MAX_VALUE + is RawRangeBorder.Normal -> end.value.toInt().let { if (end.inclusive) it else it - 1 } + }, + ) +} + +internal fun RawRange.toInt8Range(): LongRange = when (this) { + Empty -> LongRange.EMPTY + is Normal -> LongRange( + start = when (start) { + RawRangeBorder.Infinity -> Long.MIN_VALUE + is RawRangeBorder.Normal -> start.value.toLong().let { if (start.inclusive) it else it + 1 } + }, + endInclusive = when (end) { + RawRangeBorder.Infinity -> Long.MAX_VALUE + is RawRangeBorder.Normal -> end.value.toLong().let { if (end.inclusive) it else it - 1 } + }, + ) +} + +internal fun String.parseRangeBorderStart(): RawRangeBorder { + if (isBlank()) error("invalid range start ''") + if (this == "(") return Infinity + return RawRangeBorder.Normal( + value = trimStart('[', '(').takeIf { it.isNotBlank() } ?: error("invalid range start '$this'"), + inclusive = when (first()) { + '[' -> true + '(' -> false + else -> error("Retrieved unexpected range start '$this'") + } + ) +} + +internal fun String.parseRangeBorderEnd(): RawRangeBorder { + if (isBlank()) error("invalid range end ''") + if (this == ")") return Infinity + return RawRangeBorder.Normal( + value = trimStart(']', ')').takeIf { it.isNotBlank() } ?: error("invalid range end '$this'"), + inclusive = when (last()) { + ']' -> true + ')' -> false + else -> error("Retrieved unexpected range end '$this'") + } + ) +} + +internal fun String.parseRange(): RawRange { + if (this == "()") return Empty + val (startRaw, endRaw) = split(",").takeIf { it.size == 2 } + ?: error("invalid range string '$this'") + return Normal( + start = startRaw.parseRangeBorderStart(), + end = endRaw.parseRangeBorderEnd(), + ) +} + +internal fun ClosedRange<*>.toPgRangeString(): String = "[$start,$endInclusive]"