diff --git a/.editorconfig b/.editorconfig index dd24172..ab97db1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,6 +16,10 @@ insert_final_newline = true indent_size = 4 indent_style = space +[*.{sql}] +indent_size = 4 +indent_style = space + [*.md] trim_trailing_whitespace = false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1b40421..89d49ff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,17 +1,35 @@ -name: Build +name: Build the Distribution -on: [push] +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Set up JDK - uses: actions/setup-java@v1.3.0 - with: - java-version: 11 - - name: Build with Gradle - run: ./gradlew build + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: gradle clean build + + - name: Dist + run: gradle cli:distZip + + - name: Upload binary + uses: actions/upload-artifact@v2 + with: + name: norm-codegen + path: cli/build/distributions/norm-codegen.zip diff --git a/.github/workflows/gradle-upgrade.yml b/.github/workflows/gradle-upgrade.yml new file mode 100644 index 0000000..990f036 --- /dev/null +++ b/.github/workflows/gradle-upgrade.yml @@ -0,0 +1,27 @@ +## runs gradle wrapper task and creates a PR + +name: Upgrade Gradle + +on: + push: + branches: [ master ] + + schedule: + - cron: 0 2 * * 1 + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Run a one-line script + run: | + LATEST_VER=$(curl https://api.github.com/repos/gradle/gradle/releases/latest | jq -r .name) + ./gradlew wrapper --gradle-version ${LATEST_VER} --distribution-type all + - name: Create Pull Request + uses: peter-evans/create-pull-request@v2 + with: + commit-message: "[gradle-updater] updating gradle wrapper to latest version" + title: "Upgrade Gradle" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e5be0ba --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Medly + +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 index 69be821..93f96eb 100644 --- a/README.md +++ b/README.md @@ -180,3 +180,39 @@ Hence, Norm has two packages: println(res.address) } ``` + + + +## Command Line Interface + +Norm CLI can be used to generate Kotlin files corresponding to SQL files. + +we can provide multiple of files using `-f some/path/a.sql -f some/path/b.sql`. This will generate Kotlin files +at `some.path.A.kt` & `some.path.A.kt`. If we want to exclude `some` from package name then we must use `-b` option +with the base dir `-f some/path/a.sql -f some/path/b.sql -b some/`. Now the kotlin files will be generated in package +`path.A.kt` & `path.A.kt` inside the `output-dir`. + +If option `--in-dir` is used, all the `*.sql` files will be used for code generation. + +``` +$ norm-codegen --help + +Usage: norm-codegen [OPTIONS] [SQLFILES]... + + Generates Kotlin Source files for given SQL files using the Postgres + database connection + +Options: + -j, --jdbc-url TEXT JDBC connection URL (can use env var PG_JDBC_URL) + -u, --username TEXT Username (can use env var PG_USERNAME) + -p, --password TEXT Password (can use env var PG_PASSWORD) + -b, --base-path DIRECTORY relative path from this dir will be used to infer + package name + -f, --file FILE [Multiple] SQL files, the file path relative to + base path (-b) will be used to infer package name + -d, --in-dir DIRECTORY Dir containing .sql files, relative path from + this dir will be used to infer package name + -o, --out-dir DIRECTORY Output dir where source should be generated + -h, --help Show this message and exit + +``` diff --git a/build.gradle b/build.gradle index dbdd048..e77bd36 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,8 @@ plugins { subprojects { apply plugin: 'org.jetbrains.kotlin.jvm' + group = 'com.medly.norm' + repositories { jcenter() } @@ -16,8 +18,8 @@ subprojects { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' testImplementation 'io.kotlintest:kotlintest-runner-junit5:3.4.2' - testCompile 'org.testcontainers:postgresql:1.14.3' - testCompile 'postgresql:postgresql:9.1-901-1.jdbc4' + testImplementation 'org.testcontainers:postgresql:1.14.3' + testImplementation 'postgresql:postgresql:9.1-901-1.jdbc4' } test { @@ -28,6 +30,12 @@ subprojects { exceptionFormat "full" } } + + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { + kotlinOptions { + jvmTarget = "1.8" + } + } } project(":codegen") { @@ -42,3 +50,16 @@ project(":codegen") { testImplementation("io.kotlintest:kotlintest-runner-junit5:3.3.0") } } + +project(":cli") { + apply plugin: 'application' + + applicationName = 'norm-codegen' + mainClassName = "norm.cli.NormCliKt" + + dependencies { + implementation project(":codegen") + implementation 'org.postgresql:postgresql:42.2.14' + implementation "com.github.ajalt:clikt:2.7.1" + } +} diff --git a/cli/src/main/kotlin/norm/api/NormApi.kt b/cli/src/main/kotlin/norm/api/NormApi.kt new file mode 100644 index 0000000..f6133fe --- /dev/null +++ b/cli/src/main/kotlin/norm/api/NormApi.kt @@ -0,0 +1,25 @@ +package norm.api + +import norm.analyzer.SqlAnalyzer +import norm.codegen.CodeGenerator +import java.sql.Connection + +/** + * Initialize with a given postgres connection + * + * Generates code of classes against given SQL Query + */ +class NormApi( + connection: Connection +) { + private val sqlAnalyzer = SqlAnalyzer(connection) + private val codeGenerator = CodeGenerator() + + /** + * Generates code of classes against given SQL Query + */ + fun generate(query: String, packageName: String, baseName: String): String { + val sqlModel = sqlAnalyzer.sqlModel(query) + return codeGenerator.generate(sqlModel, packageName, baseName) + } +} diff --git a/cli/src/main/kotlin/norm/cli/NormCli.kt b/cli/src/main/kotlin/norm/cli/NormCli.kt new file mode 100644 index 0000000..0f00b60 --- /dev/null +++ b/cli/src/main/kotlin/norm/cli/NormCli.kt @@ -0,0 +1,91 @@ +package norm.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.arguments.multiple +import com.github.ajalt.clikt.parameters.arguments.unique +import com.github.ajalt.clikt.parameters.options.* +import com.github.ajalt.clikt.parameters.types.file +import norm.api.NormApi +import norm.fs.IO +import norm.fs.globSearch +import norm.util.withPGConnection +import java.io.File +import kotlin.system.exitProcess + + +/** + * Entry point - The main function + */ +fun main(args: Array) = NormCli().main(args) + + +/** + * Implementation of CLI using Norm API + * + * Can use env variable to pass in sensitive information + */ +class NormCli : CliktCommand( // command name is inferred as norm-cli + name = "norm-codegen", + help = """ + Generates Kotlin Source files for given SQL files using the Postgres database connection + """ +) { + + private val jdbcUrl by option("-j", "--jdbc-url", help = "JDBC connection URL (can use env var PG_JDBC_URL)", envvar = "PG_JDBC_URL") + .default("jdbc:postgresql://localhost/postgres") + + private val username by option("-u", "--username", help = "Username (can use env var PG_USERNAME)", envvar = "PG_USERNAME") + .default("postgres") + + private val password by option("-p", "--password", help = "Password (can use env var PG_PASSWORD)", envvar = "PG_PASSWORD") + .default("") + + private val basePath by option("-b", "--base-path", help = " relative path from this dir will be used to infer package name") + .file(canBeFile = false, canBeDir = true, mustExist = true) + .default(File(".")) // Current working dir + + private val inputFilesAsOpts by option("-f", "--file", help = "[Multiple] SQL files, the file path relative to base path (-b) will be used to infer package name") + .file(canBeFile = true, canBeDir = false, mustExist = true) + .multiple() + .unique() + + private val sqlFiles by argument() // give meaningful name for CLI help message + .file(canBeFile = true, canBeDir = false, mustExist = true) + .multiple() + .unique() + + private val inputDir by option("-d", "--in-dir", help = "Dir containing .sql files, relative path from this dir will be used to infer package name") + .file(canBeFile = false, canBeDir = true, mustExist = true) + + private val outDir by option("-o", "--out-dir", help = "Output dir where source should be generated") + .file(canBeFile = false, canBeDir = true, mustExist = true) + .required() + + + override fun run() { + try { + withPGConnection(jdbcUrl, username, password) { connection -> + val normApi = NormApi(connection) + + // If dir is provided, relativize to itself + inputDir?.let { dir -> + globSearch(dir, "**.sql").forEach { sqlFile -> + IO(sqlFile, dir, outDir).process(normApi::generate) + } + } + + // if file list is explicitly provided, it is relative to basePath + (inputFilesAsOpts + sqlFiles).forEach { sqlFile -> + IO(sqlFile, basePath, outDir).process(normApi::generate) + } + } + exitProcess(0) + } catch (e: Exception) { + e.printStackTrace() + exitProcess(1) + } + } +} + + diff --git a/cli/src/main/kotlin/norm/fs/IO.kt b/cli/src/main/kotlin/norm/fs/IO.kt new file mode 100644 index 0000000..661a75e --- /dev/null +++ b/cli/src/main/kotlin/norm/fs/IO.kt @@ -0,0 +1,25 @@ +package norm.fs + +import norm.util.toTitleCase +import java.io.File + +class IO( + private val sqlFile: File, + baseDir: File, + outDir: File +) { + private val sqlFileRelativeToSource = sqlFile.relativeTo(baseDir) + private val nameWithoutExtension = sqlFileRelativeToSource.nameWithoutExtension + private val parentPath = sqlFileRelativeToSource.parent + private val packageName = parentPath.replace(File.separator, ".") + private val baseName = toTitleCase(nameWithoutExtension) + private val outFileParentDir = File(outDir, parentPath) + private val outputFile = File(outFileParentDir, "$baseName.kt") + + fun process(block: (query: String, packageName: String, baseName: String) -> String) { + outFileParentDir.mkdirs() + println("will write to $outputFile") + outputFile.writeText(block(sqlFile.readText(), packageName, baseName)) + } +} + diff --git a/cli/src/main/kotlin/norm/fs/Search.kt b/cli/src/main/kotlin/norm/fs/Search.kt new file mode 100644 index 0000000..d17a86f --- /dev/null +++ b/cli/src/main/kotlin/norm/fs/Search.kt @@ -0,0 +1,18 @@ +package norm.fs + +import java.io.File +import java.nio.file.FileSystems + +/** + * Searches within a directory using glob style patterns + * + * example "**.txt" + * + * @param root must be Root Directory in which search will be performed + * @param pattern must be valid Glob Pattern + * + */ +fun globSearch(root: File, pattern: String): Sequence { + val pathMatcher = FileSystems.getDefault().getPathMatcher("glob:$pattern")!! + return root.walkTopDown().filter { file -> pathMatcher.matches(file.toPath()) } +} diff --git a/cli/src/main/kotlin/norm/util/PG.kt b/cli/src/main/kotlin/norm/util/PG.kt new file mode 100644 index 0000000..2ca7517 --- /dev/null +++ b/cli/src/main/kotlin/norm/util/PG.kt @@ -0,0 +1,14 @@ +package norm.util + +import org.postgresql.ds.PGSimpleDataSource +import java.sql.Connection + +/** + * provides connection to the callback and closes it when done, this way code block does not need to manage connection + */ +fun withPGConnection(url: String, username: String, password: String, fn: (Connection) -> Unit) = + PGSimpleDataSource().also { + it.setUrl(url) // url is not a property + it.user = username + it.password = password + }.connection.use(fn) diff --git a/codegen/src/test/java/exit/assertion/ExitAssertions.java b/cli/src/test/java/exit/assertion/ExitAssertions.java similarity index 100% rename from codegen/src/test/java/exit/assertion/ExitAssertions.java rename to cli/src/test/java/exit/assertion/ExitAssertions.java diff --git a/codegen/src/test/kotlin/norm/CodeGeneratorTest.kt b/cli/src/test/kotlin/CodeGeneratorTest.kt similarity index 69% rename from codegen/src/test/kotlin/norm/CodeGeneratorTest.kt rename to cli/src/test/kotlin/CodeGeneratorTest.kt index 51fac5a..6bfd500 100644 --- a/codegen/src/test/kotlin/norm/CodeGeneratorTest.kt +++ b/cli/src/test/kotlin/CodeGeneratorTest.kt @@ -1,13 +1,25 @@ -package norm - import io.kotlintest.matchers.string.shouldContain import io.kotlintest.matchers.string.shouldNotContain import io.kotlintest.shouldBe import io.kotlintest.specs.StringSpec +import norm.analyzer.SqlAnalyzer +import norm.codegen.CodeGenerator import org.apache.commons.io.FileUtils import org.junit.ClassRule import org.postgresql.ds.PGSimpleDataSource - +import org.testcontainers.containers.PostgreSQLContainer +import java.sql.Connection + +fun codegen( + connection: Connection, + query: String, + packageName: String, + baseName: String +): String { + val sqlModel = SqlAnalyzer(connection).sqlModel(query) + + return CodeGenerator().generate(sqlModel, packageName, baseName) +} class CodeGeneratorTest : StringSpec() { @@ -24,9 +36,43 @@ class CodeGeneratorTest : StringSpec() { "Query class generator" { dataSource.connection.use { - val expectedFileContent = FileUtils.getFile( "src", "test", "resources", "generated/employee-query").readText().trimIndent() val generatedFileContent = codegen(it, "select * from employees where first_name = :name order by :field", "com.foo", "Foo").trimIndent() - generatedFileContent shouldBe expectedFileContent + generatedFileContent shouldContain """ +data class FooParams( + val name: String?, + val field: String? +) + +class FooParamSetter : ParamSetter { + override fun map(ps: PreparedStatement, params: FooParams) { + ps.setObject(1, params.name) + ps.setObject(2, params.field) + } +} + +data class FooResult( + val id: Int, + val firstName: String?, + val lastName: String? +) + +class FooRowMapper : RowMapper { + override fun map(rs: ResultSet): FooResult = FooResult( + id = rs.getObject("id") as kotlin.Int, + firstName = rs.getObject("first_name") as kotlin.String?, + lastName = rs.getObject("last_name") as kotlin.String?) +} + +class FooQuery : Query { + override val sql: String = "select * from employees where first_name = ? order by ?" + + override val mapper: RowMapper = FooRowMapper() + + override val paramSetter: ParamSetter = FooParamSetter() +} + """.trimIndent() + + } } @@ -92,6 +138,20 @@ class CodeGeneratorTest : StringSpec() { } } + "should correctly map array columns"{ + + dataSource.connection.use { + val generatedFileContent = codegen(it, "select * from owners", "com.foo", "Foo") + generatedFileContent shouldContain "class FooRowMapper : RowMapper {\n" + + " override fun map(rs: ResultSet): FooResult = FooResult(\n" + + " id = rs.getObject(\"id\") as kotlin.Int,\n" + + " colors = rs.getArray(\"colors\").array as kotlin.Array?,\n" + + " details = rs.getObject(\"details\") as org.postgresql.util.PGobject?)\n" + + "}" + println(generatedFileContent) + } + } + "should generate empty params class if inputs params are not present" { dataSource.connection.use { val generatedFileContent = codegen(it, "select * from employees", "com.foo", "Foo") diff --git a/codegen/src/test/kotlin/norm/NormGenerationTest.kt b/cli/src/test/kotlin/NormGenerationTest.kt similarity index 54% rename from codegen/src/test/kotlin/norm/NormGenerationTest.kt rename to cli/src/test/kotlin/NormGenerationTest.kt index 0968da0..c6fbcc3 100644 --- a/codegen/src/test/kotlin/norm/NormGenerationTest.kt +++ b/cli/src/test/kotlin/NormGenerationTest.kt @@ -1,5 +1,3 @@ -package norm - import exit.assertion.ExitAssertions import io.kotlintest.Spec import io.kotlintest.TestCase @@ -7,6 +5,7 @@ import io.kotlintest.TestResult import io.kotlintest.matchers.string.shouldContain import io.kotlintest.shouldBe import io.kotlintest.specs.StringSpec +import norm.cli.main import org.junit.ClassRule import org.testcontainers.containers.PostgreSQLContainer import java.io.ByteArrayOutputStream @@ -16,16 +15,19 @@ import java.io.PrintStream class MyPostgreSQLContainer : PostgreSQLContainer() + +fun toArgs(str: String): Array = str.split(" ").toTypedArray() + class NormGenerationTest : StringSpec() { @ClassRule private val postgreSQLContainer = MyPostgreSQLContainer().withInitScript("init_postgres.sql") - private val outputDir = "src/test/resources/gen" + private val outputDir = "build/gen" private val byteArrayOutputStream = ByteArrayOutputStream() override fun beforeSpec(spec: Spec) { super.beforeSpec(spec) - System.setOut(PrintStream(byteArrayOutputStream)) + System.setErr(PrintStream(byteArrayOutputStream)) } override fun beforeTest(testCase: TestCase) { @@ -35,52 +37,67 @@ class NormGenerationTest : StringSpec() { override fun afterSpec(spec: Spec) { super.afterSpec(spec) - System.setOut(System.out) + System.setErr(System.err) } override fun afterTest(testCase: TestCase, result: TestResult) { super.afterTest(testCase, result) File(outputDir).deleteRecursively() - System.clearProperty("scripts") byteArrayOutputStream.reset() } init { postgreSQLContainer.start() + val url =postgreSQLContainer.jdbcUrl + val username = postgreSQLContainer.username + val password = postgreSQLContainer.password + + val pgStr = "-j $url -u $username -p $password" + "should generate kotlin file for all sql files" { - val args = arrayOf("src/test/resources/sql", outputDir, postgreSQLContainer.jdbcUrl, postgreSQLContainer.username, postgreSQLContainer.password) + val args = toArgs("-d src/test/resources/sql -b src/test/resources/sql -o $outputDir $pgStr") - ExitAssertions.assertExits(0) { main(args) } + ExitAssertions.assertExits(1) { main(args) } File("$outputDir/employees/AddNewEmployee.kt").exists() shouldBe true File("$outputDir/departments/AddNewDepartment.kt").exists() shouldBe true + + val out = byteArrayOutputStream.toString() + println(out) + } "should generate kotlin file for specified files only" { - val args = arrayOf("src/test/resources/sql", outputDir, postgreSQLContainer.jdbcUrl, postgreSQLContainer.username, postgreSQLContainer.password) - System.setProperty("scripts", "src/test/resources/sql/employees/add-new-employee.sql") + val args = toArgs("-f src/test/resources/sql/employees/add-new-employee.sql -b src/test/resources/sql/ -o $outputDir $pgStr" ) - ExitAssertions.assertExits(0) { main(args) } + ExitAssertions.assertExits(1) { main(args) } File("$outputDir/employees/AddNewEmployee.kt").exists() shouldBe true File("$outputDir/departments/AddNewDepartment.kt").exists() shouldBe false + + val out = byteArrayOutputStream.toString() + println(out) } "should exit when source directory is not a directory" { - val args = arrayOf("src/test/resources/init_postgres.sql", outputDir, postgreSQLContainer.jdbcUrl, postgreSQLContainer.username, postgreSQLContainer.password) + val args = toArgs("-d src/test/resources/init_postgres.sql -o $outputDir $pgStr") - ExitAssertions.assertExits(0) { main(args) } + ExitAssertions.assertExits(1) { main(args) } - byteArrayOutputStream.toString() shouldContain "invalid source or output directory" + val out = byteArrayOutputStream.toString() + println(out) + out shouldContain "Error: Invalid value for \"-d\":" } "should exit when output directory is not a directory" { - val args = arrayOf("src/test/resources/sql", "src/test/resources/init_postgres.sql", postgreSQLContainer.jdbcUrl, postgreSQLContainer.username, postgreSQLContainer.password) + val args = toArgs("-f src/test/resources/sql -o src/test/resources/init_postgres.sql $pgStr") - ExitAssertions.assertExits(0) { main(args) } + ExitAssertions.assertExits(1) { main(args) } - byteArrayOutputStream.toString() shouldContain "invalid source or output directory" + val out = byteArrayOutputStream.toString() + println(out) + out shouldContain "Error: Invalid value for \"-f\":" } } } diff --git a/cli/src/test/resources/init_postgres.sql b/cli/src/test/resources/init_postgres.sql new file mode 100644 index 0000000..fd81527 --- /dev/null +++ b/cli/src/test/resources/init_postgres.sql @@ -0,0 +1,21 @@ +CREATE TABLE employees ( + id SERIAL PRIMARY KEY, + first_name varchar, + last_name varchar +); + +CREATE TABLE departments ( + id SERIAL PRIMARY KEY, + name varchar +); + +CREATE TABLE combinations( + id serial PRIMARY KEY, + colors varchar[] +); + +CREATE TABLE owners( + id serial PRIMARY KEY, + colors varchar[], + details jsonb +); diff --git a/codegen/src/test/resources/sql/departments/add-new-department.sql b/cli/src/test/resources/sql/departments/add-new-department.sql similarity index 100% rename from codegen/src/test/resources/sql/departments/add-new-department.sql rename to cli/src/test/resources/sql/departments/add-new-department.sql diff --git a/codegen/src/test/resources/sql/employees/add-new-employee.sql b/cli/src/test/resources/sql/employees/add-new-employee.sql similarity index 100% rename from codegen/src/test/resources/sql/employees/add-new-employee.sql rename to cli/src/test/resources/sql/employees/add-new-employee.sql diff --git a/codegen/src/main/kotlin/norm/Functions.kt b/codegen/src/main/kotlin/norm/Functions.kt deleted file mode 100644 index 30c56bf..0000000 --- a/codegen/src/main/kotlin/norm/Functions.kt +++ /dev/null @@ -1,20 +0,0 @@ -package norm - -import org.apache.commons.text.CaseUtils -import java.io.File -import java.nio.file.FileSystems - -fun toCamelCase(s: String): String = CaseUtils.toCamelCase(s, false, '_', '-', ' ') - -fun toTitleCase(s: String): String = CaseUtils.toCamelCase(s, true, '_', '-') - -fun getSqlFilesToCompile(sourceDir: File): Sequence { - val sqlScripts = System.getProperty("scripts") - return sqlScripts?.split(",")?.map { File(it) }?.asSequence() - ?: globSearch(sourceDir, "**.sql") -} - -fun globSearch(root: File, pattern: String): Sequence { - val pathMatcher = FileSystems.getDefault().getPathMatcher("glob:$pattern")!! - return root.walkTopDown().filter { file -> pathMatcher.matches(file.toPath()) } -} diff --git a/codegen/src/main/kotlin/norm/Main.kt b/codegen/src/main/kotlin/norm/Main.kt deleted file mode 100644 index 4086549..0000000 --- a/codegen/src/main/kotlin/norm/Main.kt +++ /dev/null @@ -1,60 +0,0 @@ -package norm - -import org.postgresql.ds.PGSimpleDataSource -import java.io.File -import java.sql.Connection -import kotlin.system.exitProcess - -fun main(args: Array) { - if (args.isNotEmpty()) { - try { - val sourceDir = File(args[0]) - val outputDir = File(args[1]) - - val dataSource = PGSimpleDataSource().also { - it.setUrl(args.getOrElse(2) { "jdbc:postgresql://localhost/postgres" }) - it.user = args.getOrElse(3) { "postgres" } - it.password = args.getOrNull(4) - } - - println(sourceDir) - println(outputDir) - - if (!sourceDir.isDirectory || !outputDir.isDirectory) { - println("invalid source or output directory") - } - - getSqlFilesToCompile(sourceDir) - .forEach { sqlFile -> - val sqlFileRelativeToSource = sqlFile.relativeTo(sourceDir) - val nameWithoutExtension = sqlFileRelativeToSource.nameWithoutExtension - val innerPath = sqlFileRelativeToSource.parent - val packageName = innerPath.replace(File.separator, ".") - val baseName = toTitleCase(nameWithoutExtension) - - dataSource.connection.use { connection -> - val generatedFileContent = codegen(connection, sqlFile.readText(), packageName, baseName) - val outFileParentDir = File(outputDir, innerPath) - outFileParentDir.mkdirs() - File(outFileParentDir, "$baseName.kt").writeText(generatedFileContent) - } - } - } catch (e: Exception) { - println(e.message) - println(e.stackTrace) - } finally { - exitProcess(0) - } - } -} - -fun codegen( - connection: Connection, - query: String, - packageName: String, - baseName: String -): String { - val sqlModel = SqlAnalyzer(connection).sqlModel(query) - - return CodeGenerator().generate(sqlModel, packageName, baseName) -} diff --git a/codegen/src/main/kotlin/norm/SqlAnalyzer.kt b/codegen/src/main/kotlin/norm/analyzer/SqlAnalyzer.kt similarity index 55% rename from codegen/src/main/kotlin/norm/SqlAnalyzer.kt rename to codegen/src/main/kotlin/norm/analyzer/SqlAnalyzer.kt index 3eb60e8..e6c8566 100644 --- a/codegen/src/main/kotlin/norm/SqlAnalyzer.kt +++ b/codegen/src/main/kotlin/norm/analyzer/SqlAnalyzer.kt @@ -1,33 +1,13 @@ -package norm +package norm.analyzer +import norm.model.ColumnModel +import norm.model.ParamModel +import norm.model.SqlModel +import norm.util.toCamelCase import java.sql.Connection import java.sql.ParameterMetaData import java.sql.ResultSetMetaData -data class TableModel( - val columns: List, - val primaryKeys: List -) - -data class ColumnModel( - val fieldName: String, - val colType: String, - val colName: String, - val isNullable: Boolean -) - -data class ParamModel( - val name: String, - val dbType: String, - val isNullable: Boolean -) - -data class SqlModel( - val params: List, - val cols: List, - val preparableStatement: String -) - /** * Can Analyze the Tables and PreparedStatements without executing Queries. */ @@ -43,9 +23,9 @@ class SqlAnalyzer(private val connection: Connection) { val parameterMetaData = preparedStatement.parameterMetaData val params = (1..parameterMetaData.parameterCount).map { ParamModel( - paramNames[it - 1].substring(1), - parameterMetaData.getParameterTypeName(it), // db type - parameterMetaData.isNullable(it) != ParameterMetaData.parameterNoNulls + paramNames[it - 1].substring(1), + parameterMetaData.getParameterTypeName(it), // db type + parameterMetaData.isNullable(it) != ParameterMetaData.parameterNoNulls ) } @@ -54,10 +34,10 @@ class SqlAnalyzer(private val connection: Connection) { val res = if (resultSetMetaData != null) { // it is a query (1..resultSetMetaData.columnCount).map { ColumnModel( - toCamelCase(resultSetMetaData.getColumnName(it)), - resultSetMetaData.getColumnTypeName(it), - resultSetMetaData.getColumnName(it), - resultSetMetaData.isNullable(it) != ResultSetMetaData.columnNoNulls + toCamelCase(resultSetMetaData.getColumnName(it)), + resultSetMetaData.getColumnTypeName(it), + resultSetMetaData.getColumnName(it), + resultSetMetaData.isNullable(it) != ResultSetMetaData.columnNoNulls ) } } else { // it is a command diff --git a/codegen/src/main/kotlin/norm/TableAnalyzer.kt b/codegen/src/main/kotlin/norm/analyzer/TableAnalyzer.kt similarity index 66% rename from codegen/src/main/kotlin/norm/TableAnalyzer.kt rename to codegen/src/main/kotlin/norm/analyzer/TableAnalyzer.kt index 663a65f..888fe7a 100644 --- a/codegen/src/main/kotlin/norm/TableAnalyzer.kt +++ b/codegen/src/main/kotlin/norm/analyzer/TableAnalyzer.kt @@ -1,5 +1,9 @@ -package norm +package norm.analyzer +import norm.model.ColumnModel +import norm.model.TableModel +import norm.util.toCamelCase +import norm.toList import java.sql.Connection class TableAnalyzer(private val connection: Connection) { @@ -17,10 +21,10 @@ class TableAnalyzer(private val connection: Connection) { private fun fromHashMap(it: Map): ColumnModel { return ColumnModel( - fieldName = toCamelCase(it["COLUMN_NAME"].toString()), - colType = it["TYPE_NAME"].toString(), - isNullable = true, - colName = it["COLUMN_NAME"].toString() + fieldName = toCamelCase(it["COLUMN_NAME"].toString()), + colType = it["TYPE_NAME"].toString(), + isNullable = true, + colName = it["COLUMN_NAME"].toString() ) } } diff --git a/codegen/src/main/kotlin/norm/CodeGenerator.kt b/codegen/src/main/kotlin/norm/codegen/CodeGenerator.kt similarity index 97% rename from codegen/src/main/kotlin/norm/CodeGenerator.kt rename to codegen/src/main/kotlin/norm/codegen/CodeGenerator.kt index 71d89ce..c614e92 100644 --- a/codegen/src/main/kotlin/norm/CodeGenerator.kt +++ b/codegen/src/main/kotlin/norm/codegen/CodeGenerator.kt @@ -1,7 +1,11 @@ -package norm +package norm.codegen import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import norm.model.ColumnModel +import norm.model.ParamModel +import norm.model.SqlModel +import norm.typemapper.DbToKtDefaultTypeMapper class CodeGenerator(private val typeMapper: DbToKtDefaultTypeMapper = DbToKtDefaultTypeMapper()) { @@ -97,7 +101,7 @@ class CodeGenerator(private val typeMapper: DbToKtDefaultTypeMapper = DbToKtDefa val constructArgs = "\n" + cols.joinToString(",\n ") { if (it.colType.startsWith("_")) - "${it.fieldName} = rs.getArray(\"${it.colName}\").array as ${getTypeName(it)}>" + "${it.fieldName} = rs.getArray(\"${it.colName}\").array as ${getTypeName(it)}" else "${it.fieldName} = rs.getObject(\"${it.colName}\") as ${getTypeName(it)}" } diff --git a/codegen/src/main/kotlin/norm/model/ColumnModel.kt b/codegen/src/main/kotlin/norm/model/ColumnModel.kt new file mode 100644 index 0000000..f4370a3 --- /dev/null +++ b/codegen/src/main/kotlin/norm/model/ColumnModel.kt @@ -0,0 +1,8 @@ +package norm.model + +data class ColumnModel( + val fieldName: String, + val colType: String, + val colName: String, + val isNullable: Boolean +) diff --git a/codegen/src/main/kotlin/norm/model/ParamModel.kt b/codegen/src/main/kotlin/norm/model/ParamModel.kt new file mode 100644 index 0000000..1f6d453 --- /dev/null +++ b/codegen/src/main/kotlin/norm/model/ParamModel.kt @@ -0,0 +1,7 @@ +package norm.model + +data class ParamModel( + val name: String, + val dbType: String, + val isNullable: Boolean +) diff --git a/codegen/src/main/kotlin/norm/model/SqlModel.kt b/codegen/src/main/kotlin/norm/model/SqlModel.kt new file mode 100644 index 0000000..6463f7c --- /dev/null +++ b/codegen/src/main/kotlin/norm/model/SqlModel.kt @@ -0,0 +1,7 @@ +package norm.model + +data class SqlModel( + val params: List, + val cols: List, + val preparableStatement: String +) diff --git a/codegen/src/main/kotlin/norm/model/TableModel.kt b/codegen/src/main/kotlin/norm/model/TableModel.kt new file mode 100644 index 0000000..30684ec --- /dev/null +++ b/codegen/src/main/kotlin/norm/model/TableModel.kt @@ -0,0 +1,6 @@ +package norm.model + +data class TableModel( + val columns: List, + val primaryKeys: List +) diff --git a/codegen/src/main/kotlin/norm/DbToKtDefaultTypeMapper.kt b/codegen/src/main/kotlin/norm/typemapper/DbToKtDefaultTypeMapper.kt similarity index 95% rename from codegen/src/main/kotlin/norm/DbToKtDefaultTypeMapper.kt rename to codegen/src/main/kotlin/norm/typemapper/DbToKtDefaultTypeMapper.kt index 5989dc7..6724690 100644 --- a/codegen/src/main/kotlin/norm/DbToKtDefaultTypeMapper.kt +++ b/codegen/src/main/kotlin/norm/typemapper/DbToKtDefaultTypeMapper.kt @@ -1,6 +1,5 @@ -package norm +package norm.typemapper -import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.asTypeName import org.postgresql.util.PGobject diff --git a/codegen/src/main/kotlin/norm/util/Functions.kt b/codegen/src/main/kotlin/norm/util/Functions.kt new file mode 100644 index 0000000..a80e4ae --- /dev/null +++ b/codegen/src/main/kotlin/norm/util/Functions.kt @@ -0,0 +1,8 @@ +package norm.util + +import org.apache.commons.text.CaseUtils + +fun toCamelCase(s: String): String = CaseUtils.toCamelCase(s, false, '_', '-', ' ') + +fun toTitleCase(s: String): String = CaseUtils.toCamelCase(s, true, '_', '-') + diff --git a/codegen/src/test/resources/generated/employee-query b/codegen/src/test/resources/generated/employee-query deleted file mode 100644 index b703c2b..0000000 --- a/codegen/src/test/resources/generated/employee-query +++ /dev/null @@ -1,42 +0,0 @@ -package com.foo - -import java.sql.PreparedStatement -import java.sql.ResultSet -import kotlin.Int -import kotlin.String -import norm.ParamSetter -import norm.Query -import norm.RowMapper - -data class FooParams( - val name: String?, - val field: String? -) - -class FooParamSetter : ParamSetter { - override fun map(ps: PreparedStatement, params: FooParams) { - ps.setObject(1, params.name) - ps.setObject(2, params.field) - } -} - -data class FooResult( - val id: Int, - val firstName: String?, - val lastName: String? -) - -class FooRowMapper : RowMapper { - override fun map(rs: ResultSet): FooResult = FooResult( - id = rs.getObject("id") as kotlin.Int, - firstName = rs.getObject("first_name") as kotlin.String?, - lastName = rs.getObject("last_name") as kotlin.String?) -} - -class FooQuery : Query { - override val sql: String = "select * from employees where first_name = ? order by ?" - - override val mapper: RowMapper = FooRowMapper() - - override val paramSetter: ParamSetter = FooParamSetter() -} diff --git a/codegen/src/test/resources/init_postgres.sql b/codegen/src/test/resources/init_postgres.sql deleted file mode 100644 index a507780..0000000 --- a/codegen/src/test/resources/init_postgres.sql +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE employees ( - id SERIAL PRIMARY KEY, - first_name varchar, - last_name varchar -); - -CREATE TABLE departments ( - id SERIAL PRIMARY KEY, - name varchar -); - -CREATE TABLE combinations( -id serial PRIMARY KEY, -colors varchar[]); - -CREATE TABLE owners( -id serial PRIMARY KEY, -colors varchar[], -details jsonb -); diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index cc4fdc2..62d4c05 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 562e2c8..ac33e99 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5.1-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 2fe81a7..fbd7c51 100755 --- a/gradlew +++ b/gradlew @@ -82,6 +82,7 @@ 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 @@ -129,6 +130,7 @@ fi if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then APP_HOME=`cygpath --path --mixed "$APP_HOME"` CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` # We build the pattern for arguments to be converted via cygpath diff --git a/gradlew.bat b/gradlew.bat index 9618d8d..5093609 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -29,6 +29,9 @@ 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" @@ -81,6 +84,7 @@ set CMD_LINE_ARGS=%* 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 %CMD_LINE_ARGS% diff --git a/runtime/src/main/kotlin/norm/ConnectionExtensions.kt b/runtime/src/main/kotlin/norm/ConnectionExtensions.kt new file mode 100644 index 0000000..85f0152 --- /dev/null +++ b/runtime/src/main/kotlin/norm/ConnectionExtensions.kt @@ -0,0 +1,29 @@ +package norm + +import java.sql.Connection +import java.sql.ResultSet + +/* + * Makes working with JDBC/Postgres less terrible + * with some inspiration from now deprecated: https://github.com/Kotlin/obsolete-kotlin-jdbc + */ + + +fun Connection.executeCommand(sql: String, params: List = listOf()): Int = + this.prepareStatement(sql) + .withParams(params) + .use { it.executeUpdate() } // auto-close ps + +fun Connection.batchExecuteCommand(sql: String, batchedParams: List> = listOf()): List = + this.prepareStatement(sql) + .withBatches(batchedParams) + .use { it.executeBatch() } + .toList() + +fun Connection.executeQuery(sql: String, params: List = listOf()): ResultSet = + this.prepareStatement(sql) + .withParams(params) + .executeQuery() + +fun Connection.query(sql: String, vararg params: Any) = + this.executeQuery(sql, params.toList()).toList() diff --git a/runtime/src/main/kotlin/norm/DataSoruceExtensions.kt b/runtime/src/main/kotlin/norm/DataSoruceExtensions.kt new file mode 100644 index 0000000..93e4780 --- /dev/null +++ b/runtime/src/main/kotlin/norm/DataSoruceExtensions.kt @@ -0,0 +1,12 @@ +package norm + +import javax.sql.DataSource + +fun DataSource.map(sql: String, vararg params: Any, mapper: (Map) -> T): List = + this.query(sql, *params).map(mapper) + +fun DataSource.query(sql: String, vararg params: Any) = + this.connection.use { it.executeQuery(sql, params.toList()).toList() } + +fun DataSource.command(sql: String, vararg params: Any) = + this.connection.use { it.executeCommand(sql, params.toList()) } diff --git a/runtime/src/main/kotlin/norm/PreparedStatementExtensions.kt b/runtime/src/main/kotlin/norm/PreparedStatementExtensions.kt new file mode 100644 index 0000000..72ae9b1 --- /dev/null +++ b/runtime/src/main/kotlin/norm/PreparedStatementExtensions.kt @@ -0,0 +1,17 @@ +package norm + +import java.sql.PreparedStatement + +// TODO - handle prepareStatements Failure as well + +fun PreparedStatement.withParams(params: List = listOf()): PreparedStatement = + this.also { self -> + params.forEachIndexed { index, param -> self.setObject(index + 1, param) } + } + +fun PreparedStatement.withBatches(batchedParams: List> = listOf()) = + this.also { ps -> + batchedParams.forEach { params -> + ps.withParams(params).addBatch() + } + } diff --git a/runtime/src/main/kotlin/norm/ResultSetExtensions.kt b/runtime/src/main/kotlin/norm/ResultSetExtensions.kt new file mode 100644 index 0000000..f56ec1d --- /dev/null +++ b/runtime/src/main/kotlin/norm/ResultSetExtensions.kt @@ -0,0 +1,22 @@ +package norm + +import java.sql.ResultSet + +operator fun ResultSet.get(columnId: Int): Any? = this.getObject(columnId) + +operator fun ResultSet.get(columnName: String): Any? = this.getObject(columnName) // TODO specific type methods instead of getObject + +fun ResultSet.getColumnNames(): List = + (1..metaData.columnCount).map { metaData.getColumnName(it) } + +fun ResultSet.rowAsList(columnNames: List = getColumnNames()): List = // TODO Can overload for column indexes instead of name + columnNames.map { this[it] } + +fun ResultSet.rowAsMap(columnNames: List = getColumnNames()): Map = + columnNames.map { it to this[it] }.toMap() + +fun ResultSet.toList(columnNames: List = getColumnNames()): List> = + this.use { generateSequence { if (this.next()) this.rowAsMap(columnNames) else null }.toList() } + +fun ResultSet.toTable(columnNames: List = getColumnNames()): List> = + this.use { generateSequence { if (this.next()) this.rowAsList(columnNames) else null }.toList() } diff --git a/runtime/src/main/kotlin/norm/SqlExtensions.kt b/runtime/src/main/kotlin/norm/SqlExtensions.kt deleted file mode 100644 index 0be07b6..0000000 --- a/runtime/src/main/kotlin/norm/SqlExtensions.kt +++ /dev/null @@ -1,76 +0,0 @@ -package norm - -import java.sql.Connection -import java.sql.PreparedStatement -import java.sql.ResultSet -import javax.sql.DataSource - -/** - * Makes working with JDBC/Postgres less terrible - * - * - * with some inspiration from now deprecated: https://github.com/Kotlin/obsolete-kotlin-jdbc - */ - - -operator fun ResultSet.get(columnId: Int): Any? = this.getObject(columnId) - -operator fun ResultSet.get(columnName: String): Any? = this.getObject(columnName) // TODO specific type methods instead of getObject - -fun ResultSet.getColumnNames(): List = - (1..metaData.columnCount).map { metaData.getColumnName(it) } - -fun ResultSet.rowAsList(columnNames: List = getColumnNames()): List = // TODO Can overload for column indexes instead of name - columnNames.map { this[it] } - -fun ResultSet.rowAsMap(columnNames: List = getColumnNames()): Map = - columnNames.map { it to this[it] }.toMap() - -fun ResultSet.toList(columnNames: List = getColumnNames()): List> = - this.use { generateSequence { if (this.next()) this.rowAsMap(columnNames) else null }.toList() } - -fun ResultSet.toTable(columnNames: List = getColumnNames()): List> = - this.use { generateSequence { if (this.next()) this.rowAsList(columnNames) else null }.toList() } - - -// TODO - handle prepareStatements Failure as well - -fun PreparedStatement.withParams(params: List = listOf()): PreparedStatement = - this.also { self -> - params.forEachIndexed { index, param -> self.setObject(index + 1, param) } - } - -fun PreparedStatement.withBatches(batchedParams: List> = listOf()) = - this.also { ps -> - batchedParams.forEach { params -> - ps.withParams(params).addBatch() - } - } - -fun Connection.executeCommand(sql: String, params: List = listOf()): Int = - this.prepareStatement(sql) - .withParams(params) - .use { it.executeUpdate() } // auto-close ps - -fun Connection.batchExecuteCommand(sql: String, batchedParams: List> = listOf()): List = - this.prepareStatement(sql) - .withBatches(batchedParams) - .use { it.executeBatch() } - .toList() - -fun Connection.executeQuery(sql: String, params: List = listOf()): ResultSet = - this.prepareStatement(sql) - .withParams(params) - .executeQuery() - -fun DataSource.map(sql: String, vararg params: Any, mapper: (Map) -> T): List = - this.query(sql, *params).map(mapper) - -fun Connection.query(sql: String, vararg params: Any) = - this.executeQuery(sql, params.toList()).toList() - -fun DataSource.query(sql: String, vararg params: Any) = - this.connection.use { it.executeQuery(sql, params.toList()).toList() } - -fun DataSource.command(sql: String, vararg params: Any) = - this.connection.use { it.executeCommand(sql, params.toList()) } diff --git a/runtime/src/main/kotlin/norm/TypedSqlExtensions.kt b/runtime/src/main/kotlin/norm/TypedSqlExtensions.kt index 76894e3..ce29139 100644 --- a/runtime/src/main/kotlin/norm/TypedSqlExtensions.kt +++ b/runtime/src/main/kotlin/norm/TypedSqlExtensions.kt @@ -4,6 +4,11 @@ import java.sql.Connection import java.sql.PreparedStatement import java.sql.ResultSet +/** + * Enable Type Safe queries and commands + * + */ + interface RowMapper { fun map(rs: ResultSet): T } @@ -26,17 +31,19 @@ interface Query { data class CommandResult(val updatedRecordsCount: Int) fun ResultSet.toList(mapper: RowMapper): List = - this.use { generateSequence { if (this.next()) mapper.map(this) else null }.toList() } + this.use { generateSequence { if (this.next()) mapper.map(this) else null }.toList() } fun Query.query(connection: Connection, params: P): List = - connection.prepareStatement(sql) - .also { paramSetter.map(it, params) } - .executeQuery() - .toList(mapper) + connection + .prepareStatement(sql) + .also { paramSetter.map(it, params) } + .executeQuery() + .toList(mapper) fun

Command

.command(connection: Connection, params: P): CommandResult = - connection.prepareStatement(sql) - .also { paramSetter.map(it, params) } - .executeUpdate() - .let { CommandResult(it) } + connection + .prepareStatement(sql) + .also { paramSetter.map(it, params) } + .executeUpdate() + .let { CommandResult(it) } diff --git a/settings.gradle b/settings.gradle index 10750cb..c27205d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,4 @@ rootProject.name = 'norm' include 'runtime' include 'codegen' +include 'cli'