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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+