diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 64a1b7c..3777ca3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,10 +25,12 @@ jobs: key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} restore-keys: ${{ runner.os }}-m2 - name: Deploy with Maven - run: mvn clean deploy + run: mvn clean deploy --settings .mvn/settings.xml env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + JOOQ_REPO_USERNAME: ${{ secrets.JOOQ_REPO_USERNAME }} + JOOQ_REPO_PASSWORD: ${{ secrets.JOOQ_REPO_PASSWORD }} - name: Archive build output uses: actions/upload-artifact@v4 with: diff --git a/.mvn/settings.xml b/.mvn/settings.xml new file mode 100644 index 0000000..7f7feb2 --- /dev/null +++ b/.mvn/settings.xml @@ -0,0 +1,9 @@ + + + + jooq-pro + ${env.JOOQ_REPO_USERNAME} + ${env.JOOQ_REPO_PASSWORD} + + + diff --git a/CLAUDE.md b/CLAUDE.md index 4df956e..bb79204 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ npm start # Start production server - **Project Reactor** for reactive programming ### Infrastructure Components -The DSL supports: Kafka, HTTP servers, SQL databases (PostgreSQL/MySQL with JOOQ), MongoDB, S3 (via LocalStack), Hazelcast, Avro/Protobuf schemas. +The DSL supports: Kafka, HTTP servers, SQL databases (PostgreSQL/MySQL/Oracle with JOOQ), MongoDB, S3 (via LocalStack), Hazelcast, Avro/Protobuf schemas. ## Code Patterns diff --git a/docs/pages/providers/sql.mdx b/docs/pages/providers/sql.mdx index ea48e64..22f5fb3 100644 --- a/docs/pages/providers/sql.mdx +++ b/docs/pages/providers/sql.mdx @@ -3,6 +3,7 @@ There are various SQL providers available, which all share a common DSL. * `postgres` : Declares an image using the `postgres:13` image by default * `mysql`: Declares an image using the `mysql:9` image by default + * `oracle`: Declares an image using the `gvenzl/oracle-xe:21-slim-faststart` image by default ```kotlin // use the default image. @@ -79,6 +80,41 @@ postgres { } ``` +### Oracle +Oracle works the same way as the other SQL providers, but a few Oracle-specific conventions apply: + + * Use **uppercase** table and column names. Oracle folds unquoted identifiers to uppercase, and the + identifiers in the `data` maps are quoted when the rows are inserted, so they must match. Declaring + `CREATE TABLE USERS (...)` and using keys like `"USERNAME"` keeps the two in sync. + * Use Oracle-native types: `VARCHAR2` rather than `VARCHAR`/`TEXT`, `NUMBER` rather than `INTEGER`/`DECIMAL`, + and `GENERATED ALWAYS AS IDENTITY` rather than `SERIAL`. Oracle (pre-23c) has no `BOOLEAN` type — use + `NUMBER(1)` with `0`/`1` values. + +```kotlin +oracle { + table( + "USERS", """ + CREATE TABLE USERS ( + ID VARCHAR2(36) PRIMARY KEY, + USERNAME VARCHAR2(100) NOT NULL, + IS_ACTIVE NUMBER(1), + LOGIN_COUNT NUMBER(10), + BALANCE NUMBER(10, 2) + ) + """, + data = listOf( + mapOf( + "ID" to UUID.randomUUID().toString(), + "USERNAME" to "john_doe", + "IS_ACTIVE" to 1, + "LOGIN_COUNT" to 5, + "BALANCE" to BigDecimal("100.50") + ) + ) + ) +} +``` + ### Returned values When any database component is declared, the following data is returned: diff --git a/nebula-dsl/pom.xml b/nebula-dsl/pom.xml index fbd4762..8377b9f 100644 --- a/nebula-dsl/pom.xml +++ b/nebula-dsl/pom.xml @@ -160,6 +160,10 @@ org.testcontainers mysql + + org.testcontainers + oracle-xe + org.postgresql postgresql @@ -171,7 +175,12 @@ 8.0.33 - org.jooq + com.oracle.database.jdbc + ojdbc11 + 23.4.0.24.05 + + + org.jooq.pro jooq 3.19.10 diff --git a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/sql/DatabaseExecutor.kt b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/sql/DatabaseExecutor.kt index 4e401fc..ef88ecc 100644 --- a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/sql/DatabaseExecutor.kt +++ b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/sql/DatabaseExecutor.kt @@ -131,15 +131,18 @@ class DatabaseExecutor(private val config: DatabaseConfig, loggers: List - insert.values(row.values.map { convertValue(it) }) + insertedRows += dsl.insertInto(jooqTable) + .columns(jooqColumns) + .values(columns.map { convertValue(row[it]) }) + .execute() } - - val insertedRows = insert.execute() logger.info { "Inserted $insertedRows rows into ${table.name}" } } diff --git a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/sql/SqlDsl.kt b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/sql/SqlDsl.kt index 4fd3cc4..ec8deea 100644 --- a/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/sql/SqlDsl.kt +++ b/nebula-dsl/src/main/kotlin/com/orbitalhq/nebula/sql/SqlDsl.kt @@ -8,6 +8,7 @@ import mu.KotlinLogging import org.jooq.SQLDialect import org.testcontainers.containers.JdbcDatabaseContainer import org.testcontainers.containers.MySQLContainer +import org.testcontainers.containers.OracleContainer import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.utility.DockerImageName @@ -20,6 +21,9 @@ interface SqlDsl : InfraDsl { fun mysql(imageName: String = "mysql:9", databaseName: String = "testDb", componentName: ComponentName = "mysql", dsl: DatabaseBuilder.(KLogger) -> Unit): DatabaseExecutor = database(MySQLContainer(DockerImageName.parse(imageName)), SQLDialect.MYSQL, "mysql", databaseName, componentName, dsl) + fun oracle(imageName: String = "gvenzl/oracle-xe:21-slim-faststart", databaseName: String = "testDb", componentName: ComponentName = "oracle", dsl: DatabaseBuilder.(KLogger) -> Unit): DatabaseExecutor = + database(OracleContainer(DockerImageName.parse(imageName)), SQLDialect.ORACLE, "oracle", databaseName, componentName, dsl) + fun database(container: JdbcDatabaseContainer<*>, dialect: SQLDialect, type: String, databaseName: String, componentName: ComponentName, dsl: DatabaseBuilder.(KLogger) -> Unit): DatabaseExecutor { val builder = DatabaseBuilder(container, dialect, type, databaseName, componentName) builder.dsl(logger) diff --git a/nebula-dsl/src/test/kotlin/com/orbitalhq/nebula/sql/SqlExecutorTest.kt b/nebula-dsl/src/test/kotlin/com/orbitalhq/nebula/sql/SqlExecutorTest.kt index cf8d70c..18bc579 100644 --- a/nebula-dsl/src/test/kotlin/com/orbitalhq/nebula/sql/SqlExecutorTest.kt +++ b/nebula-dsl/src/test/kotlin/com/orbitalhq/nebula/sql/SqlExecutorTest.kt @@ -116,4 +116,92 @@ class SqlExecutorTest : DescribeSpec({ (widget["id"] as Int) shouldBe 1 } } + + describe("Oracle database") { + afterTest { + infra.shutDownAll() + } + + // Oracle folds unquoted identifiers to uppercase and jOOQ quotes them, so tables and + // columns are declared in uppercase here. Oracle types differ from Postgres: no UUID + // (stored as VARCHAR2), no BOOLEAN (NUMBER(1)), and identity columns instead of SERIAL. + it("should create tables and insert data with various types") { + infra = stack { + oracle { + table( + "USERS", """ + CREATE TABLE USERS ( + ID VARCHAR2(36) PRIMARY KEY, + USERNAME VARCHAR2(100) NOT NULL, + IS_ACTIVE NUMBER(1), + LOGIN_COUNT NUMBER(10), + BALANCE NUMBER(10, 2) + ) + """, data = listOf( + mapOf( + "ID" to UUID.randomUUID().toString(), + "USERNAME" to "john_doe", + "IS_ACTIVE" to 1, + "LOGIN_COUNT" to 5, + "BALANCE" to BigDecimal("100.50") + ), + mapOf( + "ID" to UUID.randomUUID().toString(), + "USERNAME" to "jane_smith", + "IS_ACTIVE" to 0, + "LOGIN_COUNT" to 2, + "BALANCE" to BigDecimal("75.25") + ) + ) + ) + + table( + "PRODUCTS", """ + CREATE TABLE PRODUCTS ( + ID NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + NAME VARCHAR2(100) NOT NULL, + DESCRIPTION VARCHAR2(255), + PRICE NUMBER(10, 2) NOT NULL + ) + """, data = listOf( + mapOf( + "NAME" to "Widget", + "DESCRIPTION" to "A fantastic widget", + "PRICE" to BigDecimal("9.99") + ), + mapOf( + "NAME" to "Gadget", + "DESCRIPTION" to "An amazing gadget", + "PRICE" to BigDecimal("24.99") + ) + ) + ) + } + }.start() + + // Set up jOOQ DSL context + dsl = infra.database.single().dsl + + // Test USERS table + val users = dsl.selectFrom("USERS").fetch() + users.size shouldBe 2 + + val johnDoe = users.first { it["USERNAME"] == "john_doe" } + johnDoe["USERNAME"] shouldBe "john_doe" + (johnDoe["IS_ACTIVE"] as Number).toInt() shouldBe 1 + (johnDoe["LOGIN_COUNT"] as Number).toInt() shouldBe 5 + (johnDoe["BALANCE"] as BigDecimal).toDouble() shouldBe 100.50 + + // Test PRODUCTS table + val products = dsl.selectFrom("PRODUCTS").fetch() + products.size shouldBe 2 + + val widget = products.first { it["NAME"] == "Widget" } + widget["DESCRIPTION"] shouldBe "A fantastic widget" + (widget["PRICE"] as BigDecimal).toDouble() shouldBe 9.99 + + // Test auto-incrementing identity column + (widget["ID"] as Number).toInt() shouldBe 1 + } + } }) \ No newline at end of file