diff --git a/.github/workflows/ci-exposed-ydb-dialect.yaml b/.github/workflows/ci-exposed-ydb-dialect.yaml new file mode 100644 index 00000000..9d3c8485 --- /dev/null +++ b/.github/workflows/ci-exposed-ydb-dialect.yaml @@ -0,0 +1,43 @@ +name: Kotlin Exposed YDB Dialect CI with Maven + +on: + push: + branches: + - main + paths: + - 'kotlin-exposed-dialect/**' + - '.github/workflows/ci-exposed-ydb-dialect.yaml' + pull_request: + paths: + - 'kotlin-exposed-dialect/**' + - '.github/workflows/ci-exposed-ydb-dialect.yaml' + +env: + MAVEN_ARGS: --batch-mode --update-snapshots -Dstyle.color=always + +jobs: + build: + name: Kotlin Exposed YDB Dialect + runs-on: ubuntu-latest + + strategy: + matrix: + java: [ '17', '21' ] + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 + with: + java-version: ${{ matrix.java }} + distribution: 'temurin' + cache: maven + + - name: Download dependencies + working-directory: ./kotlin-exposed-dialect + run: mvn $MAVEN_ARGS dependency:go-offline + + - name: Build & verify (unit + integration tests with testcontainers) + working-directory: ./kotlin-exposed-dialect + run: mvn $MAVEN_ARGS verify diff --git a/.github/workflows/publish-kotlin-exposed-dialect.yaml b/.github/workflows/publish-kotlin-exposed-dialect.yaml new file mode 100644 index 00000000..8dcd5339 --- /dev/null +++ b/.github/workflows/publish-kotlin-exposed-dialect.yaml @@ -0,0 +1,83 @@ +name: Publish Kotlin Exposed YDB Dialect + +on: + push: + tags: + - 'kotlin-exposed-ydb/v[0-9]+.[0-9]+.[0-9]+' + +env: + MAVEN_ARGS: --batch-mode --no-transfer-progress -Dstyle.color=always + +jobs: + validate: + name: Validate Kotlin Exposed YDB Dialect + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Extract dialect version + run: | + cd kotlin-exposed-dialect + KOTLIN_EXPOSED_DIALECT_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + echo "KOTLIN_EXPOSED_DIALECT_VERSION=$KOTLIN_EXPOSED_DIALECT_VERSION" >> "$GITHUB_ENV" + + - name: Fail workflow if version is snapshot + if: endsWith(env.KOTLIN_EXPOSED_DIALECT_VERSION, 'SNAPSHOT') + uses: actions/github-script@v6 + with: + script: core.setFailed('SNAPSHOT version cannot be published') + + - name: Fail workflow if version is not equal to tag name + if: format('kotlin-exposed-ydb/v{0}', env.KOTLIN_EXPOSED_DIALECT_VERSION) != github.ref_name + uses: actions/github-script@v6 + with: + script: core.setFailed('Release name must be equal to project version') + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + cache: 'maven' + + - name: Download dependencies + run: | + cd kotlin-exposed-dialect + mvn $MAVEN_ARGS dependency:go-offline + + - name: Build (no integration tests, requires testcontainers) + run: | + cd kotlin-exposed-dialect + mvn $MAVEN_ARGS -DskipITs package + + publish: + name: Publish Kotlin Exposed YDB Dialect + runs-on: ubuntu-latest + needs: validate + + steps: + - name: Install gpg secret key + run: | + cat <(echo -e "${{ secrets.MAVEN_OSSRH_GPG_SECRET_KEY }}") | gpg --batch --import + gpg --list-secret-keys --keyid-format LONG + + - uses: actions/checkout@v4 + + - name: Set up Maven Central Repository + uses: actions/setup-java@v4 + with: + java-version: 17 + distribution: 'temurin' + cache: 'maven' + server-id: ossrh-s01 + server-username: MAVEN_USERNAME + server-password: MAVEN_PASSWORD + + - name: Publish package + run: | + cd kotlin-exposed-dialect + mvn $MAVEN_ARGS -DskipTests -DskipITs -Possrh-s01 -Dgpg.passphrase=${{ secrets.MAVEN_OSSRH_GPG_PASSWORD }} clean deploy + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_OSSRH_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_OSSRH_TOKEN }} diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md new file mode 100644 index 00000000..432783c1 --- /dev/null +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -0,0 +1,22 @@ +## 0.9.0 + +Initial release of the Kotlin Exposed dialect for YDB. + +### Added + +- YDB `VendorDialect` for Exposed JDBC; `registerYdbDialect(enableSignedDatetimes = …)` for setup. +- `ydbTransaction { ... }` — retryable transactions with `readOnly` and `YdbRetryConfig` + (exponential backoff with full/equal jitter; retries classified by JDBC `SQLException` vendor codes). +- Native `UPSERT` / `REPLACE` rendering — wired into Exposed's standard `Table.upsert` and + `Table.replace` DSL. +- `YdbTable` — YQL `CREATE TABLE` with table-level `PRIMARY KEY (…)`, inline secondary indexes + (`secondaryIndex`), and optional row TTL (`ttl` / `YdbTtlColumnMode`); prefer over plain Exposed + `Table` when DDL must be valid on YDB. +- Post-create indexes via Exposed `Table.index()` on any table → `ALTER TABLE … ADD INDEX … GLOBAL`. +- JDBC metadata for reading existing indexes. +- Temporal columns: unsigned (`ydbDate`, …) and signed (`ydbDate32`, …) extensions with + JDBC vendor codes; DDL `sqlType()` derived from the code. No connection-level temporal flag. +- Custom column types for `Decimal`, `Interval`, `Json`, `JsonDocument`, native `Uuid`, + unsigned integers, `Uint64`, plus a `ydbDecimalLiteral` helper for update expressions. +- `Serial` / `BigSerial` via Exposed `autoIncrement()` on `Table`. +- Explicit rejection of ANSI `MERGE` (`UPSERT` covers the use case). diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md new file mode 100644 index 00000000..c6e129d0 --- /dev/null +++ b/kotlin-exposed-dialect/README.md @@ -0,0 +1,272 @@ +# Kotlin Exposed YDB Dialect + +YDB integration for [JetBrains Exposed](https://github.com/JetBrains/Exposed) via JDBC. +The module provides: + +- a Kotlin Exposed `VendorDialect` for YDB (SQL, type mapping, post-create indexes); +- [`createYdbStatement`](src/main/kotlin/tech/ydb/exposed/dialect/createYdbStatement.kt) for YQL `CREATE TABLE` (table-level PK, inline indexes, TTL); +- `Table.upsert` / `Table.replace` DSL backed by native YDB `UPSERT` / `REPLACE`; +- a retryable transaction wrapper that handles YDB's OCC retries transparently. + +## Requirements + +- JDK 17+ +- Maven +- [YDB JDBC driver](https://github.com/ydb-platform/ydb-jdbc-driver) on the application classpath (not bundled with this artifact) +- JetBrains Exposed 1.x + +```xml + + tech.ydb.jdbc + ydb-jdbc-driver + + + + tech.ydb.dialects + kotlin-exposed-ydb-dialect + 0.9.0 + +``` + +## Quick start + +```kotlin +import org.jetbrains.exposed.v1.jdbc.Database +import tech.ydb.exposed.dialect.registerYdbDialect +import tech.ydb.exposed.dialect.ydbTransaction + +registerYdbDialect() // or registerYdbDialect(enableSignedDatetimes = true) + +val db = Database.connect("jdbc:ydb:grpc://localhost:2136/local") + +ydbTransaction(db) { + // Exposed DSL / DAO code +} +``` + +## Defining tables + +YDB requires a table-level `PRIMARY KEY (…)` in `CREATE TABLE`, not `col Type PRIMARY KEY` on a column. +Use [`createYdbStatement`](src/main/kotlin/tech/ydb/exposed/dialect/createYdbStatement.kt) for schema DDL; plain Exposed +`Table` + `SchemaUtils.create` still works for DML/tests but emits inline PK SQL that YDB rejects. + +```kotlin +import tech.ydb.exposed.dialect.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 +import tech.ydb.exposed.dialect.ydbDecimal + +object Products : Table("products") { + val id = integer("id") + val sku = varchar("sku", 64) + val name = varchar("name", 255) + val category = varchar("category", 128) + val price = ydbDecimal("price", precision = 10, scale = 2) + val expiresAt = ydbTimestamp64("expires_at") + + override val primaryKey = PrimaryKey(id) + + init { + // Post-create index (ALTER TABLE … ADD INDEX … GLOBAL) — same as on Exposed Table + index(false, sku) + } + + override fun createStatement(): List = createYdbStatement() +} +``` + +## Insert / upsert / replace / update / delete + +Exposed's standard DSL works as-is. YDB's native `UPSERT` and `REPLACE` are exposed via +the same `Table.upsert` / `Table.replace` extensions Exposed provides for other vendors: + +```kotlin +Products.upsert { + it[id] = 1 + it[sku] = "BOOK-001" + it[name] = "Kotlin in Action" + it[category] = "books" + it[price] = BigDecimal("39.90") +} + +Products.replace { + it[id] = 1 + it[sku] = "BOOK-001" + it[name] = "Kotlin in Action, 2nd edition" + it[category] = "books" + it[price] = BigDecimal("44.90") +} +``` + +ANSI `MERGE` is intentionally rejected — `UPSERT` / `REPLACE` cover the same use cases. + +YDB `UPSERT` writes only the columns listed in the DSL block; on primary-key conflict, other +columns are left unchanged. Exposed's `onUpdate` and `keyColumns` are **ignored** (no +`ON DUPLICATE KEY UPDATE`). `upsert(where)` **throws** — use `update { }` for conditional writes. + +## Retryable transactions + +YDB uses Optimistic Concurrency Control, so a transaction can fail with `Transaction locks +invalidated` under contention. Use `ydbTransaction` instead of plain `transaction` to retry +the body on retryable YDB statuses (`ABORTED`, `OVERLOADED`, `BAD_SESSION`, ...): + +```kotlin +import tech.ydb.exposed.dialect.YdbRetryConfig +import tech.ydb.exposed.dialect.ydbTransaction + +ydbTransaction(db) { + // read-write; retries transient YDB statuses (ABORTED, OVERLOADED, BAD_SESSION, ...) +} + +ydbTransaction(db, retry = YdbRetryConfig.IDEMPOTENT) { + // idempotent body — UNDETERMINED and other non-transient retryable codes are retried too +} + +ydbTransaction(db, readOnly = true, retry = YdbRetryConfig.IDEMPOTENT) { + // read-only snapshot work +} +``` + +Backoff uses full jitter for `ABORTED` / `UNDETERMINED`, equal jitter for `UNAVAILABLE` / transport / +`OVERLOADED`, and zero delay for session errors. Status codes are read from `SQLException.errorCode` +(YDB vendor codes), not from error message text. + +Use `retry = YdbRetryConfig.IDEMPOTENT` only when the body can be safely re-executed (pure reads, +single `UPSERT` / `REPLACE`, idempotent business logic). Customize attempts and backoff via +`YdbRetryConfig` or `YdbRetryConfig.DEFAULT.copy(maxAttempts = 3)`. + +## Types + +Default mapping for standard Exposed types: + +| Exposed | YDB | +|---------------------|--------------------| +| `byte` / `ubyte` | `Int8` / `Uint8` | +| `short` / `ushort` | `Int16` / `Uint16` | +| `integer`/`uinteger`| `Int32`/`Uint32` | +| `long` | `Int64` | +| `float` / `double` | `Float` / `Double` | +| `bool` | `Bool` | +| `varchar` / `text` | `Text` | +| `binary` / `blob` | `Bytes` | +| `uuid` | `Uuid` | +| `date` | `Date` | +| `datetime` | `Datetime` | +| `timestamp` | `Timestamp` | +| `json` | `Json` | +| `jsonb` | `JsonDocument` | + +`varchar(n)` maps to `Text` (length is not preserved in YDB DDL). + +### Production types (`ydb*` / `javatime.*`) + +For temporal and unsigned columns, use **`ydbDate` / `ydbDate32`**, **`ydbUbyte`**, **`ydbUint32`**, etc. +They bind via YDB JDBC vendor type codes. Standard Exposed `date()`, `ubyte()`, `binary()` use +generic JDBC binding — DDL still maps correctly for many cases, but edge cases (unsigned ranges, +signed vs legacy temporal) may differ. **Prefer `ydb*` / `javatime.*` in production.** + +Pick unsigned legacy or signed extended temporal types per column on any `Table`; +JDBC vendor code drives both bind and DDL `sqlType()`: + +```kotlin +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDate32 +import tech.ydb.exposed.dialect.javatime.ydbDatetime64 + +object Events : Table("events") { + val created = ydbDate("created") // Date + val expires = ydbDate32("expires") // Date32 + val updated = ydbDatetime64("updated") // Datetime64 +} +``` + +Optional: `registerYdbDialect(enableSignedDatetimes = true)` switches **dialect** DDL names for +standard Exposed `date` / `datetime` / `timestamp` to `Date32` / `Datetime64` / `Timestamp64`. +Add `forceSignedDatetimes=true` to the JDBC URL yourself when the driver requires it. +Per-column types remain explicit (`ydbDate` vs `ydbDate32`). + +Additional YDB-specific column types are available via extension functions on `Table`: + +```kotlin +ydbDecimal("price", precision = 10, scale = 2) +ydbInterval("duration") +ydbJson("payload") +ydbJsonDocument("indexed_payload") // JsonDocument, analogue of jsonb +ydbUuid("id") // native Uuid; same as Exposed uuid() under this dialect +ydbUint64("counter") +``` + +`ydbUint64` / `ydbUlong` are backed by `Long` / `ULong` with range `0..Long.MAX_VALUE` for the +JDBC long path. Use a wider type if you need the full `Uint64` range. + +For Decimal literals inside update expressions there's `ydbDecimalLiteral`: + +```kotlin +import tech.ydb.exposed.dialect.ydbDecimalLiteral + +it.update(Products.price, ydbDecimalLiteral(BigDecimal("45.00"), 10, 2)) +``` + +## Identifiers + +Exposed `autoIncrement()` maps to YDB `Serial` / `BigSerial`: + +```kotlin +object Orders : YdbTable("orders") { + val id = integer("id").autoIncrement() + val total = ydbDecimal("total", precision = 12, scale = 2) + override val primaryKey = PrimaryKey(id) +} +``` + +For UUID keys use `ydbUuid("id")` or Exposed `uuid()` under this dialect. Unsigned `Serial` +columns are not supported. + +## Indexes + +- **Post-create** (any `Table` or `YdbTable`): Exposed `index()` / `index(customName, isUnique, …)` + → `ALTER TABLE … ADD INDEX … GLOBAL [UNIQUE] ON (…)`. +- **Inline in `CREATE TABLE`** (`YdbTable` only): [`secondaryIndex`](src/main/kotlin/tech/ydb/exposed/dialect/createYdbStatement.kt) + with optional `COVER`, `ASYNC`, `WITH`. + +## Known limitations + +Inherited from Exposed `VendorDialect` unless overridden here: foreign keys, sequences, +`SELECT … FOR UPDATE`, dialect-specific features aimed at PostgreSQL/MySQL may produce SQL +that YDB does not support. This module overrides indexes, UPSERT/REPLACE, LIMIT/OFFSET, JSON +functions, and YDB type names — not the entire DDL surface. + +- No ANSI `MERGE`; use `UPSERT` / `REPLACE`. +- Plain `Table` DDL uses Exposed's inline `PRIMARY KEY` on columns — use `YdbTable` (or hand-written YQL). +- No Yson / timezone-aware temporal types in this module. +- Functional indexes (Exposed `index` with expressions) are rejected. + +## Tests + +Integration tests use [testcontainers](https://www.testcontainers.org/) via +`tech.ydb.test:ydb-junit5-support` — no manual Docker setup needed: + +```bash +mvn verify +``` + +DDL-focused tests use `YdbTable`; many other integration tests still use plain `Table` and may +fail `SchemaUtils.create` on YDB until migrated to `YdbTable` (inline `PRIMARY KEY` in Exposed DDL). + +## Demo application + +The `example/` module contains a runnable demo. Install the dialect first: + +```bash +mvn -DskipTests -DskipITs install +``` + +Then run the demo: + +```bash +cd example +mvn exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.example.DemoAppKt +``` + +It expects a YDB instance at `jdbc:ydb:grpc://localhost:2136/local`. diff --git a/kotlin-exposed-dialect/example/pom.xml b/kotlin-exposed-dialect/example/pom.xml new file mode 100644 index 00000000..ec3c848d --- /dev/null +++ b/kotlin-exposed-dialect/example/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + tech.ydb.dialects + kotlin-exposed-ydb-dialect-example + 0.9.0 + jar + + Kotlin Exposed YDB Dialect Example + + + UTF-8 + 2.2.20 + 17 + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + tech.ydb.dialects + kotlin-exposed-ydb-dialect + 0.9.0 + + + + tech.ydb.jdbc + ydb-jdbc-driver + 2.3.22 + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + 17 + + src/main/kotlin + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + + diff --git a/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoApp.kt b/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoApp.kt new file mode 100644 index 00000000..af24d982 --- /dev/null +++ b/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoApp.kt @@ -0,0 +1,113 @@ +package tech.ydb.exposed.dialect.example + +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.jdbc.upsert +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.jdbc.Database +import tech.ydb.exposed.dialect.registerYdbDialect +import tech.ydb.exposed.dialect.ydbDecimalLiteral +import tech.ydb.exposed.dialect.ydbTransaction +import java.math.BigDecimal +import java.sql.Connection + +fun main() { + registerYdbDialect() + val db = Database.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver", + databaseConfig = DatabaseConfig { + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + useNestedTransactions = false + } + ) + + ydbTransaction(db) { + println("== Schema setup ==") + runCatching { SchemaUtils.drop(DemoProducts) } + SchemaUtils.create(DemoProducts) + + println("DDL:") + DemoProducts.ddl.forEach { println(it) } + + println() + println("== UPSERT seed data ==") + seedDemoData() + + DemoProducts.selectAll().orderBy(DemoProducts.id).forEach { + println("product[id=${it[DemoProducts.id]}, sku=${it[DemoProducts.sku]}, name=${it[DemoProducts.name]}, category=${it[DemoProducts.category]}, price=${it[DemoProducts.price]}]") + } + + println() + println("== READ by category ==") + DemoProducts.selectAll() + .where { DemoProducts.category eq "books" } + .orderBy(DemoProducts.id) + .forEach { + println("books -> ${it[DemoProducts.name]} (${it[DemoProducts.price]})") + } + + println() + println("== UPDATE ==") + DemoProducts.update({ DemoProducts.sku eq "BOOK-002" }) { + it[DemoProducts.name] = "Distributed Systems, 2nd edition" + it.update( + DemoProducts.price, + ydbDecimalLiteral(BigDecimal("45.00"), 10, 2) + ) + } + + DemoProducts.selectAll() + .where { DemoProducts.sku eq "BOOK-002" } + .forEach { + println("updated -> ${it[DemoProducts.name]} (${it[DemoProducts.price]})") + } + + println() + println("== DELETE ==") + DemoProducts.deleteWhere { DemoProducts.sku eq "HW-001" } + + DemoProducts.selectAll() + .orderBy(DemoProducts.id) + .forEach { + println("remaining -> ${it[DemoProducts.id]} / ${it[DemoProducts.sku]} / ${it[DemoProducts.name]}") + } + } +} + +private fun seedDemoData() { + DemoProducts.upsert { + it[id] = 1 + it[sku] = "BOOK-001" + it[name] = "Kotlin in Action" + it[category] = "books" + it[price] = BigDecimal("39.90") + } + + DemoProducts.upsert { + it[id] = 2 + it[sku] = "BOOK-002" + it[name] = "Distributed Systems" + it[category] = "books" + it[price] = BigDecimal("42.50") + } + + DemoProducts.upsert { + it[id] = 3 + it[sku] = "HW-001" + it[name] = "Mechanical Keyboard" + it[category] = "hardware" + it[price] = BigDecimal("129.99") + } + + DemoProducts.upsert { + it[id] = 4 + it[sku] = "HW-002" + it[name] = "USB-C Dock" + it[category] = "hardware" + it[price] = BigDecimal("89.00") + } +} diff --git a/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoTables.kt b/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoTables.kt new file mode 100644 index 00000000..e9ede561 --- /dev/null +++ b/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoTables.kt @@ -0,0 +1,29 @@ +package tech.ydb.exposed.dialect.example + +import tech.ydb.exposed.dialect.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.ydbDecimal + +object DemoProducts : YdbTable("demo_products") { + val id = integer("id") + val sku = varchar("sku", 64) + val name = varchar("name", 255) + val category = varchar("category", 128) + val price = ydbDecimal("price", 10, 2) + + override val primaryKey = PrimaryKey(id) + + init { + index(false, sku) + + secondaryIndex( + name = "demo_products_category_idx", + category, + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.ASYNC, + coverColumns = listOf(name, price) + ) + } +} diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml new file mode 100644 index 00000000..cd478aa5 --- /dev/null +++ b/kotlin-exposed-dialect/pom.xml @@ -0,0 +1,299 @@ + + + 4.0.0 + + tech.ydb.dialects + kotlin-exposed-ydb-dialect + 0.9.0 + + jar + + Kotlin Exposed YDB Dialect + Kotlin Exposed dialect for YDB (YQL) + https://github.com/ydb-platform/ydb-java-dialects + + + + Svetlana Markelova + sv.markelova11@gmail.com + YDB + https://ydb.tech/ + + + Kirill Kurdyukov + kurdyukov-kir@ydb.tech + YDB + https://ydb.tech/ + + + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + + https://github.com/ydb-platform/ydb-java-dialects + scm:git:https://github.com/ydb-platform/ydb-java-dialects.git + scm:git:https://github.com/ydb-platform/ydb-java-dialects.git + + + + UTF-8 + + 17 + 17 + 17 + + 2.2.20 + 1.3.0 + + 5.10.2 + 2.17.2 + + 2.4.3 + 2.3.22 + + + + + + org.jetbrains.exposed + exposed-bom + ${exposed.version} + pom + import + + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + test + + + + org.jetbrains.exposed + exposed-core + + + + org.jetbrains.exposed + exposed-jdbc + + + + + tech.ydb.jdbc + ydb-jdbc-driver + ${ydb.jdbc.version} + test + + + + org.junit.jupiter + junit-jupiter + ${junit5.version} + test + + + + org.jetbrains.exposed + exposed-java-time + test + + + + org.jetbrains.exposed + exposed-dao + test + + + + org.jetbrains.exposed + exposed-json + test + + + + tech.ydb.test + ydb-junit5-support + ${ydb.sdk.version} + test + + + + org.apache.logging.log4j + log4j-slf4j-impl + ${log4j2.version} + test + + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + compile + compile + + compile + + + 17 + + src/main/kotlin + + + + + test-compile + test-compile + + test-compile + + + 17 + + src/test/kotlin + + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.5.0 + + 17 + + + + attach-javadocs + + jar + + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + attach-sources + + jar-no-fork + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.0 + + false + + **/*Test.* + + + **/*IT.* + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.1.0 + + + integration-tests + + integration-test + verify + + + + **/*IT.* + + + true + ydbplatform/local-ydb:trunk + + + + + + + + + + + ossrh-s01 + + false + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.7 + + + sign-artifacts + verify + + sign + + + + + + --pinentry-mode + loopback + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.7.0 + true + + ossrh-s01 + + + + + + + diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbColumnTypes.kt new file mode 100644 index 00000000..06d3a22c --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbColumnTypes.kt @@ -0,0 +1,384 @@ +/** + * YDB-specific Exposed column types and table extensions. + * + * Types here use [bindYdbParameter] with JDBC vendor codes ([tech.ydb.exposed.dialect.code.YdbJdbcCode]) + * so binds match YQL DDL. For temporal columns prefer [tech.ydb.exposed.dialect.javatime]. + * + * Standard Exposed `integer`, `varchar`, `uuid`, etc. use [YdbDataTypeProvider] via [YdbDialect] for + * DDL only; production unsigned/temporal data should use `ydb*` / `javatime.*` extensions. + */ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ColumnType +import org.jetbrains.exposed.v1.core.Expression +import org.jetbrains.exposed.v1.core.QueryBuilder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.statements.api.PreparedStatementApi +import org.jetbrains.exposed.v1.jdbc.statements.jdbc.JdbcPreparedStatementImpl +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import java.math.BigDecimal +import java.math.BigInteger +import java.time.Duration +import java.util.UUID +import kotlin.UByte +import kotlin.UInt +import kotlin.ULong +import kotlin.UShort + +// region Table column extensions + +/** YDB `Decimal(precision, scale)` with scale validation on write. */ +fun Table.ydbDecimal(name: String, precision: Int, scale: Int): Column = + registerColumn(name, YdbDecimalColumnType(precision, scale)) + +/** YDB `Json` (scalar JSON string). */ +fun Table.ydbJson(name: String): Column = + registerColumn(name, YdbJsonStringColumnType()) + +/** YDB `JsonDocument` (indexed JSON, analogue of PostgreSQL `jsonb`). */ +fun Table.ydbJsonDocument(name: String): Column = + registerColumn(name, YdbJsonDocumentStringColumnType()) + +/** Native YDB `Uuid` with JDBC UUID vendor binding. */ +fun Table.ydbUuid(name: String): Column = + registerColumn(name, YdbUuidColumnType()) + +/** + * YDB `Uint64` exposed as non-negative [Long] (`0..Long.MAX_VALUE`). + * For [ULong] use [ydbUlong]. + */ +fun Table.ydbUint64(name: String): Column = + registerColumn(name, YdbUint64ColumnType()) + +/** YDB `Uint8` as Kotlin [UByte]. */ +fun Table.ydbUbyte(name: String): Column = + registerColumn(name, YdbUByteColumnType()) + +/** YDB `Uint16` as Kotlin [UShort]. */ +fun Table.ydbUshort(name: String): Column = + registerColumn(name, YdbUShortColumnType()) + +/** YDB `Uint32` as Kotlin [UInt]. */ +fun Table.ydbUint32(name: String): Column = + registerColumn(name, YdbUIntegerColumnType()) + +/** Full `Uint64` range via [ULong]; values above [Long.MAX_VALUE] are not supported (see README). */ +fun Table.ydbUlong(name: String): Column = + registerColumn(name, YdbULongColumnType()) + +/** Legacy unsigned YDB `Interval` ([Duration]). */ +fun Table.ydbInterval(name: String): Column = + registerColumn(name, YdbIntervalColumnType(YdbJdbcCode.INTERVAL)) + +/** Extended signed YDB `Interval64` ([Duration]). */ +fun Table.ydbInterval64(name: String): Column = + registerColumn(name, YdbIntervalColumnType(YdbJdbcCode.INTERVAL64)) + +/** + * YQL literal for updates: `Decimal("value", precision, scale)`. + * Use in `update { it[col] = ydbDecimalLiteral(...) }` when a plain value would not carry precision. + */ +fun ydbDecimalLiteral( + value: BigDecimal, + precision: Int, + scale: Int +): Expression = YdbDecimalLiteral(value, precision, scale) + +// endregion + +// region JDBC bind helper + +/** + * Binds a value with [PreparedStatement.setObject] and a YDB JDBC vendor [targetSqlType]. + * + * Requires Exposed's [JdbcPreparedStatementImpl] (YDB JDBC driver). Fails fast otherwise so + * values are never silently dropped. + */ +internal fun bindYdbParameter( + stmt: PreparedStatementApi, + index: Int, + value: Any?, + targetSqlType: Int +) { + val jdbcStatement = (stmt as? JdbcPreparedStatementImpl)?.statement + ?: error("YDB column bind requires JdbcPreparedStatementImpl (got ${stmt::class.qualifiedName});") + + if (value == null) { + jdbcStatement.setNull(index, targetSqlType) + return + } + + jdbcStatement.setObject(index, value, targetSqlType) +} + +// endregion + +// region Column types + +internal class YdbDecimalColumnType( + private val precision: Int, + private val scale: Int +) : ColumnType() { + + private val targetSqlType: Int = YdbJdbcCode.decimal(precision, scale) + + init { + require(precision in 1..35) { "YDB Decimal precision must be in 1..35" } + require(scale in 0..precision) { "YDB Decimal scale must be in 0..precision" } + } + + override fun sqlType(): String = "Decimal($precision, $scale)" + + override fun valueFromDB(value: Any): BigDecimal = when (value) { + is BigDecimal -> value + is String -> value.toBigDecimal() + else -> error("Unexpected value for Decimal: $value of ${value::class}") + } + + override fun notNullValueToDB(value: BigDecimal): Any = normalizeScale(value) + + override fun nonNullValueToString(value: BigDecimal): String = normalizeScale(value).toPlainString() + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + val dbValue = value?.let { normalizeScale(it as BigDecimal) } + bindYdbParameter(stmt, index, dbValue, targetSqlType) + } + + private fun normalizeScale(value: BigDecimal): BigDecimal { + require(value.scale() <= scale) { + "YDB Decimal value $value has scale ${value.scale()}, which exceeds column scale $scale" + } + return value.setScale(scale) + } +} + +internal abstract class YdbTypedStringColumnType( + private val ydbSqlType: String, + private val targetSqlType: Int +) : ColumnType() { + override fun sqlType(): String = ydbSqlType + + override fun valueFromDB(value: Any): String = value.toString() + + override fun notNullValueToDB(value: String): Any = value + + override fun nonNullValueToString(value: String): String = + "'${value.replace("'", "''")}'" + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + bindYdbParameter(stmt, index, value, targetSqlType) + } +} + +internal class YdbJsonStringColumnType : YdbTypedStringColumnType("Json", YdbJdbcCode.JSON) + +internal class YdbJsonDocumentStringColumnType : YdbTypedStringColumnType("JsonDocument", YdbJdbcCode.JSON_DOCUMENT) + +internal class YdbUuidColumnType : ColumnType() { + override fun sqlType(): String = "Uuid" + + override fun valueFromDB(value: Any): UUID = when (value) { + is UUID -> value + is String -> UUID.fromString(value) + else -> error("Unexpected value for native UUID: $value of ${value::class}") + } + + override fun notNullValueToDB(value: UUID): Any = value + + override fun nonNullValueToString(value: UUID): String = "Uuid(\"$value\")" + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + bindYdbParameter(stmt, index, value, YdbJdbcCode.UUID) + } +} + +internal class YdbUint64ColumnType : ColumnType() { + override fun sqlType(): String = "Uint64" + + override fun valueFromDB(value: Any): Long = when (value) { + is Long -> { + require(value >= 0) { "Uint64 value cannot be negative: $value" } + value + } + + is Int -> { + require(value >= 0) { "Uint64 value cannot be negative: $value" } + value.toLong() + } + + is BigInteger -> value.toLongCompatibleUint64() + is String -> value.toBigInteger().toLongCompatibleUint64() + else -> error("Unexpected value for Uint64: $value of ${value::class}") + } + + override fun notNullValueToDB(value: Long): Any { + require(value >= 0) { "Uint64 column cannot store negative value: $value" } + return value + } + + override fun nonNullValueToString(value: Long): String { + require(value >= 0) { "Uint64 column cannot store negative value: $value" } + return value.toString() + } + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + bindYdbParameter(stmt, index, value, YdbJdbcCode.UINT64) + } +} + +internal class YdbUByteColumnType : ColumnType() { + override fun sqlType(): String = "Uint8" + + override fun valueFromDB(value: Any): UByte = when (value) { + is UByte -> value + is Byte -> value.toUByte() + is Short -> value.toUByte() + is Int -> value.toUByte() + is Number -> value.toInt().toUByte() + is String -> value.toUByte() + else -> error("Unexpected value for Uint8: $value of ${value::class}") + } + + override fun notNullValueToDB(value: UByte): Any = value.toShort() + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + val dbValue = when (value) { + null -> null + is UByte -> value.toShort() + is Number -> value.toShort() + else -> error("Unexpected bind value for Uint8: $value of ${value::class}") + } + bindYdbParameter(stmt, index, dbValue, YdbJdbcCode.UINT8) + } +} + +internal class YdbUShortColumnType : ColumnType() { + override fun sqlType(): String = "Uint16" + + override fun valueFromDB(value: Any): UShort = when (value) { + is UShort -> value + is Short -> value.toUShort() + is Int -> value.toUShort() + is Number -> value.toInt().toUShort() + is String -> value.toUShort() + else -> error("Unexpected value for Uint16: $value of ${value::class}") + } + + override fun notNullValueToDB(value: UShort): Any = value.toInt() + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + val dbValue = when (value) { + null -> null + is UShort -> value.toInt() + is Number -> value.toInt() + else -> error("Unexpected bind value for Uint16: $value of ${value::class}") + } + bindYdbParameter(stmt, index, dbValue, YdbJdbcCode.UINT16) + } +} + +internal class YdbUIntegerColumnType : ColumnType() { + override fun sqlType(): String = "Uint32" + + override fun valueFromDB(value: Any): UInt = when (value) { + is UInt -> value + is Int -> value.toUInt() + is Long -> value.toUInt() + is Number -> value.toLong().toUInt() + is String -> value.toUInt() + else -> error("Unexpected value for Uint32: $value of ${value::class}") + } + + override fun notNullValueToDB(value: UInt): Any = value.toLong() + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + val dbValue = when (value) { + null -> null + is UInt -> value.toLong() + is Number -> value.toLong() + else -> error("Unexpected bind value for Uint32: $value of ${value::class}") + } + bindYdbParameter(stmt, index, dbValue, YdbJdbcCode.UINT32) + } +} + +internal class YdbULongColumnType : ColumnType() { + override fun sqlType(): String = "Uint64" + + override fun valueFromDB(value: Any): ULong = when (value) { + is ULong -> value + is Long -> value.toULong() + is Int -> value.toULong() + is Number -> value.toLong().toULong() + is String -> value.toULong() + else -> error("Unexpected value for Uint64: $value of ${value::class}") + } + + override fun notNullValueToDB(value: ULong): Any = value.toLongCompatibleUint64() + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + val dbValue = when (value) { + null -> null + is ULong -> value.toLongCompatibleUint64() + else -> value + } + bindYdbParameter(stmt, index, dbValue, YdbJdbcCode.UINT64) + } +} + +internal class YdbIntervalColumnType( + private val jdbcTypeCode: Int +) : ColumnType() { + override fun sqlType(): String = + if (jdbcTypeCode == YdbJdbcCode.INTERVAL) "Interval" else "Interval64" + + override fun valueFromDB(value: Any): Duration = when (value) { + is Duration -> value + is String -> Duration.parse(value) + else -> error("Unexpected value for Interval: $value of ${value::class}") + } + + override fun notNullValueToDB(value: Duration): Any = value + + override fun nonNullValueToString(value: Duration): String = "'$value'" + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + bindYdbParameter(stmt, index, value, jdbcTypeCode) + } +} + +internal class YdbDecimalLiteral( + private val value: BigDecimal, + private val precision: Int, + private val scale: Int +) : Expression() { + + override fun toQueryBuilder(queryBuilder: QueryBuilder) { + require(value.scale() <= scale) { + "Decimal value $value has scale ${value.scale()}, which exceeds the allowed scale $scale" + } + val normalized = value.setScale(scale).toPlainString() + queryBuilder.append("""Decimal("$normalized", $precision, $scale)""") + } +} + +private fun BigInteger.toLongCompatibleUint64(): Long { + require(this >= BigInteger.ZERO) { "Uint64 value cannot be negative: $this" } + require(this <= BigInteger.valueOf(Long.MAX_VALUE)) { + "Uint64 value $this exceeds Long-backed Uint64 range (0..${Long.MAX_VALUE})" + } + return toLong() +} + +private fun ULong.toLongCompatibleUint64(): Long { + val max = Long.MAX_VALUE.toULong() + if (this > max) { + throw IllegalArgumentException( + "Uint64 value $this exceeds Long-backed Uint64 range (0..$max)" + ) + } + return toLong() +} + +// endregion diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt new file mode 100644 index 00000000..30dc66f1 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt @@ -0,0 +1,481 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Expression +import org.jetbrains.exposed.v1.core.IColumnType +import org.jetbrains.exposed.v1.core.append +import org.jetbrains.exposed.v1.core.Index +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.QueryAlias +import org.jetbrains.exposed.v1.core.QueryBuilder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.core.statements.MergeStatement +import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider +import org.jetbrains.exposed.v1.core.vendors.FunctionProvider +import org.jetbrains.exposed.v1.core.vendors.VendorDialect +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.jdbc.vendors.DatabaseDialectMetadata +import java.sql.Connection +import java.sql.DatabaseMetaData +import kotlin.use + +/** + * Default YDB column type mappings used by Exposed when a column is declared via standard + * Exposed DSL (`integer`, `varchar`, `date`, ...) — see `YdbColumnTypes.kt` for YDB-specific + * column types that have no direct Exposed equivalent (e.g. `JsonDocument`, `Interval`, `Uint64`). + * + * For `java.time` temporal columns with correct JDBC vendor binding, use + * [tech.ydb.exposed.dialect.javatime.ydbDate] / [tech.ydb.exposed.dialect.javatime.ydbDate32] + * (and the other extensions in that package). [ydbInterval] / [ydbInterval64] live in this module root. + */ +internal class YdbDataTypeProvider( + private val enableSignedDatetimes: Boolean = false +) : DataTypeProvider() { + override fun booleanType(): String = "Bool" + + override fun byteType(): String = "Int8" + override fun shortType(): String = "Int16" + override fun integerType(): String = "Int32" + override fun longType(): String = "Int64" + + override fun ubyteType(): String = "Uint8" + override fun ushortType(): String = "Uint16" + override fun uintegerType(): String = "Uint32" + override fun ulongType(): String = "Uint64" + + override fun floatType(): String = "Float" + override fun doubleType(): String = "Double" + + override fun binaryType(): String = "Bytes" + override fun binaryType(length: Int): String = binaryType() + override fun blobType(): String = binaryType() + + override fun textType(): String = "Text" + override fun varcharType(colLength: Int): String = textType() + override fun mediumTextType(): String = textType() + override fun largeTextType(): String = textType() + + override fun jsonType(): String = "Json" + override fun jsonBType(): String = "JsonDocument" + + override fun integerAutoincType(): String = "Serial" + override fun longAutoincType(): String = "BigSerial" + + override fun uuidType(): String = "Uuid" + + override fun uintegerAutoincType(): String = + throw UnsupportedOperationException("YDB does not support unsigned Serial columns") + + override fun ulongAutoincType(): String = + throw UnsupportedOperationException("YDB does not support unsigned Serial columns") + + override fun dateType(): String = if (enableSignedDatetimes) "Date32" else "Date" + + override fun dateTimeType(): String = if (enableSignedDatetimes) "Datetime64" else "Datetime" + + override fun timestampType(): String = if (enableSignedDatetimes) "Timestamp64" else "Timestamp" + + /** YQL literal for binary columns: `Unwrap(String::HexDecode('...'), ...)`. */ + override fun hexToDb(hexString: String): String = + "Unwrap(String::HexDecode('$hexString'), 'invalid hex bytes literal')" +} + +internal object YdbFunctionProvider : FunctionProvider() { + + private const val MERGE_UNSUPPORTED = + "YDB dialect does not support ANSI MERGE through Exposed. Use UPSERT or batchUpsert instead." + + private const val JSON_CONTAINS_UNSUPPORTED = + "YDB does not support JSON_CONTAINS. Use JSON_EXISTS or compare JSON_VALUE / JSON_QUERY instead." + + /** + * Maps Exposed [org.jetbrains.exposed.v1.core.Random] to YQL [Random](https://ydb.tech/docs/en/yql/reference/builtins/basic). + * + * YDB optional arguments are **not** a reproducible PRNG seed (unlike MySQL `RAND(n)`). + * They only group call sites inside one query (same arguments → same value in the same execution phase). + * See YDB docs: `Random(1)` — one draw per query; `Random(column)` — per row. + * + * @param seed When `null`, each call site gets an independent `Random()`. + * When set, emitted as `Random(seed)` for YDB call grouping only. + */ + override fun random(seed: Int?): String = + if (seed == null) "Random()" else "Random($seed)" + + override fun charLength(expr: Expression, queryBuilder: QueryBuilder): Unit = queryBuilder { + append("Unicode::GetLength(", expr, ")") + } + + override fun substring( + expr: Expression, + start: Expression, + length: Expression, + builder: QueryBuilder, + prefix: String + ): Unit = builder { + append("Unicode::Substring(", expr, ", ", start, ", ", length, ")") + } + + override fun concat(separator: String, queryBuilder: QueryBuilder, vararg expr: Expression<*>) { + if (expr.isEmpty()) { + queryBuilder { append("''") } + return + } + queryBuilder { + if (separator.isEmpty()) { + expr.appendTo(separator = " || ") { +it } + } else { + append("Unicode::JoinFromList(AsList(") + expr.appendTo { append("CAST(", it, " AS Utf8)") } + append("), '", escapeStringLiteral(separator), "')") + } + } + } + + /** + * [Unicode::Find](https://ydb.tech/docs/en/yql/reference/udf/list/unicode) is 0-based; + * Exposed [locate] is 1-based (0 when not found). + */ + override fun locate( + queryBuilder: QueryBuilder, + expr: Expression, + substring: String + ) = queryBuilder { + val needle = escapeStringLiteral(substring) + append("COALESCE(CAST(Unicode::Find(", expr, ", '", needle, "') + 1u AS Int32), 0)") + } + + override fun regexp( + expr1: Expression, + pattern: Expression, + caseSensitive: Boolean, + queryBuilder: QueryBuilder + ): Unit = queryBuilder { + if (caseSensitive) { + append(expr1, " REGEXP ", pattern) + } else { + append("Re2::Grep(", pattern, ", Re2::Options(false AS CaseSensitive))(", expr1, ")") + } + } + + override fun jsonCast(expression: Expression, jsonType: IColumnType<*>, queryBuilder: QueryBuilder) { + queryBuilder { + append("CAST(", expression, " AS ", jsonType.sqlType(), ")") + } + } + + override fun jsonExtract( + expression: Expression, + vararg path: String, + toScalar: Boolean, + jsonType: IColumnType<*>, + queryBuilder: QueryBuilder + ) = queryBuilder { + val jsonPath = buildJsonPath(*path) + append(if (toScalar) "JSON_VALUE" else "JSON_QUERY") + append("(", expression, ", '", escapeStringLiteral(jsonPath), "')") + } + + override fun jsonContains( + target: Expression<*>, + candidate: Expression<*>, + path: String?, + jsonType: IColumnType<*>, + queryBuilder: QueryBuilder + ) { + throw UnsupportedOperationException(JSON_CONTAINS_UNSUPPORTED) + } + + override fun jsonExists( + expression: Expression<*>, + vararg path: String, + optional: String?, + jsonType: IColumnType<*>, + queryBuilder: QueryBuilder + ) = queryBuilder { + val jsonPath = buildJsonPath(*path) + append("JSON_EXISTS(", expression, ", '", escapeStringLiteral(jsonPath), "'") + optional?.let { append(" ", it) } + append(")") + } + + /** + * Native YDB [UPSERT](https://ydb.tech/docs/en/yql/reference/syntax/upsert_into): writes only + * columns from [data]; on PK conflict, other columns are unchanged. + * + * Exposed `onUpdate` and `keyColumns` are ignored. [where] must be `null`. + */ + override fun upsert( + table: Table, + data: List, Any?>>, + expression: String, + onUpdate: List, Any?>>, + keyColumns: List>, + where: Op?, + transaction: Transaction + ): String { + if (where != null) { + throw UnsupportedOperationException( + "YDB UPSERT does not support Exposed's upsert(where) (PostgreSQL ON CONFLICT … WHERE). " + + "Use Table.update { } for conditional updates." + ) + } + + val columns = data.map { it.first } + val expr = expression.trim().ifBlank { + val literals = data.joinToString(", ") { (column, value) -> + valueToSqlLiteral(column, value) + } + "VALUES ($literals)" + } + return buildYdbIntoStatement("UPSERT", table, columns, expr, transaction) + } + + /** + * Native YDB [REPLACE](https://ydb.tech/docs/en/yql/reference/syntax/replace_into): overwrites the row by PK; + * columns omitted from the statement are reset to table defaults. + */ + override fun replace( + table: Table, + columns: List>, + expression: String, + transaction: Transaction, + prepared: Boolean + ): String = buildYdbIntoStatement("REPLACE", table, columns, expression.trim(), transaction) + + /** + * Shared YQL `{verb} INTO table [(cols)] values` builder — INSERT…SELECT, explicit columns/VALUES, + * or [DEFAULT_VALUE_EXPRESSION] when the column list is empty. + */ + private fun buildYdbIntoStatement( + verb: String, + table: Table, + columns: List>, + expr: String, + transaction: Transaction + ): String { + val isInsertFromSelect = columns.isNotEmpty() && expr.isNotEmpty() && !expr.startsWith("VALUES") + + val (columnsToWrite, valuesExpr) = when { + isInsertFromSelect -> columns to expr + columns.isNotEmpty() -> columns to expr + else -> emptyList>() to DEFAULT_VALUE_EXPRESSION + } + val columnsExpr = columnsToWrite.takeIf { it.isNotEmpty() } + ?.joinToString(prefix = "(", postfix = ")") { transaction.identity(it) } + ?: "" + + return buildString { + append(verb) + append(" INTO ") + append(transaction.identity(table)) + if (columnsExpr.isNotEmpty()) { + append(' ') + append(columnsExpr) + } + append(' ') + append(valuesExpr) + } + } + + override fun merge( + dest: Table, + source: Table, + transaction: Transaction, + clauses: List, + on: Op? + ): String = throw UnsupportedOperationException(MERGE_UNSUPPORTED) + + override fun mergeSelect( + dest: Table, + source: QueryAlias, + transaction: Transaction, + clauses: List, + on: Op, + prepared: Boolean + ): String = throw UnsupportedOperationException(MERGE_UNSUPPORTED) + + override fun queryLimitAndOffset( + size: Int?, + offset: Long, + alreadyOrdered: Boolean + ): String = buildString { + if (size != null) { + append(" LIMIT ") + append(size) + } + if (offset > 0) { + append(" OFFSET ") + append(offset) + } + } + + @Suppress("UNCHECKED_CAST") + private fun valueToSqlLiteral(column: Column<*>, value: Any?): String { + if (value == null) return "NULL" + val columnType = column.columnType as IColumnType + return columnType.valueToString(value) + } + + /** [JsonPath](https://ydb.tech/docs/en/yql/reference/builtins/json) for `JSON_VALUE` / `JSON_QUERY` / `JSON_EXISTS`. */ + private fun buildJsonPath(vararg segments: String): String { + if (segments.isEmpty()) return "$" + if (segments.size == 1) { + val only = segments[0] + if (only.startsWith("$")) return only + } + + val path = StringBuilder("$") + for (segment in segments) { + if (segment.isEmpty()) continue + when { + segment.all(Char::isDigit) -> path.append('[').append(segment).append(']') + segment.startsWith("[") && segment.endsWith("]") -> path.append(segment) + else -> { + if (path.last() == '$' || path.last() == ']') { + path.append('.') + } + path.append(quoteJsonPathKey(segment)) + } + } + } + return path.toString() + } + + private fun quoteJsonPathKey(key: String): String = + if (key.all { it.isLetterOrDigit() || it == '_' }) { + key + } else { + "\"${key.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + } + + private fun escapeStringLiteral(value: String): String = value.replace("'", "''") +} + +/** + * Exposed [VendorDialect] for YDB. + * + * Use [registerYdbDialect] then `Database.connect("jdbc:ydb:...")`. + * + * Notable behavior: + * - [YdbTable] — YQL `CREATE TABLE` with table-level PK, inline indexes, TTL. + * - [tech.ydb.exposed.dialect.YdbFunctionProvider.upsert] / [replace] → native YQL `UPSERT` / `REPLACE` + * (`onUpdate` / `keyColumns` ignored). + * - [createIndex] → `ALTER TABLE ... ADD INDEX ... GLOBAL` (post-create indexes). + * - Functional indexes and ANSI `MERGE` are rejected. + * + * @property enableSignedDatetimes Passed to [YdbDataTypeProvider] for standard temporal DDL only. + */ +class YdbDialect internal constructor( + val enableSignedDatetimes: Boolean = false +) : VendorDialect( + DIALECT_NAME, + YdbDataTypeProvider(enableSignedDatetimes), + YdbFunctionProvider +) { + /** + * Post-create index: `ALTER TABLE t ADD INDEX i GLOBAL [UNIQUE] ON (cols)`. + * Use Exposed [Table.index] / [SchemaUtils.create]; indexes are added via `ALTER TABLE … ADD INDEX … GLOBAL`. + */ + override fun createIndex(index: Index): String { + val tr = runCatching { TransactionManager.current() }.getOrNull() + if (!index.functions.isNullOrEmpty()) { + throw UnsupportedOperationException("YDB dialect does not support functional indexes") + } + + val columns = index.columns.joinToString(", ") { column -> + tr?.identity(column) ?: column.name + } + + val indexName = tr?.db?.identifierManager?.cutIfNecessaryAndQuote(index.indexName) ?: index.indexName + val tableName = tr?.identity(index.table) ?: index.table.tableName + val unique = index.unique + + return buildString { + append("ALTER TABLE ") + append(tableName) + append(" ADD INDEX ") + append(indexName) + append(" GLOBAL") + if (unique) { + append(" UNIQUE") + } + append(" ON (") + append(columns) + append(")") + } + } + + override fun dropIndex( + tableName: String, + indexName: String, + isUnique: Boolean, + isPartialOrFunctional: Boolean + ): String = "ALTER TABLE $tableName DROP INDEX $indexName" + + internal companion object { + const val DIALECT_NAME = "ydb" + } +} + +/** JDBC metadata bridge so Exposed can read existing GLOBAL indexes on YDB. */ +internal object YdbDialectMetadata : DatabaseDialectMetadata() { + + override fun existingIndices(vararg tables: Table): Map> { + val connection = TransactionManager.current().connection.connection as Connection + val metadata = connection.metaData + + return tables.associateWith { table -> + readIndices(metadata, table) + } + } + + private fun readIndices(metadata: DatabaseMetaData, table: Table): List { + val indexColumns = linkedMapOf>() + + metadata.getIndexInfo(null, null, table.tableName, false, false).use { rs -> + while (rs.next()) { + val indexName = rs.getString("INDEX_NAME") ?: continue + val columnName = rs.getString("COLUMN_NAME") ?: continue + + val column = table.columns.firstOrNull { it.name.equals(columnName, ignoreCase = true) } + ?: continue + + val ordinal = rs.getShort("ORDINAL_POSITION").toInt() + val unique = !rs.getBoolean("NON_UNIQUE") + + indexColumns + .getOrPut(indexName) { mutableListOf() } + .add(IndexedColumn(column, ordinal, unique)) + } + } + + return indexColumns.mapNotNull { (indexName, columns) -> + val orderedColumns = columns + .sortedWith(compareBy { + it.ordinal.takeIf { ordinal -> ordinal > 0 } ?: Int.MAX_VALUE + }) + .map { it.column } + + if (orderedColumns.isEmpty()) { + null + } else { + Index( + columns = orderedColumns, + unique = columns.all { it.unique }, + customName = indexName, + indexType = null, + filterCondition = null, + functions = emptyList(), + functionsTable = table + ) + } + } + } + + private data class IndexedColumn( + val column: Column<*>, + val ordinal: Int, + val unique: Boolean + ) +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectRegistration.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectRegistration.kt new file mode 100644 index 00000000..08d1713f --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectRegistration.kt @@ -0,0 +1,36 @@ +/** + * Registers the YDB JDBC driver and Exposed [YdbDialect]. + * + * After [registerYdbDialect], open a database with `Database.connect("jdbc:ydb:...")` + * and use [ydbTransaction] for DML under YDB OCC. + */ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.DatabaseApi +import org.jetbrains.exposed.v1.jdbc.Database + +internal const val YDB_JDBC_URL_PREFIX = "jdbc:ydb:" + +internal const val YDB_DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" + +/** + * Registers the YDB JDBC driver and Exposed dialect. + * + * @param enableSignedDatetimes When `true`, standard Exposed `date` / `datetime` / `timestamp` DDL + * uses `Date32` / `Datetime64` / `Timestamp64`. Per-column types stay explicit via `javatime.*`. + */ +fun registerYdbDialect(enableSignedDatetimes: Boolean = false) { + Database.registerJdbcDriver( + prefix = YDB_JDBC_URL_PREFIX, + driverClassName = YDB_DRIVER_CLASS, + dialect = YdbDialect.DIALECT_NAME + ) + + Database.registerDialectMetadata(YdbDialect.DIALECT_NAME) { + YdbDialectMetadata + } + + DatabaseApi.registerDialect(YdbDialect.DIALECT_NAME) { + YdbDialect(enableSignedDatetimes) + } +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryConfig.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryConfig.kt new file mode 100644 index 00000000..1e37a856 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryConfig.kt @@ -0,0 +1,52 @@ +package tech.ydb.exposed.dialect + +/** + * Retry and backoff settings for [ydbTransaction]. + * + * Fast tier: `Aborted`, `Undetermined`, `Unavailable`, gRPC errors. + * Slow tier: `Overloaded`, `CLIENT_RESOURCE_EXHAUSTED`. + */ +data class YdbRetryConfig( + /** Total number of execution attempts (initial try + retries). */ + val maxAttempts: Int = DEFAULT_MAX_ATTEMPTS, + /** Base delay for full/equal jitter on the fast tier (ms). */ + val fastBackoffBaseMs: Int = DEFAULT_FAST_BACKOFF_BASE_MS, + /** Base delay for equal jitter on the slow tier (ms). */ + val slowBackoffBaseMs: Int = DEFAULT_SLOW_BACKOFF_BASE_MS, + /** Upper bound for fast-tier backoff before jitter (ms). */ + val fastCapBackoffMs: Int = DEFAULT_FAST_CAP_BACKOFF_MS, + /** Upper bound for slow-tier backoff before jitter (ms). */ + val slowCapBackoffMs: Int = DEFAULT_SLOW_CAP_BACKOFF_MS, + /** + * When `true`, statuses handled by [getNextRetryDelayMs] are retried even if they are not + * [isTransientVendorCode]. Enable only for idempotent work. + */ + val enableRetryIdempotence: Boolean = false, +) { + internal val fastCeiling: Int + get() = ceilingFromCapBackoffMs(fastCapBackoffMs) + + internal val slowCeiling: Int + get() = ceilingFromCapBackoffMs(slowCapBackoffMs) + + companion object { + const val DEFAULT_MAX_ATTEMPTS: Int = 10 + const val DEFAULT_FAST_BACKOFF_BASE_MS: Int = 5 + const val DEFAULT_SLOW_BACKOFF_BASE_MS: Int = 50 + const val DEFAULT_FAST_CAP_BACKOFF_MS: Int = 500 + const val DEFAULT_SLOW_CAP_BACKOFF_MS: Int = 5_000 + + /** Default for read-write / non-idempotent transaction bodies. */ + @JvmField + val DEFAULT: YdbRetryConfig = YdbRetryConfig(enableRetryIdempotence = false) + + /** Default for idempotent bodies (reads, single UPSERT/REPLACE, etc.). */ + @JvmField + val IDEMPOTENT: YdbRetryConfig = YdbRetryConfig(enableRetryIdempotence = true) + } +} + +internal fun ceilingFromCapBackoffMs(capBackoffMs: Int): Int { + val value = capBackoffMs + 1 + return kotlin.math.ceil(kotlin.math.ln(value.toDouble()) / kotlin.math.ln(2.0)).toInt() +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryPolicy.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryPolicy.kt new file mode 100644 index 00000000..6dabd359 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryPolicy.kt @@ -0,0 +1,132 @@ +package tech.ydb.exposed.dialect + +import tech.ydb.exposed.dialect.code.YdbVendorCode +import java.sql.SQLException +import kotlin.math.min +import kotlin.random.Random + +/** + * Retry delay calculation for [ydbTransaction] from JDBC [SQLException.errorCode] (YDB vendor codes). + * + * - [fullJitterMillis] — `Aborted`, `Undetermined` + * - [equalJitterMillis] — `Unavailable`, transport errors, `Overloaded`, resource exhausted + * - zero delay — `BadSession`, `SessionBusy`, `SessionExpired` + * + * Not retried here (add handling if your workload needs it): `TIMEOUT`, `CLIENT_DEADLINE_EXPIRED`, + * `PRECONDITION_FAILED`, and other vendor codes. [YdbRetryConfig.enableRetryIdempotence] retries + * any code returned by this policy when `true`, not only [isTransientVendorCode]. + * `SESSION_EXPIRED` is retried with zero backoff but is not in the transient set. + */ +internal fun getNextRetryDelayMs( + error: Throwable, + attempt: Int, + config: YdbRetryConfig, + random: Random = Random.Default +): Long? { + if (attempt >= config.maxAttempts - 1) { + return null + } + + val vendorCode = extractVendorCode(error) ?: return null + + if (!config.enableRetryIdempotence && !isTransientVendorCode(vendorCode)) { + return null + } + + return when (vendorCode) { + YdbVendorCode.BAD_SESSION, + YdbVendorCode.SESSION_BUSY, + YdbVendorCode.SESSION_EXPIRED -> 0L + + YdbVendorCode.ABORTED, + YdbVendorCode.UNDETERMINED -> + fullJitterMillis( + backoffBaseMs = config.fastBackoffBaseMs, + capMs = config.fastCapBackoffMs, + ceiling = config.fastCeiling, + attempt = attempt, + random = random + ) + + YdbVendorCode.UNAVAILABLE, + YdbVendorCode.TRANSPORT_UNAVAILABLE, + YdbVendorCode.CLIENT_GRPC_ERROR -> + equalJitterMillis( + backoffBaseMs = config.fastBackoffBaseMs, + capMs = config.fastCapBackoffMs, + ceiling = config.fastCeiling, + attempt = attempt, + random = random + ) + + YdbVendorCode.OVERLOADED, + YdbVendorCode.CLIENT_RESOURCE_EXHAUSTED -> + equalJitterMillis( + backoffBaseMs = config.slowBackoffBaseMs, + capMs = config.slowCapBackoffMs, + ceiling = config.slowCeiling, + attempt = attempt, + random = random + ) + + else -> null + } +} + +/** Vendor codes retried without [YdbRetryConfig.enableRetryIdempotence]. */ +internal fun isTransientVendorCode(vendorCode: Int): Boolean = vendorCode in TRANSIENT_VENDOR_CODES + +internal fun extractVendorCode(error: Throwable): Int? { + var current: Throwable? = error + while (current != null) { + if (current is SQLException) { + val vendorCode = current.errorCode + if (vendorCode != 0) { + return vendorCode + } + } + current = current.cause + } + return null +} + +internal fun calculateBackoffMillis( + backoffBaseMs: Int, + capMs: Int, + ceiling: Int, + attempt: Int +): Int = min(backoffBaseMs * (1 shl min(ceiling, attempt)), capMs) + +/** Full jitter: uniform in `[0, calculatedBackoff]`. */ +internal fun fullJitterMillis( + backoffBaseMs: Int, + capMs: Int, + ceiling: Int, + attempt: Int, + random: Random +): Long { + val calculatedBackoff = calculateBackoffMillis(backoffBaseMs, capMs, ceiling, attempt) + return random.nextLong(calculatedBackoff + 1L) +} + +/** Equal jitter: `calculatedBackoff/2 + calculatedBackoff%2 + random(0..calculatedBackoff/2)`. */ +internal fun equalJitterMillis( + backoffBaseMs: Int, + capMs: Int, + ceiling: Int, + attempt: Int, + random: Random +): Long { + val calculatedBackoff = calculateBackoffMillis(backoffBaseMs, capMs, ceiling, attempt) + val temp = calculatedBackoff / 2 + return temp + calculatedBackoff % 2 + random.nextLong(temp + 1L) +} + +private val TRANSIENT_VENDOR_CODES: Set = setOf( + YdbVendorCode.ABORTED, + YdbVendorCode.UNAVAILABLE, + YdbVendorCode.OVERLOADED, + YdbVendorCode.CLIENT_RESOURCE_EXHAUSTED, + YdbVendorCode.BAD_SESSION, + YdbVendorCode.SESSION_BUSY +) diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTransaction.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTransaction.kt new file mode 100644 index 00000000..853582ab --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTransaction.kt @@ -0,0 +1,55 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +import org.jetbrains.exposed.v1.jdbc.transactions.transaction + +/** + * Runs [statement] inside an Exposed [transaction] with YDB-friendly defaults and retries. + * + * Each attempt uses `TRANSACTION_SERIALIZABLE` (YDB snapshot isolation / OCC). On retryable + * [java.sql.SQLException] vendor codes ([getNextRetryDelayMs]) the whole transaction is re-run after + * jittered backoff; the last failure is rethrown when [YdbRetryConfig.maxAttempts] is exhausted. + * + * Use [YdbRetryConfig.IDEMPOTENT] only when the body is safe to repeat (reads, idempotent UPSERT). + * + * @param db Target database; `null` uses Exposed's current default. + * @param retry Backoff caps and whether non-transient codes may be retried. + * @param readOnly Passed through to Exposed `transaction(readOnly = ...)`. + */ +fun ydbTransaction( + db: Database? = null, + retry: YdbRetryConfig = YdbRetryConfig.DEFAULT, + readOnly: Boolean = false, + statement: JdbcTransaction.() -> T +): T { + require(retry.maxAttempts >= 1) { "maxAttempts must be >= 1" } + + var lastError: Throwable? = null + + repeat(retry.maxAttempts) { attempt -> + try { + return transaction( + db = db, + transactionIsolation = java.sql.Connection.TRANSACTION_SERIALIZABLE, + readOnly = readOnly, + statement = statement + ) + } catch (t: Throwable) { + lastError = t + + val delayMs = getNextRetryDelayMs(t, attempt, retry) ?: throw t + + if (delayMs > 0) { + try { + Thread.sleep(delayMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } + } + } + } + + throw lastError ?: IllegalStateException("Retry loop finished without result") +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/code/YdbJdbcCode.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/code/YdbJdbcCode.kt new file mode 100644 index 00000000..0b57c82a --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/code/YdbJdbcCode.kt @@ -0,0 +1,46 @@ +package tech.ydb.exposed.dialect.code + +/** + * YDB JDBC vendor type codes for [java.sql.PreparedStatement.setObject] (index, value, sqlType). + * + * Layout matches the YDB JDBC driver ([YdbTypes](https://github.com/ydb-platform/ydb-jdbc-driver/blob/master/jdbc/src/main/java/tech/ydb/jdbc/common/YdbTypes.java)). + */ +internal object YdbJdbcCode { + const val SQL_KIND_PRIMITIVE: Int = 10_000 + const val SQL_KIND_DECIMAL: Int = 1 shl 14 + + const val BOOL: Int = SQL_KIND_PRIMITIVE + const val INT8: Int = SQL_KIND_PRIMITIVE + 1 + const val UINT8: Int = SQL_KIND_PRIMITIVE + 2 + const val INT16: Int = SQL_KIND_PRIMITIVE + 3 + const val UINT16: Int = SQL_KIND_PRIMITIVE + 4 + const val INT32: Int = SQL_KIND_PRIMITIVE + 5 + const val UINT32: Int = SQL_KIND_PRIMITIVE + 6 + const val INT64: Int = SQL_KIND_PRIMITIVE + 7 + const val UINT64: Int = SQL_KIND_PRIMITIVE + 8 + const val FLOAT: Int = SQL_KIND_PRIMITIVE + 9 + const val DOUBLE: Int = SQL_KIND_PRIMITIVE + 10 + const val BYTES: Int = SQL_KIND_PRIMITIVE + 11 + const val TEXT: Int = SQL_KIND_PRIMITIVE + 12 + const val YSON: Int = SQL_KIND_PRIMITIVE + 13 + const val JSON: Int = SQL_KIND_PRIMITIVE + 14 + const val UUID: Int = SQL_KIND_PRIMITIVE + 15 + const val DATE: Int = SQL_KIND_PRIMITIVE + 16 + const val DATETIME: Int = SQL_KIND_PRIMITIVE + 17 + const val TIMESTAMP: Int = SQL_KIND_PRIMITIVE + 18 + const val INTERVAL: Int = SQL_KIND_PRIMITIVE + 19 + const val TZ_DATE: Int = SQL_KIND_PRIMITIVE + 20 + const val TZ_DATETIME: Int = SQL_KIND_PRIMITIVE + 21 + const val TZ_TIMESTAMP: Int = SQL_KIND_PRIMITIVE + 22 + const val JSON_DOCUMENT: Int = SQL_KIND_PRIMITIVE + 23 + const val DATE32: Int = SQL_KIND_PRIMITIVE + 25 + const val DATETIME64: Int = SQL_KIND_PRIMITIVE + 26 + const val TIMESTAMP64: Int = SQL_KIND_PRIMITIVE + 27 + const val INTERVAL64: Int = SQL_KIND_PRIMITIVE + 28 + + fun decimal(precision: Int, scale: Int): Int { + require(precision in 1..35) { "YDB Decimal precision must be in 1..35" } + require(scale in 0..precision) { "YDB Decimal scale must be in 0..precision" } + return SQL_KIND_DECIMAL + (precision shl 6) + scale + } +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/code/YdbVendorCode.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/code/YdbVendorCode.kt new file mode 100644 index 00000000..c120f41c --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/code/YdbVendorCode.kt @@ -0,0 +1,35 @@ +package tech.ydb.exposed.dialect.code + +/** + * YDB status codes as JDBC [java.sql.SQLException.getErrorCode] vendor codes. + * + * Values match [tech.ydb.core.StatusCode](https://github.com/ydb-platform/ydb-java-sdk/blob/master/core/src/main/java/tech/ydb/core/StatusCode.java) + * and [tech.ydb.core.Constants](https://github.com/ydb-platform/ydb-java-sdk/blob/master/core/src/main/java/tech/ydb/core/Constants.java). + */ +internal object YdbVendorCode { + const val SERVER_STATUSES_FIRST: Int = 400_000 + const val TRANSPORT_STATUSES_FIRST: Int = 401_000 + const val INTERNAL_CLIENT_FIRST: Int = 402_000 + + const val ABORTED: Int = SERVER_STATUSES_FIRST + 40 + const val UNAVAILABLE: Int = SERVER_STATUSES_FIRST + 50 + const val OVERLOADED: Int = SERVER_STATUSES_FIRST + 60 + const val TIMEOUT: Int = SERVER_STATUSES_FIRST + 90 + const val BAD_SESSION: Int = SERVER_STATUSES_FIRST + 100 + const val PRECONDITION_FAILED: Int = SERVER_STATUSES_FIRST + 120 + const val NOT_FOUND: Int = SERVER_STATUSES_FIRST + 140 + const val SESSION_EXPIRED: Int = SERVER_STATUSES_FIRST + 150 + const val UNDETERMINED: Int = SERVER_STATUSES_FIRST + 170 + const val SESSION_BUSY: Int = SERVER_STATUSES_FIRST + 190 + + const val TRANSPORT_UNAVAILABLE: Int = TRANSPORT_STATUSES_FIRST + 10 + const val CLIENT_RESOURCE_EXHAUSTED: Int = TRANSPORT_STATUSES_FIRST + 20 + const val CLIENT_INTERNAL_ERROR: Int = TRANSPORT_STATUSES_FIRST + 50 + const val CLIENT_CANCELLED: Int = TRANSPORT_STATUSES_FIRST + 60 + + const val CLIENT_DISCOVERY_FAILED: Int = INTERNAL_CLIENT_FIRST + 10 + const val CLIENT_LIMITS_REACHED: Int = INTERNAL_CLIENT_FIRST + 20 + const val CLIENT_DEADLINE_EXPIRED: Int = INTERNAL_CLIENT_FIRST + 30 + const val CLIENT_GRPC_ERROR: Int = INTERNAL_CLIENT_FIRST + 40 + const val CLIENT_DEADLINE_EXCEEDED: Int = TRANSPORT_STATUSES_FIRST + 30 +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/createYdbStatement.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/createYdbStatement.kt new file mode 100644 index 00000000..856c32a7 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/createYdbStatement.kt @@ -0,0 +1,120 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager + +/** + * Builds a YDB-compatible `CREATE TABLE` statement for this Exposed [Table]. + * + * This function is intended to be used from [Table.createStatement] overrides + * for tables that should be created in YDB. + * + * The reason for this helper is that Exposed may render a single-column primary + * key inline, for example: + * + * ```sql + * CREATE TABLE indexed_table ( + * id Int32 PRIMARY KEY, + * email Utf8, + * name Utf8 + * ) + * ``` + * + * YDB does not accept inline primary key declarations. It expects the primary + * key to be declared as a table-level clause: + * + * ```sql + * CREATE TABLE indexed_table ( + * id Int32 NOT NULL, + * email Utf8 NOT NULL, + * name Utf8 NOT NULL, + * INDEX ..., + * PRIMARY KEY (id) + * ) + * ``` + * + * This function renders the table in the YDB-compatible form by: + * + * - rendering all columns without inline `PRIMARY KEY` declarations; + * - appending a table-level `PRIMARY KEY (...)` clause; + * - preserving `NOT NULL` for non-nullable columns; + * - preserving database-side default values; + * - appending Exposed indexes declared on this table. + * + * The generated statement is plain `CREATE TABLE`, not + * `CREATE TABLE IF NOT EXISTS`. This is useful for migration tools, because an + * already existing table should normally fail the migration instead of being + * silently ignored. + * + * Example: + * + * ```kotlin + * object Users : Table("users") { + * val id = integer("id") + * val email = varchar("email", 255) + * val name = varchar("name", 255) + * + * override val primaryKey = PrimaryKey(id) + * + * init { + * index(false, email) + * index("email-cover-idx", isUnique = true, email) + * } + * + * val emailIndexDefinition + * get() = indices.single { !it.unique && it.columns == listOf(email) } + * + * override fun createStatement() = createYdbStatement() + * } + * ``` + * + * @return A list containing a single YDB-compatible `CREATE TABLE` statement + * for this table. + * + * @throws IllegalStateException If this table does not define a primary key. + * YDB requires every table to have one. + * + * @see Table.createStatement + * @see Table.primaryKey + */ +fun Table.createYdbStatement(): List { + val tr = TransactionManager.current() + + val pk = primaryKey ?: error("YDB requires PRIMARY KEY for every table: $tableName") + + val columnsSql = columns.joinToString(", ") { column -> + buildString { + append(tr.identity(column)) + append(" ") + append(column.columnType.sqlType()) + if (!column.columnType.nullable) { + append(" NOT NULL") + } + + if (column.defaultValueInDb() != null) { + append(" DEFAULT ") + append(column.defaultValueInDb()) + } + } + } + + val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } + + val sql = buildString { + append("CREATE TABLE IF NOT EXISTS ") + append(tr.identity(this@createYdbStatement)) + append(" (") + append(columnsSql) + append(", PRIMARY KEY (") + append(pkSql) + append("))") + + if (storageParameters.isNotEmpty()) { + append(" WITH (") + append(storageParameters.joinToString(separator = ", ") { it.toSQL() }) + append(")") + } + } + + return listOf(sql) +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/javatime/YdbJavaTimeColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/javatime/YdbJavaTimeColumnTypes.kt new file mode 100644 index 00000000..00a4e9ad --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/javatime/YdbJavaTimeColumnTypes.kt @@ -0,0 +1,113 @@ +/** + * YDB `java.time` column types with correct JDBC vendor binding. + * + * Import this package explicitly — it does **not** replace `org.jetbrains.exposed.v1.javatime.date`. + * + * Pair column DDL with the JDBC driver: + * - Legacy unsigned: [ydbDate], [ydbDatetime], [ydbTimestamp] + `forceSignedDatetimes=false` (default). + * - Extended signed: [ydbDate32], [ydbDatetime64], [ydbTimestamp64] + `forceSignedDatetimes=true`. + * + * [tech.ydb.exposed.dialect.registerYdbDialect] with `enableSignedDatetimes = true` only changes standard Exposed + * `date`/`datetime`/`timestamp` DDL via [tech.ydb.exposed.dialect.YdbDataTypeProvider]; these + * extensions always emit the type named in the function (`Date` vs `Date32`, etc.). + */ +@file:OptIn(kotlin.time.ExperimentalTime::class) + +package tech.ydb.exposed.dialect.javatime + +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalDateTime +import kotlinx.datetime.toKotlinLocalDate +import kotlinx.datetime.toKotlinLocalDateTime +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.datetime.InstantColumnType +import org.jetbrains.exposed.v1.core.datetime.LocalDateColumnType +import org.jetbrains.exposed.v1.core.datetime.LocalDateTimeColumnType +import org.jetbrains.exposed.v1.core.statements.api.PreparedStatementApi +import tech.ydb.exposed.dialect.bindYdbParameter +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime + +// region Table column extensions + +/** Legacy unsigned YDB `Date` ([LocalDate]). */ +fun Table.ydbDate(name: String): Column = + registerColumn(name, YdbDateColumnType(YdbJdbcCode.DATE)) + +/** Extended signed YDB `Date32` ([LocalDate]). */ +fun Table.ydbDate32(name: String): Column = + registerColumn(name, YdbDateColumnType(YdbJdbcCode.DATE32)) + +/** Legacy unsigned YDB `Datetime` ([LocalDateTime]). */ +fun Table.ydbDatetime(name: String): Column = + registerColumn(name, YdbDateTimeColumnType(YdbJdbcCode.DATETIME)) + +/** Extended signed YDB `Datetime64` ([LocalDateTime]). */ +fun Table.ydbDatetime64(name: String): Column = + registerColumn(name, YdbDateTimeColumnType(YdbJdbcCode.DATETIME64)) + +/** Legacy unsigned YDB `Timestamp` ([Instant]). */ +fun Table.ydbTimestamp(name: String): Column = + registerColumn(name, YdbTimestampColumnType(YdbJdbcCode.TIMESTAMP)) + +/** Extended signed YDB `Timestamp64` ([Instant]). */ +fun Table.ydbTimestamp64(name: String): Column = + registerColumn(name, YdbTimestampColumnType(YdbJdbcCode.TIMESTAMP64)) + +// endregion + +// region Column types + +internal class YdbDateColumnType( + private val jdbcTypeCode: Int +) : LocalDateColumnType() { + override fun sqlType(): String = + if (jdbcTypeCode == YdbJdbcCode.DATE) "Date" else "Date32" + + override fun toLocalDate(value: LocalDate): kotlinx.datetime.LocalDate = value.toKotlinLocalDate() + + override fun fromLocalDate(value: kotlinx.datetime.LocalDate): LocalDate = value.toJavaLocalDate() + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + bindYdbParameter(stmt, index, value, jdbcTypeCode) + } +} + +internal class YdbDateTimeColumnType( + private val jdbcTypeCode: Int +) : LocalDateTimeColumnType() { + override fun sqlType(): String = + if (jdbcTypeCode == YdbJdbcCode.DATETIME) "Datetime" else "Datetime64" + + override fun toLocalDateTime(value: LocalDateTime): kotlinx.datetime.LocalDateTime = + value.toKotlinLocalDateTime() + + override fun fromLocalDateTime(value: kotlinx.datetime.LocalDateTime): LocalDateTime = + value.toJavaLocalDateTime() + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + bindYdbParameter(stmt, index, value, jdbcTypeCode) + } +} + +internal class YdbTimestampColumnType( + private val jdbcTypeCode: Int +) : InstantColumnType() { + override fun sqlType(): String = + if (jdbcTypeCode == YdbJdbcCode.TIMESTAMP) "Timestamp" else "Timestamp64" + + override fun toInstant(value: Instant): kotlin.time.Instant = + kotlin.time.Instant.fromEpochMilliseconds(value.toEpochMilli()) + + override fun fromInstant(value: kotlin.time.Instant): Instant = + Instant.ofEpochMilli(value.toEpochMilliseconds()) + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + bindYdbParameter(stmt, index, value, jdbcTypeCode) + } +} + +// endregion diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/BaseYdbTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/BaseYdbTest.kt new file mode 100644 index 00000000..bf54ac59 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/BaseYdbTest.kt @@ -0,0 +1,73 @@ +package tech.ydb.exposed.dialect.integration.base + +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.RegisterExtension +import tech.ydb.exposed.dialect.YDB_DRIVER_CLASS +import tech.ydb.exposed.dialect.registerYdbDialect +import tech.ydb.exposed.dialect.ydbTransaction +import tech.ydb.test.junit5.YdbHelperExtension +import java.sql.Connection + +/** + * Base class for integration tests. + * + * Starts a YDB testcontainer via [YdbHelperExtension] and opens an Exposed [Database] + * against it. Schema is created in `@BeforeEach` and dropped in `@AfterEach` + * (YDB DDL is not transactional, so we drop in teardown rather than retry-on-create). + */ +abstract class BaseYdbTest { + + protected lateinit var db: Database + + protected open val tables: List = emptyList() + + protected open val enableSignedDatetimes: Boolean = false + + /** Appended to the JDBC URL after `disablePrepareDataQuery=true` (e.g. `&forceSignedDatetimes=true`). */ + protected open val jdbcUrlSuffix: String = "" + + @BeforeEach + fun setupDatabase() { + registerYdbDialect(enableSignedDatetimes) + + val jdbcUrl = buildString { + append("jdbc:ydb:") + append(if (ydb.useTls()) "grpcs://" else "grpc://") + append(ydb.endpoint()) + append(ydb.database()) + append("?disablePrepareDataQuery=true") + append(jdbcUrlSuffix) + ydb.authToken()?.let { append("&token=").append(it) } + } + + db = Database.connect( + url = jdbcUrl, + driver = YDB_DRIVER_CLASS, + databaseConfig = DatabaseConfig { + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + useNestedTransactions = false + } + ) + + if (tables.isNotEmpty()) { + transaction(db) { + runCatching { SchemaUtils.drop(*tables.toTypedArray()) } + SchemaUtils.create(*tables.toTypedArray()) + } + } + } + + protected fun tx(block: JdbcTransaction.() -> Unit) = ydbTransaction(db, statement = block) + + companion object { + @JvmField + @RegisterExtension + val ydb: YdbHelperExtension = YdbHelperExtension() + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/ConnectionIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/ConnectionIT.kt new file mode 100644 index 00000000..02c141b1 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/ConnectionIT.kt @@ -0,0 +1,12 @@ +package tech.ydb.exposed.dialect.integration.base + +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +class ConnectionIT : BaseYdbTest() { + + @Test + fun `should connect to ydb with explicit dialect`() = tx { + assertNotNull(connection) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/RegisterYdbDialectConnectIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/RegisterYdbDialectConnectIT.kt new file mode 100644 index 00000000..dfd92ffb --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/RegisterYdbDialectConnectIT.kt @@ -0,0 +1,104 @@ +package tech.ydb.exposed.dialect.integration.basic + +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import tech.ydb.exposed.dialect.YDB_DRIVER_CLASS +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.registerYdbDialect +import tech.ydb.exposed.dialect.ydbTransaction +import tech.ydb.test.junit5.YdbHelperExtension +import java.sql.Connection + +/** + * Verifies that after [registerYdbDialect] plain Exposed [Database.connect] works + * with the same [DatabaseConfig] defaults as integration tests. + */ +class RegisterYdbDialectConnectIT { + + companion object { + @JvmField + @RegisterExtension + val ydb: YdbHelperExtension = YdbHelperExtension() + } + + object PlainConnectTable : Table("register_ydb_dialect_plain_connect") { + val id = integer("id") + val label = varchar("label", 64) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + private lateinit var jdbcUrl: String + private lateinit var db: Database + + @BeforeEach + fun setUp() { + registerYdbDialect() + + jdbcUrl = buildString { + append("jdbc:ydb:") + append(if (ydb.useTls()) "grpcs://" else "grpc://") + append(ydb.endpoint()) + append(ydb.database()) + append("?disablePrepareDataQuery=true") + ydb.authToken()?.let { append("&token=").append(it) } + } + + db = Database.connect( + url = jdbcUrl, + driver = YDB_DRIVER_CLASS, + databaseConfig = DatabaseConfig { + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + useNestedTransactions = false + } + ) + } + + @AfterEach + fun tearDown() { + if (!::db.isInitialized) return + + runCatching { + ydbTransaction(db) { + SchemaUtils.drop(PlainConnectTable) + } + } + runCatching { TransactionManager.closeAndUnregister(db) } + } + + @Test + fun `Database connect resolves YdbDialect after registerYdbDialect`() { + assertInstanceOf(YdbDialect::class.java, db.dialect) + } + + @Test + fun `Table ddl insert and select work with plain Database connect`() = ydbTransaction(db) { + SchemaUtils.create(PlainConnectTable) + + PlainConnectTable.insert { + it[id] = 1 + it[label] = "via-plain-connect" + } + + assertEquals("via-plain-connect", PlainConnectTable.selectAll().single()[PlainConnectTable.label]) + + val ddl = PlainConnectTable.ddl.joinToString(" ") + assertTrue(ddl.contains("PRIMARY KEY (id)")) + assertTrue(ddl.contains("label Text")) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbIndexSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbIndexSqlIT.kt new file mode 100644 index 00000000..09e4214b --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbIndexSqlIT.kt @@ -0,0 +1,88 @@ +package tech.ydb.exposed.dialect.integration.basic + +import org.jetbrains.exposed.v1.core.Function +import org.jetbrains.exposed.v1.core.Index +import org.jetbrains.exposed.v1.core.QueryBuilder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class YdbIndexSqlIT : BaseYdbTest() { + + object IndexedTable : Table("indexed_table") { + val id = integer("id") + val email = varchar("email", 255) + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + init { + index(false, email) + index("email-cover-idx", isUnique = true, email) + } + + val emailIndexDefinition + get() = indices.single { !it.unique && it.columns == listOf(email) } + + override fun createStatement() = createYdbStatement() + } + + @Test + fun `renders a standard Exposed index as YDB ALTER TABLE`() { + transaction(db) { + val dialect = db.dialect as YdbDialect + val sql = dialect.createIndex(IndexedTable.emailIndexDefinition) + + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("ADD INDEX"), sql) + assertTrue(sql.contains("GLOBAL ON"), sql) + assertTrue(sql.contains("email"), sql) + } + } + + @Test + fun `rejects functional indexes`() { + transaction(db) { + val dialect = db.dialect as YdbDialect + val functionIndex = Index( + columns = emptyList(), + unique = false, + customName = "email_lower_idx", + indexType = null, + filterCondition = null, + functions = listOf( + object : Function(IndexedTable.email.columnType) { + override fun toQueryBuilder(queryBuilder: QueryBuilder) { + queryBuilder.append("LOWER(email)") + } + } + ), + functionsTable = IndexedTable + ) + + val error = assertThrows(UnsupportedOperationException::class.java) { + dialect.createIndex(functionIndex) + } + + assertTrue(error.message == "YDB dialect does not support functional indexes", error.message) + } + } + + @Test + fun `renders unique index with custom name`() { + transaction(db) { + val dialect = db.dialect as YdbDialect + val uniqueIndex = IndexedTable.indices.single { it.unique } + val sql = dialect.createIndex(uniqueIndex) + val expectedName = db.identifierManager.cutIfNecessaryAndQuote("email-cover-idx") + + assertTrue(sql.contains("GLOBAL UNIQUE"), sql) + assertTrue(sql.contains(expectedName), sql) + } + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt new file mode 100644 index 00000000..4f6ac4ac --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt @@ -0,0 +1,90 @@ +package tech.ydb.exposed.dialect.integration.basic + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbUint64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 +import java.sql.SQLException + +class YdbTableIT : BaseYdbTest() { + + object BasicTable : Table("unit_basic_table") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement() = createYdbStatement() + } + + object TtlTimestampTable : Table("unit_ttl_timestamp_table") { + val id = integer("id") + val expireAt = ydbTimestamp64("expire_at") + + override val storageParameters: List = + listOf(RawTableStorageParameter("TTL = Interval(\"PT1H\") ON expire_at")) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement() = createYdbStatement() + } + + object TtlNumericTable : Table("unit_ttl_numeric_table") { + val id = integer("id") + val modifiedAtEpoch = ydbUint64("modified_at_epoch") + + override val storageParameters: List = + listOf(RawTableStorageParameter("TTL = Interval(\"PT1H\") ON modified_at_epoch AS SECONDS")) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement() = createYdbStatement() + } + + object NoPkTable : Table("unit_no_pk_table") { + val id = integer("id") + val name = varchar("name", 255) + + override fun createStatement() = createYdbStatement() + } + + @Test + fun `renders CREATE TABLE with primary key`() = tx { + val ddl = BasicTable.ddl.joinToString(" ") + assertTrue(ddl.contains("CREATE TABLE IF NOT EXISTS"), ddl) + assertTrue( + ddl.contains("PRIMARY KEY (id)") || ddl.contains("PRIMARY KEY (`id`)"), + ddl + ) + assertTrue( + ddl.contains("`name` Text") || ddl.contains("name Text"), + ddl + ) + } + + @Test + fun `renders TTL clause for a Timestamp64 column`() = tx { + val ddl = TtlTimestampTable.ddl.joinToString(" ") + assertTrue(ddl.contains("""WITH (TTL = Interval("PT1H") ON expire_at)"""), ddl) + assertTrue(ddl.contains("Timestamp64"), ddl) + } + + @Test + fun `renders TTL clause for a numeric epoch column`() = tx { + val ddl = TtlNumericTable.ddl.joinToString(" ") + assertTrue(ddl.contains("""WITH (TTL = Interval("PT1H") ON modified_at_epoch AS SECONDS)"""), ddl) + assertTrue(ddl.contains("modified_at_epoch Uint64"), ddl) + } + + @Test + fun `fails when the table has no primary key`() = tx { + assertThrows(IllegalStateException::class.java) { + NoPkTable.ddl + } + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbUniqueIndexSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbUniqueIndexSqlIT.kt new file mode 100644 index 00000000..fb9d9dfb --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbUniqueIndexSqlIT.kt @@ -0,0 +1,55 @@ +package tech.ydb.exposed.dialect.integration.basic + +import org.jetbrains.exposed.v1.core.Index +import org.jetbrains.exposed.v1.core.Table +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class YdbUniqueIndexSqlIT : BaseYdbTest() { + + object T : Table("t_unique_idx_test") { + val id = integer("id") + val email = varchar("email", 255) + + override val primaryKey = PrimaryKey(id) + + init { + index(true, email) + } + + val emailIndexDefinition + get() = indices.single { it.columns == listOf(email) } + } + + @Test + fun `renders a unique standard Exposed index`() = tx { + val dialect = db.dialect as YdbDialect + + val sql = dialect.createIndex(T.emailIndexDefinition) + + assertTrue(sql.contains("ADD INDEX"), sql) + assertTrue(sql.contains("GLOBAL UNIQUE"), sql) + assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) + } + + @Test + fun `quotes a custom index name through the identifier manager`() = tx { + val dialect = db.dialect as YdbDialect + val index = Index( + columns = listOf(T.email), + unique = false, + customName = "select", + indexType = null, + filterCondition = null, + functions = emptyList(), + functionsTable = T + ) + + val sql = dialect.createIndex(index) + + assertTrue(sql.contains("ADD INDEX"), sql) + assertTrue(sql.contains("`select`") || sql.contains("\"select\""), sql) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/batch/BatchOperationsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/batch/BatchOperationsIT.kt new file mode 100644 index 00000000..1f7796ed --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/batch/BatchOperationsIT.kt @@ -0,0 +1,122 @@ +package tech.ydb.exposed.dialect.integration.batch + +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.batchInsert +import org.jetbrains.exposed.v1.jdbc.batchUpsert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class BatchOperationsIT : BaseYdbTest() { + + object BatchItems : Table("batch_items") { + val id = integer("id") + val name = varchar("name", 255) + val quantity = integer("quantity") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(BatchItems) + + data class ItemRow( + val id: Int, + val name: String, + val quantity: Int + ) + + @Test + fun `should support batch insert`() = tx { + val items = listOf( + ItemRow(1, "apple", 10), + ItemRow(2, "banana", 20), + ItemRow(3, "orange", 30) + ) + + BatchItems.batchInsert(items) { item -> + this[BatchItems.id] = item.id + this[BatchItems.name] = item.name + this[BatchItems.quantity] = item.quantity + } + + val rows = BatchItems + .selectAll() + .orderBy(BatchItems.id to SortOrder.ASC) + .toList() + + assertEquals(3, rows.size) + assertEquals("apple", rows[0][BatchItems.name]) + assertEquals(10, rows[0][BatchItems.quantity]) + assertEquals("banana", rows[1][BatchItems.name]) + assertEquals(20, rows[1][BatchItems.quantity]) + assertEquals("orange", rows[2][BatchItems.name]) + assertEquals(30, rows[2][BatchItems.quantity]) + } + + @Test + fun `should allow querying rows inserted by batch insert`() = tx { + val items = listOf( + ItemRow(1, "apple", 10), + ItemRow(2, "banana", 20), + ItemRow(3, "orange", 30) + ) + + BatchItems.batchInsert(items) { item -> + this[BatchItems.id] = item.id + this[BatchItems.name] = item.name + this[BatchItems.quantity] = item.quantity + } + + val banana = BatchItems + .selectAll() + .where { BatchItems.name eq "banana" } + .single() + + assertEquals(2, banana[BatchItems.id]) + assertEquals(20, banana[BatchItems.quantity]) + assertTrue(banana[BatchItems.name].startsWith("ban")) + } + + @Test + fun `should support batch upsert`() = tx { + BatchItems.batchInsert( + listOf( + ItemRow(1, "apple", 10), + ItemRow(2, "banana", 20) + ) + ) { item -> + this[BatchItems.id] = item.id + this[BatchItems.name] = item.name + this[BatchItems.quantity] = item.quantity + } + + BatchItems.batchUpsert( + listOf( + ItemRow(1, "apple-updated", 11), + ItemRow(3, "orange", 30) + ) + ) { item -> + this[BatchItems.id] = item.id + this[BatchItems.name] = item.name + this[BatchItems.quantity] = item.quantity + } + + val rows = BatchItems + .selectAll() + .orderBy(BatchItems.id to SortOrder.ASC) + .toList() + + assertEquals(3, rows.size) + assertEquals("apple-updated", rows[0][BatchItems.name]) + assertEquals(11, rows[0][BatchItems.quantity]) + assertEquals("banana", rows[1][BatchItems.name]) + assertEquals("orange", rows[2][BatchItems.name]) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/crud/CrudIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/crud/CrudIT.kt new file mode 100644 index 00000000..a863a4a8 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/crud/CrudIT.kt @@ -0,0 +1,45 @@ +package tech.ydb.exposed.dialect.integration.crud + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class CrudIT : BaseYdbTest() { + + object Users : Table("users") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Users) + + @Test + fun `should perform full CRUD`() = tx { + Users.insert { it[id] = 1; it[name] = "Alice" } + + // READ + val user = Users.selectAll().single() + Assertions.assertEquals("Alice", user[Users.name]) + + // UPDATE + Users.update({ Users.id eq 1 }) { it[name] = "Bob" } + val updated = Users.selectAll().single() + Assertions.assertEquals("Bob", updated[Users.name]) + + // DELETE + Users.deleteWhere { Users.id eq 1 } + val count = Users.selectAll().count() + Assertions.assertEquals(0, count) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/DaoSmokeIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/DaoSmokeIT.kt new file mode 100644 index 00000000..b30bff69 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/DaoSmokeIT.kt @@ -0,0 +1,70 @@ +package tech.ydb.exposed.dialect.integration.dao + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.dao.Entity +import org.jetbrains.exposed.v1.dao.EntityClass +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +/** Exposed [Entity] / [IdTable] smoke test against YDB. */ +class DaoSmokeIT : BaseYdbTest() { + + object Articles : IdTable("dao_articles") { + override val id = varchar("id", 64).entityId() + val title = varchar("title", 255) + val body = text("body") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + class Article(id: EntityID) : Entity(id) { + companion object : EntityClass(Articles) + + var title by Articles.title + var body by Articles.body + } + + override val tables: List
= listOf(Articles) + + @Test + fun `should support dao create read update delete with manual string id`() { + tx { + Article.new(id = "article-1") { + title = "draft" + body = "hello" + } + } + + tx { + val loaded = Article.findById("article-1") + assertNotNull(loaded) + assertEquals("draft", loaded!!.title) + assertEquals("hello", loaded.body) + + loaded.title = "published" + } + + tx { + val reloaded = Article.findById("article-1") + assertNotNull(reloaded) + assertEquals("published", reloaded!!.title) + + reloaded.delete() + } + + tx { + val deleted = Article.findById("article-1") + assertNull(deleted) + assertEquals(0, Articles.selectAll().count()) + } + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/SerialDaoIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/SerialDaoIT.kt new file mode 100644 index 00000000..dfe1990d --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/SerialDaoIT.kt @@ -0,0 +1,50 @@ +package tech.ydb.exposed.dialect.integration.dao + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +/** + * YDB `Serial` works with Exposed `autoIncrement()` on a standard [Table]. + */ +class SerialDaoIT : BaseYdbTest() { + + object Events : Table("serial_dao_events") { + val id = integer("id").autoIncrement() + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Events) + + @Test + fun `should create ddl with Serial and assign ids on insert`() = tx { + val ddl = Events.ddl.joinToString(" ") + assertTrue(ddl.contains("id Serial"), ddl) + assertTrue(ddl.contains("PRIMARY KEY (id)") || ddl.contains("PRIMARY KEY (`id`)"), ddl) + + val firstId = Events.insert { + it[name] = "opened" + } get Events.id + + val secondId = Events.insert { + it[name] = "closed" + } get Events.id + + assertTrue(firstId > 0) + assertTrue(secondId > firstId) + + val rows = Events.selectAll().orderBy(Events.id).toList() + assertEquals(2, rows.size) + assertEquals("opened", rows[0][Events.name]) + assertEquals("closed", rows[1][Events.name]) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/IndexIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/IndexIT.kt new file mode 100644 index 00000000..2041c227 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/IndexIT.kt @@ -0,0 +1,51 @@ +package tech.ydb.exposed.dialect.integration.ddl + +import org.jetbrains.exposed.v1.core.Table +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class IndexIT : BaseYdbTest() { + + object Customers : Table("customers") { + val id = integer("id") + val name = varchar("name", 255) + val email = varchar("email", 255) + + override val primaryKey = PrimaryKey(id) + + init { + index(false, email) + } + + val emailIndexDefinition + get() = indices.single { it.columns == listOf(email) } + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Customers) + + @Test + fun `should generate standard exposed index sql`() = tx { + val dialect = db.dialect as YdbDialect + val sql = dialect.createIndex(Customers.emailIndexDefinition) + + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("ADD INDEX"), sql) + assertTrue(sql.contains("GLOBAL ON"), sql) + assertTrue(sql.contains("email"), sql) + } + + @Test + fun `should read existing indexes from jdbc metadata`() = tx { + val indexes = db.dialectMetadata.existingIndices(Customers).getValue(Customers) + val byName = indexes.associateBy { it.indexName } + + assertTrue("customers_email" in byName.keys, indexes.joinToString { it.indexName }) + assertEquals(listOf(Customers.email), byName.getValue("customers_email").columns) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/UniqueIndexIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/UniqueIndexIT.kt new file mode 100644 index 00000000..2b692b00 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/UniqueIndexIT.kt @@ -0,0 +1,68 @@ +package tech.ydb.exposed.dialect.integration.ddl + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.exceptions.ExposedSQLException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class UniqueIndexIT : BaseYdbTest() { + + object UniqueCustomers : Table("unique_customers") { + val id = integer("id") + val email = varchar("email", 255) + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + init { + index("unique_email_idx", isUnique = true, email) + } + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(UniqueCustomers) + + /** + * [ERROR] UniqueIndexIT>BaseYdbTest.setupDatabase:61->BaseYdbTest.setupDatabase$lambda$2:63 » ExposedSQL tech.ydb.jdbc.exception.YdbSQLException: Cannot call 'SCHEME_QUERY >> + * CREATE TABLE unique_customers (id Int32 NOT NULL, email Text NOT NULL, `name` Text NOT NULL, PRIMARY KEY (id)); ALTER TABLE unique_customers ADD INDEX unique_email_idx GLOBAL UNIQUE ON (email);' with Status{code = BAD_REQUEST(code=400010), issues = [Failed item check: Adding a unique index to an existing table is disabled (S_ERROR)]} + */ +// @Test + fun `should reject duplicate value for unique index`() { + tx { + UniqueCustomers.insert { + it[id] = 1 + it[email] = "alice@example.com" + it[name] = "Alice" + } + } + + val error = assertThrows(ExposedSQLException::class.java) { + tx { + UniqueCustomers.insert { + it[id] = 2 + it[email] = "alice@example.com" + it[name] = "Bob" + } + } + } + + val message = error.message.orEmpty() + assertTrue( + message.contains("PRECONDITION_FAILED") || + message.contains("duplicate", ignoreCase = true) || + message.contains("unique", ignoreCase = true), + message + ) + + tx { + assertEquals(1, UniqueCustomers.selectAll().count()) + } + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/FunctionProviderIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/FunctionProviderIT.kt new file mode 100644 index 00000000..8a67787d --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/FunctionProviderIT.kt @@ -0,0 +1,153 @@ +package tech.ydb.exposed.dialect.integration.functions + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbFunctionProvider +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class FunctionProviderIT : BaseYdbTest() { + + object Users : Table("users") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object SourceUsers : Table("source_users") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + private val provider = YdbFunctionProvider + + @Test + fun `should generate UPSERT statement`() = tx { + val data = listOf( + Users.id to 1, + Users.name to "Alice" + ) + + val sql = provider.upsert( + table = Users, + data = data, + expression = "", + onUpdate = emptyList(), + keyColumns = emptyList(), + where = null, + transaction = this + ) + + assertTrue(sql.startsWith("UPSERT")) + assertTrue(sql.contains("users")) + assertTrue(sql.contains("id")) + assertTrue(sql.contains("name")) + } + + @Test + fun `should support NULL values`() = tx { + val data = listOf( + Users.id to 1, + Users.name to null + ) + + val sql = provider.upsert( + table = Users, + data = data, + expression = "", + onUpdate = emptyList(), + keyColumns = emptyList(), + where = null, + transaction = this + ) + + assertTrue(sql.contains("NULL")) + } + + @Test + fun `should reject WHERE in UPSERT`() { + assertThrows(UnsupportedOperationException::class.java) { + tx { + provider.upsert( + table = Users, + data = listOf(Users.id to 1, Users.name to "Alice"), + expression = "", + onUpdate = emptyList(), + keyColumns = listOf(Users.id), + where = Users.id eq 1, + transaction = this + ) + } + } + } + + @Test + fun `should ignore ON UPDATE in UPSERT`() = tx { + val sql = provider.upsert( + table = Users, + data = listOf(Users.id to 1, Users.name to "Alice"), + expression = "", + onUpdate = listOf(Users.name to "Bob"), + keyColumns = emptyList(), + where = null, + transaction = this + ) + + assertTrue(sql.startsWith("UPSERT")) + assertTrue(!sql.contains("ON UPDATE")) + } + + @Test + fun `should add column list to prepared UPSERT values expression`() = tx { + val sql = provider.upsert( + table = Users, + data = listOf(Users.id to 1, Users.name to "Alice"), + expression = "VALUES (?, ?)", + onUpdate = listOf(Users.name to "Alice"), + keyColumns = listOf(Users.id), + where = null, + transaction = this + ) + + assertTrue(sql.contains("UPSERT INTO")) + assertTrue( + sql.contains("(id, name)") || + sql.contains("(`id`, `name`)") || + sql.contains("(id, `name`)") || + sql.contains("(`id`, name)"), + sql + ) + assertTrue(sql.contains("VALUES (?, ?)")) + } + + @Test + fun `should reject MERGE and point users to UPSERT`() { + val error = assertThrows(UnsupportedOperationException::class.java) { + tx { + provider.merge( + dest = Users, + source = SourceUsers, + transaction = this, + clauses = emptyList(), + on = Users.id eq SourceUsers.id + ) + } + } + + assertEquals( + "YDB dialect does not support ANSI MERGE through Exposed. Use UPSERT or batchUpsert instead.", + error.message + ) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/YdbSqlFunctionsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/YdbSqlFunctionsIT.kt new file mode 100644 index 00000000..6493ad63 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/YdbSqlFunctionsIT.kt @@ -0,0 +1,197 @@ +package tech.ydb.exposed.dialect.integration.functions + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.TextColumnType +import org.jetbrains.exposed.v1.core.charLength +import org.jetbrains.exposed.v1.core.concat +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.locate +import org.jetbrains.exposed.v1.core.regexp +import org.jetbrains.exposed.v1.core.substring +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.json.Extract +import org.jetbrains.exposed.v1.json.exists +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbJson + +/** + * Integration tests for [tech.ydb.exposed.dialect.YdbFunctionProvider] string and JSON mappings + * against a live YDB instance. + * + * String functions use Exposed core DSL ([charLength], [substring], …). + * JSON functions use [exposed-json](https://www.jetbrains.com/help/exposed/json-and-jsonb-types.html) + * ([extract], [exists]), which delegate to [org.jetbrains.exposed.v1.core.vendors.FunctionProvider]. + */ +class YdbSqlFunctionsIT : BaseYdbTest() { + + object Strings : Table("fn_strings") { + val id = integer("id") + val value = text("value") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object JsonRows : Table("fn_json") { + val id = integer("id") + val payload = ydbJson("payload") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Strings, JsonRows) + + private val sampleJson = """ + { + "title": "Rocinante", + "crew": ["James Holden", "Naomi Nagata"], + "meta": {"active": true} + } + """.trimIndent().replace("\n", "").replace(" ", "") + + @Test + fun `charLength counts Unicode code points`() = tx { + Strings.insert { + it[id] = 1 + it[value] = "жніўня" + } + + val length = Strings.value.charLength() + assertEquals(6, Strings.select(length).single()[length]) + } + + @Test + fun `substring extracts by Unicode positions`() = tx { + Strings.insert { + it[id] = 1 + it[value] = "0123456789abcdefghij" + } + + val part = Strings.value.substring(10, 5) + assertEquals("abcde", Strings.select(part).single()[part]) + } + + @Test + fun `locate is one-based and returns zero when not found`() = tx { + Strings.insert { + it[id] = 1 + it[value] = "abcdef" + } + Strings.insert { + it[id] = 2 + it[value] = "xyz" + } + + val position = Strings.value.locate("cd") + assertEquals(3, Strings.select(position).where { Strings.id eq 1 }.single()[position]) + assertEquals(0, Strings.select(position).where { Strings.id eq 2 }.single()[position]) + } + + @Test + fun `concat joins expressions`() = tx { + Strings.insert { + it[id] = 1 + it[value] = "ab" + } + + val joined = concat("-", listOf(Strings.value, org.jetbrains.exposed.v1.core.stringLiteral("z"))) + assertEquals("ab-z", Strings.select(joined).single()[joined]) + } + + @Test + fun `regexp matches with REGEXP operator`() = tx { + Strings.insert { + it[id] = 1 + it[value] = "aaabccc" + } + Strings.insert { + it[id] = 2 + it[value] = "zzz" + } + + assertEquals(1, Strings.selectAll().where { Strings.value regexp "b+" }.count()) + } + + @Test + fun `regexp ignores case via Re2 Grep`() = tx { + Strings.insert { + it[id] = 1 + it[value] = "FooBar" + } + + assertEquals( + 1, + Strings.selectAll().where { + Strings.value.regexp( + org.jetbrains.exposed.v1.core.stringParam("foo"), + caseSensitive = false + ) + }.count() + ) + } + + @Test + fun `extract scalar uses JSON_VALUE via dialect`() = tx { + JsonRows.insert { + it[id] = 1 + it[payload] = sampleJson + } + + val title = jsonExtract("title", toScalar = true) + assertEquals("Rocinante", JsonRows.select(title).single()[title]) + } + + @Test + fun `exists uses JSON_EXISTS via dialect`() = tx { + JsonRows.insert { + it[id] = 1 + it[payload] = sampleJson + } + + assertEquals(1, JsonRows.selectAll().where { JsonRows.payload.exists("title") }.count()) + assertEquals(1, JsonRows.selectAll().where { JsonRows.payload.exists("crew", "[*]") }.count()) + assertEquals(0, JsonRows.selectAll().where { JsonRows.payload.exists("missing") }.count()) + } + + @Test + fun `extract reads array element by index`() = tx { + JsonRows.insert { + it[id] = 1 + it[payload] = sampleJson + } + + val firstCrew = jsonExtract("crew", "0", toScalar = true) + // JSON_VALUE returns Utf8; YDB may normalize string scalars (e.g. drop spaces in names). + assertEquals("JamesHolden", JsonRows.select(firstCrew).single()[firstCrew]) + } + + @Test + fun `extract object uses JSON_QUERY via dialect`() = tx { + JsonRows.insert { + it[id] = 1 + it[payload] = sampleJson + } + + val crew = jsonExtract("crew", toScalar = false) + val fragment = JsonRows.select(crew).single()[crew] + assertTrue(!fragment.isNullOrBlank(), "JSON_QUERY crew: $fragment") + assertTrue(fragment!!.contains("Holden"), "JSON_QUERY crew: $fragment") + } + + private fun jsonExtract(vararg path: String, toScalar: Boolean) = Extract( + JsonRows.payload, + *path, + toScalar = toScalar, + jsonType = JsonRows.payload.columnType, + columnType = TextColumnType() + ) +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/JoinIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/JoinIT.kt new file mode 100644 index 00000000..1e37aad1 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/JoinIT.kt @@ -0,0 +1,112 @@ +package tech.ydb.exposed.dialect.integration.query + +import org.jetbrains.exposed.v1.core.JoinType +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class JoinIT : BaseYdbTest() { + + object Authors : Table("authors") { + val id = integer("id") + val name = varchar("name", 255) + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object Books : Table("books") { + val id = integer("id") + val title = varchar("title", 255) + val authorId = integer("author_id") + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Authors, Books) + + @Test + fun `should support inner join`() = tx { + Authors.insert { + it[id] = 1 + it[name] = "Alice" + } + Authors.insert { + it[id] = 2 + it[name] = "Bob" + } + + Books.insert { + it[id] = 10 + it[title] = "A-Book" + it[authorId] = 1 + } + Books.insert { + it[id] = 11 + it[title] = "B-Book" + it[authorId] = 2 + } + + val rows = Authors + .join( + otherTable = Books, + joinType = JoinType.INNER, + onColumn = Authors.id, + otherColumn = Books.authorId + ) + .select(Authors.name, Books.title) + .orderBy(Books.id to SortOrder.ASC) + .toList() + + assertEquals(2, rows.size) + assertEquals("Alice", rows[0][Authors.name]) + assertEquals("A-Book", rows[0][Books.title]) + assertEquals("Bob", rows[1][Authors.name]) + assertEquals("B-Book", rows[1][Books.title]) + } + + @Test + fun `should support filtered join query`() = tx { + Authors.insert { + it[id] = 1 + it[name] = "Alice" + } + Authors.insert { + it[id] = 2 + it[name] = "Bob" + } + + Books.insert { + it[id] = 10 + it[title] = "A-Book" + it[authorId] = 1 + } + Books.insert { + it[id] = 11 + it[title] = "B-Book" + it[authorId] = 2 + } + + val rows = Authors + .join( + otherTable = Books, + joinType = JoinType.INNER, + onColumn = Authors.id, + otherColumn = Books.authorId + ) + .select(Authors.name, Books.title) + .where { Authors.name eq "Alice" } + .toList() + + assertEquals(1, rows.size) + assertEquals("Alice", rows.single()[Authors.name]) + assertEquals("A-Book", rows.single()[Books.title]) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/ManyToManyIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/ManyToManyIT.kt new file mode 100644 index 00000000..926a3631 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/ManyToManyIT.kt @@ -0,0 +1,103 @@ +package tech.ydb.exposed.dialect.integration.query + +import org.jetbrains.exposed.v1.core.JoinType +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class ManyToManyIT : BaseYdbTest() { + + object Students : Table("students") { + val id = integer("id") + val name = varchar("name", 255) + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object Courses : Table("courses") { + val id = integer("id") + val title = varchar("title", 255) + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object StudentCourses : Table("student_courses") { + val id = integer("id") + val studentId = integer("student_id") + val courseId = integer("course_id") + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Students, Courses, StudentCourses) + + @Test + fun `should support many to many through link table`() = tx { + Students.insert { + it[id] = 1 + it[name] = "Alice" + } + Students.insert { + it[id] = 2 + it[name] = "Bob" + } + + Courses.insert { + it[id] = 10 + it[title] = "Math" + } + Courses.insert { + it[id] = 11 + it[title] = "Physics" + } + + StudentCourses.insert { + it[id] = 100 + it[studentId] = 1 + it[courseId] = 10 + } + StudentCourses.insert { + it[id] = 101 + it[studentId] = 1 + it[courseId] = 11 + } + StudentCourses.insert { + it[id] = 102 + it[studentId] = 2 + it[courseId] = 11 + } + + val rows = Students + .join( + otherTable = StudentCourses, + joinType = JoinType.INNER, + onColumn = Students.id, + otherColumn = StudentCourses.studentId + ) + .join( + otherTable = Courses, + joinType = JoinType.INNER, + onColumn = StudentCourses.courseId, + otherColumn = Courses.id + ) + .select(Students.name, Courses.title) + .where { Students.name eq "Alice" } + .orderBy(Courses.id to SortOrder.ASC) + .toList() + + assertEquals(2, rows.size) + assertEquals("Alice", rows[0][Students.name]) + assertEquals("Math", rows[0][Courses.title]) + assertEquals("Alice", rows[1][Students.name]) + assertEquals("Physics", rows[1][Courses.title]) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/SubqueryIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/SubqueryIT.kt new file mode 100644 index 00000000..66396ede --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/SubqueryIT.kt @@ -0,0 +1,69 @@ +package tech.ydb.exposed.dialect.integration.query + +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.alias +import org.jetbrains.exposed.v1.core.greaterEq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class SubqueryIT : BaseYdbTest() { + + object Sales : Table("sales") { + val id = integer("id") + val customer = varchar("customer", 255) + val amount = integer("amount") + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Sales) + + @Test + fun `should support selecting from aliased subquery`() = tx { + Sales.insert { + it[id] = 1 + it[customer] = "Alice" + it[amount] = 100 + } + Sales.insert { + it[id] = 2 + it[customer] = "Bob" + it[amount] = 200 + } + Sales.insert { + it[id] = 3 + it[customer] = "Carol" + it[amount] = 300 + } + + val filtered = Sales + .select(Sales.id, Sales.customer, Sales.amount) + .where { Sales.amount greaterEq 200 } + + val base = filtered.alias("sales_filtered") + + val idCol = base[Sales.id] + val customerCol = base[Sales.customer] + val amountCol = base[Sales.amount] + + val rows = base + .select(idCol, customerCol, amountCol) + .orderBy(idCol to SortOrder.ASC) + .toList() + + assertEquals(2, rows.size) + assertEquals(2, rows[0][idCol]) + assertEquals("Bob", rows[0][customerCol]) + assertEquals(200, rows[0][amountCol]) + + assertEquals(3, rows[1][idCol]) + assertEquals("Carol", rows[1][customerCol]) + assertEquals(300, rows[1][amountCol]) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/scenario/UniversityScenarioIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/scenario/UniversityScenarioIT.kt new file mode 100644 index 00000000..7c3f57c1 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/scenario/UniversityScenarioIT.kt @@ -0,0 +1,177 @@ +package tech.ydb.exposed.dialect.integration.scenario + +import org.jetbrains.exposed.v1.core.JoinType +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class UniversityScenarioIT : BaseYdbTest() { + + object Departments : Table("departments") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object Students : Table("students") { + val id = integer("id") + val name = varchar("name", 255) + val departmentId = integer("department_id") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object Courses : Table("courses") { + val id = integer("id") + val name = varchar("name", 255) + val departmentId = integer("department_id") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object Enrollments : Table("enrollments") { + val id = integer("id") + val studentId = integer("student_id") + val courseId = integer("course_id") + val semester = varchar("semester", 64) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf( + Departments, + Students, + Courses, + Enrollments + ) + + private fun seedData() { + Departments.insert { + it[id] = 1 + it[name] = "Computer Science" + } + Departments.insert { + it[id] = 2 + it[name] = "Mathematics" + } + + Students.insert { + it[id] = 1 + it[name] = "Alice" + it[departmentId] = 1 + } + Students.insert { + it[id] = 2 + it[name] = "Bob" + it[departmentId] = 1 + } + Students.insert { + it[id] = 3 + it[name] = "Carol" + it[departmentId] = 2 + } + + Courses.insert { + it[id] = 1 + it[name] = "Algorithms" + it[departmentId] = 1 + } + Courses.insert { + it[id] = 2 + it[name] = "Databases" + it[departmentId] = 1 + } + Courses.insert { + it[id] = 3 + it[name] = "Linear Algebra" + it[departmentId] = 2 + } + + Enrollments.insert { + it[id] = 1 + it[studentId] = 1 + it[courseId] = 1 + it[semester] = "2026-spring" + } + Enrollments.insert { + it[id] = 2 + it[studentId] = 1 + it[courseId] = 2 + it[semester] = "2026-spring" + } + Enrollments.insert { + it[id] = 3 + it[studentId] = 2 + it[courseId] = 1 + it[semester] = "2026-spring" + } + Enrollments.insert { + it[id] = 4 + it[studentId] = 3 + it[courseId] = 3 + it[semester] = "2026-spring" + } + } + + @Test + fun `should load computer science students with their enrolled courses`() = tx { + seedData() + + val rows = Students + .join(Departments, JoinType.INNER, Students.departmentId, Departments.id) + .join(Enrollments, JoinType.INNER, Students.id, Enrollments.studentId) + .join(Courses, JoinType.INNER, Enrollments.courseId, Courses.id) + .selectAll() + .where { Departments.name eq "Computer Science" } + .map { + Triple( + it[Students.name], + it[Courses.name], + it[Enrollments.semester] + ) + } + .sortedWith(compareBy({ it.first }, { it.second })) + + assertEquals( + listOf( + Triple("Alice", "Algorithms", "2026-spring"), + Triple("Alice", "Databases", "2026-spring"), + Triple("Bob", "Algorithms", "2026-spring") + ), + rows + ) + } + + @Test + fun `should load spring roster for algorithms course`() = tx { + seedData() + + val rows = Courses + .join(Enrollments, JoinType.INNER, Courses.id, Enrollments.courseId) + .join(Students, JoinType.INNER, Enrollments.studentId, Students.id) + .selectAll() + .where { + (Courses.name eq "Algorithms") and + (Enrollments.semester eq "2026-spring") + } + .map { it[Students.name] } + .sorted() + + assertEquals(listOf("Alice", "Bob"), rows) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/transaction/YdbRetryingTransactionsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/transaction/YdbRetryingTransactionsIT.kt new file mode 100644 index 00000000..7e29dc4f --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/transaction/YdbRetryingTransactionsIT.kt @@ -0,0 +1,83 @@ +package tech.ydb.exposed.dialect.integration.transaction + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbRetryConfig +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.code.YdbVendorCode +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.ydbTransaction +import java.sql.SQLException +import java.util.concurrent.atomic.AtomicInteger + +class YdbRetryingTransactionsIT : BaseYdbTest() { + + object RetryItems : Table("retry_items") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(RetryItems) + + @Test + fun `ydbTransaction executes a write-and-read round trip`() { + ydbTransaction(db) { + RetryItems.insert { + it[id] = 1 + it[name] = "alpha" + } + } + + val name = ydbTransaction(db, readOnly = true, retry = YdbRetryConfig.IDEMPOTENT) { + RetryItems.selectAll().single()[RetryItems.name] + } + assertEquals("alpha", name) + } + + @Test + fun `ydbTransaction retries the body on a retryable failure`() { + val attempts = AtomicInteger() + + ydbTransaction(db, retry = YdbRetryConfig.IDEMPOTENT.copy(maxAttempts = 3)) { + val attempt = attempts.incrementAndGet() + if (attempt < 2) { + throw FakeAbortedException() + } + RetryItems.insert { + it[id] = 42 + it[name] = "retried" + } + } + + assertEquals(2, attempts.get()) + + val stored = ydbTransaction(db, readOnly = true, retry = YdbRetryConfig.IDEMPOTENT) { + RetryItems.selectAll().single()[RetryItems.name] + } + assertEquals("retried", stored) + } + + @Test + fun `non-retryable error fails fast`() { + val attempts = AtomicInteger() + + assertThrows(IllegalStateException::class.java) { + ydbTransaction(db, retry = YdbRetryConfig.DEFAULT.copy(maxAttempts = 3)) { + attempts.incrementAndGet() + error("non-retryable") + } + } + assertEquals(1, attempts.get()) + } + + private class FakeAbortedException : + SQLException("simulated ABORTED", "YDB", YdbVendorCode.ABORTED) +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/NumericTtlTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/NumericTtlTypesIT.kt new file mode 100644 index 00000000..757b7c12 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/NumericTtlTypesIT.kt @@ -0,0 +1,35 @@ +package tech.ydb.exposed.dialect.integration.ttl + +import org.jetbrains.exposed.v1.core.Table +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbUint64 + +class NumericTtlTypesIT : BaseYdbTest() { + + object NumericTtlItems : Table("numeric_ttl_items") { + val id = integer("id") + val modifiedAtEpoch = ydbUint64("modified_at_epoch") + + override val primaryKey = PrimaryKey(id) + + override val storageParameters: List = + listOf(RawTableStorageParameter("TTL = Interval(\"PT1H\") ON modified_at_epoch AS SECONDS")) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(NumericTtlItems) + + @Test + fun `should generate ttl for numeric epoch column`() = tx { + val ddl = NumericTtlItems.ddl.joinToString(" ") + + assertTrue( + ddl.contains("""WITH (TTL = Interval("PT1H") ON modified_at_epoch AS SECONDS)""") + ) + assertTrue(ddl.contains("modified_at_epoch Uint64")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt new file mode 100644 index 00000000..6fe3fca6 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt @@ -0,0 +1,33 @@ +package tech.ydb.exposed.dialect.integration.ttl + +import org.jetbrains.exposed.v1.core.Table +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.javatime.ydbTimestamp + +class TtlTypesIT : BaseYdbTest() { + + object ExpiringItems : Table("expiring_items") { + val id = integer("id") + val expireAt = ydbTimestamp("expire_at") + + override val primaryKey = PrimaryKey(id) + + override val storageParameters: List = + listOf(RawTableStorageParameter("TTL = Interval(\"PT1H\") ON expire_at")) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(ExpiringItems) + + @Test + fun `should generate ttl for timestamp column`() = tx { + val ddl = ExpiringItems.ddl.joinToString(" ") + + assertTrue(ddl.contains("""WITH (TTL = Interval("PT1H") ON expire_at)""")) + assertTrue(ddl.contains("PRIMARY KEY (id)")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/AllTypesRoundTripIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/AllTypesRoundTripIT.kt new file mode 100644 index 00000000..8462bbe7 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/AllTypesRoundTripIT.kt @@ -0,0 +1,225 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbDecimal +import tech.ydb.exposed.dialect.ydbInterval64 +import tech.ydb.exposed.dialect.ydbJson +import tech.ydb.exposed.dialect.ydbJsonDocument +import tech.ydb.exposed.dialect.ydbUint64 +import tech.ydb.exposed.dialect.ydbUbyte +import tech.ydb.exposed.dialect.ydbUint32 +import tech.ydb.exposed.dialect.ydbUlong +import tech.ydb.exposed.dialect.ydbUshort +import tech.ydb.exposed.dialect.ydbUuid +import java.math.BigDecimal +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.UUID +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDate32 +import tech.ydb.exposed.dialect.javatime.ydbDatetime +import tech.ydb.exposed.dialect.javatime.ydbDatetime64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 + +/** + * End-to-end insert/select coverage for every type mapping implemented in this dialect. + * + * Not covered here (no public column API yet): `Yson`, `TzDate`, `TzDatetime`, `TzTimestamp`. + */ +class AllTypesRoundTripIT : BaseYdbTest() { + + object ScalarTypes : Table("all_types_scalars") { + val id = integer("id") + val byteCol = byte("byte_col") + val ubyteCol = ydbUbyte("ubyte_col") + val shortCol = short("short_col") + val ushortCol = ydbUshort("ushort_col") + val intCol = integer("int_col") + val uintCol = ydbUint32("uint_col") + val longCol = long("long_col") + val ulongCol = ydbUlong("ulong_col") + val boolCol = bool("bool_col") + val floatCol = float("float_col") + val doubleCol = double("double_col") + val varcharCol = varchar("varchar_col", 255) + val textCol = text("text_col") + val binaryCol = binary("binary_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object StandardTemporal : Table("all_types_std_temporal") { + val id = integer("id") + val dateCol = ydbDate("date_col") + val dateTimeCol = ydbDatetime("datetime_col") + val timestampCol = ydbTimestamp("timestamp_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object YdbExtensionTypes : Table("all_types_ydb_ext") { + val id = integer("id") + val amount = ydbDecimal("amount", 12, 4) + val jsonCol = ydbJson("json_col") + val jsonDocCol = ydbJsonDocument("json_doc_col") + val uuidCol = ydbUuid("uuid_col") + val uint64Col = ydbUint64("uint64_col") + val date32Col = ydbDate32("date32_col") + val datetime64Col = ydbDatetime64("datetime64_col") + val timestamp64Col = ydbTimestamp64("timestamp64_col") + val interval64Col = ydbInterval64("interval64_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(ScalarTypes, StandardTemporal, YdbExtensionTypes) + + @Test + fun `should round-trip standard scalar and unsigned types`() = tx { + val bytes = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + + ScalarTypes.insert { + it[id] = 1 + it[byteCol] = 42 + it[ubyteCol] = 255.toUByte() + it[shortCol] = -32000 + it[ushortCol] = 65000.toUShort() + it[intCol] = -2_000_000_000 + it[uintCol] = 3_000_000_000u + it[longCol] = -9_000_000_000_000_000_000L + it[ulongCol] = 9_223_372_036_854_775_807u + it[boolCol] = true + it[floatCol] = 1.25f + it[doubleCol] = 3.141592653589793 + it[varcharCol] = "varchar-α" + it[textCol] = "text-β\nline2" + it[binaryCol] = bytes + } + + val row = ScalarTypes.selectAll().single() + + assertEquals(42.toByte(), row[ScalarTypes.byteCol]) + assertEquals(255.toUByte(), row[ScalarTypes.ubyteCol]) + assertEquals((-32000).toShort(), row[ScalarTypes.shortCol]) + assertEquals(65000.toUShort(), row[ScalarTypes.ushortCol]) + assertEquals(-2_000_000_000, row[ScalarTypes.intCol]) + assertEquals(3_000_000_000u, row[ScalarTypes.uintCol]) + assertEquals(-9_000_000_000_000_000_000L, row[ScalarTypes.longCol]) + assertEquals(9_223_372_036_854_775_807u, row[ScalarTypes.ulongCol]) + assertEquals(true, row[ScalarTypes.boolCol]) + assertEquals(1.25f, row[ScalarTypes.floatCol]) + assertEquals(3.141592653589793, row[ScalarTypes.doubleCol]) + assertEquals("varchar-α", row[ScalarTypes.varcharCol]) + assertEquals("text-β\nline2", row[ScalarTypes.textCol]) + assertArrayEquals(bytes, row[ScalarTypes.binaryCol]) + } + + @Test + fun `should round-trip standard Exposed java-time columns (Date Datetime Timestamp)`() = tx { + val dateValue = LocalDate.of(2026, 5, 16) + val dateTimeValue = LocalDateTime.of(2026, 5, 16, 18, 45, 30) + val timestampValue = Instant.parse("2026-05-16T15:45:30Z") + + StandardTemporal.insert { + it[id] = 1 + it[dateCol] = dateValue + it[dateTimeCol] = dateTimeValue + it[timestampCol] = timestampValue + } + + val row = StandardTemporal.selectAll().single() + + assertEquals(dateValue, row[StandardTemporal.dateCol]) + assertEquals(dateTimeValue, row[StandardTemporal.dateTimeCol]) + assertEquals(timestampValue, row[StandardTemporal.timestampCol]) + } + + @Test + fun `should round-trip YDB-specific column extensions`() = tx { + val uuid = UUID.fromString("6ba7b810-9dad-11d1-80b4-00c04fd430c8") + val json = """{"k":"v","n":1}""" + val jsonDoc = """{"doc":true}""" + val duration = Duration.ofDays(2).plusHours(5).plusMinutes(7) + + YdbExtensionTypes.insert { + it[id] = 1 + it[amount] = BigDecimal("9999.1234") + it[jsonCol] = json + it[jsonDocCol] = jsonDoc + it[uuidCol] = uuid + it[uint64Col] = 9_223_372_036_854_775_807L + it[date32Col] = LocalDate.of(2030, 1, 2) + it[datetime64Col] = LocalDateTime.of(2030, 1, 2, 3, 4, 5) + it[timestamp64Col] = Instant.parse("2030-01-02T00:04:05Z") + it[interval64Col] = duration + } + + val row = YdbExtensionTypes.selectAll().single() + + assertEquals(BigDecimal("9999.1234"), row[YdbExtensionTypes.amount].setScale(4)) + assertEquals(json, row[YdbExtensionTypes.jsonCol]) + assertTrue(row[YdbExtensionTypes.jsonDocCol].contains("\"doc\":true")) + assertEquals(uuid, row[YdbExtensionTypes.uuidCol]) + assertEquals(9_223_372_036_854_775_807L, row[YdbExtensionTypes.uint64Col]) + assertEquals(LocalDate.of(2030, 1, 2), row[YdbExtensionTypes.date32Col]) + assertEquals(LocalDateTime.of(2030, 1, 2, 3, 4, 5), row[YdbExtensionTypes.datetime64Col]) + assertEquals(Instant.parse("2030-01-02T00:04:05Z"), row[YdbExtensionTypes.timestamp64Col]) + assertEquals(duration, row[YdbExtensionTypes.interval64Col]) + } + + @Test + fun `should emit expected ddl for scalar mappings`() = tx { + val ddl = ScalarTypes.ddl.joinToString(" ") + + assertTrue(ddl.contains("byte_col Int8")) + assertTrue(ddl.contains("ubyte_col Uint8")) + assertTrue(ddl.contains("short_col Int16")) + assertTrue(ddl.contains("ushort_col Uint16")) + assertTrue(ddl.contains("int_col Int32")) + assertTrue(ddl.contains("uint_col Uint32")) + assertTrue(ddl.contains("long_col Int64")) + assertTrue(ddl.contains("ulong_col Uint64")) + assertTrue(ddl.contains("bool_col Bool")) + assertTrue(ddl.contains("float_col Float")) + assertTrue(ddl.contains("double_col Double")) + assertTrue(ddl.contains("varchar_col Text")) + assertTrue(ddl.contains("text_col Text")) + assertTrue(ddl.contains("binary_col Bytes")) + } + + @Test + fun `should emit expected ddl for standard temporal and ydb extensions`() = tx { + val stdDdl = StandardTemporal.ddl.joinToString(" ") + assertTrue(stdDdl.contains("date_col Date")) + assertTrue(stdDdl.contains("datetime_col Datetime")) + assertTrue(stdDdl.contains("timestamp_col Timestamp")) + + val extDdl = YdbExtensionTypes.ddl.joinToString(" ") + assertTrue(extDdl.contains("amount Decimal(12, 4)")) + assertTrue(extDdl.contains("json_col Json")) + assertTrue(extDdl.contains("json_doc_col JsonDocument")) + assertTrue(extDdl.contains("uuid_col Uuid")) + assertTrue(extDdl.contains("uint64_col Uint64")) + assertTrue(extDdl.contains("date32_col Date32")) + assertTrue(extDdl.contains("datetime64_col Datetime64")) + assertTrue(extDdl.contains("timestamp64_col Timestamp64")) + assertTrue(extDdl.contains("interval64_col Interval64")) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryHexToDbIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryHexToDbIT.kt new file mode 100644 index 00000000..55a61fb3 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryHexToDbIT.kt @@ -0,0 +1,91 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.BlobColumnType +import org.jetbrains.exposed.v1.core.LiteralOp +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.api.ExposedBlob +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbFunctionProvider +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +/** + * End-to-end checks for [tech.ydb.exposed.dialect.YdbDataTypeProvider.hexToDb]. + * + * Exposed calls it from [BlobColumnType.nonNullValueToString] (e.g. [LiteralOp] or inline UPSERT values). + */ +class BinaryHexToDbIT : BaseYdbTest() { + + object BinaryHex : Table("binary_hex_to_db") { + val id = integer("id") + val payload = blob("payload") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(BinaryHex) + + @Test + fun `select with LiteralOp ExposedBlob uses hexToDb in SQL`() = tx { + val bytes = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + val literal = LiteralOp(BlobColumnType(), ExposedBlob(bytes)) + + BinaryHex.insert { + it[id] = 1 + it[payload] = ExposedBlob(bytes) + } + + BinaryHex.insert { + it[id] = 2 + it[payload] = ExposedBlob(byteArrayOf(0x00, 0x01)) + } + + val sql = BinaryHex + .selectAll() + .where { BinaryHex.payload eq literal } + .prepareSQL(this, prepared = false) + + assertTrue(sql.contains("String::HexDecode('deadbeef')"), "SQL: $sql") + + val rows = BinaryHex.selectAll().where { BinaryHex.payload eq literal }.toList() + + assertEquals(1, rows.size) + assertArrayEquals(bytes, rows.single()[BinaryHex.payload].bytes) + } + + @Test + fun `upsert with ExposedBlob embeds hexToDb literal and round-trips`() = tx { + val bytes = byteArrayOf(1, 2, 3, 4) + + val upsertSql = YdbFunctionProvider.upsert( + table = BinaryHex, + data = listOf( + BinaryHex.id to 1, + BinaryHex.payload to ExposedBlob(bytes) + ), + expression = "", + onUpdate = emptyList(), + keyColumns = emptyList(), + where = null, + transaction = this + ) + + assertTrue( + upsertSql.contains("String::HexDecode('01020304')"), + "UPSERT SQL should use hexToDb: $upsertSql" + ) + + exec(upsertSql) + + val row = BinaryHex.selectAll().single() + assertArrayEquals(bytes, row[BinaryHex.payload].bytes) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryTypesIT.kt new file mode 100644 index 00000000..809e4739 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryTypesIT.kt @@ -0,0 +1,43 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class BinaryTypesIT : BaseYdbTest() { + + object BinaryTypes : Table("binary_types") { + val id = integer("id") + val payload = binary("payload") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(BinaryTypes) + + @Test + fun `should round-trip binary data`() = tx { + val bytes = byteArrayOf(1, 2, 3, 4) + + BinaryTypes.insert { + it[id] = 1 + it[payload] = bytes + } + + val row = BinaryTypes.selectAll().single() + assertArrayEquals(bytes, row[BinaryTypes.payload]) + } + + @Test + fun `should generate ddl for binary type`() = tx { + val ddl = BinaryTypes.ddl.joinToString(" ") + assertTrue(ddl.contains("payload Bytes")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalTypesIT.kt new file mode 100644 index 00000000..369a8710 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalTypesIT.kt @@ -0,0 +1,43 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbDecimal +import java.math.BigDecimal + +class DecimalTypesIT : BaseYdbTest() { + + object DecimalTypes : Table("decimal_types") { + val id = integer("id") + val amount = ydbDecimal("amount", 10, 2) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(DecimalTypes) + + @Test + fun `should round-trip decimal type`() = tx { + DecimalTypes.insert { + it[id] = 1 + it[amount] = BigDecimal("123.45") + } + + val row = DecimalTypes.selectAll().single() + assertEquals(BigDecimal("123.45"), row[DecimalTypes.amount].setScale(2)) + } + + @Test + fun `should generate ddl for decimal type`() = tx { + val ddl = DecimalTypes.ddl.joinToString(" ") + assertTrue(ddl.contains("amount Decimal(10, 2)")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalUpdateIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalUpdateIT.kt new file mode 100644 index 00000000..5a341963 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalUpdateIT.kt @@ -0,0 +1,71 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbDecimal +import tech.ydb.exposed.dialect.ydbDecimalLiteral +import java.math.BigDecimal + +class DecimalUpdateIT : BaseYdbTest() { + + object DecimalItems : Table("decimal_update_items") { + val id = integer("id") + val name = varchar("name", 255) + val price = ydbDecimal("price", 10, 2) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(DecimalItems) + + @Test + fun `should update decimal value using ydb decimal literal`() = tx { + DecimalItems.insert { + it[id] = 1 + it[name] = "book" + it[price] = BigDecimal("39.90") + } + + DecimalItems.update({ DecimalItems.id eq 1 }) { + it.update( + DecimalItems.price, + ydbDecimalLiteral(BigDecimal("45.00"), 10, 2) + ) + } + + val row = DecimalItems.selectAll().single() + + assertEquals(BigDecimal("45.00"), row[DecimalItems.price]) + } + + @Test + fun `should update decimal and another column in one statement`() = tx { + DecimalItems.insert { + it[id] = 2 + it[name] = "draft" + it[price] = BigDecimal("10.50") + } + + DecimalItems.update({ DecimalItems.id eq 2 }) { + it[name] = "published" + it.update( + DecimalItems.price, + ydbDecimalLiteral(BigDecimal("12.75"), 10, 2) + ) + } + + val row = DecimalItems.selectAll().where { DecimalItems.id eq 2 }.single() + + assertEquals("published", row[DecimalItems.name]) + assertEquals(BigDecimal("12.75"), row[DecimalItems.price]) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/ForceLegacyStandardTemporalIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/ForceLegacyStandardTemporalIT.kt new file mode 100644 index 00000000..ca4bd7ae --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/ForceLegacyStandardTemporalIT.kt @@ -0,0 +1,65 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDatetime +import tech.ydb.exposed.dialect.javatime.ydbTimestamp +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime + +/** [ydbDate] / [ydbDatetime] / [ydbTimestamp] — unsigned legacy types with JDBC vendor codes. */ +class ForceLegacyStandardTemporalIT : BaseYdbTest() { + + object LegacyStdTemporal : Table("force_legacy_std_temporal") { + val id = integer("id") + val dateCol = ydbDate("date_col") + val dateTimeCol = ydbDatetime("datetime_col") + val timestampCol = ydbTimestamp("timestamp_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= emptyList() + + @Test + fun `should round-trip standard temporal columns as legacy types`() = tx { + SchemaUtils.create(LegacyStdTemporal) + + val dateValue = LocalDate.of(2019, 12, 31) + val dateTimeValue = LocalDateTime.of(2019, 12, 31, 23, 59, 59) + val timestampValue = Instant.parse("2019-12-31T20:59:59Z") + + LegacyStdTemporal.insert { + it[id] = 1 + it[dateCol] = dateValue + it[dateTimeCol] = dateTimeValue + it[timestampCol] = timestampValue + } + + val row = LegacyStdTemporal.selectAll().single() + assertEquals(dateValue, row[LegacyStdTemporal.dateCol]) + assertEquals(dateTimeValue, row[LegacyStdTemporal.dateTimeCol]) + assertEquals(timestampValue, row[LegacyStdTemporal.timestampCol]) + } + + @Test + fun `should emit Date Datetime Timestamp ddl`() = tx { + SchemaUtils.create(LegacyStdTemporal) + + val ddl = LegacyStdTemporal.ddl.joinToString(" ") + assertTrue(ddl.contains("date_col Date") && !ddl.contains("Date32"), ddl) + assertTrue(ddl.contains("datetime_col Datetime") && !ddl.contains("Datetime64"), ddl) + assertTrue(ddl.contains("timestamp_col Timestamp") && !ddl.contains("Timestamp64"), ddl) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/IntervalTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/IntervalTypesIT.kt new file mode 100644 index 00000000..36fc8ec0 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/IntervalTypesIT.kt @@ -0,0 +1,73 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbInterval +import tech.ydb.exposed.dialect.ydbInterval64 +import java.time.Duration + +class IntervalTypesIT : BaseYdbTest() { + + object Interval64Types : Table("interval64_types") { + val id = integer("id") + val durationCol = ydbInterval64("duration_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object LegacyIntervalTypes : Table("legacy_interval_types") { + val id = integer("id") + val durationCol = ydbInterval("duration_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Interval64Types, LegacyIntervalTypes) + + @Test + fun `should round-trip Interval64`() = tx { + val duration = Duration.ofHours(26).plusMinutes(3).plusSeconds(4) + + Interval64Types.insert { + it[id] = 1 + it[durationCol] = duration + } + + val row = Interval64Types.selectAll().single() + assertEquals(duration, row[Interval64Types.durationCol]) + } + + @Test + fun `should generate ddl for Interval64`() = tx { + val ddl = Interval64Types.ddl.joinToString(" ") + assertTrue(ddl.contains("duration_col Interval64")) + } + + @Test + fun `should round-trip legacy Interval`() = tx { + val duration = Duration.ofDays(1).plusHours(2) + + LegacyIntervalTypes.insert { + it[id] = 1 + it[durationCol] = duration + } + + assertEquals(duration, LegacyIntervalTypes.selectAll().single()[LegacyIntervalTypes.durationCol]) + } + + @Test + fun `should generate ddl for legacy Interval`() = tx { + val ddl = LegacyIntervalTypes.ddl.joinToString(" ") + assertTrue(ddl.contains("duration_col Interval") && !ddl.contains("Interval64")) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/JsonTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/JsonTypesIT.kt new file mode 100644 index 00000000..8f2dbf0c --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/JsonTypesIT.kt @@ -0,0 +1,76 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbJson +import tech.ydb.exposed.dialect.ydbJsonDocument + +class JsonTypesIT : BaseYdbTest() { + + object JsonTypes : Table("json_types") { + val id = integer("id") + val payload = ydbJson("payload") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object JsonDocumentTypes : Table("json_document_types") { + val id = integer("id") + val payload = ydbJsonDocument("payload") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(JsonTypes, JsonDocumentTypes) + + @Test + fun `should round-trip json type`() = tx { + val json = """{"name":"alice","active":true}""" + + JsonTypes.insert { + it[id] = 1 + it[payload] = json + } + + val row = JsonTypes.selectAll().single() + assertEquals(json, row[JsonTypes.payload]) + } + + @Test + fun `should generate ddl for json type`() = tx { + val ddl = JsonTypes.ddl.joinToString(" ") + assertTrue(ddl.contains("payload Json")) + } + + @Test + fun `should round-trip json document type`() = tx { + val json = """{"name":"alice","active":true}""" + + JsonDocumentTypes.insert { + it[id] = 1 + it[payload] = json + } + + val row = JsonDocumentTypes.selectAll().single() + val actual = row[JsonDocumentTypes.payload] + assertTrue(actual.startsWith("{") && actual.endsWith("}")) + assertTrue(actual.contains(""""name":"alice"""")) + assertTrue(actual.contains(""""active":true""")) + } + + @Test + fun `should generate ddl for json document type`() = tx { + val ddl = JsonDocumentTypes.ddl.joinToString(" ") + assertTrue(ddl.contains("payload JsonDocument")) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyTemporalTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyTemporalTypesIT.kt new file mode 100644 index 00000000..97ca20aa --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyTemporalTypesIT.kt @@ -0,0 +1,37 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDatetime +import tech.ydb.exposed.dialect.javatime.ydbTimestamp + +class LegacyTemporalTypesIT : BaseYdbTest() { + + object LegacyTemporal : Table("legacy_temporal_types") { + val id = integer("id") + val dateCol = ydbDate("date_col") + val dateTimeCol = ydbDatetime("datetime_col") + val timestampCol = ydbTimestamp("timestamp_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= emptyList() + + @Test + fun `unsigned ydb temporal extensions emit Date Datetime Timestamp`() = tx { + SchemaUtils.create(LegacyTemporal) + + val ddl = LegacyTemporal.ddl.joinToString(" ") + assertTrue(ddl.contains("date_col Date") && !ddl.contains("Date32"), ddl) + assertTrue(ddl.contains("datetime_col Datetime") && !ddl.contains("Datetime64"), ddl) + assertTrue(ddl.contains("timestamp_col Timestamp") && !ddl.contains("Timestamp64"), ddl) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyYdbTypesRoundTripIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyYdbTypesRoundTripIT.kt new file mode 100644 index 00000000..ac44fc9a --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyYdbTypesRoundTripIT.kt @@ -0,0 +1,58 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDatetime +import tech.ydb.exposed.dialect.ydbInterval +import tech.ydb.exposed.dialect.javatime.ydbTimestamp +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime + +/** Round-trip for legacy YDB temporal/interval types (`Date`, `Datetime`, `Timestamp`, `Interval`). */ +class LegacyYdbTypesRoundTripIT : BaseYdbTest() { + + object LegacyTypes : Table("legacy_ydb_types_round_trip") { + val id = integer("id") + val dateCol = ydbDate("date_col") + val dateTimeCol = ydbDatetime("datetime_col") + val timestampCol = ydbTimestamp("timestamp_col") + val intervalCol = ydbInterval("interval_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(LegacyTypes) + + @Test + fun `should round-trip legacy Date Datetime Timestamp Interval`() = tx { + val dateValue = LocalDate.of(2020, 6, 15) + val dateTimeValue = LocalDateTime.of(2020, 6, 15, 12, 30, 0) + val timestampValue = Instant.parse("2020-06-15T09:30:00Z") + val duration = Duration.ofHours(48).plusMinutes(15) + + LegacyTypes.insert { + it[id] = 1 + it[dateCol] = dateValue + it[dateTimeCol] = dateTimeValue + it[timestampCol] = timestampValue + it[intervalCol] = duration + } + + val row = LegacyTypes.selectAll().single() + + assertEquals(dateValue, row[LegacyTypes.dateCol]) + assertEquals(dateTimeValue, row[LegacyTypes.dateTimeCol]) + assertEquals(timestampValue, row[LegacyTypes.timestampCol]) + assertEquals(duration, row[LegacyTypes.intervalCol]) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/SignedTemporalTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/SignedTemporalTypesIT.kt new file mode 100644 index 00000000..534f7066 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/SignedTemporalTypesIT.kt @@ -0,0 +1,77 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.javatime.ydbDate32 +import tech.ydb.exposed.dialect.javatime.ydbDatetime64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * Signed temporal columns with [tech.ydb.exposed.dialect.registerYdbDialect] `enableSignedDatetimes = true` and + * `forceSignedDatetimes=true` on the JDBC URL (set explicitly in [jdbcUrlSuffix]). + */ +class SignedTemporalTypesIT : BaseYdbTest() { + + object SignedTemporal : Table("signed_temporal_types") { + val id = integer("id") + val dateCol = ydbDate32("date_col") + val dateTimeCol = ydbDatetime64("datetime_col") + val timestampCol = ydbTimestamp64("timestamp_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val enableSignedDatetimes: Boolean = true + + override val tables: List
= listOf(SignedTemporal) + + override val jdbcUrlSuffix: String = "&forceSignedDatetimes=true" + + @Test + fun `registerYdbDialect wires signed dialect when forceSignedDatetimes in url`() { + val dialect = db.dialect as YdbDialect + assertTrue(dialect.enableSignedDatetimes) + } + + @Test + fun `should round-trip signed temporal columns`() = tx { + val dateValue = LocalDate.of(2026, 5, 16) + val dateTimeValue = LocalDateTime.of(2026, 5, 16, 14, 30, 15) + val timestampValue = Instant.parse("2026-05-16T11:30:15Z") + + SignedTemporal.insert { + it[id] = 1 + it[dateCol] = dateValue + it[dateTimeCol] = dateTimeValue + it[timestampCol] = timestampValue + } + + val row = SignedTemporal.selectAll().single() + assertEquals(dateValue, row[SignedTemporal.dateCol]) + assertEquals(dateTimeValue, row[SignedTemporal.dateTimeCol]) + assertEquals(timestampValue, row[SignedTemporal.timestampCol]) + } + + @Test + fun `should emit Date32 Datetime64 Timestamp64 ddl`() = tx { + SchemaUtils.create(SignedTemporal) + + val ddl = SignedTemporal.ddl.joinToString(" ") + assertTrue(ddl.contains("date_col Date32"), ddl) + assertTrue(ddl.contains("datetime_col Datetime64"), ddl) + assertTrue(ddl.contains("timestamp_col Timestamp64"), ddl) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TemporalTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TemporalTypesIT.kt new file mode 100644 index 00000000..41fe6730 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TemporalTypesIT.kt @@ -0,0 +1,61 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import tech.ydb.exposed.dialect.javatime.ydbDate32 +import tech.ydb.exposed.dialect.javatime.ydbDatetime64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 + +class TemporalTypesIT : BaseYdbTest() { + + object TemporalTypes : Table("temporal_types") { + val id = integer("id") + val dateCol = ydbDate32("date_col") + val dateTimeCol = ydbDatetime64("datetime_col") + val timestampCol = ydbTimestamp64("timestamp_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(TemporalTypes) + + @Test + fun `should round-trip extended temporal types with explicit JDBC vendor codes`() = tx { + val dateValue = LocalDate.of(2026, 4, 13) + val dateTimeValue = LocalDateTime.of(2026, 4, 13, 14, 30, 15) + val timestampValue = Instant.parse("2026-04-13T11:30:15Z") + + TemporalTypes.insert { + it[id] = 1 + it[dateCol] = dateValue + it[dateTimeCol] = dateTimeValue + it[timestampCol] = timestampValue + } + + val row = TemporalTypes.selectAll().single() + + assertEquals(dateValue, row[TemporalTypes.dateCol]) + assertEquals(dateTimeValue, row[TemporalTypes.dateTimeCol]) + assertEquals(timestampValue, row[TemporalTypes.timestampCol]) + } + + @Test + fun `should generate ddl for extended temporal types`() = tx { + val ddl = TemporalTypes.ddl.joinToString(" ") + + assertTrue(ddl.contains("date_col Date32")) + assertTrue(ddl.contains("datetime_col Datetime64")) + assertTrue(ddl.contains("timestamp_col Timestamp64")) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TextTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TextTypesIT.kt new file mode 100644 index 00000000..12be12fb --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TextTypesIT.kt @@ -0,0 +1,47 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class TextTypesIT : BaseYdbTest() { + + object TextTypes : Table("text_types") { + val id = integer("id") + val varcharCol = varchar("varchar_col", 255) + val textCol = text("text_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(TextTypes) + + @Test + fun `should round-trip text and varchar values`() = tx { + TextTypes.insert { + it[id] = 1 + it[varcharCol] = "hello" + it[textCol] = "world" + } + + val row = TextTypes.selectAll().single() + + assertEquals("hello", row[TextTypes.varcharCol]) + assertEquals("world", row[TextTypes.textCol]) + } + + @Test + fun `should generate ddl for text-based string types`() = tx { + val ddl = TextTypes.ddl.joinToString(" ") + + assertTrue(ddl.contains("varchar_col Text")) + assertTrue(ddl.contains("text_col Text")) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TypesIT.kt new file mode 100644 index 00000000..ca734ce4 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TypesIT.kt @@ -0,0 +1,72 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class TypesIT : BaseYdbTest() { + + object BasicTypes : Table("basic_types") { + val id = integer("id") + val shortCol = short("short_col") + val intCol = integer("int_col") + val longCol = long("long_col") + val boolCol = bool("bool_col") + val floatCol = float("float_col") + val doubleCol = double("double_col") + val varcharCol = varchar("varchar_col", 255) + val textCol = text("text_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(BasicTypes) + + @Test + fun `should round-trip basic scalar types`() = tx { + BasicTypes.insert { + it[id] = 1 + it[shortCol] = 7 + it[intCol] = 42 + it[longCol] = 1000L + it[boolCol] = true + it[floatCol] = 1.5f + it[doubleCol] = 2.5 + it[varcharCol] = "hello" + it[textCol] = "world" + } + + val row = BasicTypes.selectAll().single() + + assertEquals(7.toShort(), row[BasicTypes.shortCol]) + assertEquals(42, row[BasicTypes.intCol]) + assertEquals(1000L, row[BasicTypes.longCol]) + assertEquals(true, row[BasicTypes.boolCol]) + assertEquals(1.5f, row[BasicTypes.floatCol]) + assertEquals(2.5, row[BasicTypes.doubleCol]) + assertEquals("hello", row[BasicTypes.varcharCol]) + assertEquals("world", row[BasicTypes.textCol]) + } + + @Test + fun `should generate expected ddl for basic types`() = tx { + val ddl = BasicTypes.ddl.joinToString(" ") + + assertTrue(ddl.contains("short_col Int16")) + assertTrue(ddl.contains("int_col Int32")) + assertTrue(ddl.contains("long_col Int64")) + assertTrue(ddl.contains("bool_col Bool")) + assertTrue(ddl.contains("float_col Float")) + assertTrue(ddl.contains("double_col Double")) + assertTrue(ddl.contains("varchar_col Text")) + assertTrue(ddl.contains("text_col Text")) + assertTrue(ddl.contains("PRIMARY KEY (id)")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/Uint64TypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/Uint64TypesIT.kt new file mode 100644 index 00000000..60b2195c --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/Uint64TypesIT.kt @@ -0,0 +1,42 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbUint64 + +class Uint64TypesIT : BaseYdbTest() { + + object Uint64Types : Table("uint64_types") { + val id = integer("id") + val valueCol = ydbUint64("value_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Uint64Types) + + @Test + fun `should round-trip uint64 type`() = tx { + Uint64Types.insert { + it[id] = 1 + it[valueCol] = 1_700_000_000L + } + + val row = Uint64Types.selectAll().single() + assertEquals(1_700_000_000L, row[Uint64Types.valueCol]) + } + + @Test + fun `should generate ddl for uint64 type`() = tx { + val ddl = Uint64Types.ddl.joinToString(" ") + assertTrue(ddl.contains("value_col Uint64")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/UuidTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/UuidTypesIT.kt new file mode 100644 index 00000000..d5f18165 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/UuidTypesIT.kt @@ -0,0 +1,43 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbUuid +import java.util.UUID + +class UuidTypesIT : BaseYdbTest() { + + object NativeUuidTypes : Table("native_uuid_types") { + val id = integer("id") + val uuidCol = ydbUuid("uuid_col") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(NativeUuidTypes) + + @Test + fun `ydbUuid round-trips a java util UUID through native YDB Uuid (no string conversion)`() = tx { + val uuid = UUID.randomUUID() + + NativeUuidTypes.insert { + it[id] = 1 + it[uuidCol] = uuid + } + + assertEquals(uuid, NativeUuidTypes.selectAll().single()[NativeUuidTypes.uuidCol]) + } + + @Test + fun `DDL emits Uuid for ydbUuid`() = tx { + assertTrue(NativeUuidTypes.ddl.joinToString(" ").contains("uuid_col Uuid")) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/TableBatchUpsertIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/TableBatchUpsertIT.kt new file mode 100644 index 00000000..9249e800 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/TableBatchUpsertIT.kt @@ -0,0 +1,64 @@ +package tech.ydb.exposed.dialect.integration.upsert + +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.batchUpsert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +/** [Table.batchUpsert] over native YDB UPSERT. */ +class TableBatchUpsertIT : BaseYdbTest() { + + object BatchItems : Table("table_batch_upsert_items") { + val id = integer("id") + val name = varchar("name", 255) + val quantity = integer("quantity") + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(BatchItems) + + data class Row(val id: Int, val name: String, val quantity: Int) + + @Test + fun `Table batchUpsert inserts and updates rows`() = tx { + BatchItems.batchUpsert( + listOf( + Row(1, "apple", 10), + Row(2, "banana", 20), + ) + ) { row -> + this[BatchItems.id] = row.id + this[BatchItems.name] = row.name + this[BatchItems.quantity] = row.quantity + } + + BatchItems.batchUpsert( + listOf( + Row(1, "apple-updated", 11), + Row(3, "orange", 30), + ) + ) { row -> + this[BatchItems.id] = row.id + this[BatchItems.name] = row.name + this[BatchItems.quantity] = row.quantity + } + + val rows = BatchItems + .selectAll() + .orderBy(BatchItems.id to SortOrder.ASC) + .toList() + + assertEquals(3, rows.size) + assertEquals("apple-updated", rows[0][BatchItems.name]) + assertEquals(11, rows[0][BatchItems.quantity]) + assertEquals("banana", rows[1][BatchItems.name]) + assertEquals("orange", rows[2][BatchItems.name]) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/UpsertIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/UpsertIT.kt new file mode 100644 index 00000000..176f0991 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/UpsertIT.kt @@ -0,0 +1,242 @@ +package tech.ydb.exposed.dialect.integration.upsert + +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.replace +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.upsert +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbUint64 + +/** + * Integration coverage for Exposed [Table.upsert] / [Table.replace] backed by native YQL. + * + * Scenarios mirror the upstream Exposed JDBC upsert tests where YDB semantics allow: + * - insert-on-miss / update-on-hit via UPSERT + * - full row overwrite via REPLACE + * - partial UPSERT (only listed columns change on conflict) + */ +class UpsertIT : BaseYdbTest() { + + object Products : Table("upsert_products") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object Inventory : Table("upsert_inventory") { + val id = integer("id") + val name = varchar("name", 255) + val quantity = integer("quantity").default(0) + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + object NullableItems : Table("upsert_nullable_items") { + val id = integer("id") + val note = ydbUint64("note").nullable() + + override val primaryKey = PrimaryKey(id) + + override fun createStatement(): List = createYdbStatement() + } + + override val tables: List
= listOf(Products, Inventory, NullableItems) + + @Test + fun `Table upsert inserts a new row`() = tx { + Products.upsert { + it[id] = 1 + it[name] = "new" + } + + val row = Products.selectAll().single() + assertEquals(1, row[Products.id]) + assertEquals("new", row[Products.name]) + } + + @Test + fun `Table upsert updates an existing row by primary key`() = tx { + Products.upsert { + it[id] = 1 + it[name] = "first" + } + Products.upsert { + it[id] = 1 + it[name] = "second" + } + + val row = Products.selectAll().single() + assertEquals("second", row[Products.name]) + } + + @Test + fun `Table upsert can insert multiple rows`() = tx { + Products.upsert { + it[id] = 1 + it[name] = "a" + } + Products.upsert { + it[id] = 2 + it[name] = "b" + } + Products.upsert { + it[id] = 3 + it[name] = "c" + } + + val names = Products + .selectAll() + .orderBy(Products.id to SortOrder.ASC) + .map { it[Products.name] } + + assertEquals(listOf("a", "b", "c"), names) + } + + @Test + fun `Table upsert on conflict updates only columns listed in the block`() = tx { + Inventory.upsert { + it[id] = 10 + it[name] = "widget" + it[quantity] = 5 + } + + Inventory.upsert { + it[id] = 10 + it[name] = "renamed" + } + + val row = Inventory.selectAll().single() + assertEquals("renamed", row[Inventory.name]) + assertEquals(5, row[Inventory.quantity]) + } + + @Test + fun `Table replace overwrites the row and resets omitted columns to defaults`() = tx { + Inventory.upsert { + it[id] = 20 + it[name] = "before" + it[quantity] = 7 + } + + Inventory.replace { + it[id] = 20 + it[name] = "after" + } + + val row = Inventory.selectAll().single() + assertEquals(20, row[Inventory.id]) + assertEquals("after", row[Inventory.name]) + assertEquals(0, row[Inventory.quantity]) + } + + @Test + fun `Table replace overwrites all listed column values`() = tx { + Products.upsert { + it[id] = 5 + it[name] = "original" + } + + Products.replace { + it[id] = 5 + it[name] = "replaced" + } + + val row = Products.selectAll().single() + assertEquals(5, row[Products.id]) + assertEquals("replaced", row[Products.name]) + } + + @Test + fun `Table upsert is idempotent when writing the same values twice`() = tx { + Products.upsert { + it[id] = 99 + it[name] = "stable" + } + Products.upsert { + it[id] = 99 + it[name] = "stable" + } + + val rows = Products.selectAll().where { Products.id eq 99 }.toList() + assertEquals(1, rows.size) + assertEquals("stable", rows.single()[Products.name]) + } + + @Test + fun `Table replace can insert when the primary key is new`() = tx { + Products.replace { + it[id] = 42 + it[name] = "via-replace" + } + + val row = Products.selectAll().single() + assertEquals(42, row[Products.id]) + assertEquals("via-replace", row[Products.name]) + } + + @Test + fun `Table upsert leaves other rows untouched`() = tx { + Products.upsert { + it[id] = 1 + it[name] = "keep" + } + Products.upsert { + it[id] = 2 + it[name] = "change-me" + } + + Products.upsert { + it[id] = 2 + it[name] = "changed" + } + + val kept = Products.selectAll().where { Products.id eq 1 }.single() + assertEquals("keep", kept[Products.name]) + + val updated = Products.selectAll().where { Products.id eq 2 }.single() + assertEquals("changed", updated[Products.name]) + } + + @Test + fun `Table upsert followed by replace yields replace semantics`() = tx { + Inventory.upsert { + it[id] = 30 + it[name] = "stock" + it[quantity] = 100 + } + + Inventory.replace { + it[id] = 30 + it[name] = "cleared-qty" + } + + val row = Inventory.selectAll().single() + assertEquals("cleared-qty", row[Inventory.name]) + assertEquals(0, row[Inventory.quantity]) + } + + @Test + fun `Table upsert can clear a nullable column when explicitly set to null`() = tx { + NullableItems.upsert { + it[id] = 1 + it[note] = 4 + } + NullableItems.upsert { + it[id] = 1 + it[note] = null + } + + assertNull(NullableItems.selectAll().single()[NullableItems.note]) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/code/YdbJdbcCodeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/code/YdbJdbcCodeTest.kt new file mode 100644 index 00000000..03ef7ffa --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/code/YdbJdbcCodeTest.kt @@ -0,0 +1,24 @@ +package tech.ydb.exposed.dialect.unit.code + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.code.YdbJdbcCode + +class YdbJdbcCodeTest { + + @Test + fun `primitive codes match JDBC driver layout`() { + assertEquals(10_025, YdbJdbcCode.DATE32) + assertEquals(10_026, YdbJdbcCode.DATETIME64) + assertEquals(10_027, YdbJdbcCode.TIMESTAMP64) + assertEquals(10_028, YdbJdbcCode.INTERVAL64) + assertEquals(10_014, YdbJdbcCode.JSON) + assertEquals(10_016, YdbJdbcCode.DATE) + assertEquals(10_019, YdbJdbcCode.INTERVAL) + } + + @Test + fun `decimal code encodes precision and scale`() { + assertEquals(YdbJdbcCode.SQL_KIND_DECIMAL + (10 shl 6) + 2, YdbJdbcCode.decimal(10, 2)) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/YdbFunctionProviderTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/YdbFunctionProviderTest.kt new file mode 100644 index 00000000..01ed4d0e --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/YdbFunctionProviderTest.kt @@ -0,0 +1,214 @@ +package tech.ydb.exposed.dialect.unit.functions + +import org.jetbrains.exposed.v1.core.QueryBuilder +import org.jetbrains.exposed.v1.core.intLiteral +import org.jetbrains.exposed.v1.core.stringLiteral +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbFunctionProvider +import tech.ydb.exposed.dialect.YdbJsonDocumentStringColumnType +import tech.ydb.exposed.dialect.YdbJsonStringColumnType +class YdbFunctionProviderTest { + + private val provider = YdbFunctionProvider + + private fun sql(build: QueryBuilder.() -> Unit): String = + QueryBuilder(false).apply(build).toString() + + @Test + fun `charLength uses Unicode GetLength`() { + val expr = stringLiteral("hello") + val result = sql { provider.charLength(expr, this) } + assertEquals("Unicode::GetLength('hello')", result) + } + + @Test + fun `substring uses Unicode Substring`() { + val expr = stringLiteral("abcdef") + val result = sql { + provider.substring(expr, intLiteral(2), intLiteral(3), this) + } + assertEquals("Unicode::Substring('abcdef', 2, 3)", result) + } + + @Test + fun `concat without separator uses concatenation operator`() { + val result = sql { + provider.concat("", this, stringLiteral("a"), stringLiteral("b")) + } + assertEquals("'a' || 'b'", result) + } + + @Test + fun `concat with separator uses Unicode JoinFromList`() { + val result = sql { + provider.concat("-", this, stringLiteral("a"), stringLiteral("b")) + } + assertEquals( + "Unicode::JoinFromList(AsList(CAST('a' AS Utf8), CAST('b' AS Utf8)), '-')", + result + ) + } + + @Test + fun `locate returns one-based index`() { + val expr = stringLiteral("abcdef") + val result = sql { provider.locate(this, expr, "cd") } + assertEquals( + "COALESCE(CAST(Unicode::Find('abcdef', 'cd') + 1u AS Int32), 0)", + result + ) + } + + @Test + fun `locate escapes quotes in needle`() { + val expr = stringLiteral("a'b") + val result = sql { provider.locate(this, expr, "x'y") } + assertEquals( + "COALESCE(CAST(Unicode::Find('a''b', 'x''y') + 1u AS Int32), 0)", + result + ) + } + + @Test + fun `regexp case sensitive uses REGEXP operator`() { + val haystack = stringLiteral("aaabccc") + val pattern = stringLiteral("b+") + val result = sql { provider.regexp(haystack, pattern, caseSensitive = true, this) } + assertEquals("'aaabccc' REGEXP 'b+'", result) + } + + @Test + fun `regexp case insensitive uses Re2 Match`() { + val haystack = stringLiteral("Foo") + val pattern = stringLiteral("foo") + val result = sql { provider.regexp(haystack, pattern, caseSensitive = false, this) } + assertEquals( + "Re2::Grep('foo', Re2::Options(false AS CaseSensitive))('Foo')", + result + ) + } + + @Test + fun `jsonCast casts to column sql type`() { + val expr = stringLiteral("""{"a":1}""") + val json = sql { provider.jsonCast(expr, YdbJsonStringColumnType(), this) } + val jsonDocument = sql { provider.jsonCast(expr, YdbJsonDocumentStringColumnType(), this) } + + assertEquals("CAST('{\"a\":1}' AS Json)", json) + assertEquals("CAST('{\"a\":1}' AS JsonDocument)", jsonDocument) + } + + @Test + fun `jsonExtract scalar uses JSON_VALUE`() { + val expr = stringLiteral("""{"friends":[{"name":"Jim"}]}""") + val result = sql { + provider.jsonExtract( + expr, + "friends", + "0", + "name", + toScalar = true, + jsonType = YdbJsonStringColumnType(), + queryBuilder = this + ) + } + assertEquals( + "JSON_VALUE('{\"friends\":[{\"name\":\"Jim\"}]}', '$.friends[0].name')", + result + ) + } + + @Test + fun `jsonExtract quotes path keys with special characters`() { + val expr = stringLiteral("{}") + val result = sql { + provider.jsonExtract( + expr, + "key-name", + toScalar = true, + jsonType = YdbJsonStringColumnType(), + queryBuilder = this + ) + } + assertEquals("JSON_VALUE('{}', '$.\"key-name\"')", result) + } + + @Test + fun `jsonExtract object uses JSON_QUERY`() { + val expr = stringLiteral("""{"friends":[]}""") + val result = sql { + provider.jsonExtract( + expr, + "friends", + toScalar = false, + jsonType = YdbJsonStringColumnType(), + queryBuilder = this + ) + } + assertEquals("JSON_QUERY('{\"friends\":[]}', '$.friends')", result) + } + + @Test + fun `jsonExists uses JSON_EXISTS`() { + val expr = stringLiteral("""{"title":"Rocinante"}""") + val result = sql { + provider.jsonExists( + expr, + "title", + optional = null, + jsonType = YdbJsonStringColumnType(), + queryBuilder = this + ) + } + assertEquals("JSON_EXISTS('{\"title\":\"Rocinante\"}', '$.title')", result) + } + + @Test + fun `jsonExists supports optional ON ERROR clause`() { + val expr = stringLiteral("{}") + val result = sql { + provider.jsonExists( + expr, + "missing", + optional = "ERROR ON ERROR", + jsonType = YdbJsonStringColumnType(), + queryBuilder = this + ) + } + assertEquals("JSON_EXISTS('{}', '$.missing' ERROR ON ERROR)", result) + } + + @Test + fun `jsonContains is not supported`() { + assertThrows(UnsupportedOperationException::class.java) { + sql { + provider.jsonContains( + stringLiteral("{}"), + stringLiteral("""{"a":1}"""), + path = null, + jsonType = YdbJsonStringColumnType(), + queryBuilder = this + ) + } + } + } + + @Test + fun `random without seed uses bare Random`() { + assertEquals("Random()", provider.random(seed = null)) + } + + @Test + fun `random with seed passes constant for YDB call grouping`() { + assertEquals("Random(42)", provider.random(seed = 42)) + } + + @Test + fun `queryLimitAndOffset`() { + assertEquals(" LIMIT 10", provider.queryLimitAndOffset(size = 10, offset = 0, alreadyOrdered = false)) + assertEquals(" LIMIT 10 OFFSET 5", provider.queryLimitAndOffset(size = 10, offset = 5, alreadyOrdered = false)) + assertEquals(" OFFSET 5", provider.queryLimitAndOffset(size = null, offset = 5, alreadyOrdered = false)) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryPolicyTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryPolicyTest.kt new file mode 100644 index 00000000..eb36b2f9 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryPolicyTest.kt @@ -0,0 +1,140 @@ +package tech.ydb.exposed.dialect.unit.transaction + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbRetryConfig +import tech.ydb.exposed.dialect.calculateBackoffMillis +import tech.ydb.exposed.dialect.ceilingFromCapBackoffMs +import tech.ydb.exposed.dialect.code.YdbVendorCode +import tech.ydb.exposed.dialect.equalJitterMillis +import tech.ydb.exposed.dialect.fullJitterMillis +import tech.ydb.exposed.dialect.getNextRetryDelayMs +import tech.ydb.exposed.dialect.isTransientVendorCode +import java.sql.SQLException +import kotlin.random.Random + +class YdbRetryPolicyTest { + + private class FakeSqlException(vendorCode: Int) : SQLException("fake", "YDB", vendorCode) + + private val fixedRandom = Random(42) + + @Test + fun `ceiling is derived from cap backoff`() { + assertEquals(9, ceilingFromCapBackoffMs(500)) + assertEquals(13, ceilingFromCapBackoffMs(5_000)) + } + + @Test + fun `calculateBackoff caps exponential growth`() { + assertEquals(10, calculateBackoffMillis(backoffBaseMs = 5, capMs = 500, ceiling = 9, attempt = 1)) + assertEquals(500, calculateBackoffMillis(backoffBaseMs = 5, capMs = 500, ceiling = 9, attempt = 100)) + } + + @Test + fun `full jitter stays within calculated backoff`() { + repeat(20) { + val delay = fullJitterMillis(5, 500, 9, attempt = 2, random = fixedRandom) + assertTrue(delay in 0..20) + } + } + + @Test + fun `equal jitter stays within calculated backoff`() { + repeat(20) { + val delay = equalJitterMillis(50, 5_000, 13, attempt = 1, random = fixedRandom) + assertTrue(delay in 50..100) + } + } + + @Test + fun `ABORTED uses full jitter fast backoff`() { + val delay = getNextRetryDelayMs( + FakeSqlException(YdbVendorCode.ABORTED), + attempt = 0, + config = YdbRetryConfig.DEFAULT, + random = fixedRandom + ) + assertTrue(delay != null && delay >= 0) + } + + @Test + fun `UNAVAILABLE uses equal jitter fast backoff`() { + val delay = getNextRetryDelayMs( + FakeSqlException(YdbVendorCode.UNAVAILABLE), + attempt = 0, + config = YdbRetryConfig.DEFAULT, + random = fixedRandom + ) + assertTrue(delay != null && delay >= 0) + } + + @Test + fun `OVERLOADED uses equal jitter slow backoff`() { + val delay = getNextRetryDelayMs( + FakeSqlException(YdbVendorCode.OVERLOADED), + attempt = 0, + config = YdbRetryConfig.DEFAULT, + random = fixedRandom + ) + assertTrue(delay != null && delay >= 0) + } + + @Test + fun `BAD_SESSION returns zero delay`() { + assertEquals( + 0L, + getNextRetryDelayMs( + FakeSqlException(YdbVendorCode.BAD_SESSION), + attempt = 0, + config = YdbRetryConfig.DEFAULT + ) + ) + } + + @Test + fun `UNDETERMINED retries only with enableRetryIdempotence`() { + assertNull( + getNextRetryDelayMs( + FakeSqlException(YdbVendorCode.UNDETERMINED), + attempt = 0, + config = YdbRetryConfig.DEFAULT + ) + ) + assertTrue( + getNextRetryDelayMs( + FakeSqlException(YdbVendorCode.UNDETERMINED), + attempt = 0, + config = YdbRetryConfig.IDEMPOTENT + ) != null + ) + } + + @Test + fun `PRECONDITION_FAILED never retries`() { + assertNull( + getNextRetryDelayMs( + FakeSqlException(YdbVendorCode.PRECONDITION_FAILED), + attempt = 0, + config = YdbRetryConfig.IDEMPOTENT + ) + ) + } + + @Test + fun `stops after maxAttempts`() { + val config = YdbRetryConfig.DEFAULT.copy(maxAttempts = 3) + assertTrue(getNextRetryDelayMs(FakeSqlException(YdbVendorCode.ABORTED), 0, config) != null) + assertTrue(getNextRetryDelayMs(FakeSqlException(YdbVendorCode.ABORTED), 1, config) != null) + assertNull(getNextRetryDelayMs(FakeSqlException(YdbVendorCode.ABORTED), 2, config)) + } + + @Test + fun `transient codes gate non idempotent retries`() { + assertTrue(isTransientVendorCode(YdbVendorCode.ABORTED)) + assertFalse(isTransientVendorCode(YdbVendorCode.UNDETERMINED)) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/BindYdbParameterTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/BindYdbParameterTest.kt new file mode 100644 index 00000000..546f14dc --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/BindYdbParameterTest.kt @@ -0,0 +1,27 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.jetbrains.exposed.v1.core.ArrayColumnType +import org.jetbrains.exposed.v1.core.IColumnType +import org.jetbrains.exposed.v1.core.statements.api.PreparedStatementApi +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.bindYdbParameter +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import java.io.InputStream + +class BindYdbParameterTest { + + @Test + fun `fails fast when statement is not JdbcPreparedStatementImpl`() { + val fakeStmt = object : PreparedStatementApi { + override fun set(index: Int, value: Any, columnType: IColumnType<*>) {} + override fun setNull(index: Int, columnType: IColumnType<*>) {} + override fun setInputStream(index: Int, inputStream: InputStream, setAsBlobObject: Boolean) {} + override fun setArray(index: Int, type: ArrayColumnType<*, *>, array: Array<*>) {} + } + + assertThrows(IllegalStateException::class.java) { + bindYdbParameter(fakeStmt, 1, 42L, YdbJdbcCode.UINT64) + } + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/JdbcBindingCapture.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/JdbcBindingCapture.kt new file mode 100644 index 00000000..48cbf621 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/JdbcBindingCapture.kt @@ -0,0 +1,59 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.jetbrains.exposed.v1.jdbc.statements.jdbc.JdbcPreparedStatementImpl +import java.lang.reflect.Proxy +import java.sql.PreparedStatement + +data class BoundSqlObject( + val index: Int, + val value: Any?, + val targetSqlType: Int +) + +fun ydbPreparedStatementCapture(): Pair BoundSqlObject?> { + var boundValue: BoundSqlObject? = null + + val proxy = Proxy.newProxyInstance( + PreparedStatement::class.java.classLoader, + arrayOf(PreparedStatement::class.java) + ) { _, method, args -> + when (method.name) { + "setObject" -> { + if (args?.size == 3 && args[0] is Int && args[2] is Int) { + boundValue = BoundSqlObject( + index = args[0] as Int, + value = args[1], + targetSqlType = args[2] as Int + ) + } + null + } + + "toString" -> "PreparedStatementProxy" + "hashCode" -> 0 + "equals" -> false + "isClosed" -> false + "execute" -> false + "executeUpdate" -> 0 + "executeLargeUpdate" -> 0L + "getUpdateCount" -> 0 + "getLargeUpdateCount" -> 0L + "getMoreResults" -> false + else -> defaultValue(method.returnType) + } + } as PreparedStatement + + return JdbcPreparedStatementImpl(proxy, false) to { boundValue } +} + +private fun defaultValue(type: Class<*>): Any? = when (type) { + java.lang.Boolean.TYPE -> false + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Short.TYPE -> 0.toShort() + java.lang.Byte.TYPE -> 0.toByte() + java.lang.Float.TYPE -> 0f + java.lang.Double.TYPE -> 0.0 + java.lang.Character.TYPE -> '\u0000' + else -> null +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDataTypeProviderTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDataTypeProviderTest.kt new file mode 100644 index 00000000..06c32477 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDataTypeProviderTest.kt @@ -0,0 +1,75 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDataTypeProvider + +class YdbDataTypeProviderTest { + + private val provider = YdbDataTypeProvider(enableSignedDatetimes = false) + + @Test + fun `maps integer types`() { + assertEquals("Int32", provider.integerType()) + assertEquals("Int64", provider.longType()) + assertEquals("Uint64", provider.ulongType()) + assertEquals("Int16", provider.shortType()) + assertEquals("Uint8", provider.ubyteType()) + assertEquals("Uint16", provider.ushortType()) + assertEquals("Uint32", provider.uintegerType()) + } + + @Test + fun `maps string and binary types`() { + assertEquals("Text", provider.varcharType(255)) + assertEquals("Text", provider.textType()) + assertEquals("Bytes", provider.binaryType()) + assertEquals("Bytes", provider.binaryType(100)) + } + + @Test + fun `maps boolean and UUID and JSON types`() { + assertEquals("Bool", provider.booleanType()) + assertEquals("Uuid", provider.uuidType()) + assertEquals("Json", provider.jsonType()) + } + + @Test + fun `maps floating-point types`() { + assertEquals("Float", provider.floatType()) + assertEquals("Double", provider.doubleType()) + } + + @Test + fun `maps standard temporal types to legacy Date Datetime Timestamp`() { + assertEquals("Date", provider.dateType()) + assertEquals("Datetime", provider.dateTimeType()) + assertEquals("Timestamp", provider.timestampType()) + } + + @Test + fun `maps temporal types to Date32 Datetime64 Timestamp64 when enableSignedDatetimes`() { + val signed = YdbDataTypeProvider(enableSignedDatetimes = true) + assertEquals("Date32", signed.dateType()) + assertEquals("Datetime64", signed.dateTimeType()) + assertEquals("Timestamp64", signed.timestampType()) + } + + @Test + fun `maps autoincrement to Serial and BigSerial`() { + assertEquals("Serial", provider.integerAutoincType()) + assertEquals("BigSerial", provider.longAutoincType()) + } + + @Test + fun `rejects unsigned autoincrement`() { + assertThrows(UnsupportedOperationException::class.java) { + provider.uintegerAutoincType() + } + assertThrows(UnsupportedOperationException::class.java) { + provider.ulongAutoincType() + } + } + +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalColumnTypeTest.kt new file mode 100644 index 00000000..2b4aa35c --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalColumnTypeTest.kt @@ -0,0 +1,89 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDecimalColumnType +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import java.math.BigDecimal + +class YdbDecimalColumnTypeTest { + + @Test + fun `should return decimal sql type`() { + val type = YdbDecimalColumnType(10, 2) + assertEquals("Decimal(10, 2)", type.sqlType()) + } + + @Test + fun `should reject invalid precision`() { + assertThrows(IllegalArgumentException::class.java) { + YdbDecimalColumnType(0, 0) + } + + assertThrows(IllegalArgumentException::class.java) { + YdbDecimalColumnType(36, 2) + } + } + + @Test + fun `should reject invalid scale`() { + assertThrows(IllegalArgumentException::class.java) { + YdbDecimalColumnType(10, 11) + } + + assertThrows(IllegalArgumentException::class.java) { + YdbDecimalColumnType(10, -1) + } + } + + @Test + fun `should parse decimal from db`() { + val type = YdbDecimalColumnType(10, 2) + + assertEquals(BigDecimal("123.45"), type.valueFromDB(BigDecimal("123.45"))) + assertEquals(BigDecimal("123.45"), type.valueFromDB("123.45")) + } + + @Test + fun `should convert decimal to db with scale`() { + val type = YdbDecimalColumnType(10, 2) + + assertEquals(BigDecimal("123.40"), type.notNullValueToDB(BigDecimal("123.4"))) + assertEquals("123.40", type.nonNullValueToString(BigDecimal("123.4"))) + } + + @Test + fun `should bind decimal with JDBC vendor code for precision and scale`() { + val type = YdbDecimalColumnType(10, 2) + val value = BigDecimal("123.40") + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, value) + + val actual = capture() + org.junit.jupiter.api.Assertions.assertNotNull(actual) + assertEquals(BoundSqlObject(1, BigDecimal("123.40"), YdbJdbcCode.decimal(10, 2)), actual) + } + + @Test + fun `should reject decimal with scale greater than column scale`() { + val type = YdbDecimalColumnType(10, 2) + + val error1 = assertThrows(IllegalArgumentException::class.java) { + type.notNullValueToDB(BigDecimal("123.456")) + } + assertEquals( + "YDB Decimal value 123.456 has scale 3, which exceeds column scale 2", + error1.message + ) + + val error2 = assertThrows(IllegalArgumentException::class.java) { + type.nonNullValueToString(BigDecimal("123.456")) + } + assertEquals( + "YDB Decimal value 123.456 has scale 3, which exceeds column scale 2", + error2.message + ) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalLiteralTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalLiteralTest.kt new file mode 100644 index 00000000..76ff4876 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalLiteralTest.kt @@ -0,0 +1,44 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.jetbrains.exposed.v1.core.QueryBuilder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDecimalLiteral +import java.math.BigDecimal + +class YdbDecimalLiteralTest { + + @Test + fun `should render decimal literal`() { + val expression = YdbDecimalLiteral( + value = BigDecimal("45.00"), + precision = 10, + scale = 2 + ) + + val queryBuilder = QueryBuilder(false) + expression.toQueryBuilder(queryBuilder) + + assertEquals("""Decimal("45.00", 10, 2)""", queryBuilder.toString()) + } + + @Test + fun `should reject decimal literal with scale greater than allowed`() { + val expression = YdbDecimalLiteral( + value = BigDecimal("45.123"), + precision = 10, + scale = 2 + ) + + val error = assertThrows(IllegalArgumentException::class.java) { + val queryBuilder = QueryBuilder(false) + expression.toQueryBuilder(queryBuilder) + } + + assertEquals( + "Decimal value 45.123 has scale 3, which exceeds the allowed scale 2", + error.message + ) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbHexToDbTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbHexToDbTest.kt new file mode 100644 index 00000000..6d3dbd16 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbHexToDbTest.kt @@ -0,0 +1,44 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.jetbrains.exposed.v1.core.statements.api.ExposedBlob +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDataTypeProvider + +/** + * [YdbDataTypeProvider.hexToDb] formats binary data for **inline SQL literals**, not for JDBC bind. + * + * Exposed calls it from [org.jetbrains.exposed.v1.core.BlobColumnType.nonNullValueToString] + * ([org.jetbrains.exposed.v1.core.LiteralOp], inline UPSERT values, etc.). + * YDB `Bytes` is a [String](https://ydb.tech/docs/en/yql/reference/types/primitive) alias; literals use + * [String::HexDecode](https://ydb.tech/docs/en/yql/reference/udf/list/string) returns `String?`; + * [Unwrap](https://ydb.tech/docs/en/yql/reference/builtins/basic#unwrap) satisfies NOT NULL `Bytes` columns. + */ +class YdbHexToDbTest { + + private val provider = YdbDataTypeProvider() + + @Test + fun `uses String HexDecode without cast`() { + assertEquals( + "Unwrap(String::HexDecode('deadbeef'), 'invalid hex bytes literal')", + provider.hexToDb("deadbeef") + ) + assertEquals( + "Unwrap(String::HexDecode(''), 'invalid hex bytes literal')", + provider.hexToDb("") + ) + } + + @Test + fun `matches ExposedBlob hexString output`() { + val bytes = byteArrayOf(0x01, 0x02, 0xAB.toByte(), 0xCD.toByte()) + val hex = ExposedBlob(bytes).hexString() + + assertEquals("0102abcd", hex) + assertEquals( + "Unwrap(String::HexDecode('0102abcd'), 'invalid hex bytes literal')", + provider.hexToDb(hex) + ) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbIntervalColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbIntervalColumnTypeTest.kt new file mode 100644 index 00000000..a446879e --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbIntervalColumnTypeTest.kt @@ -0,0 +1,44 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.jetbrains.exposed.v1.core.Table +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.ydbInterval +import tech.ydb.exposed.dialect.ydbInterval64 +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import java.time.Duration + +class YdbIntervalColumnTypeTest { + + private object IntervalColumns : Table("interval_columns") { + val legacy = ydbInterval("legacy") + val extended = ydbInterval64("extended") + } + + @Test + fun `legacy ydbInterval uses Interval sql type and vendor code`() { + assertEquals("Interval", IntervalColumns.legacy.columnType.sqlType()) + assertBinding(IntervalColumns.legacy, YdbJdbcCode.INTERVAL) + } + + @Test + fun `ydbInterval64 uses Interval64 sql type and vendor code`() { + assertEquals("Interval64", IntervalColumns.extended.columnType.sqlType()) + assertBinding(IntervalColumns.extended, YdbJdbcCode.INTERVAL64) + } + + private fun assertBinding( + column: org.jetbrains.exposed.v1.core.Column<*>, + expectedVendorCode: Int + ) { + val duration = Duration.ofMinutes(90) + val (stmt, capture) = ydbPreparedStatementCapture() + + column.columnType.setParameter(stmt, 1, duration) + + val actual = capture() + assertNotNull(actual) + assertEquals(BoundSqlObject(1, duration, expectedVendorCode), actual) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonDocumentStringColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonDocumentStringColumnTypeTest.kt new file mode 100644 index 00000000..9fc0cc84 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonDocumentStringColumnTypeTest.kt @@ -0,0 +1,48 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import tech.ydb.exposed.dialect.YdbJsonDocumentStringColumnType + +class YdbJsonDocumentStringColumnTypeTest { + + private val type = YdbJsonDocumentStringColumnType() + + @Test + fun `should return json document sql type`() { + assertEquals("JsonDocument", type.sqlType()) + } + + @Test + fun `should parse json document from db`() { + val json = """{"name":"alice","active":true}""" + assertEquals(json, type.valueFromDB(json)) + } + + @Test + fun `should convert json document to db`() { + val json = """{"name":"alice","active":true}""" + assertEquals(json, type.notNullValueToDB(json)) + assertEquals("""'{"name":"alice","active":true}'""", type.nonNullValueToString(json)) + } + + @Test + fun `should escape single quotes in json document`() { + val json = """{"name":"O'Brien"}""" + assertEquals("""'{"name":"O''Brien"}'""", type.nonNullValueToString(json)) + } + + @Test + fun `should bind json document with explicit JDBC vendor type`() { + val json = """{"name":"alice","active":true}""" + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, json) + + val actual = capture() + assertNotNull(actual) + assertEquals(BoundSqlObject(1, json, YdbJdbcCode.JSON_DOCUMENT), actual) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonStringColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonStringColumnTypeTest.kt new file mode 100644 index 00000000..26816abe --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonStringColumnTypeTest.kt @@ -0,0 +1,48 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import tech.ydb.exposed.dialect.YdbJsonStringColumnType + +class YdbJsonStringColumnTypeTest { + + private val type = YdbJsonStringColumnType() + + @Test + fun `should return json sql type`() { + assertEquals("Json", type.sqlType()) + } + + @Test + fun `should parse json from db`() { + val json = """{"name":"alice","active":true}""" + assertEquals(json, type.valueFromDB(json)) + } + + @Test + fun `should convert json to db`() { + val json = """{"name":"alice","active":true}""" + assertEquals(json, type.notNullValueToDB(json)) + assertEquals("""'{"name":"alice","active":true}'""", type.nonNullValueToString(json)) + } + + @Test + fun `should escape single quotes in json`() { + val json = """{"name":"O'Brien"}""" + assertEquals("""'{"name":"O''Brien"}'""", type.nonNullValueToString(json)) + } + + @Test + fun `should bind json with explicit JDBC vendor type`() { + val json = """{"name":"alice","active":true}""" + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, json) + + val actual = capture() + assertNotNull(actual) + assertEquals(BoundSqlObject(1, json, YdbJdbcCode.JSON), actual) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbTemporalColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbTemporalColumnTypeTest.kt new file mode 100644 index 00000000..3a5428ba --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbTemporalColumnTypeTest.kt @@ -0,0 +1,94 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.jetbrains.exposed.v1.core.Table +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDate32 +import tech.ydb.exposed.dialect.javatime.ydbDatetime64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime + +class YdbTemporalColumnTypeTest { + + private object PlainTable : Table("temporal_columns") { + val legacyDate = ydbDate("legacy_date") + val signedDate = ydbDate32("date32") + } + + private object TemporalColumnsTable : Table("ydb_temporal_columns") { + val legacyDate = ydbDate("legacy_date") + val signedDate = ydbDate32("date32") + val signedDatetime = ydbDatetime64("datetime64") + val signedTimestamp = ydbTimestamp64("timestamp64") + } + + @Test + fun `sqlType is derived from JDBC code for Table ydbDate`() { + assertBinding( + column = PlainTable.legacyDate, + sqlType = "Date", + vendorCode = YdbJdbcCode.DATE, + value = LocalDate.of(2026, 4, 13) + ) + assertEquals("Date32", PlainTable.signedDate.columnType.sqlType()) + } + + @Test + fun `ydbDate and ydbDate32 use unsigned and signed types`() { + assertEquals("Date", TemporalColumnsTable.legacyDate.columnType.sqlType()) + assertEquals("Date32", TemporalColumnsTable.signedDate.columnType.sqlType()) + assertEquals("Datetime64", TemporalColumnsTable.signedDatetime.columnType.sqlType()) + assertEquals("Timestamp64", TemporalColumnsTable.signedTimestamp.columnType.sqlType()) + } + + @Test + fun `ydbDate32 binds Date32 vendor code`() { + assertBinding( + column = TemporalColumnsTable.signedDate, + sqlType = "Date32", + vendorCode = YdbJdbcCode.DATE32, + value = LocalDate.of(2026, 4, 13) + ) + } + + @Test + fun `ydbDatetime64 binds Datetime64 vendor code`() { + assertBinding( + column = TemporalColumnsTable.signedDatetime, + sqlType = "Datetime64", + vendorCode = YdbJdbcCode.DATETIME64, + value = LocalDateTime.of(2026, 4, 13, 14, 30, 15) + ) + } + + @Test + fun `ydbTimestamp64 binds Timestamp64 vendor code`() { + assertBinding( + column = TemporalColumnsTable.signedTimestamp, + sqlType = "Timestamp64", + vendorCode = YdbJdbcCode.TIMESTAMP64, + value = Instant.parse("2026-04-13T11:30:15Z") + ) + } + + private fun assertBinding( + column: org.jetbrains.exposed.v1.core.Column<*>, + sqlType: String, + vendorCode: Int, + value: Any + ) { + assertEquals(sqlType, column.columnType.sqlType()) + + val (stmt, capture) = ydbPreparedStatementCapture() + column.columnType.setParameter(stmt, 1, value) + + val actual = capture() + assertNotNull(actual) + assertEquals(BoundSqlObject(1, value, vendorCode), actual) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUintColumnTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUintColumnTest.kt new file mode 100644 index 00000000..f6affa20 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUintColumnTest.kt @@ -0,0 +1,40 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbUint64ColumnType + +class YdbUintColumnTest { + + private val type = YdbUint64ColumnType() + + @Test + fun `should return uint64 sql type`() { + assertEquals("Uint64", type.sqlType()) + } + + @Test + fun `should accept non negative values`() { + assertEquals(123L, type.notNullValueToDB(123L)) + assertEquals("123", type.nonNullValueToString(123L)) + } + + @Test + fun `should reject negative values`() { + assertThrows(IllegalArgumentException::class.java) { + type.notNullValueToDB(-1L) + } + + assertThrows(IllegalArgumentException::class.java) { + type.nonNullValueToString(-1L) + } + } + + @Test + fun `should parse values from db`() { + assertEquals(42L, type.valueFromDB(42L)) + assertEquals(42L, type.valueFromDB(42)) + assertEquals(42L, type.valueFromDB("42")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUnsignedColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUnsignedColumnTypeTest.kt new file mode 100644 index 00000000..e1c4998f --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUnsignedColumnTypeTest.kt @@ -0,0 +1,73 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import tech.ydb.exposed.dialect.YdbUByteColumnType +import tech.ydb.exposed.dialect.YdbUIntegerColumnType +import tech.ydb.exposed.dialect.YdbULongColumnType +import tech.ydb.exposed.dialect.YdbUShortColumnType + +class YdbUnsignedColumnTypeTest { + + @Test + fun `ubyte binds Uint8 vendor code`() { + val type = YdbUByteColumnType() + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, 42.toUByte()) + + val bound = capture()!! + assertEquals(1, bound.index) + assertEquals(42.toShort(), bound.value) + assertEquals(YdbJdbcCode.UINT8, bound.targetSqlType) + } + + @Test + fun `ushort binds Uint16 vendor code`() { + val type = YdbUShortColumnType() + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, 1000.toUShort()) + + val bound = capture()!! + assertEquals(1, bound.index) + assertEquals(1000, bound.value) + assertEquals(YdbJdbcCode.UINT16, bound.targetSqlType) + } + + @Test + fun `uint32 binds Uint32 vendor code`() { + val type = YdbUIntegerColumnType() + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, 3_000_000_000u) + + assertEquals(BoundSqlObject(1, 3_000_000_000L, YdbJdbcCode.UINT32), capture()) + } + + @Test + fun `ulong binds Uint64 vendor code`() { + val type = YdbULongColumnType() + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, 42uL) + + assertEquals(BoundSqlObject(1, 42L, YdbJdbcCode.UINT64), capture()) + } + + @Test + fun `ulong rejects values above Long MAX_VALUE with clear error`() { + val type = YdbULongColumnType() + val overflow = Long.MAX_VALUE.toULong() + 1uL + + val error = assertThrows(IllegalArgumentException::class.java) { + type.notNullValueToDB(overflow) + } + + assertTrue(error.message!!.contains("exceeds Long-backed Uint64 range")) + assertTrue(error.message!!.contains(overflow.toString())) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidColumnTypeTest.kt new file mode 100644 index 00000000..3a6ab0fa --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidColumnTypeTest.kt @@ -0,0 +1,35 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbUuidColumnType +import java.util.UUID + +class YdbUuidColumnTypeTest { + + private val type = YdbUuidColumnType() + + @Test + fun `maps to native Uuid sql type`() { + assertEquals("Uuid", type.sqlType()) + } + + @Test + fun `valueFromDB accepts both UUID and String`() { + val uuid = UUID.randomUUID() + assertEquals(uuid, type.valueFromDB(uuid)) + assertEquals(uuid, type.valueFromDB(uuid.toString())) + } + + @Test + fun `notNullValueToDB returns the UUID itself (no string conversion)`() { + val uuid = UUID.randomUUID() + assertEquals(uuid, type.notNullValueToDB(uuid)) + } + + @Test + fun `nonNullValueToString renders the YQL Uuid literal`() { + val uuid = UUID.fromString("00000000-0000-0000-0000-000000000001") + assertEquals("Uuid(\"$uuid\")", type.nonNullValueToString(uuid)) + } +} diff --git a/kotlin-exposed-dialect/src/test/resources/log4j2.xml b/kotlin-exposed-dialect/src/test/resources/log4j2.xml new file mode 100644 index 00000000..9595b96a --- /dev/null +++ b/kotlin-exposed-dialect/src/test/resources/log4j2.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + +