From ed9e07d213de3ffb7a6873d713dc304bb6dbca18 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 23 Mar 2026 01:08:06 +0300 Subject: [PATCH 01/43] add basic funcs without debug --- kotlin-exposed-dialect/.gitignore | 1 + kotlin-exposed-dialect/README.md | 15 ++ kotlin-exposed-dialect/docker-compose.yml | 10 + kotlin-exposed-dialect/pom.xml | 227 ++++++++++++++++++ .../tech/ydb/exposed/dialect/BaseYdbTest.kt | 33 +++ .../kotlin/tech/ydb/exposed/dialect/CrudIT.kt | 41 ++++ .../tech/ydb/exposed/dialect/IndexIT.kt | 27 +++ .../tech/ydb/exposed/dialect/PaginationIT.kt | 29 +++ .../tech/ydb/exposed/dialect/TypesIT.kt | 29 +++ .../tech/ydb/exposed/dialect/UpsertIT.kt | 42 ++++ .../exposed/dialect/YdbDataTypeProvider.kt | 36 +++ .../tech/ydb/exposed/dialect/YdbDialect.kt | 70 ++++++ .../ydb/exposed/dialect/YdbDialectProvider.kt | 32 +++ .../exposed/dialect/YdbFunctionProvider.kt | 61 +++++ .../exposed/dialect/YdbRetryingTransaction.kt | 54 +++++ .../dialect/pagination/KeysetPagination.kt | 26 ++ .../ydb/exposed/dialect/ConnectionTest.kt | 17 ++ .../tech/ydb/exposed/dialect/FunctionTest.kt | 84 +++++++ .../dialect/YdbDataTypeProviderTest.kt | 62 +++++ .../dialect/YdbRetryingTransactionTest.kt | 48 ++++ .../pagination/KeysetPaginationTest.kt | 42 ++++ 21 files changed, 986 insertions(+) create mode 100644 kotlin-exposed-dialect/.gitignore create mode 100644 kotlin-exposed-dialect/README.md create mode 100644 kotlin-exposed-dialect/docker-compose.yml create mode 100644 kotlin-exposed-dialect/pom.xml create mode 100644 kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/BaseYdbTest.kt create mode 100644 kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/CrudIT.kt create mode 100644 kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/IndexIT.kt create mode 100644 kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/PaginationIT.kt create mode 100644 kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/TypesIT.kt create mode 100644 kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/UpsertIT.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProvider.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbFunctionProvider.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransaction.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/ConnectionTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/FunctionTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProviderTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransactionTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPaginationTest.kt diff --git a/kotlin-exposed-dialect/.gitignore b/kotlin-exposed-dialect/.gitignore new file mode 100644 index 00000000..485dee64 --- /dev/null +++ b/kotlin-exposed-dialect/.gitignore @@ -0,0 +1 @@ +.idea diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md new file mode 100644 index 00000000..b8a70cf9 --- /dev/null +++ b/kotlin-exposed-dialect/README.md @@ -0,0 +1,15 @@ +## Поддержка YDB в Kotlin Exposed (YDB SQL Dialect + JDBC интеграция) +Руководитель: Курдюков Кирилл Алексеевич +Telegram: https://t.me/ForeverTired +Уровень: стажировка / курсовая / магистратура (lite?) + +### Описание предметной области +Exposed — ORM/SQL‑DSL для Kotlin. Требует диалект БД для генерации SQL и DDL через JDBC. YDB — распределённая транзакционная СУБД (аналог «SQL поверх key‑value») с собственным SQL/YQL, UPSERT, глобальными вторичными индексами и транзакциями с ретраями. Задача — добавить полноценный диалект YDB в Exposed, чтобы писать к YDB обычный Kotlin‑DSL/DAO без ручного SQL. + +### Что следует сделать: + +Реализовать диалект Exposed для YDB (JDBC): синтаксис идентификаторов, LIMIT, UPSERT/MERGE, генерация DDL (CREATE TABLE с обязательным PK), индексы (GSI), TTL. +Маппинг типов YDB ↔ Exposed: числовые, строка/Bytes, Bool, Date/Datetime/Timestamp/Interval, Decimal(p,s), UUID (String/Bytes), JSON. +Транзакции и ретраи: обработка abort/timeout с backoff, read-only/read-write, батчи, пагинация (LIMIT и keyset-хелпер). +Совместимость DSL/DAO: JOIN, подзапросы, many-to-many, optimistic locking (version column); при необходимости эмуляция UNIQUE/FOREIGN KEY, генерация идентификаторов без AUTO_INCREMENT (UUID/ULID). +Тесты и примеры: юнит и интеграционные (YDB в Docker), CI, демо-приложение (CRUD/индексы/пагинация), документация с ограничениями и рецептами. diff --git a/kotlin-exposed-dialect/docker-compose.yml b/kotlin-exposed-dialect/docker-compose.yml new file mode 100644 index 00000000..ecbbdc70 --- /dev/null +++ b/kotlin-exposed-dialect/docker-compose.yml @@ -0,0 +1,10 @@ +services: + ydb: + image: ydbplatform/local-ydb:latest + container_name: ydb-local + ports: + - "2136:2136" + - "8765:8765" + environment: + GRPC_PORT: 2136 + MON_PORT: 8765 \ No newline at end of file diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml new file mode 100644 index 00000000..f602690f --- /dev/null +++ b/kotlin-exposed-dialect/pom.xml @@ -0,0 +1,227 @@ + + + 4.0.0 + + tech.ydb.dialects + kotlin-exposed-ydb-dialect + 0.1.0-SNAPSHOT + + jar + + Kotlin Exposed YDB Dialect + Exposed dialect for YDB (YQL) + https://github.com/ydb-platform/ydb-java-dialects + + + + Svetlana Markelova + sv.markelova11@gmail.com + YDB + https://ydb.tech/ + + + + + 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 + + 2.2.20 + 1.0.0 + + 17 + + 17 + + 5.9.3 + + 2.3.22 + 2.3.22 + + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + org.jetbrains.exposed + exposed-core + ${exposed.version} + + + + org.jetbrains.exposed + exposed-jdbc + ${exposed.version} + + + + org.jetbrains.exposed + exposed-java-time + ${exposed.version} + + + + tech.ydb.jdbc + ydb-jdbc-driver + ${ydb.jdbc.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + tech.ydb.test + ydb-junit5-support + ${ydb.sdk.version} + test + + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + + + + + + + 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 + src/integrationTest/kotlin + + + + + + + + + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.0 + + false + + + **/*Test.kt + + + **/*IT.kt + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.1.0 + + + integration-tests + + integration-test + verify + + + + **/*IT + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.5.0 + + + + add-integration-test-source + generate-test-sources + + add-test-source + + + + src/integrationTest/kotlin + + + + + + add-test-source-kotlin + generate-test-sources + + add-test-source + + + + src/test/kotlin + + + + + + + + \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/BaseYdbTest.kt b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/BaseYdbTest.kt new file mode 100644 index 00000000..d5eecd7e --- /dev/null +++ b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/BaseYdbTest.kt @@ -0,0 +1,33 @@ +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 +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach + +abstract class BaseYdbTest { + + protected lateinit var db: Database + + @BeforeEach + fun setupDatabase() { + db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver" + ) + } + + @BeforeEach + fun setup() = transaction(db) { + // Можно очищать схемы, если нужно + } + + @AfterEach + fun teardown() = transaction(db) { + // Очистка таблиц после тестов + } + + // Утилита для упрощения вызова transaction + protected fun tx(block: JdbcTransaction.() -> Unit) = transaction(db) { block() } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/CrudIT.kt b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/CrudIT.kt new file mode 100644 index 00000000..cd532fec --- /dev/null +++ b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/CrudIT.kt @@ -0,0 +1,41 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.* +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.update +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CrudIT : BaseYdbTest() { + + object Users : Table("users") { + val id = integer("id") + val name = varchar("name", 255) + override val primaryKey = PrimaryKey(id) + } + + @Test + fun `should perform full CRUD`() = tx { + SchemaUtils.create(Users) + + // CREATE + Users.insert { it[id] = 1; it[name] = "Alice" } + + // READ + val user = Users.selectAll().single() + assertEquals("Alice", user[Users.name]) + + // UPDATE + Users.update({ Users.id eq 1 }) { it[name] = "Bob" } + val updated = Users.selectAll().single() + assertEquals("Bob", updated[Users.name]) + + // DELETE + Users.deleteWhere { Users.id eq 1 } + val count = Users.selectAll().count() + assertEquals(0, count) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/IndexIT.kt b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/IndexIT.kt new file mode 100644 index 00000000..82d87908 --- /dev/null +++ b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/IndexIT.kt @@ -0,0 +1,27 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +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) + val emailIndex = index(true, email) // UNIQUE INDEX + } + + @Test + fun `should create indexes`() = tx { + SchemaUtils.create(Customers) + + // Проверяем, что индекс создан через DSL (фактически SQL выполняется через SchemaUtils) + val indices = Customers.indices + assertTrue(indices.any { it.columns.contains(Customers.email) }) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/PaginationIT.kt b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/PaginationIT.kt new file mode 100644 index 00000000..059f20fd --- /dev/null +++ b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/PaginationIT.kt @@ -0,0 +1,29 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.* +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.Test + +class PaginationIT : BaseYdbTest() { + + object Items : Table("items") { + val id = integer("id") + val name = varchar("name", 255) + override val primaryKey = PrimaryKey(id) + } + + @Test + fun `should support LIMIT`() = tx { + SchemaUtils.create(Items) + + Items.insert { it[id] = 1; it[name] = "A" } + Items.insert { it[id] = 2; it[name] = "B" } + Items.insert { it[id] = 3; it[name] = "C" } + + val rows = Items.selectAll().limit(2).toList() + assertEquals(2, rows.size) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/TypesIT.kt b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/TypesIT.kt new file mode 100644 index 00000000..661b46de --- /dev/null +++ b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/TypesIT.kt @@ -0,0 +1,29 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TypesIT { + + object TestTable : Table("test_types") { + val id = integer("id") + val text = varchar("text", 255) + + override val primaryKey = PrimaryKey(id) + } + + @Test + fun `should map types correctly`() { + + transaction { + + val ddl = TestTable.ddl.joinToString(" ") + + assertTrue(ddl.contains("Int32")) + assertTrue(ddl.contains("Utf8") || ddl.contains("String")) + } + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/UpsertIT.kt b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/UpsertIT.kt new file mode 100644 index 00000000..f77e1887 --- /dev/null +++ b/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/UpsertIT.kt @@ -0,0 +1,42 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class UpsertIT : BaseYdbTest() { + + object Products : Table("products") { + val id = integer("id") + val name = varchar("name", 255) + override val primaryKey = PrimaryKey(id) + } + + @Test + fun `should perform UPSERT`() = tx { + SchemaUtils.create(Products) + + // Используем YdbFunctionProvider.upsert + val provider = YdbFunctionProvider() + val data = listOf( + Products.id to 1, + Products.name to "Item1" + ) + + provider.upsert( + table = Products, + data = data, + expression = "", + onUpdate = emptyList(), + keyColumns = listOf(Products.id), + where = null, + transaction = this + ) + + // Проверяем результат + val row = Products.selectAll().single() + assertEquals("Item1", row[Products.name]) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProvider.kt new file mode 100644 index 00000000..f2b30295 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProvider.kt @@ -0,0 +1,36 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider + + +class YdbDataTypeProvider : DataTypeProvider() { + override fun binaryType(): String = "String" + + override fun hexToDb(hexString: String): String = "'$hexString'" + + override fun integerType(): String = "Int32" + + override fun longType(): String = "Int64" + + override fun booleanType(): String = "Bool" + + override fun uuidType(): String = "Uuid" + + override fun textType(): String = "String" + + override fun varcharType(colLength: Int): String = "Utf8" + + override fun binaryType(length: Int): String = "String" + + override fun dateType(): String = "Date" + + override fun floatType(): String = "Float" + + override fun doubleType(): String = "Double" + + override fun dateTimeType(): String = "Datetime" + + override fun shortType(): String = "Int16" + + override fun jsonType(): String = "Json" +} \ No newline at end of file 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..23b6d9af --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt @@ -0,0 +1,70 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ColumnDiff +import org.jetbrains.exposed.v1.core.Index +import org.jetbrains.exposed.v1.core.Schema +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.vendors.DatabaseDialect +import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider +import org.jetbrains.exposed.v1.core.vendors.FunctionProvider + + +class YdbDialect(override val supportsMultipleGeneratedKeys: Boolean) : DatabaseDialect { + + override val name: String = "ydb" + + override fun createSchema(schema: Schema): String { + return "CREATE SCHEMA IF NOT EXISTS ${schema.identifier}" + } + + override fun addPrimaryKey( + table: Table, + pkName: String?, + vararg pkColumns: Column<*> + ): String { + val columns = pkColumns.joinToString(", ") { it.name } + return "PRIMARY KEY ($columns)" + } + + override fun createIndex(index: Index): String { + val columns = index.columns.joinToString(", ") { it.name } + val indexName = index.indexName + val tableName = index.table.tableName + + return buildString { + append("ALTER TABLE ") + append(tableName) + append(" ADD INDEX ") + append(indexName) + append(" GLOBAL ON (") + append(columns) + append(")") + } + } + + override fun dropIndex( + tableName: String, + indexName: String, + isUnique: Boolean, + isPartialOrFunctional: Boolean + ): String { + return "ALTER TABLE $tableName DROP INDEX $indexName" + } + + /** + * YDB не поддерживает ALTER COLUMN напрямую. + * Обычно требуется пересоздание таблицы. + */ + override fun modifyColumn( + column: Column<*>, + columnDiff: ColumnDiff + ): List { + return emptyList() + } + + override val dataTypeProvider: DataTypeProvider = YdbDataTypeProvider() + + override val functionProvider: FunctionProvider = YdbFunctionProvider() + +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt new file mode 100644 index 00000000..82711058 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt @@ -0,0 +1,32 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.jdbc.Database + + +object YdbDialectProvider { + + fun connect( + url: String = "jdbc:ydb:grpc://localhost:2136/local", + driver: String = "tech.ydb.jdbc.YdbDriver", + user: String = "", + password: String = "" + ): Database { + + val config = DatabaseConfig { + defaultFetchSize = 1000 + } + + return Database.connect( + url = url, + driver = driver, + user = user, + password = password, + databaseConfig = config, + dialect = YdbDialect(true), + setupConnection = TODO(), + connectionAutoRegistration = TODO(), + manager = TODO() + ) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbFunctionProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbFunctionProvider.kt new file mode 100644 index 00000000..5e65485d --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbFunctionProvider.kt @@ -0,0 +1,61 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.core.vendors.FunctionProvider + + +class YdbFunctionProvider: FunctionProvider() { + override fun random(seed: Int?): String = "Random()" + + override fun upsert( + table: Table, + data: List, Any?>>, + expression: String, + onUpdate: List, Any?>>, + keyColumns: List>, + where: Op?, + transaction: Transaction + ): String { + val columns = data.map { it.first.name } + + val values = data.map { (_, value) -> + when (value) { + null -> "NULL" + is String -> "'$value'" + else -> value.toString() + } + } + + val columnList = columns.joinToString(", ") + val valueList = values.joinToString(", ") + + return buildString { + append("UPSERT INTO ") + append(table.tableName) + append(" (") + append(columnList) + append(") VALUES (") + append(valueList) + append(")") + } + } + + override fun queryLimitAndOffset( + size: Int?, + offset: Long, + alreadyOrdered: Boolean + ): String { + return buildString { + append(" LIMIT ") + append(size) + + if (offset > 0) { + append(" OFFSET ") + append(offset) + } + } + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransaction.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransaction.kt new file mode 100644 index 00000000..267611df --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransaction.kt @@ -0,0 +1,54 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import java.sql.SQLException +import kotlin.math.min + +private val retryableErrors = listOf( + "ABORTED", + "UNAVAILABLE", + "OVERLOADED" +) + +fun ydbTransaction( + maxRetries: Int = 5, + initialBackoffMs: Long = 50, + maxBackoffMs: Long = 2000, + block: Transaction.() -> T +): T { + + var attempt = 0 + var backoff = initialBackoffMs + var lastException: Exception? = null + + while (attempt < maxRetries) { + + try { + return transaction { + block() + } + + } catch (e: SQLException) { + + val message = e.message ?: "" + + val retryable = retryableErrors.any { + message.contains(it, ignoreCase = true) + } + + if (!retryable) { + throw e + } + + lastException = e + } + + Thread.sleep(backoff) + + backoff = min(backoff * 2, maxBackoffMs) + attempt++ + } + + throw lastException ?: IllegalStateException("Transaction failed after retries") +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt new file mode 100644 index 00000000..6902b947 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt @@ -0,0 +1,26 @@ +package tech.ydb.exposed.dialect.pagination + +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.Query +import org.jetbrains.exposed.v1.jdbc.andWhere + +/** + * Helper для keyset pagination в YDB. + * + * Генерирует: + * SELECT ... WHERE key > lastKey ORDER BY key LIMIT N + */ +fun > Query.keysetPage( + column: Column, + lastValue: T?, + limit: Int +): Query { + + if (lastValue != null) { + this.andWhere { column greater lastValue } + } + + return this + .orderBy(column to SortOrder.ASC) + .limit(limit) +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/ConnectionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/ConnectionTest.kt new file mode 100644 index 00000000..22652b38 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/ConnectionTest.kt @@ -0,0 +1,17 @@ +package tech.ydb.exposed.dialect + +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + + +class ConnectionTest { + @Test + fun `should connect to YDB`() { + val db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver" + ) + + assertNotNull(db) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/FunctionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/FunctionTest.kt new file mode 100644 index 00000000..e34ba12f --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/FunctionTest.kt @@ -0,0 +1,84 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +object Users : Table("users") { + + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) +} + +class FunctionTests { + private val provider = YdbFunctionProvider() + + @Test + fun `should generate UPSERT statement`() { + val data = listOf( + Users.id to 1, + Users.name to "Alice" + ) + + val sql = transaction { + 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`() { + val data = listOf( + Users.id to 1, + Users.name to null + ) + + val sql = transaction { + provider.upsert( + table = Users, + data = data, + expression = "", + onUpdate = emptyList(), + keyColumns = emptyList(), + where = null, + transaction = this + ) + } + + assertTrue(sql.contains("NULL")) + } + + @Test + fun `should generate correct CREATE TABLE`() { + val ddlStatements = Users.ddl + val ddl = ddlStatements.joinToString(" ") + + assertTrue(ddl.contains("CREATE TABLE users")) + assertTrue(ddl.contains("id")) + assertTrue(ddl.contains("name")) + assertTrue(ddl.contains("PRIMARY KEY")) + } + + @Test + fun `should map integer to Int32`() { + val column = Users.id + val type = column.columnType.sqlType() + + assertTrue(type.contains("Int32") || type.contains("INT")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProviderTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProviderTest.kt new file mode 100644 index 00000000..e856e30a --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProviderTest.kt @@ -0,0 +1,62 @@ +package tech.ydb.exposed.dialect + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class YdbDataTypeProviderTest { + + private val provider = YdbDataTypeProvider() + + @Test + fun `should map integer types`() { + assertEquals("Int32", provider.integerType()) + assertEquals("Int64", provider.longType()) + assertEquals("Int16", provider.shortType()) + } + + @Test + fun `should map string types`() { + assertEquals("Utf8", provider.varcharType(255)) + assertEquals("String", provider.textType()) + assertEquals("String", provider.binaryType()) + assertEquals("String", provider.binaryType(100)) + } + + @Test + fun `should map boolean type`() { + assertEquals("Bool", provider.booleanType()) + } + + @Test + fun `should map UUID type`() { + assertEquals("Uuid", provider.uuidType()) + } + + @Test + fun `should map JSON type`() { + assertEquals("Json", provider.jsonType()) + } + + @Test + fun `should map numeric types`() { + assertEquals("Float", provider.floatType()) + assertEquals("Double", provider.doubleType()) + } + + @Test + fun `should map date and time types`() { + assertEquals("Date", provider.dateType()) + assertEquals("Datetime", provider.dateTimeType()) + } + + @Test + fun `should map autoincrement type`() { + assertEquals("Int32", provider.integerAutoincType()) + } + + @Test + fun `should convert hex to SQL`() { + val hex = "0xABCD" + assertEquals("'0xABCD'", provider.hexToDb(hex)) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransactionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransactionTest.kt new file mode 100644 index 00000000..1eb86d3b --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransactionTest.kt @@ -0,0 +1,48 @@ +package tech.ydb.exposed.dialect + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.sql.SQLException +import java.util.concurrent.atomic.AtomicInteger + +class YdbRetryingTransactionTest { + @Test + fun `should retry aborted transaction`() { + + val attempts = AtomicInteger(0) + + val result = try { + ydbTransaction(maxRetries = 3) { + + if (attempts.incrementAndGet() < 3) { + throw SQLException("ABORTED") + } + + 42 + } + } catch (e: Exception) { + -1 + } + + assertEquals(42, result) + assertEquals(3, attempts.get()) + } + + @Test + fun `should not retry non retryable errors`() { + + var attempts = 0 + + try { + ydbTransaction(maxRetries = 3) { + + attempts++ + throw SQLException("SYNTAX ERROR") + + } + } catch (_: SQLException) { + } + + assertEquals(1, attempts) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPaginationTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPaginationTest.kt new file mode 100644 index 00000000..1864a12d --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPaginationTest.kt @@ -0,0 +1,42 @@ +package tech.ydb.exposed.dialect.pagination + + +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +object TestUsers : Table("users") { + val id = integer("id") + val name = varchar("name", 255) +} + +class KeysetPaginationTest { + + @Test + fun `should apply keyset pagination`() { + + val query = TestUsers + .selectAll() + .keysetPage(TestUsers.id, lastValue = 10, limit = 20) + + val sql = query.prepareSQL(QueryBuilder(false)) + + assertTrue(sql.contains("WHERE")) + assertTrue(sql.contains("id")) + assertTrue(sql.contains("LIMIT")) + } + + @Test + fun `should generate first page without where`() { + + val query = TestUsers + .selectAll() + .keysetPage(TestUsers.id, lastValue = null, limit = 20) + + val sql = query.prepareSQL(QueryBuilder(false)) + + assertTrue(sql.contains("LIMIT")) + } +} + From 4be79ba30aac255fbb934a74741d82fd39fbf72d Mon Sep 17 00:00:00 2001 From: s-markelova Date: Wed, 25 Mar 2026 00:55:37 +0300 Subject: [PATCH 02/43] change project structure --- kotlin-exposed-dialect/pom.xml | 55 ++-------------- .../exposed/dialect/{ => basic}/YdbDialect.kt | 7 ++- .../dialect/{ => basic}/YdbDialectProvider.kt | 7 +-- .../{ => functions}/YdbFunctionProvider.kt | 3 +- .../YdbRetryingTransaction.kt | 2 +- .../{ => types}/YdbDataTypeProvider.kt | 3 +- .../dialect/YdbDataTypeProviderTest.kt | 62 ------------------ .../dialect/integration/base}/BaseYdbTest.kt | 3 +- .../dialect/integration/crud}/CrudIT.kt | 16 ++--- .../dialect/integration/ddl}/IndexIT.kt | 10 +-- .../integration/pagination}/PaginationIT.kt | 9 +-- .../dialect/integration/types}/TypesIT.kt | 11 ++-- .../dialect/integration/upsert}/UpsertIT.kt | 10 +-- .../pagination/KeysetPaginationTest.kt | 42 ------------- .../{ => unit/connection}/ConnectionTest.kt | 8 +-- .../{ => unit/functions}/FunctionTest.kt | 3 +- .../YdbRetryingTransactionTest.kt | 11 ++-- .../unit/types/YdbDataTypeProviderTest.kt | 63 +++++++++++++++++++ 18 files changed, 122 insertions(+), 203 deletions(-) rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{ => basic}/YdbDialect.kt (92%) rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{ => basic}/YdbDialectProvider.kt (89%) rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{ => functions}/YdbFunctionProvider.kt (97%) rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{ => transaction}/YdbRetryingTransaction.kt (96%) rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{ => types}/YdbDataTypeProvider.kt (95%) delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProviderTest.kt rename kotlin-exposed-dialect/src/{integrationTest/kotlin/tech/ydb/exposed/dialect => test/kotlin/tech/ydb/exposed/dialect/integration/base}/BaseYdbTest.kt (89%) rename kotlin-exposed-dialect/src/{integrationTest/kotlin/tech/ydb/exposed/dialect => test/kotlin/tech/ydb/exposed/dialect/integration/crud}/CrudIT.kt (70%) rename kotlin-exposed-dialect/src/{integrationTest/kotlin/tech/ydb/exposed/dialect => test/kotlin/tech/ydb/exposed/dialect/integration/ddl}/IndexIT.kt (71%) rename kotlin-exposed-dialect/src/{integrationTest/kotlin/tech/ydb/exposed/dialect => test/kotlin/tech/ydb/exposed/dialect/integration/pagination}/PaginationIT.kt (73%) rename kotlin-exposed-dialect/src/{integrationTest/kotlin/tech/ydb/exposed/dialect => test/kotlin/tech/ydb/exposed/dialect/integration/types}/TypesIT.kt (60%) rename kotlin-exposed-dialect/src/{integrationTest/kotlin/tech/ydb/exposed/dialect => test/kotlin/tech/ydb/exposed/dialect/integration/upsert}/UpsertIT.kt (75%) delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPaginationTest.kt rename kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/{ => unit/connection}/ConnectionTest.kt (59%) rename kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/{ => unit/functions}/FunctionTest.kt (95%) rename kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/{ => unit/transaction}/YdbRetryingTransactionTest.kt (74%) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDataTypeProviderTest.kt diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index f602690f..4bc567cf 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -107,12 +107,14 @@ + org.jetbrains.kotlin kotlin-maven-plugin ${kotlin.version} + compile compile @@ -121,11 +123,9 @@ 17 - - src/main/kotlin - + test-compile test-compile @@ -134,22 +134,11 @@ 17 - - src/test/kotlin - src/integrationTest/kotlin - + - - - - - - - - @@ -158,7 +147,6 @@ 3.1.0 false - **/*Test.kt @@ -182,46 +170,13 @@ - **/*IT + **/*IT.kt - - org.codehaus.mojo - build-helper-maven-plugin - 3.5.0 - - - - add-integration-test-source - generate-test-sources - - add-test-source - - - - src/integrationTest/kotlin - - - - - - add-test-source-kotlin - generate-test-sources - - add-test-source - - - - src/test/kotlin - - - - - \ No newline at end of file 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/basic/YdbDialect.kt similarity index 92% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index 23b6d9af..8bd6dc8f 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -1,14 +1,15 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.basic import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.ColumnDiff import org.jetbrains.exposed.v1.core.Index import org.jetbrains.exposed.v1.core.Schema import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.vendors.DatabaseDialect import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider +import org.jetbrains.exposed.v1.core.vendors.DatabaseDialect import org.jetbrains.exposed.v1.core.vendors.FunctionProvider - +import tech.ydb.exposed.dialect.types.YdbDataTypeProvider +import tech.ydb.exposed.dialect.functions.YdbFunctionProvider class YdbDialect(override val supportsMultipleGeneratedKeys: Boolean) : DatabaseDialect { diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt similarity index 89% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt index 82711058..9d72b69d 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt @@ -1,11 +1,10 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.basic import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database - object YdbDialectProvider { - + fun connect( url: String = "jdbc:ydb:grpc://localhost:2136/local", driver: String = "tech.ydb.jdbc.YdbDriver", @@ -13,7 +12,7 @@ object YdbDialectProvider { password: String = "" ): Database { - val config = DatabaseConfig { + val config = DatabaseConfig.Companion { defaultFetchSize = 1000 } diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbFunctionProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt similarity index 97% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbFunctionProvider.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt index 5e65485d..514eaf1c 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbFunctionProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt @@ -1,4 +1,4 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.functions import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Op @@ -6,7 +6,6 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.Transaction import org.jetbrains.exposed.v1.core.vendors.FunctionProvider - class YdbFunctionProvider: FunctionProvider() { override fun random(seed: Int?): String = "Random()" diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransaction.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt similarity index 96% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransaction.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt index 267611df..0723a3a3 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransaction.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt @@ -1,4 +1,4 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.transaction import org.jetbrains.exposed.v1.core.Transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt similarity index 95% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProvider.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt index f2b30295..3d53d48a 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt @@ -1,8 +1,7 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.types import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider - class YdbDataTypeProvider : DataTypeProvider() { override fun binaryType(): String = "String" diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProviderTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProviderTest.kt deleted file mode 100644 index e856e30a..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbDataTypeProviderTest.kt +++ /dev/null @@ -1,62 +0,0 @@ -package tech.ydb.exposed.dialect - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class YdbDataTypeProviderTest { - - private val provider = YdbDataTypeProvider() - - @Test - fun `should map integer types`() { - assertEquals("Int32", provider.integerType()) - assertEquals("Int64", provider.longType()) - assertEquals("Int16", provider.shortType()) - } - - @Test - fun `should map string types`() { - assertEquals("Utf8", provider.varcharType(255)) - assertEquals("String", provider.textType()) - assertEquals("String", provider.binaryType()) - assertEquals("String", provider.binaryType(100)) - } - - @Test - fun `should map boolean type`() { - assertEquals("Bool", provider.booleanType()) - } - - @Test - fun `should map UUID type`() { - assertEquals("Uuid", provider.uuidType()) - } - - @Test - fun `should map JSON type`() { - assertEquals("Json", provider.jsonType()) - } - - @Test - fun `should map numeric types`() { - assertEquals("Float", provider.floatType()) - assertEquals("Double", provider.doubleType()) - } - - @Test - fun `should map date and time types`() { - assertEquals("Date", provider.dateType()) - assertEquals("Datetime", provider.dateTimeType()) - } - - @Test - fun `should map autoincrement type`() { - assertEquals("Int32", provider.integerAutoincType()) - } - - @Test - fun `should convert hex to SQL`() { - val hex = "0xABCD" - assertEquals("'0xABCD'", provider.hexToDb(hex)) - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/BaseYdbTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/BaseYdbTest.kt similarity index 89% rename from kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/BaseYdbTest.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/BaseYdbTest.kt index d5eecd7e..e80eb500 100644 --- a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/BaseYdbTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/BaseYdbTest.kt @@ -1,10 +1,11 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.integration.base import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.JdbcTransaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach +import tech.ydb.exposed.dialect.basic.YdbDialectProvider abstract class BaseYdbTest { diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/CrudIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/crud/CrudIT.kt similarity index 70% rename from kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/CrudIT.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/crud/CrudIT.kt index cd532fec..960fa01f 100644 --- a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/CrudIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/crud/CrudIT.kt @@ -1,13 +1,15 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.integration.crud -import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.Table +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.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.update -import org.jetbrains.exposed.v1.jdbc.deleteWhere -import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class CrudIT : BaseYdbTest() { @@ -26,16 +28,16 @@ class CrudIT : BaseYdbTest() { // READ val user = Users.selectAll().single() - assertEquals("Alice", user[Users.name]) + Assertions.assertEquals("Alice", user[Users.name]) // UPDATE Users.update({ Users.id eq 1 }) { it[name] = "Bob" } val updated = Users.selectAll().single() - assertEquals("Bob", updated[Users.name]) + Assertions.assertEquals("Bob", updated[Users.name]) // DELETE Users.deleteWhere { Users.id eq 1 } val count = Users.selectAll().count() - assertEquals(0, count) + Assertions.assertEquals(0, count) } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/IndexIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/IndexIT.kt similarity index 71% rename from kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/IndexIT.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/IndexIT.kt index 82d87908..31e8dffd 100644 --- a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/IndexIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/IndexIT.kt @@ -1,10 +1,10 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.integration.ddl -import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.SchemaUtils -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class IndexIT : BaseYdbTest() { @@ -22,6 +22,6 @@ class IndexIT : BaseYdbTest() { // Проверяем, что индекс создан через DSL (фактически SQL выполняется через SchemaUtils) val indices = Customers.indices - assertTrue(indices.any { it.columns.contains(Customers.email) }) + Assertions.assertTrue(indices.any { it.columns.contains(Customers.email) }) } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/PaginationIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt similarity index 73% rename from kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/PaginationIT.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt index 059f20fd..c476ec9a 100644 --- a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/PaginationIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt @@ -1,11 +1,12 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.integration.pagination -import org.jetbrains.exposed.v1.core.* +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 import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class PaginationIT : BaseYdbTest() { @@ -24,6 +25,6 @@ class PaginationIT : BaseYdbTest() { Items.insert { it[id] = 3; it[name] = "C" } val rows = Items.selectAll().limit(2).toList() - assertEquals(2, rows.size) + Assertions.assertEquals(2, rows.size) } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/TypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TypesIT.kt similarity index 60% rename from kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/TypesIT.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TypesIT.kt index 661b46de..8ea2a443 100644 --- a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/TypesIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TypesIT.kt @@ -1,9 +1,8 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.integration.types -import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.jdbc.* +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test class TypesIT { @@ -22,8 +21,8 @@ class TypesIT { val ddl = TestTable.ddl.joinToString(" ") - assertTrue(ddl.contains("Int32")) - assertTrue(ddl.contains("Utf8") || ddl.contains("String")) + Assertions.assertTrue(ddl.contains("Int32")) + Assertions.assertTrue(ddl.contains("Utf8") || ddl.contains("String")) } } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/UpsertIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/UpsertIT.kt similarity index 75% rename from kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/UpsertIT.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/UpsertIT.kt index f77e1887..efce45af 100644 --- a/kotlin-exposed-dialect/src/integrationTest/kotlin/tech/ydb/exposed/dialect/UpsertIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/UpsertIT.kt @@ -1,10 +1,12 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.integration.upsert -import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.selectAll -import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.functions.YdbFunctionProvider +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UpsertIT : BaseYdbTest() { @@ -37,6 +39,6 @@ class UpsertIT : BaseYdbTest() { // Проверяем результат val row = Products.selectAll().single() - assertEquals("Item1", row[Products.name]) + Assertions.assertEquals("Item1", row[Products.name]) } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPaginationTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPaginationTest.kt deleted file mode 100644 index 1864a12d..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPaginationTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package tech.ydb.exposed.dialect.pagination - - -import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.jdbc.selectAll -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -object TestUsers : Table("users") { - val id = integer("id") - val name = varchar("name", 255) -} - -class KeysetPaginationTest { - - @Test - fun `should apply keyset pagination`() { - - val query = TestUsers - .selectAll() - .keysetPage(TestUsers.id, lastValue = 10, limit = 20) - - val sql = query.prepareSQL(QueryBuilder(false)) - - assertTrue(sql.contains("WHERE")) - assertTrue(sql.contains("id")) - assertTrue(sql.contains("LIMIT")) - } - - @Test - fun `should generate first page without where`() { - - val query = TestUsers - .selectAll() - .keysetPage(TestUsers.id, lastValue = null, limit = 20) - - val sql = query.prepareSQL(QueryBuilder(false)) - - assertTrue(sql.contains("LIMIT")) - } -} - diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/ConnectionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt similarity index 59% rename from kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/ConnectionTest.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt index 22652b38..6687e189 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/ConnectionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt @@ -1,8 +1,8 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.unit.connection -import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test - +import tech.ydb.exposed.dialect.basic.YdbDialectProvider class ConnectionTest { @Test @@ -12,6 +12,6 @@ class ConnectionTest { driver = "tech.ydb.jdbc.YdbDriver" ) - assertNotNull(db) + Assertions.assertNotNull(db) } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/FunctionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt similarity index 95% rename from kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/FunctionTest.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt index e34ba12f..9c528a05 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/FunctionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt @@ -1,9 +1,10 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.unit.functions import org.jetbrains.exposed.v1.core.* import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.functions.YdbFunctionProvider object Users : Table("users") { diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransactionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt similarity index 74% rename from kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransactionTest.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt index 1eb86d3b..543a180f 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/YdbRetryingTransactionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt @@ -1,7 +1,8 @@ -package tech.ydb.exposed.dialect +package tech.ydb.exposed.dialect.unit.transaction -import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.transaction.ydbTransaction import java.sql.SQLException import java.util.concurrent.atomic.AtomicInteger @@ -24,8 +25,8 @@ class YdbRetryingTransactionTest { -1 } - assertEquals(42, result) - assertEquals(3, attempts.get()) + Assertions.assertEquals(42, result) + Assertions.assertEquals(3, attempts.get()) } @Test @@ -43,6 +44,6 @@ class YdbRetryingTransactionTest { } catch (_: SQLException) { } - assertEquals(1, attempts) + Assertions.assertEquals(1, attempts) } } \ No newline at end of file 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..1b6b162c --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDataTypeProviderTest.kt @@ -0,0 +1,63 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.types.YdbDataTypeProvider + +class YdbDataTypeProviderTest { + + private val provider = YdbDataTypeProvider() + + @Test + fun `should map integer types`() { + Assertions.assertEquals("Int32", provider.integerType()) + Assertions.assertEquals("Int64", provider.longType()) + Assertions.assertEquals("Int16", provider.shortType()) + } + + @Test + fun `should map string types`() { + Assertions.assertEquals("Utf8", provider.varcharType(255)) + Assertions.assertEquals("String", provider.textType()) + Assertions.assertEquals("String", provider.binaryType()) + Assertions.assertEquals("String", provider.binaryType(100)) + } + + @Test + fun `should map boolean type`() { + Assertions.assertEquals("Bool", provider.booleanType()) + } + + @Test + fun `should map UUID type`() { + Assertions.assertEquals("Uuid", provider.uuidType()) + } + + @Test + fun `should map JSON type`() { + Assertions.assertEquals("Json", provider.jsonType()) + } + + @Test + fun `should map numeric types`() { + Assertions.assertEquals("Float", provider.floatType()) + Assertions.assertEquals("Double", provider.doubleType()) + } + + @Test + fun `should map date and time types`() { + Assertions.assertEquals("Date", provider.dateType()) + Assertions.assertEquals("Datetime", provider.dateTimeType()) + } + + @Test + fun `should map autoincrement type`() { + Assertions.assertEquals("Int32", provider.integerAutoincType()) + } + + @Test + fun `should convert hex to SQL`() { + val hex = "0xABCD" + Assertions.assertEquals("'0xABCD'", provider.hexToDb(hex)) + } +} \ No newline at end of file From 1dc2d1d675ca46661468fbef37272fa6f09744e6 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Sun, 29 Mar 2026 18:29:12 +0300 Subject: [PATCH 03/43] change description --- kotlin-exposed-dialect/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index b8a70cf9..45b80236 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -1,7 +1,4 @@ ## Поддержка YDB в Kotlin Exposed (YDB SQL Dialect + JDBC интеграция) -Руководитель: Курдюков Кирилл Алексеевич -Telegram: https://t.me/ForeverTired -Уровень: стажировка / курсовая / магистратура (lite?) ### Описание предметной области Exposed — ORM/SQL‑DSL для Kotlin. Требует диалект БД для генерации SQL и DDL через JDBC. YDB — распределённая транзакционная СУБД (аналог «SQL поверх key‑value») с собственным SQL/YQL, UPSERT, глобальными вторичными индексами и транзакциями с ретраями. Задача — добавить полноценный диалект YDB в Exposed, чтобы писать к YDB обычный Kotlin‑DSL/DAO без ручного SQL. From a659b3b69eabcffc5684c5d1d07cd531659be011 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Thu, 9 Apr 2026 03:18:17 +0300 Subject: [PATCH 04/43] edit tests and connection --- kotlin-exposed-dialect/pom.xml | 20 ++++++- .../ydb/exposed/dialect/basic/YdbDialect.kt | 55 +++++++++--------- .../dialect/basic/YdbDialectProvider.kt | 46 ++++++++++----- .../dialect/types/YdbDataTypeProvider.kt | 4 +- ...ns.exposed.v1.core.vendors.DatabaseDialect | 2 + .../dialect/integration/base/BaseYdbTest.kt | 5 ++ .../dialect/unit/connection/ConnectionTest.kt | 34 +++++------ .../transaction/YdbRetryingTransactionTest.kt | 58 +++++++++++-------- 8 files changed, 134 insertions(+), 90 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.core.vendors.DatabaseDialect diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 4bc567cf..56afc87f 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -57,6 +57,7 @@ org.jetbrains.kotlin kotlin-stdlib ${kotlin.version} + test @@ -103,6 +104,13 @@ ${kotlin.version} + + io.mockk + mockk + 1.13.7 + test + + @@ -123,6 +131,9 @@ 17 + + src/main/kotlin + @@ -134,6 +145,9 @@ 17 + + src/test/kotlin + @@ -148,10 +162,10 @@ false - **/*Test.kt + **/*Test.* - **/*IT.kt + **/*IT.* @@ -170,7 +184,7 @@ - **/*IT.kt + **/*IT.* diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index 8bd6dc8f..f452e624 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -3,30 +3,28 @@ package tech.ydb.exposed.dialect.basic import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.ColumnDiff import org.jetbrains.exposed.v1.core.Index -import org.jetbrains.exposed.v1.core.Schema import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider import org.jetbrains.exposed.v1.core.vendors.DatabaseDialect import org.jetbrains.exposed.v1.core.vendors.FunctionProvider +import org.jetbrains.exposed.v1.core.vendors.VendorDialect import tech.ydb.exposed.dialect.types.YdbDataTypeProvider import tech.ydb.exposed.dialect.functions.YdbFunctionProvider -class YdbDialect(override val supportsMultipleGeneratedKeys: Boolean) : DatabaseDialect { +class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvider()) { - override val name: String = "ydb" +// override val name: String = "ydb" + +// override fun addPrimaryKey( +// table: Table, +// pkName: String?, +// vararg pkColumns: Column<*> +// ): String { +// val columns = pkColumns.joinToString(", ") { it.name } +// return "PRIMARY KEY ($columns)" +// } - override fun createSchema(schema: Schema): String { - return "CREATE SCHEMA IF NOT EXISTS ${schema.identifier}" - } - override fun addPrimaryKey( - table: Table, - pkName: String?, - vararg pkColumns: Column<*> - ): String { - val columns = pkColumns.joinToString(", ") { it.name } - return "PRIMARY KEY ($columns)" - } override fun createIndex(index: Index): String { val columns = index.columns.joinToString(", ") { it.name } @@ -53,19 +51,20 @@ class YdbDialect(override val supportsMultipleGeneratedKeys: Boolean) : Database return "ALTER TABLE $tableName DROP INDEX $indexName" } - /** - * YDB не поддерживает ALTER COLUMN напрямую. - * Обычно требуется пересоздание таблицы. - */ - override fun modifyColumn( - column: Column<*>, - columnDiff: ColumnDiff - ): List { - return emptyList() - } - - override val dataTypeProvider: DataTypeProvider = YdbDataTypeProvider() - - override val functionProvider: FunctionProvider = YdbFunctionProvider() +// /** +// * YDB не поддерживает ALTER COLUMN напрямую. +// * Обычно требуется пересоздание таблицы. +// */ +// override fun modifyColumn( +// column: Column<*>, +// columnDiff: ColumnDiff +// ): List { +// return emptyList() +// } +// +// +// override val dataTypeProvider: DataTypeProvider = YdbDataTypeProvider() +// +// override val functionProvider: FunctionProvider = YdbFunctionProvider() } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt index 9d72b69d..18a709c7 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt @@ -1,31 +1,47 @@ package tech.ydb.exposed.dialect.basic -import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database object YdbDialectProvider { fun connect( - url: String = "jdbc:ydb:grpc://localhost:2136/local", - driver: String = "tech.ydb.jdbc.YdbDriver", + url: String, + driver: String, user: String = "", password: String = "" ): Database { - - val config = DatabaseConfig.Companion { - defaultFetchSize = 1000 - } - return Database.connect( url = url, driver = driver, user = user, - password = password, - databaseConfig = config, - dialect = YdbDialect(true), - setupConnection = TODO(), - connectionAutoRegistration = TODO(), - manager = TODO() + password = password ) } -} \ No newline at end of file +} +// +////object YdbDialectProvider { +//// +//// fun connect( +//// url: String = "jdbc:ydb:grpc://localhost:2136/local", +//// driver: String = "tech.ydb.jdbc.YdbDriver", +//// user: String = "", +//// password: String = "" +//// ): Database { +//// +//// val config = DatabaseConfig.Companion { +//// defaultFetchSize = 1000 +//// } +//// +//// return Database.connect( +//// url = url, +//// driver = driver, +//// user = user, +//// password = password, +//// databaseConfig = config, +//// dialect = YdbDialect(true), +//// setupConnection = TODO(), +//// connectionAutoRegistration = TODO(), +//// manager = TODO() +//// ) +//// } +////} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt index 3d53d48a..98f383e3 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt @@ -7,6 +7,8 @@ class YdbDataTypeProvider : DataTypeProvider() { override fun hexToDb(hexString: String): String = "'$hexString'" + override fun shortType(): String = "Int16" + override fun integerType(): String = "Int32" override fun longType(): String = "Int64" @@ -29,7 +31,5 @@ class YdbDataTypeProvider : DataTypeProvider() { override fun dateTimeType(): String = "Datetime" - override fun shortType(): String = "Int16" - override fun jsonType(): String = "Json" } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.core.vendors.DatabaseDialect b/kotlin-exposed-dialect/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.core.vendors.DatabaseDialect new file mode 100644 index 00000000..a099292d --- /dev/null +++ b/kotlin-exposed-dialect/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.core.vendors.DatabaseDialect @@ -0,0 +1,2 @@ +tech.ydb.exposed.dialect.basic.YdbDialect + 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 index e80eb500..bdbb5400 100644 --- 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 @@ -29,6 +29,11 @@ abstract class BaseYdbTest { // Очистка таблиц после тестов } +// protected fun tx(block: () -> Unit) = +// YdbTransactionManager.transactionWithRetry(db) { +// block() +// } + // Утилита для упрощения вызова transaction protected fun tx(block: JdbcTransaction.() -> Unit) = transaction(db) { block() } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt index 6687e189..dac23588 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt @@ -1,17 +1,17 @@ -package tech.ydb.exposed.dialect.unit.connection - -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbDialectProvider - -class ConnectionTest { - @Test - fun `should connect to YDB`() { - val db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - driver = "tech.ydb.jdbc.YdbDriver" - ) - - Assertions.assertNotNull(db) - } -} \ No newline at end of file +//package tech.ydb.exposed.dialect.unit.connection +// +//import org.junit.jupiter.api.Assertions +//import org.junit.jupiter.api.Test +//import tech.ydb.exposed.dialect.basic.YdbDialectProvider +// +//class ConnectionTest { +// @Test +// fun `should connect to YDB`() { +// val db = YdbDialectProvider.connect( +// url = "jdbc:ydb:grpc://localhost:2136/local", +// driver = "tech.ydb.jdbc.YdbDriver" +// ) +// +// Assertions.assertNotNull(db) +// } +//} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt index 543a180f..f4dee88a 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt @@ -1,49 +1,57 @@ -package tech.ydb.exposed.dialect.unit.transaction - -import org.junit.jupiter.api.Assertions +import io.mockk.every +import io.mockk.mockkStatic +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.transaction.ydbTransaction import java.sql.SQLException import java.util.concurrent.atomic.AtomicInteger class YdbRetryingTransactionTest { + @Test fun `should retry aborted transaction`() { - val attempts = AtomicInteger(0) - val result = try { - ydbTransaction(maxRetries = 3) { + // мок статической функции + mockkStatic("tech.ydb.exposed.dialect.transaction.YdbTransactionKt") { + every { ydbTransaction(any(), any(), captureLambda()) } answers { + lambda().invoke() // вызываем блок + } - if (attempts.incrementAndGet() < 3) { - throw SQLException("ABORTED") + val result = try { + ydbTransaction(maxRetries = 3) { + if (attempts.incrementAndGet() < 3) { + throw SQLException("ABORTED") + } + 42 } - - 42 + } catch (e: Exception) { + -1 } - } catch (e: Exception) { - -1 - } - Assertions.assertEquals(42, result) - Assertions.assertEquals(3, attempts.get()) + assertEquals(42, result) + assertEquals(3, attempts.get()) + } } @Test fun `should not retry non retryable errors`() { + val attempts = AtomicInteger(0) - var attempts = 0 - - try { - ydbTransaction(maxRetries = 3) { + mockkStatic("tech.ydb.exposed.dialect.transaction.YdbTransactionKt") { + every { ydbTransaction(any(), any(), captureLambda()) } answers { + lambda().invoke() + } - attempts++ - throw SQLException("SYNTAX ERROR") + val exception = runCatching { + ydbTransaction(maxRetries = 3) { + attempts.incrementAndGet() + throw SQLException("SYNTAX ERROR") + } + }.exceptionOrNull() - } - } catch (_: SQLException) { + assertEquals(1, attempts.get()) + assert(exception is SQLException) } - - Assertions.assertEquals(1, attempts) } } \ No newline at end of file From a6c484bac40ca483f550f84c45159c59fa39904f Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 13 Apr 2026 00:05:17 +0300 Subject: [PATCH 05/43] fix connection error --- kotlin-exposed-dialect/docker-compose.yml | 4 +- kotlin-exposed-dialect/pom.xml | 14 +-- .../ydb/exposed/dialect/basic/YdbDialect.kt | 4 +- .../dialect/basic/YdbDialectProvider.kt | 60 +++++++++++- .../dialect/types/YdbDataTypeProvider.kt | 2 + ...ns.exposed.v1.core.vendors.DatabaseDialect | 2 - .../dialect/integration/base/BaseYdbTest.kt | 75 ++++++++++++--- .../dialect/integration/base/ConnectionIT.kt | 12 +++ .../transaction/YdbRetryingTransactionTest.kt | 95 ++++++++----------- 9 files changed, 182 insertions(+), 86 deletions(-) delete mode 100644 kotlin-exposed-dialect/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.core.vendors.DatabaseDialect create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/base/ConnectionIT.kt diff --git a/kotlin-exposed-dialect/docker-compose.yml b/kotlin-exposed-dialect/docker-compose.yml index ecbbdc70..f9641b26 100644 --- a/kotlin-exposed-dialect/docker-compose.yml +++ b/kotlin-exposed-dialect/docker-compose.yml @@ -2,9 +2,11 @@ services: ydb: image: ydbplatform/local-ydb:latest container_name: ydb-local + hostname: localhost ports: - "2136:2136" - "8765:8765" environment: GRPC_PORT: 2136 - MON_PORT: 8765 \ No newline at end of file + MON_PORT: 8765 +# YDB_GRPC_PUBLIC_HOST: localhost \ No newline at end of file diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 56afc87f..76212fcb 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -57,7 +57,7 @@ org.jetbrains.kotlin kotlin-stdlib ${kotlin.version} - test + @@ -104,12 +104,12 @@ ${kotlin.version} - - io.mockk - mockk - 1.13.7 - test - + + + + + + diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index f452e624..8938db76 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -11,7 +11,7 @@ import org.jetbrains.exposed.v1.core.vendors.VendorDialect import tech.ydb.exposed.dialect.types.YdbDataTypeProvider import tech.ydb.exposed.dialect.functions.YdbFunctionProvider -class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvider()) { +class YdbDialect: VendorDialect("YDB", YdbDataTypeProvider(), YdbFunctionProvider()) { // override val name: String = "ydb" @@ -24,8 +24,6 @@ class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvide // return "PRIMARY KEY ($columns)" // } - - override fun createIndex(index: Index): String { val columns = index.columns.joinToString(", ") { it.name } val indexName = index.indexName diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt index 18a709c7..84fce019 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt @@ -1,24 +1,74 @@ package tech.ydb.exposed.dialect.basic +import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database +import java.sql.Connection +import java.sql.DriverManager +import java.util.Properties object YdbDialectProvider { + private const val DEFAULT_DRIVER = "tech.ydb.jdbc.YdbDriver" + fun connect( url: String, - driver: String, + driver: String = DEFAULT_DRIVER, user: String = "", password: String = "" ): Database { + Class.forName(driver).getDeclaredConstructor().newInstance() + + val props = Properties().apply { + if (user.isNotEmpty()) setProperty("user", user) + if (password.isNotEmpty()) setProperty("password", password) + } + return Database.connect( - url = url, - driver = driver, - user = user, - password = password + getNewConnection = { + DriverManager.getConnection(url, props) + }, + databaseConfig = DatabaseConfig { + explicitDialect = YdbDialect() + + // Для YDB нельзя оставлять дефолт Exposed/JDBC, + // иначе transaction() попытается выставить READ_COMMITTED (2). + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + + // Пока безопаснее явно зафиксировать. + defaultReadOnly = false + + // Опционально: без nested transactions на первом этапе. + useNestedTransactions = false + } ) } } + +//package tech.ydb.exposed.dialect.basic +// +//import org.jetbrains.exposed.v1.core.DatabaseConfig +//import org.jetbrains.exposed.v1.jdbc.Database // +//object YdbDialectProvider { +// +// fun connect( +// url: String, +// driver: String, +// user: String = "", +// password: String = "" +// ): Database { +// return Database.connect( +// url = url, +// driver = driver, +// user = user, +// password = password, +// databaseConfig = DatabaseConfig { +// explicitDialect = YdbDialect() +// } +// ) +// } +//} + ////object YdbDialectProvider { //// //// fun connect( diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt index 98f383e3..939d0574 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt @@ -9,6 +9,8 @@ class YdbDataTypeProvider : DataTypeProvider() { override fun shortType(): String = "Int16" + override fun integerAutoincType(): String = "Int32" + override fun integerType(): String = "Int32" override fun longType(): String = "Int64" diff --git a/kotlin-exposed-dialect/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.core.vendors.DatabaseDialect b/kotlin-exposed-dialect/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.core.vendors.DatabaseDialect deleted file mode 100644 index a099292d..00000000 --- a/kotlin-exposed-dialect/src/main/resources/META-INF/services/org.jetbrains.exposed.v1.core.vendors.DatabaseDialect +++ /dev/null @@ -1,2 +0,0 @@ -tech.ydb.exposed.dialect.basic.YdbDialect - 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 index bdbb5400..7e90e58d 100644 --- 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 @@ -6,6 +6,7 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import tech.ydb.exposed.dialect.basic.YdbDialectProvider +import java.sql.Connection abstract class BaseYdbTest { @@ -20,20 +21,72 @@ abstract class BaseYdbTest { } @BeforeEach - fun setup() = transaction(db) { - // Можно очищать схемы, если нужно + fun setup() { + // Пока пусто } @AfterEach - fun teardown() = transaction(db) { - // Очистка таблиц после тестов + fun teardown() { + if (!::db.isInitialized) return + // Пока cleanup не делаем } -// protected fun tx(block: () -> Unit) = -// YdbTransactionManager.transactionWithRetry(db) { -// block() -// } + protected fun tx(block: JdbcTransaction.() -> Unit) = + transaction( + db = db, + transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, + readOnly = false + ) { + block() + } - // Утилита для упрощения вызова transaction - protected fun tx(block: JdbcTransaction.() -> Unit) = transaction(db) { block() } -} \ No newline at end of file + protected fun roTx(block: JdbcTransaction.() -> Unit) = + transaction( + db = db, + transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, + readOnly = true + ) { + block() + } +} + +//package tech.ydb.exposed.dialect.integration.base +// +//import org.jetbrains.exposed.v1.jdbc.Database +//import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +//import org.jetbrains.exposed.v1.jdbc.transactions.transaction +//import org.junit.jupiter.api.AfterEach +//import org.junit.jupiter.api.BeforeEach +//import tech.ydb.exposed.dialect.basic.YdbDialectProvider +// +//abstract class BaseYdbTest { +// +// protected lateinit var db: Database +// +// @BeforeEach +// fun setupDatabase() { +// +// db = YdbDialectProvider.connect( +// url = "jdbc:ydb:grpc://localhost:2136/local", +// driver = "tech.ydb.jdbc.YdbDriver" +// ) +// } +// +// @BeforeEach +// fun setup() = transaction(db) { +// // Можно очищать схемы, если нужно +// } +// +// @AfterEach +// fun teardown() = transaction(db) { +// // Очистка таблиц после тестов +// } +// +//// protected fun tx(block: () -> Unit) = +//// YdbTransactionManager.transactionWithRetry(db) { +//// block() +//// } +// +// // Утилита для упрощения вызова transaction +// protected fun tx(block: JdbcTransaction.() -> Unit) = transaction(db) { block() } +//} \ No newline at end of file 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/unit/transaction/YdbRetryingTransactionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt index f4dee88a..7f7e31cf 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt @@ -1,57 +1,38 @@ -import io.mockk.every -import io.mockk.mockkStatic -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.transaction.ydbTransaction -import java.sql.SQLException -import java.util.concurrent.atomic.AtomicInteger - -class YdbRetryingTransactionTest { - - @Test - fun `should retry aborted transaction`() { - val attempts = AtomicInteger(0) - - // мок статической функции - mockkStatic("tech.ydb.exposed.dialect.transaction.YdbTransactionKt") { - every { ydbTransaction(any(), any(), captureLambda()) } answers { - lambda().invoke() // вызываем блок - } - - val result = try { - ydbTransaction(maxRetries = 3) { - if (attempts.incrementAndGet() < 3) { - throw SQLException("ABORTED") - } - 42 - } - } catch (e: Exception) { - -1 - } - - assertEquals(42, result) - assertEquals(3, attempts.get()) - } - } - - @Test - fun `should not retry non retryable errors`() { - val attempts = AtomicInteger(0) - - mockkStatic("tech.ydb.exposed.dialect.transaction.YdbTransactionKt") { - every { ydbTransaction(any(), any(), captureLambda()) } answers { - lambda().invoke() - } - - val exception = runCatching { - ydbTransaction(maxRetries = 3) { - attempts.incrementAndGet() - throw SQLException("SYNTAX ERROR") - } - }.exceptionOrNull() - - assertEquals(1, attempts.get()) - assert(exception is SQLException) - } - } -} \ No newline at end of file +//import org.junit.jupiter.api.Assertions.* +//import org.junit.jupiter.api.Test +//import tech.ydb.exposed.dialect.transaction.ydbTransaction +//import java.sql.SQLException +//import java.util.concurrent.atomic.AtomicInteger +// +//class YdbRetryingTransactionTest { +// +// @Test +// fun `should retry aborted transaction`() { +// val attempts = AtomicInteger(0) +// +// val result = ydbTransaction(maxRetries = 3) { +// if (attempts.incrementAndGet() < 3) { +// throw SQLException("ABORTED") +// } +// 42 +// } +// +// assertEquals(42, result) +// assertEquals(3, attempts.get()) +// } +// +// @Test +// fun `should not retry non retryable errors`() { +// val attempts = AtomicInteger(0) +// +// val exception = runCatching { +// ydbTransaction(maxRetries = 3) { +// attempts.incrementAndGet() +// throw SQLException("SYNTAX ERROR") +// } +// }.exceptionOrNull() +// +// assertEquals(1, attempts.get()) +// assertTrue(exception is SQLException) +// } +//} \ No newline at end of file From 712711974b9c972ab4602fed2dfded0c1db7fc0c Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 13 Apr 2026 00:46:52 +0300 Subject: [PATCH 06/43] fix connection error 2 --- .../dialect/basic/YdbDialectMetadata.kt | 10 +++ .../dialect/basic/YdbDialectProvider.kt | 69 ++++++++++++++----- .../dialect/basic/YdbExposedBootstrap.kt | 28 ++++++++ 3 files changed, 89 insertions(+), 18 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt new file mode 100644 index 00000000..2d2b827f --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt @@ -0,0 +1,10 @@ +package tech.ydb.exposed.dialect.basic + +import org.jetbrains.exposed.v1.jdbc.vendors.DatabaseDialectMetadata + +class YdbDialectMetadata : DatabaseDialectMetadata() { + // Минимальная рабочая реализация: + // - existingIndices(...) + // - existingPrimaryKeys(...) + // - maybe tableExists / columns metadata, если это требуется вашим тестам и SchemaUtils +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt index 84fce019..caad2771 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt @@ -3,8 +3,6 @@ package tech.ydb.exposed.dialect.basic import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database import java.sql.Connection -import java.sql.DriverManager -import java.util.Properties object YdbDialectProvider { @@ -16,34 +14,69 @@ object YdbDialectProvider { user: String = "", password: String = "" ): Database { - Class.forName(driver).getDeclaredConstructor().newInstance() - - val props = Properties().apply { - if (user.isNotEmpty()) setProperty("user", user) - if (password.isNotEmpty()) setProperty("password", password) - } + YdbExposedBootstrap.init() return Database.connect( - getNewConnection = { - DriverManager.getConnection(url, props) - }, + url = url, + driver = driver, + user = user, + password = password, databaseConfig = DatabaseConfig { explicitDialect = YdbDialect() - - // Для YDB нельзя оставлять дефолт Exposed/JDBC, - // иначе transaction() попытается выставить READ_COMMITTED (2). defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE - - // Пока безопаснее явно зафиксировать. defaultReadOnly = false - - // Опционально: без nested transactions на первом этапе. useNestedTransactions = false } ) } } +//package tech.ydb.exposed.dialect.basic +// +//import org.jetbrains.exposed.v1.core.DatabaseConfig +//import org.jetbrains.exposed.v1.jdbc.Database +//import java.sql.Connection +//import java.sql.DriverManager +//import java.util.Properties +// +//object YdbDialectProvider { +// +// private const val DEFAULT_DRIVER = "tech.ydb.jdbc.YdbDriver" +// +// fun connect( +// url: String, +// driver: String = DEFAULT_DRIVER, +// user: String = "", +// password: String = "" +// ): Database { +// Class.forName(driver).getDeclaredConstructor().newInstance() +// +// val props = Properties().apply { +// if (user.isNotEmpty()) setProperty("user", user) +// if (password.isNotEmpty()) setProperty("password", password) +// } +// +// return Database.connect( +// getNewConnection = { +// DriverManager.getConnection(url, props) +// }, +// databaseConfig = DatabaseConfig { +// explicitDialect = YdbDialect() +// +// // Для YDB нельзя оставлять дефолт Exposed/JDBC, +// // иначе transaction() попытается выставить READ_COMMITTED (2). +// defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE +// +// // Пока безопаснее явно зафиксировать. +// defaultReadOnly = false +// +// // Опционально: без nested transactions на первом этапе. +// useNestedTransactions = false +// } +// ) +// } +//} + //package tech.ydb.exposed.dialect.basic // //import org.jetbrains.exposed.v1.core.DatabaseConfig diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt new file mode 100644 index 00000000..5ec89638 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt @@ -0,0 +1,28 @@ +package tech.ydb.exposed.dialect.basic + +import org.jetbrains.exposed.v1.jdbc.Database +import java.util.concurrent.atomic.AtomicBoolean + +object YdbExposedBootstrap { + private const val DIALECT_NAME = "ydb" + private const val URL_PREFIX = "jdbc:ydb:" + private const val DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" + + private val initialized = AtomicBoolean(false) + + fun init() { + if (!initialized.compareAndSet(false, true)) return + + Database.registerJdbcDriver( + prefix = URL_PREFIX, + driverClassName = DRIVER_CLASS, + dialect = DIALECT_NAME + ) + + // Оставляйте это только если у вас уже есть рабочий YdbDialectMetadata. + // Иначе пока можно не регистрировать metadata вовсе. + // Database.registerDialectMetadata(DIALECT_NAME) { + // YdbDialectMetadata() + // } + } +} \ No newline at end of file From 221eedb2f2372f96b2bc0b1fcfa831c67792ec32 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 13 Apr 2026 00:57:33 +0300 Subject: [PATCH 07/43] fix connection error 3 --- .../main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index 8938db76..b570f205 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -11,7 +11,7 @@ import org.jetbrains.exposed.v1.core.vendors.VendorDialect import tech.ydb.exposed.dialect.types.YdbDataTypeProvider import tech.ydb.exposed.dialect.functions.YdbFunctionProvider -class YdbDialect: VendorDialect("YDB", YdbDataTypeProvider(), YdbFunctionProvider()) { +class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvider()) { // override val name: String = "ydb" From 204f956a302badc66d49ee09d83a23983caca0d7 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 13 Apr 2026 03:28:55 +0300 Subject: [PATCH 08/43] passed all tests --- .../ydb/exposed/dialect/basic/YdbDialect.kt | 7 +- .../dialect/basic/YdbExposedBootstrap.kt | 9 +- .../ydb/exposed/dialect/basic/YdbTable.kt | 44 ++++++++ .../dialect/functions/YdbFunctionProvider.kt | 103 +++++++++++++++--- .../dialect/types/YdbDataTypeProvider.kt | 20 ++-- .../dialect/integration/crud/CrudIT.kt | 3 +- .../dialect/integration/ddl/IndexIT.kt | 3 +- .../integration/pagination/PaginationIT.kt | 3 +- .../integration/types/TemporalTypesIT.kt | 49 +++++++++ .../dialect/integration/types/TypesIT.kt | 98 ++++++++++++++--- .../dialect/integration/upsert/UpsertIT.kt | 10 +- 11 files changed, 291 insertions(+), 58 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TemporalTypesIT.kt diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index b570f205..782520f6 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -1,12 +1,7 @@ package tech.ydb.exposed.dialect.basic -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.ColumnDiff + import org.jetbrains.exposed.v1.core.Index -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider -import org.jetbrains.exposed.v1.core.vendors.DatabaseDialect -import org.jetbrains.exposed.v1.core.vendors.FunctionProvider import org.jetbrains.exposed.v1.core.vendors.VendorDialect import tech.ydb.exposed.dialect.types.YdbDataTypeProvider import tech.ydb.exposed.dialect.functions.YdbFunctionProvider diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt index 5ec89638..c6320a3d 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt @@ -19,10 +19,9 @@ object YdbExposedBootstrap { dialect = DIALECT_NAME ) - // Оставляйте это только если у вас уже есть рабочий YdbDialectMetadata. - // Иначе пока можно не регистрировать metadata вовсе. - // Database.registerDialectMetadata(DIALECT_NAME) { - // YdbDialectMetadata() - // } + Database.registerDialectMetadata(DIALECT_NAME) { + YdbDialectMetadata() + } + } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt new file mode 100644 index 00000000..97e5e7f1 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt @@ -0,0 +1,44 @@ +package tech.ydb.exposed.dialect.basic + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager + + +open class YdbTable(name: String = "") : Table(name) { + + override fun createStatement(): 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") + } + + // Пока сознательно пропускаем DEFAULT, UNIQUE, FK, CHECK + // и любые inline constraints. + } + } + + val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } + + val sql = buildString { + append("CREATE TABLE IF NOT EXISTS ") + append(tr.identity(this@YdbTable)) + append(" (") + append(columnsSql) + append(", PRIMARY KEY (") + append(pkSql) + append("))") + } + + return listOf(sql) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt index 514eaf1c..64f7e406 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt @@ -6,7 +6,8 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.Transaction import org.jetbrains.exposed.v1.core.vendors.FunctionProvider -class YdbFunctionProvider: FunctionProvider() { +class YdbFunctionProvider : FunctionProvider() { + override fun random(seed: Int?): String = "Random()" override fun upsert( @@ -18,22 +19,24 @@ class YdbFunctionProvider: FunctionProvider() { where: Op?, transaction: Transaction ): String { - val columns = data.map { it.first.name } + require(where == null) { + "YDB UPSERT does not support WHERE clause in this dialect implementation" + } + require(onUpdate.isEmpty()) { + "YDB UPSERT does not use ON UPDATE clause in this dialect implementation" + } - val values = data.map { (_, value) -> - when (value) { - null -> "NULL" - is String -> "'$value'" - else -> value.toString() - } + val columnList = data.joinToString(", ") { (column, _) -> + transaction.identity(column) } - val columnList = columns.joinToString(", ") - val valueList = values.joinToString(", ") + val valueList = data.joinToString(", ") { (column, value) -> + valueToSqlLiteral(column, value) + } return buildString { append("UPSERT INTO ") - append(table.tableName) + append(transaction.identity(table)) append(" (") append(columnList) append(") VALUES (") @@ -48,13 +51,83 @@ class YdbFunctionProvider: FunctionProvider() { alreadyOrdered: Boolean ): String { return buildString { - append(" LIMIT ") - append(size) - + if (size != null) { + append(" LIMIT ") + append(size) + } if (offset > 0) { append(" OFFSET ") append(offset) } } } -} \ No newline at end of file + + @Suppress("UNCHECKED_CAST") + private fun valueToSqlLiteral(column: Column<*>, value: Any?): String { + if (value == null) return "NULL" + + val columnType = column.columnType as org.jetbrains.exposed.v1.core.IColumnType + return columnType.valueToString(value) + } +} + +//package tech.ydb.exposed.dialect.functions +// +//import org.jetbrains.exposed.v1.core.Column +//import org.jetbrains.exposed.v1.core.Op +//import org.jetbrains.exposed.v1.core.Table +//import org.jetbrains.exposed.v1.core.Transaction +//import org.jetbrains.exposed.v1.core.vendors.FunctionProvider +// +//class YdbFunctionProvider: FunctionProvider() { +// override fun random(seed: Int?): String = "Random()" +// +// override fun upsert( +// table: Table, +// data: List, Any?>>, +// expression: String, +// onUpdate: List, Any?>>, +// keyColumns: List>, +// where: Op?, +// transaction: Transaction +// ): String { +// val columns = data.map { it.first.name } +// +// val values = data.map { (_, value) -> +// when (value) { +// null -> "NULL" +// is String -> "'$value'" +// else -> value.toString() +// } +// } +// +// val columnList = columns.joinToString(", ") +// val valueList = values.joinToString(", ") +// +// return buildString { +// append("UPSERT INTO ") +// append(table.tableName) +// append(" (") +// append(columnList) +// append(") VALUES (") +// append(valueList) +// append(")") +// } +// } +// +// override fun queryLimitAndOffset( +// size: Int?, +// offset: Long, +// alreadyOrdered: Boolean +// ): String { +// return buildString { +// append(" LIMIT ") +// append(size) +// +// if (offset > 0) { +// append(" OFFSET ") +// append(offset) +// } +// } +// } +//} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt index 939d0574..9e0de6df 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt @@ -4,34 +4,32 @@ import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider class YdbDataTypeProvider : DataTypeProvider() { override fun binaryType(): String = "String" + override fun binaryType(length: Int): String = "String" override fun hexToDb(hexString: String): String = "'$hexString'" override fun shortType(): String = "Int16" + override fun integerType(): String = "Int32" override fun integerAutoincType(): String = "Int32" - override fun integerType(): String = "Int32" override fun longType(): String = "Int64" - override fun booleanType(): String = "Bool" - override fun uuidType(): String = "Uuid" - - override fun textType(): String = "String" + override fun floatType(): String = "Float" + override fun doubleType(): String = "Double" override fun varcharType(colLength: Int): String = "Utf8" - override fun binaryType(length: Int): String = "String" + override fun textType(): String = "Utf8" //"String" - override fun dateType(): String = "Date" - - override fun floatType(): String = "Float" - - override fun doubleType(): String = "Double" + override fun uuidType(): String = "Uuid" + override fun dateType(): String = "Date" override fun dateTimeType(): String = "Datetime" + override fun timestampType(): String = "Timestamp" + override fun jsonType(): String = "Json" } \ No newline at end of file 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 index 960fa01f..f21ec2dd 100644 --- 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 @@ -9,11 +9,12 @@ 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.basic.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class CrudIT : BaseYdbTest() { - object Users : Table("users") { + object Users : YdbTable("users") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) 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 index 31e8dffd..5769be72 100644 --- 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 @@ -4,11 +4,12 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class IndexIT : BaseYdbTest() { - object Customers : Table("customers") { + object Customers : YdbTable("customers") { val id = integer("id") val name = varchar("name", 255) val email = varchar("email", 255) diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt index c476ec9a..a0de465b 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt @@ -6,11 +6,12 @@ import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class PaginationIT : BaseYdbTest() { - object Items : Table("items") { + object Items : YdbTable("items") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) 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..c34bd227 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TemporalTypesIT.kt @@ -0,0 +1,49 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.javatime.date +import org.jetbrains.exposed.v1.javatime.datetime +import org.jetbrains.exposed.v1.javatime.timestamp +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.Test +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime + +class TemporalTypesIT : BaseYdbTest() { + + object TemporalTypes : YdbTable("temporal_types") { + val id = integer("id") + val dateCol = date("date_col") + val dateTimeCol = datetime("datetime_col") + val timestampCol = timestamp("timestamp_col") + + override val primaryKey = PrimaryKey(id) + } + + @Test + fun `should round-trip temporal types`() = tx { + SchemaUtils.create(TemporalTypes) + + 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]) + } +} \ No newline at end of file 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 index 8ea2a443..5276f15d 100644 --- 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 @@ -1,28 +1,100 @@ package tech.ydb.exposed.dialect.integration.types -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.junit.jupiter.api.Assertions +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -class TypesIT { +class TypesIT : BaseYdbTest() { - object TestTable : Table("test_types") { + object BasicTypes : YdbTable("basic_types") { val id = integer("id") - val text = varchar("text", 255) + 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) } @Test - fun `should map types correctly`() { + fun `should round-trip basic scalar types`() = tx { + SchemaUtils.create(BasicTypes) - transaction { + 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 ddl = TestTable.ddl.joinToString(" ") + val row = BasicTypes.selectAll().single() - Assertions.assertTrue(ddl.contains("Int32")) - Assertions.assertTrue(ddl.contains("Utf8") || ddl.contains("String")) - } + 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]) } -} \ No newline at end of file + + @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 Utf8")) + assertTrue(ddl.contains("text_col Utf8")) + assertTrue(ddl.contains("PRIMARY KEY (id)")) + } +} + +//package tech.ydb.exposed.dialect.integration.types +// +//import org.jetbrains.exposed.v1.core.Table +//import org.jetbrains.exposed.v1.jdbc.transactions.transaction +//import org.junit.jupiter.api.Assertions +//import org.junit.jupiter.api.Test +//import tech.ydb.exposed.dialect.basic.YdbTable +// +//class TypesIT { +// +// object TestTable : YdbTable("test_types") { +// val id = integer("id") +// val text = varchar("text", 255) +// +// override val primaryKey = PrimaryKey(id) +// } +// +// @Test +// fun `should map types correctly`() { +// +// transaction { +// +// val ddl = TestTable.ddl.joinToString(" ") +// +// Assertions.assertTrue(ddl.contains("Int32")) +// Assertions.assertTrue(ddl.contains("Utf8") || ddl.contains("String")) +// } +// } +//} \ No newline at end of file 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 index efce45af..a3c93b47 100644 --- 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 @@ -1,16 +1,16 @@ package tech.ydb.exposed.dialect.integration.upsert -import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.selectAll import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbTable import tech.ydb.exposed.dialect.functions.YdbFunctionProvider import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UpsertIT : BaseYdbTest() { - object Products : Table("products") { + object Products : YdbTable("products") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) @@ -20,14 +20,13 @@ class UpsertIT : BaseYdbTest() { fun `should perform UPSERT`() = tx { SchemaUtils.create(Products) - // Используем YdbFunctionProvider.upsert val provider = YdbFunctionProvider() val data = listOf( Products.id to 1, Products.name to "Item1" ) - provider.upsert( + val sql = provider.upsert( table = Products, data = data, expression = "", @@ -37,7 +36,8 @@ class UpsertIT : BaseYdbTest() { transaction = this ) - // Проверяем результат + exec(sql) + val row = Products.selectAll().single() Assertions.assertEquals("Item1", row[Products.name]) } From 494000aeb9141dcd1a501a183d73bbaaa90343c7 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Fri, 17 Apr 2026 02:40:39 +0300 Subject: [PATCH 09/43] add ddl, types and test --- .../ydb/exposed/dialect/basic/YdbDialect.kt | 42 ++++- .../ydb/exposed/dialect/basic/YdbTable.kt | 52 +++++- .../tech/ydb/exposed/dialect/basic/YdbTtl.kt | 25 +++ .../dialect/types/YdbCustomColumnTypes.kt | 149 ++++++++++++++++++ .../dialect/types/YdbDataTypeProvider.kt | 3 - .../dialect/integration/base/BaseYdbTest.kt | 35 ++-- .../dialect/integration/crud/CrudIT.kt | 5 +- .../dialect/integration/ddl/IndexIT.kt | 5 +- .../integration/pagination/PaginationIT.kt | 20 ++- .../integration/ttl/NumericTtlTypesIT.kt | 35 ++++ .../dialect/integration/ttl/TtlAlterSqlIT.kt | 36 +++++ .../dialect/integration/ttl/TtlTypesIT.kt | 32 ++++ .../integration/types/BinaryTypesIT.kt | 41 +++++ .../integration/types/DecimalTypesIT.kt | 41 +++++ .../integration/types/IntervalTypesIT.kt | 43 +++++ .../dialect/integration/types/JsonTypesIT.kt | 42 +++++ .../integration/types/TemporalTypesIT.kt | 5 +- .../dialect/integration/types/TypesIT.kt | 6 +- .../integration/types/Uint64TypesIT.kt | 40 +++++ .../dialect/integration/types/UuidTypesIT.kt | 90 +++++++++++ .../dialect/integration/upsert/UpsertIT.kt | 3 + .../unit/basic/YdbDialectTtlSqlTest.kt | 79 ++++++++++ .../dialect/unit/basic/YdbTableTest.kt | 125 +++++++++++++++ .../dialect/unit/functions/FunctionTest.kt | 61 +++++-- .../dialect/unit/ttl/YdbTtlColumnModeTest.kt | 22 +++ .../dialect/unit/types/Uint64TypesTest.kt | 40 +++++ .../unit/types/YdbDataTypeProviderTest.kt | 5 +- .../unit/types/YdbDecimalColumnTypeTest.kt | 54 +++++++ .../unit/types/YdbIntervalColumnTypeTest.kt | 32 ++++ .../unit/types/YdbJsonStringColumnTypeTest.kt | 34 ++++ .../dialect/unit/types/YdbUintColumnTest.kt | 40 +++++ .../types/YdbUuidAsStringColumnTypeTest.kt | 35 ++++ .../unit/types/YdbUuidAsUtf8ColumnTypeTest.kt | 32 ++++ .../unit/types/YdbUuidColumnTypeTest.kt | 32 ++++ 34 files changed, 1294 insertions(+), 47 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTtl.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/NumericTtlTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/IntervalTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/JsonTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/Uint64TypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/UuidTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/Uint64TypesTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalColumnTypeTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbIntervalColumnTypeTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonStringColumnTypeTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUintColumnTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsStringColumnTypeTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsUtf8ColumnTypeTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidColumnTypeTest.kt diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index 782520f6..912719f4 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -1,10 +1,10 @@ package tech.ydb.exposed.dialect.basic - import org.jetbrains.exposed.v1.core.Index import org.jetbrains.exposed.v1.core.vendors.VendorDialect import tech.ydb.exposed.dialect.types.YdbDataTypeProvider import tech.ydb.exposed.dialect.functions.YdbFunctionProvider +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvider()) { @@ -44,6 +44,46 @@ class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvide return "ALTER TABLE $tableName DROP INDEX $indexName" } + fun setTtl(table: YdbTable): String { + val tr = TransactionManager.current() + val ttl = table.getTtlSettings() + ?: error("TTL is not configured for table ${table.tableName}") + + val sqlType = ttl.column.columnType.sqlType() + val supported = when (ttl.mode) { + YdbTtlColumnMode.DATE_TYPE -> + sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" + + YdbTtlColumnMode.SECONDS, + YdbTtlColumnMode.MILLISECONDS, + YdbTtlColumnMode.MICROSECONDS, + YdbTtlColumnMode.NANOSECONDS -> + sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" + } + + require(supported) { + "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" + } + + return buildString { + append("ALTER TABLE ") + append(tr.identity(table)) + append(" SET (TTL = Interval(\"") + append(ttl.intervalIso8601) + append("\") ON ") + append(tr.identity(ttl.column)) + ttl.mode.toSql()?.let { + append(" AS ") + append(it) + } + append(")") + } + } + + fun resetTtl(table: YdbTable): String { + val tr = TransactionManager.current() + return "ALTER TABLE ${tr.identity(table)} RESET (TTL)" + } // /** // * YDB не поддерживает ALTER COLUMN напрямую. // * Обычно требуется пересоздание таблицы. diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt index 97e5e7f1..af53eaa1 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt @@ -1,11 +1,24 @@ package tech.ydb.exposed.dialect.basic +import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager open class YdbTable(name: String = "") : Table(name) { + private var ttlSettings: YdbTtlSettings? = null + + protected fun ttl( + column: Column<*>, + intervalIso8601: String, + mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE + ) { + ttlSettings = YdbTtlSettings(column, intervalIso8601, mode) + } + + fun getTtlSettings(): YdbTtlSettings? = ttlSettings + override fun createStatement(): List { val tr = TransactionManager.current() @@ -21,14 +34,27 @@ open class YdbTable(name: String = "") : Table(name) { if (!column.columnType.nullable) { append(" NOT NULL") } - - // Пока сознательно пропускаем DEFAULT, UNIQUE, FK, CHECK - // и любые inline constraints. } } val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } + val ttlSql = ttlSettings?.let { ttl -> + validateTtlColumn(ttl) + + buildString { + append(" WITH (TTL = Interval(\"") + append(ttl.intervalIso8601) + append("\") ON ") + append(tr.identity(ttl.column)) + ttl.mode.toSql()?.let { + append(" AS ") + append(it) + } + append(")") + } + } ?: "" + val sql = buildString { append("CREATE TABLE IF NOT EXISTS ") append(tr.identity(this@YdbTable)) @@ -37,8 +63,28 @@ open class YdbTable(name: String = "") : Table(name) { append(", PRIMARY KEY (") append(pkSql) append("))") + append(ttlSql) } return listOf(sql) } + + private fun validateTtlColumn(ttl: YdbTtlSettings) { + val sqlType = ttl.column.columnType.sqlType() + + val supported = when (ttl.mode) { + YdbTtlColumnMode.DATE_TYPE -> + sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" + + YdbTtlColumnMode.SECONDS, + YdbTtlColumnMode.MILLISECONDS, + YdbTtlColumnMode.MICROSECONDS, + YdbTtlColumnMode.NANOSECONDS -> + sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" + } + + require(supported) { + "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" + } + } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTtl.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTtl.kt new file mode 100644 index 00000000..44dbf8fb --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTtl.kt @@ -0,0 +1,25 @@ +package tech.ydb.exposed.dialect.basic + +import org.jetbrains.exposed.v1.core.Column + +enum class YdbTtlColumnMode { + DATE_TYPE, + SECONDS, + MILLISECONDS, + MICROSECONDS, + NANOSECONDS; + + fun toSql(): String? = when (this) { + DATE_TYPE -> null + SECONDS -> "SECONDS" + MILLISECONDS -> "MILLISECONDS" + MICROSECONDS -> "MICROSECONDS" + NANOSECONDS -> "NANOSECONDS" + } +} + +data class YdbTtlSettings( + val column: Column<*>, + val intervalIso8601: String, + val mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE +) \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt new file mode 100644 index 00000000..19ca6834 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt @@ -0,0 +1,149 @@ +package tech.ydb.exposed.dialect.types + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ColumnType +import org.jetbrains.exposed.v1.core.Table +import java.math.BigDecimal +import java.time.Duration +import java.util.UUID + +class YdbDecimalColumnType( + private val precision: Int, + private val scale: Int +) : ColumnType() { + + 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 = + value.setScale(scale) + + override fun nonNullValueToString(value: BigDecimal): String = + value.setScale(scale).toPlainString() +} + +class YdbIntervalColumnType : ColumnType() { + override fun sqlType(): String = "Interval" + + 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}'" +} + +class YdbJsonStringColumnType : ColumnType() { + override fun sqlType(): String = "Json" + + override fun valueFromDB(value: Any): String = value.toString() + + override fun notNullValueToDB(value: String): Any = value + + override fun nonNullValueToString(value: String): String = + "'${value.replace("'", "''")}'" +} + +class YdbUuidAsUtf8ColumnType : ColumnType() { + override fun sqlType(): String = "Utf8" + + override fun valueFromDB(value: Any): UUID = when (value) { + is UUID -> value + is String -> UUID.fromString(value) + else -> error("Unexpected value for UUID(Utf8): $value of ${value::class}") + } + + override fun notNullValueToDB(value: UUID): Any = value.toString() + + override fun nonNullValueToString(value: UUID): String = + "'$value'" +} + +class YdbUuidAsStringColumnType : ColumnType() { + override fun sqlType(): String = "String" + + override fun valueFromDB(value: Any): UUID = when (value) { + is UUID -> value + is ByteArray -> UUID.fromString(value.toString(Charsets.UTF_8)) + is String -> UUID.fromString(value) + else -> error("Unexpected value for UUID(String): $value of ${value::class}") + } + + override fun notNullValueToDB(value: UUID): Any = value.toString().toByteArray(Charsets.UTF_8) + + override fun nonNullValueToString(value: UUID): String = + "'$value'" +} + +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.toString() + + override fun nonNullValueToString(value: UUID): String = + "'$value'" +} + +class YdbUint64ColumnType : ColumnType() { + override fun sqlType(): String = "Uint64" + + override fun valueFromDB(value: Any): Long = when (value) { + is Long -> value + is Int -> value.toLong() + is Number -> value.toLong() + is String -> value.toLong() + 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() + } +} + +fun Table.ydbDecimal(name: String, precision: Int, scale: Int): Column = + registerColumn(name, YdbDecimalColumnType(precision, scale)) + +fun Table.ydbInterval(name: String): Column = + registerColumn(name, YdbIntervalColumnType()) + +fun Table.ydbJson(name: String): Column = + registerColumn(name, YdbJsonStringColumnType()) + +fun Table.ydbUuidUtf8(name: String): Column = + registerColumn(name, YdbUuidAsUtf8ColumnType()) + +fun Table.ydbUuidBytes(name: String): Column = + registerColumn(name, YdbUuidAsStringColumnType()) + +fun Table.ydbUuid(name: String): Column = + registerColumn(name, YdbUuidColumnType()) + +fun Table.ydbUint64(name: String): Column = + registerColumn(name, YdbUint64ColumnType()) \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt index 9e0de6df..2b1ef10e 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt @@ -10,10 +10,8 @@ class YdbDataTypeProvider : DataTypeProvider() { override fun shortType(): String = "Int16" override fun integerType(): String = "Int32" - override fun integerAutoincType(): String = "Int32" - override fun longType(): String = "Int64" override fun booleanType(): String = "Bool" @@ -28,7 +26,6 @@ class YdbDataTypeProvider : DataTypeProvider() { override fun dateType(): String = "Date" override fun dateTimeType(): String = "Datetime" - override fun timestampType(): String = "Timestamp" override fun jsonType(): String = "Json" 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 index 7e90e58d..7863df8b 100644 --- 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 @@ -1,7 +1,10 @@ package tech.ydb.exposed.dialect.integration.base +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.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -12,6 +15,8 @@ abstract class BaseYdbTest { protected lateinit var db: Database + protected open val tables: List = emptyList() + @BeforeEach fun setupDatabase() { db = YdbDialectProvider.connect( @@ -21,30 +26,42 @@ abstract class BaseYdbTest { } @BeforeEach - fun setup() { - // Пока пусто + fun setupSchema() = transaction( + db = db, + transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, + readOnly = false + ) { + if (tables.isNotEmpty()) { + runCatching { SchemaUtils.drop(*tables.toTypedArray()) } + SchemaUtils.create(*tables.toTypedArray()) + } } @AfterEach - fun teardown() { + fun teardownSchema() { if (!::db.isInitialized) return - // Пока cleanup не делаем - } - protected fun tx(block: JdbcTransaction.() -> Unit) = transaction( db = db, transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, readOnly = false ) { - block() + if (tables.isNotEmpty()) { + runCatching { SchemaUtils.drop(*tables.toTypedArray()) } + SchemaUtils.create(*tables.toTypedArray()) + } } - protected fun roTx(block: JdbcTransaction.() -> Unit) = + runCatching { + TransactionManager.closeAndUnregister(db) + } + } + + protected fun tx(block: JdbcTransaction.() -> Unit) = transaction( db = db, transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, - readOnly = true + readOnly = false ) { block() } 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 index f21ec2dd..a283b260 100644 --- 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 @@ -20,11 +20,10 @@ class CrudIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } + override val tables: List
= listOf(Users) + @Test fun `should perform full CRUD`() = tx { - SchemaUtils.create(Users) - - // CREATE Users.insert { it[id] = 1; it[name] = "Alice" } // READ 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 index 5769be72..326b3737 100644 --- 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 @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.basic.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.integration.crud.CrudIT.Users class IndexIT : BaseYdbTest() { @@ -17,10 +18,10 @@ class IndexIT : BaseYdbTest() { val emailIndex = index(true, email) // UNIQUE INDEX } + override val tables: List
= listOf(Customers) + @Test fun `should create indexes`() = tx { - SchemaUtils.create(Customers) - // Проверяем, что индекс создан через DSL (фактически SQL выполняется через SchemaUtils) val indices = Customers.indices Assertions.assertTrue(indices.any { it.columns.contains(Customers.email) }) diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt index a0de465b..49a160f2 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt @@ -1,7 +1,6 @@ package tech.ydb.exposed.dialect.integration.pagination 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 @@ -17,13 +16,22 @@ class PaginationIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } + override val tables: List
= listOf(Items) + @Test fun `should support LIMIT`() = tx { - SchemaUtils.create(Items) - - Items.insert { it[id] = 1; it[name] = "A" } - Items.insert { it[id] = 2; it[name] = "B" } - Items.insert { it[id] = 3; it[name] = "C" } + Items.insert { + it[id] = 1 + it[name] = "one" + } + Items.insert { + it[id] = 2 + it[name] = "two" + } + Items.insert { + it[id] = 3 + it[name] = "three" + } val rows = Items.selectAll().limit(2).toList() Assertions.assertEquals(2, rows.size) 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..efbdaf56 --- /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.basic.YdbTable +import tech.ydb.exposed.dialect.basic.YdbTtlColumnMode +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.types.ydbUint64 + +class NumericTtlTypesIT : BaseYdbTest() { + + object NumericTtlItems : YdbTable("numeric_ttl_items") { + val id = integer("id") + val modifiedAtEpoch = ydbUint64("modified_at_epoch") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) + } + } + + 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/TtlAlterSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt new file mode 100644 index 00000000..34adb306 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt @@ -0,0 +1,36 @@ +package tech.ydb.exposed.dialect.integration.ttl + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestamp +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbDialect +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class TtlAlterSqlIT : BaseYdbTest() { + + object AlterTtlItems : YdbTable("alter_ttl_items") { + val id = integer("id") + val expireAt = timestamp("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, "PT24H") + } + } + + override val tables: List
= listOf(AlterTtlItems) + + @Test + fun `should generate alter ttl sql`() = tx { + val dialect = db.dialect as YdbDialect + + val setSql = dialect.setTtl(AlterTtlItems) + val resetSql = dialect.resetTtl(AlterTtlItems) + + assertTrue(setSql.contains("""SET (TTL = Interval("PT24H") ON expire_at)""")) + assertTrue(resetSql.contains("RESET (TTL)")) + } +} \ 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..814a1bdc --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt @@ -0,0 +1,32 @@ +package tech.ydb.exposed.dialect.integration.ttl + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestamp +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class TtlTypesIT : BaseYdbTest() { + + object ExpiringItems : YdbTable("expiring_items") { + val id = integer("id") + val expireAt = timestamp("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, "PT1H") + } + } + + 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/BinaryTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryTypesIT.kt new file mode 100644 index 00000000..eb86a6fc --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryTypesIT.kt @@ -0,0 +1,41 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class BinaryTypesIT : BaseYdbTest() { + + object BinaryTypes : YdbTable("binary_types") { + val id = integer("id") + val payload = binary("payload") + + override val primaryKey = PrimaryKey(id) + } + + 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 String")) + } +} \ 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..d3170521 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalTypesIT.kt @@ -0,0 +1,41 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.types.ydbDecimal +import java.math.BigDecimal + +class DecimalTypesIT : BaseYdbTest() { + + object DecimalTypes : YdbTable("decimal_types") { + val id = integer("id") + val amount = ydbDecimal("amount", 10, 2) + + override val primaryKey = PrimaryKey(id) + } + + 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/IntervalTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/IntervalTypesIT.kt new file mode 100644 index 00000000..ca2c6913 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/IntervalTypesIT.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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.types.ydbInterval +import java.time.Duration + +class IntervalTypesIT : BaseYdbTest() { + + object IntervalTypes : YdbTable("interval_types") { + val id = integer("id") + val durationCol = ydbInterval("duration_col") + + override val primaryKey = PrimaryKey(id) + } + + override val tables: List
= listOf(IntervalTypes) + + @Test + fun `should round-trip interval type`() = tx { + val duration = Duration.ofHours(26).plusMinutes(3).plusSeconds(4) + + IntervalTypes.insert { + it[id] = 1 + it[durationCol] = duration + } + + val row = IntervalTypes.selectAll().single() + assertEquals(duration, row[IntervalTypes.durationCol]) + } + + @Test + fun `should generate ddl for interval type`() = tx { + val ddl = IntervalTypes.ddl.joinToString(" ") + assertTrue(ddl.contains("duration_col Interval")) + } +} \ No newline at end of file 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..1df0c584 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/JsonTypesIT.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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.types.ydbJson + +class JsonTypesIT : BaseYdbTest() { + + object JsonTypes : YdbTable("json_types") { + val id = integer("id") + val payload = ydbJson("payload") + + override val primaryKey = PrimaryKey(id) + } + + override val tables: List
= listOf(JsonTypes) + + @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")) + } +} \ No newline at end of file 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 index c34bd227..06ce718e 100644 --- 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 @@ -1,5 +1,6 @@ package tech.ydb.exposed.dialect.integration.types +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.javatime.date import org.jetbrains.exposed.v1.javatime.datetime import org.jetbrains.exposed.v1.javatime.timestamp @@ -25,10 +26,10 @@ class TemporalTypesIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } + override val tables: List
= listOf(TemporalTypes) + @Test fun `should round-trip temporal types`() = tx { - SchemaUtils.create(TemporalTypes) - 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") 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 index 5276f15d..8fae5e61 100644 --- 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 @@ -1,6 +1,6 @@ package tech.ydb.exposed.dialect.integration.types -import org.jetbrains.exposed.v1.jdbc.SchemaUtils +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 @@ -25,10 +25,10 @@ class TypesIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } + override val tables: List
= listOf(BasicTypes) + @Test fun `should round-trip basic scalar types`() = tx { - SchemaUtils.create(BasicTypes) - BasicTypes.insert { it[id] = 1 it[shortCol] = 7 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..7ec7fb97 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/Uint64TypesIT.kt @@ -0,0 +1,40 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.types.ydbUint64 + +class Uint64TypesIT : BaseYdbTest() { + + object Uint64Types : YdbTable("uint64_types") { + val id = integer("id") + val valueCol = ydbUint64("value_col") + + override val primaryKey = PrimaryKey(id) + } + + 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..90fa005d --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/UuidTypesIT.kt @@ -0,0 +1,90 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.types.ydbUuid +import tech.ydb.exposed.dialect.types.ydbUuidBytes +import tech.ydb.exposed.dialect.types.ydbUuidUtf8 +import java.util.UUID + +class UuidTypesIT : BaseYdbTest() { + + object NativeUuidTypes : YdbTable("native_uuid_types") { + val id = integer("id") + val uuidCol = ydbUuid("uuid_col") + + override val primaryKey = PrimaryKey(id) + } + + object UuidUtf8Types : YdbTable("uuid_utf8_types") { + val id = integer("id") + val uuidCol = ydbUuidUtf8("uuid_col") + + override val primaryKey = PrimaryKey(id) + } + + object UuidBytesTypes : YdbTable("uuid_bytes_types") { + val id = integer("id") + val uuidCol = ydbUuidBytes("uuid_col") + + override val primaryKey = PrimaryKey(id) + } + + override val tables: List
= listOf(NativeUuidTypes, UuidUtf8Types, UuidBytesTypes) + + @Test + fun `should round-trip native uuid type`() = tx { + val uuid = UUID.randomUUID() + + NativeUuidTypes.insert { + it[id] = 1 + it[uuidCol] = uuid + } + + val row = NativeUuidTypes.selectAll().single() + assertEquals(uuid, row[NativeUuidTypes.uuidCol]) + } + + @Test + fun `should round-trip uuid utf8 type`() = tx { + val uuid = UUID.randomUUID() + + UuidUtf8Types.insert { + it[id] = 1 + it[uuidCol] = uuid + } + + val row = UuidUtf8Types.selectAll().single() + assertEquals(uuid, row[UuidUtf8Types.uuidCol]) + } + + @Test + fun `should round-trip uuid bytes type`() = tx { + val uuid = UUID.randomUUID() + + UuidBytesTypes.insert { + it[id] = 1 + it[uuidCol] = uuid + } + + val row = UuidBytesTypes.selectAll().single() + assertEquals(uuid, row[UuidBytesTypes.uuidCol]) + } + + @Test + fun `should generate ddl for uuid mappings`() = tx { + val nativeDdl = NativeUuidTypes.ddl.joinToString(" ") + val utf8Ddl = UuidUtf8Types.ddl.joinToString(" ") + val bytesDdl = UuidBytesTypes.ddl.joinToString(" ") + + assertTrue(nativeDdl.contains("uuid_col Uuid")) + assertTrue(utf8Ddl.contains("uuid_col Utf8")) + assertTrue(bytesDdl.contains("uuid_col String")) + } +} \ No newline at end of file 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 index a3c93b47..7b580e90 100644 --- 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 @@ -1,5 +1,6 @@ package tech.ydb.exposed.dialect.integration.upsert +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.selectAll import org.junit.jupiter.api.Assertions @@ -16,6 +17,8 @@ class UpsertIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } + override val tables: List
= listOf(Products) + @Test fun `should perform UPSERT`() = tx { SchemaUtils.create(Products) diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt new file mode 100644 index 00000000..3667de7b --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt @@ -0,0 +1,79 @@ +package tech.ydb.exposed.dialect.unit.basic + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbDialect +import tech.ydb.exposed.dialect.basic.YdbDialectProvider +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.basic.YdbTtlColumnMode +import tech.ydb.exposed.dialect.types.ydbUint64 + +class YdbDialectTtlSqlTest { + + companion object { + private lateinit var db: Database + + @JvmStatic + @BeforeAll + fun setupDb() { + db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver" + ) + } + } + + object AlterTtlTimestampTable : YdbTable("unit_alter_ttl_timestamp_table") { + val id = integer("id") + val expireAt = timestamp("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, "PT24H") + } + } + + object AlterTtlNumericTable : YdbTable("unit_alter_ttl_numeric_table") { + val id = integer("id") + val modifiedAtEpoch = ydbUint64("modified_at_epoch") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(modifiedAtEpoch, "PT2H", YdbTtlColumnMode.SECONDS) + } + } + + @Test + fun `should generate alter table set ttl for timestamp`() = transaction(db) { + val dialect = db.dialect as YdbDialect + val sql = dialect.setTtl(AlterTtlTimestampTable) + + assertTrue(sql.contains("ALTER TABLE")) + assertTrue(sql.contains("""SET (TTL = Interval("PT24H") ON expire_at)""")) + } + + @Test + fun `should generate alter table set ttl for numeric column`() = transaction(db) { + val dialect = db.dialect as YdbDialect + val sql = dialect.setTtl(AlterTtlNumericTable) + + assertTrue(sql.contains("ALTER TABLE")) + assertTrue(sql.contains("""SET (TTL = Interval("PT2H") ON modified_at_epoch AS SECONDS)""")) + } + + @Test + fun `should generate alter table reset ttl`() = transaction(db) { + val dialect = db.dialect as YdbDialect + val sql = dialect.resetTtl(AlterTtlTimestampTable) + + assertTrue(sql.contains("ALTER TABLE")) + assertTrue(sql.contains("RESET (TTL)")) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt new file mode 100644 index 00000000..a1f49106 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt @@ -0,0 +1,125 @@ +package tech.ydb.exposed.dialect.unit.basic + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.Database +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.BeforeAll +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbDialectProvider +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.basic.YdbTtlColumnMode +import tech.ydb.exposed.dialect.types.ydbUint64 + +class YdbTableTest { + + companion object { + private lateinit var db: Database + + @JvmStatic + @BeforeAll + fun setupDb() { + db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver" + ) + } + } + + object BasicTable : YdbTable("unit_basic_table") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + } + + object TtlTimestampTable : YdbTable("unit_ttl_timestamp_table") { + val id = integer("id") + val expireAt = timestamp("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, "PT1H") + } + } + + object TtlNumericTable : YdbTable("unit_ttl_numeric_table") { + val id = integer("id") + val modifiedAtEpoch = ydbUint64("modified_at_epoch") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) + } + } + + object NoPkTable : YdbTable("unit_no_pk_table") { + val id = integer("id") + val name = varchar("name", 255) + } + + object InvalidNumericTtlTable : YdbTable("unit_invalid_numeric_ttl_table") { + val id = integer("id") + val modifiedAtEpoch = integer("modified_at_epoch") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) + } + } + + @Test + fun `should generate create table with primary key clause`() { + transaction(db) { + 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` Utf8") || ddl.contains("name Utf8"), + ddl + ) + } + } + + @Test + fun `should generate ttl clause for timestamp column`() = transaction(db) { + val ddl = TtlTimestampTable.ddl.joinToString(" ") + + assertTrue(ddl.contains("""WITH (TTL = Interval("PT1H") ON expire_at)""")) + } + + @Test + fun `should generate ttl clause for numeric epoch column`() = transaction(db) { + val ddl = TtlNumericTable.ddl.joinToString(" ") + + assertTrue(ddl.contains("""WITH (TTL = Interval("PT1H") ON modified_at_epoch AS SECONDS)""")) + assertTrue(ddl.contains("modified_at_epoch Uint64")) + } + + @Test + fun `should fail when table has no primary key`() { + transaction(db) { + assertThrows(IllegalStateException::class.java) { + NoPkTable.ddl + } + } + } + + @Test + fun `should fail for unsupported numeric ttl column type`() { + transaction(db) { + assertThrows(IllegalArgumentException::class.java) { + InvalidNumericTtlTable.ddl + } + } + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt index 9c528a05..1a543b2f 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt @@ -1,13 +1,14 @@ package tech.ydb.exposed.dialect.unit.functions -import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.eq +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.functions.YdbFunctionProvider object Users : Table("users") { - val id = integer("id") val name = varchar("name", 255) @@ -65,21 +66,55 @@ class FunctionTests { } @Test - fun `should generate correct CREATE TABLE`() { - val ddlStatements = Users.ddl - val ddl = ddlStatements.joinToString(" ") + fun `should reject WHERE in UPSERT`() { + assertThrows(IllegalArgumentException::class.java) { + transaction { + provider.upsert( + table = Users, + data = listOf(Users.id to 1, Users.name to "Alice"), + expression = "", + onUpdate = emptyList(), + keyColumns = emptyList(), + where = Users.id eq 1, + transaction = this + ) + } + } + } + + @Test + fun `should reject ON UPDATE in UPSERT`() { + assertThrows(IllegalArgumentException::class.java) { + transaction { + 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(ddl.contains("CREATE TABLE users")) - assertTrue(ddl.contains("id")) - assertTrue(ddl.contains("name")) - assertTrue(ddl.contains("PRIMARY KEY")) + @Test + fun `should generate limit only`() { + val sql = provider.queryLimitAndOffset(size = 10, offset = 0, alreadyOrdered = false) + assertTrue(sql.contains("LIMIT 10")) } @Test - fun `should map integer to Int32`() { - val column = Users.id - val type = column.columnType.sqlType() + fun `should generate limit and offset`() { + val sql = provider.queryLimitAndOffset(size = 10, offset = 5, alreadyOrdered = false) + assertTrue(sql.contains("LIMIT 10")) + assertTrue(sql.contains("OFFSET 5")) + } - assertTrue(type.contains("Int32") || type.contains("INT")) + @Test + fun `should generate offset without limit`() { + val sql = provider.queryLimitAndOffset(size = null, offset = 5, alreadyOrdered = false) + assertTrue(sql.contains("OFFSET 5")) } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt new file mode 100644 index 00000000..3793c539 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt @@ -0,0 +1,22 @@ +package tech.ydb.exposed.dialect.unit.ttl + +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.basic.YdbTtlColumnMode + +class YdbTtlColumnModeTest { + + @Test + fun `should map date type mode to null suffix`() { + assertNull(YdbTtlColumnMode.DATE_TYPE.toSql()) + } + + @Test + fun `should map numeric ttl modes to sql suffix`() { + assertEquals("SECONDS", YdbTtlColumnMode.SECONDS.toSql()) + assertEquals("MILLISECONDS", YdbTtlColumnMode.MILLISECONDS.toSql()) + assertEquals("MICROSECONDS", YdbTtlColumnMode.MICROSECONDS.toSql()) + assertEquals("NANOSECONDS", YdbTtlColumnMode.NANOSECONDS.toSql()) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/Uint64TypesTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/Uint64TypesTest.kt new file mode 100644 index 00000000..07230dc1 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/Uint64TypesTest.kt @@ -0,0 +1,40 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.types.ydbUint64 + +class Uint64TypesTest : BaseYdbTest() { + + object Uint64Types : YdbTable("uint64_types") { + val id = integer("id") + val valueCol = ydbUint64("value_col") + + override val primaryKey = PrimaryKey(id) + } + + 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/unit/types/YdbDataTypeProviderTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDataTypeProviderTest.kt index 1b6b162c..6096c677 100644 --- 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 @@ -16,9 +16,9 @@ class YdbDataTypeProviderTest { } @Test - fun `should map string types`() { + fun `should map string and binary types`() { Assertions.assertEquals("Utf8", provider.varcharType(255)) - Assertions.assertEquals("String", provider.textType()) + Assertions.assertEquals("Utf8", provider.textType()) Assertions.assertEquals("String", provider.binaryType()) Assertions.assertEquals("String", provider.binaryType(100)) } @@ -48,6 +48,7 @@ class YdbDataTypeProviderTest { fun `should map date and time types`() { Assertions.assertEquals("Date", provider.dateType()) Assertions.assertEquals("Datetime", provider.dateTimeType()) + Assertions.assertEquals("Timestamp", provider.timestampType()) } @Test 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..2ac72b7b --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalColumnTypeTest.kt @@ -0,0 +1,54 @@ +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.types.YdbDecimalColumnType +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"))) + } +} \ No newline at end of file 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..61968880 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbIntervalColumnTypeTest.kt @@ -0,0 +1,32 @@ +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.types.YdbIntervalColumnType +import java.time.Duration + +class YdbIntervalColumnTypeTest { + + private val type = YdbIntervalColumnType() + + @Test + fun `should return interval sql type`() { + assertEquals("Interval", type.sqlType()) + } + + @Test + fun `should parse interval from db`() { + val duration = Duration.ofHours(1).plusMinutes(2).plusSeconds(3) + + assertEquals(duration, type.valueFromDB(duration)) + assertEquals(duration, type.valueFromDB("PT1H2M3S")) + } + + @Test + fun `should convert interval to db`() { + val duration = Duration.ofMinutes(90) + + assertEquals(duration, type.notNullValueToDB(duration)) + assertEquals("'PT1H30M'", type.nonNullValueToString(duration)) + } +} \ No newline at end of file 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..9d3c38a6 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonStringColumnTypeTest.kt @@ -0,0 +1,34 @@ +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.types.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)) + } +} \ No newline at end of file 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..a94df142 --- /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.types.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/YdbUuidAsStringColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsStringColumnTypeTest.kt new file mode 100644 index 00000000..63252802 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsStringColumnTypeTest.kt @@ -0,0 +1,35 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.types.YdbUuidAsStringColumnType +import java.util.UUID + +class YdbUuidAsStringColumnTypeTest { + + private val type = YdbUuidAsStringColumnType() + + @Test + fun `should return string uuid sql type`() { + assertEquals("String", type.sqlType()) + } + + @Test + fun `should parse uuid from db string and bytes`() { + val uuid = UUID.randomUUID() + + assertEquals(uuid, type.valueFromDB(uuid)) + assertEquals(uuid, type.valueFromDB(uuid.toString())) + assertEquals(uuid, type.valueFromDB(uuid.toString().toByteArray(Charsets.UTF_8))) + } + + @Test + fun `should convert uuid to db bytes`() { + val uuid = UUID.randomUUID() + + val dbValue = type.notNullValueToDB(uuid) + assertArrayEquals(uuid.toString().toByteArray(Charsets.UTF_8), dbValue as ByteArray) + assertEquals("'$uuid'", type.nonNullValueToString(uuid)) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsUtf8ColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsUtf8ColumnTypeTest.kt new file mode 100644 index 00000000..445a28e4 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsUtf8ColumnTypeTest.kt @@ -0,0 +1,32 @@ +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.types.YdbUuidAsUtf8ColumnType +import java.util.UUID + +class YdbUuidAsUtf8ColumnTypeTest { + + private val type = YdbUuidAsUtf8ColumnType() + + @Test + fun `should return utf8 uuid sql type`() { + assertEquals("Utf8", type.sqlType()) + } + + @Test + fun `should parse utf8 uuid from db`() { + val uuid = UUID.randomUUID() + + assertEquals(uuid, type.valueFromDB(uuid)) + assertEquals(uuid, type.valueFromDB(uuid.toString())) + } + + @Test + fun `should convert utf8 uuid to db`() { + val uuid = UUID.randomUUID() + + assertEquals(uuid.toString(), type.notNullValueToDB(uuid)) + assertEquals("'$uuid'", type.nonNullValueToString(uuid)) + } +} \ No newline at end of file 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..bde196c4 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidColumnTypeTest.kt @@ -0,0 +1,32 @@ +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.types.YdbUuidColumnType +import java.util.UUID + +class YdbUuidColumnTypeTest { + + private val type = YdbUuidColumnType() + + @Test + fun `should return native uuid sql type`() { + assertEquals("Uuid", type.sqlType()) + } + + @Test + fun `should parse native uuid from db`() { + val uuid = UUID.randomUUID() + + assertEquals(uuid, type.valueFromDB(uuid)) + assertEquals(uuid, type.valueFromDB(uuid.toString())) + } + + @Test + fun `should convert native uuid to db`() { + val uuid = UUID.randomUUID() + + assertEquals(uuid.toString(), type.notNullValueToDB(uuid)) + assertEquals("'$uuid'", type.nonNullValueToString(uuid)) + } +} \ No newline at end of file From 391d147ce6fad9b431c2ac05b1f1e4311547e978 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Fri, 17 Apr 2026 22:14:45 +0300 Subject: [PATCH 10/43] add retry and tests --- .../dialect/transaction/YdbRetryClassifier.kt | 85 ++++++++++++++ .../dialect/transaction/YdbRetryDecision.kt | 14 +++ .../transaction/YdbRetryingTransaction.kt | 111 +++++++++++------- .../dialect/integration/query/JoinIT.kt | 108 +++++++++++++++++ .../dialect/integration/query/SubqueryIT.kt | 67 +++++++++++ .../transaction/YdbRetryingTransactionsIT.kt | 37 ++++++ .../transaction/YdbRetryClassifierTest.kt | 95 +++++++++++++++ .../transaction/YdbRetryingTransactionTest.kt | 38 ------ 8 files changed, 476 insertions(+), 79 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryDecision.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/JoinIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/SubqueryIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/transaction/YdbRetryingTransactionsIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt new file mode 100644 index 00000000..b2e06e88 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt @@ -0,0 +1,85 @@ +package tech.ydb.exposed.dialect.transaction + +import java.sql.SQLException +import kotlin.math.min +import kotlin.random.Random + +object YdbRetryClassifier { + + fun classify(error: Throwable, idempotent: Boolean): YdbRetryDecision { + val text = buildString { + append(error.message.orEmpty()) + val causeMessage = error.cause?.message.orEmpty() + if (causeMessage.isNotBlank()) { + append(" | ") + append(causeMessage) + } + }.uppercase() + + return when { + "ABORTED" in text -> + YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) + + "UNAVAILABLE" in text -> + YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) + + "OVERLOADED" in text -> + YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.SLOW) + + "BAD_SESSION" in text -> + YdbRetryDecision(retryable = true, recreateSession = true, backoffKind = YdbBackoffKind.INSTANT) + + "SESSION_EXPIRED" in text -> + YdbRetryDecision(retryable = true, recreateSession = true, backoffKind = YdbBackoffKind.INSTANT) + + "SESSION_BUSY" in text -> + YdbRetryDecision(retryable = true, recreateSession = true, backoffKind = YdbBackoffKind.FAST) + + "TIMEOUT" in text -> + YdbRetryDecision(retryable = idempotent, backoffKind = if (idempotent) YdbBackoffKind.INSTANT else YdbBackoffKind.NONE) + + "UNDETERMINED" in text -> + YdbRetryDecision(retryable = idempotent, backoffKind = if (idempotent) YdbBackoffKind.FAST else YdbBackoffKind.NONE) + + "PRECONDITION_FAILED" in text -> + YdbRetryDecision(retryable = false) + + "ALREADY_EXISTS" in text -> + YdbRetryDecision(retryable = false) + + "NOT_FOUND" in text -> + YdbRetryDecision(retryable = false) + + "SCHEME_ERROR" in text -> + YdbRetryDecision(retryable = false) + + "GENERIC_ERROR" in text -> + YdbRetryDecision(retryable = false) + + else -> + YdbRetryDecision(retryable = false) + } + } + + fun backoffMillis(kind: YdbBackoffKind, attempt: Int): Long { + val n = attempt.coerceAtLeast(1) + return when (kind) { + YdbBackoffKind.NONE -> 0L + YdbBackoffKind.INSTANT -> 0L + YdbBackoffKind.FAST -> { + val base = 25L * (1L shl min(n - 1, 5)) + jitter(base, 15) + } + YdbBackoffKind.SLOW -> { + val base = 200L * (1L shl min(n - 1, 4)) + jitter(base, 50) + } + } + } + + private fun jitter(base: Long, spreadPercent: Int): Long { + if (base <= 0) return 0L + val spread = (base * spreadPercent) / 100 + return base + Random.nextLong(-spread, spread + 1) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryDecision.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryDecision.kt new file mode 100644 index 00000000..d47d682c --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryDecision.kt @@ -0,0 +1,14 @@ +package tech.ydb.exposed.dialect.transaction + +enum class YdbBackoffKind { + NONE, + INSTANT, + FAST, + SLOW +} + +data class YdbRetryDecision( + val retryable: Boolean, + val recreateSession: Boolean = false, + val backoffKind: YdbBackoffKind = YdbBackoffKind.NONE +) \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt index 0723a3a3..39ac42be 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt @@ -1,54 +1,83 @@ package tech.ydb.exposed.dialect.transaction -import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import java.sql.SQLException -import kotlin.math.min - -private val retryableErrors = listOf( - "ABORTED", - "UNAVAILABLE", - "OVERLOADED" -) - -fun ydbTransaction( - maxRetries: Int = 5, - initialBackoffMs: Long = 50, - maxBackoffMs: Long = 2000, - block: Transaction.() -> T -): T { - - var attempt = 0 - var backoff = initialBackoffMs - var lastException: Exception? = null - - while (attempt < maxRetries) { - - try { - return transaction { - block() - } +import java.sql.Connection - } catch (e: SQLException) { +enum class YdbTransactionMode { + READ_WRITE, + READ_ONLY +} - val message = e.message ?: "" +object YdbRetryingTransactions { - val retryable = retryableErrors.any { - message.contains(it, ignoreCase = true) - } + fun withRetry( + db: Database, + mode: YdbTransactionMode = YdbTransactionMode.READ_WRITE, + maxAttempts: Int = 5, + idempotent: Boolean = mode == YdbTransactionMode.READ_ONLY, + block: JdbcTransaction.() -> T + ): T { + require(maxAttempts >= 1) { "maxAttempts must be >= 1" } - if (!retryable) { - throw e - } + var lastError: Throwable? = null - lastException = e - } + repeat(maxAttempts) { index -> + val attempt = index + 1 + try { + return transaction( + db = db, + transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, + readOnly = mode == YdbTransactionMode.READ_ONLY + ) { + block() + } + } catch (t: Throwable) { + lastError = t + + val decision = YdbRetryClassifier.classify(t, idempotent) + if (!decision.retryable || attempt >= maxAttempts) { + throw t + } - Thread.sleep(backoff) + if (decision.recreateSession) { + runCatching { TransactionManager.closeAndUnregister(db) } + } - backoff = min(backoff * 2, maxBackoffMs) - attempt++ + val sleepMs = YdbRetryClassifier.backoffMillis(decision.backoffKind, attempt) + if (sleepMs > 0) { + Thread.sleep(sleepMs) + } + } + } + + throw lastError ?: IllegalStateException("Retry loop finished without result") } - throw lastException ?: IllegalStateException("Transaction failed after retries") + fun readOnly( + db: Database, + maxAttempts: Int = 5, + block: JdbcTransaction.() -> T + ): T = withRetry( + db = db, + mode = YdbTransactionMode.READ_ONLY, + maxAttempts = maxAttempts, + idempotent = true, + block = block + ) + + fun readWrite( + db: Database, + maxAttempts: Int = 5, + idempotent: Boolean = false, + block: JdbcTransaction.() -> T + ): T = withRetry( + db = db, + mode = YdbTransactionMode.READ_WRITE, + maxAttempts = maxAttempts, + idempotent = idempotent, + block = block + ) } \ No newline at end of file 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..f64db924 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/JoinIT.kt @@ -0,0 +1,108 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class JoinIT : BaseYdbTest() { + + object Authors : YdbTable("authors") { + val id = integer("id") + val name = varchar("name", 255) + override val primaryKey = PrimaryKey(id) + } + + object Books : YdbTable("books") { + val id = integer("id") + val title = varchar("title", 255) + val authorId = integer("author_id") + override val primaryKey = PrimaryKey(id) + } + + 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/SubqueryIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/SubqueryIT.kt new file mode 100644 index 00000000..ce7b18c3 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/SubqueryIT.kt @@ -0,0 +1,67 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class SubqueryIT : BaseYdbTest() { + + object Sales : YdbTable("sales") { + val id = integer("id") + val customer = varchar("customer", 255) + val amount = integer("amount") + override val primaryKey = PrimaryKey(id) + } + + 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/transaction/YdbRetryingTransactionsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/transaction/YdbRetryingTransactionsIT.kt new file mode 100644 index 00000000..cd1194cb --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/transaction/YdbRetryingTransactionsIT.kt @@ -0,0 +1,37 @@ +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.Test +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.transaction.YdbRetryingTransactions + +class YdbRetryingTransactionsIT : BaseYdbTest() { + + object RetryItems : YdbTable("retry_items") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + } + + override val tables: List
= listOf(RetryItems) + + @Test + fun `should execute read write helper`() { + YdbRetryingTransactions.readWrite(db) { + RetryItems.insert { + it[id] = 1 + it[name] = "alpha" + } + } + + YdbRetryingTransactions.readOnly(db) { + val row = RetryItems.selectAll().single() + assertEquals("alpha", row[RetryItems.name]) + } + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt new file mode 100644 index 00000000..fe0d05ae --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt @@ -0,0 +1,95 @@ +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.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.transaction.YdbBackoffKind +import tech.ydb.exposed.dialect.transaction.YdbRetryClassifier + +class YdbRetryClassifierTest { + + @Test + fun `should classify aborted as retryable fast`() { + val decision = YdbRetryClassifier.classify( + RuntimeException("Status{code = ABORTED(code=400040)}"), + idempotent = false + ) + + assertTrue(decision.retryable) + assertEquals(YdbBackoffKind.FAST, decision.backoffKind) + assertFalse(decision.recreateSession) + } + + @Test + fun `should classify overloaded as retryable slow`() { + val decision = YdbRetryClassifier.classify( + RuntimeException("Status{code = OVERLOADED(code=400060)}"), + idempotent = false + ) + + assertTrue(decision.retryable) + assertEquals(YdbBackoffKind.SLOW, decision.backoffKind) + } + + @Test + fun `should classify bad session as retryable with recreate`() { + val decision = YdbRetryClassifier.classify( + RuntimeException("Status{code = BAD_SESSION(code=400100)}"), + idempotent = false + ) + + assertTrue(decision.retryable) + assertTrue(decision.recreateSession) + assertEquals(YdbBackoffKind.INSTANT, decision.backoffKind) + } + + @Test + fun `should classify precondition failed as non retryable`() { + val decision = YdbRetryClassifier.classify( + RuntimeException("Status{code = PRECONDITION_FAILED(code=400120)}"), + idempotent = false + ) + + assertFalse(decision.retryable) + } + + @Test + fun `should classify timeout as retryable only for idempotent operations`() { + val retryable = YdbRetryClassifier.classify( + RuntimeException("Status{code = TIMEOUT(code=400090)}"), + idempotent = true + ) + val nonRetryable = YdbRetryClassifier.classify( + RuntimeException("Status{code = TIMEOUT(code=400090)}"), + idempotent = false + ) + + assertTrue(retryable.retryable) + assertFalse(nonRetryable.retryable) + } + + @Test + fun `should classify undetermined as retryable only for idempotent operations`() { + val retryable = YdbRetryClassifier.classify( + RuntimeException("Status{code = UNDETERMINED(code=400170)}"), + idempotent = true + ) + val nonRetryable = YdbRetryClassifier.classify( + RuntimeException("Status{code = UNDETERMINED(code=400170)}"), + idempotent = false + ) + + assertTrue(retryable.retryable) + assertFalse(nonRetryable.retryable) + } + + @Test + fun `should produce backoff for fast and slow kinds`() { + val fast = YdbRetryClassifier.backoffMillis(YdbBackoffKind.FAST, 1) + val slow = YdbRetryClassifier.backoffMillis(YdbBackoffKind.SLOW, 1) + + assertTrue(fast >= 0) + assertTrue(slow >= 0) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt deleted file mode 100644 index 7f7e31cf..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryingTransactionTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -//import org.junit.jupiter.api.Assertions.* -//import org.junit.jupiter.api.Test -//import tech.ydb.exposed.dialect.transaction.ydbTransaction -//import java.sql.SQLException -//import java.util.concurrent.atomic.AtomicInteger -// -//class YdbRetryingTransactionTest { -// -// @Test -// fun `should retry aborted transaction`() { -// val attempts = AtomicInteger(0) -// -// val result = ydbTransaction(maxRetries = 3) { -// if (attempts.incrementAndGet() < 3) { -// throw SQLException("ABORTED") -// } -// 42 -// } -// -// assertEquals(42, result) -// assertEquals(3, attempts.get()) -// } -// -// @Test -// fun `should not retry non retryable errors`() { -// val attempts = AtomicInteger(0) -// -// val exception = runCatching { -// ydbTransaction(maxRetries = 3) { -// attempts.incrementAndGet() -// throw SQLException("SYNTAX ERROR") -// } -// }.exceptionOrNull() -// -// assertEquals(1, attempts.get()) -// assertTrue(exception is SQLException) -// } -//} \ No newline at end of file From 549586a0e3a07cc0b9c477c07b214bcf6e2c3a01 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Sat, 18 Apr 2026 04:01:20 +0300 Subject: [PATCH 11/43] add many-to-many --- .../dialect/locking/YdbOptimisticLocking.kt | 31 ++++++ .../locking/OptimisticLockingIT.kt | 75 ++++++++++++++ .../dialect/integration/query/ManyToManyIT.kt | 97 +++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/ManyToManyIT.kt diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt new file mode 100644 index 00000000..8cff79ff --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt @@ -0,0 +1,31 @@ +package tech.ydb.exposed.dialect.locking + +import org.jetbrains.exposed.v1.core.Column +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.core.statements.UpdateBuilder +import org.jetbrains.exposed.v1.jdbc.update + +object YdbOptimisticLocking { + + fun updateWithVersion( + table: Table, + idColumn: Column, + idValue: ID, + versionColumn: Column, + expectedVersion: Int, + body: (UpdateBuilder<*>) -> Unit + ): Boolean { + val updatedRows = table.update( + where = { + (idColumn eq idValue) and (versionColumn eq expectedVersion) + } + ) { stmt -> + body(stmt) + stmt[versionColumn] = expectedVersion + 1 + } + + return updatedRows == 1 + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt new file mode 100644 index 00000000..d435d30c --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt @@ -0,0 +1,75 @@ +package tech.ydb.exposed.dialect.integration.locking + +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.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.locking.YdbOptimisticLocking + +class OptimisticLockingIT : BaseYdbTest() { + + object Documents : YdbTable("documents") { + val id = integer("id") + val title = varchar("title", 255) + val version = integer("version") + + override val primaryKey = PrimaryKey(id) + } + + override val tables: List
= listOf(Documents) + + @Test + fun `should update row when expected version matches`() = tx { + Documents.insert { + it[id] = 1 + it[title] = "draft" + it[version] = 0 + } + + val updated = YdbOptimisticLocking.updateWithVersion( + table = Documents, + idColumn = Documents.id, + idValue = 1, + versionColumn = Documents.version, + expectedVersion = 0 + ) { + it[Documents.title] = "published" + } + + assertTrue(updated) + + val row = Documents.selectAll().single() + assertEquals("published", row[Documents.title]) + assertEquals(1, row[Documents.version]) + } + + @Test + fun `should not update row when expected version does not match`() = tx { + Documents.insert { + it[id] = 1 + it[title] = "draft" + it[version] = 1 + } + + val updated = YdbOptimisticLocking.updateWithVersion( + table = Documents, + idColumn = Documents.id, + idValue = 1, + versionColumn = Documents.version, + expectedVersion = 0 + ) { + it[Documents.title] = "published" + } + + assertFalse(updated) + + val row = Documents.selectAll().single() + assertEquals("draft", row[Documents.title]) + assertEquals(1, row[Documents.version]) + } +} \ 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..cd295499 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/query/ManyToManyIT.kt @@ -0,0 +1,97 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class ManyToManyIT : BaseYdbTest() { + + object Students : YdbTable("students") { + val id = integer("id") + val name = varchar("name", 255) + override val primaryKey = PrimaryKey(id) + } + + object Courses : YdbTable("courses") { + val id = integer("id") + val title = varchar("title", 255) + override val primaryKey = PrimaryKey(id) + } + + object StudentCourses : YdbTable("student_courses") { + val id = integer("id") + val studentId = integer("student_id") + val courseId = integer("course_id") + override val primaryKey = PrimaryKey(id) + } + + 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 From 87d6cc858b816dccc598049b86b56cc3e8e4e143 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Sun, 19 Apr 2026 00:13:53 +0300 Subject: [PATCH 12/43] add dao, keyset, improve indexes --- kotlin-exposed-dialect/pom.xml | 6 + .../ydb/exposed/dialect/basic/YdbDialect.kt | 65 +++++- .../ydb/exposed/dialect/basic/YdbIdTable.kt | 217 ++++++++++++++++++ .../dialect/basic/YdbSecondaryIndex.kt | 89 +++++++ .../exposed/dialect/basic/YdbStringIdTable.kt | 15 ++ .../ydb/exposed/dialect/basic/YdbTable.kt | 34 +++ .../dialect/locking/YdbOptimisticLocking.kt | 17 +- .../dialect/pagination/KeysetPagination.kt | 57 ++++- .../integration/batch/BatchOperationsIT.kt | 83 +++++++ .../dialect/integration/dao/DaoSmokeIT.kt | 63 +++++ .../dialect/integration/ddl/IndexIT.kt | 68 +++++- .../locking/OptimisticLockingIT.kt | 4 +- .../pagination/KeysetPaginationIT.kt | 91 ++++++++ .../integration/pagination/PaginationIT.kt | 39 ---- .../dialect/unit/basic/YdbIndexSqlTest.kt | 97 ++++++++ .../basic/YdbUniqueIndexUnsupportedTest.kt | 64 ++++++ 16 files changed, 936 insertions(+), 73 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbStringIdTable.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/batch/BatchOperationsIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/DaoSmokeIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexUnsupportedTest.kt diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 76212fcb..4b65e3ae 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -104,6 +104,12 @@ ${kotlin.version} + + org.jetbrains.exposed + exposed-dao + ${exposed.version} + + diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index 912719f4..75e1a0df 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -1,10 +1,12 @@ package tech.ydb.exposed.dialect.basic import org.jetbrains.exposed.v1.core.Index +import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.vendors.VendorDialect import tech.ydb.exposed.dialect.types.YdbDataTypeProvider import tech.ydb.exposed.dialect.functions.YdbFunctionProvider import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import kotlin.reflect.full.memberProperties class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvider()) { @@ -19,19 +21,54 @@ class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvide // return "PRIMARY KEY ($columns)" // } - override fun createIndex(index: Index): String { - val columns = index.columns.joinToString(", ") { it.name } - val indexName = index.indexName - val tableName = index.table.tableName +// override fun createIndex(index: Index): String { +// val columns = index.columns.joinToString(", ") { it.name } +// val indexName = index.indexName +// val tableName = index.table.tableName +// +// return buildString { +// append("ALTER TABLE ") +// append(tableName) +// append(" ADD INDEX ") +// append(indexName) +// append(" GLOBAL ON (") +// append(columns) +// append(")") +// } +// } +override fun createIndex(index: Index): String { + val tr = runCatching { TransactionManager.current() }.getOrNull() + + val columns = index.columns.joinToString(", ") { column -> + tr?.identity(column) ?: column.name + } + val indexName = index.indexName + val tableName = tr?.identity(index.table) ?: index.table.tableName + val unique = index.extractUniqueFlag() + + require(!unique) { + "UNIQUE secondary indexes are not supported by the current YDB runtime used by this dialect" + } + + return buildString { + append("ALTER TABLE ") + append(tableName) + append(" ADD INDEX ") + append(indexName) + append(" GLOBAL ON (") + append(columns) + append(")") + } +} + + fun createSecondaryIndex(table: Table, spec: YdbSecondaryIndexSpec): String { + val tr = TransactionManager.current() return buildString { append("ALTER TABLE ") - append(tableName) - append(" ADD INDEX ") - append(indexName) - append(" GLOBAL ON (") - append(columns) - append(")") + append(tr.identity(table)) + append(" ADD ") + append(renderYdbSecondaryIndex(spec)) } } @@ -84,6 +121,14 @@ class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvide val tr = TransactionManager.current() return "ALTER TABLE ${tr.identity(table)} RESET (TTL)" } + + private fun Index.extractUniqueFlag(): Boolean { + val prop = this::class.memberProperties.firstOrNull { + it.name == "unique" || it.name == "isUnique" + } ?: return false + + return (runCatching { prop.getter.call(this) }.getOrNull() as? Boolean) == true + } // /** // * YDB не поддерживает ALTER COLUMN напрямую. // * Обычно требуется пересоздание таблицы. diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt new file mode 100644 index 00000000..59e62d19 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt @@ -0,0 +1,217 @@ +package tech.ydb.exposed.dialect.basic + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager + +abstract class YdbIdTable(name: String = "") : IdTable(name) { + + private var ttlSettings: YdbTtlSettings? = null + private val ydbSecondaryIndices = mutableListOf() + + protected fun ttl( + column: Column<*>, + intervalIso8601: String, + mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE + ) { + ttlSettings = YdbTtlSettings(column, intervalIso8601, mode) + } + + protected fun secondaryIndex( + name: String, + vararg columns: Column<*>, + unique: Boolean = false, + scope: YdbIndexScope = YdbIndexScope.GLOBAL, + syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + indexType: String? = null, + coverColumns: List> = emptyList(), + withParams: Map = emptyMap() + ) { + require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } + + ydbSecondaryIndices += YdbSecondaryIndexSpec( + name = name, + columns = columns.toList(), + unique = unique, + scope = scope, + syncMode = syncMode, + indexType = indexType, + coverColumns = coverColumns, + withParams = withParams + ) + } + + fun getTtlSettings(): YdbTtlSettings? = ttlSettings + + fun getYdbSecondaryIndices(): List = + ydbSecondaryIndices.toList() + + override fun createStatement(): 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") + } + } + } + + val indexesSql = ydbSecondaryIndices.joinToString(", ") { spec -> + renderYdbSecondaryIndex(spec) + } + + val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } + + val ttlSql = ttlSettings?.let { ttl -> + validateTtlColumn(ttl) + + buildString { + append(" WITH (TTL = Interval(\"") + append(ttl.intervalIso8601) + append("\") ON ") + append(tr.identity(ttl.column)) + ttl.mode.toSql()?.let { + append(" AS ") + append(it) + } + append(")") + } + } ?: "" + + val sql = buildString { + append("CREATE TABLE IF NOT EXISTS ") + append(tr.identity(this@YdbIdTable)) + append(" (") + append(columnsSql) + + if (indexesSql.isNotEmpty()) { + append(", ") + append(indexesSql) + } + + append(", PRIMARY KEY (") + append(pkSql) + append("))") + append(ttlSql) + } + + return listOf(sql) + } + + private fun validateTtlColumn(ttl: YdbTtlSettings) { + val sqlType = ttl.column.columnType.sqlType() + + val supported = when (ttl.mode) { + YdbTtlColumnMode.DATE_TYPE -> + sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" + + YdbTtlColumnMode.SECONDS, + YdbTtlColumnMode.MILLISECONDS, + YdbTtlColumnMode.MICROSECONDS, + YdbTtlColumnMode.NANOSECONDS -> + sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" + } + + require(supported) { + "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" + } + } +} + +//package tech.ydb.exposed.dialect.basic +// +//import org.jetbrains.exposed.v1.core.Column +//import org.jetbrains.exposed.v1.core.dao.id.IdTable +//import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +// +//abstract class YdbIdTable(name: String = "") : IdTable(name) { +// +// private var ttlSettings: YdbTtlSettings? = null +// +// protected fun ttl( +// column: Column<*>, +// intervalIso8601: String, +// mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE +// ) { +// ttlSettings = YdbTtlSettings(column, intervalIso8601, mode) +// } +// +// fun getTtlSettings(): YdbTtlSettings? = ttlSettings +// +// override fun createStatement(): 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") +// } +// } +// } +// +// val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } +// +// val ttlSql = ttlSettings?.let { ttl -> +// validateTtlColumn(ttl) +// +// buildString { +// append(" WITH (TTL = Interval(\"") +// append(ttl.intervalIso8601) +// append("\") ON ") +// append(tr.identity(ttl.column)) +// ttl.mode.toSql()?.let { +// append(" AS ") +// append(it) +// } +// append(")") +// } +// } ?: "" +// +// val sql = buildString { +// append("CREATE TABLE IF NOT EXISTS ") +// append(tr.identity(this@YdbIdTable)) +// append(" (") +// append(columnsSql) +// append(", PRIMARY KEY (") +// append(pkSql) +// append("))") +// append(ttlSql) +// } +// +// return listOf(sql) +// } +// +// private fun validateTtlColumn(ttl: YdbTtlSettings) { +// val sqlType = ttl.column.columnType.sqlType() +// +// val supported = when (ttl.mode) { +// YdbTtlColumnMode.DATE_TYPE -> +// sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" +// +// YdbTtlColumnMode.SECONDS, +// YdbTtlColumnMode.MILLISECONDS, +// YdbTtlColumnMode.MICROSECONDS, +// YdbTtlColumnMode.NANOSECONDS -> +// sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" +// } +// +// require(supported) { +// "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" +// } +// } +//} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt new file mode 100644 index 00000000..6a602728 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt @@ -0,0 +1,89 @@ +package tech.ydb.exposed.dialect.basic + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager + +enum class YdbIndexScope { + GLOBAL +} + +enum class YdbIndexSyncMode { + SYNC, + ASYNC +} + +data class YdbSecondaryIndexSpec( + val name: String, + val columns: List>, + val unique: Boolean = false, + val scope: YdbIndexScope = YdbIndexScope.GLOBAL, + val syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + val indexType: String? = null, + val coverColumns: List> = emptyList(), + val withParams: Map = emptyMap() +) + +internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { + val tr = TransactionManager.current() + + require(spec.columns.isNotEmpty()) { + "YDB secondary index must contain at least one column" + } + + require(spec.scope == YdbIndexScope.GLOBAL) { + "Only GLOBAL secondary indexes are supported by YDB row-oriented tables in this dialect" + } + + require(!spec.unique) { + "UNIQUE secondary indexes are not supported by the current YDB runtime used by this dialect" + } + + val columnsSql = spec.columns.joinToString(", ") { tr.identity(it) } + val coverSql = spec.coverColumns + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") { tr.identity(it) } + + val withSql = spec.withParams + .takeIf { it.isNotEmpty() } + ?.entries + ?.joinToString(", ") { (k, v) -> "$k = ${renderYdbIndexParamValue(v)}" } + + return buildString { + append("INDEX ") + append(spec.name) + append(" ") + append(spec.scope.name) + + if (spec.syncMode != YdbIndexSyncMode.SYNC) { + append(" ") + append(spec.syncMode.name) + } + + if (spec.indexType != null) { + append(" USING ") + append(spec.indexType) + } + + append(" ON (") + append(columnsSql) + append(")") + + if (coverSql != null) { + append(" COVER (") + append(coverSql) + append(")") + } + + if (withSql != null) { + append(" WITH (") + append(withSql) + append(")") + } + } +} + +private fun renderYdbIndexParamValue(value: Any): String = when (value) { + is Number -> value.toString() + is Boolean -> value.toString().uppercase() + else -> "\"${value.toString().replace("\"", "\\\"")}\"" +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbStringIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbStringIdTable.kt new file mode 100644 index 00000000..3b4da5bb --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbStringIdTable.kt @@ -0,0 +1,15 @@ +package tech.ydb.exposed.dialect.basic + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID + + +open class YdbStringIdTable( + name: String = "", + idLength: Int = 64 +) : YdbIdTable(name) { + + final override val id: Column> = varchar("id", idLength).entityId() + + final override val primaryKey = PrimaryKey(id) +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt index af53eaa1..e9f499a0 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt @@ -8,6 +8,7 @@ import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager open class YdbTable(name: String = "") : Table(name) { private var ttlSettings: YdbTtlSettings? = null + private val ydbSecondaryIndices = mutableListOf() protected fun ttl( column: Column<*>, @@ -17,7 +18,32 @@ open class YdbTable(name: String = "") : Table(name) { ttlSettings = YdbTtlSettings(column, intervalIso8601, mode) } + protected fun secondaryIndex( + name: String, + vararg columns: Column<*>, + unique: Boolean = false, + scope: YdbIndexScope = YdbIndexScope.GLOBAL, + syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + indexType: String? = null, + coverColumns: List> = emptyList(), + withParams: Map = emptyMap() + ) { + require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } + + ydbSecondaryIndices += YdbSecondaryIndexSpec( + name = name, + columns = columns.toList(), + unique = unique, + scope = scope, + syncMode = syncMode, + indexType = indexType, + coverColumns = coverColumns, + withParams = withParams + ) + } + fun getTtlSettings(): YdbTtlSettings? = ttlSettings + fun getYdbSecondaryIndices(): List = ydbSecondaryIndices.toList() override fun createStatement(): List { val tr = TransactionManager.current() @@ -37,6 +63,8 @@ open class YdbTable(name: String = "") : Table(name) { } } + val indexesSql = ydbSecondaryIndices.joinToString(", ") { renderYdbSecondaryIndex(it) } + val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } val ttlSql = ttlSettings?.let { ttl -> @@ -60,6 +88,12 @@ open class YdbTable(name: String = "") : Table(name) { append(tr.identity(this@YdbTable)) append(" (") append(columnsSql) + + if (indexesSql.isNotEmpty()) { + append(", ") + append(indexesSql) + } + append(", PRIMARY KEY (") append(pkSql) append("))") diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt index 8cff79ff..57b59d55 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt @@ -2,9 +2,9 @@ package tech.ydb.exposed.dialect.locking import org.jetbrains.exposed.v1.core.Column 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.core.statements.UpdateBuilder +import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.update object YdbOptimisticLocking { @@ -17,10 +17,19 @@ object YdbOptimisticLocking { expectedVersion: Int, body: (UpdateBuilder<*>) -> Unit ): Boolean { + val currentRow = table + .select(idColumn, versionColumn) + .where { idColumn eq idValue } + .singleOrNull() + ?: return false + + val currentVersion = currentRow[versionColumn] + if (currentVersion != expectedVersion) { + return false + } + val updatedRows = table.update( - where = { - (idColumn eq idValue) and (versionColumn eq expectedVersion) - } + where = { idColumn eq idValue } ) { stmt -> body(stmt) stmt[versionColumn] = expectedVersion + 1 diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt index 6902b947..06292f4f 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt @@ -1,26 +1,63 @@ package tech.ydb.exposed.dialect.pagination -import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.core.less import org.jetbrains.exposed.v1.jdbc.Query import org.jetbrains.exposed.v1.jdbc.andWhere /** - * Helper для keyset pagination в YDB. + * Keyset pagination helper for YDB-oriented Exposed queries. * - * Генерирует: - * SELECT ... WHERE key > lastKey ORDER BY key LIMIT N + * ASC: + * SELECT ... WHERE key > :lastValue ORDER BY key ASC LIMIT :limit + * + * DESC: + * SELECT ... WHERE key < :lastValue ORDER BY key DESC LIMIT :limit + * + * Intended for sortable columns, ideally primary key columns. */ fun > Query.keysetPage( column: Column, lastValue: T?, - limit: Int + limit: Int, + direction: SortOrder = SortOrder.ASC ): Query { + require(limit > 0) { "limit must be > 0" } + require(direction == SortOrder.ASC || direction == SortOrder.DESC) { + "keysetPage supports only ASC and DESC sort orders" + } if (lastValue != null) { - this.andWhere { column greater lastValue } + when (direction) { + SortOrder.ASC -> andWhere { column greater lastValue } + SortOrder.DESC -> andWhere { column less lastValue } + else -> error("Unsupported sort order: $direction") + } } - return this - .orderBy(column to SortOrder.ASC) - .limit(limit) -} \ No newline at end of file + return orderBy(column to direction).limit(limit) +} + +fun > Query.keysetPageAsc( + column: Column, + lastValue: T?, + limit: Int +): Query = keysetPage( + column = column, + lastValue = lastValue, + limit = limit, + direction = SortOrder.ASC +) + +fun > Query.keysetPageDesc( + column: Column, + lastValue: T?, + limit: Int +): Query = keysetPage( + column = column, + lastValue = lastValue, + limit = limit, + direction = SortOrder.DESC +) \ No newline at end of file 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..ce62f6fb --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/batch/BatchOperationsIT.kt @@ -0,0 +1,83 @@ +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.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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class BatchOperationsIT : BaseYdbTest() { + + object BatchItems : YdbTable("batch_items") { + val id = integer("id") + val name = varchar("name", 255) + val quantity = integer("quantity") + + override val primaryKey = PrimaryKey(id) + } + + 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")) + } +} \ 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..15ba1803 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/DaoSmokeIT.kt @@ -0,0 +1,63 @@ +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.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.basic.YdbStringIdTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class DaoSmokeIT : BaseYdbTest() { + + object Articles : YdbStringIdTable("dao_articles", idLength = 64) { + val title = varchar("title", 255) + val body = text("body") + } + + 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`() { + 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()) + } + } +} \ No newline at end of file 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 index 326b3737..5d77fb65 100644 --- 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 @@ -1,12 +1,14 @@ package tech.ydb.exposed.dialect.integration.ddl import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.jdbc.SchemaUtils -import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbDialect +import tech.ydb.exposed.dialect.basic.YdbIndexScope +import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode +import tech.ydb.exposed.dialect.basic.YdbSecondaryIndexSpec import tech.ydb.exposed.dialect.basic.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.integration.crud.CrudIT.Users class IndexIT : BaseYdbTest() { @@ -14,16 +16,66 @@ class IndexIT : BaseYdbTest() { val id = integer("id") val name = varchar("name", 255) val email = varchar("email", 255) + override val primaryKey = PrimaryKey(id) - val emailIndex = index(true, email) // UNIQUE INDEX + + init { + index(false, email) + + secondaryIndex( + name = "email_name_cover_idx", + email, + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.ASYNC, + coverColumns = listOf(name) + ) + } + + val emailIndexDefinition + get() = indices.single { it.columns == listOf(email) } } override val tables: List
= listOf(Customers) @Test - fun `should create indexes`() = tx { - // Проверяем, что индекс создан через DSL (фактически SQL выполняется через SchemaUtils) - val indices = Customers.indices - Assertions.assertTrue(indices.any { it.columns.contains(Customers.email) }) + 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 generate inline ydb secondary index in create table ddl`() = tx { + val ddl = Customers.ddl.joinToString(" ") + + assertTrue(ddl.contains("INDEX email_name_cover_idx"), ddl) + assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) + assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) + assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) + assertTrue(ddl.contains("PRIMARY KEY"), ddl) + } + + @Test + fun `should generate alter table sql for ydb specific secondary index`() = tx { + val dialect = db.dialect as YdbDialect + + val spec = YdbSecondaryIndexSpec( + name = "email_lookup_idx", + columns = listOf(Customers.email), + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) + + val sql = dialect.createSecondaryIndex(Customers, spec) + + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("ADD INDEX email_lookup_idx GLOBAL"), sql) + assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt index d435d30c..7a2fddce 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt @@ -53,7 +53,7 @@ class OptimisticLockingIT : BaseYdbTest() { Documents.insert { it[id] = 1 it[title] = "draft" - it[version] = 1 + it[version] = 5 } val updated = YdbOptimisticLocking.updateWithVersion( @@ -70,6 +70,6 @@ class OptimisticLockingIT : BaseYdbTest() { val row = Documents.selectAll().single() assertEquals("draft", row[Documents.title]) - assertEquals(1, row[Documents.version]) + assertEquals(5, row[Documents.version]) } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt new file mode 100644 index 00000000..dd061d33 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt @@ -0,0 +1,91 @@ +package tech.ydb.exposed.dialect.integration.pagination + +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.pagination.keysetPageAsc +import tech.ydb.exposed.dialect.pagination.keysetPageDesc + +class KeysetPaginationIT : BaseYdbTest() { + + object FeedItems : YdbTable("feed_items") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + } + + override val tables: List
= listOf(FeedItems) + + @Test + fun `should support forward keyset pagination`() = tx { + (1..5).forEach { i -> + FeedItems.insert { + it[id] = i + it[name] = "item-$i" + } + } + + val page1 = FeedItems + .selectAll() + .keysetPageAsc(FeedItems.id, lastValue = null, limit = 2) + .toList() + + assertEquals(2, page1.size) + assertEquals(1, page1[0][FeedItems.id]) + assertEquals(2, page1[1][FeedItems.id]) + + val lastSeenId = page1.last()[FeedItems.id] + + val page2 = FeedItems + .selectAll() + .keysetPageAsc(FeedItems.id, lastValue = lastSeenId, limit = 2) + .toList() + + assertEquals(2, page2.size) + assertEquals(3, page2[0][FeedItems.id]) + assertEquals(4, page2[1][FeedItems.id]) + + val page3 = FeedItems + .selectAll() + .keysetPageAsc(FeedItems.id, lastValue = page2.last()[FeedItems.id], limit = 2) + .toList() + + assertEquals(1, page3.size) + assertEquals(5, page3[0][FeedItems.id]) + } + + @Test + fun `should support backward keyset pagination`() = tx { + (1..5).forEach { i -> + FeedItems.insert { + it[id] = i + it[name] = "item-$i" + } + } + + val page1 = FeedItems + .selectAll() + .keysetPageDesc(FeedItems.id, lastValue = null, limit = 2) + .toList() + + assertEquals(2, page1.size) + assertEquals(5, page1[0][FeedItems.id]) + assertEquals(4, page1[1][FeedItems.id]) + + val lastSeenId = page1.last()[FeedItems.id] + + val page2 = FeedItems + .selectAll() + .keysetPageDesc(FeedItems.id, lastValue = lastSeenId, limit = 2) + .toList() + + assertEquals(2, page2.size) + assertEquals(3, page2[0][FeedItems.id]) + assertEquals(2, page2[1][FeedItems.id]) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt deleted file mode 100644 index 49a160f2..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/PaginationIT.kt +++ /dev/null @@ -1,39 +0,0 @@ -package tech.ydb.exposed.dialect.integration.pagination - -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 -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbTable -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest - -class PaginationIT : BaseYdbTest() { - - object Items : YdbTable("items") { - val id = integer("id") - val name = varchar("name", 255) - override val primaryKey = PrimaryKey(id) - } - - override val tables: List
= listOf(Items) - - @Test - fun `should support LIMIT`() = tx { - Items.insert { - it[id] = 1 - it[name] = "one" - } - Items.insert { - it[id] = 2 - it[name] = "two" - } - Items.insert { - it[id] = 3 - it[name] = "three" - } - - val rows = Items.selectAll().limit(2).toList() - Assertions.assertEquals(2, rows.size) - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt new file mode 100644 index 00000000..3c2fe52e --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt @@ -0,0 +1,97 @@ +package tech.ydb.exposed.dialect.unit.basic + +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.* + +class YdbIndexSqlTest { + + companion object { + private lateinit var db: Database + + @JvmStatic + @BeforeAll + fun setupDb() { + db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver" + ) + } + } + + object IndexedTable : YdbTable("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) + + secondaryIndex( + name = "email_cover_idx", + email, + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.ASYNC, + coverColumns = listOf(name), + withParams = mapOf("foo" to "bar") + ) + } + + val emailIndexDefinition + get() = indices.single { it.columns == listOf(email) } + } + + @Test + fun `should render standard exposed index as ydb alter table sql`() { + 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 `should render ydb specific inline secondary index`() { + transaction(db) { + val ddl = IndexedTable.ddl.joinToString(" ") + + assertTrue(ddl.contains("INDEX email_cover_idx"), ddl) + assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) + assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) + assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) + assertTrue(ddl.contains("WITH (foo = \"bar\")"), ddl) + } + } + + @Test + fun `should render ydb specific alter table secondary index sql`() { + transaction(db) { + val dialect = db.dialect as YdbDialect + + val sql = dialect.createSecondaryIndex( + table = IndexedTable, + spec = YdbSecondaryIndexSpec( + name = "email_lookup_idx", + columns = listOf(IndexedTable.email), + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) + ) + + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("ADD INDEX email_lookup_idx GLOBAL"), sql) + assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) + } + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexUnsupportedTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexUnsupportedTest.kt new file mode 100644 index 00000000..3aa71bbf --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexUnsupportedTest.kt @@ -0,0 +1,64 @@ +package tech.ydb.exposed.dialect.unit.basic + +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.* + +class YdbUniqueIndexUnsupportedTest { + + companion object { + private lateinit var db: Database + + @JvmStatic + @BeforeAll + fun setupDb() { + db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver" + ) + } + } + + object T : YdbTable("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 `should reject unique standard index for current ydb runtime`() { + transaction(db) { + val dialect = db.dialect as YdbDialect + + assertThrows(IllegalArgumentException::class.java) { + dialect.createIndex(T.emailIndexDefinition) + } + } + } + + @Test + fun `should reject unique ydb secondary index for current runtime`() { + transaction(db) { + assertThrows(IllegalArgumentException::class.java) { + renderYdbSecondaryIndex( + YdbSecondaryIndexSpec( + name = "email_unique_idx", + columns = listOf(T.email), + unique = true + ) + ) + } + } + } +} \ No newline at end of file From f74466ebaebe6ac7e310a59152e23d187fb2af2c Mon Sep 17 00:00:00 2001 From: s-markelova Date: Sun, 19 Apr 2026 14:35:37 +0300 Subject: [PATCH 13/43] add demo, tests, update description --- .../.github/workflows/ci.yml | 55 +++++ kotlin-exposed-dialect/README.md | 207 +++++++++++++++++- kotlin-exposed-dialect/pom.xml | 6 + .../dialect/basic/YdbDialectMetadata.kt | 35 ++- .../tech/ydb/exposed/dialect/demo/DemoApp.kt | 132 +++++++++++ .../ydb/exposed/dialect/demo/DemoTables.kt | 48 ++++ .../dialect/types/YdbDecimalExpressions.kt | 23 ++ .../scenario/UniversityScenarioIT.kt | 169 ++++++++++++++ .../integration/types/DecimalUpdateIT.kt | 69 ++++++ 9 files changed, 737 insertions(+), 7 deletions(-) create mode 100644 kotlin-exposed-dialect/.github/workflows/ci.yml create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/scenario/UniversityScenarioIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalUpdateIT.kt diff --git a/kotlin-exposed-dialect/.github/workflows/ci.yml b/kotlin-exposed-dialect/.github/workflows/ci.yml new file mode 100644 index 00000000..d145f5ca --- /dev/null +++ b/kotlin-exposed-dialect/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + + - name: Show Docker and Compose versions + run: | + docker version + docker compose version + + - name: Start local YDB with docker compose + run: docker compose up -d + + - name: Wait for YDB gRPC port + shell: bash + run: | + for i in {1..60}; do + if (echo > /dev/tcp/127.0.0.1/2136) >/dev/null 2>&1; then + echo "YDB is reachable on port 2136" + exit 0 + fi + echo "Waiting for YDB..." + sleep 2 + done + echo "YDB did not become ready in time" + docker compose logs || true + exit 1 + + - name: Run Maven build + run: mvn --batch-mode --update-snapshots clean install + + - name: Dump YDB logs on failure + if: failure() + run: docker compose logs + + - name: Stop YDB + if: always() + run: docker compose down -v \ No newline at end of file diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 45b80236..e4f8ac11 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -1,7 +1,12 @@ +# Kotlin Exposed YDB Dialect + +Проект реализует SQL-диалект YDB для фреймворка Kotlin Exposed и позволяет использовать Exposed DSL/DAO для работы с YDB через JDBC. + ## Поддержка YDB в Kotlin Exposed (YDB SQL Dialect + JDBC интеграция) ### Описание предметной области -Exposed — ORM/SQL‑DSL для Kotlin. Требует диалект БД для генерации SQL и DDL через JDBC. YDB — распределённая транзакционная СУБД (аналог «SQL поверх key‑value») с собственным SQL/YQL, UPSERT, глобальными вторичными индексами и транзакциями с ретраями. Задача — добавить полноценный диалект YDB в Exposed, чтобы писать к YDB обычный Kotlin‑DSL/DAO без ручного SQL. +Exposed — ORM/SQL‑DSL для Kotlin. Требует диалект БД для генерации SQL и DDL через JDBC. YDB — распределённая транзакционная СУБД (аналог «SQL поверх key‑value») с собственным SQL/YQL, UPSERT, глобальными вторичными индексами и транзакциями с ретраями. +Цель проекта — добавить полноценный диалект YDB в Exposed, чтобы писать к YDB обычный Kotlin‑DSL/DAO без ручного SQL. ### Что следует сделать: @@ -10,3 +15,203 @@ Exposed — ORM/SQL‑DSL для Kotlin. Требует диалект БД дл Транзакции и ретраи: обработка abort/timeout с backoff, read-only/read-write, батчи, пагинация (LIMIT и keyset-хелпер). Совместимость DSL/DAO: JOIN, подзапросы, many-to-many, optimistic locking (version column); при необходимости эмуляция UNIQUE/FOREIGN KEY, генерация идентификаторов без AUTO_INCREMENT (UUID/ULID). Тесты и примеры: юнит и интеграционные (YDB в Docker), CI, демо-приложение (CRUD/индексы/пагинация), документация с ограничениями и рецептами. + +## Статус + +Проект находится в рабочем состоянии и покрыт unit- и integration-тестами. +Реализован базовый функциональный слой диалекта, типы YDB, DDL/DDL-расширения, CRUD-сценарии, запросы на нескольких таблицах, batch-операции, keyset pagination, optimistic locking, demo-приложение и CI. + +## Поддерживаемые возможности + +### SQL / DDL + +Поддерживаются: + +- генерация идентификаторов и SQL в стиле YDB; +- `LIMIT`; +- `UPSERT`; +- `CREATE TABLE` с обязательным `PRIMARY KEY`; +- secondary indexes для YDB; +- TTL для таблиц YDB; +- `ALTER TABLE ... ADD INDEX` в рамках поддерживаемой модели secondary index. + +### Маппинг типов YDB ↔ Exposed + +Поддерживаются: + +- целочисленные типы: `Int16`, `Int32`, `Int64`; +- беззнаковые типы, используемые в YDB-расширениях диалекта, включая `Uint64`; +- строковые типы: `Utf8`; +- бинарные данные: `String`/bytes-представление; +- `Bool`; +- `Float`, `Double`; +- `Date`, `Datetime`, `Timestamp`; +- `Interval`; +- `Decimal(p, s)`; +- `UUID`: + - native `Uuid`, + - UUID как `Utf8`, + - UUID как bytes/`String`; +- `JSON`. + +### Транзакции и выполнение запросов + +Поддерживаются: + +- обычные транзакции Exposed поверх YDB JDBC; +- retry-классификация для retriable-сценариев; +- batch operations; +- обычная пагинация через `LIMIT`; +- keyset pagination helper. + +### Совместимость с Exposed DSL / DAO + +Поддерживаются и протестированы: + +- CRUD через Exposed DSL; +- `JOIN`; +- подзапросы; +- many-to-many через связующую таблицу; +- DAO basic workflow; +- optimistic locking через version column pattern. + +### Тесты и примеры + +В проекте есть: + +- unit tests для отдельных частей диалекта; +- integration tests с локальной YDB в Docker; +- GitHub Actions CI; +- консольное demo-приложение. + +## Ограничения текущей реализации + +На текущем этапе важно учитывать следующие ограничения: + +- YDB требует явный `PRIMARY KEY` для каждой таблицы. +- `AUTO_INCREMENT` в привычном SQL-смысле не используется; идентификаторы должны задаваться явно или генерироваться на уровне приложения. +- Secondary indexes в проекте ориентированы на текущую модель YDB global secondary indexes. +- `UNIQUE` secondary indexes не считаются поддержанными в текущем tested runtime и не должны использоваться как рабочий путь. +- Metadata/introspection layer (`YdbDialectMetadata`) реализован минимально; основной акцент сделан на генерации и выполнении SQL/DDL, а не на полном schema inspection. +- `FOREIGN KEY` и полноценные SQL-ограничения уникальности не являются основным механизмом моделирования в YDB и не рассматриваются как полностью поддержанный слой диалекта. +- Для `Decimal(p, s)` рекомендуется использовать YDB-специфичные расширения диалекта: + - `ydbDecimal(...)` для колонки, + - `ydbDecimalLiteral(...)` для update-expression сценариев, где требуется корректный decimal literal в YDB. + +## Структура проекта + +Основные части проекта: + +- диалект YDB для Exposed; +- провайдеры SQL/DDL и типов; +- YDB-специфичные column types и helpers; +- unit tests; +- integration tests; +- demo-приложение; +- CI-конфигурация. + +## Как поднять YDB локально + +Для запуска integration tests и demo-приложения требуется локальный экземпляр YDB, доступный по адресу: + +```text +grpc://localhost:2136/local +``` + +В корне проекта выполните: + +```bash +docker compose up -d +``` + +После запуска контейнеру требуется несколько секунд на инициализацию. До завершения инициализации база может быть недоступна. + +Остановить локальный YDB можно командой: +```bash +docker compose down +``` + +Локальный web UI YDB доступен по адресу: +```text +http://localhost:8765 +``` + +### Как запустить тесты + +После запуска локального YDB выполните: +```bash +mvn clean install +``` +Эта команда запускает unit tests, integration tests и сборку артефакта. + +## Демо-приложение + +В проекте есть консольное демо-приложение, показывающее работу диалекта на реальной локальной YDB: + +### Что показывает demo + +Demo демонстрирует: + +создание таблицы demo_products; +создание secondary index по полю category; +CRUD-операции: +вставка тестовых данных; +чтение данных по категории; +обновление записи; +удаление записи; +keyset pagination по первичному ключу; +вывод сгенерированного DDL и результатов выполнения операций в консоль. + +## Как запустить демо: + +Сначала поднимите локальный YDB: +```bash +docker compose up -d +``` + +Далее запустите demo + +**Linux / macOS / cmd** +```bash +mvn compile exec:java -Dexec.mainClass="tech.ydb.exposed.dialect.demo.DemoAppKt" +``` + +**PowerShell** +```bash +mvn --% compile exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt +``` + +## Что проверяют тесты + +Тестовый набор покрывает основные сценарии использования диалекта: + +- подключение к локальной YDB; +- DDL и создание таблиц; +- CRUD; +- UPSERT; +- batch operations; +- secondary indexes; +- TTL; +- типы данных; +- JOIN, подзапросы, many-to-many; +- optimistic locking; +- keyset pagination; +- DAO basic workflow; +- предметный integration scenario на нескольких таблицах. + +Это подтверждает корректную работу диалекта в рамках реализованного функционального подмножества. + +## CI + +В проекте настроен GitHub Actions workflow, который поднимает локальный YDB в CI и запускает сборку и тесты проекта. + +## Практические рекомендации + +При использовании диалекта рекомендуется: + +всегда задавать явный PRIMARY KEY; +использовать YDB-специфичные типы и helpers там, где они уже предусмотрены проектом; +для Decimal(p, s) использовать ydbDecimal(...); +для decimal update-expression использовать ydbDecimalLiteral(...); +не рассчитывать на AUTO_INCREMENT; +воспринимать UNIQUE secondary index как неподдержанный сценарий текущего tested runtime. diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 4b65e3ae..82eb97a1 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -197,6 +197,12 @@ + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt index 2d2b827f..2a0854c9 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt @@ -2,9 +2,32 @@ package tech.ydb.exposed.dialect.basic import org.jetbrains.exposed.v1.jdbc.vendors.DatabaseDialectMetadata -class YdbDialectMetadata : DatabaseDialectMetadata() { - // Минимальная рабочая реализация: - // - existingIndices(...) - // - existingPrimaryKeys(...) - // - maybe tableExists / columns metadata, если это требуется вашим тестам и SchemaUtils -} \ No newline at end of file +class YdbDialectMetadata : DatabaseDialectMetadata() + +//package tech.ydb.exposed.dialect.basic +// +//import org.jetbrains.exposed.v1.core.Index +//import org.jetbrains.exposed.v1.core.Table +//import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +//import org.jetbrains.exposed.v1.jdbc.vendors.DatabaseDialectMetadata +//import java.sql.DatabaseMetaData +// +//class YdbDialectMetadata : DatabaseDialectMetadata() { +// +// override fun existingIndices(vararg tables: Table): Map> { +// return jdbcMeta { meta -> +// tables.associateWith { table -> +// readIndices(meta, table) +// } +// } +// } +// +// private fun jdbcMeta(block: (DatabaseMetaData) -> T): T = +// TransactionManager.current().connection.metadata { meta -> +// block(meta) +// } +// +// private fun readIndices(meta: DatabaseMetaData, table: Table): List { +// return emptyList() +// } +//} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt new file mode 100644 index 00000000..f9211bb1 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt @@ -0,0 +1,132 @@ +package tech.ydb.exposed.dialect.demo + +import org.jetbrains.exposed.v1.core.asLiteral +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.insert +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.update +import tech.ydb.exposed.dialect.basic.YdbDialectProvider +import tech.ydb.exposed.dialect.types.ydbDecimalLiteral +import java.math.BigDecimal + +fun main() { + val db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver" + ) + + transaction(db) { + println("== Подготовка схемы ==") + SchemaUtils.drop(DemoProducts) + SchemaUtils.create(DemoProducts) + + println("DDL:") + DemoProducts.ddl.forEach { println(it) } + + println() + println("== CREATE ==") + 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 по 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("== KEYSET PAGINATION ==") + val page1 = DemoProducts + .selectAll() + .keysetPage(DemoProducts.id, lastValue = null, limit = 2) + .toList() + + println("page1:") + page1.forEach { + println(" ${it[DemoProducts.id]} -> ${it[DemoProducts.name]}") + } + + val lastSeenId = page1.last()[DemoProducts.id] + + val page2 = DemoProducts + .selectAll() + .keysetPage(DemoProducts.id, lastValue = lastSeenId, limit = 2) + .toList() + + println("page2:") + page2.forEach { + println(" ${it[DemoProducts.id]} -> ${it[DemoProducts.name]}") + } + + 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.insert { + it[id] = 1 + it[sku] = "BOOK-001" + it[name] = "Kotlin in Action" + it[category] = "books" + it[price] = BigDecimal("39.90") + } + + DemoProducts.insert { + it[id] = 2 + it[sku] = "BOOK-002" + it[name] = "Distributed Systems" + it[category] = "books" + it[price] = BigDecimal("42.50") + } + + DemoProducts.insert { + it[id] = 3 + it[sku] = "HW-001" + it[name] = "Mechanical Keyboard" + it[category] = "hardware" + it[price] = BigDecimal("129.99") + } + + DemoProducts.insert { + it[id] = 4 + it[sku] = "HW-002" + it[name] = "USB-C Dock" + it[category] = "hardware" + it[price] = BigDecimal("89.00") + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt new file mode 100644 index 00000000..f34b0403 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt @@ -0,0 +1,48 @@ +package tech.ydb.exposed.dialect.demo + +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.jdbc.Query +import org.jetbrains.exposed.v1.jdbc.andWhere +import tech.ydb.exposed.dialect.basic.YdbIndexScope +import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.types.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 { + // Обычный Exposed index + index(false, sku) + + // YDB-specific secondary index + secondaryIndex( + name = "demo_products_category_idx", + category, + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.ASYNC, + coverColumns = listOf(name, price) + ) + } +} + +fun > Query.keysetPage( + column: org.jetbrains.exposed.v1.core.Column, + lastValue: T?, + limit: Int +): Query { + if (lastValue != null) { + andWhere { column greater lastValue } + } + + return orderBy(column to SortOrder.ASC).limit(limit) +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt new file mode 100644 index 00000000..7c543ff4 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt @@ -0,0 +1,23 @@ +package tech.ydb.exposed.dialect.types + +import org.jetbrains.exposed.v1.core.Expression +import org.jetbrains.exposed.v1.core.QueryBuilder +import java.math.BigDecimal + +class YdbDecimalLiteral( + private val value: BigDecimal, + private val precision: Int, + private val scale: Int +) : Expression() { + + override fun toQueryBuilder(queryBuilder: QueryBuilder) { + val normalized = value.setScale(scale).toPlainString() + queryBuilder.append("""Decimal("$normalized", $precision, $scale)""") + } +} + +fun ydbDecimalLiteral( + value: BigDecimal, + precision: Int, + scale: Int +): Expression = YdbDecimalLiteral(value, precision, scale) \ 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..31b5b4c8 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/scenario/UniversityScenarioIT.kt @@ -0,0 +1,169 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class UniversityScenarioIT : BaseYdbTest() { + + object Departments : YdbTable("departments") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + } + + object Students : YdbTable("students") { + val id = integer("id") + val name = varchar("name", 255) + val departmentId = integer("department_id") + + override val primaryKey = PrimaryKey(id) + } + + object Courses : YdbTable("courses") { + val id = integer("id") + val name = varchar("name", 255) + val departmentId = integer("department_id") + + override val primaryKey = PrimaryKey(id) + } + + object Enrollments : YdbTable("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 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/types/DecimalUpdateIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalUpdateIT.kt new file mode 100644 index 00000000..63c16713 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/DecimalUpdateIT.kt @@ -0,0 +1,69 @@ +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.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.types.ydbDecimal +import tech.ydb.exposed.dialect.types.ydbDecimalLiteral +import java.math.BigDecimal + +class DecimalUpdateIT : BaseYdbTest() { + + object DecimalItems : YdbTable("decimal_update_items") { + val id = integer("id") + val name = varchar("name", 255) + val price = ydbDecimal("price", 10, 2) + + override val primaryKey = PrimaryKey(id) + } + + 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 From ae9a454330ced50446f5ffed8bf30aa2a079aeef Mon Sep 17 00:00:00 2001 From: s-markelova Date: Sun, 19 Apr 2026 15:22:32 +0300 Subject: [PATCH 14/43] change ci --- .../.github/workflows/ci.yml | 55 ------------------- 1 file changed, 55 deletions(-) delete mode 100644 kotlin-exposed-dialect/.github/workflows/ci.yml diff --git a/kotlin-exposed-dialect/.github/workflows/ci.yml b/kotlin-exposed-dialect/.github/workflows/ci.yml deleted file mode 100644 index d145f5ca..00000000 --- a/kotlin-exposed-dialect/.github/workflows/ci.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: CI - -on: - push: - branches: [ main, master ] - pull_request: - -jobs: - build-and-test: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '17' - cache: maven - - - name: Show Docker and Compose versions - run: | - docker version - docker compose version - - - name: Start local YDB with docker compose - run: docker compose up -d - - - name: Wait for YDB gRPC port - shell: bash - run: | - for i in {1..60}; do - if (echo > /dev/tcp/127.0.0.1/2136) >/dev/null 2>&1; then - echo "YDB is reachable on port 2136" - exit 0 - fi - echo "Waiting for YDB..." - sleep 2 - done - echo "YDB did not become ready in time" - docker compose logs || true - exit 1 - - - name: Run Maven build - run: mvn --batch-mode --update-snapshots clean install - - - name: Dump YDB logs on failure - if: failure() - run: docker compose logs - - - name: Stop YDB - if: always() - run: docker compose down -v \ No newline at end of file From 2d538f98f3beb1a98b00edde1f591210d32c7f1a Mon Sep 17 00:00:00 2001 From: s-markelova Date: Sun, 19 Apr 2026 15:24:29 +0300 Subject: [PATCH 15/43] change ci --- .github/workflows/ci-exposed-ydb-dialect.yaml | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/ci-exposed-ydb-dialect.yaml diff --git a/.github/workflows/ci-exposed-ydb-dialect.yaml b/.github/workflows/ci-exposed-ydb-dialect.yaml new file mode 100644 index 00000000..7980da20 --- /dev/null +++ b/.github/workflows/ci-exposed-ydb-dialect.yaml @@ -0,0 +1,66 @@ +name: CI - Kotlin Exposed YDB Dialect + +on: + push: + branches: [ main, master ] + paths: + - "kotlin-exposed-dialect/**" + - ".github/workflows/ci-exposed-ydb-dialect.yml" + pull_request: + paths: + - "kotlin-exposed-dialect/**" + - ".github/workflows/ci-exposed-ydb-dialect.yml" + +jobs: + build-and-test: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: kotlin-exposed-dialect + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + cache: maven + cache-dependency-path: kotlin-exposed-dialect/pom.xml + + - name: Show Docker and Compose versions + run: | + docker version + docker compose version + + - name: Start local YDB with docker compose + run: docker compose up -d + + - name: Wait for YDB gRPC port + shell: bash + run: | + for i in {1..60}; do + if (echo > /dev/tcp/127.0.0.1/2136) >/dev/null 2>&1; then + echo "YDB is reachable on port 2136" + exit 0 + fi + echo "Waiting for YDB..." + sleep 2 + done + echo "YDB did not become ready in time" + docker compose logs || true + exit 1 + + - name: Run Maven build + run: mvn --batch-mode --update-snapshots clean install + + - name: Dump YDB logs on failure + if: failure() + run: docker compose logs + + - name: Stop YDB + if: always() + run: docker compose down -v \ No newline at end of file From 6f47601cb4b94aedc90f05dff961117b0b48b602 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 20 Apr 2026 03:16:19 +0300 Subject: [PATCH 16/43] edit core files, tests, documentaion --- kotlin-exposed-dialect/.gitignore | 1 - kotlin-exposed-dialect/CHANGELOG.md | 59 +++ kotlin-exposed-dialect/README.md | 441 ++++++++++++------ kotlin-exposed-dialect/pom.xml | 2 +- .../ydb/exposed/dialect/basic/YdbDialect.kt | 132 ++---- .../dialect/basic/YdbDialectMetadata.kt | 93 ++-- .../dialect/basic/YdbDialectProvider.kt | 100 +--- .../exposed/dialect/basic/YdbGeneratedIds.kt | 83 ++++ .../ydb/exposed/dialect/basic/YdbIdTable.kt | 90 ---- .../tech/ydb/exposed/dialect/demo/DemoApp.kt | 26 +- .../ydb/exposed/dialect/demo/DemoTables.kt | 19 - .../dialect/functions/YdbFunctionProvider.kt | 130 ++---- .../dialect/locking/YdbOptimisticLocking.kt | 14 +- .../dialect/types/YdbDataTypeProvider.kt | 7 +- .../integration/batch/BatchOperationsIT.kt | 39 +- .../dialect/integration/dao/GeneratedIdsIT.kt | 51 ++ .../dialect/integration/ddl/IndexIT.kt | 15 +- .../locking/OptimisticLockingIT.kt | 115 ++++- .../dialect/integration/upsert/UpsertIT.kt | 19 +- .../dialect/unit/basic/YdbGeneratedIdsTest.kt | 34 ++ .../dialect/unit/functions/FunctionTest.kt | 70 ++- .../unit/types/YdbDataTypeProviderTest.kt | 8 +- 22 files changed, 956 insertions(+), 592 deletions(-) delete mode 100644 kotlin-exposed-dialect/.gitignore create mode 100644 kotlin-exposed-dialect/CHANGELOG.md create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbGeneratedIds.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt diff --git a/kotlin-exposed-dialect/.gitignore b/kotlin-exposed-dialect/.gitignore deleted file mode 100644 index 485dee64..00000000 --- a/kotlin-exposed-dialect/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.idea diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md new file mode 100644 index 00000000..5382a399 --- /dev/null +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -0,0 +1,59 @@ +## 0.1.0 + +Initial Kotlin Exposed dialect implementation for YDB. + +### Added + +- Added YDB dialect registration for Exposed JDBC. +- Added YDB SQL function provider. +- Added YDB-compatible `LIMIT` generation. +- Added YDB-compatible `UPSERT` generation for Exposed DSL. +- Added explicit rejection for ANSI `MERGE` with guidance to use `UPSERT`. +- Added YDB DDL generation for tables with mandatory primary keys. +- Added support for YDB secondary indexes / global secondary indexes. +- Added TTL helpers and TTL SQL generation. +- Added YDB data type provider. +- Added YDB-specific column type helpers for: + - decimal values + - interval values + - JSON values + - UUID values + - unsigned integer values +- Added decimal literal helper for update-expression scenarios. +- Added UUID and ULID ID generation helpers. +- Added base table helpers for UUID/ULID identifiers. +- Added explicit rejection for `AUTO_INCREMENT`. +- Added retry classification for YDB retriable errors. +- Added transaction retry helper coverage. +- Added keyset pagination helper. +- Added optimistic locking helper based on a version column. +- Added metadata support for reading existing YDB indexes through JDBC metadata. +- Added console demo application with CRUD, UPSERT, indexes, decimal values, and keyset pagination. +- Added Docker-based integration test setup. +- Added GitHub Actions CI workflow. + +### Tested + +- Connection to local YDB. +- CRUD operations. +- UPSERT through Exposed DSL. +- Batch operations. +- DAO smoke workflow. +- Generated UUID/ULID identifiers. +- Secondary indexes. +- TTL. +- Numeric, binary, temporal, interval, decimal, UUID, unsigned integer, and JSON types. +- JOIN queries. +- Subqueries. +- Many-to-many relation through a join table. +- Optimistic locking. +- Keyset pagination. +- Integration scenario with several related tables. + +### Limitations + +- ANSI `MERGE` is not supported. +- SQL `AUTO_INCREMENT` is not supported. +- Unique secondary indexes are not treated as a supported portable feature. +- Foreign keys are not implemented as an enforced YDB constraint layer. +- Metadata support is focused on tested Exposed workflows rather than full schema introspection. diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index e4f8ac11..190fd586 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -1,217 +1,370 @@ -# Kotlin Exposed YDB Dialect +## Kotlin Exposed YDB Dialect -Проект реализует SQL-диалект YDB для фреймворка Kotlin Exposed и позволяет использовать Exposed DSL/DAO для работы с YDB через JDBC. +SQL dialect and JDBC integration layer that allows using [JetBrains Exposed](https://github.com/JetBrains/Exposed) DSL/DAO with [YDB](https://ydb.tech). -## Поддержка YDB в Kotlin Exposed (YDB SQL Dialect + JDBC интеграция) +The module provides YDB-specific SQL generation, DDL support, type mappings, UPSERT support, transaction helpers, pagination helpers, tests, CI, and a small demo application. -### Описание предметной области -Exposed — ORM/SQL‑DSL для Kotlin. Требует диалект БД для генерации SQL и DDL через JDBC. YDB — распределённая транзакционная СУБД (аналог «SQL поверх key‑value») с собственным SQL/YQL, UPSERT, глобальными вторичными индексами и транзакциями с ретраями. -Цель проекта — добавить полноценный диалект YDB в Exposed, чтобы писать к YDB обычный Kotlin‑DSL/DAO без ручного SQL. +### Module Coordinates -### Что следует сделать: +```xml + + tech.ydb.dialects + kotlin-exposed-ydb-dialect + 0.1.0 + +``` -Реализовать диалект Exposed для YDB (JDBC): синтаксис идентификаторов, LIMIT, UPSERT/MERGE, генерация DDL (CREATE TABLE с обязательным PK), индексы (GSI), TTL. -Маппинг типов YDB ↔ Exposed: числовые, строка/Bytes, Bool, Date/Datetime/Timestamp/Interval, Decimal(p,s), UUID (String/Bytes), JSON. -Транзакции и ретраи: обработка abort/timeout с backoff, read-only/read-write, батчи, пагинация (LIMIT и keyset-хелпер). -Совместимость DSL/DAO: JOIN, подзапросы, many-to-many, optimistic locking (version column); при необходимости эмуляция UNIQUE/FOREIGN KEY, генерация идентификаторов без AUTO_INCREMENT (UUID/ULID). -Тесты и примеры: юнит и интеграционные (YDB в Docker), CI, демо-приложение (CRUD/индексы/пагинация), документация с ограничениями и рецептами. +The artifact is currently intended to be built from this repository: -## Статус +```bash +mvn clean install +``` -Проект находится в рабочем состоянии и покрыт unit- и integration-тестами. -Реализован базовый функциональный слой диалекта, типы YDB, DDL/DDL-расширения, CRUD-сценарии, запросы на нескольких таблицах, batch-операции, keyset pagination, optimistic locking, demo-приложение и CI. +### Requirements -## Поддерживаемые возможности +- JDK 17+ +- Maven +- Docker / Docker Compose for integration tests and local demo +- Local YDB instance for integration tests -### SQL / DDL +The module uses the YDB JDBC driver and Exposed 1.x APIs. -Поддерживаются: +### Quick Start -- генерация идентификаторов и SQL в стиле YDB; -- `LIMIT`; -- `UPSERT`; -- `CREATE TABLE` с обязательным `PRIMARY KEY`; -- secondary indexes для YDB; -- TTL для таблиц YDB; -- `ALTER TABLE ... ADD INDEX` в рамках поддерживаемой модели secondary index. +Start local YDB: -### Маппинг типов YDB ↔ Exposed +```bash +docker compose up -d +``` -Поддерживаются: +Connect to YDB through the dialect provider: -- целочисленные типы: `Int16`, `Int32`, `Int64`; -- беззнаковые типы, используемые в YDB-расширениях диалекта, включая `Uint64`; -- строковые типы: `Utf8`; -- бинарные данные: `String`/bytes-представление; -- `Bool`; -- `Float`, `Double`; -- `Date`, `Datetime`, `Timestamp`; -- `Interval`; -- `Decimal(p, s)`; -- `UUID`: - - native `Uuid`, - - UUID как `Utf8`, - - UUID как bytes/`String`; -- `JSON`. +```kotlin +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import tech.ydb.exposed.dialect.YdbDialectProvider -### Транзакции и выполнение запросов +val database = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + user = "", + password = "" +) -Поддерживаются: +transaction(database) { + // Exposed DSL / DAO code +} +``` -- обычные транзакции Exposed поверх YDB JDBC; -- retry-классификация для retriable-сценариев; -- batch operations; -- обычная пагинация через `LIMIT`; -- keyset pagination helper. +### Table Example -### Совместимость с Exposed DSL / DAO +YDB requires every table to have an explicit primary key. -Поддерживаются и протестированы: +```kotlin +import org.jetbrains.exposed.v1.core.Table +import tech.ydb.exposed.dialect.ddl.secondaryIndex +import tech.ydb.exposed.dialect.types.ydbDecimal -- CRUD через Exposed DSL; -- `JOIN`; -- подзапросы; -- many-to-many через связующую таблицу; -- DAO basic workflow; -- optimistic locking через version column pattern. +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 = 22, scale = 9) -### Тесты и примеры + override val primaryKey = PrimaryKey(id) -В проекте есть: + init { + index(isUnique = false, sku) + secondaryIndex("products_category_idx", category) + } +} +``` -- unit tests для отдельных частей диалекта; -- integration tests с локальной YDB в Docker; -- GitHub Actions CI; -- консольное demo-приложение. +### UPSERT -## Ограничения текущей реализации +YDB has native `UPSERT`, and the dialect maps Exposed `upsert` calls to YDB-compatible SQL. -На текущем этапе важно учитывать следующие ограничения: +```kotlin +import org.jetbrains.exposed.v1.jdbc.upsert +import tech.ydb.exposed.dialect.types.ydbDecimalLiteral +import java.math.BigDecimal -- YDB требует явный `PRIMARY KEY` для каждой таблицы. -- `AUTO_INCREMENT` в привычном SQL-смысле не используется; идентификаторы должны задаваться явно или генерироваться на уровне приложения. -- Secondary indexes в проекте ориентированы на текущую модель YDB global secondary indexes. -- `UNIQUE` secondary indexes не считаются поддержанными в текущем tested runtime и не должны использоваться как рабочий путь. -- Metadata/introspection layer (`YdbDialectMetadata`) реализован минимально; основной акцент сделан на генерации и выполнении SQL/DDL, а не на полном schema inspection. -- `FOREIGN KEY` и полноценные SQL-ограничения уникальности не являются основным механизмом моделирования в YDB и не рассматриваются как полностью поддержанный слой диалекта. -- Для `Decimal(p, s)` рекомендуется использовать YDB-специфичные расширения диалекта: - - `ydbDecimal(...)` для колонки, - - `ydbDecimalLiteral(...)` для update-expression сценариев, где требуется корректный decimal literal в YDB. +Products.upsert { + it[id] = 1 + it[sku] = "book-001" + it[name] = "YDB recipes" + it[category] = "books" + it[price] = ydbDecimalLiteral(BigDecimal("19.990000000")) +} +``` -## Структура проекта +YDB requires an explicit column list for `UPSERT INTO ... VALUES (...)`; the dialect generates it automatically for Exposed DSL calls. -Основные части проекта: +### Generated IDs -- диалект YDB для Exposed; -- провайдеры SQL/DDL и типов; -- YDB-специфичные column types и helpers; -- unit tests; -- integration tests; -- demo-приложение; -- CI-конфигурация. +YDB does not support SQL `AUTO_INCREMENT` in the usual relational database sense. The dialect rejects `autoIncrement()` explicitly. -## Как поднять YDB локально +For application-side generated identifiers, use the provided helpers: -Для запуска integration tests и demo-приложения требуется локальный экземпляр YDB, доступный по адресу: +```kotlin +import tech.ydb.exposed.dialect.basic.YdbUuidStringIdTable +import tech.ydb.exposed.dialect.basic.YdbUlidTable -```text -grpc://localhost:2136/local +object Users : YdbUuidStringIdTable("users") { + val name = varchar("name", 255) +} + +object Events : YdbUlidTable("events") { + val payload = text("payload") +} ``` -В корне проекта выполните: +Available helpers include: + +- `YdbUuidIdTable` +- `YdbUuidStringIdTable` +- `YdbUlidTable` +- `YdbGeneratedIds.uuid()` +- `YdbGeneratedIds.uuidString()` +- `YdbGeneratedIds.ulid()` + +### Type Mapping + +The dialect provides YDB-aware mappings for common Exposed column types and additional YDB-specific helpers. + +Supported groups: + +- integer types: `Int16`, `Int32`, `Int64` +- unsigned integer helpers, including `Uint64` +- floating-point types: `Float`, `Double` +- `Bool` +- text/string values +- binary values +- `Date` +- `Datetime` +- `Timestamp` +- `Interval` +- `Decimal(p, s)` +- UUID: + - native YDB UUID representation + - UUID as UTF-8 string + - UUID as bytes +- JSON + +For decimal columns and decimal update expressions, prefer the dialect helpers: + +```kotlin +val price = ydbDecimal("price", precision = 22, scale = 9) +``` -```bash -docker compose up -d +```kotlin +it[price] = ydbDecimalLiteral(BigDecimal("12.490000000")) ``` -После запуска контейнеру требуется несколько секунд на инициализацию. До завершения инициализации база может быть недоступна. +### DDL Support -Остановить локальный YDB можно командой: -```bash -docker compose down +Supported DDL features: + +- `CREATE TABLE` +- mandatory `PRIMARY KEY` +- YDB-compatible column type generation +- secondary indexes / global secondary indexes +- TTL helpers +- `ALTER TABLE ... ADD INDEX` for supported secondary index scenarios + +### TTL + +The dialect includes helpers for YDB TTL expressions and table-level TTL settings. TTL support is covered by unit and integration tests for several supported YDB column modes. + +### Pagination + +Standard Exposed `LIMIT` is supported. + +The module also provides keyset pagination helpers for YDB-friendly pagination over ordered keys: + +```kotlin +import tech.ydb.exposed.dialect.pagination.keysetPageAsc + +val page = Products + .selectAll() + .orderBy(Products.id) + .keysetPageAsc(Products.id, lastValue = null, limit = 20) + .toList() ``` -Локальный web UI YDB доступен по адресу: -```text -http://localhost:8765 +### Transactions and Retries + +The dialect works with standard Exposed JDBC transactions on top of the YDB JDBC driver. + +The project also includes retry classification and retry helpers for typical YDB retriable failures, including abort and timeout-like scenarios. Retry behavior is covered by unit tests and integration smoke tests. + +### DSL and DAO Compatibility + +The following Exposed scenarios are covered by tests: + +- basic connection +- CRUD +- UPSERT +- batch operations +- DAO smoke workflow +- generated UUID/ULID identifiers +- joins +- subqueries +- many-to-many relation through a join table +- optimistic locking with a version column +- keyset pagination +- secondary indexes +- TTL +- YDB-specific types + +### MERGE Support + +ANSI `MERGE` is not implemented by this dialect. + +YDB has native `UPSERT`, but Exposed `MERGE` has broader conditional semantics. Translating Exposed `MERGE` to YDB `UPSERT` would be misleading, so the dialect explicitly rejects `MERGE` calls with an `UnsupportedOperationException`. + +Use `upsert` or batch UPSERT-style operations instead. + +### Limitations + +Important limitations: + +- Every YDB table must have an explicit primary key. +- `AUTO_INCREMENT` is not supported. Use application-side UUID/ULID generation. +- ANSI `MERGE` is not supported. Use `UPSERT`. +- Unique secondary indexes are not treated as a supported portable feature in this dialect. +- Foreign keys are not enforced as a primary modeling mechanism for YDB in this project. +- Schema metadata support is intentionally focused on the parts Exposed needs for tested workflows, especially table/index inspection. +- Some advanced YDB/YQL features are outside the current Exposed dialect surface. + +### Running Tests + +Start local YDB: + +```bash +docker compose up -d ``` -### Как запустить тесты +Run all unit and integration tests: -После запуска локального YDB выполните: ```bash mvn clean install ``` -Эта команда запускает unit tests, integration tests и сборку артефакта. -## Демо-приложение +The build runs: -В проекте есть консольное демо-приложение, показывающее работу диалекта на реальной локальной YDB: +- unit tests through Surefire +- integration tests through Failsafe +- packaging and local Maven installation -### Что показывает demo +Stop local YDB: -Demo демонстрирует: +```bash +docker compose down -v +``` + +### Demo Application + +The demo application shows the dialect against a real local YDB instance. + +It demonstrates: -создание таблицы demo_products; -создание secondary index по полю category; -CRUD-операции: -вставка тестовых данных; -чтение данных по категории; -обновление записи; -удаление записи; -keyset pagination по первичному ключу; -вывод сгенерированного DDL и результатов выполнения операций в консоль. +- YDB connection through `YdbDialectProvider` +- table creation +- primary key requirement +- secondary index creation +- UPSERT seed data +- read queries +- update +- delete +- decimal values +- keyset pagination +- generated DDL output -## Как запустить демо: +Start YDB: -Сначала поднимите локальный YDB: ```bash docker compose up -d ``` -Далее запустите demo +Run demo on Linux, macOS, or cmd: -**Linux / macOS / cmd** ```bash mvn compile exec:java -Dexec.mainClass="tech.ydb.exposed.dialect.demo.DemoAppKt" ``` -**PowerShell** -```bash +Run demo in PowerShell: + +```powershell mvn --% compile exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt ``` -## Что проверяют тесты +By default, the demo uses: + +```text +jdbc:ydb:grpc://localhost:2136/local +``` + +You can override connection settings with environment variables: + +- `YDB_JDBC_URL` +- `YDB_USER` +- `YDB_PASSWORD` + +### CI + +The repository contains a GitHub Actions workflow for this module. + +The CI workflow: + +- checks out the repository +- sets up JDK +- starts local YDB with Docker Compose +- waits until YDB is reachable +- runs `mvn clean install` +- prints YDB logs on failure +- stops and removes the local YDB container + +### Project Structure + +Main areas: -Тестовый набор покрывает основные сценарии использования диалекта: +- `src/main/kotlin/tech/ydb/exposed/dialect/basic` + Dialect registration, metadata, generated ID helpers, table helpers. -- подключение к локальной YDB; -- DDL и создание таблиц; -- CRUD; -- UPSERT; -- batch operations; -- secondary indexes; -- TTL; -- типы данных; -- JOIN, подзапросы, many-to-many; -- optimistic locking; -- keyset pagination; -- DAO basic workflow; -- предметный integration scenario на нескольких таблицах. +- `src/main/kotlin/tech/ydb/exposed/dialect/functions` + SQL function provider, LIMIT, UPSERT, MERGE rejection. -Это подтверждает корректную работу диалекта в рамках реализованного функционального подмножества. +- `src/main/kotlin/tech/ydb/exposed/dialect/types` + YDB-specific column types and type helpers. -## CI +- `src/main/kotlin/tech/ydb/exposed/dialect/ddl` + YDB DDL extensions such as secondary indexes and TTL. -В проекте настроен GitHub Actions workflow, который поднимает локальный YDB в CI и запускает сборку и тесты проекта. +- `src/main/kotlin/tech/ydb/exposed/dialect/pagination` + Keyset pagination helpers. -## Практические рекомендации +- `src/main/kotlin/tech/ydb/exposed/dialect/transaction` + Retry classification and transaction helpers. -При использовании диалекта рекомендуется: +- `src/main/kotlin/tech/ydb/exposed/dialect/demo` + Console demo application. -всегда задавать явный PRIMARY KEY; -использовать YDB-специфичные типы и helpers там, где они уже предусмотрены проектом; -для Decimal(p, s) использовать ydbDecimal(...); -для decimal update-expression использовать ydbDecimalLiteral(...); -не рассчитывать на AUTO_INCREMENT; -воспринимать UNIQUE secondary index как неподдержанный сценарий текущего tested runtime. +- `src/test/kotlin` + Unit and integration tests. + +### Current Status + +The dialect is implemented as a working YDB integration for Exposed DSL/DAO within the tested feature set. + +Current test coverage includes: + +- 61 unit tests +- 48 integration tests + +The latest local verification passed with: + +```text +Tests run: 61, Failures: 0, Errors: 0, Skipped: 0 +Tests run: 48, Failures: 0, Errors: 0, Skipped: 0 +BUILD SUCCESS +``` diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 82eb97a1..25c784d9 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -6,7 +6,7 @@ tech.ydb.dialects kotlin-exposed-ydb-dialect - 0.1.0-SNAPSHOT + 0.1.0 jar diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index 75e1a0df..fc3c8b8b 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -3,64 +3,38 @@ package tech.ydb.exposed.dialect.basic import org.jetbrains.exposed.v1.core.Index import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.core.vendors.VendorDialect -import tech.ydb.exposed.dialect.types.YdbDataTypeProvider -import tech.ydb.exposed.dialect.functions.YdbFunctionProvider import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import tech.ydb.exposed.dialect.functions.YdbFunctionProvider +import tech.ydb.exposed.dialect.types.YdbDataTypeProvider import kotlin.reflect.full.memberProperties -class YdbDialect: VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvider()) { - -// override val name: String = "ydb" - -// override fun addPrimaryKey( -// table: Table, -// pkName: String?, -// vararg pkColumns: Column<*> -// ): String { -// val columns = pkColumns.joinToString(", ") { it.name } -// return "PRIMARY KEY ($columns)" -// } - -// override fun createIndex(index: Index): String { -// val columns = index.columns.joinToString(", ") { it.name } -// val indexName = index.indexName -// val tableName = index.table.tableName -// -// return buildString { -// append("ALTER TABLE ") -// append(tableName) -// append(" ADD INDEX ") -// append(indexName) -// append(" GLOBAL ON (") -// append(columns) -// append(")") -// } -// } -override fun createIndex(index: Index): String { - val tr = runCatching { TransactionManager.current() }.getOrNull() - - val columns = index.columns.joinToString(", ") { column -> - tr?.identity(column) ?: column.name - } +class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvider()) { - val indexName = index.indexName - val tableName = tr?.identity(index.table) ?: index.table.tableName - val unique = index.extractUniqueFlag() + override fun createIndex(index: Index): String { + val tr = runCatching { TransactionManager.current() }.getOrNull() - require(!unique) { - "UNIQUE secondary indexes are not supported by the current YDB runtime used by this dialect" - } + val columns = index.columns.joinToString(", ") { column -> + tr?.identity(column) ?: column.name + } - return buildString { - append("ALTER TABLE ") - append(tableName) - append(" ADD INDEX ") - append(indexName) - append(" GLOBAL ON (") - append(columns) - append(")") + val indexName = index.indexName + val tableName = tr?.identity(index.table) ?: index.table.tableName + val unique = index.extractUniqueFlag() + + require(!unique) { + "UNIQUE secondary indexes are not supported by the current YDB runtime used by this dialect" + } + + return buildString { + append("ALTER TABLE ") + append(tableName) + append(" ADD INDEX ") + append(indexName) + append(" GLOBAL ON (") + append(columns) + append(")") + } } -} fun createSecondaryIndex(table: Table, spec: YdbSecondaryIndexSpec): String { val tr = TransactionManager.current() @@ -77,30 +51,14 @@ override fun createIndex(index: Index): String { indexName: String, isUnique: Boolean, isPartialOrFunctional: Boolean - ): String { - return "ALTER TABLE $tableName DROP INDEX $indexName" - } + ): String = "ALTER TABLE $tableName DROP INDEX $indexName" fun setTtl(table: YdbTable): String { val tr = TransactionManager.current() val ttl = table.getTtlSettings() ?: error("TTL is not configured for table ${table.tableName}") - val sqlType = ttl.column.columnType.sqlType() - val supported = when (ttl.mode) { - YdbTtlColumnMode.DATE_TYPE -> - sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" - - YdbTtlColumnMode.SECONDS, - YdbTtlColumnMode.MILLISECONDS, - YdbTtlColumnMode.MICROSECONDS, - YdbTtlColumnMode.NANOSECONDS -> - sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" - } - - require(supported) { - "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" - } + validateTtlColumn(ttl) return buildString { append("ALTER TABLE ") @@ -122,6 +80,24 @@ override fun createIndex(index: Index): String { return "ALTER TABLE ${tr.identity(table)} RESET (TTL)" } + private fun validateTtlColumn(ttl: YdbTtlSettings) { + val sqlType = ttl.column.columnType.sqlType() + val supported = when (ttl.mode) { + YdbTtlColumnMode.DATE_TYPE -> + sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" + + YdbTtlColumnMode.SECONDS, + YdbTtlColumnMode.MILLISECONDS, + YdbTtlColumnMode.MICROSECONDS, + YdbTtlColumnMode.NANOSECONDS -> + sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" + } + + require(supported) { + "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" + } + } + private fun Index.extractUniqueFlag(): Boolean { val prop = this::class.memberProperties.firstOrNull { it.name == "unique" || it.name == "isUnique" @@ -129,20 +105,4 @@ override fun createIndex(index: Index): String { return (runCatching { prop.getter.call(this) }.getOrNull() as? Boolean) == true } -// /** -// * YDB не поддерживает ALTER COLUMN напрямую. -// * Обычно требуется пересоздание таблицы. -// */ -// override fun modifyColumn( -// column: Column<*>, -// columnDiff: ColumnDiff -// ): List { -// return emptyList() -// } -// -// -// override val dataTypeProvider: DataTypeProvider = YdbDataTypeProvider() -// -// override val functionProvider: FunctionProvider = YdbFunctionProvider() - -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt index 2a0854c9..933bbb89 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt @@ -1,33 +1,68 @@ package tech.ydb.exposed.dialect.basic +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Index +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.vendors.DatabaseDialectMetadata +import java.sql.Connection +import java.sql.DatabaseMetaData -class YdbDialectMetadata : DatabaseDialectMetadata() - -//package tech.ydb.exposed.dialect.basic -// -//import org.jetbrains.exposed.v1.core.Index -//import org.jetbrains.exposed.v1.core.Table -//import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager -//import org.jetbrains.exposed.v1.jdbc.vendors.DatabaseDialectMetadata -//import java.sql.DatabaseMetaData -// -//class YdbDialectMetadata : DatabaseDialectMetadata() { -// -// override fun existingIndices(vararg tables: Table): Map> { -// return jdbcMeta { meta -> -// tables.associateWith { table -> -// readIndices(meta, table) -// } -// } -// } -// -// private fun jdbcMeta(block: (DatabaseMetaData) -> T): T = -// TransactionManager.current().connection.metadata { meta -> -// block(meta) -// } -// -// private fun readIndices(meta: DatabaseMetaData, table: Table): List { -// return emptyList() -// } -//} \ No newline at end of file +class 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/basic/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt index caad2771..2a5a7fc2 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt @@ -29,102 +29,4 @@ object YdbDialectProvider { } ) } -} - -//package tech.ydb.exposed.dialect.basic -// -//import org.jetbrains.exposed.v1.core.DatabaseConfig -//import org.jetbrains.exposed.v1.jdbc.Database -//import java.sql.Connection -//import java.sql.DriverManager -//import java.util.Properties -// -//object YdbDialectProvider { -// -// private const val DEFAULT_DRIVER = "tech.ydb.jdbc.YdbDriver" -// -// fun connect( -// url: String, -// driver: String = DEFAULT_DRIVER, -// user: String = "", -// password: String = "" -// ): Database { -// Class.forName(driver).getDeclaredConstructor().newInstance() -// -// val props = Properties().apply { -// if (user.isNotEmpty()) setProperty("user", user) -// if (password.isNotEmpty()) setProperty("password", password) -// } -// -// return Database.connect( -// getNewConnection = { -// DriverManager.getConnection(url, props) -// }, -// databaseConfig = DatabaseConfig { -// explicitDialect = YdbDialect() -// -// // Для YDB нельзя оставлять дефолт Exposed/JDBC, -// // иначе transaction() попытается выставить READ_COMMITTED (2). -// defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE -// -// // Пока безопаснее явно зафиксировать. -// defaultReadOnly = false -// -// // Опционально: без nested transactions на первом этапе. -// useNestedTransactions = false -// } -// ) -// } -//} - -//package tech.ydb.exposed.dialect.basic -// -//import org.jetbrains.exposed.v1.core.DatabaseConfig -//import org.jetbrains.exposed.v1.jdbc.Database -// -//object YdbDialectProvider { -// -// fun connect( -// url: String, -// driver: String, -// user: String = "", -// password: String = "" -// ): Database { -// return Database.connect( -// url = url, -// driver = driver, -// user = user, -// password = password, -// databaseConfig = DatabaseConfig { -// explicitDialect = YdbDialect() -// } -// ) -// } -//} - -////object YdbDialectProvider { -//// -//// fun connect( -//// url: String = "jdbc:ydb:grpc://localhost:2136/local", -//// driver: String = "tech.ydb.jdbc.YdbDriver", -//// user: String = "", -//// password: String = "" -//// ): Database { -//// -//// val config = DatabaseConfig.Companion { -//// defaultFetchSize = 1000 -//// } -//// -//// return Database.connect( -//// url = url, -//// driver = driver, -//// user = user, -//// password = password, -//// databaseConfig = config, -//// dialect = YdbDialect(true), -//// setupConnection = TODO(), -//// connectionAutoRegistration = TODO(), -//// manager = TODO() -//// ) -//// } -////} \ No newline at end of file +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbGeneratedIds.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbGeneratedIds.kt new file mode 100644 index 00000000..f48967aa --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbGeneratedIds.kt @@ -0,0 +1,83 @@ +package tech.ydb.exposed.dialect.basic + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.EntityID +import tech.ydb.exposed.dialect.types.ydbUuid +import java.security.SecureRandom +import java.util.UUID + +object YdbGeneratedIds { + private const val ULID_LENGTH = 26 + private const val RANDOM_BYTES = 10 + private val alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray() + private val random = SecureRandom() + + fun uuid(): UUID = UUID.randomUUID() + + fun uuidString(): String = uuid().toString() + + fun ulid(nowMillis: Long = System.currentTimeMillis()): String { + require(nowMillis >= 0) { "ULID timestamp must be non-negative" } + + val chars = CharArray(ULID_LENGTH) + var timestamp = nowMillis + + for (i in 9 downTo 0) { + chars[i] = alphabet[(timestamp and 31L).toInt()] + timestamp = timestamp ushr 5 + } + + val bytes = ByteArray(RANDOM_BYTES) + random.nextBytes(bytes) + + var bitBuffer = 0 + var bitCount = 0 + var charIndex = 10 + + for (byte in bytes) { + bitBuffer = (bitBuffer shl 8) or (byte.toInt() and 0xff) + bitCount += 8 + + while (bitCount >= 5 && charIndex < ULID_LENGTH) { + bitCount -= 5 + chars[charIndex++] = alphabet[(bitBuffer ushr bitCount) and 31] + } + } + + if (charIndex < ULID_LENGTH) { + chars[charIndex] = alphabet[(bitBuffer shl (5 - bitCount)) and 31] + } + + return String(chars) + } +} + +open class YdbUuidIdTable(name: String = "") : YdbIdTable(name) { + final override val id: Column> = ydbUuid("id") + .clientDefault { YdbGeneratedIds.uuid() } + .entityId() + + final override val primaryKey = PrimaryKey(id) +} + +open class YdbUuidStringIdTable( + name: String = "", + idLength: Int = 36 +) : YdbIdTable(name) { + final override val id: Column> = varchar("id", idLength) + .clientDefault { YdbGeneratedIds.uuidString() } + .entityId() + + final override val primaryKey = PrimaryKey(id) +} + +open class YdbUlidTable( + name: String = "", + idLength: Int = 26 +) : YdbIdTable(name) { + final override val id: Column> = varchar("id", idLength) + .clientDefault { YdbGeneratedIds.ulid() } + .entityId() + + final override val primaryKey = PrimaryKey(id) +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt index 59e62d19..d0bd75bf 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt @@ -125,93 +125,3 @@ abstract class YdbIdTable(name: String = "") : IdTable(name) { } } } - -//package tech.ydb.exposed.dialect.basic -// -//import org.jetbrains.exposed.v1.core.Column -//import org.jetbrains.exposed.v1.core.dao.id.IdTable -//import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager -// -//abstract class YdbIdTable(name: String = "") : IdTable(name) { -// -// private var ttlSettings: YdbTtlSettings? = null -// -// protected fun ttl( -// column: Column<*>, -// intervalIso8601: String, -// mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE -// ) { -// ttlSettings = YdbTtlSettings(column, intervalIso8601, mode) -// } -// -// fun getTtlSettings(): YdbTtlSettings? = ttlSettings -// -// override fun createStatement(): 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") -// } -// } -// } -// -// val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } -// -// val ttlSql = ttlSettings?.let { ttl -> -// validateTtlColumn(ttl) -// -// buildString { -// append(" WITH (TTL = Interval(\"") -// append(ttl.intervalIso8601) -// append("\") ON ") -// append(tr.identity(ttl.column)) -// ttl.mode.toSql()?.let { -// append(" AS ") -// append(it) -// } -// append(")") -// } -// } ?: "" -// -// val sql = buildString { -// append("CREATE TABLE IF NOT EXISTS ") -// append(tr.identity(this@YdbIdTable)) -// append(" (") -// append(columnsSql) -// append(", PRIMARY KEY (") -// append(pkSql) -// append("))") -// append(ttlSql) -// } -// -// return listOf(sql) -// } -// -// private fun validateTtlColumn(ttl: YdbTtlSettings) { -// val sqlType = ttl.column.columnType.sqlType() -// -// val supported = when (ttl.mode) { -// YdbTtlColumnMode.DATE_TYPE -> -// sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" -// -// YdbTtlColumnMode.SECONDS, -// YdbTtlColumnMode.MILLISECONDS, -// YdbTtlColumnMode.MICROSECONDS, -// YdbTtlColumnMode.NANOSECONDS -> -// sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" -// } -// -// require(supported) { -// "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" -// } -// } -//} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt index f9211bb1..f06f07fa 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt @@ -1,14 +1,14 @@ package tech.ydb.exposed.dialect.demo -import org.jetbrains.exposed.v1.core.asLiteral 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.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.jdbc.upsert import tech.ydb.exposed.dialect.basic.YdbDialectProvider +import tech.ydb.exposed.dialect.pagination.keysetPageAsc import tech.ydb.exposed.dialect.types.ydbDecimalLiteral import java.math.BigDecimal @@ -19,15 +19,15 @@ fun main() { ) transaction(db) { - println("== Подготовка схемы ==") - SchemaUtils.drop(DemoProducts) + println("== Schema setup ==") + runCatching { SchemaUtils.drop(DemoProducts) } SchemaUtils.create(DemoProducts) println("DDL:") DemoProducts.ddl.forEach { println(it) } println() - println("== CREATE ==") + println("== UPSERT seed data ==") seedDemoData() DemoProducts.selectAll() @@ -37,7 +37,7 @@ fun main() { } println() - println("== READ по category ==") + println("== READ by category ==") DemoProducts.selectAll() .where { DemoProducts.category eq "books" } .orderBy(DemoProducts.id) @@ -65,7 +65,7 @@ fun main() { println("== KEYSET PAGINATION ==") val page1 = DemoProducts .selectAll() - .keysetPage(DemoProducts.id, lastValue = null, limit = 2) + .keysetPageAsc(DemoProducts.id, lastValue = null, limit = 2) .toList() println("page1:") @@ -77,7 +77,7 @@ fun main() { val page2 = DemoProducts .selectAll() - .keysetPage(DemoProducts.id, lastValue = lastSeenId, limit = 2) + .keysetPageAsc(DemoProducts.id, lastValue = lastSeenId, limit = 2) .toList() println("page2:") @@ -98,7 +98,7 @@ fun main() { } private fun seedDemoData() { - DemoProducts.insert { + DemoProducts.upsert { it[id] = 1 it[sku] = "BOOK-001" it[name] = "Kotlin in Action" @@ -106,7 +106,7 @@ private fun seedDemoData() { it[price] = BigDecimal("39.90") } - DemoProducts.insert { + DemoProducts.upsert { it[id] = 2 it[sku] = "BOOK-002" it[name] = "Distributed Systems" @@ -114,7 +114,7 @@ private fun seedDemoData() { it[price] = BigDecimal("42.50") } - DemoProducts.insert { + DemoProducts.upsert { it[id] = 3 it[sku] = "HW-001" it[name] = "Mechanical Keyboard" @@ -122,11 +122,11 @@ private fun seedDemoData() { it[price] = BigDecimal("129.99") } - DemoProducts.insert { + DemoProducts.upsert { it[id] = 4 it[sku] = "HW-002" it[name] = "USB-C Dock" it[category] = "hardware" it[price] = BigDecimal("89.00") } -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt index f34b0403..9ea23740 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt @@ -1,10 +1,5 @@ package tech.ydb.exposed.dialect.demo -import org.jetbrains.exposed.v1.core.SortOrder -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.greater -import org.jetbrains.exposed.v1.jdbc.Query -import org.jetbrains.exposed.v1.jdbc.andWhere import tech.ydb.exposed.dialect.basic.YdbIndexScope import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode import tech.ydb.exposed.dialect.basic.YdbTable @@ -20,10 +15,8 @@ object DemoProducts : YdbTable("demo_products") { override val primaryKey = PrimaryKey(id) init { - // Обычный Exposed index index(false, sku) - // YDB-specific secondary index secondaryIndex( name = "demo_products_category_idx", category, @@ -34,15 +27,3 @@ object DemoProducts : YdbTable("demo_products") { ) } } - -fun > Query.keysetPage( - column: org.jetbrains.exposed.v1.core.Column, - lastValue: T?, - limit: Int -): Query { - if (lastValue != null) { - andWhere { column greater lastValue } - } - - return orderBy(column to SortOrder.ASC).limit(limit) -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt index 64f7e406..d9354eed 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt @@ -1,13 +1,21 @@ package tech.ydb.exposed.dialect.functions import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.IColumnType import org.jetbrains.exposed.v1.core.Op +import org.jetbrains.exposed.v1.core.QueryAlias 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.FunctionProvider class YdbFunctionProvider : FunctionProvider() { + private companion object { + const val MERGE_UNSUPPORTED = + "YDB dialect does not support ANSI MERGE through Exposed. Use UPSERT or batchUpsert instead." + } + override fun random(seed: Int?): String = "Random()" override fun upsert( @@ -22,43 +30,58 @@ class YdbFunctionProvider : FunctionProvider() { require(where == null) { "YDB UPSERT does not support WHERE clause in this dialect implementation" } - require(onUpdate.isEmpty()) { - "YDB UPSERT does not use ON UPDATE clause in this dialect implementation" - } val columnList = data.joinToString(", ") { (column, _) -> transaction.identity(column) } + if (expression.isNotBlank()) { + val valuesExpression = expression.trim() + val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { + "($columnList) $valuesExpression" + } else { + valuesExpression + } + + return "UPSERT INTO ${transaction.identity(table)} $expressionWithColumns" + } + val valueList = data.joinToString(", ") { (column, value) -> valueToSqlLiteral(column, value) } - return buildString { - append("UPSERT INTO ") - append(transaction.identity(table)) - append(" (") - append(columnList) - append(") VALUES (") - append(valueList) - append(")") - } + return "UPSERT INTO ${transaction.identity(table)} ($columnList) VALUES ($valueList)" } + 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 { - return buildString { - if (size != null) { - append(" LIMIT ") - append(size) - } - if (offset > 0) { - append(" OFFSET ") - append(offset) - } + ): String = buildString { + if (size != null) { + append(" LIMIT ") + append(size) + } + if (offset > 0) { + append(" OFFSET ") + append(offset) } } @@ -66,68 +89,7 @@ class YdbFunctionProvider : FunctionProvider() { private fun valueToSqlLiteral(column: Column<*>, value: Any?): String { if (value == null) return "NULL" - val columnType = column.columnType as org.jetbrains.exposed.v1.core.IColumnType + val columnType = column.columnType as IColumnType return columnType.valueToString(value) } } - -//package tech.ydb.exposed.dialect.functions -// -//import org.jetbrains.exposed.v1.core.Column -//import org.jetbrains.exposed.v1.core.Op -//import org.jetbrains.exposed.v1.core.Table -//import org.jetbrains.exposed.v1.core.Transaction -//import org.jetbrains.exposed.v1.core.vendors.FunctionProvider -// -//class YdbFunctionProvider: FunctionProvider() { -// override fun random(seed: Int?): String = "Random()" -// -// override fun upsert( -// table: Table, -// data: List, Any?>>, -// expression: String, -// onUpdate: List, Any?>>, -// keyColumns: List>, -// where: Op?, -// transaction: Transaction -// ): String { -// val columns = data.map { it.first.name } -// -// val values = data.map { (_, value) -> -// when (value) { -// null -> "NULL" -// is String -> "'$value'" -// else -> value.toString() -// } -// } -// -// val columnList = columns.joinToString(", ") -// val valueList = values.joinToString(", ") -// -// return buildString { -// append("UPSERT INTO ") -// append(table.tableName) -// append(" (") -// append(columnList) -// append(") VALUES (") -// append(valueList) -// append(")") -// } -// } -// -// override fun queryLimitAndOffset( -// size: Int?, -// offset: Long, -// alreadyOrdered: Boolean -// ): String { -// return buildString { -// append(" LIMIT ") -// append(size) -// -// if (offset > 0) { -// append(" OFFSET ") -// append(offset) -// } -// } -// } -//} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt index 57b59d55..1d396d82 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt @@ -2,6 +2,7 @@ package tech.ydb.exposed.dialect.locking import org.jetbrains.exposed.v1.core.Column 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.core.statements.UpdateBuilder import org.jetbrains.exposed.v1.jdbc.select @@ -17,24 +18,25 @@ object YdbOptimisticLocking { expectedVersion: Int, body: (UpdateBuilder<*>) -> Unit ): Boolean { - val currentRow = table + val currentVersion = table .select(idColumn, versionColumn) .where { idColumn eq idValue } .singleOrNull() + ?.get(versionColumn) ?: return false - val currentVersion = currentRow[versionColumn] if (currentVersion != expectedVersion) { return false } - val updatedRows = table.update( - where = { idColumn eq idValue } + table.update( + where = { (idColumn eq idValue) and (versionColumn eq expectedVersion) } ) { stmt -> body(stmt) stmt[versionColumn] = expectedVersion + 1 } - return updatedRows == 1 + return true } -} \ No newline at end of file +} + diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt index 2b1ef10e..345d901d 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt @@ -10,7 +10,10 @@ class YdbDataTypeProvider : DataTypeProvider() { override fun shortType(): String = "Int16" override fun integerType(): String = "Int32" - override fun integerAutoincType(): String = "Int32" + override fun integerAutoincType(): String = + throw UnsupportedOperationException( + "YDB does not support AUTO_INCREMENT. Use YdbUuidIdTable, YdbUuidStringIdTable, or YdbUlidTable instead." + ) override fun longType(): String = "Int64" override fun booleanType(): String = "Bool" @@ -29,4 +32,4 @@ class YdbDataTypeProvider : DataTypeProvider() { override fun timestampType(): String = "Timestamp" override fun jsonType(): String = "Json" -} \ No newline at end of file +} 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 index ce62f6fb..bded757a 100644 --- 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 @@ -4,6 +4,7 @@ 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 @@ -80,4 +81,40 @@ class BatchOperationsIT : BaseYdbTest() { assertEquals(20, banana[BatchItems.quantity]) assertTrue(banana[BatchItems.name].startsWith("ban")) } -} \ No newline at end of file + + @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/dao/GeneratedIdsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt new file mode 100644 index 00000000..f9c5cb52 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt @@ -0,0 +1,51 @@ +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.assertNotNull +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbUlidTable +import tech.ydb.exposed.dialect.basic.YdbUuidStringIdTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import java.util.UUID + +class GeneratedIdsIT : BaseYdbTest() { + + object UuidItems : YdbUuidStringIdTable("generated_uuid_items") { + val name = varchar("name", 255) + } + + object UlidItems : YdbUlidTable("generated_ulid_items") { + val name = varchar("name", 255) + } + + override val tables: List
= listOf(UuidItems, UlidItems) + + @Test + fun `should generate uuid string id on insert`() = tx { + UuidItems.insert { + it[name] = "uuid-backed" + } + + val row = UuidItems.selectAll().single() + val id = row[UuidItems.id].value + + assertNotNull(UUID.fromString(id)) + assertEquals("uuid-backed", row[UuidItems.name]) + } + + @Test + fun `should generate ulid id on insert`() = tx { + UlidItems.insert { + it[name] = "ulid-backed" + } + + val row = UlidItems.selectAll().single() + val id = row[UlidItems.id].value + + assertEquals(26, id.length) + assertEquals("ulid-backed", row[UlidItems.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 index 5d77fb65..19a03b98 100644 --- 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 @@ -1,6 +1,7 @@ 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.basic.YdbDialect @@ -78,4 +79,16 @@ class IndexIT : BaseYdbTest() { assertTrue(sql.contains("ADD INDEX email_lookup_idx GLOBAL"), sql) assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) } -} \ No newline at end of file + + @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 }) + assertTrue("email_name_cover_idx" in byName.keys, indexes.joinToString { it.indexName }) + + assertEquals(listOf(Customers.email), byName.getValue("customers_email").columns) + assertEquals(listOf(Customers.email), byName.getValue("email_name_cover_idx").columns) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt index 7a2fddce..447cfacc 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt @@ -72,4 +72,117 @@ class OptimisticLockingIT : BaseYdbTest() { assertEquals("draft", row[Documents.title]) assertEquals(5, row[Documents.version]) } -} \ No newline at end of file + + @Test + fun `should reject stale update after version was changed`() = tx { + Documents.insert { + it[id] = 1 + it[title] = "draft" + it[version] = 0 + } + + val firstUpdate = YdbOptimisticLocking.updateWithVersion( + table = Documents, + idColumn = Documents.id, + idValue = 1, + versionColumn = Documents.version, + expectedVersion = 0 + ) { + it[Documents.title] = "published" + } + + val staleUpdate = YdbOptimisticLocking.updateWithVersion( + table = Documents, + idColumn = Documents.id, + idValue = 1, + versionColumn = Documents.version, + expectedVersion = 0 + ) { + it[Documents.title] = "overwritten" + } + + assertTrue(firstUpdate) + assertFalse(staleUpdate) + + val row = Documents.selectAll().single() + assertEquals("published", row[Documents.title]) + assertEquals(1, row[Documents.version]) + } +} + + +//package tech.ydb.exposed.dialect.integration.locking +// +//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.assertFalse +//import org.junit.jupiter.api.Assertions.assertTrue +//import org.junit.jupiter.api.Test +//import tech.ydb.exposed.dialect.basic.YdbTable +//import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +//import tech.ydb.exposed.dialect.locking.YdbOptimisticLocking +// +//class OptimisticLockingIT : BaseYdbTest() { +// +// object Documents : YdbTable("documents") { +// val id = integer("id") +// val title = varchar("title", 255) +// val version = integer("version") +// +// override val primaryKey = PrimaryKey(id) +// } +// +// override val tables: List
= listOf(Documents) +// +// @Test +// fun `should update row when expected version matches`() = tx { +// Documents.insert { +// it[id] = 1 +// it[title] = "draft" +// it[version] = 0 +// } +// +// val updated = YdbOptimisticLocking.updateWithVersion( +// table = Documents, +// idColumn = Documents.id, +// idValue = 1, +// versionColumn = Documents.version, +// expectedVersion = 0 +// ) { +// it[Documents.title] = "published" +// } +// +// assertTrue(updated) +// +// val row = Documents.selectAll().single() +// assertEquals("published", row[Documents.title]) +// assertEquals(1, row[Documents.version]) +// } +// +// @Test +// fun `should not update row when expected version does not match`() = tx { +// Documents.insert { +// it[id] = 1 +// it[title] = "draft" +// it[version] = 5 +// } +// +// val updated = YdbOptimisticLocking.updateWithVersion( +// table = Documents, +// idColumn = Documents.id, +// idValue = 1, +// versionColumn = Documents.version, +// expectedVersion = 0 +// ) { +// it[Documents.title] = "published" +// } +// +// assertFalse(updated) +// +// val row = Documents.selectAll().single() +// assertEquals("draft", row[Documents.title]) +// assertEquals(5, row[Documents.version]) +// } +//} \ No newline at end of file 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 index 7b580e90..ade1aed6 100644 --- 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 @@ -3,6 +3,7 @@ package tech.ydb.exposed.dialect.integration.upsert import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.upsert import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.basic.YdbTable @@ -44,4 +45,20 @@ class UpsertIT : BaseYdbTest() { val row = Products.selectAll().single() Assertions.assertEquals("Item1", row[Products.name]) } -} \ No newline at end of file + + @Test + fun `should perform UPSERT through Exposed DSL`() = tx { + Products.upsert { + it[id] = 1 + it[name] = "Item1" + } + + Products.upsert { + it[id] = 1 + it[name] = "Item2" + } + + val row = Products.selectAll().single() + Assertions.assertEquals("Item2", row[Products.name]) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt new file mode 100644 index 00000000..335efbd1 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt @@ -0,0 +1,34 @@ +package tech.ydb.exposed.dialect.unit.basic + +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.basic.YdbGeneratedIds +import java.util.UUID + +class YdbGeneratedIdsTest { + + @Test + fun `should generate uuid string`() { + val value = YdbGeneratedIds.uuidString() + + UUID.fromString(value) + assertEquals(36, value.length) + } + + @Test + fun `should generate ulid with stable length and alphabet`() { + val value = YdbGeneratedIds.ulid(nowMillis = 1_700_000_000_000) + + assertEquals(26, value.length) + assertTrue(value.all { it in "0123456789ABCDEFGHJKMNPQRSTVWXYZ" }) + } + + @Test + fun `should encode ulid timestamp in lexicographic prefix`() { + val older = YdbGeneratedIds.ulid(nowMillis = 1_700_000_000_000) + val newer = YdbGeneratedIds.ulid(nowMillis = 1_700_000_000_001) + + assertTrue(older.take(10) < newer.take(10)) + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt index 1a543b2f..93b023e7 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt @@ -3,6 +3,7 @@ package tech.ydb.exposed.dialect.unit.functions import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.transactions.transaction +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 @@ -15,6 +16,13 @@ object Users : Table("users") { override val primaryKey = PrimaryKey(id) } +object SourceUsers : Table("source_users") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) +} + class FunctionTests { private val provider = YdbFunctionProvider() @@ -83,20 +91,60 @@ class FunctionTests { } @Test - fun `should reject ON UPDATE in UPSERT`() { - assertThrows(IllegalArgumentException::class.java) { + fun `should ignore ON UPDATE in UPSERT`() { + val sql = transaction { + 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`() { + val sql = transaction { + 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`)")) + assertTrue(sql.contains("VALUES (?, ?)")) + } + + @Test + fun `should reject MERGE and point users to UPSERT`() { + val error = assertThrows(UnsupportedOperationException::class.java) { transaction { - 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 + 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 + ) } @Test @@ -117,4 +165,4 @@ class FunctionTests { val sql = provider.queryLimitAndOffset(size = null, offset = 5, alreadyOrdered = false) assertTrue(sql.contains("OFFSET 5")) } -} \ No newline at end of file +} 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 index 6096c677..d4e61429 100644 --- 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 @@ -52,8 +52,10 @@ class YdbDataTypeProviderTest { } @Test - fun `should map autoincrement type`() { - Assertions.assertEquals("Int32", provider.integerAutoincType()) + fun `should reject autoincrement type`() { + Assertions.assertThrows(UnsupportedOperationException::class.java) { + provider.integerAutoincType() + } } @Test @@ -61,4 +63,4 @@ class YdbDataTypeProviderTest { val hex = "0xABCD" Assertions.assertEquals("'0xABCD'", provider.hexToDb(hex)) } -} \ No newline at end of file +} From 1bd771044c66e2a5337f3fa88faa096601ede6ee Mon Sep 17 00:00:00 2001 From: s-markelova Date: Tue, 21 Apr 2026 02:02:23 +0300 Subject: [PATCH 17/43] edit documentation and format --- kotlin-exposed-dialect/CHANGELOG.md | 74 ++- kotlin-exposed-dialect/README.md | 466 ++++++++++-------- kotlin-exposed-dialect/pom.xml | 8 - .../dialect/pagination/KeysetPagination.kt | 10 +- .../dialect/transaction/YdbRetryClassifier.kt | 1 - .../dialect/integration/base/BaseYdbTest.kt | 43 +- .../dialect/integration/crud/CrudIT.kt | 1 - .../locking/OptimisticLockingIT.kt | 79 +-- .../dialect/integration/types/TypesIT.kt | 32 +- .../unit/basic/YdbDialectTtlSqlTest.kt | 1 - .../dialect/unit/basic/YdbTableTest.kt | 1 - .../dialect/unit/connection/ConnectionTest.kt | 17 - 12 files changed, 286 insertions(+), 447 deletions(-) delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 5382a399..306f310a 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -1,59 +1,49 @@ ## 0.1.0 -Initial Kotlin Exposed dialect implementation for YDB. +Initial release of the Kotlin Exposed dialect for YDB. ### Added -- Added YDB dialect registration for Exposed JDBC. -- Added YDB SQL function provider. -- Added YDB-compatible `LIMIT` generation. -- Added YDB-compatible `UPSERT` generation for Exposed DSL. -- Added explicit rejection for ANSI `MERGE` with guidance to use `UPSERT`. -- Added YDB DDL generation for tables with mandatory primary keys. -- Added support for YDB secondary indexes / global secondary indexes. -- Added TTL helpers and TTL SQL generation. -- Added YDB data type provider. -- Added YDB-specific column type helpers for: - - decimal values - - interval values - - JSON values - - UUID values - - unsigned integer values -- Added decimal literal helper for update-expression scenarios. -- Added UUID and ULID ID generation helpers. -- Added base table helpers for UUID/ULID identifiers. -- Added explicit rejection for `AUTO_INCREMENT`. -- Added retry classification for YDB retriable errors. -- Added transaction retry helper coverage. -- Added keyset pagination helper. -- Added optimistic locking helper based on a version column. -- Added metadata support for reading existing YDB indexes through JDBC metadata. -- Added console demo application with CRUD, UPSERT, indexes, decimal values, and keyset pagination. -- Added Docker-based integration test setup. -- Added GitHub Actions CI workflow. +- YDB dialect registration for Exposed JDBC. +- Connection helper based on `YdbDialectProvider`. +- YDB-specific data type provider. +- YDB SQL function provider. +- `LIMIT` / `OFFSET` SQL generation. +- Native YDB `UPSERT` generation for Exposed DSL. +- Explicit handling of unsupported ANSI `MERGE` scenarios. +- YDB-compatible `CREATE TABLE` generation with mandatory primary key. +- Secondary index generation, including global indexes and cover columns. +- TTL support for supported YDB column modes. +- JDBC metadata support for reading existing indexes. +- Custom column types for decimal, interval, JSON, UUID variants and unsigned integer values. +- Decimal literal helper for update-expression scenarios. +- UUID and ULID generation helpers. +- YDB table helpers for UUID/ULID/string identifiers. +- Explicit rejection of SQL `AUTO_INCREMENT`. +- Retry classifier for common YDB retriable failures. +- Read-only and read-write retrying transaction helpers. +- Keyset pagination helpers. +- Optimistic locking helper based on a version column. +- Console demo application with CRUD, UPSERT, indexes and pagination. +- Docker Compose configuration for local YDB. +- Unit and integration test suites. +- GitHub Actions workflow for build and integration tests. -### Tested +### Tested Scenarios -- Connection to local YDB. +- Connection to local YDB through JDBC. +- Table creation and DDL generation. - CRUD operations. - UPSERT through Exposed DSL. - Batch operations. -- DAO smoke workflow. -- Generated UUID/ULID identifiers. +- DAO basic workflow. +- Generated UUID and ULID identifiers. - Secondary indexes. - TTL. -- Numeric, binary, temporal, interval, decimal, UUID, unsigned integer, and JSON types. +- Numeric, binary, temporal, interval, decimal, UUID, unsigned integer and JSON types. - JOIN queries. - Subqueries. - Many-to-many relation through a join table. - Optimistic locking. - Keyset pagination. -- Integration scenario with several related tables. - -### Limitations - -- ANSI `MERGE` is not supported. -- SQL `AUTO_INCREMENT` is not supported. -- Unique secondary indexes are not treated as a supported portable feature. -- Foreign keys are not implemented as an enforced YDB constraint layer. -- Metadata support is focused on tested Exposed workflows rather than full schema introspection. +- Multi-table integration scenario. diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 190fd586..b081a9ed 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -1,370 +1,400 @@ -## Kotlin Exposed YDB Dialect +# Kotlin Exposed YDB Dialect -SQL dialect and JDBC integration layer that allows using [JetBrains Exposed](https://github.com/JetBrains/Exposed) DSL/DAO with [YDB](https://ydb.tech). +Модуль добавляет поддержку YDB для Kotlin Exposed через JDBC. Диалект описывает YDB-специфичную генерацию SQL и DDL, маппинг типов, работу с `UPSERT`, secondary indexes, TTL, транзакциями и вспомогательными сценариями Exposed DSL/DAO. -The module provides YDB-specific SQL generation, DDL support, type mappings, UPSERT support, transaction helpers, pagination helpers, tests, CI, and a small demo application. - -### Module Coordinates +Проект предназначен для использования в составе репозитория `ydb-java-dialects` как отдельный Maven-модуль: ```xml - - tech.ydb.dialects - kotlin-exposed-ydb-dialect - 0.1.0 - -``` - -The artifact is currently intended to be built from this repository: - -```bash -mvn clean install +tech.ydb.dialects +kotlin-exposed-ydb-dialect +0.1.0 ``` -### Requirements +## Требования -- JDK 17+ +- JDK 17 или новее - Maven -- Docker / Docker Compose for integration tests and local demo -- Local YDB instance for integration tests +- Docker и Docker Compose для интеграционных тестов и локального demo +- YDB JDBC Driver +- JetBrains Exposed 1.x -The module uses the YDB JDBC driver and Exposed 1.x APIs. - -### Quick Start - -Start local YDB: - -```bash -docker compose up -d -``` +## Подключение к YDB -Connect to YDB through the dialect provider: +Для подключения используется `YdbDialectProvider`. Он регистрирует JDBC-драйвер YDB, metadata provider и передаёт Exposed явный dialect. ```kotlin import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import tech.ydb.exposed.dialect.YdbDialectProvider +import tech.ydb.exposed.dialect.basic.YdbDialectProvider -val database = YdbDialectProvider.connect( +val db = YdbDialectProvider.connect( url = "jdbc:ydb:grpc://localhost:2136/local", user = "", password = "" ) -transaction(database) { +transaction(db) { // Exposed DSL / DAO code } ``` -### Table Example +По умолчанию используется JDBC driver: -YDB requires every table to have an explicit primary key. +```text +tech.ydb.jdbc.YdbDriver +``` + +## Пример таблицы + +YDB требует, чтобы у каждой таблицы был явно задан `PRIMARY KEY`. Для YDB-специфичных возможностей можно наследоваться от `YdbTable`. ```kotlin -import org.jetbrains.exposed.v1.core.Table -import tech.ydb.exposed.dialect.ddl.secondaryIndex +import tech.ydb.exposed.dialect.basic.YdbIndexScope +import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode +import tech.ydb.exposed.dialect.basic.YdbTable import tech.ydb.exposed.dialect.types.ydbDecimal -object Products : Table("products") { +object Products : YdbTable("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 = 22, scale = 9) + val price = ydbDecimal("price", precision = 10, scale = 2) override val primaryKey = PrimaryKey(id) init { index(isUnique = false, sku) - secondaryIndex("products_category_idx", category) + + secondaryIndex( + name = "products_category_idx", + category, + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.ASYNC, + coverColumns = listOf(name, price) + ) } } ``` +Такой класс генерирует YDB-compatible `CREATE TABLE` с primary key и secondary index. + +## Основные возможности + +### SQL и DDL + +Реализованы: + +- регистрация YDB dialect для Exposed JDBC; +- генерация YDB-compatible `LIMIT` / `OFFSET`; +- генерация `UPSERT`; +- генерация `CREATE TABLE` с обязательным `PRIMARY KEY`; +- создание и удаление secondary indexes; +- поддержка YDB global secondary indexes; +- поддержка `COVER` columns для secondary indexes; +- TTL для таблиц; +- чтение существующих индексов через JDBC metadata. + ### UPSERT -YDB has native `UPSERT`, and the dialect maps Exposed `upsert` calls to YDB-compatible SQL. +YDB имеет собственную команду `UPSERT`. Диалект формирует SQL в формате, который ожидает YDB, включая обязательный список колонок: + +```sql +UPSERT INTO products (id, sku, name) VALUES (?, ?, ?) +``` + +Пример через Exposed DSL: ```kotlin import org.jetbrains.exposed.v1.jdbc.upsert -import tech.ydb.exposed.dialect.types.ydbDecimalLiteral import java.math.BigDecimal Products.upsert { it[id] = 1 - it[sku] = "book-001" - it[name] = "YDB recipes" + it[sku] = "BOOK-001" + it[name] = "Kotlin in Action" it[category] = "books" - it[price] = ydbDecimalLiteral(BigDecimal("19.990000000")) + it[price] = BigDecimal("39.90") } ``` -YDB requires an explicit column list for `UPSERT INTO ... VALUES (...)`; the dialect generates it automatically for Exposed DSL calls. +### Типы данных -### Generated IDs +Поддерживаются стандартные и YDB-специфичные типы: -YDB does not support SQL `AUTO_INCREMENT` in the usual relational database sense. The dialect rejects `autoIncrement()` explicitly. +- `Int16`, `Int32`, `Int64`; +- `Uint64`; +- `Float`, `Double`; +- `Bool`; +- `Utf8`; +- `String` для бинарных данных; +- `Date`; +- `Datetime`; +- `Timestamp`; +- `Interval`; +- `Decimal(p, s)`; +- `Uuid`; +- UUID как `Utf8`; +- UUID как bytes / `String`; +- `Json`. -For application-side generated identifiers, use the provided helpers: +Для дополнительных типов доступны extension-функции: ```kotlin -import tech.ydb.exposed.dialect.basic.YdbUuidStringIdTable -import tech.ydb.exposed.dialect.basic.YdbUlidTable - -object Users : YdbUuidStringIdTable("users") { - val name = varchar("name", 255) -} - -object Events : YdbUlidTable("events") { - val payload = text("payload") -} +ydbDecimal("price", precision = 10, scale = 2) +ydbInterval("duration") +ydbJson("payload") +ydbUuid("id") +ydbUuidUtf8("external_id") +ydbUuidBytes("binary_uuid") +ydbUint64("counter") ``` -Available helpers include: +Для update-expression сценариев с decimal можно использовать literal helper: -- `YdbUuidIdTable` -- `YdbUuidStringIdTable` -- `YdbUlidTable` -- `YdbGeneratedIds.uuid()` -- `YdbGeneratedIds.uuidString()` -- `YdbGeneratedIds.ulid()` +```kotlin +import tech.ydb.exposed.dialect.types.ydbDecimalLiteral +import java.math.BigDecimal -### Type Mapping +it.update(Products.price, ydbDecimalLiteral(BigDecimal("45.00"), 10, 2)) +``` -The dialect provides YDB-aware mappings for common Exposed column types and additional YDB-specific helpers. +## Идентификаторы без AUTO_INCREMENT -Supported groups: +YDB не использует SQL `AUTO_INCREMENT` в привычном для реляционных СУБД виде. Диалект явно отклоняет `autoIncrement()` и предоставляет application-side генерацию идентификаторов. -- integer types: `Int16`, `Int32`, `Int64` -- unsigned integer helpers, including `Uint64` -- floating-point types: `Float`, `Double` -- `Bool` -- text/string values -- binary values -- `Date` -- `Datetime` -- `Timestamp` -- `Interval` -- `Decimal(p, s)` -- UUID: - - native YDB UUID representation - - UUID as UTF-8 string - - UUID as bytes -- JSON +Доступны: -For decimal columns and decimal update expressions, prefer the dialect helpers: +- `YdbGeneratedIds.uuid()`; +- `YdbGeneratedIds.uuidString()`; +- `YdbGeneratedIds.ulid()`; +- `YdbUuidIdTable`; +- `YdbUuidStringIdTable`; +- `YdbUlidTable`; +- `YdbStringIdTable`. -```kotlin -val price = ydbDecimal("price", precision = 22, scale = 9) -``` +Пример: ```kotlin -it[price] = ydbDecimalLiteral(BigDecimal("12.490000000")) +import tech.ydb.exposed.dialect.basic.YdbUlidTable + +object Events : YdbUlidTable("events") { + val payload = text("payload") +} ``` -### DDL Support +## Транзакции и retry -Supported DDL features: +Модуль работает со стандартными Exposed JDBC transactions и добавляет helper для повторного выполнения транзакций при retriable-ошибках YDB. -- `CREATE TABLE` -- mandatory `PRIMARY KEY` -- YDB-compatible column type generation -- secondary indexes / global secondary indexes -- TTL helpers -- `ALTER TABLE ... ADD INDEX` for supported secondary index scenarios +Поддерживаются режимы: -### TTL +- `READ_WRITE`; +- `READ_ONLY`. -The dialect includes helpers for YDB TTL expressions and table-level TTL settings. TTL support is covered by unit and integration tests for several supported YDB column modes. +Retry classifier обрабатывает типичные статусы и сообщения YDB, включая: -### Pagination +- `ABORTED`; +- `UNAVAILABLE`; +- `OVERLOADED`; +- `BAD_SESSION`; +- `SESSION_EXPIRED`; +- `SESSION_BUSY`; +- `TIMEOUT`; +- `UNDETERMINED`. -Standard Exposed `LIMIT` is supported. +Повторы выполняются с backoff и jitter. -The module also provides keyset pagination helpers for YDB-friendly pagination over ordered keys: +## Пагинация + +Обычная пагинация через `LIMIT` поддерживается на уровне dialect. + +Для больших таблиц также доступен keyset pagination helper: ```kotlin import tech.ydb.exposed.dialect.pagination.keysetPageAsc val page = Products .selectAll() - .orderBy(Products.id) .keysetPageAsc(Products.id, lastValue = null, limit = 20) .toList() ``` -### Transactions and Retries +Для обратного порядка используется `keysetPageDesc`. -The dialect works with standard Exposed JDBC transactions on top of the YDB JDBC driver. +## Optimistic Locking -The project also includes retry classification and retry helpers for typical YDB retriable failures, including abort and timeout-like scenarios. Retry behavior is covered by unit tests and integration smoke tests. +Для сценариев с версионированием строк добавлен helper `YdbOptimisticLocking.updateWithVersion`. Он проверяет текущую версию строки и выполняет update только если версия совпадает с ожидаемой. -### DSL and DAO Compatibility +Типовой сценарий: -The following Exposed scenarios are covered by tests: +- строка содержит колонку `version`; +- клиент передаёт ожидаемую версию; +- helper обновляет строку и увеличивает `version`; +- если версия устарела, update не выполняется. -- basic connection -- CRUD -- UPSERT -- batch operations -- DAO smoke workflow -- generated UUID/ULID identifiers -- joins -- subqueries -- many-to-many relation through a join table -- optimistic locking with a version column -- keyset pagination -- secondary indexes -- TTL -- YDB-specific types +## Совместимость с Exposed DSL / DAO -### MERGE Support +Интеграционные тесты покрывают следующие сценарии: -ANSI `MERGE` is not implemented by this dialect. +- подключение к YDB; +- CRUD через Exposed DSL; +- `UPSERT`; +- batch operations; +- DAO smoke workflow; +- generated UUID / ULID identifiers; +- secondary indexes; +- TTL; +- JOIN; +- подзапросы; +- many-to-many через связующую таблицу; +- optimistic locking; +- keyset pagination; +- работу с YDB-типами. -YDB has native `UPSERT`, but Exposed `MERGE` has broader conditional semantics. Translating Exposed `MERGE` to YDB `UPSERT` would be misleading, so the dialect explicitly rejects `MERGE` calls with an `UnsupportedOperationException`. +## Локальный запуск YDB -Use `upsert` or batch UPSERT-style operations instead. +В модуле есть `docker-compose.yml` для локальной YDB. -### Limitations +Запуск: -Important limitations: +```bash +docker compose up -d +``` -- Every YDB table must have an explicit primary key. -- `AUTO_INCREMENT` is not supported. Use application-side UUID/ULID generation. -- ANSI `MERGE` is not supported. Use `UPSERT`. -- Unique secondary indexes are not treated as a supported portable feature in this dialect. -- Foreign keys are not enforced as a primary modeling mechanism for YDB in this project. -- Schema metadata support is intentionally focused on the parts Exposed needs for tested workflows, especially table/index inspection. -- Some advanced YDB/YQL features are outside the current Exposed dialect surface. +YDB будет доступна по адресу: -### Running Tests +```text +jdbc:ydb:grpc://localhost:2136/local +``` -Start local YDB: +Web UI: -```bash -docker compose up -d +```text +http://localhost:8765 ``` -Run all unit and integration tests: +Остановка: ```bash -mvn clean install +docker compose down -v ``` -The build runs: - -- unit tests through Surefire -- integration tests through Failsafe -- packaging and local Maven installation +## Тесты -Stop local YDB: +Полная проверка: ```bash -docker compose down -v +mvn clean install ``` -### Demo Application +Команда запускает: -The demo application shows the dialect against a real local YDB instance. +- unit tests через Maven Surefire; +- integration tests через Maven Failsafe; +- сборку jar; +- установку artifact в локальный Maven repository. -It demonstrates: +Интеграционные тесты рассчитаны на локальную YDB, поднятую через Docker Compose. -- YDB connection through `YdbDialectProvider` -- table creation -- primary key requirement -- secondary index creation -- UPSERT seed data -- read queries -- update -- delete -- decimal values -- keyset pagination -- generated DDL output +## Demo Application -Start YDB: +Demo-приложение находится в package: -```bash -docker compose up -d +```text +tech.ydb.exposed.dialect.demo ``` -Run demo on Linux, macOS, or cmd: +Оно показывает: -```bash -mvn compile exec:java -Dexec.mainClass="tech.ydb.exposed.dialect.demo.DemoAppKt" -``` +- подключение к YDB; +- создание таблицы; +- генерацию DDL; +- secondary index с `COVER`; +- `UPSERT`; +- чтение данных; +- update decimal-поля; +- keyset pagination; +- delete. -Run demo in PowerShell: +Запуск в PowerShell: ```powershell -mvn --% compile exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt +mvn --% exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt ``` -By default, the demo uses: +Если классы ещё не скомпилированы: -```text -jdbc:ydb:grpc://localhost:2136/local +```powershell +mvn --% compile exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt ``` -You can override connection settings with environment variables: +Запуск в Linux, macOS или cmd: -- `YDB_JDBC_URL` -- `YDB_USER` -- `YDB_PASSWORD` +```bash +mvn exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt +``` -### CI +## CI -The repository contains a GitHub Actions workflow for this module. +Для модуля подготовлен GitHub Actions workflow. Он: -The CI workflow: +- запускается при изменениях в `kotlin-exposed-dialect`; +- поднимает локальную YDB через Docker Compose; +- ожидает доступности YDB; +- выполняет `mvn clean install`; +- выводит логи YDB при ошибке; +- останавливает контейнер после завершения. -- checks out the repository -- sets up JDK -- starts local YDB with Docker Compose -- waits until YDB is reachable -- runs `mvn clean install` -- prints YDB logs on failure -- stops and removes the local YDB container +## Структура модуля -### Project Structure +```text +src/main/kotlin/tech/ydb/exposed/dialect/basic +``` -Main areas: +Базовые классы dialect, registration/bootstrap, table helpers, generated IDs, TTL, secondary indexes и metadata. -- `src/main/kotlin/tech/ydb/exposed/dialect/basic` - Dialect registration, metadata, generated ID helpers, table helpers. +```text +src/main/kotlin/tech/ydb/exposed/dialect/functions +``` -- `src/main/kotlin/tech/ydb/exposed/dialect/functions` - SQL function provider, LIMIT, UPSERT, MERGE rejection. +Генерация SQL-конструкций dialect: `UPSERT`, `LIMIT`, обработка `MERGE`. -- `src/main/kotlin/tech/ydb/exposed/dialect/types` - YDB-specific column types and type helpers. +```text +src/main/kotlin/tech/ydb/exposed/dialect/types +``` -- `src/main/kotlin/tech/ydb/exposed/dialect/ddl` - YDB DDL extensions such as secondary indexes and TTL. +YDB data type provider и custom column types. -- `src/main/kotlin/tech/ydb/exposed/dialect/pagination` - Keyset pagination helpers. +```text +src/main/kotlin/tech/ydb/exposed/dialect/transaction +``` -- `src/main/kotlin/tech/ydb/exposed/dialect/transaction` - Retry classification and transaction helpers. +Retry classifier и transaction helpers. -- `src/main/kotlin/tech/ydb/exposed/dialect/demo` - Console demo application. +```text +src/main/kotlin/tech/ydb/exposed/dialect/pagination +``` -- `src/test/kotlin` - Unit and integration tests. +Keyset pagination helpers. -### Current Status +```text +src/main/kotlin/tech/ydb/exposed/dialect/locking +``` -The dialect is implemented as a working YDB integration for Exposed DSL/DAO within the tested feature set. +Optimistic locking helper. -Current test coverage includes: +```text +src/main/kotlin/tech/ydb/exposed/dialect/demo +``` -- 61 unit tests -- 48 integration tests +Консольное demo-приложение. -The latest local verification passed with: +## Особенности реализации -```text -Tests run: 61, Failures: 0, Errors: 0, Skipped: 0 -Tests run: 48, Failures: 0, Errors: 0, Skipped: 0 -BUILD SUCCESS -``` +- Для каждой таблицы требуется явный `PRIMARY KEY`. +- `AUTO_INCREMENT` не используется; вместо него предусмотрены UUID/ULID helpers. +- `UPSERT` реализован через native YDB syntax. +- ANSI `MERGE` не преобразуется в `UPSERT`, поскольку эти операции не являются полными эквивалентами. +- `UNIQUE` secondary indexes и `FOREIGN KEY` не используются как основной механизм моделирования в YDB в рамках данного dialect. +- Metadata layer покрывает сценарии, необходимые для реализованных и протестированных DDL/DSL workflow. diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 25c784d9..8a9411d2 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -57,7 +57,6 @@ org.jetbrains.kotlin kotlin-stdlib ${kotlin.version} - @@ -110,13 +109,6 @@ ${exposed.version} - - - - - - - diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt index 06292f4f..4f318474 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt @@ -8,15 +8,11 @@ import org.jetbrains.exposed.v1.jdbc.Query import org.jetbrains.exposed.v1.jdbc.andWhere /** - * Keyset pagination helper for YDB-oriented Exposed queries. - * * ASC: - * SELECT ... WHERE key > :lastValue ORDER BY key ASC LIMIT :limit - * + * SELECT ... WHERE key > :lastValue ORDER BY key ASC LIMIT :limit * DESC: - * SELECT ... WHERE key < :lastValue ORDER BY key DESC LIMIT :limit - * - * Intended for sortable columns, ideally primary key columns. + * SELECT ... WHERE key < :lastValue ORDER BY key DESC LIMIT :limit + * Intended for sortable columns, ideally primary key columns */ fun > Query.keysetPage( column: Column, diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt index b2e06e88..f734a188 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt @@ -1,6 +1,5 @@ package tech.ydb.exposed.dialect.transaction -import java.sql.SQLException import kotlin.math.min import kotlin.random.Random 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 index 7863df8b..37be59d8 100644 --- 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 @@ -65,45 +65,4 @@ abstract class BaseYdbTest { ) { block() } -} - -//package tech.ydb.exposed.dialect.integration.base -// -//import org.jetbrains.exposed.v1.jdbc.Database -//import org.jetbrains.exposed.v1.jdbc.JdbcTransaction -//import org.jetbrains.exposed.v1.jdbc.transactions.transaction -//import org.junit.jupiter.api.AfterEach -//import org.junit.jupiter.api.BeforeEach -//import tech.ydb.exposed.dialect.basic.YdbDialectProvider -// -//abstract class BaseYdbTest { -// -// protected lateinit var db: Database -// -// @BeforeEach -// fun setupDatabase() { -// -// db = YdbDialectProvider.connect( -// url = "jdbc:ydb:grpc://localhost:2136/local", -// driver = "tech.ydb.jdbc.YdbDriver" -// ) -// } -// -// @BeforeEach -// fun setup() = transaction(db) { -// // Можно очищать схемы, если нужно -// } -// -// @AfterEach -// fun teardown() = transaction(db) { -// // Очистка таблиц после тестов -// } -// -//// protected fun tx(block: () -> Unit) = -//// YdbTransactionManager.transactionWithRetry(db) { -//// block() -//// } -// -// // Утилита для упрощения вызова transaction -// protected fun tx(block: JdbcTransaction.() -> Unit) = transaction(db) { block() } -//} \ No newline at end of file +} \ No newline at end of file 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 index a283b260..ae302688 100644 --- 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 @@ -2,7 +2,6 @@ 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.SchemaUtils import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt index 447cfacc..4f0d4948 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt @@ -108,81 +108,4 @@ class OptimisticLockingIT : BaseYdbTest() { assertEquals("published", row[Documents.title]) assertEquals(1, row[Documents.version]) } -} - - -//package tech.ydb.exposed.dialect.integration.locking -// -//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.assertFalse -//import org.junit.jupiter.api.Assertions.assertTrue -//import org.junit.jupiter.api.Test -//import tech.ydb.exposed.dialect.basic.YdbTable -//import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -//import tech.ydb.exposed.dialect.locking.YdbOptimisticLocking -// -//class OptimisticLockingIT : BaseYdbTest() { -// -// object Documents : YdbTable("documents") { -// val id = integer("id") -// val title = varchar("title", 255) -// val version = integer("version") -// -// override val primaryKey = PrimaryKey(id) -// } -// -// override val tables: List
= listOf(Documents) -// -// @Test -// fun `should update row when expected version matches`() = tx { -// Documents.insert { -// it[id] = 1 -// it[title] = "draft" -// it[version] = 0 -// } -// -// val updated = YdbOptimisticLocking.updateWithVersion( -// table = Documents, -// idColumn = Documents.id, -// idValue = 1, -// versionColumn = Documents.version, -// expectedVersion = 0 -// ) { -// it[Documents.title] = "published" -// } -// -// assertTrue(updated) -// -// val row = Documents.selectAll().single() -// assertEquals("published", row[Documents.title]) -// assertEquals(1, row[Documents.version]) -// } -// -// @Test -// fun `should not update row when expected version does not match`() = tx { -// Documents.insert { -// it[id] = 1 -// it[title] = "draft" -// it[version] = 5 -// } -// -// val updated = YdbOptimisticLocking.updateWithVersion( -// table = Documents, -// idColumn = Documents.id, -// idValue = 1, -// versionColumn = Documents.version, -// expectedVersion = 0 -// ) { -// it[Documents.title] = "published" -// } -// -// assertFalse(updated) -// -// val row = Documents.selectAll().single() -// assertEquals("draft", row[Documents.title]) -// assertEquals(5, row[Documents.version]) -// } -//} \ No newline at end of file +} \ No newline at end of file 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 index 8fae5e61..64bb255e 100644 --- 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 @@ -67,34 +67,4 @@ class TypesIT : BaseYdbTest() { assertTrue(ddl.contains("text_col Utf8")) assertTrue(ddl.contains("PRIMARY KEY (id)")) } -} - -//package tech.ydb.exposed.dialect.integration.types -// -//import org.jetbrains.exposed.v1.core.Table -//import org.jetbrains.exposed.v1.jdbc.transactions.transaction -//import org.junit.jupiter.api.Assertions -//import org.junit.jupiter.api.Test -//import tech.ydb.exposed.dialect.basic.YdbTable -// -//class TypesIT { -// -// object TestTable : YdbTable("test_types") { -// val id = integer("id") -// val text = varchar("text", 255) -// -// override val primaryKey = PrimaryKey(id) -// } -// -// @Test -// fun `should map types correctly`() { -// -// transaction { -// -// val ddl = TestTable.ddl.joinToString(" ") -// -// Assertions.assertTrue(ddl.contains("Int32")) -// Assertions.assertTrue(ddl.contains("Utf8") || ddl.contains("String")) -// } -// } -//} \ No newline at end of file +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt index 3667de7b..35483473 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt @@ -1,6 +1,5 @@ package tech.ydb.exposed.dialect.unit.basic -import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.transactions.transaction diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt index a1f49106..74e31989 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt @@ -1,6 +1,5 @@ package tech.ydb.exposed.dialect.unit.basic -import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.transactions.transaction diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt deleted file mode 100644 index dac23588..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/connection/ConnectionTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -//package tech.ydb.exposed.dialect.unit.connection -// -//import org.junit.jupiter.api.Assertions -//import org.junit.jupiter.api.Test -//import tech.ydb.exposed.dialect.basic.YdbDialectProvider -// -//class ConnectionTest { -// @Test -// fun `should connect to YDB`() { -// val db = YdbDialectProvider.connect( -// url = "jdbc:ydb:grpc://localhost:2136/local", -// driver = "tech.ydb.jdbc.YdbDriver" -// ) -// -// Assertions.assertNotNull(db) -// } -//} \ No newline at end of file From 7b107baad3fcddb8cb1db4079ff78a3cf0e3a6d4 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Tue, 21 Apr 2026 14:37:11 +0300 Subject: [PATCH 18/43] edit readme --- kotlin-exposed-dialect/README.md | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index b081a9ed..edd97caa 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -2,14 +2,6 @@ Модуль добавляет поддержку YDB для Kotlin Exposed через JDBC. Диалект описывает YDB-специфичную генерацию SQL и DDL, маппинг типов, работу с `UPSERT`, secondary indexes, TTL, транзакциями и вспомогательными сценариями Exposed DSL/DAO. -Проект предназначен для использования в составе репозитория `ydb-java-dialects` как отдельный Maven-модуль: - -```xml -tech.ydb.dialects -kotlin-exposed-ydb-dialect -0.1.0 -``` - ## Требования - JDK 17 или новее @@ -290,10 +282,8 @@ mvn clean install Команда запускает: -- unit tests через Maven Surefire; -- integration tests через Maven Failsafe; -- сборку jar; -- установку artifact в локальный Maven repository. +- unit tests; +- integration tests; Интеграционные тесты рассчитаны на локальную YDB, поднятую через Docker Compose. @@ -337,14 +327,7 @@ mvn exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt ## CI -Для модуля подготовлен GitHub Actions workflow. Он: - -- запускается при изменениях в `kotlin-exposed-dialect`; -- поднимает локальную YDB через Docker Compose; -- ожидает доступности YDB; -- выполняет `mvn clean install`; -- выводит логи YDB при ошибке; -- останавливает контейнер после завершения. +Для модуля подготовлен GitHub Actions workflow. ## Структура модуля @@ -397,4 +380,3 @@ src/main/kotlin/tech/ydb/exposed/dialect/demo - `UPSERT` реализован через native YDB syntax. - ANSI `MERGE` не преобразуется в `UPSERT`, поскольку эти операции не являются полными эквивалентами. - `UNIQUE` secondary indexes и `FOREIGN KEY` не используются как основной механизм моделирования в YDB в рамках данного dialect. -- Metadata layer покрывает сценарии, необходимые для реализованных и протестированных DDL/DSL workflow. From 5ca51fa07124df3a69c385ba27c12e08c86b4e73 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Wed, 22 Apr 2026 20:51:11 +0300 Subject: [PATCH 19/43] change directory for demo --- kotlin-exposed-dialect/example/pom.xml | 64 +++++++++++++++++++ .../ydb/exposed/dialect/example}/DemoApp.kt | 2 +- .../exposed/dialect/example}/DemoTables.kt | 2 +- 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 kotlin-exposed-dialect/example/pom.xml rename kotlin-exposed-dialect/{src/main/kotlin/tech/ydb/exposed/dialect/demo => example/src/main/kotlin/tech/ydb/exposed/dialect/example}/DemoApp.kt (98%) rename kotlin-exposed-dialect/{src/main/kotlin/tech/ydb/exposed/dialect/demo => example/src/main/kotlin/tech/ydb/exposed/dialect/example}/DemoTables.kt (95%) diff --git a/kotlin-exposed-dialect/example/pom.xml b/kotlin-exposed-dialect/example/pom.xml new file mode 100644 index 00000000..5747f893 --- /dev/null +++ b/kotlin-exposed-dialect/example/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + tech.ydb.dialects + kotlin-exposed-ydb-dialect-example + 0.1.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.1.0 + + + + + + + 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/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt b/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoApp.kt similarity index 98% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt rename to kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoApp.kt index f06f07fa..0bcd5a7e 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoApp.kt +++ b/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoApp.kt @@ -1,4 +1,4 @@ -package tech.ydb.exposed.dialect.demo +package tech.ydb.exposed.dialect.example import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.SchemaUtils diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt b/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoTables.kt similarity index 95% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt rename to kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoTables.kt index 9ea23740..a9e1158e 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/demo/DemoTables.kt +++ b/kotlin-exposed-dialect/example/src/main/kotlin/tech/ydb/exposed/dialect/example/DemoTables.kt @@ -1,4 +1,4 @@ -package tech.ydb.exposed.dialect.demo +package tech.ydb.exposed.dialect.example import tech.ydb.exposed.dialect.basic.YdbIndexScope import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode From 34434590c4b3e785a2b3a78b6c2e988788412d9d Mon Sep 17 00:00:00 2001 From: s-markelova Date: Thu, 23 Apr 2026 11:16:18 +0300 Subject: [PATCH 20/43] edit main readme and pom.xml --- kotlin-exposed-dialect/README.md | 30 +++++++++++++++++++++--------- kotlin-exposed-dialect/pom.xml | 6 ------ 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index edd97caa..56084439 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -6,7 +6,7 @@ - JDK 17 или новее - Maven -- Docker и Docker Compose для интеграционных тестов и локального demo +- Docker и Docker Compose для интеграционных тестов и локального example-приложения - YDB JDBC Driver - JetBrains Exposed 1.x @@ -289,13 +289,14 @@ mvn clean install ## Demo Application -Demo-приложение находится в package: +Demo-приложение вынесено в отдельный модуль:: ```text -tech.ydb.exposed.dialect.demo +example ``` -Оно показывает: +Он не входит в библиотечный jar и предназначен для демонстрации использования dialect. +Приложение показывает: - подключение к YDB; - создание таблицы; @@ -307,22 +308,33 @@ tech.ydb.exposed.dialect.demo - keyset pagination; - delete. +Перед запуском example-модуля библиотеку нужно установить в локальный Maven repository: + +```powershell +mvn clean install +``` + +После этого example можно запускать отдельно. + Запуск в PowerShell: ```powershell -mvn --% exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt +cd example +mvn --% exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.example.DemoAppKt ``` Если классы ещё не скомпилированы: ```powershell -mvn --% compile exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt +cd example +mvn --% compile exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.example.DemoAppKt ``` Запуск в Linux, macOS или cmd: ```bash -mvn exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.demo.DemoAppKt +cd example +mvn exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.example.DemoAppKt ``` ## CI @@ -368,10 +380,10 @@ src/main/kotlin/tech/ydb/exposed/dialect/locking Optimistic locking helper. ```text -src/main/kotlin/tech/ydb/exposed/dialect/demo +example/src/main/kotlin/tech/ydb/exposed/dialect/example ``` -Консольное demo-приложение. +Отдельное demo-приложение. ## Особенности реализации diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 8a9411d2..00b2dba8 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -189,12 +189,6 @@ - - org.codehaus.mojo - exec-maven-plugin - 3.5.0 - - \ No newline at end of file From 82aa8560389927e4674d24906df34a954e404954 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Thu, 23 Apr 2026 22:39:52 +0300 Subject: [PATCH 21/43] fix some pull requests comments --- kotlin-exposed-dialect/CHANGELOG.md | 2 +- kotlin-exposed-dialect/README.md | 33 +++- .../ydb/exposed/dialect/basic/YdbDialect.kt | 10 +- .../dialect/basic/YdbSecondaryIndex.kt | 10 +- .../transaction/YdbRetryingTransaction.kt | 7 +- .../dialect/types/YdbCustomColumnTypes.kt | 13 +- .../dialect/types/YdbDecimalExpressions.kt | 5 +- .../dialect/integration/base/BaseYdbTest.kt | 1 - .../dialect/integration/ddl/UniqueIndexIT.kt | 70 +++++++++ .../functions/FunctionProviderIT.kt | 148 ++++++++++++++++++ .../dialect/unit/basic/YdbIndexSqlTest.kt | 24 ++- .../unit/basic/YdbUniqueIndexSqlTest.kt | 71 +++++++++ .../basic/YdbUniqueIndexUnsupportedTest.kt | 64 -------- .../dialect/unit/functions/FunctionTest.kt | 142 +---------------- .../dialect/unit/types/Uint64TypesTest.kt | 40 ----- .../unit/types/YdbDecimalColumnTypeTest.kt | 23 ++- .../unit/types/YdbDecimalLiteralTest.kt | 44 ++++++ 17 files changed, 441 insertions(+), 266 deletions(-) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/UniqueIndexIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/FunctionProviderIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexUnsupportedTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/Uint64TypesTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalLiteralTest.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 306f310a..5436a14a 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -12,7 +12,7 @@ Initial release of the Kotlin Exposed dialect for YDB. - Native YDB `UPSERT` generation for Exposed DSL. - Explicit handling of unsupported ANSI `MERGE` scenarios. - YDB-compatible `CREATE TABLE` generation with mandatory primary key. -- Secondary index generation, including global indexes and cover columns. +- Secondary index generation, including global indexes, unique indexes and cover columns. - TTL support for supported YDB column modes. - JDBC metadata support for reading existing indexes. - Custom column types for decimal, interval, JSON, UUID variants and unsigned integer values. diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 56084439..224b26c3 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -65,6 +65,14 @@ object Products : YdbTable("products") { syncMode = YdbIndexSyncMode.ASYNC, coverColumns = listOf(name, price) ) + + secondaryIndex( + name = "products_sku_unique_idx", + sku, + unique = true, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) } } ``` @@ -83,6 +91,7 @@ object Products : YdbTable("products") { - генерация `CREATE TABLE` с обязательным `PRIMARY KEY`; - создание и удаление secondary indexes; - поддержка YDB global secondary indexes; +- поддержка `UNIQUE` secondary indexes; - поддержка `COVER` columns для secondary indexes; - TTL для таблиц; - чтение существующих индексов через JDBC metadata. @@ -109,7 +118,26 @@ Products.upsert { it[price] = BigDecimal("39.90") } ``` - +### Secondary indexes + +Диалект поддерживает два способа объявления индекса: + +- стандартный Exposed API: + ```kotlin + index(isUnique = false, sku) + ``` +- YDB-специфичный API: + ```kotlin + secondaryIndex( + name = "products_category_idx", + category, + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.ASYNC, + coverColumns = listOf(name, price) + ) + ``` + ### Типы данных Поддерживаются стандартные и YDB-специфичные типы: @@ -391,4 +419,5 @@ example/src/main/kotlin/tech/ydb/exposed/dialect/example - `AUTO_INCREMENT` не используется; вместо него предусмотрены UUID/ULID helpers. - `UPSERT` реализован через native YDB syntax. - ANSI `MERGE` не преобразуется в `UPSERT`, поскольку эти операции не являются полными эквивалентами. -- `UNIQUE` secondary indexes и `FOREIGN KEY` не используются как основной механизм моделирования в YDB в рамках данного dialect. +- `UNIQUE` secondary indexes поддерживаются на уровне генерации DDL. Такие индексы используются для проверки уникальности значений; при нарушении уникальности YDB возвращает ошибку выполнения операции. +- `FOREIGN KEY` не используется как основной механизм моделирования в YDB в рамках данного dialect. \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index fc3c8b8b..f7cd55ac 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -21,16 +21,16 @@ class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvid val tableName = tr?.identity(index.table) ?: index.table.tableName val unique = index.extractUniqueFlag() - require(!unique) { - "UNIQUE secondary indexes are not supported by the current YDB runtime used by this dialect" - } - return buildString { append("ALTER TABLE ") append(tableName) append(" ADD INDEX ") append(indexName) - append(" GLOBAL ON (") + append(" GLOBAL") + if (unique) { + append(" UNIQUE") + } + append(" ON (") append(columns) append(")") } diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt index 6a602728..f5f2340c 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt @@ -34,10 +34,6 @@ internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { "Only GLOBAL secondary indexes are supported by YDB row-oriented tables in this dialect" } - require(!spec.unique) { - "UNIQUE secondary indexes are not supported by the current YDB runtime used by this dialect" - } - val columnsSql = spec.columns.joinToString(", ") { tr.identity(it) } val coverSql = spec.coverColumns .takeIf { it.isNotEmpty() } @@ -54,6 +50,10 @@ internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { append(" ") append(spec.scope.name) + if (spec.unique) { + append(" UNIQUE") + } + if (spec.syncMode != YdbIndexSyncMode.SYNC) { append(" ") append(spec.syncMode.name) @@ -86,4 +86,4 @@ private fun renderYdbIndexParamValue(value: Any): String = when (value) { is Number -> value.toString() is Boolean -> value.toString().uppercase() else -> "\"${value.toString().replace("\"", "\\\"")}\"" -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt index 39ac42be..08f3c7bb 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt @@ -48,7 +48,12 @@ object YdbRetryingTransactions { val sleepMs = YdbRetryClassifier.backoffMillis(decision.backoffKind, attempt) if (sleepMs > 0) { - Thread.sleep(sleepMs) + try { + Thread.sleep(sleepMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } } } } diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt index 19ca6834..8c2f8d60 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt @@ -26,10 +26,17 @@ class YdbDecimalColumnType( } override fun notNullValueToDB(value: BigDecimal): Any = - value.setScale(scale) + normalizeScale(value) override fun nonNullValueToString(value: BigDecimal): String = - value.setScale(scale).toPlainString() + normalizeScale(value).toPlainString() + + 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) + } } class YdbIntervalColumnType : ColumnType() { @@ -146,4 +153,4 @@ fun Table.ydbUuid(name: String): Column = registerColumn(name, YdbUuidColumnType()) fun Table.ydbUint64(name: String): Column = - registerColumn(name, YdbUint64ColumnType()) \ No newline at end of file + registerColumn(name, YdbUint64ColumnType()) diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt index 7c543ff4..27139598 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt @@ -11,6 +11,9 @@ class YdbDecimalLiteral( ) : 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)""") } @@ -20,4 +23,4 @@ fun ydbDecimalLiteral( value: BigDecimal, precision: Int, scale: Int -): Expression = YdbDecimalLiteral(value, precision, scale) \ No newline at end of file +): Expression = YdbDecimalLiteral(value, precision, scale) 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 index 37be59d8..4f949da3 100644 --- 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 @@ -48,7 +48,6 @@ abstract class BaseYdbTest { ) { if (tables.isNotEmpty()) { runCatching { SchemaUtils.drop(*tables.toTypedArray()) } - SchemaUtils.create(*tables.toTypedArray()) } } 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..a6b229dd --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/UniqueIndexIT.kt @@ -0,0 +1,70 @@ +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.basic.YdbIndexScope +import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class UniqueIndexIT : BaseYdbTest() { + + object UniqueCustomers : YdbTable("unique_customers") { + val id = integer("id") + val email = varchar("email", 255) + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + + init { + secondaryIndex( + name = "unique_email_idx", + email, + unique = true, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) + } + } + + override val tables: List
= listOf(UniqueCustomers) + + @Test + fun `should reject duplicate value for unique secondary 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..7571dfb9 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/FunctionProviderIT.kt @@ -0,0 +1,148 @@ +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.functions.YdbFunctionProvider +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) + } + + object SourceUsers : Table("source_users") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + } + + 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(IllegalArgumentException::class.java) { + tx { + provider.upsert( + table = Users, + data = listOf(Users.id to 1, Users.name to "Alice"), + expression = "", + onUpdate = emptyList(), + keyColumns = emptyList(), + 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/unit/basic/YdbIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt index 3c2fe52e..b347485a 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt @@ -94,4 +94,26 @@ class YdbIndexSqlTest { assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) } } -} \ No newline at end of file + + @Test + fun `should render unique ydb specific alter table secondary index sql`() { + transaction(db) { + val dialect = db.dialect as YdbDialect + + val sql = dialect.createSecondaryIndex( + table = IndexedTable, + spec = YdbSecondaryIndexSpec( + name = "email_unique_lookup_idx", + columns = listOf(IndexedTable.email), + unique = true, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) + ) + + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("ADD INDEX email_unique_lookup_idx GLOBAL UNIQUE"), sql) + assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) + } + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt new file mode 100644 index 00000000..9fa5670a --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt @@ -0,0 +1,71 @@ +package tech.ydb.exposed.dialect.unit.basic + +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.basic.YdbDialect +import tech.ydb.exposed.dialect.basic.YdbDialectProvider +import tech.ydb.exposed.dialect.basic.YdbSecondaryIndexSpec +import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.basic.renderYdbSecondaryIndex + +class YdbUniqueIndexSqlTest { + + companion object { + private lateinit var db: Database + + @JvmStatic + @BeforeAll + fun setupDb() { + db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver" + ) + } + } + + object T : YdbTable("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 `should render unique standard exposed index`() { + transaction(db) { + 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 `should render unique ydb secondary index`() { + transaction(db) { + val sql = renderYdbSecondaryIndex( + YdbSecondaryIndexSpec( + name = "email_unique_idx", + columns = listOf(T.email), + unique = true + ) + ) + + assertTrue(sql.contains("INDEX email_unique_idx GLOBAL UNIQUE"), sql) + assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) + } + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexUnsupportedTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexUnsupportedTest.kt deleted file mode 100644 index 3aa71bbf..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexUnsupportedTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package tech.ydb.exposed.dialect.unit.basic - -import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.junit.jupiter.api.Assertions.assertThrows -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.* - -class YdbUniqueIndexUnsupportedTest { - - companion object { - private lateinit var db: Database - - @JvmStatic - @BeforeAll - fun setupDb() { - db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - driver = "tech.ydb.jdbc.YdbDriver" - ) - } - } - - object T : YdbTable("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 `should reject unique standard index for current ydb runtime`() { - transaction(db) { - val dialect = db.dialect as YdbDialect - - assertThrows(IllegalArgumentException::class.java) { - dialect.createIndex(T.emailIndexDefinition) - } - } - } - - @Test - fun `should reject unique ydb secondary index for current runtime`() { - transaction(db) { - assertThrows(IllegalArgumentException::class.java) { - renderYdbSecondaryIndex( - YdbSecondaryIndexSpec( - name = "email_unique_idx", - columns = listOf(T.email), - unique = true - ) - ) - } - } - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt index 93b023e7..9788c94e 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt @@ -1,152 +1,12 @@ package tech.ydb.exposed.dialect.unit.functions -import org.jetbrains.exposed.v1.core.eq -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -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.functions.YdbFunctionProvider -object Users : Table("users") { - val id = integer("id") - val name = varchar("name", 255) - - override val primaryKey = PrimaryKey(id) -} - -object SourceUsers : Table("source_users") { - val id = integer("id") - val name = varchar("name", 255) - - override val primaryKey = PrimaryKey(id) -} - -class FunctionTests { +class FunctionTest { private val provider = YdbFunctionProvider() - @Test - fun `should generate UPSERT statement`() { - val data = listOf( - Users.id to 1, - Users.name to "Alice" - ) - - val sql = transaction { - 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`() { - val data = listOf( - Users.id to 1, - Users.name to null - ) - - val sql = transaction { - 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(IllegalArgumentException::class.java) { - transaction { - provider.upsert( - table = Users, - data = listOf(Users.id to 1, Users.name to "Alice"), - expression = "", - onUpdate = emptyList(), - keyColumns = emptyList(), - where = Users.id eq 1, - transaction = this - ) - } - } - } - - @Test - fun `should ignore ON UPDATE in UPSERT`() { - val sql = transaction { - 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`() { - val sql = transaction { - 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`)")) - assertTrue(sql.contains("VALUES (?, ?)")) - } - - @Test - fun `should reject MERGE and point users to UPSERT`() { - val error = assertThrows(UnsupportedOperationException::class.java) { - transaction { - 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 - ) - } - @Test fun `should generate limit only`() { val sql = provider.queryLimitAndOffset(size = 10, offset = 0, alreadyOrdered = false) diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/Uint64TypesTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/Uint64TypesTest.kt deleted file mode 100644 index 07230dc1..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/Uint64TypesTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -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.basic.YdbTable -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbUint64 - -class Uint64TypesTest : BaseYdbTest() { - - object Uint64Types : YdbTable("uint64_types") { - val id = integer("id") - val valueCol = ydbUint64("value_col") - - override val primaryKey = PrimaryKey(id) - } - - 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/unit/types/YdbDecimalColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbDecimalColumnTypeTest.kt index 2ac72b7b..edf4eae3 100644 --- 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 @@ -51,4 +51,25 @@ class YdbDecimalColumnTypeTest { assertEquals(BigDecimal("123.40"), type.notNullValueToDB(BigDecimal("123.4"))) assertEquals("123.40", type.nonNullValueToString(BigDecimal("123.4"))) } -} \ No newline at end of file + + @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..afb2e208 --- /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.types.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 + ) + } +} From 4376883a54ac2336ab592ad30f352b161ebc16ac Mon Sep 17 00:00:00 2001 From: s-markelova Date: Thu, 23 Apr 2026 22:42:53 +0300 Subject: [PATCH 22/43] fix ci tests --- .github/workflows/ci-exposed-ydb-dialect.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-exposed-ydb-dialect.yaml b/.github/workflows/ci-exposed-ydb-dialect.yaml index 7980da20..f315ad71 100644 --- a/.github/workflows/ci-exposed-ydb-dialect.yaml +++ b/.github/workflows/ci-exposed-ydb-dialect.yaml @@ -5,11 +5,11 @@ on: branches: [ main, master ] paths: - "kotlin-exposed-dialect/**" - - ".github/workflows/ci-exposed-ydb-dialect.yml" + - ".github/workflows/ci-exposed-ydb-dialect.yaml" pull_request: paths: - "kotlin-exposed-dialect/**" - - ".github/workflows/ci-exposed-ydb-dialect.yml" + - ".github/workflows/ci-exposed-ydb-dialect.yaml" jobs: build-and-test: From 951c047b392119079239aa65ad54f690aa760405 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Fri, 24 Apr 2026 03:11:12 +0300 Subject: [PATCH 23/43] fixes after fork comparison --- .../ydb/exposed/dialect/basic/YdbDialect.kt | 5 ++- .../dialect/types/YdbDataTypeProvider.kt | 11 ++++++ .../dialect/unit/basic/YdbIndexSqlTest.kt | 35 +++++++++++++++++++ .../unit/basic/YdbUniqueIndexSqlTest.kt | 22 ++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt index f7cd55ac..750da125 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt @@ -12,12 +12,15 @@ class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvid 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 = index.indexName + val indexName = tr?.db?.identifierManager?.cutIfNecessaryAndQuote(index.indexName) ?: index.indexName val tableName = tr?.identity(index.table) ?: index.table.tableName val unique = index.extractUniqueFlag() diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt index 345d901d..fa9ad7c2 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt @@ -3,13 +3,22 @@ package tech.ydb.exposed.dialect.types import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider class YdbDataTypeProvider : DataTypeProvider() { + override fun byteType(): String = "Int8" + override fun ubyteType(): String = "Uint8" + override fun binaryType(): String = "String" override fun binaryType(length: Int): String = "String" + override fun blobType(): String = binaryType() + override fun hexToDb(hexString: String): String = "'$hexString'" override fun shortType(): String = "Int16" + override fun ushortType(): String = "Uint16" + override fun integerType(): String = "Int32" + override fun uintegerType(): String = "Uint32" + override fun integerAutoincType(): String = throw UnsupportedOperationException( "YDB does not support AUTO_INCREMENT. Use YdbUuidIdTable, YdbUuidStringIdTable, or YdbUlidTable instead." @@ -24,6 +33,8 @@ class YdbDataTypeProvider : DataTypeProvider() { override fun varcharType(colLength: Int): String = "Utf8" override fun textType(): String = "Utf8" //"String" + override fun mediumTextType(): String = textType() + override fun largeTextType(): String = textType() override fun uuidType(): String = "Uuid" diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt index b347485a..3ab493da 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt @@ -1,7 +1,11 @@ package tech.ydb.exposed.dialect.unit.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.jdbc.Database 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.BeforeAll import org.junit.jupiter.api.Test @@ -60,6 +64,37 @@ class YdbIndexSqlTest { } } + @Test + fun `should reject 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 `should render ydb specific inline secondary index`() { transaction(db) { diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt index 9fa5670a..a094eb95 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt @@ -1,5 +1,6 @@ package tech.ydb.exposed.dialect.unit.basic +import org.jetbrains.exposed.v1.core.Index import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.Assertions.assertTrue @@ -53,6 +54,27 @@ class YdbUniqueIndexSqlTest { } } + @Test + fun `should quote custom index name through identifier manager`() { + transaction(db) { + 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) + } + } + @Test fun `should render unique ydb secondary index`() { transaction(db) { From 8d69045bfc998cb7de94d3689314c2c512e127be Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 11 May 2026 01:30:15 +0300 Subject: [PATCH 24/43] fixes after code review --- kotlin-exposed-dialect/pom.xml | 10 +- .../tech/ydb/exposed/dialect/YdbDialect.kt | 283 ++++++++++++++++++ .../ydb/exposed/dialect/YdbDialectProvider.kt | 49 +++ .../dialect/{basic => }/YdbGeneratedIds.kt | 2 +- .../tech/ydb/exposed/dialect/YdbIdTable.kt | 54 ++++ .../dialect/{basic => }/YdbSecondaryIndex.kt | 6 +- .../dialect/{basic => }/YdbStringIdTable.kt | 3 +- .../tech/ydb/exposed/dialect/YdbTable.kt | 51 ++++ .../ydb/exposed/dialect/YdbTableSupport.kt | 135 +++++++++ .../ydb/exposed/dialect/{basic => }/YdbTtl.kt | 2 +- .../ydb/exposed/dialect/basic/YdbDialect.kt | 111 ------- .../dialect/basic/YdbDialectMetadata.kt | 68 ----- .../dialect/basic/YdbDialectProvider.kt | 32 -- .../dialect/basic/YdbExposedBootstrap.kt | 27 -- .../ydb/exposed/dialect/basic/YdbIdTable.kt | 127 -------- .../ydb/exposed/dialect/basic/YdbTable.kt | 124 -------- .../dialect/functions/YdbFunctionProvider.kt | 95 ------ .../dialect/locking/YdbOptimisticLocking.kt | 4 +- .../dialect/transaction/YdbRetryClassifier.kt | 88 ++++-- .../transaction/YdbRetryingTransaction.kt | 5 - .../dialect/types/YdbCustomColumnTypes.kt | 43 ++- .../dialect/types/YdbDataTypeProvider.kt | 46 --- .../dialect/integration/base/BaseYdbTest.kt | 5 +- .../integration/batch/BatchOperationsIT.kt | 2 +- .../dialect/integration/crud/CrudIT.kt | 2 +- .../dialect/integration/dao/DaoSmokeIT.kt | 2 +- .../dialect/integration/dao/GeneratedIdsIT.kt | 4 +- .../dialect/integration/ddl/IndexIT.kt | 10 +- .../dialect/integration/ddl/UniqueIndexIT.kt | 6 +- .../functions/FunctionProviderIT.kt | 4 +- .../locking/OptimisticLockingIT.kt | 17 +- .../pagination/KeysetPaginationIT.kt | 2 +- .../dialect/integration/query/JoinIT.kt | 2 +- .../dialect/integration/query/ManyToManyIT.kt | 2 +- .../dialect/integration/query/SubqueryIT.kt | 2 +- .../scenario/UniversityScenarioIT.kt | 2 +- .../transaction/YdbRetryingTransactionsIT.kt | 2 +- .../integration/ttl/NumericTtlTypesIT.kt | 4 +- .../dialect/integration/ttl/TtlAlterSqlIT.kt | 4 +- .../dialect/integration/ttl/TtlTypesIT.kt | 2 +- .../integration/types/BinaryTypesIT.kt | 2 +- .../integration/types/DecimalTypesIT.kt | 2 +- .../integration/types/DecimalUpdateIT.kt | 2 +- .../integration/types/IntervalTypesIT.kt | 2 +- .../dialect/integration/types/JsonTypesIT.kt | 36 ++- .../integration/types/TemporalTypesIT.kt | 15 +- .../dialect/integration/types/TypesIT.kt | 2 +- .../integration/types/Uint64TypesIT.kt | 2 +- .../dialect/integration/types/UuidTypesIT.kt | 2 +- .../dialect/integration/upsert/UpsertIT.kt | 6 +- .../unit/basic/YdbDialectTtlSqlTest.kt | 11 +- .../dialect/unit/basic/YdbGeneratedIdsTest.kt | 2 +- .../dialect/unit/basic/YdbIndexSqlTest.kt | 10 +- .../dialect/unit/basic/YdbTableTest.kt | 9 +- .../unit/basic/YdbUniqueIndexSqlTest.kt | 13 +- .../dialect/unit/functions/FunctionTest.kt | 4 +- .../transaction/YdbRetryClassifierTest.kt | 35 ++- .../dialect/unit/ttl/YdbTtlColumnModeTest.kt | 2 +- .../unit/types/YdbDataTypeProviderTest.kt | 12 +- .../YdbJsonDocumentStringColumnTypeTest.kt | 34 +++ 60 files changed, 877 insertions(+), 765 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{basic => }/YdbGeneratedIds.kt (98%) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{basic => }/YdbSecondaryIndex.kt (96%) rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{basic => }/YdbStringIdTable.kt (89%) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt rename kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/{basic => }/YdbTtl.kt (93%) delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonDocumentStringColumnTypeTest.kt diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 00b2dba8..fff6a11d 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -97,11 +97,11 @@ test - - org.jetbrains.kotlin - kotlin-reflect - ${kotlin.version} - + + + + + org.jetbrains.exposed 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..c7684471 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt @@ -0,0 +1,283 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.IColumnType +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.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 + + +internal object YdbDataTypeProvider : DataTypeProvider() { + override fun byteType(): String = "Int8" + override fun ubyteType(): String = "Uint8" + + override fun binaryType(): String = "String" + override fun binaryType(length: Int): String = "String" + + override fun blobType(): String = binaryType() + + override fun hexToDb(hexString: String): String = "'$hexString'" + + override fun shortType(): String = "Int16" + override fun ushortType(): String = "Uint16" + + override fun integerType(): String = "Int32" + override fun uintegerType(): String = "Uint32" + + override fun integerAutoincType(): String = + throw UnsupportedOperationException( + "YDB does not support AUTO_INCREMENT. Use YdbUuidIdTable, YdbUuidStringIdTable, or YdbUlidTable instead." + ) + + override fun longType(): String = "Int64" + override fun booleanType(): String = "Bool" + + override fun floatType(): String = "Float" + override fun doubleType(): String = "Double" + + override fun varcharType(colLength: Int): String = "Utf8" + + override fun textType(): String = "Utf8" + override fun mediumTextType(): String = textType() + override fun largeTextType(): String = textType() + + override fun uuidType(): String = "Uuid" + + override fun dateType(): String = "Date32" + override fun dateTimeType(): String = "Datetime64" + override fun timestampType(): String = "Timestamp64" + + override fun jsonType(): String = "JsonDocument" +} + +internal object YdbFunctionProvider : FunctionProvider() { + + private const val MERGE_UNSUPPORTED = + "YDB dialect does not support ANSI MERGE through Exposed. Use UPSERT or batchUpsert instead." + + override fun random(seed: Int?): String = "Random()" + + override fun upsert( + table: Table, + data: List, Any?>>, + expression: String, + onUpdate: List, Any?>>, + keyColumns: List>, + where: Op?, + transaction: Transaction + ): String { + require(where == null) { + "YDB UPSERT does not support WHERE clause in this dialect implementation" + } + + val columnList = data.joinToString(", ") { (column, _) -> + transaction.identity(column) + } + + if (expression.isNotBlank()) { + val valuesExpression = expression.trim() + val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { + "($columnList) $valuesExpression" + } else { + valuesExpression + } + + return "UPSERT INTO ${transaction.identity(table)} $expressionWithColumns" + } + + val valueList = data.joinToString(", ") { (column, value) -> + valueToSqlLiteral(column, value) + } + + return "UPSERT INTO ${transaction.identity(table)} ($columnList) VALUES ($valueList)" + } + + 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) + } +} + +class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider, YdbFunctionProvider) { + + override fun createIndex(index: Index): String { + val tr = runCatching { TransactionManager.Companion.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(")") + } + } + + fun createSecondaryIndex(table: Table, spec: YdbSecondaryIndexSpec): String { + val tr = TransactionManager.Companion.current() + return buildString { + append("ALTER TABLE ") + append(tr.identity(table)) + append(" ADD ") + append(renderYdbSecondaryIndex(spec)) + } + } + + override fun dropIndex( + tableName: String, + indexName: String, + isUnique: Boolean, + isPartialOrFunctional: Boolean + ): String = "ALTER TABLE $tableName DROP INDEX $indexName" + + fun setTtl(table: YdbTable): String { + val tr = TransactionManager.Companion.current() + val ttl = table.ttlSettings + ?: error("TTL is not configured for table ${table.tableName}") + + validateYdbTtlColumn(ttl) + + return buildString { + append("ALTER TABLE ") + append(tr.identity(table)) + append(" SET (TTL = Interval(\"") + append(ttl.intervalIso8601) + append("\") ON ") + append(tr.identity(ttl.column)) + ttl.mode.toSql()?.let { + append(" AS ") + append(it) + } + append(")") + } + } + + fun resetTtl(table: YdbTable): String { + val tr = TransactionManager.Companion.current() + return "ALTER TABLE ${tr.identity(table)} RESET (TTL)" + } + + internal object Metadata : DatabaseDialectMetadata() { + + override fun existingIndices(vararg tables: Table): Map> { + val connection = TransactionManager.Companion.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/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt new file mode 100644 index 00000000..e7572b8a --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt @@ -0,0 +1,49 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.jdbc.Database +import java.sql.Connection +import java.util.concurrent.atomic.AtomicBoolean + +object YdbDialectProvider { + private const val DIALECT_NAME = "ydb" + private const val URL_PREFIX = "jdbc:ydb:" + private const val DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" + + private val initialized = AtomicBoolean(false) + + fun init() { + if (!initialized.compareAndSet(false, true)) return + + Database.registerJdbcDriver( + prefix = URL_PREFIX, + driverClassName = DRIVER_CLASS, + dialect = DIALECT_NAME + ) + + Database.registerDialectMetadata(DIALECT_NAME) { + YdbDialect.Metadata + } + } + + fun connect( + url: String, + user: String = "", + password: String = "" + ): Database { + init() + + return Database.connect( + url = url, + driver = DRIVER_CLASS, + user = user, + password = password, + databaseConfig = DatabaseConfig { + explicitDialect = YdbDialect() + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + defaultReadOnly = false + useNestedTransactions = false + } + ) + } +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbGeneratedIds.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt similarity index 98% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbGeneratedIds.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt index f48967aa..2156907d 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbGeneratedIds.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt @@ -1,4 +1,4 @@ -package tech.ydb.exposed.dialect.basic +package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.dao.id.EntityID diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt new file mode 100644 index 00000000..0f79b17d --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt @@ -0,0 +1,54 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.dao.id.IdTable +import tech.ydb.exposed.dialect.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec +import tech.ydb.exposed.dialect.YdbTableFeatures +import tech.ydb.exposed.dialect.YdbTtlColumnMode +import tech.ydb.exposed.dialect.YdbTtlSettings +import tech.ydb.exposed.dialect.buildYdbCreateStatement + +abstract class YdbIdTable(name: String = "") : IdTable(name) { + private val ydbFeatures = YdbTableFeatures() + + protected fun ttl( + column: Column<*>, + intervalIso8601: String, + mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE + ) { + ydbFeatures.ttl(column, intervalIso8601, mode) + } + + protected fun secondaryIndex( + name: String, + vararg columns: Column<*>, + unique: Boolean = false, + scope: YdbIndexScope = YdbIndexScope.GLOBAL, + syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + indexType: String? = null, + coverColumns: List> = emptyList(), + withParams: Map = emptyMap() + ) { + ydbFeatures.secondaryIndex( + name = name, + columns = *columns, + unique = unique, + scope = scope, + syncMode = syncMode, + indexType = indexType, + coverColumns = coverColumns, + withParams = withParams + ) + } + + val ttlSettings: YdbTtlSettings? + get() = ydbFeatures.ttlSettings + + val ydbSecondaryIndices: List + get() = ydbFeatures.ydbSecondaryIndices + + override fun createStatement(): List = + buildYdbCreateStatement(this, ttlSettings, ydbSecondaryIndices) +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt similarity index 96% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt index f5f2340c..33899e50 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbSecondaryIndex.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt @@ -1,4 +1,4 @@ -package tech.ydb.exposed.dialect.basic +package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager @@ -24,7 +24,7 @@ data class YdbSecondaryIndexSpec( ) internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { - val tr = TransactionManager.current() + val tr = TransactionManager.Companion.current() require(spec.columns.isNotEmpty()) { "YDB secondary index must contain at least one column" @@ -86,4 +86,4 @@ private fun renderYdbIndexParamValue(value: Any): String = when (value) { is Number -> value.toString() is Boolean -> value.toString().uppercase() else -> "\"${value.toString().replace("\"", "\\\"")}\"" -} +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbStringIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt similarity index 89% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbStringIdTable.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt index 3b4da5bb..9bedb26f 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbStringIdTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt @@ -1,9 +1,8 @@ -package tech.ydb.exposed.dialect.basic +package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.dao.id.EntityID - open class YdbStringIdTable( name: String = "", idLength: Int = 64 diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt new file mode 100644 index 00000000..4ee6f9c7 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt @@ -0,0 +1,51 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table +import tech.ydb.exposed.dialect.YdbTableFeatures +import tech.ydb.exposed.dialect.YdbTtlColumnMode +import tech.ydb.exposed.dialect.YdbTtlSettings +import tech.ydb.exposed.dialect.buildYdbCreateStatement + +open class YdbTable(name: String = "") : Table(name) { + private val ydbFeatures = YdbTableFeatures() + + protected fun ttl( + column: Column<*>, + intervalIso8601: String, + mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE + ) { + ydbFeatures.ttl(column, intervalIso8601, mode) + } + + protected fun secondaryIndex( + name: String, + vararg columns: Column<*>, + unique: Boolean = false, + scope: YdbIndexScope = YdbIndexScope.GLOBAL, + syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + indexType: String? = null, + coverColumns: List> = emptyList(), + withParams: Map = emptyMap() + ) { + ydbFeatures.secondaryIndex( + name = name, + columns = *columns, + unique = unique, + scope = scope, + syncMode = syncMode, + indexType = indexType, + coverColumns = coverColumns, + withParams = withParams + ) + } + + val ttlSettings: YdbTtlSettings? + get() = ydbFeatures.ttlSettings + + val ydbSecondaryIndices: List + get() = ydbFeatures.ydbSecondaryIndices + + override fun createStatement(): List = + buildYdbCreateStatement(this, ttlSettings, ydbSecondaryIndices) +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt new file mode 100644 index 00000000..509e3f95 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt @@ -0,0 +1,135 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import tech.ydb.exposed.dialect.YdbTtlColumnMode +import tech.ydb.exposed.dialect.YdbTtlSettings + +internal class YdbTableFeatures { + private var ttlSettingsState: YdbTtlSettings? = null + private val secondaryIndices = mutableListOf() + + fun ttl( + column: Column<*>, + intervalIso8601: String, + mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE + ) { + ttlSettingsState = YdbTtlSettings(column, intervalIso8601, mode) + } + + fun secondaryIndex( + name: String, + vararg columns: Column<*>, + unique: Boolean = false, + scope: YdbIndexScope = YdbIndexScope.GLOBAL, + syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + indexType: String? = null, + coverColumns: List> = emptyList(), + withParams: Map = emptyMap() + ) { + require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } + + secondaryIndices += YdbSecondaryIndexSpec( + name = name, + columns = columns.toList(), + unique = unique, + scope = scope, + syncMode = syncMode, + indexType = indexType, + coverColumns = coverColumns, + withParams = withParams + ) + } + + val ttlSettings: YdbTtlSettings? + get() = ttlSettingsState + + val ydbSecondaryIndices: List + get() = secondaryIndices +} + +internal fun buildYdbCreateStatement( + table: Table, + ttlSettings: YdbTtlSettings?, + secondaryIndices: List +): List { + val tr = TransactionManager.Companion.current() + + val pk = table.primaryKey + ?: error("YDB requires PRIMARY KEY for every table: ${table.tableName}") + + val columnsSql = table.columns.joinToString(", ") { column -> + buildString { + append(tr.identity(column)) + append(" ") + append(column.columnType.sqlType()) + + if (!column.columnType.nullable) { + append(" NOT NULL") + } + } + } + + val indexesSql = secondaryIndices.joinToString(", ") { renderYdbSecondaryIndex(it) } + val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } + + val ttlSql = ttlSettings?.let { ttl -> + validateYdbTtlColumn(ttl) + + buildString { + append(" WITH (TTL = Interval(\"") + append(ttl.intervalIso8601) + append("\") ON ") + append(tr.identity(ttl.column)) + ttl.mode.toSql()?.let { + append(" AS ") + append(it) + } + append(")") + } + }.orEmpty() + + val sql = buildString { + append("CREATE TABLE IF NOT EXISTS ") + append(tr.identity(table)) + append(" (") + append(columnsSql) + + if (indexesSql.isNotEmpty()) { + append(", ") + append(indexesSql) + } + + append(", PRIMARY KEY (") + append(pkSql) + append("))") + append(ttlSql) + } + + return listOf(sql) +} + +internal fun validateYdbTtlColumn(ttl: YdbTtlSettings) { + val sqlType = ttl.column.columnType.sqlType() + + val supported = when (ttl.mode) { + YdbTtlColumnMode.DATE_TYPE -> + sqlType == "Date" || + sqlType == "Date32" || + sqlType == "Datetime" || + sqlType == "Datetime64" || + sqlType == "Timestamp" || + sqlType == "Timestamp64" + + YdbTtlColumnMode.SECONDS, + YdbTtlColumnMode.MILLISECONDS, + YdbTtlColumnMode.MICROSECONDS, + YdbTtlColumnMode.NANOSECONDS -> + sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" + } + + require(supported) { + "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" + } +} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTtl.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt similarity index 93% rename from kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTtl.kt rename to kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt index 44dbf8fb..b7fe9707 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTtl.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt @@ -1,4 +1,4 @@ -package tech.ydb.exposed.dialect.basic +package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt deleted file mode 100644 index 750da125..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialect.kt +++ /dev/null @@ -1,111 +0,0 @@ -package tech.ydb.exposed.dialect.basic - -import org.jetbrains.exposed.v1.core.Index -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.vendors.VendorDialect -import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager -import tech.ydb.exposed.dialect.functions.YdbFunctionProvider -import tech.ydb.exposed.dialect.types.YdbDataTypeProvider -import kotlin.reflect.full.memberProperties - -class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider(), YdbFunctionProvider()) { - - 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.extractUniqueFlag() - - return buildString { - append("ALTER TABLE ") - append(tableName) - append(" ADD INDEX ") - append(indexName) - append(" GLOBAL") - if (unique) { - append(" UNIQUE") - } - append(" ON (") - append(columns) - append(")") - } - } - - fun createSecondaryIndex(table: Table, spec: YdbSecondaryIndexSpec): String { - val tr = TransactionManager.current() - return buildString { - append("ALTER TABLE ") - append(tr.identity(table)) - append(" ADD ") - append(renderYdbSecondaryIndex(spec)) - } - } - - override fun dropIndex( - tableName: String, - indexName: String, - isUnique: Boolean, - isPartialOrFunctional: Boolean - ): String = "ALTER TABLE $tableName DROP INDEX $indexName" - - fun setTtl(table: YdbTable): String { - val tr = TransactionManager.current() - val ttl = table.getTtlSettings() - ?: error("TTL is not configured for table ${table.tableName}") - - validateTtlColumn(ttl) - - return buildString { - append("ALTER TABLE ") - append(tr.identity(table)) - append(" SET (TTL = Interval(\"") - append(ttl.intervalIso8601) - append("\") ON ") - append(tr.identity(ttl.column)) - ttl.mode.toSql()?.let { - append(" AS ") - append(it) - } - append(")") - } - } - - fun resetTtl(table: YdbTable): String { - val tr = TransactionManager.current() - return "ALTER TABLE ${tr.identity(table)} RESET (TTL)" - } - - private fun validateTtlColumn(ttl: YdbTtlSettings) { - val sqlType = ttl.column.columnType.sqlType() - val supported = when (ttl.mode) { - YdbTtlColumnMode.DATE_TYPE -> - sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" - - YdbTtlColumnMode.SECONDS, - YdbTtlColumnMode.MILLISECONDS, - YdbTtlColumnMode.MICROSECONDS, - YdbTtlColumnMode.NANOSECONDS -> - sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" - } - - require(supported) { - "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" - } - } - - private fun Index.extractUniqueFlag(): Boolean { - val prop = this::class.memberProperties.firstOrNull { - it.name == "unique" || it.name == "isUnique" - } ?: return false - - return (runCatching { prop.getter.call(this) }.getOrNull() as? Boolean) == true - } -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt deleted file mode 100644 index 933bbb89..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectMetadata.kt +++ /dev/null @@ -1,68 +0,0 @@ -package tech.ydb.exposed.dialect.basic - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.Index -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager -import org.jetbrains.exposed.v1.jdbc.vendors.DatabaseDialectMetadata -import java.sql.Connection -import java.sql.DatabaseMetaData - -class 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/basic/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt deleted file mode 100644 index 2a5a7fc2..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbDialectProvider.kt +++ /dev/null @@ -1,32 +0,0 @@ -package tech.ydb.exposed.dialect.basic - -import org.jetbrains.exposed.v1.core.DatabaseConfig -import org.jetbrains.exposed.v1.jdbc.Database -import java.sql.Connection - -object YdbDialectProvider { - - private const val DEFAULT_DRIVER = "tech.ydb.jdbc.YdbDriver" - - fun connect( - url: String, - driver: String = DEFAULT_DRIVER, - user: String = "", - password: String = "" - ): Database { - YdbExposedBootstrap.init() - - return Database.connect( - url = url, - driver = driver, - user = user, - password = password, - databaseConfig = DatabaseConfig { - explicitDialect = YdbDialect() - defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE - defaultReadOnly = false - useNestedTransactions = false - } - ) - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt deleted file mode 100644 index c6320a3d..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbExposedBootstrap.kt +++ /dev/null @@ -1,27 +0,0 @@ -package tech.ydb.exposed.dialect.basic - -import org.jetbrains.exposed.v1.jdbc.Database -import java.util.concurrent.atomic.AtomicBoolean - -object YdbExposedBootstrap { - private const val DIALECT_NAME = "ydb" - private const val URL_PREFIX = "jdbc:ydb:" - private const val DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" - - private val initialized = AtomicBoolean(false) - - fun init() { - if (!initialized.compareAndSet(false, true)) return - - Database.registerJdbcDriver( - prefix = URL_PREFIX, - driverClassName = DRIVER_CLASS, - dialect = DIALECT_NAME - ) - - Database.registerDialectMetadata(DIALECT_NAME) { - YdbDialectMetadata() - } - - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt deleted file mode 100644 index d0bd75bf..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbIdTable.kt +++ /dev/null @@ -1,127 +0,0 @@ -package tech.ydb.exposed.dialect.basic - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.dao.id.IdTable -import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager - -abstract class YdbIdTable(name: String = "") : IdTable(name) { - - private var ttlSettings: YdbTtlSettings? = null - private val ydbSecondaryIndices = mutableListOf() - - protected fun ttl( - column: Column<*>, - intervalIso8601: String, - mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE - ) { - ttlSettings = YdbTtlSettings(column, intervalIso8601, mode) - } - - protected fun secondaryIndex( - name: String, - vararg columns: Column<*>, - unique: Boolean = false, - scope: YdbIndexScope = YdbIndexScope.GLOBAL, - syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, - indexType: String? = null, - coverColumns: List> = emptyList(), - withParams: Map = emptyMap() - ) { - require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } - - ydbSecondaryIndices += YdbSecondaryIndexSpec( - name = name, - columns = columns.toList(), - unique = unique, - scope = scope, - syncMode = syncMode, - indexType = indexType, - coverColumns = coverColumns, - withParams = withParams - ) - } - - fun getTtlSettings(): YdbTtlSettings? = ttlSettings - - fun getYdbSecondaryIndices(): List = - ydbSecondaryIndices.toList() - - override fun createStatement(): 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") - } - } - } - - val indexesSql = ydbSecondaryIndices.joinToString(", ") { spec -> - renderYdbSecondaryIndex(spec) - } - - val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } - - val ttlSql = ttlSettings?.let { ttl -> - validateTtlColumn(ttl) - - buildString { - append(" WITH (TTL = Interval(\"") - append(ttl.intervalIso8601) - append("\") ON ") - append(tr.identity(ttl.column)) - ttl.mode.toSql()?.let { - append(" AS ") - append(it) - } - append(")") - } - } ?: "" - - val sql = buildString { - append("CREATE TABLE IF NOT EXISTS ") - append(tr.identity(this@YdbIdTable)) - append(" (") - append(columnsSql) - - if (indexesSql.isNotEmpty()) { - append(", ") - append(indexesSql) - } - - append(", PRIMARY KEY (") - append(pkSql) - append("))") - append(ttlSql) - } - - return listOf(sql) - } - - private fun validateTtlColumn(ttl: YdbTtlSettings) { - val sqlType = ttl.column.columnType.sqlType() - - val supported = when (ttl.mode) { - YdbTtlColumnMode.DATE_TYPE -> - sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" - - YdbTtlColumnMode.SECONDS, - YdbTtlColumnMode.MILLISECONDS, - YdbTtlColumnMode.MICROSECONDS, - YdbTtlColumnMode.NANOSECONDS -> - sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" - } - - require(supported) { - "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" - } - } -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt deleted file mode 100644 index e9f499a0..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/basic/YdbTable.kt +++ /dev/null @@ -1,124 +0,0 @@ -package tech.ydb.exposed.dialect.basic - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager - - -open class YdbTable(name: String = "") : Table(name) { - - private var ttlSettings: YdbTtlSettings? = null - private val ydbSecondaryIndices = mutableListOf() - - protected fun ttl( - column: Column<*>, - intervalIso8601: String, - mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE - ) { - ttlSettings = YdbTtlSettings(column, intervalIso8601, mode) - } - - protected fun secondaryIndex( - name: String, - vararg columns: Column<*>, - unique: Boolean = false, - scope: YdbIndexScope = YdbIndexScope.GLOBAL, - syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, - indexType: String? = null, - coverColumns: List> = emptyList(), - withParams: Map = emptyMap() - ) { - require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } - - ydbSecondaryIndices += YdbSecondaryIndexSpec( - name = name, - columns = columns.toList(), - unique = unique, - scope = scope, - syncMode = syncMode, - indexType = indexType, - coverColumns = coverColumns, - withParams = withParams - ) - } - - fun getTtlSettings(): YdbTtlSettings? = ttlSettings - fun getYdbSecondaryIndices(): List = ydbSecondaryIndices.toList() - - override fun createStatement(): 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") - } - } - } - - val indexesSql = ydbSecondaryIndices.joinToString(", ") { renderYdbSecondaryIndex(it) } - - val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } - - val ttlSql = ttlSettings?.let { ttl -> - validateTtlColumn(ttl) - - buildString { - append(" WITH (TTL = Interval(\"") - append(ttl.intervalIso8601) - append("\") ON ") - append(tr.identity(ttl.column)) - ttl.mode.toSql()?.let { - append(" AS ") - append(it) - } - append(")") - } - } ?: "" - - val sql = buildString { - append("CREATE TABLE IF NOT EXISTS ") - append(tr.identity(this@YdbTable)) - append(" (") - append(columnsSql) - - if (indexesSql.isNotEmpty()) { - append(", ") - append(indexesSql) - } - - append(", PRIMARY KEY (") - append(pkSql) - append("))") - append(ttlSql) - } - - return listOf(sql) - } - - private fun validateTtlColumn(ttl: YdbTtlSettings) { - val sqlType = ttl.column.columnType.sqlType() - - val supported = when (ttl.mode) { - YdbTtlColumnMode.DATE_TYPE -> - sqlType == "Date" || sqlType == "Datetime" || sqlType == "Timestamp" - - YdbTtlColumnMode.SECONDS, - YdbTtlColumnMode.MILLISECONDS, - YdbTtlColumnMode.MICROSECONDS, - YdbTtlColumnMode.NANOSECONDS -> - sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" - } - - require(supported) { - "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" - } - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt deleted file mode 100644 index d9354eed..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/functions/YdbFunctionProvider.kt +++ /dev/null @@ -1,95 +0,0 @@ -package tech.ydb.exposed.dialect.functions - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.IColumnType -import org.jetbrains.exposed.v1.core.Op -import org.jetbrains.exposed.v1.core.QueryAlias -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.FunctionProvider - -class YdbFunctionProvider : FunctionProvider() { - - private companion object { - const val MERGE_UNSUPPORTED = - "YDB dialect does not support ANSI MERGE through Exposed. Use UPSERT or batchUpsert instead." - } - - override fun random(seed: Int?): String = "Random()" - - override fun upsert( - table: Table, - data: List, Any?>>, - expression: String, - onUpdate: List, Any?>>, - keyColumns: List>, - where: Op?, - transaction: Transaction - ): String { - require(where == null) { - "YDB UPSERT does not support WHERE clause in this dialect implementation" - } - - val columnList = data.joinToString(", ") { (column, _) -> - transaction.identity(column) - } - - if (expression.isNotBlank()) { - val valuesExpression = expression.trim() - val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { - "($columnList) $valuesExpression" - } else { - valuesExpression - } - - return "UPSERT INTO ${transaction.identity(table)} $expressionWithColumns" - } - - val valueList = data.joinToString(", ") { (column, value) -> - valueToSqlLiteral(column, value) - } - - return "UPSERT INTO ${transaction.identity(table)} ($columnList) VALUES ($valueList)" - } - - 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) - } -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt index 1d396d82..4615e7fa 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt @@ -29,14 +29,14 @@ object YdbOptimisticLocking { return false } - table.update( + val updatedCount = table.update( where = { (idColumn eq idValue) and (versionColumn eq expectedVersion) } ) { stmt -> body(stmt) stmt[versionColumn] = expectedVersion + 1 } - return true + return updatedCount > 0 } } diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt index f734a188..2c70197a 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt @@ -1,63 +1,85 @@ package tech.ydb.exposed.dialect.transaction +import tech.ydb.core.StatusCode +import tech.ydb.jdbc.exception.YdbStatusable import kotlin.math.min import kotlin.random.Random object YdbRetryClassifier { + private val statusCodeRegex = Regex("""STATUS\{CODE\s*=\s*([A-Z_]+)""") fun classify(error: Throwable, idempotent: Boolean): YdbRetryDecision { - val text = buildString { - append(error.message.orEmpty()) - val causeMessage = error.cause?.message.orEmpty() - if (causeMessage.isNotBlank()) { - append(" | ") - append(causeMessage) - } - }.uppercase() + val code = extractStatusCode(error) - return when { - "ABORTED" in text -> + return when (code) { + StatusCode.ABORTED -> YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) - "UNAVAILABLE" in text -> + StatusCode.UNAVAILABLE, + StatusCode.TRANSPORT_UNAVAILABLE, + StatusCode.CLIENT_DISCOVERY_FAILED, + StatusCode.CLIENT_GRPC_ERROR, + StatusCode.CLIENT_INTERNAL_ERROR -> YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) - "OVERLOADED" in text -> + StatusCode.OVERLOADED, + StatusCode.CLIENT_RESOURCE_EXHAUSTED, + StatusCode.CLIENT_LIMITS_REACHED -> YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.SLOW) - "BAD_SESSION" in text -> - YdbRetryDecision(retryable = true, recreateSession = true, backoffKind = YdbBackoffKind.INSTANT) - - "SESSION_EXPIRED" in text -> + StatusCode.BAD_SESSION, + StatusCode.SESSION_EXPIRED -> YdbRetryDecision(retryable = true, recreateSession = true, backoffKind = YdbBackoffKind.INSTANT) - "SESSION_BUSY" in text -> + StatusCode.SESSION_BUSY -> YdbRetryDecision(retryable = true, recreateSession = true, backoffKind = YdbBackoffKind.FAST) - "TIMEOUT" in text -> + StatusCode.TIMEOUT, + StatusCode.CLIENT_DEADLINE_EXCEEDED, + StatusCode.CLIENT_DEADLINE_EXPIRED -> YdbRetryDecision(retryable = idempotent, backoffKind = if (idempotent) YdbBackoffKind.INSTANT else YdbBackoffKind.NONE) - "UNDETERMINED" in text -> + StatusCode.UNDETERMINED -> YdbRetryDecision(retryable = idempotent, backoffKind = if (idempotent) YdbBackoffKind.FAST else YdbBackoffKind.NONE) - "PRECONDITION_FAILED" in text -> - YdbRetryDecision(retryable = false) - - "ALREADY_EXISTS" in text -> - YdbRetryDecision(retryable = false) - - "NOT_FOUND" in text -> + StatusCode.PRECONDITION_FAILED, + StatusCode.ALREADY_EXISTS, + StatusCode.NOT_FOUND, + StatusCode.SCHEME_ERROR, + StatusCode.GENERIC_ERROR, + StatusCode.INTERNAL_ERROR, + StatusCode.BAD_REQUEST, + StatusCode.UNAUTHORIZED, + StatusCode.UNSUPPORTED, + StatusCode.CANCELLED, + StatusCode.CLIENT_CANCELLED, + StatusCode.CLIENT_UNAUTHENTICATED, + StatusCode.CLIENT_CALL_UNIMPLEMENTED, + StatusCode.EXTERNAL_ERROR, + StatusCode.UNUSED_STATUS, + StatusCode.SUCCESS, + null -> YdbRetryDecision(retryable = false) + } + } - "SCHEME_ERROR" in text -> - YdbRetryDecision(retryable = false) + private fun extractStatusCode(error: Throwable): StatusCode? { + var current: Throwable? = error + while (current != null) { + if (current is YdbStatusable) { + return current.status.code + } - "GENERIC_ERROR" in text -> - YdbRetryDecision(retryable = false) + val message = current.message.orEmpty().uppercase() + val matched = statusCodeRegex.find(message)?.groupValues?.getOrNull(1) + if (matched != null) { + runCatching { return StatusCode.valueOf(matched) } + } - else -> - YdbRetryDecision(retryable = false) + current = current.cause } + + return null } fun backoffMillis(kind: YdbBackoffKind, attempt: Int): Long { @@ -81,4 +103,4 @@ object YdbRetryClassifier { val spread = (base * spreadPercent) / 100 return base + Random.nextLong(-spread, spread + 1) } -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt index 08f3c7bb..7a8c1714 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt @@ -2,7 +2,6 @@ package tech.ydb.exposed.dialect.transaction import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.JdbcTransaction -import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction import java.sql.Connection @@ -42,10 +41,6 @@ object YdbRetryingTransactions { throw t } - if (decision.recreateSession) { - runCatching { TransactionManager.closeAndUnregister(db) } - } - val sleepMs = YdbRetryClassifier.backoffMillis(decision.backoffKind, attempt) if (sleepMs > 0) { try { diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt index 8c2f8d60..d578ad8a 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt @@ -4,6 +4,7 @@ import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.ColumnType import org.jetbrains.exposed.v1.core.Table import java.math.BigDecimal +import java.math.BigInteger import java.time.Duration import java.util.UUID @@ -65,6 +66,17 @@ class YdbJsonStringColumnType : ColumnType() { "'${value.replace("'", "''")}'" } +class YdbJsonDocumentStringColumnType : ColumnType() { + override fun sqlType(): String = "JsonDocument" + + override fun valueFromDB(value: Any): String = value.toString() + + override fun notNullValueToDB(value: String): Any = value + + override fun nonNullValueToString(value: String): String = + "'${value.replace("'", "''")}'" +} + class YdbUuidAsUtf8ColumnType : ColumnType() { override fun sqlType(): String = "Utf8" @@ -113,13 +125,25 @@ class YdbUuidColumnType : ColumnType() { } class YdbUint64ColumnType : ColumnType() { + /** + * Maps YDB Uint64 to Kotlin Long. + * + * This implementation supports only the non-negative subset that fits into Long + * (0..Long.MAX_VALUE). Values above Long.MAX_VALUE are not supported by this mapping. + */ override fun sqlType(): String = "Uint64" override fun valueFromDB(value: Any): Long = when (value) { - is Long -> value - is Int -> value.toLong() - is Number -> value.toLong() - is String -> value.toLong() + 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}") } @@ -134,6 +158,14 @@ class YdbUint64ColumnType : ColumnType() { } } +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() +} + fun Table.ydbDecimal(name: String, precision: Int, scale: Int): Column = registerColumn(name, YdbDecimalColumnType(precision, scale)) @@ -143,6 +175,9 @@ fun Table.ydbInterval(name: String): Column = fun Table.ydbJson(name: String): Column = registerColumn(name, YdbJsonStringColumnType()) +fun Table.ydbJsonDocument(name: String): Column = + registerColumn(name, YdbJsonDocumentStringColumnType()) + fun Table.ydbUuidUtf8(name: String): Column = registerColumn(name, YdbUuidAsUtf8ColumnType()) diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt deleted file mode 100644 index fa9ad7c2..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDataTypeProvider.kt +++ /dev/null @@ -1,46 +0,0 @@ -package tech.ydb.exposed.dialect.types - -import org.jetbrains.exposed.v1.core.vendors.DataTypeProvider - -class YdbDataTypeProvider : DataTypeProvider() { - override fun byteType(): String = "Int8" - override fun ubyteType(): String = "Uint8" - - override fun binaryType(): String = "String" - override fun binaryType(length: Int): String = "String" - - override fun blobType(): String = binaryType() - - override fun hexToDb(hexString: String): String = "'$hexString'" - - override fun shortType(): String = "Int16" - override fun ushortType(): String = "Uint16" - - override fun integerType(): String = "Int32" - override fun uintegerType(): String = "Uint32" - - override fun integerAutoincType(): String = - throw UnsupportedOperationException( - "YDB does not support AUTO_INCREMENT. Use YdbUuidIdTable, YdbUuidStringIdTable, or YdbUlidTable instead." - ) - - override fun longType(): String = "Int64" - override fun booleanType(): String = "Bool" - - override fun floatType(): String = "Float" - override fun doubleType(): String = "Double" - - override fun varcharType(colLength: Int): String = "Utf8" - - override fun textType(): String = "Utf8" //"String" - override fun mediumTextType(): String = textType() - override fun largeTextType(): String = textType() - - override fun uuidType(): String = "Uuid" - - override fun dateType(): String = "Date" - override fun dateTimeType(): String = "Datetime" - override fun timestampType(): String = "Timestamp" - - override fun jsonType(): String = "Json" -} 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 index 4f949da3..25834fec 100644 --- 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 @@ -8,7 +8,7 @@ import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach -import tech.ydb.exposed.dialect.basic.YdbDialectProvider +import tech.ydb.exposed.dialect.YdbDialectProvider import java.sql.Connection abstract class BaseYdbTest { @@ -20,8 +20,7 @@ abstract class BaseYdbTest { @BeforeEach fun setupDatabase() { db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - driver = "tech.ydb.jdbc.YdbDriver" + url = "jdbc:ydb:grpc://localhost:2136/local" ) } 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 index bded757a..7cab3ba5 100644 --- 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 @@ -9,7 +9,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class BatchOperationsIT : BaseYdbTest() { 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 index ae302688..00aa8ec5 100644 --- 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 @@ -8,7 +8,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class CrudIT : BaseYdbTest() { 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 index 15ba1803..a924de86 100644 --- 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 @@ -9,7 +9,7 @@ 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.basic.YdbStringIdTable +import tech.ydb.exposed.dialect.YdbStringIdTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class DaoSmokeIT : BaseYdbTest() { diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt index f9c5cb52..2d559b94 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt @@ -6,8 +6,8 @@ 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.Test -import tech.ydb.exposed.dialect.basic.YdbUlidTable -import tech.ydb.exposed.dialect.basic.YdbUuidStringIdTable +import tech.ydb.exposed.dialect.YdbUlidTable +import tech.ydb.exposed.dialect.YdbUuidStringIdTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import java.util.UUID 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 index 19a03b98..b1bd35c1 100644 --- 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 @@ -4,11 +4,11 @@ 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.basic.YdbDialect -import tech.ydb.exposed.dialect.basic.YdbIndexScope -import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode -import tech.ydb.exposed.dialect.basic.YdbSecondaryIndexSpec -import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class IndexIT : BaseYdbTest() { 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 index a6b229dd..1b3657c8 100644 --- 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 @@ -8,9 +8,9 @@ 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.basic.YdbIndexScope -import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode -import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UniqueIndexIT : BaseYdbTest() { 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 index 7571dfb9..41a4278b 100644 --- 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 @@ -6,7 +6,7 @@ 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.functions.YdbFunctionProvider +import tech.ydb.exposed.dialect.YdbFunctionProvider import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class FunctionProviderIT : BaseYdbTest() { @@ -25,7 +25,7 @@ class FunctionProviderIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - private val provider = YdbFunctionProvider() + private val provider = YdbFunctionProvider @Test fun `should generate UPSERT statement`() = tx { diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt index 4f0d4948..c85b7a39 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.locking.YdbOptimisticLocking @@ -108,4 +108,19 @@ class OptimisticLockingIT : BaseYdbTest() { assertEquals("published", row[Documents.title]) assertEquals(1, row[Documents.version]) } + + @Test + fun `should return false when row does not exist`() = tx { + val updated = YdbOptimisticLocking.updateWithVersion( + table = Documents, + idColumn = Documents.id, + idValue = 42, + versionColumn = Documents.version, + expectedVersion = 0 + ) { + it[Documents.title] = "missing" + } + + assertFalse(updated) + } } \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt index dd061d33..05e696f7 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt @@ -5,7 +5,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.pagination.keysetPageAsc import tech.ydb.exposed.dialect.pagination.keysetPageDesc 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 index f64db924..7db7a50d 100644 --- 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 @@ -8,7 +8,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class JoinIT : BaseYdbTest() { 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 index cd295499..9b7e77cf 100644 --- 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 @@ -8,7 +8,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class ManyToManyIT : BaseYdbTest() { 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 index ce7b18c3..c66239fe 100644 --- 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 @@ -8,7 +8,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class SubqueryIT : BaseYdbTest() { 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 index 31b5b4c8..5fb0ae4f 100644 --- 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 @@ -8,7 +8,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UniversityScenarioIT : BaseYdbTest() { 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 index cd1194cb..e870aecd 100644 --- 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 @@ -5,7 +5,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.transaction.YdbRetryingTransactions 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 index efbdaf56..947496ef 100644 --- 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 @@ -3,8 +3,8 @@ 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.basic.YdbTable -import tech.ydb.exposed.dialect.basic.YdbTtlColumnMode +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.YdbTtlColumnMode import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbUint64 diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt index 34adb306..969e67c3 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt @@ -4,8 +4,8 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.javatime.timestamp import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbDialect -import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class TtlAlterSqlIT : BaseYdbTest() { 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 index 814a1bdc..c56c4f33 100644 --- 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 @@ -4,7 +4,7 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.javatime.timestamp import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class TtlTypesIT : BaseYdbTest() { 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 index eb86a6fc..b211b5b3 100644 --- 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 @@ -6,7 +6,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class BinaryTypesIT : BaseYdbTest() { 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 index d3170521..01b8e504 100644 --- 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 @@ -6,7 +6,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbDecimal import java.math.BigDecimal 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 index 63c16713..2f125286 100644 --- 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 @@ -7,7 +7,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbDecimal import tech.ydb.exposed.dialect.types.ydbDecimalLiteral 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 index ca2c6913..34bcde8d 100644 --- 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 @@ -6,7 +6,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbInterval import java.time.Duration 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 index 1df0c584..749c688e 100644 --- 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 @@ -6,9 +6,10 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbJson +import tech.ydb.exposed.dialect.types.ydbJsonDocument class JsonTypesIT : BaseYdbTest() { @@ -19,7 +20,14 @@ class JsonTypesIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - override val tables: List
= listOf(JsonTypes) + object JsonDocumentTypes : YdbTable("json_document_types") { + val id = integer("id") + val payload = ydbJsonDocument("payload") + + override val primaryKey = PrimaryKey(id) + } + + override val tables: List
= listOf(JsonTypes, JsonDocumentTypes) @Test fun `should round-trip json type`() = tx { @@ -39,4 +47,26 @@ class JsonTypesIT : BaseYdbTest() { val ddl = JsonTypes.ddl.joinToString(" ") assertTrue(ddl.contains("payload Json")) } -} \ No newline at end of file + + @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/TemporalTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TemporalTypesIT.kt index 06ce718e..ffb4666e 100644 --- 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 @@ -4,12 +4,12 @@ import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.javatime.date import org.jetbrains.exposed.v1.javatime.datetime import org.jetbrains.exposed.v1.javatime.timestamp -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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import java.time.Instant import java.time.LocalDate @@ -47,4 +47,13 @@ class TemporalTypesIT : BaseYdbTest() { assertEquals(dateTimeValue, row[TemporalTypes.dateTimeCol]) assertEquals(timestampValue, row[TemporalTypes.timestampCol]) } -} \ No newline at end of file + + @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/TypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TypesIT.kt index 64bb255e..f271c3f5 100644 --- 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 @@ -6,7 +6,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class TypesIT : BaseYdbTest() { 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 index 7ec7fb97..523ecb45 100644 --- 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 @@ -6,7 +6,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbUint64 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 index 90fa005d..5ff208be 100644 --- 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 @@ -6,7 +6,7 @@ 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.basic.YdbTable +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbUuid import tech.ydb.exposed.dialect.types.ydbUuidBytes 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 index ade1aed6..8f3de24a 100644 --- 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 @@ -6,8 +6,8 @@ import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.upsert import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbTable -import tech.ydb.exposed.dialect.functions.YdbFunctionProvider +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.YdbFunctionProvider import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UpsertIT : BaseYdbTest() { @@ -24,7 +24,7 @@ class UpsertIT : BaseYdbTest() { fun `should perform UPSERT`() = tx { SchemaUtils.create(Products) - val provider = YdbFunctionProvider() + val provider = YdbFunctionProvider val data = listOf( Products.id to 1, Products.name to "Item1" diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt index 35483473..2f2e3693 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt @@ -6,10 +6,10 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbDialect -import tech.ydb.exposed.dialect.basic.YdbDialectProvider -import tech.ydb.exposed.dialect.basic.YdbTable -import tech.ydb.exposed.dialect.basic.YdbTtlColumnMode +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.YdbDialectProvider +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.YdbTtlColumnMode import tech.ydb.exposed.dialect.types.ydbUint64 class YdbDialectTtlSqlTest { @@ -21,8 +21,7 @@ class YdbDialectTtlSqlTest { @BeforeAll fun setupDb() { db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - driver = "tech.ydb.jdbc.YdbDriver" + url = "jdbc:ydb:grpc://localhost:2136/local" ) } } diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt index 335efbd1..77694464 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt @@ -3,7 +3,7 @@ package tech.ydb.exposed.dialect.unit.basic 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.basic.YdbGeneratedIds +import tech.ydb.exposed.dialect.YdbGeneratedIds import java.util.UUID class YdbGeneratedIdsTest { diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt index 3ab493da..5a32a3b1 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt @@ -9,7 +9,12 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.* +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.YdbDialectProvider +import tech.ydb.exposed.dialect.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec +import tech.ydb.exposed.dialect.YdbTable class YdbIndexSqlTest { @@ -20,8 +25,7 @@ class YdbIndexSqlTest { @BeforeAll fun setupDb() { db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - driver = "tech.ydb.jdbc.YdbDriver" + url = "jdbc:ydb:grpc://localhost:2136/local" ) } } diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt index 74e31989..d2b5a734 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt @@ -7,9 +7,9 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbDialectProvider -import tech.ydb.exposed.dialect.basic.YdbTable -import tech.ydb.exposed.dialect.basic.YdbTtlColumnMode +import tech.ydb.exposed.dialect.YdbDialectProvider +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.YdbTtlColumnMode import tech.ydb.exposed.dialect.types.ydbUint64 class YdbTableTest { @@ -21,8 +21,7 @@ class YdbTableTest { @BeforeAll fun setupDb() { db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - driver = "tech.ydb.jdbc.YdbDriver" + url = "jdbc:ydb:grpc://localhost:2136/local" ) } } diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt index a094eb95..55300913 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt @@ -6,11 +6,11 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.basic.YdbDialect -import tech.ydb.exposed.dialect.basic.YdbDialectProvider -import tech.ydb.exposed.dialect.basic.YdbSecondaryIndexSpec -import tech.ydb.exposed.dialect.basic.YdbTable -import tech.ydb.exposed.dialect.basic.renderYdbSecondaryIndex +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.YdbDialectProvider +import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.renderYdbSecondaryIndex class YdbUniqueIndexSqlTest { @@ -21,8 +21,7 @@ class YdbUniqueIndexSqlTest { @BeforeAll fun setupDb() { db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - driver = "tech.ydb.jdbc.YdbDriver" + url = "jdbc:ydb:grpc://localhost:2136/local" ) } } diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt index 9788c94e..d6429c7f 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt @@ -2,10 +2,10 @@ package tech.ydb.exposed.dialect.unit.functions import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.functions.YdbFunctionProvider +import tech.ydb.exposed.dialect.YdbFunctionProvider class FunctionTest { - private val provider = YdbFunctionProvider() + private val provider = YdbFunctionProvider @Test fun `should generate limit only`() { diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt index fe0d05ae..afcc1c81 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt @@ -4,13 +4,34 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import tech.ydb.core.Status +import tech.ydb.core.StatusCode +import tech.ydb.jdbc.exception.YdbStatusable import tech.ydb.exposed.dialect.transaction.YdbBackoffKind import tech.ydb.exposed.dialect.transaction.YdbRetryClassifier class YdbRetryClassifierTest { + private class FakeYdbStatusException( + private val ydbStatus: Status, + message: String = "fake" + ) : RuntimeException(message), YdbStatusable { + override fun getStatus(): Status = ydbStatus + } @Test - fun `should classify aborted as retryable fast`() { + fun `should classify structured aborted as retryable fast`() { + val decision = YdbRetryClassifier.classify( + FakeYdbStatusException(Status.of(StatusCode.ABORTED)), + idempotent = false + ) + + assertTrue(decision.retryable) + assertEquals(YdbBackoffKind.FAST, decision.backoffKind) + assertFalse(decision.recreateSession) + } + + @Test + fun `should classify regex aborted as retryable fast`() { val decision = YdbRetryClassifier.classify( RuntimeException("Status{code = ABORTED(code=400040)}"), idempotent = false @@ -69,6 +90,16 @@ class YdbRetryClassifierTest { assertFalse(nonRetryable.retryable) } + @Test + fun `should not classify unrelated timeout text as ydb timeout`() { + val decision = YdbRetryClassifier.classify( + RuntimeException("Connection timed out while reading metadata"), + idempotent = true + ) + + assertFalse(decision.retryable) + } + @Test fun `should classify undetermined as retryable only for idempotent operations`() { val retryable = YdbRetryClassifier.classify( @@ -92,4 +123,4 @@ class YdbRetryClassifierTest { assertTrue(fast >= 0) assertTrue(slow >= 0) } -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt index 3793c539..49c1fe94 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt @@ -3,7 +3,7 @@ package tech.ydb.exposed.dialect.unit.ttl 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.basic.YdbTtlColumnMode +import tech.ydb.exposed.dialect.YdbTtlColumnMode class YdbTtlColumnModeTest { 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 index d4e61429..f5eb208b 100644 --- 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 @@ -2,11 +2,11 @@ package tech.ydb.exposed.dialect.unit.types import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.types.YdbDataTypeProvider +import tech.ydb.exposed.dialect.YdbDataTypeProvider class YdbDataTypeProviderTest { - private val provider = YdbDataTypeProvider() + private val provider = YdbDataTypeProvider @Test fun `should map integer types`() { @@ -35,7 +35,7 @@ class YdbDataTypeProviderTest { @Test fun `should map JSON type`() { - Assertions.assertEquals("Json", provider.jsonType()) + Assertions.assertEquals("JsonDocument", provider.jsonType()) } @Test @@ -46,9 +46,9 @@ class YdbDataTypeProviderTest { @Test fun `should map date and time types`() { - Assertions.assertEquals("Date", provider.dateType()) - Assertions.assertEquals("Datetime", provider.dateTimeType()) - Assertions.assertEquals("Timestamp", provider.timestampType()) + Assertions.assertEquals("Date32", provider.dateType()) + Assertions.assertEquals("Datetime64", provider.dateTimeType()) + Assertions.assertEquals("Timestamp64", provider.timestampType()) } @Test 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..62018794 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonDocumentStringColumnTypeTest.kt @@ -0,0 +1,34 @@ +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.types.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)) + } +} From 76d1c31bbd7db79ab8661eb0e5f8605ec59625c9 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 11 May 2026 16:11:45 +0300 Subject: [PATCH 25/43] update text types and make properties internal --- .../tech/ydb/exposed/dialect/YdbDialect.kt | 14 +++--- .../tech/ydb/exposed/dialect/YdbIdTable.kt | 4 +- .../tech/ydb/exposed/dialect/YdbTable.kt | 4 +- .../dialect/integration/types/TextTypesIT.kt | 45 +++++++++++++++++++ .../dialect/integration/types/TypesIT.kt | 4 +- .../dialect/unit/basic/YdbTableTest.kt | 2 +- .../unit/types/YdbDataTypeProviderTest.kt | 4 +- 7 files changed, 62 insertions(+), 15 deletions(-) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TextTypesIT.kt 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 index c7684471..9900403f 100644 --- 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 @@ -46,9 +46,11 @@ internal object YdbDataTypeProvider : DataTypeProvider() { override fun floatType(): String = "Float" override fun doubleType(): String = "Double" - override fun varcharType(colLength: Int): String = "Utf8" - - override fun textType(): String = "Utf8" +// override fun varcharType(colLength: Int): String = "Utf8" +// +// override fun textType(): String = "Utf8" + override fun varcharType(colLength: Int): String = "Text" + override fun textType(): String = "Text" override fun mediumTextType(): String = textType() override fun largeTextType(): String = textType() @@ -193,7 +195,7 @@ class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider, YdbFunctionProvider ): String = "ALTER TABLE $tableName DROP INDEX $indexName" fun setTtl(table: YdbTable): String { - val tr = TransactionManager.Companion.current() + val tr = TransactionManager.current() val ttl = table.ttlSettings ?: error("TTL is not configured for table ${table.tableName}") @@ -215,14 +217,14 @@ class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider, YdbFunctionProvider } fun resetTtl(table: YdbTable): String { - val tr = TransactionManager.Companion.current() + val tr = TransactionManager.current() return "ALTER TABLE ${tr.identity(table)} RESET (TTL)" } internal object Metadata : DatabaseDialectMetadata() { override fun existingIndices(vararg tables: Table): Map> { - val connection = TransactionManager.Companion.current().connection.connection as Connection + val connection = TransactionManager.current().connection.connection as Connection val metadata = connection.metaData return tables.associateWith { table -> diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt index 0f79b17d..9be22599 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt @@ -43,10 +43,10 @@ abstract class YdbIdTable(name: String = "") : IdTable(name) { ) } - val ttlSettings: YdbTtlSettings? + internal val ttlSettings: YdbTtlSettings? get() = ydbFeatures.ttlSettings - val ydbSecondaryIndices: List + internal val ydbSecondaryIndices: List get() = ydbFeatures.ydbSecondaryIndices override fun createStatement(): List = diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt index 4ee6f9c7..504a27ba 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt @@ -40,10 +40,10 @@ open class YdbTable(name: String = "") : Table(name) { ) } - val ttlSettings: YdbTtlSettings? + internal val ttlSettings: YdbTtlSettings? get() = ydbFeatures.ttlSettings - val ydbSecondaryIndices: List + internal val ydbSecondaryIndices: List get() = ydbFeatures.ydbSecondaryIndices override fun createStatement(): List = 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..c1bf4504 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TextTypesIT.kt @@ -0,0 +1,45 @@ +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.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class TextTypesIT : BaseYdbTest() { + + object TextTypes : YdbTable("text_types") { + val id = integer("id") + val varcharCol = varchar("varchar_col", 255) + val textCol = text("text_col") + + override val primaryKey = PrimaryKey(id) + } + + 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 index f271c3f5..6c5e9531 100644 --- 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 @@ -63,8 +63,8 @@ class TypesIT : BaseYdbTest() { assertTrue(ddl.contains("bool_col Bool")) assertTrue(ddl.contains("float_col Float")) assertTrue(ddl.contains("double_col Double")) - assertTrue(ddl.contains("varchar_col Utf8")) - assertTrue(ddl.contains("text_col Utf8")) + 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/unit/basic/YdbTableTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt index d2b5a734..28f681df 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt @@ -82,7 +82,7 @@ class YdbTableTest { ddl ) assertTrue( - ddl.contains("`name` Utf8") || ddl.contains("name Utf8"), + ddl.contains("`name` Text") || ddl.contains("name Text"), ddl ) } 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 index f5eb208b..98fbe14c 100644 --- 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 @@ -17,8 +17,8 @@ class YdbDataTypeProviderTest { @Test fun `should map string and binary types`() { - Assertions.assertEquals("Utf8", provider.varcharType(255)) - Assertions.assertEquals("Utf8", provider.textType()) + Assertions.assertEquals("Text", provider.varcharType(255)) + Assertions.assertEquals("Text", provider.textType()) Assertions.assertEquals("String", provider.binaryType()) Assertions.assertEquals("String", provider.binaryType(100)) } From b5f2286f49be5765ae91d6b98b9e2bb4104d06e2 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Mon, 11 May 2026 16:18:36 +0300 Subject: [PATCH 26/43] update text types --- .../src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt | 3 --- 1 file changed, 3 deletions(-) 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 index 9900403f..2aba3eb3 100644 --- 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 @@ -46,9 +46,6 @@ internal object YdbDataTypeProvider : DataTypeProvider() { override fun floatType(): String = "Float" override fun doubleType(): String = "Double" -// override fun varcharType(colLength: Int): String = "Utf8" -// -// override fun textType(): String = "Utf8" override fun varcharType(colLength: Int): String = "Text" override fun textType(): String = "Text" override fun mediumTextType(): String = textType() From 9e206f9463fde459d34bc0694e6a0679f70c8000 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Tue, 12 May 2026 18:40:14 +0300 Subject: [PATCH 27/43] rework retry/transaction api, drop optimistic-locking and keyset pagination, testcontainers Reshapes the dialect around the review feedback in PR #214: - Retry: drop YdbRetryingTransactions object in favour of top-level ydbTransaction(db, idempotent, ...) and ydbReadOnlyTransaction(db). Classifier now only looks at YdbStatusable (no error-text parsing) and no longer unregisters the whole Database on BAD_SESSION. - DML: render native REPLACE in addition to UPSERT so Table.upsert and Table.replace from Exposed work out of the box. Reject MERGE explicitly. - Locking: remove YdbOptimisticLocking. YDB is OCC by design; conflicts surface as retryable Transaction locks invalidated. - Pagination: remove KeysetPagination. Exposed already provides orderBy/limit; the helper was sugar. - DDL DSL: extract YdbTableDsl interface, YdbTable and YdbIdTable share it via Kotlin delegation to a single YdbTableFeatures. No more copy-paste of ttl()/secondaryIndex()/createStatement() across both. - Types: temporal columns default to Date32 / Datetime64 / Timestamp64; YdbDialectProvider.connect(forceLegacyDatetimes = true) switches to legacy types. Drop string/utf8 UUID variants and YdbUuidStringIdTable (native Uuid is the primary path). YdbUuidColumnType.notNullValueToDB stops stringifying. UUID/uuidString helpers removed; only ydbUlid() top-level function remains. - Tests: BaseYdbTest now uses YdbHelperExtension (testcontainers, same as hibernate-dialect and spring-data-jdbc-ydb). Unit tests that needed a real DB to render DDL moved into integration/basic/*IT. New tests cover retries, REPLACE, legacy datetimes flag and Uuid round-trip without string conversion. - Build: pom aligned with sibling modules (ydb-sdk-bom, log4j2, maven-javadoc/source plugins, ossrh-s01 profile, TESTCONTAINERS_REUSE_ENABLE / YDB_DOCKER_IMAGE on failsafe). docker-compose.yml removed; CI workflow runs mvn verify only. - New: .github/workflows/publish-kotlin-exposed-dialect.yaml mirrors publish-hibernate-dialect.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci-exposed-ydb-dialect.yaml | 73 +-- .../publish-kotlin-exposed-dialect.yaml | 83 ++++ kotlin-exposed-dialect/CHANGELOG.md | 63 +-- kotlin-exposed-dialect/README.md | 439 +++++------------- kotlin-exposed-dialect/docker-compose.yml | 12 - kotlin-exposed-dialect/example/pom.xml | 6 + .../ydb/exposed/dialect/example/DemoApp.kt | 42 +- .../ydb/exposed/dialect/example/DemoTables.kt | 6 +- kotlin-exposed-dialect/pom.xml | 156 +++++-- .../tech/ydb/exposed/dialect/YdbDialect.kt | 125 +++-- .../ydb/exposed/dialect/YdbDialectProvider.kt | 30 +- .../ydb/exposed/dialect/YdbGeneratedIds.kt | 102 ++-- .../tech/ydb/exposed/dialect/YdbIdTable.kt | 58 +-- .../ydb/exposed/dialect/YdbSecondaryIndex.kt | 2 +- .../ydb/exposed/dialect/YdbStringIdTable.kt | 8 +- .../tech/ydb/exposed/dialect/YdbTable.kt | 58 +-- .../ydb/exposed/dialect/YdbTableSupport.kt | 52 ++- .../ydb/exposed/dialect/YdbTransaction.kt | 167 +++++++ .../dialect/locking/YdbOptimisticLocking.kt | 42 -- .../dialect/pagination/KeysetPagination.kt | 59 --- .../dialect/transaction/YdbRetryClassifier.kt | 106 ----- .../dialect/transaction/YdbRetryDecision.kt | 14 - .../transaction/YdbRetryingTransaction.kt | 83 ---- .../dialect/types/YdbCustomColumnTypes.kt | 67 +-- .../dialect/integration/base/BaseYdbTest.kt | 72 +-- .../basic/YdbDialectTtlSqlIT.kt} | 41 +- .../integration/basic/YdbIndexSqlIT.kt | 130 ++++++ .../basic/YdbTableIT.kt} | 77 ++- .../integration/basic/YdbUniqueIndexSqlIT.kt | 71 +++ .../dialect/integration/dao/GeneratedIdsIT.kt | 19 +- .../locking/OptimisticLockingIT.kt | 126 ----- .../pagination/KeysetPaginationIT.kt | 91 ---- .../transaction/YdbRetryingTransactionsIT.kt | 58 ++- .../types/LegacyTemporalTypesIT.kt | 77 +++ .../dialect/integration/types/UuidTypesIT.kt | 62 +-- .../dialect/integration/upsert/UpsertIT.kt | 50 +- .../dialect/unit/basic/YdbGeneratedIdsTest.kt | 25 +- .../dialect/unit/basic/YdbIndexSqlTest.kt | 158 ------- .../unit/basic/YdbUniqueIndexSqlTest.kt | 92 ---- .../transaction/YdbRetryClassifierTest.kt | 128 ++--- .../unit/types/YdbDataTypeProviderTest.kt | 64 +-- .../types/YdbUuidAsStringColumnTypeTest.kt | 35 -- .../unit/types/YdbUuidAsUtf8ColumnTypeTest.kt | 32 -- .../unit/types/YdbUuidColumnTypeTest.kt | 17 +- .../src/test/resources/log4j2.xml | 19 + 45 files changed, 1372 insertions(+), 1925 deletions(-) create mode 100644 .github/workflows/publish-kotlin-exposed-dialect.yaml delete mode 100644 kotlin-exposed-dialect/docker-compose.yml create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTransaction.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryDecision.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt rename kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/{unit/basic/YdbDialectTtlSqlTest.kt => integration/basic/YdbDialectTtlSqlIT.kt} (57%) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbIndexSqlIT.kt rename kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/{unit/basic/YdbTableTest.kt => integration/basic/YdbTableIT.kt} (52%) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbUniqueIndexSqlIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyTemporalTypesIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsStringColumnTypeTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsUtf8ColumnTypeTest.kt create mode 100644 kotlin-exposed-dialect/src/test/resources/log4j2.xml diff --git a/.github/workflows/ci-exposed-ydb-dialect.yaml b/.github/workflows/ci-exposed-ydb-dialect.yaml index f315ad71..9d3c8485 100644 --- a/.github/workflows/ci-exposed-ydb-dialect.yaml +++ b/.github/workflows/ci-exposed-ydb-dialect.yaml @@ -1,66 +1,43 @@ -name: CI - Kotlin Exposed YDB Dialect +name: Kotlin Exposed YDB Dialect CI with Maven on: push: - branches: [ main, master ] + branches: + - main paths: - - "kotlin-exposed-dialect/**" - - ".github/workflows/ci-exposed-ydb-dialect.yaml" + - 'kotlin-exposed-dialect/**' + - '.github/workflows/ci-exposed-ydb-dialect.yaml' pull_request: paths: - - "kotlin-exposed-dialect/**" - - ".github/workflows/ci-exposed-ydb-dialect.yaml" + - 'kotlin-exposed-dialect/**' + - '.github/workflows/ci-exposed-ydb-dialect.yaml' + +env: + MAVEN_ARGS: --batch-mode --update-snapshots -Dstyle.color=always jobs: - build-and-test: + build: + name: Kotlin Exposed YDB Dialect runs-on: ubuntu-latest - defaults: - run: - working-directory: kotlin-exposed-dialect + strategy: + matrix: + java: [ '17', '21' ] steps: - - name: Checkout - uses: actions/checkout@v5 + - uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK ${{ matrix.java }} uses: actions/setup-java@v4 with: - distribution: temurin - java-version: "17" + java-version: ${{ matrix.java }} + distribution: 'temurin' cache: maven - cache-dependency-path: kotlin-exposed-dialect/pom.xml - - - name: Show Docker and Compose versions - run: | - docker version - docker compose version - - - name: Start local YDB with docker compose - run: docker compose up -d - - - name: Wait for YDB gRPC port - shell: bash - run: | - for i in {1..60}; do - if (echo > /dev/tcp/127.0.0.1/2136) >/dev/null 2>&1; then - echo "YDB is reachable on port 2136" - exit 0 - fi - echo "Waiting for YDB..." - sleep 2 - done - echo "YDB did not become ready in time" - docker compose logs || true - exit 1 - - - name: Run Maven build - run: mvn --batch-mode --update-snapshots clean install - - name: Dump YDB logs on failure - if: failure() - run: docker compose logs + - name: Download dependencies + working-directory: ./kotlin-exposed-dialect + run: mvn $MAVEN_ARGS dependency:go-offline - - name: Stop YDB - if: always() - run: docker compose down -v \ No newline at end of file + - 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 index 5436a14a..5ef43419 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -4,46 +4,25 @@ Initial release of the Kotlin Exposed dialect for YDB. ### Added -- YDB dialect registration for Exposed JDBC. -- Connection helper based on `YdbDialectProvider`. -- YDB-specific data type provider. -- YDB SQL function provider. -- `LIMIT` / `OFFSET` SQL generation. -- Native YDB `UPSERT` generation for Exposed DSL. -- Explicit handling of unsupported ANSI `MERGE` scenarios. +- YDB `VendorDialect` for Exposed JDBC, registered via `YdbDialectProvider.connect`. +- `ydbTransaction { ... }` / `ydbReadOnlyTransaction { ... }` — retryable transactions + that classify failures via `YdbStatusable` (no fragile error-message parsing) and apply + appropriate backoff for `ABORTED` / `OVERLOADED` / `BAD_SESSION` / `TRANSPORT_UNAVAILABLE` + / `TIMEOUT` (idempotent only) / `UNDETERMINED` (idempotent only). +- Native `UPSERT` / `REPLACE` rendering — wired into Exposed's standard `Table.upsert` and + `Table.replace` DSL. - YDB-compatible `CREATE TABLE` generation with mandatory primary key. -- Secondary index generation, including global indexes, unique indexes and cover columns. -- TTL support for supported YDB column modes. -- JDBC metadata support for reading existing indexes. -- Custom column types for decimal, interval, JSON, UUID variants and unsigned integer values. -- Decimal literal helper for update-expression scenarios. -- UUID and ULID generation helpers. -- YDB table helpers for UUID/ULID/string identifiers. -- Explicit rejection of SQL `AUTO_INCREMENT`. -- Retry classifier for common YDB retriable failures. -- Read-only and read-write retrying transaction helpers. -- Keyset pagination helpers. -- Optimistic locking helper based on a version column. -- Console demo application with CRUD, UPSERT, indexes and pagination. -- Docker Compose configuration for local YDB. -- Unit and integration test suites. -- GitHub Actions workflow for build and integration tests. - -### Tested Scenarios - -- Connection to local YDB through JDBC. -- Table creation and DDL generation. -- CRUD operations. -- UPSERT through Exposed DSL. -- Batch operations. -- DAO basic workflow. -- Generated UUID and ULID identifiers. -- Secondary indexes. -- TTL. -- Numeric, binary, temporal, interval, decimal, UUID, unsigned integer and JSON types. -- JOIN queries. -- Subqueries. -- Many-to-many relation through a join table. -- Optimistic locking. -- Keyset pagination. -- Multi-table integration scenario. +- Secondary index DSL on `YdbTable` / `YdbIdTable` — global, async, cover columns, unique. +- TTL clause on `CREATE TABLE` / `ALTER TABLE`, plus numeric epoch modes. +- JDBC metadata for reading existing indexes. +- Default temporal mapping to extended types: `Date32` / `Datetime64` / `Timestamp64`. + Legacy types are available via `YdbDialectProvider.connect(forceLegacyDatetimes = true)`. +- Custom column types for `Decimal`, `Interval`, `Json`, `JsonDocument`, three `Uuid` + flavours and `Uint64`, plus a `ydbDecimalLiteral` helper for update expressions. +- Table base classes for generated identifiers: `YdbUuidIdTable`, `YdbUuidStringIdTable`, + `YdbUlidTable`, `YdbStringIdTable`. +- Explicit rejection of `AUTO_INCREMENT` and ANSI `MERGE` (`UPSERT` covers the use case). +- Console demo application showing CRUD, UPSERT and DDL. +- Integration tests powered by testcontainers via `tech.ydb.test:ydb-junit5-support`. +- GitHub Actions workflows for CI (`ci-exposed-ydb-dialect.yaml`) and Maven Central + publishing (`publish-kotlin-exposed-dialect.yaml`). diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 224b26c3..76029e5d 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -1,48 +1,48 @@ # Kotlin Exposed YDB Dialect -Модуль добавляет поддержку YDB для Kotlin Exposed через JDBC. Диалект описывает YDB-специфичную генерацию SQL и DDL, маппинг типов, работу с `UPSERT`, secondary indexes, TTL, транзакциями и вспомогательными сценариями Exposed DSL/DAO. +YDB integration for [JetBrains Exposed](https://github.com/JetBrains/Exposed) via JDBC. +The module provides: -## Требования +- a Kotlin Exposed `VendorDialect` for YDB (DDL, SQL, type mapping, secondary indexes, TTL); +- `Table.upsert` / `Table.replace` DSL backed by native YDB `UPSERT` / `REPLACE`; +- a retryable transaction wrapper that handles YDB's OCC retries transparently; +- table base classes for tables with generated identifiers (UUID, ULID, ...). -- JDK 17 или новее +## Requirements + +- JDK 17+ - Maven -- Docker и Docker Compose для интеграционных тестов и локального example-приложения - YDB JDBC Driver - JetBrains Exposed 1.x -## Подключение к YDB - -Для подключения используется `YdbDialectProvider`. Он регистрирует JDBC-драйвер YDB, metadata provider и передаёт Exposed явный dialect. +## Quick start ```kotlin -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import tech.ydb.exposed.dialect.basic.YdbDialectProvider +import tech.ydb.exposed.dialect.YdbDialectProvider +import tech.ydb.exposed.dialect.ydbTransaction val db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - user = "", - password = "" + url = "jdbc:ydb:grpc://localhost:2136/local" ) -transaction(db) { +ydbTransaction(db) { // Exposed DSL / DAO code } ``` -По умолчанию используется JDBC driver: - -```text -tech.ydb.jdbc.YdbDriver -``` +`YdbDialectProvider.connect` registers the YDB JDBC driver and dialect metadata exactly once, +then opens an Exposed `Database` with a sane default configuration +(`SERIALIZABLE` isolation, no nested transactions). -## Пример таблицы +## Defining tables -YDB требует, чтобы у каждой таблицы был явно задан `PRIMARY KEY`. Для YDB-специфичных возможностей можно наследоваться от `YdbTable`. +YDB requires every table to declare a `PRIMARY KEY`. Inherit from `YdbTable` to get YDB-specific +DDL helpers on top of the standard Exposed `Table`: ```kotlin -import tech.ydb.exposed.dialect.basic.YdbIndexScope -import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode -import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.types.ydbDecimal object Products : YdbTable("products") { @@ -65,51 +65,19 @@ object Products : YdbTable("products") { syncMode = YdbIndexSyncMode.ASYNC, coverColumns = listOf(name, price) ) - - secondaryIndex( - name = "products_sku_unique_idx", - sku, - unique = true, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) } } ``` -Такой класс генерирует YDB-compatible `CREATE TABLE` с primary key и secondary index. - -## Основные возможности - -### SQL и DDL - -Реализованы: - -- регистрация YDB dialect для Exposed JDBC; -- генерация YDB-compatible `LIMIT` / `OFFSET`; -- генерация `UPSERT`; -- генерация `CREATE TABLE` с обязательным `PRIMARY KEY`; -- создание и удаление secondary indexes; -- поддержка YDB global secondary indexes; -- поддержка `UNIQUE` secondary indexes; -- поддержка `COVER` columns для secondary indexes; -- TTL для таблиц; -- чтение существующих индексов через JDBC metadata. - -### UPSERT - -YDB имеет собственную команду `UPSERT`. Диалект формирует SQL в формате, который ожидает YDB, включая обязательный список колонок: +For tables that need to participate in Exposed DAO, use `YdbIdTable` (or its specializations +`YdbUuidIdTable`, `YdbUuidStringIdTable`, `YdbUlidTable`, `YdbStringIdTable`). -```sql -UPSERT INTO products (id, sku, name) VALUES (?, ?, ?) -``` +## Insert / upsert / replace / update / delete -Пример через Exposed DSL: +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 -import org.jetbrains.exposed.v1.jdbc.upsert -import java.math.BigDecimal - Products.upsert { it[id] = 1 it[sku] = "BOOK-001" @@ -117,307 +85,156 @@ Products.upsert { it[category] = "books" it[price] = BigDecimal("39.90") } -``` -### Secondary indexes - -Диалект поддерживает два способа объявления индекса: - -- стандартный Exposed API: - ```kotlin - index(isUnique = false, sku) - ``` -- YDB-специфичный API: - ```kotlin - secondaryIndex( - name = "products_category_idx", - category, - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.ASYNC, - coverColumns = listOf(name, price) - ) - ``` - -### Типы данных - -Поддерживаются стандартные и YDB-специфичные типы: - -- `Int16`, `Int32`, `Int64`; -- `Uint64`; -- `Float`, `Double`; -- `Bool`; -- `Utf8`; -- `String` для бинарных данных; -- `Date`; -- `Datetime`; -- `Timestamp`; -- `Interval`; -- `Decimal(p, s)`; -- `Uuid`; -- UUID как `Utf8`; -- UUID как bytes / `String`; -- `Json`. - -Для дополнительных типов доступны extension-функции: -```kotlin -ydbDecimal("price", precision = 10, scale = 2) -ydbInterval("duration") -ydbJson("payload") -ydbUuid("id") -ydbUuidUtf8("external_id") -ydbUuidBytes("binary_uuid") -ydbUint64("counter") -``` - -Для update-expression сценариев с decimal можно использовать literal helper: - -```kotlin -import tech.ydb.exposed.dialect.types.ydbDecimalLiteral -import java.math.BigDecimal - -it.update(Products.price, ydbDecimalLiteral(BigDecimal("45.00"), 10, 2)) +Products.replace { + it[id] = 1 + it[sku] = "BOOK-001" + it[name] = "Kotlin in Action, 2nd edition" + it[category] = "books" + it[price] = BigDecimal("44.90") +} ``` -## Идентификаторы без AUTO_INCREMENT +ANSI `MERGE` is intentionally rejected — `UPSERT` / `REPLACE` cover the same use cases. -YDB не использует SQL `AUTO_INCREMENT` в привычном для реляционных СУБД виде. Диалект явно отклоняет `autoIncrement()` и предоставляет application-side генерацию идентификаторов. +## Retryable transactions -Доступны: - -- `YdbGeneratedIds.uuid()`; -- `YdbGeneratedIds.uuidString()`; -- `YdbGeneratedIds.ulid()`; -- `YdbUuidIdTable`; -- `YdbUuidStringIdTable`; -- `YdbUlidTable`; -- `YdbStringIdTable`. - -Пример: +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.basic.YdbUlidTable +import tech.ydb.exposed.dialect.ydbTransaction +import tech.ydb.exposed.dialect.ydbReadOnlyTransaction -object Events : YdbUlidTable("events") { - val payload = text("payload") +ydbTransaction(db) { + // read-write, non-idempotent } -``` - -## Транзакции и retry - -Модуль работает со стандартными Exposed JDBC transactions и добавляет helper для повторного выполнения транзакций при retriable-ошибках YDB. - -Поддерживаются режимы: - -- `READ_WRITE`; -- `READ_ONLY`. - -Retry classifier обрабатывает типичные статусы и сообщения YDB, включая: - -- `ABORTED`; -- `UNAVAILABLE`; -- `OVERLOADED`; -- `BAD_SESSION`; -- `SESSION_EXPIRED`; -- `SESSION_BUSY`; -- `TIMEOUT`; -- `UNDETERMINED`. -Повторы выполняются с backoff и jitter. - -## Пагинация - -Обычная пагинация через `LIMIT` поддерживается на уровне dialect. - -Для больших таблиц также доступен keyset pagination helper: - -```kotlin -import tech.ydb.exposed.dialect.pagination.keysetPageAsc +ydbTransaction(db, idempotent = true) { + // single UPSERT / pure read body — TIMEOUT / UNDETERMINED also retried +} -val page = Products - .selectAll() - .keysetPageAsc(Products.id, lastValue = null, limit = 20) - .toList() +ydbReadOnlyTransaction(db) { + // shortcut for idempotent read-only work +} ``` -Для обратного порядка используется `keysetPageDesc`. +Set `idempotent = true` only when the body can be safely re-executed (pure reads, single +`UPSERT` / `REPLACE`, idempotent business operation). The classifier inspects YDB status codes +via `YdbStatusable` rather than parsing error message text. -## Optimistic Locking +## Types -Для сценариев с версионированием строк добавлен helper `YdbOptimisticLocking.updateWithVersion`. Он проверяет текущую версию строки и выполняет update только если версия совпадает с ожидаемой. +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` | `String` | +| `uuid` | `Uuid` | +| `date` | `Date32` | +| `datetime` | `Datetime64` | +| `timestamp` | `Timestamp64` | +| `json` | `JsonDocument` | -- строка содержит колонку `version`; -- клиент передаёт ожидаемую версию; -- helper обновляет строку и увеличивает `version`; -- если версия устарела, update не выполняется. +Temporal columns default to YDB **extended** types (`Date32`, `Datetime64`, `Timestamp64`). +To target the legacy unsigned types when integrating with an existing schema, pass +`forceLegacyDatetimes = true`: -## Совместимость с Exposed DSL / DAO - -Интеграционные тесты покрывают следующие сценарии: - -- подключение к YDB; -- CRUD через Exposed DSL; -- `UPSERT`; -- batch operations; -- DAO smoke workflow; -- generated UUID / ULID identifiers; -- secondary indexes; -- TTL; -- JOIN; -- подзапросы; -- many-to-many через связующую таблицу; -- optimistic locking; -- keyset pagination; -- работу с YDB-типами. - -## Локальный запуск YDB - -В модуле есть `docker-compose.yml` для локальной YDB. - -Запуск: - -```bash -docker compose up -d +```kotlin +val db = YdbDialectProvider.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + forceLegacyDatetimes = true // emits Date / Datetime / Timestamp +) ``` -YDB будет доступна по адресу: +Additional YDB-specific column types are available via extension functions on `Table`: -```text -jdbc:ydb:grpc://localhost:2136/local +```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") ``` -Web UI: +`ydbUint64` is backed by `Long` and supports values `0..Long.MAX_VALUE`. Use a wider type +(`BigInteger`) if you need the full `Uint64` range. -```text -http://localhost:8765 -``` +For Decimal literals inside update expressions there's `ydbDecimalLiteral`: -Остановка: +```kotlin +import tech.ydb.exposed.dialect.types.ydbDecimalLiteral -```bash -docker compose down -v +it.update(Products.price, ydbDecimalLiteral(BigDecimal("45.00"), 10, 2)) ``` -## Тесты +## Identifiers -Полная проверка: +YDB does not expose `AUTO_INCREMENT`. The dialect explicitly rejects `autoIncrement()`. +Use one of the IdTable base classes instead: -```bash -mvn clean install -``` +- `YdbUuidIdTable` — native YDB `Uuid` column, auto-generated via `UUID.randomUUID()`; +- `YdbUlidTable` — 26-char [ULID](https://github.com/ulid/spec), lexicographically sortable; +- `YdbStringIdTable` — caller-provided business key. -Команда запускает: +A top-level `ydbUlid()` is also exposed for generating ULIDs manually. -- unit tests; -- integration tests; +```kotlin +import tech.ydb.exposed.dialect.YdbUlidTable -Интеграционные тесты рассчитаны на локальную YDB, поднятую через Docker Compose. +object Events : YdbUlidTable("events") { + val payload = text("payload") +} +``` -## Demo Application +## TTL -Demo-приложение вынесено в отдельный модуль:: +```kotlin +object Sessions : YdbTable("sessions") { + val id = integer("id") + val expireAt = timestamp("expire_at") + override val primaryKey = PrimaryKey(id) -```text -example + init { + ttl(expireAt, "PT1H") + } +} ``` -Он не входит в библиотечный jar и предназначен для демонстрации использования dialect. -Приложение показывает: +Numeric epoch columns are also supported via `YdbTtlColumnMode.SECONDS` / +`MILLISECONDS` / `MICROSECONDS` / `NANOSECONDS`. -- подключение к YDB; -- создание таблицы; -- генерацию DDL; -- secondary index с `COVER`; -- `UPSERT`; -- чтение данных; -- update decimal-поля; -- keyset pagination; -- delete. +## Tests -Перед запуском example-модуля библиотеку нужно установить в локальный Maven repository: +Integration tests use [testcontainers](https://www.testcontainers.org/) via +`tech.ydb.test:ydb-junit5-support` — no manual Docker setup needed: -```powershell -mvn clean install +```bash +mvn verify ``` -После этого example можно запускать отдельно. +## Demo application -Запуск в PowerShell: +The `example/` module contains a runnable demo. Install the dialect first: -```powershell -cd example -mvn --% exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.example.DemoAppKt -``` - -Если классы ещё не скомпилированы: - -```powershell -cd example -mvn --% compile exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.example.DemoAppKt +```bash +mvn -DskipTests -DskipITs install ``` -Запуск в Linux, macOS или cmd: +Then run the demo: ```bash cd example mvn exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.example.DemoAppKt ``` -## CI - -Для модуля подготовлен GitHub Actions workflow. - -## Структура модуля - -```text -src/main/kotlin/tech/ydb/exposed/dialect/basic -``` - -Базовые классы dialect, registration/bootstrap, table helpers, generated IDs, TTL, secondary indexes и metadata. - -```text -src/main/kotlin/tech/ydb/exposed/dialect/functions -``` - -Генерация SQL-конструкций dialect: `UPSERT`, `LIMIT`, обработка `MERGE`. - -```text -src/main/kotlin/tech/ydb/exposed/dialect/types -``` - -YDB data type provider и custom column types. - -```text -src/main/kotlin/tech/ydb/exposed/dialect/transaction -``` - -Retry classifier и transaction helpers. - -```text -src/main/kotlin/tech/ydb/exposed/dialect/pagination -``` - -Keyset pagination helpers. - -```text -src/main/kotlin/tech/ydb/exposed/dialect/locking -``` - -Optimistic locking helper. - -```text -example/src/main/kotlin/tech/ydb/exposed/dialect/example -``` - -Отдельное demo-приложение. - -## Особенности реализации - -- Для каждой таблицы требуется явный `PRIMARY KEY`. -- `AUTO_INCREMENT` не используется; вместо него предусмотрены UUID/ULID helpers. -- `UPSERT` реализован через native YDB syntax. -- ANSI `MERGE` не преобразуется в `UPSERT`, поскольку эти операции не являются полными эквивалентами. -- `UNIQUE` secondary indexes поддерживаются на уровне генерации DDL. Такие индексы используются для проверки уникальности значений; при нарушении уникальности YDB возвращает ошибку выполнения операции. -- `FOREIGN KEY` не используется как основной механизм моделирования в YDB в рамках данного dialect. \ No newline at end of file +It expects a YDB instance at `jdbc:ydb:grpc://localhost:2136/local`. diff --git a/kotlin-exposed-dialect/docker-compose.yml b/kotlin-exposed-dialect/docker-compose.yml deleted file mode 100644 index f9641b26..00000000 --- a/kotlin-exposed-dialect/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -services: - ydb: - image: ydbplatform/local-ydb:latest - container_name: ydb-local - hostname: localhost - ports: - - "2136:2136" - - "8765:8765" - environment: - GRPC_PORT: 2136 - MON_PORT: 8765 -# YDB_GRPC_PUBLIC_HOST: localhost \ No newline at end of file diff --git a/kotlin-exposed-dialect/example/pom.xml b/kotlin-exposed-dialect/example/pom.xml index 5747f893..eed6c90e 100644 --- a/kotlin-exposed-dialect/example/pom.xml +++ b/kotlin-exposed-dialect/example/pom.xml @@ -29,6 +29,12 @@ kotlin-exposed-ydb-dialect 0.1.0 + + + tech.ydb.jdbc + ydb-jdbc-driver + 2.3.22 + 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 index 0bcd5a7e..8c27ea1b 100644 --- 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 @@ -4,21 +4,19 @@ 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.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.upsert -import tech.ydb.exposed.dialect.basic.YdbDialectProvider -import tech.ydb.exposed.dialect.pagination.keysetPageAsc +import tech.ydb.exposed.dialect.YdbDialectProvider import tech.ydb.exposed.dialect.types.ydbDecimalLiteral +import tech.ydb.exposed.dialect.ydbTransaction import java.math.BigDecimal fun main() { val db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - driver = "tech.ydb.jdbc.YdbDriver" + url = "jdbc:ydb:grpc://localhost:2136/local" ) - transaction(db) { + ydbTransaction(db) { println("== Schema setup ==") runCatching { SchemaUtils.drop(DemoProducts) } SchemaUtils.create(DemoProducts) @@ -30,11 +28,9 @@ fun main() { 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]}]") - } + 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 ==") @@ -61,30 +57,6 @@ fun main() { println("updated -> ${it[DemoProducts.name]} (${it[DemoProducts.price]})") } - println() - println("== KEYSET PAGINATION ==") - val page1 = DemoProducts - .selectAll() - .keysetPageAsc(DemoProducts.id, lastValue = null, limit = 2) - .toList() - - println("page1:") - page1.forEach { - println(" ${it[DemoProducts.id]} -> ${it[DemoProducts.name]}") - } - - val lastSeenId = page1.last()[DemoProducts.id] - - val page2 = DemoProducts - .selectAll() - .keysetPageAsc(DemoProducts.id, lastValue = lastSeenId, limit = 2) - .toList() - - println("page2:") - page2.forEach { - println(" ${it[DemoProducts.id]} -> ${it[DemoProducts.name]}") - } - println() println("== DELETE ==") DemoProducts.deleteWhere { DemoProducts.sku eq "HW-001" } 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 index a9e1158e..6f761dd6 100644 --- 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 @@ -1,8 +1,8 @@ package tech.ydb.exposed.dialect.example -import tech.ydb.exposed.dialect.basic.YdbIndexScope -import tech.ydb.exposed.dialect.basic.YdbIndexSyncMode -import tech.ydb.exposed.dialect.basic.YdbTable +import tech.ydb.exposed.dialect.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.types.ydbDecimal object DemoProducts : YdbTable("demo_products") { diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index fff6a11d..570e22fc 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -11,7 +11,7 @@ jar Kotlin Exposed YDB Dialect - Exposed dialect for YDB (YQL) + Kotlin Exposed dialect for YDB (YQL) https://github.com/ydb-platform/ydb-java-dialects @@ -21,8 +21,21 @@ 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 @@ -32,25 +45,31 @@ UTF-8 - 2.2.20 - 1.0.0 - 17 - 17 + 17 - 5.9.3 + 2.2.20 + 1.0.0 + + 5.10.2 + 2.17.2 - 2.3.22 2.3.22 + 2.3.22 - - - Apache License, Version 2.0 - https://www.apache.org/licenses/LICENSE-2.0 - - + + + + tech.ydb + ydb-sdk-bom + ${ydb.sdk.version} + pom + import + + + @@ -77,50 +96,48 @@ ${exposed.version} + + org.jetbrains.exposed + exposed-dao + ${exposed.version} + + tech.ydb.jdbc ydb-jdbc-driver ${ydb.jdbc.version} + org.junit.jupiter junit-jupiter - ${junit.version} + ${junit5.version} test tech.ydb.test ydb-junit5-support - ${ydb.sdk.version} test - - - - - - - org.jetbrains.exposed - exposed-dao - ${exposed.version} + org.apache.logging.log4j + log4j-slf4j-impl + ${log4j2.version} + test - - org.jetbrains.kotlin kotlin-maven-plugin ${kotlin.version} - compile compile @@ -134,7 +151,6 @@ - test-compile test-compile @@ -148,11 +164,41 @@ + + + + + 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 @@ -168,7 +214,7 @@ - + org.apache.maven.plugins maven-failsafe-plugin @@ -184,11 +230,57 @@ **/*IT.* + + true + ydbplatform/local-ydb:trunk + - - \ No newline at end of file + + + + 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/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt index 2aba3eb3..ac556371 100644 --- 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 @@ -15,10 +15,20 @@ 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 - -internal object YdbDataTypeProvider : DataTypeProvider() { +/** + * Default YDB column type mappings used by Exposed when a column is declared via standard + * Exposed DSL (`integer`, `varchar`, `date`, ...) — see `YdbCustomColumnTypes.kt` for YDB-specific + * column types that have no direct Exposed equivalent (e.g. `JsonDocument`, `Interval`, `Uint64`). + * + * Temporal columns default to YDB **extended** types (`Date32`, `Datetime64`, `Timestamp64`). + * Use `YdbDialectProvider.connect(..., forceLegacyDatetimes = true)` to fall back to the + * legacy unsigned range (`Date`, `Datetime`, `Timestamp`) when integrating with schemas + * that already use them. + */ +internal class YdbDataTypeProvider( + private val forceLegacyDatetimes: Boolean +) : DataTypeProvider() { override fun byteType(): String = "Int8" override fun ubyteType(): String = "Uint8" @@ -37,7 +47,7 @@ internal object YdbDataTypeProvider : DataTypeProvider() { override fun integerAutoincType(): String = throw UnsupportedOperationException( - "YDB does not support AUTO_INCREMENT. Use YdbUuidIdTable, YdbUuidStringIdTable, or YdbUlidTable instead." + "YDB does not support AUTO_INCREMENT. Use YdbUuidIdTable, YdbUlidTable or YdbStringIdTable instead." ) override fun longType(): String = "Int64" @@ -53,9 +63,9 @@ internal object YdbDataTypeProvider : DataTypeProvider() { override fun uuidType(): String = "Uuid" - override fun dateType(): String = "Date32" - override fun dateTimeType(): String = "Datetime64" - override fun timestampType(): String = "Timestamp64" + override fun dateType(): String = if (forceLegacyDatetimes) "Date" else "Date32" + override fun dateTimeType(): String = if (forceLegacyDatetimes) "Datetime" else "Datetime64" + override fun timestampType(): String = if (forceLegacyDatetimes) "Timestamp" else "Timestamp64" override fun jsonType(): String = "JsonDocument" } @@ -75,31 +85,27 @@ internal object YdbFunctionProvider : FunctionProvider() { keyColumns: List>, where: Op?, transaction: Transaction - ): String { - require(where == null) { - "YDB UPSERT does not support WHERE clause in this dialect implementation" - } - - val columnList = data.joinToString(", ") { (column, _) -> - transaction.identity(column) - } + ): String = renderUpsertOrReplace("UPSERT", table, data, expression, where, transaction) - if (expression.isNotBlank()) { - val valuesExpression = expression.trim() - val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { - "($columnList) $valuesExpression" - } else { - valuesExpression - } - - return "UPSERT INTO ${transaction.identity(table)} $expressionWithColumns" - } - - val valueList = data.joinToString(", ") { (column, value) -> - valueToSqlLiteral(column, value) + /** + * YDB has native `REPLACE INTO` which has the same write semantics as INSERT-or-overwrite + * (key is the primary key, no need for an extra unique constraint). + */ + override fun replace( + table: Table, + columns: List>, + expression: String, + transaction: Transaction, + prepared: Boolean + ): String { + val columnList = columns.joinToString(", ") { transaction.identity(it) } + val valuesExpression = expression.trim() + val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { + "($columnList) $valuesExpression" + } else { + valuesExpression } - - return "UPSERT INTO ${transaction.identity(table)} ($columnList) VALUES ($valueList)" + return "REPLACE INTO ${transaction.identity(table)} $expressionWithColumns" } override fun merge( @@ -134,6 +140,39 @@ internal object YdbFunctionProvider : FunctionProvider() { } } + private fun renderUpsertOrReplace( + operation: String, + table: Table, + data: List, Any?>>, + expression: String, + where: Op?, + transaction: Transaction + ): String { + require(where == null) { + "YDB $operation does not support WHERE clause" + } + + val columnList = data.joinToString(", ") { (column, _) -> + transaction.identity(column) + } + + if (expression.isNotBlank()) { + val valuesExpression = expression.trim() + val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { + "($columnList) $valuesExpression" + } else { + valuesExpression + } + return "$operation INTO ${transaction.identity(table)} $expressionWithColumns" + } + + val valueList = data.joinToString(", ") { (column, value) -> + valueToSqlLiteral(column, value) + } + + return "$operation INTO ${transaction.identity(table)} ($columnList) VALUES ($valueList)" + } + @Suppress("UNCHECKED_CAST") private fun valueToSqlLiteral(column: Column<*>, value: Any?): String { if (value == null) return "NULL" @@ -143,10 +182,25 @@ internal object YdbFunctionProvider : FunctionProvider() { } } -class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider, YdbFunctionProvider) { +/** + * Exposed [VendorDialect] for YDB. + * + * Usually obtained via [YdbDialectProvider.connect], which wires it into a [Database] together + * with a default [org.jetbrains.exposed.v1.core.DatabaseConfig] tuned for YDB + * (SERIALIZABLE isolation, nested transactions disabled). + */ +class YdbDialect internal constructor( + forceLegacyDatetimes: Boolean +) : VendorDialect( + DIALECT_NAME, + YdbDataTypeProvider(forceLegacyDatetimes), + YdbFunctionProvider +) { + + constructor() : this(forceLegacyDatetimes = false) override fun createIndex(index: Index): String { - val tr = runCatching { TransactionManager.Companion.current() }.getOrNull() + val tr = runCatching { TransactionManager.current() }.getOrNull() if (!index.functions.isNullOrEmpty()) { throw UnsupportedOperationException("YDB dialect does not support functional indexes") } @@ -175,7 +229,7 @@ class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider, YdbFunctionProvider } fun createSecondaryIndex(table: Table, spec: YdbSecondaryIndexSpec): String { - val tr = TransactionManager.Companion.current() + val tr = TransactionManager.current() return buildString { append("ALTER TABLE ") append(tr.identity(table)) @@ -278,5 +332,8 @@ class YdbDialect : VendorDialect("ydb", YdbDataTypeProvider, YdbFunctionProvider val unique: Boolean ) } -} + internal companion object { + const val DIALECT_NAME = "ydb" + } +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt index e7572b8a..baeb531d 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt @@ -5,31 +5,51 @@ import org.jetbrains.exposed.v1.jdbc.Database import java.sql.Connection import java.util.concurrent.atomic.AtomicBoolean +/** + * Entry point that wires the YDB dialect into Exposed and produces a [Database] tuned for YDB. + * + * Registration with Exposed is performed once per JVM in [init], which is automatically called + * by [connect]. After that, plain `Database.connect("jdbc:ydb:...")` also works because Exposed + * resolves dialect/driver via the registered prefix. + */ object YdbDialectProvider { - private const val DIALECT_NAME = "ydb" private const val URL_PREFIX = "jdbc:ydb:" private const val DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" private val initialized = AtomicBoolean(false) + /** + * Registers the YDB JDBC driver, dialect name and dialect metadata with Exposed. + * Idempotent — repeated calls are no-ops. + */ fun init() { if (!initialized.compareAndSet(false, true)) return Database.registerJdbcDriver( prefix = URL_PREFIX, driverClassName = DRIVER_CLASS, - dialect = DIALECT_NAME + dialect = YdbDialect.DIALECT_NAME ) - Database.registerDialectMetadata(DIALECT_NAME) { + Database.registerDialectMetadata(YdbDialect.DIALECT_NAME) { YdbDialect.Metadata } } + /** + * Opens a YDB-backed Exposed [Database]. + * + * @param url JDBC URL, e.g. `jdbc:ydb:grpc://localhost:2136/local`. + * @param forceLegacyDatetimes When `true`, the dialect emits legacy YDB temporal types + * (`Date`, `Datetime`, `Timestamp`) instead of the default extended ones + * (`Date32`, `Datetime64`, `Timestamp64`). Use this only when integrating with schemas that + * already rely on the unsigned/legacy range. + */ fun connect( url: String, user: String = "", - password: String = "" + password: String = "", + forceLegacyDatetimes: Boolean = false ): Database { init() @@ -39,7 +59,7 @@ object YdbDialectProvider { user = user, password = password, databaseConfig = DatabaseConfig { - explicitDialect = YdbDialect() + explicitDialect = YdbDialect(forceLegacyDatetimes = forceLegacyDatetimes) defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE defaultReadOnly = false useNestedTransactions = false diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt index 2156907d..bb1ab67f 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt @@ -6,78 +6,76 @@ import tech.ydb.exposed.dialect.types.ydbUuid import java.security.SecureRandom import java.util.UUID -object YdbGeneratedIds { - private const val ULID_LENGTH = 26 - private const val RANDOM_BYTES = 10 - private val alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray() - private val random = SecureRandom() - - fun uuid(): UUID = UUID.randomUUID() - - fun uuidString(): String = uuid().toString() - - fun ulid(nowMillis: Long = System.currentTimeMillis()): String { - require(nowMillis >= 0) { "ULID timestamp must be non-negative" } - - val chars = CharArray(ULID_LENGTH) - var timestamp = nowMillis - - for (i in 9 downTo 0) { - chars[i] = alphabet[(timestamp and 31L).toInt()] - timestamp = timestamp ushr 5 - } +private const val ULID_LENGTH = 26 +private const val ULID_RANDOM_BYTES = 10 +private val ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray() +private val ULID_RANDOM = SecureRandom() + +/** + * Generates a [ULID](https://github.com/ulid/spec) encoded as a 26-character Crockford-base32 string. + * Time component is taken from [nowMillis] (defaults to current wall clock), random component is + * cryptographically random. Lexicographic ordering of ULIDs matches their time component. + */ +fun ydbUlid(nowMillis: Long = System.currentTimeMillis()): String { + require(nowMillis >= 0) { "ULID timestamp must be non-negative" } + + val chars = CharArray(ULID_LENGTH) + var timestamp = nowMillis + + for (i in 9 downTo 0) { + chars[i] = ULID_ALPHABET[(timestamp and 31L).toInt()] + timestamp = timestamp ushr 5 + } - val bytes = ByteArray(RANDOM_BYTES) - random.nextBytes(bytes) + val bytes = ByteArray(ULID_RANDOM_BYTES) + ULID_RANDOM.nextBytes(bytes) - var bitBuffer = 0 - var bitCount = 0 - var charIndex = 10 + var bitBuffer = 0 + var bitCount = 0 + var charIndex = 10 - for (byte in bytes) { - bitBuffer = (bitBuffer shl 8) or (byte.toInt() and 0xff) - bitCount += 8 + for (byte in bytes) { + bitBuffer = (bitBuffer shl 8) or (byte.toInt() and 0xff) + bitCount += 8 - while (bitCount >= 5 && charIndex < ULID_LENGTH) { - bitCount -= 5 - chars[charIndex++] = alphabet[(bitBuffer ushr bitCount) and 31] - } - } - - if (charIndex < ULID_LENGTH) { - chars[charIndex] = alphabet[(bitBuffer shl (5 - bitCount)) and 31] + while (bitCount >= 5 && charIndex < ULID_LENGTH) { + bitCount -= 5 + chars[charIndex++] = ULID_ALPHABET[(bitBuffer ushr bitCount) and 31] } + } - return String(chars) + if (charIndex < ULID_LENGTH) { + chars[charIndex] = ULID_ALPHABET[(bitBuffer shl (5 - bitCount)) and 31] } + + return String(chars) } +/** + * IdTable with a native YDB `Uuid` primary key, auto-generated client-side via + * [UUID.randomUUID]. + */ open class YdbUuidIdTable(name: String = "") : YdbIdTable(name) { final override val id: Column> = ydbUuid("id") - .clientDefault { YdbGeneratedIds.uuid() } - .entityId() - - final override val primaryKey = PrimaryKey(id) -} - -open class YdbUuidStringIdTable( - name: String = "", - idLength: Int = 36 -) : YdbIdTable(name) { - final override val id: Column> = varchar("id", idLength) - .clientDefault { YdbGeneratedIds.uuidString() } + .clientDefault { UUID.randomUUID() } .entityId() final override val primaryKey = PrimaryKey(id) } +/** + * IdTable with a [ULID][ydbUlid] string primary key, auto-generated client-side. + * + * Pick this over [YdbUuidIdTable] when you want lexicographically-sortable identifiers + * (e.g. range scans by id approximate time-of-creation order). + */ open class YdbUlidTable( name: String = "", - idLength: Int = 26 + idLength: Int = ULID_LENGTH ) : YdbIdTable(name) { final override val id: Column> = varchar("id", idLength) - .clientDefault { YdbGeneratedIds.ulid() } + .clientDefault { ydbUlid() } .entityId() final override val primaryKey = PrimaryKey(id) -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt index 9be22599..61fddd05 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt @@ -1,54 +1,18 @@ package tech.ydb.exposed.dialect -import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.dao.id.IdTable -import tech.ydb.exposed.dialect.YdbIndexScope -import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec -import tech.ydb.exposed.dialect.YdbTableFeatures -import tech.ydb.exposed.dialect.YdbTtlColumnMode -import tech.ydb.exposed.dialect.YdbTtlSettings -import tech.ydb.exposed.dialect.buildYdbCreateStatement -abstract class YdbIdTable(name: String = "") : IdTable(name) { - private val ydbFeatures = YdbTableFeatures() - - protected fun ttl( - column: Column<*>, - intervalIso8601: String, - mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE - ) { - ydbFeatures.ttl(column, intervalIso8601, mode) - } - - protected fun secondaryIndex( - name: String, - vararg columns: Column<*>, - unique: Boolean = false, - scope: YdbIndexScope = YdbIndexScope.GLOBAL, - syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, - indexType: String? = null, - coverColumns: List> = emptyList(), - withParams: Map = emptyMap() - ) { - ydbFeatures.secondaryIndex( - name = name, - columns = *columns, - unique = unique, - scope = scope, - syncMode = syncMode, - indexType = indexType, - coverColumns = coverColumns, - withParams = withParams - ) - } - - internal val ttlSettings: YdbTtlSettings? - get() = ydbFeatures.ttlSettings - - internal val ydbSecondaryIndices: List - get() = ydbFeatures.ydbSecondaryIndices +/** + * Base class for YDB tables with a typed entity id, used with Exposed DAO. + * + * Same DDL surface as [YdbTable] — [ttl] / [secondaryIndex] come from [YdbTableDsl] via + * Kotlin delegation. Subclasses declare the primary key explicitly via `final override val id` + * / `primaryKey` (see [YdbUuidIdTable], [YdbUlidTable], [YdbStringIdTable]). + */ +abstract class YdbIdTable(name: String = "") : + IdTable(name), + YdbTableDsl by YdbTableFeatures() { override fun createStatement(): List = buildYdbCreateStatement(this, ttlSettings, ydbSecondaryIndices) -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt index 33899e50..12e66e58 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt @@ -24,7 +24,7 @@ data class YdbSecondaryIndexSpec( ) internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { - val tr = TransactionManager.Companion.current() + val tr = TransactionManager.current() require(spec.columns.isNotEmpty()) { "YDB secondary index must contain at least one column" diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt index 9bedb26f..48cba5c9 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt @@ -3,6 +3,12 @@ package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.dao.id.EntityID +/** + * IdTable with a user-provided string primary key (e.g. business keys, slugs). + * + * No client default — callers must set `[idColumn] = "..."` on every insert. + * For auto-generated ids prefer [YdbUuidIdTable] (native UUID) or [YdbUlidTable] (sortable). + */ open class YdbStringIdTable( name: String = "", idLength: Int = 64 @@ -11,4 +17,4 @@ open class YdbStringIdTable( final override val id: Column> = varchar("id", idLength).entityId() final override val primaryKey = PrimaryKey(id) -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt index 504a27ba..61310e47 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt @@ -1,51 +1,21 @@ package tech.ydb.exposed.dialect -import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Table -import tech.ydb.exposed.dialect.YdbTableFeatures -import tech.ydb.exposed.dialect.YdbTtlColumnMode -import tech.ydb.exposed.dialect.YdbTtlSettings -import tech.ydb.exposed.dialect.buildYdbCreateStatement -open class YdbTable(name: String = "") : Table(name) { - private val ydbFeatures = YdbTableFeatures() - - protected fun ttl( - column: Column<*>, - intervalIso8601: String, - mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE - ) { - ydbFeatures.ttl(column, intervalIso8601, mode) - } - - protected fun secondaryIndex( - name: String, - vararg columns: Column<*>, - unique: Boolean = false, - scope: YdbIndexScope = YdbIndexScope.GLOBAL, - syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, - indexType: String? = null, - coverColumns: List> = emptyList(), - withParams: Map = emptyMap() - ) { - ydbFeatures.secondaryIndex( - name = name, - columns = *columns, - unique = unique, - scope = scope, - syncMode = syncMode, - indexType = indexType, - coverColumns = coverColumns, - withParams = withParams - ) - } - - internal val ttlSettings: YdbTtlSettings? - get() = ydbFeatures.ttlSettings - - internal val ydbSecondaryIndices: List - get() = ydbFeatures.ydbSecondaryIndices +/** + * Base class for YDB row-oriented tables. + * + * Adds YDB-specific DDL extensions on top of Exposed's [Table] (see [YdbTableDsl]): + * - [ttl] declares a TTL on a date/numeric column; + * - [secondaryIndex] declares a YDB secondary index with COVER / ASYNC / WITH params. + * + * Tables that need a generated primary key for DAO should use [YdbIdTable] (or one of its + * specializations: [YdbUuidIdTable], [YdbUlidTable], [YdbStringIdTable]). + */ +open class YdbTable(name: String = "") : + Table(name), + YdbTableDsl by YdbTableFeatures() { override fun createStatement(): List = buildYdbCreateStatement(this, ttlSettings, ydbSecondaryIndices) -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt index 509e3f95..8153ae52 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt @@ -3,20 +3,19 @@ package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager -import tech.ydb.exposed.dialect.YdbTtlColumnMode -import tech.ydb.exposed.dialect.YdbTtlSettings - -internal class YdbTableFeatures { - private var ttlSettingsState: YdbTtlSettings? = null - private val secondaryIndices = mutableListOf() +/** + * YDB-specific DDL surface shared by [YdbTable] and [YdbIdTable]. + * + * Both implement this interface through Kotlin delegation to a single [YdbTableFeatures] + * instance, so TTL/secondary-index state is collected and rendered in exactly one place. + */ +sealed interface YdbTableDsl { fun ttl( column: Column<*>, intervalIso8601: String, mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE - ) { - ttlSettingsState = YdbTtlSettings(column, intervalIso8601, mode) - } + ) fun secondaryIndex( name: String, @@ -27,6 +26,33 @@ internal class YdbTableFeatures { indexType: String? = null, coverColumns: List> = emptyList(), withParams: Map = emptyMap() + ) + + val ttlSettings: YdbTtlSettings? + val ydbSecondaryIndices: List +} + +/** + * Default in-memory implementation of [YdbTableDsl] used as a delegate by [YdbTable] and + * [YdbIdTable]. Users typically don't need to reference this class directly. + */ +class YdbTableFeatures : YdbTableDsl { + private var ttlSettingsState: YdbTtlSettings? = null + private val secondaryIndices = mutableListOf() + + override fun ttl(column: Column<*>, intervalIso8601: String, mode: YdbTtlColumnMode) { + ttlSettingsState = YdbTtlSettings(column, intervalIso8601, mode) + } + + override fun secondaryIndex( + name: String, + vararg columns: Column<*>, + unique: Boolean, + scope: YdbIndexScope, + syncMode: YdbIndexSyncMode, + indexType: String?, + coverColumns: List>, + withParams: Map ) { require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } @@ -42,10 +68,10 @@ internal class YdbTableFeatures { ) } - val ttlSettings: YdbTtlSettings? + override val ttlSettings: YdbTtlSettings? get() = ttlSettingsState - val ydbSecondaryIndices: List + override val ydbSecondaryIndices: List get() = secondaryIndices } @@ -54,7 +80,7 @@ internal fun buildYdbCreateStatement( ttlSettings: YdbTtlSettings?, secondaryIndices: List ): List { - val tr = TransactionManager.Companion.current() + val tr = TransactionManager.current() val pk = table.primaryKey ?: error("YDB requires PRIMARY KEY for every table: ${table.tableName}") @@ -132,4 +158,4 @@ internal fun validateYdbTtlColumn(ttl: YdbTtlSettings) { require(supported) { "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" } -} \ No newline at end of file +} 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..5054e615 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTransaction.kt @@ -0,0 +1,167 @@ +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 +import tech.ydb.core.StatusCode +import tech.ydb.jdbc.exception.YdbStatusable +import kotlin.math.min +import kotlin.random.Random + +/** + * Runs [statement] inside an Exposed [transaction] and retries it on retryable YDB errors + * (ABORTED / OVERLOADED / BAD_SESSION / TRANSPORT_UNAVAILABLE / ...). + * + * This is the recommended way to execute a unit of work against YDB from Kotlin Exposed. + * YDB uses Optimistic Concurrency Control, so under contention a transaction can fail with + * `Transaction locks invalidated` — the retry loop here makes such conflicts transparent. + * + * Set [idempotent] to `true` if the transaction body has no externally observable side effects + * besides the database write (e.g. read-only queries, single UPSERT/REPLACE, idempotent writes). + * Errors with undetermined outcome (TIMEOUT / UNDETERMINED) are retried only when [idempotent] is `true`. + */ +fun ydbTransaction( + db: Database? = null, + idempotent: Boolean = false, + maxAttempts: Int = 5, + readOnly: Boolean = false, + statement: JdbcTransaction.() -> T +): T { + require(maxAttempts >= 1) { "maxAttempts must be >= 1" } + + var lastError: Throwable? = null + + repeat(maxAttempts) { index -> + val attempt = index + 1 + try { + return transaction( + db = db, + transactionIsolation = java.sql.Connection.TRANSACTION_SERIALIZABLE, + readOnly = readOnly, + statement = statement + ) + } catch (t: Throwable) { + lastError = t + + val decision = classifyYdbError(t, idempotent) + if (!decision.retryable || attempt >= maxAttempts) { + throw t + } + + val sleepMs = backoffMillis(decision.backoffKind, attempt) + if (sleepMs > 0) { + try { + Thread.sleep(sleepMs) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } + } + } + } + + throw lastError ?: IllegalStateException("Retry loop finished without result") +} + +/** + * Shortcut for read-only retryable transactions. Reads in YDB are inherently idempotent, + * so timeouts and undetermined outcomes are also retried. + */ +fun ydbReadOnlyTransaction( + db: Database? = null, + maxAttempts: Int = 5, + statement: JdbcTransaction.() -> T +): T = ydbTransaction( + db = db, + idempotent = true, + maxAttempts = maxAttempts, + readOnly = true, + statement = statement +) + +internal enum class YdbBackoffKind { + NONE, + INSTANT, + FAST, + SLOW +} + +internal data class YdbRetryDecision( + val retryable: Boolean, + val backoffKind: YdbBackoffKind = YdbBackoffKind.NONE +) + +internal fun classifyYdbError(error: Throwable, idempotent: Boolean): YdbRetryDecision { + val code = extractStatusCode(error) ?: return YdbRetryDecision(retryable = false) + + return when (code) { + StatusCode.ABORTED, + StatusCode.UNAVAILABLE, + StatusCode.TRANSPORT_UNAVAILABLE, + StatusCode.CLIENT_DISCOVERY_FAILED, + StatusCode.CLIENT_GRPC_ERROR, + StatusCode.CLIENT_INTERNAL_ERROR -> + YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) + + StatusCode.OVERLOADED, + StatusCode.CLIENT_RESOURCE_EXHAUSTED, + StatusCode.CLIENT_LIMITS_REACHED -> + YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.SLOW) + + StatusCode.BAD_SESSION, + StatusCode.SESSION_EXPIRED -> + YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.INSTANT) + + StatusCode.SESSION_BUSY -> + YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) + + StatusCode.TIMEOUT, + StatusCode.CLIENT_DEADLINE_EXCEEDED, + StatusCode.CLIENT_DEADLINE_EXPIRED -> + YdbRetryDecision( + retryable = idempotent, + backoffKind = if (idempotent) YdbBackoffKind.INSTANT else YdbBackoffKind.NONE + ) + + StatusCode.UNDETERMINED -> + YdbRetryDecision( + retryable = idempotent, + backoffKind = if (idempotent) YdbBackoffKind.FAST else YdbBackoffKind.NONE + ) + + else -> YdbRetryDecision(retryable = false) + } +} + +private fun extractStatusCode(error: Throwable): StatusCode? { + var current: Throwable? = error + while (current != null) { + if (current is YdbStatusable) { + return current.status.code + } + current = current.cause + } + return null +} + +internal fun backoffMillis(kind: YdbBackoffKind, attempt: Int): Long { + val n = attempt.coerceAtLeast(1) + return when (kind) { + YdbBackoffKind.NONE -> 0L + YdbBackoffKind.INSTANT -> 0L + YdbBackoffKind.FAST -> { + val base = 25L * (1L shl min(n - 1, 5)) + jitter(base, 15) + } + YdbBackoffKind.SLOW -> { + val base = 200L * (1L shl min(n - 1, 4)) + jitter(base, 50) + } + } +} + +private fun jitter(base: Long, spreadPercent: Int): Long { + if (base <= 0) return 0L + val spread = (base * spreadPercent) / 100 + return base + Random.nextLong(-spread, spread + 1) +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt deleted file mode 100644 index 4615e7fa..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/locking/YdbOptimisticLocking.kt +++ /dev/null @@ -1,42 +0,0 @@ -package tech.ydb.exposed.dialect.locking - -import org.jetbrains.exposed.v1.core.Column -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.core.statements.UpdateBuilder -import org.jetbrains.exposed.v1.jdbc.select -import org.jetbrains.exposed.v1.jdbc.update - -object YdbOptimisticLocking { - - fun updateWithVersion( - table: Table, - idColumn: Column, - idValue: ID, - versionColumn: Column, - expectedVersion: Int, - body: (UpdateBuilder<*>) -> Unit - ): Boolean { - val currentVersion = table - .select(idColumn, versionColumn) - .where { idColumn eq idValue } - .singleOrNull() - ?.get(versionColumn) - ?: return false - - if (currentVersion != expectedVersion) { - return false - } - - val updatedCount = table.update( - where = { (idColumn eq idValue) and (versionColumn eq expectedVersion) } - ) { stmt -> - body(stmt) - stmt[versionColumn] = expectedVersion + 1 - } - - return updatedCount > 0 - } -} - diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt deleted file mode 100644 index 4f318474..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/pagination/KeysetPagination.kt +++ /dev/null @@ -1,59 +0,0 @@ -package tech.ydb.exposed.dialect.pagination - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.SortOrder -import org.jetbrains.exposed.v1.core.greater -import org.jetbrains.exposed.v1.core.less -import org.jetbrains.exposed.v1.jdbc.Query -import org.jetbrains.exposed.v1.jdbc.andWhere - -/** - * ASC: - * SELECT ... WHERE key > :lastValue ORDER BY key ASC LIMIT :limit - * DESC: - * SELECT ... WHERE key < :lastValue ORDER BY key DESC LIMIT :limit - * Intended for sortable columns, ideally primary key columns - */ -fun > Query.keysetPage( - column: Column, - lastValue: T?, - limit: Int, - direction: SortOrder = SortOrder.ASC -): Query { - require(limit > 0) { "limit must be > 0" } - require(direction == SortOrder.ASC || direction == SortOrder.DESC) { - "keysetPage supports only ASC and DESC sort orders" - } - - if (lastValue != null) { - when (direction) { - SortOrder.ASC -> andWhere { column greater lastValue } - SortOrder.DESC -> andWhere { column less lastValue } - else -> error("Unsupported sort order: $direction") - } - } - - return orderBy(column to direction).limit(limit) -} - -fun > Query.keysetPageAsc( - column: Column, - lastValue: T?, - limit: Int -): Query = keysetPage( - column = column, - lastValue = lastValue, - limit = limit, - direction = SortOrder.ASC -) - -fun > Query.keysetPageDesc( - column: Column, - lastValue: T?, - limit: Int -): Query = keysetPage( - column = column, - lastValue = lastValue, - limit = limit, - direction = SortOrder.DESC -) \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt deleted file mode 100644 index 2c70197a..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryClassifier.kt +++ /dev/null @@ -1,106 +0,0 @@ -package tech.ydb.exposed.dialect.transaction - -import tech.ydb.core.StatusCode -import tech.ydb.jdbc.exception.YdbStatusable -import kotlin.math.min -import kotlin.random.Random - -object YdbRetryClassifier { - private val statusCodeRegex = Regex("""STATUS\{CODE\s*=\s*([A-Z_]+)""") - - fun classify(error: Throwable, idempotent: Boolean): YdbRetryDecision { - val code = extractStatusCode(error) - - return when (code) { - StatusCode.ABORTED -> - YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) - - StatusCode.UNAVAILABLE, - StatusCode.TRANSPORT_UNAVAILABLE, - StatusCode.CLIENT_DISCOVERY_FAILED, - StatusCode.CLIENT_GRPC_ERROR, - StatusCode.CLIENT_INTERNAL_ERROR -> - YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) - - StatusCode.OVERLOADED, - StatusCode.CLIENT_RESOURCE_EXHAUSTED, - StatusCode.CLIENT_LIMITS_REACHED -> - YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.SLOW) - - StatusCode.BAD_SESSION, - StatusCode.SESSION_EXPIRED -> - YdbRetryDecision(retryable = true, recreateSession = true, backoffKind = YdbBackoffKind.INSTANT) - - StatusCode.SESSION_BUSY -> - YdbRetryDecision(retryable = true, recreateSession = true, backoffKind = YdbBackoffKind.FAST) - - StatusCode.TIMEOUT, - StatusCode.CLIENT_DEADLINE_EXCEEDED, - StatusCode.CLIENT_DEADLINE_EXPIRED -> - YdbRetryDecision(retryable = idempotent, backoffKind = if (idempotent) YdbBackoffKind.INSTANT else YdbBackoffKind.NONE) - - StatusCode.UNDETERMINED -> - YdbRetryDecision(retryable = idempotent, backoffKind = if (idempotent) YdbBackoffKind.FAST else YdbBackoffKind.NONE) - - StatusCode.PRECONDITION_FAILED, - StatusCode.ALREADY_EXISTS, - StatusCode.NOT_FOUND, - StatusCode.SCHEME_ERROR, - StatusCode.GENERIC_ERROR, - StatusCode.INTERNAL_ERROR, - StatusCode.BAD_REQUEST, - StatusCode.UNAUTHORIZED, - StatusCode.UNSUPPORTED, - StatusCode.CANCELLED, - StatusCode.CLIENT_CANCELLED, - StatusCode.CLIENT_UNAUTHENTICATED, - StatusCode.CLIENT_CALL_UNIMPLEMENTED, - StatusCode.EXTERNAL_ERROR, - StatusCode.UNUSED_STATUS, - StatusCode.SUCCESS, - null -> - YdbRetryDecision(retryable = false) - } - } - - private fun extractStatusCode(error: Throwable): StatusCode? { - var current: Throwable? = error - while (current != null) { - if (current is YdbStatusable) { - return current.status.code - } - - val message = current.message.orEmpty().uppercase() - val matched = statusCodeRegex.find(message)?.groupValues?.getOrNull(1) - if (matched != null) { - runCatching { return StatusCode.valueOf(matched) } - } - - current = current.cause - } - - return null - } - - fun backoffMillis(kind: YdbBackoffKind, attempt: Int): Long { - val n = attempt.coerceAtLeast(1) - return when (kind) { - YdbBackoffKind.NONE -> 0L - YdbBackoffKind.INSTANT -> 0L - YdbBackoffKind.FAST -> { - val base = 25L * (1L shl min(n - 1, 5)) - jitter(base, 15) - } - YdbBackoffKind.SLOW -> { - val base = 200L * (1L shl min(n - 1, 4)) - jitter(base, 50) - } - } - } - - private fun jitter(base: Long, spreadPercent: Int): Long { - if (base <= 0) return 0L - val spread = (base * spreadPercent) / 100 - return base + Random.nextLong(-spread, spread + 1) - } -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryDecision.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryDecision.kt deleted file mode 100644 index d47d682c..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryDecision.kt +++ /dev/null @@ -1,14 +0,0 @@ -package tech.ydb.exposed.dialect.transaction - -enum class YdbBackoffKind { - NONE, - INSTANT, - FAST, - SLOW -} - -data class YdbRetryDecision( - val retryable: Boolean, - val recreateSession: Boolean = false, - val backoffKind: YdbBackoffKind = YdbBackoffKind.NONE -) \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt deleted file mode 100644 index 7a8c1714..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/transaction/YdbRetryingTransaction.kt +++ /dev/null @@ -1,83 +0,0 @@ -package tech.ydb.exposed.dialect.transaction - -import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.JdbcTransaction -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import java.sql.Connection - -enum class YdbTransactionMode { - READ_WRITE, - READ_ONLY -} - -object YdbRetryingTransactions { - - fun withRetry( - db: Database, - mode: YdbTransactionMode = YdbTransactionMode.READ_WRITE, - maxAttempts: Int = 5, - idempotent: Boolean = mode == YdbTransactionMode.READ_ONLY, - block: JdbcTransaction.() -> T - ): T { - require(maxAttempts >= 1) { "maxAttempts must be >= 1" } - - var lastError: Throwable? = null - - repeat(maxAttempts) { index -> - val attempt = index + 1 - try { - return transaction( - db = db, - transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, - readOnly = mode == YdbTransactionMode.READ_ONLY - ) { - block() - } - } catch (t: Throwable) { - lastError = t - - val decision = YdbRetryClassifier.classify(t, idempotent) - if (!decision.retryable || attempt >= maxAttempts) { - throw t - } - - val sleepMs = YdbRetryClassifier.backoffMillis(decision.backoffKind, attempt) - if (sleepMs > 0) { - try { - Thread.sleep(sleepMs) - } catch (e: InterruptedException) { - Thread.currentThread().interrupt() - throw e - } - } - } - } - - throw lastError ?: IllegalStateException("Retry loop finished without result") - } - - fun readOnly( - db: Database, - maxAttempts: Int = 5, - block: JdbcTransaction.() -> T - ): T = withRetry( - db = db, - mode = YdbTransactionMode.READ_ONLY, - maxAttempts = maxAttempts, - idempotent = true, - block = block - ) - - fun readWrite( - db: Database, - maxAttempts: Int = 5, - idempotent: Boolean = false, - block: JdbcTransaction.() -> T - ): T = withRetry( - db = db, - mode = YdbTransactionMode.READ_WRITE, - maxAttempts = maxAttempts, - idempotent = idempotent, - block = block - ) -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt index d578ad8a..eb61975d 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt @@ -51,8 +51,7 @@ class YdbIntervalColumnType : ColumnType() { override fun notNullValueToDB(value: Duration): Any = value - override fun nonNullValueToString(value: Duration): String = - "'${value}'" + override fun nonNullValueToString(value: Duration): String = "'$value'" } class YdbJsonStringColumnType : ColumnType() { @@ -77,37 +76,13 @@ class YdbJsonDocumentStringColumnType : ColumnType() { "'${value.replace("'", "''")}'" } -class YdbUuidAsUtf8ColumnType : ColumnType() { - override fun sqlType(): String = "Utf8" - - override fun valueFromDB(value: Any): UUID = when (value) { - is UUID -> value - is String -> UUID.fromString(value) - else -> error("Unexpected value for UUID(Utf8): $value of ${value::class}") - } - - override fun notNullValueToDB(value: UUID): Any = value.toString() - - override fun nonNullValueToString(value: UUID): String = - "'$value'" -} - -class YdbUuidAsStringColumnType : ColumnType() { - override fun sqlType(): String = "String" - - override fun valueFromDB(value: Any): UUID = when (value) { - is UUID -> value - is ByteArray -> UUID.fromString(value.toString(Charsets.UTF_8)) - is String -> UUID.fromString(value) - else -> error("Unexpected value for UUID(String): $value of ${value::class}") - } - - override fun notNullValueToDB(value: UUID): Any = value.toString().toByteArray(Charsets.UTF_8) - - override fun nonNullValueToString(value: UUID): String = - "'$value'" -} - +/** + * Maps a Kotlin [UUID] to the native YDB `Uuid` type. + * + * Binds [java.util.UUID] directly via JDBC — no string conversion. Use this for new schemas; + * Exposed's built-in `uuid()` extension also produces a `Uuid` column under this dialect + * because [YdbDataTypeProvider.uuidType] maps to `Uuid`. + */ class YdbUuidColumnType : ColumnType() { override fun sqlType(): String = "Uuid" @@ -117,20 +92,18 @@ class YdbUuidColumnType : ColumnType() { else -> error("Unexpected value for native UUID: $value of ${value::class}") } - override fun notNullValueToDB(value: UUID): Any = - value.toString() + override fun notNullValueToDB(value: UUID): Any = value - override fun nonNullValueToString(value: UUID): String = - "'$value'" + override fun nonNullValueToString(value: UUID): String = "Uuid(\"$value\")" } +/** + * Maps YDB `Uint64` to Kotlin [Long]. + * + * Only the non-negative subset that fits into [Long] (0..[Long.MAX_VALUE]) is supported. + * Use [BigInteger] mapping if you need values above [Long.MAX_VALUE]. + */ class YdbUint64ColumnType : ColumnType() { - /** - * Maps YDB Uint64 to Kotlin Long. - * - * This implementation supports only the non-negative subset that fits into Long - * (0..Long.MAX_VALUE). Values above Long.MAX_VALUE are not supported by this mapping. - */ override fun sqlType(): String = "Uint64" override fun valueFromDB(value: Any): Long = when (value) { @@ -175,15 +148,11 @@ fun Table.ydbInterval(name: String): Column = fun Table.ydbJson(name: String): Column = registerColumn(name, YdbJsonStringColumnType()) +/** Indexed JSON storage — analogous to PostgreSQL `jsonb`. */ fun Table.ydbJsonDocument(name: String): Column = registerColumn(name, YdbJsonDocumentStringColumnType()) -fun Table.ydbUuidUtf8(name: String): Column = - registerColumn(name, YdbUuidAsUtf8ColumnType()) - -fun Table.ydbUuidBytes(name: String): Column = - registerColumn(name, YdbUuidAsStringColumnType()) - +/** Native YDB `Uuid` column. Equivalent to Exposed's `uuid()` under this dialect. */ fun Table.ydbUuid(name: String): Column = registerColumn(name, YdbUuidColumnType()) 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 index 25834fec..e15be60b 100644 --- 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 @@ -5,12 +5,20 @@ 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.TransactionManager -import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.extension.RegisterExtension import tech.ydb.exposed.dialect.YdbDialectProvider -import java.sql.Connection +import tech.ydb.exposed.dialect.ydbTransaction +import tech.ydb.test.junit5.YdbHelperExtension +/** + * 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 @@ -19,48 +27,44 @@ abstract class BaseYdbTest { @BeforeEach fun setupDatabase() { - db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local" - ) - } + val jdbcUrl = buildString { + append("jdbc:ydb:") + append(if (ydb.useTls()) "grpcs://" else "grpc://") + append(ydb.endpoint()) + append(ydb.database()) + ydb.authToken()?.let { append("?token=").append(it) } + } + + db = YdbDialectProvider.connect(url = jdbcUrl) - @BeforeEach - fun setupSchema() = transaction( - db = db, - transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, - readOnly = false - ) { if (tables.isNotEmpty()) { - runCatching { SchemaUtils.drop(*tables.toTypedArray()) } - SchemaUtils.create(*tables.toTypedArray()) + ydbTransaction(db) { + runCatching { SchemaUtils.drop(*tables.toTypedArray()) } + SchemaUtils.create(*tables.toTypedArray()) + } } } @AfterEach - fun teardownSchema() { + fun teardown() { if (!::db.isInitialized) return - transaction( - db = db, - transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, - readOnly = false - ) { - if (tables.isNotEmpty()) { - runCatching { SchemaUtils.drop(*tables.toTypedArray()) } + if (tables.isNotEmpty()) { + runCatching { + ydbTransaction(db) { + SchemaUtils.drop(*tables.toTypedArray()) + } } } - runCatching { - TransactionManager.closeAndUnregister(db) - } + runCatching { TransactionManager.closeAndUnregister(db) } } - protected fun tx(block: JdbcTransaction.() -> Unit) = - transaction( - db = db, - transactionIsolation = Connection.TRANSACTION_SERIALIZABLE, - readOnly = false - ) { - block() - } -} \ No newline at end of file + 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/unit/basic/YdbDialectTtlSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt similarity index 57% rename from kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt index 2f2e3693..fea6e28f 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectTtlSqlTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt @@ -1,30 +1,15 @@ -package tech.ydb.exposed.dialect.unit.basic +package tech.ydb.exposed.dialect.integration.basic import org.jetbrains.exposed.v1.javatime.timestamp -import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbDialect -import tech.ydb.exposed.dialect.YdbDialectProvider import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.YdbTtlColumnMode +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbUint64 -class YdbDialectTtlSqlTest { - - companion object { - private lateinit var db: Database - - @JvmStatic - @BeforeAll - fun setupDb() { - db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local" - ) - } - } +class YdbDialectTtlSqlIT : BaseYdbTest() { object AlterTtlTimestampTable : YdbTable("unit_alter_ttl_timestamp_table") { val id = integer("id") @@ -49,29 +34,29 @@ class YdbDialectTtlSqlTest { } @Test - fun `should generate alter table set ttl for timestamp`() = transaction(db) { + fun `generates ALTER TABLE SET TTL for a timestamp column`() = tx { val dialect = db.dialect as YdbDialect val sql = dialect.setTtl(AlterTtlTimestampTable) - assertTrue(sql.contains("ALTER TABLE")) - assertTrue(sql.contains("""SET (TTL = Interval("PT24H") ON expire_at)""")) + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("""SET (TTL = Interval("PT24H") ON expire_at)"""), sql) } @Test - fun `should generate alter table set ttl for numeric column`() = transaction(db) { + fun `generates ALTER TABLE SET TTL for a numeric epoch column`() = tx { val dialect = db.dialect as YdbDialect val sql = dialect.setTtl(AlterTtlNumericTable) - assertTrue(sql.contains("ALTER TABLE")) - assertTrue(sql.contains("""SET (TTL = Interval("PT2H") ON modified_at_epoch AS SECONDS)""")) + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("""SET (TTL = Interval("PT2H") ON modified_at_epoch AS SECONDS)"""), sql) } @Test - fun `should generate alter table reset ttl`() = transaction(db) { + fun `generates ALTER TABLE RESET TTL`() = tx { val dialect = db.dialect as YdbDialect val sql = dialect.resetTtl(AlterTtlTimestampTable) - assertTrue(sql.contains("ALTER TABLE")) - assertTrue(sql.contains("RESET (TTL)")) + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("RESET (TTL)"), sql) } -} \ No newline at end of file +} 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..82b9be02 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbIndexSqlIT.kt @@ -0,0 +1,130 @@ +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.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.YdbIndexScope +import tech.ydb.exposed.dialect.YdbIndexSyncMode +import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +class YdbIndexSqlIT : BaseYdbTest() { + + object IndexedTable : YdbTable("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) + + secondaryIndex( + name = "email_cover_idx", + email, + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.ASYNC, + coverColumns = listOf(name), + withParams = mapOf("foo" to "bar") + ) + } + + val emailIndexDefinition + get() = indices.single { it.columns == listOf(email) } + } + + @Test + fun `renders a standard Exposed index as YDB ALTER TABLE`() = tx { + 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`() = tx { + 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 YDB-specific inline secondary index`() = tx { + val ddl = IndexedTable.ddl.joinToString(" ") + + assertTrue(ddl.contains("INDEX email_cover_idx"), ddl) + assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) + assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) + assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) + assertTrue(ddl.contains("WITH (foo = \"bar\")"), ddl) + } + + @Test + fun `renders YDB-specific ALTER TABLE secondary index SQL`() = tx { + val dialect = db.dialect as YdbDialect + + val sql = dialect.createSecondaryIndex( + table = IndexedTable, + spec = YdbSecondaryIndexSpec( + name = "email_lookup_idx", + columns = listOf(IndexedTable.email), + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) + ) + + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("ADD INDEX email_lookup_idx GLOBAL"), sql) + assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) + } + + @Test + fun `renders UNIQUE YDB-specific ALTER TABLE secondary index SQL`() = tx { + val dialect = db.dialect as YdbDialect + + val sql = dialect.createSecondaryIndex( + table = IndexedTable, + spec = YdbSecondaryIndexSpec( + name = "email_unique_lookup_idx", + columns = listOf(IndexedTable.email), + unique = true, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) + ) + + assertTrue(sql.contains("ALTER TABLE"), sql) + assertTrue(sql.contains("ADD INDEX email_unique_lookup_idx GLOBAL UNIQUE"), sql) + assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt similarity index 52% rename from kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt index 28f681df..7e25a1bc 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbTableTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt @@ -1,30 +1,15 @@ -package tech.ydb.exposed.dialect.unit.basic +package tech.ydb.exposed.dialect.integration.basic import org.jetbrains.exposed.v1.javatime.timestamp -import org.jetbrains.exposed.v1.jdbc.Database -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.BeforeAll import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.YdbDialectProvider import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.YdbTtlColumnMode +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbUint64 -class YdbTableTest { - - companion object { - private lateinit var db: Database - - @JvmStatic - @BeforeAll - fun setupDb() { - db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local" - ) - } - } +class YdbTableIT : BaseYdbTest() { object BasicTable : YdbTable("unit_basic_table") { val id = integer("id") @@ -72,52 +57,44 @@ class YdbTableTest { } @Test - fun `should generate create table with primary key clause`() { - transaction(db) { - 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 - ) - } + 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 `should generate ttl clause for timestamp column`() = transaction(db) { + fun `renders TTL clause for a Timestamp64 column`() = tx { val ddl = TtlTimestampTable.ddl.joinToString(" ") - - assertTrue(ddl.contains("""WITH (TTL = Interval("PT1H") ON expire_at)""")) + assertTrue(ddl.contains("""WITH (TTL = Interval("PT1H") ON expire_at)"""), ddl) + assertTrue(ddl.contains("Timestamp64"), ddl) } @Test - fun `should generate ttl clause for numeric epoch column`() = transaction(db) { + 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)""")) - assertTrue(ddl.contains("modified_at_epoch Uint64")) + assertTrue(ddl.contains("""WITH (TTL = Interval("PT1H") ON modified_at_epoch AS SECONDS)"""), ddl) + assertTrue(ddl.contains("modified_at_epoch Uint64"), ddl) } @Test - fun `should fail when table has no primary key`() { - transaction(db) { - assertThrows(IllegalStateException::class.java) { - NoPkTable.ddl - } + fun `fails when the table has no primary key`() = tx { + assertThrows(IllegalStateException::class.java) { + NoPkTable.ddl } } @Test - fun `should fail for unsupported numeric ttl column type`() { - transaction(db) { - assertThrows(IllegalArgumentException::class.java) { - InvalidNumericTtlTable.ddl - } + fun `fails when numeric TTL column type is unsupported`() = tx { + assertThrows(IllegalArgumentException::class.java) { + InvalidNumericTtlTable.ddl } } -} \ No newline at end of file +} 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..89ad2647 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbUniqueIndexSqlIT.kt @@ -0,0 +1,71 @@ +package tech.ydb.exposed.dialect.integration.basic + +import org.jetbrains.exposed.v1.core.Index +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.renderYdbSecondaryIndex + +class YdbUniqueIndexSqlIT : BaseYdbTest() { + + object T : YdbTable("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) + } + + @Test + fun `renders a unique YDB secondary index`() = tx { + val sql = renderYdbSecondaryIndex( + YdbSecondaryIndexSpec( + name = "email_unique_idx", + columns = listOf(T.email), + unique = true + ) + ) + + assertTrue(sql.contains("INDEX email_unique_idx GLOBAL UNIQUE"), sql) + assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt index 2d559b94..bbeb0a5e 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt @@ -4,16 +4,14 @@ 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.assertNotNull import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbUlidTable -import tech.ydb.exposed.dialect.YdbUuidStringIdTable +import tech.ydb.exposed.dialect.YdbUuidIdTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import java.util.UUID class GeneratedIdsIT : BaseYdbTest() { - object UuidItems : YdbUuidStringIdTable("generated_uuid_items") { + object UuidItems : YdbUuidIdTable("generated_uuid_items") { val name = varchar("name", 255) } @@ -24,28 +22,27 @@ class GeneratedIdsIT : BaseYdbTest() { override val tables: List
= listOf(UuidItems, UlidItems) @Test - fun `should generate uuid string id on insert`() = tx { + fun `YdbUuidIdTable generates a native UUID on insert`() = tx { UuidItems.insert { it[name] = "uuid-backed" } val row = UuidItems.selectAll().single() + // The id is auto-generated by clientDefault — we just verify the row landed and + // the column is non-null. The actual id value is a fresh UUID. val id = row[UuidItems.id].value - - assertNotNull(UUID.fromString(id)) + assertEquals(36, id.toString().length) assertEquals("uuid-backed", row[UuidItems.name]) } @Test - fun `should generate ulid id on insert`() = tx { + fun `YdbUlidTable generates a 26-char ULID on insert`() = tx { UlidItems.insert { it[name] = "ulid-backed" } val row = UlidItems.selectAll().single() - val id = row[UlidItems.id].value - - assertEquals(26, id.length) + assertEquals(26, row[UlidItems.id].value.length) assertEquals("ulid-backed", row[UlidItems.name]) } } diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt deleted file mode 100644 index c85b7a39..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/locking/OptimisticLockingIT.kt +++ /dev/null @@ -1,126 +0,0 @@ -package tech.ydb.exposed.dialect.integration.locking - -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.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.YdbTable -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.locking.YdbOptimisticLocking - -class OptimisticLockingIT : BaseYdbTest() { - - object Documents : YdbTable("documents") { - val id = integer("id") - val title = varchar("title", 255) - val version = integer("version") - - override val primaryKey = PrimaryKey(id) - } - - override val tables: List
= listOf(Documents) - - @Test - fun `should update row when expected version matches`() = tx { - Documents.insert { - it[id] = 1 - it[title] = "draft" - it[version] = 0 - } - - val updated = YdbOptimisticLocking.updateWithVersion( - table = Documents, - idColumn = Documents.id, - idValue = 1, - versionColumn = Documents.version, - expectedVersion = 0 - ) { - it[Documents.title] = "published" - } - - assertTrue(updated) - - val row = Documents.selectAll().single() - assertEquals("published", row[Documents.title]) - assertEquals(1, row[Documents.version]) - } - - @Test - fun `should not update row when expected version does not match`() = tx { - Documents.insert { - it[id] = 1 - it[title] = "draft" - it[version] = 5 - } - - val updated = YdbOptimisticLocking.updateWithVersion( - table = Documents, - idColumn = Documents.id, - idValue = 1, - versionColumn = Documents.version, - expectedVersion = 0 - ) { - it[Documents.title] = "published" - } - - assertFalse(updated) - - val row = Documents.selectAll().single() - assertEquals("draft", row[Documents.title]) - assertEquals(5, row[Documents.version]) - } - - @Test - fun `should reject stale update after version was changed`() = tx { - Documents.insert { - it[id] = 1 - it[title] = "draft" - it[version] = 0 - } - - val firstUpdate = YdbOptimisticLocking.updateWithVersion( - table = Documents, - idColumn = Documents.id, - idValue = 1, - versionColumn = Documents.version, - expectedVersion = 0 - ) { - it[Documents.title] = "published" - } - - val staleUpdate = YdbOptimisticLocking.updateWithVersion( - table = Documents, - idColumn = Documents.id, - idValue = 1, - versionColumn = Documents.version, - expectedVersion = 0 - ) { - it[Documents.title] = "overwritten" - } - - assertTrue(firstUpdate) - assertFalse(staleUpdate) - - val row = Documents.selectAll().single() - assertEquals("published", row[Documents.title]) - assertEquals(1, row[Documents.version]) - } - - @Test - fun `should return false when row does not exist`() = tx { - val updated = YdbOptimisticLocking.updateWithVersion( - table = Documents, - idColumn = Documents.id, - idValue = 42, - versionColumn = Documents.version, - expectedVersion = 0 - ) { - it[Documents.title] = "missing" - } - - assertFalse(updated) - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt deleted file mode 100644 index 05e696f7..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/pagination/KeysetPaginationIT.kt +++ /dev/null @@ -1,91 +0,0 @@ -package tech.ydb.exposed.dialect.integration.pagination - -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.YdbTable -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.pagination.keysetPageAsc -import tech.ydb.exposed.dialect.pagination.keysetPageDesc - -class KeysetPaginationIT : BaseYdbTest() { - - object FeedItems : YdbTable("feed_items") { - val id = integer("id") - val name = varchar("name", 255) - - override val primaryKey = PrimaryKey(id) - } - - override val tables: List
= listOf(FeedItems) - - @Test - fun `should support forward keyset pagination`() = tx { - (1..5).forEach { i -> - FeedItems.insert { - it[id] = i - it[name] = "item-$i" - } - } - - val page1 = FeedItems - .selectAll() - .keysetPageAsc(FeedItems.id, lastValue = null, limit = 2) - .toList() - - assertEquals(2, page1.size) - assertEquals(1, page1[0][FeedItems.id]) - assertEquals(2, page1[1][FeedItems.id]) - - val lastSeenId = page1.last()[FeedItems.id] - - val page2 = FeedItems - .selectAll() - .keysetPageAsc(FeedItems.id, lastValue = lastSeenId, limit = 2) - .toList() - - assertEquals(2, page2.size) - assertEquals(3, page2[0][FeedItems.id]) - assertEquals(4, page2[1][FeedItems.id]) - - val page3 = FeedItems - .selectAll() - .keysetPageAsc(FeedItems.id, lastValue = page2.last()[FeedItems.id], limit = 2) - .toList() - - assertEquals(1, page3.size) - assertEquals(5, page3[0][FeedItems.id]) - } - - @Test - fun `should support backward keyset pagination`() = tx { - (1..5).forEach { i -> - FeedItems.insert { - it[id] = i - it[name] = "item-$i" - } - } - - val page1 = FeedItems - .selectAll() - .keysetPageDesc(FeedItems.id, lastValue = null, limit = 2) - .toList() - - assertEquals(2, page1.size) - assertEquals(5, page1[0][FeedItems.id]) - assertEquals(4, page1[1][FeedItems.id]) - - val lastSeenId = page1.last()[FeedItems.id] - - val page2 = FeedItems - .selectAll() - .keysetPageDesc(FeedItems.id, lastValue = lastSeenId, limit = 2) - .toList() - - assertEquals(2, page2.size) - assertEquals(3, page2[0][FeedItems.id]) - assertEquals(2, page2[1][FeedItems.id]) - } -} \ 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 index e870aecd..c804c510 100644 --- 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 @@ -4,10 +4,13 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.transaction.YdbRetryingTransactions +import tech.ydb.exposed.dialect.ydbReadOnlyTransaction +import tech.ydb.exposed.dialect.ydbTransaction +import java.util.concurrent.atomic.AtomicInteger class YdbRetryingTransactionsIT : BaseYdbTest() { @@ -21,17 +24,58 @@ class YdbRetryingTransactionsIT : BaseYdbTest() { override val tables: List
= listOf(RetryItems) @Test - fun `should execute read write helper`() { - YdbRetryingTransactions.readWrite(db) { + fun `ydbTransaction executes a write-and-read round trip`() { + ydbTransaction(db) { RetryItems.insert { it[id] = 1 it[name] = "alpha" } } - YdbRetryingTransactions.readOnly(db) { - val row = RetryItems.selectAll().single() - assertEquals("alpha", row[RetryItems.name]) + val name = ydbReadOnlyTransaction(db) { + RetryItems.selectAll().single()[RetryItems.name] } + assertEquals("alpha", name) } -} \ No newline at end of file + + @Test + fun `ydbTransaction retries the body on a retryable failure`() { + val attempts = AtomicInteger() + + ydbTransaction(db, idempotent = true, 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 = ydbReadOnlyTransaction(db) { + RetryItems.selectAll().single()[RetryItems.name] + } + assertEquals("retried", stored) + } + + @Test + fun `non-retryable error fails fast`() { + val attempts = AtomicInteger() + + assertThrows(IllegalStateException::class.java) { + ydbTransaction(db, maxAttempts = 3) { + attempts.incrementAndGet() + error("non-retryable") + } + } + assertEquals(1, attempts.get()) + } + + private class FakeAbortedException : RuntimeException("simulated"), tech.ydb.jdbc.exception.YdbStatusable { + override fun getStatus(): tech.ydb.core.Status = + tech.ydb.core.Status.of(tech.ydb.core.StatusCode.ABORTED) + } +} 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..ad5064ac --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyTemporalTypesIT.kt @@ -0,0 +1,77 @@ +package tech.ydb.exposed.dialect.integration.types + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.javatime.date +import org.jetbrains.exposed.v1.javatime.datetime +import org.jetbrains.exposed.v1.javatime.timestamp +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import org.junit.jupiter.api.AfterEach +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.YdbDialectProvider +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.ydbTransaction +import tech.ydb.test.junit5.YdbHelperExtension + +/** + * Verifies that opening the dialect with `forceLegacyDatetimes = true` emits + * the legacy YDB temporal types (`Date`, `Datetime`, `Timestamp`). + * + * Doesn't extend [BaseYdbTest] because it needs to control the [YdbDialectProvider.connect] + * call to flip the flag. + */ +class LegacyTemporalTypesIT { + + object LegacyTemporal : YdbTable("legacy_temporal_types") { + val id = integer("id") + val dateCol = date("date_col") + val dateTimeCol = datetime("datetime_col") + val timestampCol = timestamp("timestamp_col") + + override val primaryKey = PrimaryKey(id) + } + + private lateinit var db: Database + + @BeforeEach + fun setUp() { + val jdbcUrl = buildString { + append("jdbc:ydb:") + append(if (ydb.useTls()) "grpcs://" else "grpc://") + append(ydb.endpoint()) + append(ydb.database()) + ydb.authToken()?.let { append("?token=").append(it) } + } + db = YdbDialectProvider.connect(url = jdbcUrl, forceLegacyDatetimes = true) + } + + @AfterEach + fun tearDown() { + if (!::db.isInitialized) return + + runCatching { + ydbTransaction(db) { + SchemaUtils.drop(LegacyTemporal) + } + } + runCatching { TransactionManager.closeAndUnregister(db) } + } + + @Test + fun `forceLegacyDatetimes emits Date Datetime Timestamp`() = ydbTransaction(db) { + 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) + } + + companion object { + @JvmField + @RegisterExtension + val ydb: YdbHelperExtension = YdbHelperExtension() + } +} 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 index 5ff208be..bf833fb4 100644 --- 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 @@ -9,8 +9,6 @@ import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.types.ydbUuid -import tech.ydb.exposed.dialect.types.ydbUuidBytes -import tech.ydb.exposed.dialect.types.ydbUuidUtf8 import java.util.UUID class UuidTypesIT : BaseYdbTest() { @@ -18,28 +16,13 @@ class UuidTypesIT : BaseYdbTest() { object NativeUuidTypes : YdbTable("native_uuid_types") { val id = integer("id") val uuidCol = ydbUuid("uuid_col") - - override val primaryKey = PrimaryKey(id) - } - - object UuidUtf8Types : YdbTable("uuid_utf8_types") { - val id = integer("id") - val uuidCol = ydbUuidUtf8("uuid_col") - override val primaryKey = PrimaryKey(id) } - object UuidBytesTypes : YdbTable("uuid_bytes_types") { - val id = integer("id") - val uuidCol = ydbUuidBytes("uuid_col") - - override val primaryKey = PrimaryKey(id) - } - - override val tables: List
= listOf(NativeUuidTypes, UuidUtf8Types, UuidBytesTypes) + override val tables: List
= listOf(NativeUuidTypes) @Test - fun `should round-trip native uuid type`() = tx { + fun `ydbUuid round-trips a java util UUID through native YDB Uuid (no string conversion)`() = tx { val uuid = UUID.randomUUID() NativeUuidTypes.insert { @@ -47,44 +30,11 @@ class UuidTypesIT : BaseYdbTest() { it[uuidCol] = uuid } - val row = NativeUuidTypes.selectAll().single() - assertEquals(uuid, row[NativeUuidTypes.uuidCol]) - } - - @Test - fun `should round-trip uuid utf8 type`() = tx { - val uuid = UUID.randomUUID() - - UuidUtf8Types.insert { - it[id] = 1 - it[uuidCol] = uuid - } - - val row = UuidUtf8Types.selectAll().single() - assertEquals(uuid, row[UuidUtf8Types.uuidCol]) + assertEquals(uuid, NativeUuidTypes.selectAll().single()[NativeUuidTypes.uuidCol]) } @Test - fun `should round-trip uuid bytes type`() = tx { - val uuid = UUID.randomUUID() - - UuidBytesTypes.insert { - it[id] = 1 - it[uuidCol] = uuid - } - - val row = UuidBytesTypes.selectAll().single() - assertEquals(uuid, row[UuidBytesTypes.uuidCol]) - } - - @Test - fun `should generate ddl for uuid mappings`() = tx { - val nativeDdl = NativeUuidTypes.ddl.joinToString(" ") - val utf8Ddl = UuidUtf8Types.ddl.joinToString(" ") - val bytesDdl = UuidBytesTypes.ddl.joinToString(" ") - - assertTrue(nativeDdl.contains("uuid_col Uuid")) - assertTrue(utf8Ddl.contains("uuid_col Utf8")) - assertTrue(bytesDdl.contains("uuid_col String")) + fun `DDL emits Uuid for ydbUuid`() = tx { + assertTrue(NativeUuidTypes.ddl.joinToString(" ").contains("uuid_col Uuid")) } -} \ No newline at end of file +} 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 index 8f3de24a..fc3c22c4 100644 --- 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 @@ -1,13 +1,12 @@ package tech.ydb.exposed.dialect.integration.upsert import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.jdbc.SchemaUtils +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 +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable -import tech.ydb.exposed.dialect.YdbFunctionProvider import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UpsertIT : BaseYdbTest() { @@ -21,44 +20,35 @@ class UpsertIT : BaseYdbTest() { override val tables: List
= listOf(Products) @Test - fun `should perform UPSERT`() = tx { - SchemaUtils.create(Products) - - val provider = YdbFunctionProvider - val data = listOf( - Products.id to 1, - Products.name to "Item1" - ) - - val sql = provider.upsert( - table = Products, - data = data, - expression = "", - onUpdate = emptyList(), - keyColumns = listOf(Products.id), - where = null, - transaction = this - ) + fun `Table upsert inserts and then updates the same row`() = tx { + Products.upsert { + it[id] = 1 + it[name] = "Item1" + } - exec(sql) + Products.upsert { + it[id] = 1 + it[name] = "Item2" + } val row = Products.selectAll().single() - Assertions.assertEquals("Item1", row[Products.name]) + assertEquals("Item2", row[Products.name]) } @Test - fun `should perform UPSERT through Exposed DSL`() = tx { + fun `Table replace overwrites an existing row by primary key`() = tx { Products.upsert { - it[id] = 1 - it[name] = "Item1" + it[id] = 5 + it[name] = "original" } - Products.upsert { - it[id] = 1 - it[name] = "Item2" + Products.replace { + it[id] = 5 + it[name] = "replaced" } val row = Products.selectAll().single() - Assertions.assertEquals("Item2", row[Products.name]) + assertEquals(5, row[Products.id]) + assertEquals("replaced", row[Products.name]) } } diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt index 77694464..980110d3 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt @@ -3,32 +3,21 @@ package tech.ydb.exposed.dialect.unit.basic 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.YdbGeneratedIds -import java.util.UUID +import tech.ydb.exposed.dialect.ydbUlid class YdbGeneratedIdsTest { @Test - fun `should generate uuid string`() { - val value = YdbGeneratedIds.uuidString() - - UUID.fromString(value) - assertEquals(36, value.length) - } - - @Test - fun `should generate ulid with stable length and alphabet`() { - val value = YdbGeneratedIds.ulid(nowMillis = 1_700_000_000_000) - + fun `ydbUlid generates a 26-char Crockford-base32 string`() { + val value = ydbUlid(nowMillis = 1_700_000_000_000) assertEquals(26, value.length) assertTrue(value.all { it in "0123456789ABCDEFGHJKMNPQRSTVWXYZ" }) } @Test - fun `should encode ulid timestamp in lexicographic prefix`() { - val older = YdbGeneratedIds.ulid(nowMillis = 1_700_000_000_000) - val newer = YdbGeneratedIds.ulid(nowMillis = 1_700_000_000_001) - + fun `ydbUlid encodes timestamp in the lexicographic prefix`() { + val older = ydbUlid(nowMillis = 1_700_000_000_000) + val newer = ydbUlid(nowMillis = 1_700_000_000_001) assertTrue(older.take(10) < newer.take(10)) } -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt deleted file mode 100644 index 5a32a3b1..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbIndexSqlTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -package tech.ydb.exposed.dialect.unit.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.jdbc.Database -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.BeforeAll -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.YdbDialect -import tech.ydb.exposed.dialect.YdbDialectProvider -import tech.ydb.exposed.dialect.YdbIndexScope -import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec -import tech.ydb.exposed.dialect.YdbTable - -class YdbIndexSqlTest { - - companion object { - private lateinit var db: Database - - @JvmStatic - @BeforeAll - fun setupDb() { - db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local" - ) - } - } - - object IndexedTable : YdbTable("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) - - secondaryIndex( - name = "email_cover_idx", - email, - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.ASYNC, - coverColumns = listOf(name), - withParams = mapOf("foo" to "bar") - ) - } - - val emailIndexDefinition - get() = indices.single { it.columns == listOf(email) } - } - - @Test - fun `should render standard exposed index as ydb alter table sql`() { - 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 `should reject 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 `should render ydb specific inline secondary index`() { - transaction(db) { - val ddl = IndexedTable.ddl.joinToString(" ") - - assertTrue(ddl.contains("INDEX email_cover_idx"), ddl) - assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) - assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) - assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) - assertTrue(ddl.contains("WITH (foo = \"bar\")"), ddl) - } - } - - @Test - fun `should render ydb specific alter table secondary index sql`() { - transaction(db) { - val dialect = db.dialect as YdbDialect - - val sql = dialect.createSecondaryIndex( - table = IndexedTable, - spec = YdbSecondaryIndexSpec( - name = "email_lookup_idx", - columns = listOf(IndexedTable.email), - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) - ) - - assertTrue(sql.contains("ALTER TABLE"), sql) - assertTrue(sql.contains("ADD INDEX email_lookup_idx GLOBAL"), sql) - assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) - } - } - - @Test - fun `should render unique ydb specific alter table secondary index sql`() { - transaction(db) { - val dialect = db.dialect as YdbDialect - - val sql = dialect.createSecondaryIndex( - table = IndexedTable, - spec = YdbSecondaryIndexSpec( - name = "email_unique_lookup_idx", - columns = listOf(IndexedTable.email), - unique = true, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) - ) - - assertTrue(sql.contains("ALTER TABLE"), sql) - assertTrue(sql.contains("ADD INDEX email_unique_lookup_idx GLOBAL UNIQUE"), sql) - assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) - } - } -} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt deleted file mode 100644 index 55300913..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbUniqueIndexSqlTest.kt +++ /dev/null @@ -1,92 +0,0 @@ -package tech.ydb.exposed.dialect.unit.basic - -import org.jetbrains.exposed.v1.core.Index -import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.YdbDialect -import tech.ydb.exposed.dialect.YdbDialectProvider -import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec -import tech.ydb.exposed.dialect.YdbTable -import tech.ydb.exposed.dialect.renderYdbSecondaryIndex - -class YdbUniqueIndexSqlTest { - - companion object { - private lateinit var db: Database - - @JvmStatic - @BeforeAll - fun setupDb() { - db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local" - ) - } - } - - object T : YdbTable("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 `should render unique standard exposed index`() { - transaction(db) { - 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 `should quote custom index name through identifier manager`() { - transaction(db) { - 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) - } - } - - @Test - fun `should render unique ydb secondary index`() { - transaction(db) { - val sql = renderYdbSecondaryIndex( - YdbSecondaryIndexSpec( - name = "email_unique_idx", - columns = listOf(T.email), - unique = true - ) - ) - - assertTrue(sql.contains("INDEX email_unique_idx GLOBAL UNIQUE"), sql) - assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) - } - } -} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt index afcc1c81..a9ebde42 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt @@ -6,121 +6,79 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import tech.ydb.core.Status import tech.ydb.core.StatusCode +import tech.ydb.exposed.dialect.YdbBackoffKind +import tech.ydb.exposed.dialect.backoffMillis +import tech.ydb.exposed.dialect.classifyYdbError import tech.ydb.jdbc.exception.YdbStatusable -import tech.ydb.exposed.dialect.transaction.YdbBackoffKind -import tech.ydb.exposed.dialect.transaction.YdbRetryClassifier class YdbRetryClassifierTest { - private class FakeYdbStatusException( - private val ydbStatus: Status, - message: String = "fake" - ) : RuntimeException(message), YdbStatusable { - override fun getStatus(): Status = ydbStatus + + private class FakeStatusException(code: StatusCode) : + RuntimeException("fake"), YdbStatusable { + private val s = Status.of(code) + override fun getStatus(): Status = s } @Test - fun `should classify structured aborted as retryable fast`() { - val decision = YdbRetryClassifier.classify( - FakeYdbStatusException(Status.of(StatusCode.ABORTED)), - idempotent = false - ) - - assertTrue(decision.retryable) - assertEquals(YdbBackoffKind.FAST, decision.backoffKind) - assertFalse(decision.recreateSession) + fun `ABORTED is retryable with FAST backoff`() { + val d = classifyYdbError(FakeStatusException(StatusCode.ABORTED), idempotent = false) + assertTrue(d.retryable) + assertEquals(YdbBackoffKind.FAST, d.backoffKind) } @Test - fun `should classify regex aborted as retryable fast`() { - val decision = YdbRetryClassifier.classify( - RuntimeException("Status{code = ABORTED(code=400040)}"), - idempotent = false - ) - - assertTrue(decision.retryable) - assertEquals(YdbBackoffKind.FAST, decision.backoffKind) - assertFalse(decision.recreateSession) + fun `OVERLOADED is retryable with SLOW backoff`() { + val d = classifyYdbError(FakeStatusException(StatusCode.OVERLOADED), idempotent = false) + assertTrue(d.retryable) + assertEquals(YdbBackoffKind.SLOW, d.backoffKind) } @Test - fun `should classify overloaded as retryable slow`() { - val decision = YdbRetryClassifier.classify( - RuntimeException("Status{code = OVERLOADED(code=400060)}"), - idempotent = false - ) - - assertTrue(decision.retryable) - assertEquals(YdbBackoffKind.SLOW, decision.backoffKind) + fun `BAD_SESSION is retryable with INSTANT backoff`() { + val d = classifyYdbError(FakeStatusException(StatusCode.BAD_SESSION), idempotent = false) + assertTrue(d.retryable) + assertEquals(YdbBackoffKind.INSTANT, d.backoffKind) } @Test - fun `should classify bad session as retryable with recreate`() { - val decision = YdbRetryClassifier.classify( - RuntimeException("Status{code = BAD_SESSION(code=400100)}"), - idempotent = false - ) - - assertTrue(decision.retryable) - assertTrue(decision.recreateSession) - assertEquals(YdbBackoffKind.INSTANT, decision.backoffKind) + fun `PRECONDITION_FAILED is not retryable`() { + val d = classifyYdbError(FakeStatusException(StatusCode.PRECONDITION_FAILED), idempotent = true) + assertFalse(d.retryable) } @Test - fun `should classify precondition failed as non retryable`() { - val decision = YdbRetryClassifier.classify( - RuntimeException("Status{code = PRECONDITION_FAILED(code=400120)}"), - idempotent = false - ) - - assertFalse(decision.retryable) + fun `TIMEOUT retries only when idempotent`() { + assertTrue(classifyYdbError(FakeStatusException(StatusCode.TIMEOUT), idempotent = true).retryable) + assertFalse(classifyYdbError(FakeStatusException(StatusCode.TIMEOUT), idempotent = false).retryable) } @Test - fun `should classify timeout as retryable only for idempotent operations`() { - val retryable = YdbRetryClassifier.classify( - RuntimeException("Status{code = TIMEOUT(code=400090)}"), - idempotent = true - ) - val nonRetryable = YdbRetryClassifier.classify( - RuntimeException("Status{code = TIMEOUT(code=400090)}"), - idempotent = false - ) - - assertTrue(retryable.retryable) - assertFalse(nonRetryable.retryable) + fun `UNDETERMINED retries only when idempotent`() { + assertTrue(classifyYdbError(FakeStatusException(StatusCode.UNDETERMINED), idempotent = true).retryable) + assertFalse(classifyYdbError(FakeStatusException(StatusCode.UNDETERMINED), idempotent = false).retryable) } @Test - fun `should not classify unrelated timeout text as ydb timeout`() { - val decision = YdbRetryClassifier.classify( - RuntimeException("Connection timed out while reading metadata"), - idempotent = true - ) - - assertFalse(decision.retryable) + fun `text-only error without YdbStatusable is treated as non-retryable`() { + val d = classifyYdbError(RuntimeException("Status{code = ABORTED}"), idempotent = true) + assertFalse(d.retryable) } @Test - fun `should classify undetermined as retryable only for idempotent operations`() { - val retryable = YdbRetryClassifier.classify( - RuntimeException("Status{code = UNDETERMINED(code=400170)}"), - idempotent = true - ) - val nonRetryable = YdbRetryClassifier.classify( - RuntimeException("Status{code = UNDETERMINED(code=400170)}"), - idempotent = false - ) + fun `walks cause chain to find a YdbStatusable`() { + val cause = FakeStatusException(StatusCode.ABORTED) + val wrapped = RuntimeException("outer", RuntimeException("middle", cause)) - assertTrue(retryable.retryable) - assertFalse(nonRetryable.retryable) + val d = classifyYdbError(wrapped, idempotent = false) + assertTrue(d.retryable) + assertEquals(YdbBackoffKind.FAST, d.backoffKind) } @Test - fun `should produce backoff for fast and slow kinds`() { - val fast = YdbRetryClassifier.backoffMillis(YdbBackoffKind.FAST, 1) - val slow = YdbRetryClassifier.backoffMillis(YdbBackoffKind.SLOW, 1) - - assertTrue(fast >= 0) - assertTrue(slow >= 0) + fun `backoffMillis returns non-negative values`() { + assertTrue(backoffMillis(YdbBackoffKind.FAST, 1) >= 0) + assertTrue(backoffMillis(YdbBackoffKind.SLOW, 1) >= 0) + assertEquals(0L, backoffMillis(YdbBackoffKind.NONE, 1)) + assertEquals(0L, backoffMillis(YdbBackoffKind.INSTANT, 1)) } } 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 index 98fbe14c..878c242b 100644 --- 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 @@ -1,66 +1,66 @@ package tech.ydb.exposed.dialect.unit.types -import org.junit.jupiter.api.Assertions +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 + private val provider = YdbDataTypeProvider(forceLegacyDatetimes = false) + private val legacyProvider = YdbDataTypeProvider(forceLegacyDatetimes = true) @Test - fun `should map integer types`() { - Assertions.assertEquals("Int32", provider.integerType()) - Assertions.assertEquals("Int64", provider.longType()) - Assertions.assertEquals("Int16", provider.shortType()) + fun `maps integer types`() { + assertEquals("Int32", provider.integerType()) + assertEquals("Int64", provider.longType()) + assertEquals("Int16", provider.shortType()) } @Test - fun `should map string and binary types`() { - Assertions.assertEquals("Text", provider.varcharType(255)) - Assertions.assertEquals("Text", provider.textType()) - Assertions.assertEquals("String", provider.binaryType()) - Assertions.assertEquals("String", provider.binaryType(100)) + fun `maps string and binary types`() { + assertEquals("Text", provider.varcharType(255)) + assertEquals("Text", provider.textType()) + assertEquals("String", provider.binaryType()) + assertEquals("String", provider.binaryType(100)) } @Test - fun `should map boolean type`() { - Assertions.assertEquals("Bool", provider.booleanType()) + fun `maps boolean and UUID and JSON types`() { + assertEquals("Bool", provider.booleanType()) + assertEquals("Uuid", provider.uuidType()) + assertEquals("JsonDocument", provider.jsonType()) } @Test - fun `should map UUID type`() { - Assertions.assertEquals("Uuid", provider.uuidType()) + fun `maps floating-point types`() { + assertEquals("Float", provider.floatType()) + assertEquals("Double", provider.doubleType()) } @Test - fun `should map JSON type`() { - Assertions.assertEquals("JsonDocument", provider.jsonType()) + fun `defaults temporal types to extended (Date32 Datetime64 Timestamp64)`() { + assertEquals("Date32", provider.dateType()) + assertEquals("Datetime64", provider.dateTimeType()) + assertEquals("Timestamp64", provider.timestampType()) } @Test - fun `should map numeric types`() { - Assertions.assertEquals("Float", provider.floatType()) - Assertions.assertEquals("Double", provider.doubleType()) + fun `forceLegacyDatetimes switches to Date Datetime Timestamp`() { + assertEquals("Date", legacyProvider.dateType()) + assertEquals("Datetime", legacyProvider.dateTimeType()) + assertEquals("Timestamp", legacyProvider.timestampType()) } @Test - fun `should map date and time types`() { - Assertions.assertEquals("Date32", provider.dateType()) - Assertions.assertEquals("Datetime64", provider.dateTimeType()) - Assertions.assertEquals("Timestamp64", provider.timestampType()) - } - - @Test - fun `should reject autoincrement type`() { - Assertions.assertThrows(UnsupportedOperationException::class.java) { + fun `rejects autoincrement type`() { + assertThrows(UnsupportedOperationException::class.java) { provider.integerAutoincType() } } @Test - fun `should convert hex to SQL`() { - val hex = "0xABCD" - Assertions.assertEquals("'0xABCD'", provider.hexToDb(hex)) + fun `hexToDb wraps in single quotes`() { + assertEquals("'0xABCD'", provider.hexToDb("0xABCD")) } } diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsStringColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsStringColumnTypeTest.kt deleted file mode 100644 index 63252802..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsStringColumnTypeTest.kt +++ /dev/null @@ -1,35 +0,0 @@ -package tech.ydb.exposed.dialect.unit.types - -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.types.YdbUuidAsStringColumnType -import java.util.UUID - -class YdbUuidAsStringColumnTypeTest { - - private val type = YdbUuidAsStringColumnType() - - @Test - fun `should return string uuid sql type`() { - assertEquals("String", type.sqlType()) - } - - @Test - fun `should parse uuid from db string and bytes`() { - val uuid = UUID.randomUUID() - - assertEquals(uuid, type.valueFromDB(uuid)) - assertEquals(uuid, type.valueFromDB(uuid.toString())) - assertEquals(uuid, type.valueFromDB(uuid.toString().toByteArray(Charsets.UTF_8))) - } - - @Test - fun `should convert uuid to db bytes`() { - val uuid = UUID.randomUUID() - - val dbValue = type.notNullValueToDB(uuid) - assertArrayEquals(uuid.toString().toByteArray(Charsets.UTF_8), dbValue as ByteArray) - assertEquals("'$uuid'", type.nonNullValueToString(uuid)) - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsUtf8ColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsUtf8ColumnTypeTest.kt deleted file mode 100644 index 445a28e4..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUuidAsUtf8ColumnTypeTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -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.types.YdbUuidAsUtf8ColumnType -import java.util.UUID - -class YdbUuidAsUtf8ColumnTypeTest { - - private val type = YdbUuidAsUtf8ColumnType() - - @Test - fun `should return utf8 uuid sql type`() { - assertEquals("Utf8", type.sqlType()) - } - - @Test - fun `should parse utf8 uuid from db`() { - val uuid = UUID.randomUUID() - - assertEquals(uuid, type.valueFromDB(uuid)) - assertEquals(uuid, type.valueFromDB(uuid.toString())) - } - - @Test - fun `should convert utf8 uuid to db`() { - val uuid = UUID.randomUUID() - - assertEquals(uuid.toString(), type.notNullValueToDB(uuid)) - assertEquals("'$uuid'", type.nonNullValueToString(uuid)) - } -} \ No newline at end of file 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 index bde196c4..63e51b2f 100644 --- 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 @@ -10,23 +10,26 @@ class YdbUuidColumnTypeTest { private val type = YdbUuidColumnType() @Test - fun `should return native uuid sql type`() { + fun `maps to native Uuid sql type`() { assertEquals("Uuid", type.sqlType()) } @Test - fun `should parse native uuid from db`() { + fun `valueFromDB accepts both UUID and String`() { val uuid = UUID.randomUUID() - assertEquals(uuid, type.valueFromDB(uuid)) assertEquals(uuid, type.valueFromDB(uuid.toString())) } @Test - fun `should convert native uuid to db`() { + fun `notNullValueToDB returns the UUID itself (no string conversion)`() { val uuid = UUID.randomUUID() + assertEquals(uuid, type.notNullValueToDB(uuid)) + } - assertEquals(uuid.toString(), type.notNullValueToDB(uuid)) - assertEquals("'$uuid'", type.nonNullValueToString(uuid)) + @Test + fun `nonNullValueToString renders the YQL Uuid literal`() { + val uuid = UUID.fromString("00000000-0000-0000-0000-000000000001") + assertEquals("Uuid(\"$uuid\")", type.nonNullValueToString(uuid)) } -} \ No newline at end of file +} 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 @@ + + + + + + + + + + + + + + + + + + + From 4abb01c529aad65909e9305b0ec42216acacc19f Mon Sep 17 00:00:00 2001 From: s-markelova Date: Thu, 14 May 2026 04:05:35 +0300 Subject: [PATCH 28/43] edit code after review --- kotlin-exposed-dialect/CHANGELOG.md | 4 +- kotlin-exposed-dialect/README.md | 2 +- .../tech/ydb/exposed/dialect/YdbDialect.kt | 3 +- .../ydb/exposed/dialect/YdbDialectProvider.kt | 37 ++++++++++- .../ydb/exposed/dialect/YdbSecondaryIndex.kt | 10 ++- .../ydb/exposed/dialect/YdbTableSupport.kt | 12 +++- .../dialect/types/YdbCustomColumnTypes.kt | 34 ++++++++--- .../integration/basic/YdbDialectTtlSqlIT.kt | 19 ++++++ .../integration/basic/YdbIndexSqlIT.kt | 19 ++++++ .../unit/basic/YdbDialectProviderTest.kt | 60 ++++++++++++++++++ .../dialect/unit/types/JdbcBindingCapture.kt | 61 +++++++++++++++++++ .../YdbJsonDocumentStringColumnTypeTest.kt | 14 +++++ .../unit/types/YdbJsonStringColumnTypeTest.kt | 16 ++++- 13 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectProviderTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/JdbcBindingCapture.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 5ef43419..f16e4a25 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -19,8 +19,8 @@ Initial release of the Kotlin Exposed dialect for YDB. Legacy types are available via `YdbDialectProvider.connect(forceLegacyDatetimes = true)`. - Custom column types for `Decimal`, `Interval`, `Json`, `JsonDocument`, three `Uuid` flavours and `Uint64`, plus a `ydbDecimalLiteral` helper for update expressions. -- Table base classes for generated identifiers: `YdbUuidIdTable`, `YdbUuidStringIdTable`, - `YdbUlidTable`, `YdbStringIdTable`. +- Table base classes for generated identifiers: `YdbUuidIdTable`, `YdbUlidTable`, + `YdbStringIdTable`. - Explicit rejection of `AUTO_INCREMENT` and ANSI `MERGE` (`UPSERT` covers the use case). - Console demo application showing CRUD, UPSERT and DDL. - Integration tests powered by testcontainers via `tech.ydb.test:ydb-junit5-support`. diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 76029e5d..6975a11f 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -70,7 +70,7 @@ object Products : YdbTable("products") { ``` For tables that need to participate in Exposed DAO, use `YdbIdTable` (or its specializations -`YdbUuidIdTable`, `YdbUuidStringIdTable`, `YdbUlidTable`, `YdbStringIdTable`). +`YdbUuidIdTable`, `YdbUlidTable`, `YdbStringIdTable`). ## Insert / upsert / replace / update / delete 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 index ac556371..77537a3d 100644 --- 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 @@ -251,12 +251,13 @@ class YdbDialect internal constructor( ?: error("TTL is not configured for table ${table.tableName}") validateYdbTtlColumn(ttl) + val normalizedInterval = normalizeTtlInterval(ttl.intervalIso8601) return buildString { append("ALTER TABLE ") append(tr.identity(table)) append(" SET (TTL = Interval(\"") - append(ttl.intervalIso8601) + append(normalizedInterval) append("\") ON ") append(tr.identity(ttl.column)) ttl.mode.toSql()?.let { diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt index baeb531d..bf6c01d1 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicBoolean object YdbDialectProvider { private const val URL_PREFIX = "jdbc:ydb:" private const val DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" + internal const val FORCE_SIGNED_DATETIMES_PROPERTY = "forceSignedDatetimes" private val initialized = AtomicBoolean(false) @@ -42,8 +43,9 @@ object YdbDialectProvider { * @param url JDBC URL, e.g. `jdbc:ydb:grpc://localhost:2136/local`. * @param forceLegacyDatetimes When `true`, the dialect emits legacy YDB temporal types * (`Date`, `Datetime`, `Timestamp`) instead of the default extended ones - * (`Date32`, `Datetime64`, `Timestamp64`). Use this only when integrating with schemas that - * already rely on the unsigned/legacy range. + * (`Date32`, `Datetime64`, `Timestamp64`). The same mode is also propagated to the YDB JDBC + * driver via the `forceSignedDatetimes` URL flag, so column DDL and value binding stay in sync. + * Use legacy mode only when integrating with schemas that already rely on the unsigned range. */ fun connect( url: String, @@ -52,9 +54,10 @@ object YdbDialectProvider { forceLegacyDatetimes: Boolean = false ): Database { init() + val driverUrl = withTemporalDriverMode(url, forceLegacyDatetimes) return Database.connect( - url = url, + url = driverUrl, driver = DRIVER_CLASS, user = user, password = password, @@ -66,4 +69,32 @@ object YdbDialectProvider { } ) } + + internal fun withTemporalDriverMode(url: String, forceLegacyDatetimes: Boolean): String = + withBooleanQueryParameter( + url = url, + name = FORCE_SIGNED_DATETIMES_PROPERTY, + value = !forceLegacyDatetimes + ) + + private fun withBooleanQueryParameter(url: String, name: String, value: Boolean): String { + val queryStart = url.indexOf('?') + if (queryStart < 0) { + return "$url?$name=$value" + } + + val base = url.substring(0, queryStart) + val query = url.substring(queryStart + 1) + val filteredParams = query + .split('&') + .filter { it.isNotBlank() } + .filterNot { param -> + val key = param.substringBefore('=') + key == name + } + .toMutableList() + + filteredParams += "$name=$value" + return "$base?${filteredParams.joinToString("&")}" + } } diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt index 12e66e58..c613050a 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt @@ -30,10 +30,16 @@ internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { "YDB secondary index must contain at least one column" } + require(spec.name.isNotBlank()) { + "YDB secondary index name must not be blank" + } + require(spec.scope == YdbIndexScope.GLOBAL) { "Only GLOBAL secondary indexes are supported by YDB row-oriented tables in this dialect" } + val indexName = tr.db.identifierManager.cutIfNecessaryAndQuote(spec.name) + val columnsSql = spec.columns.joinToString(", ") { tr.identity(it) } val coverSql = spec.coverColumns .takeIf { it.isNotEmpty() } @@ -46,7 +52,7 @@ internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { return buildString { append("INDEX ") - append(spec.name) + append(indexName) append(" ") append(spec.scope.name) @@ -86,4 +92,4 @@ private fun renderYdbIndexParamValue(value: Any): String = when (value) { is Number -> value.toString() is Boolean -> value.toString().uppercase() else -> "\"${value.toString().replace("\"", "\\\"")}\"" -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt index 8153ae52..5fe6c168 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt @@ -3,6 +3,7 @@ package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import java.time.Duration /** * YDB-specific DDL surface shared by [YdbTable] and [YdbIdTable]. @@ -41,7 +42,7 @@ class YdbTableFeatures : YdbTableDsl { private val secondaryIndices = mutableListOf() override fun ttl(column: Column<*>, intervalIso8601: String, mode: YdbTtlColumnMode) { - ttlSettingsState = YdbTtlSettings(column, intervalIso8601, mode) + ttlSettingsState = YdbTtlSettings(column, normalizeTtlInterval(intervalIso8601), mode) } override fun secondaryIndex( @@ -102,10 +103,11 @@ internal fun buildYdbCreateStatement( val ttlSql = ttlSettings?.let { ttl -> validateYdbTtlColumn(ttl) + val normalizedInterval = normalizeTtlInterval(ttl.intervalIso8601) buildString { append(" WITH (TTL = Interval(\"") - append(ttl.intervalIso8601) + append(normalizedInterval) append("\") ON ") append(tr.identity(ttl.column)) ttl.mode.toSql()?.let { @@ -136,6 +138,12 @@ internal fun buildYdbCreateStatement( return listOf(sql) } +internal fun normalizeTtlInterval(intervalIso8601: String): String = + runCatching { Duration.parse(intervalIso8601).toString() } + .getOrElse { cause -> + throw IllegalArgumentException("Invalid YDB TTL interval: '$intervalIso8601'", cause) + } + internal fun validateYdbTtlColumn(ttl: YdbTtlSettings) { val sqlType = ttl.column.columnType.sqlType() diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt index eb61975d..68091bc2 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt @@ -3,6 +3,10 @@ package tech.ydb.exposed.dialect.types import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.ColumnType 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.jdbc.YdbPreparedStatement +import tech.ydb.table.values.PrimitiveType import java.math.BigDecimal import java.math.BigInteger import java.time.Duration @@ -54,8 +58,11 @@ class YdbIntervalColumnType : ColumnType() { override fun nonNullValueToString(value: Duration): String = "'$value'" } -class YdbJsonStringColumnType : ColumnType() { - override fun sqlType(): String = "Json" +abstract class YdbTypedStringColumnType( + private val ydbSqlType: String, + private val primitiveType: PrimitiveType +) : ColumnType() { + override fun sqlType(): String = ydbSqlType override fun valueFromDB(value: Any): String = value.toString() @@ -63,18 +70,25 @@ class YdbJsonStringColumnType : ColumnType() { override fun nonNullValueToString(value: String): String = "'${value.replace("'", "''")}'" -} -class YdbJsonDocumentStringColumnType : ColumnType() { - override fun sqlType(): String = "JsonDocument" + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + if (value == null) { + super.setParameter(stmt, index, null) + return + } - override fun valueFromDB(value: Any): String = value.toString() + val ydbStatement = (stmt as? JdbcPreparedStatementImpl)?.statement as? YdbPreparedStatement + if (ydbStatement != null) { + ydbStatement.setObject(index, value, primitiveType) + } else { + super.setParameter(stmt, index, value) + } + } +} - override fun notNullValueToDB(value: String): Any = value +class YdbJsonStringColumnType : YdbTypedStringColumnType("Json", PrimitiveType.Json) - override fun nonNullValueToString(value: String): String = - "'${value.replace("'", "''")}'" -} +class YdbJsonDocumentStringColumnType : YdbTypedStringColumnType("JsonDocument", PrimitiveType.JsonDocument) /** * Maps a Kotlin [UUID] to the native YDB `Uuid` type. diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt index fea6e28f..5a5d3992 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt @@ -1,6 +1,7 @@ package tech.ydb.exposed.dialect.integration.basic import org.jetbrains.exposed.v1.javatime.timestamp +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 @@ -59,4 +60,22 @@ class YdbDialectTtlSqlIT : BaseYdbTest() { assertTrue(sql.contains("ALTER TABLE"), sql) assertTrue(sql.contains("RESET (TTL)"), sql) } + + @Test + fun `rejects invalid TTL interval early`() { + val error = assertThrows(IllegalArgumentException::class.java) { + object : YdbTable("invalid_ttl_interval_table") { + val id = integer("id") + val expireAt = timestamp("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, """PT1H" ON hacked""") + } + } + } + + assertTrue(error.message?.contains("Invalid YDB TTL interval") == true, error.message) + } } 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 index 82b9be02..24c4193d 100644 --- 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 @@ -127,4 +127,23 @@ class YdbIndexSqlIT : BaseYdbTest() { assertTrue(sql.contains("ADD INDEX email_unique_lookup_idx GLOBAL UNIQUE"), sql) assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) } + + @Test + fun `quotes secondary index name when needed`() = tx { + val dialect = db.dialect as YdbDialect + val expectedName = db.identifierManager.cutIfNecessaryAndQuote("email-cover-idx") + + val sql = dialect.createSecondaryIndex( + table = IndexedTable, + spec = YdbSecondaryIndexSpec( + name = "email-cover-idx", + columns = listOf(IndexedTable.email), + unique = false, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) + ) + + assertTrue(sql.contains("ADD INDEX $expectedName GLOBAL"), sql) + } } diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectProviderTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectProviderTest.kt new file mode 100644 index 00000000..6d827099 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectProviderTest.kt @@ -0,0 +1,60 @@ +package tech.ydb.exposed.dialect.unit + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.YdbDialectProvider + +class YdbDialectProviderTest { + + @Test + fun `default temporal mode enables driver flag for new datetime types`() { + val actual = YdbDialectProvider.withTemporalDriverMode( + url = "jdbc:ydb:grpc://localhost:2136/local", + forceLegacyDatetimes = false + ) + + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?forceSignedDatetimes=true", + actual + ) + } + + @Test + fun `legacy temporal mode disables driver flag`() { + val actual = YdbDialectProvider.withTemporalDriverMode( + url = "jdbc:ydb:grpc://localhost:2136/local", + forceLegacyDatetimes = true + ) + + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?forceSignedDatetimes=false", + actual + ) + } + + @Test + fun `temporal mode preserves existing query parameters`() { + val actual = YdbDialectProvider.withTemporalDriverMode( + url = "jdbc:ydb:grpc://localhost:2136/local?token=abc", + forceLegacyDatetimes = false + ) + + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=true", + actual + ) + } + + @Test + fun `temporal mode replaces existing driver flag`() { + val actual = YdbDialectProvider.withTemporalDriverMode( + url = "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=false", + forceLegacyDatetimes = false + ) + + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=true", + actual + ) + } +} 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..9ba41a54 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/JdbcBindingCapture.kt @@ -0,0 +1,61 @@ +package tech.ydb.exposed.dialect.unit.types + +import org.jetbrains.exposed.v1.jdbc.statements.jdbc.JdbcPreparedStatementImpl +import tech.ydb.jdbc.YdbPreparedStatement +import tech.ydb.table.values.Type +import java.lang.reflect.Proxy +import java.sql.PreparedStatement + +data class BoundTypedObject( + val index: Int, + val value: Any?, + val type: Type +) + +fun ydbPreparedStatementCapture(): Pair BoundTypedObject?> { + var boundValue: BoundTypedObject? = null + + val proxy = Proxy.newProxyInstance( + YdbPreparedStatement::class.java.classLoader, + arrayOf(PreparedStatement::class.java, YdbPreparedStatement::class.java) + ) { _, method, args -> + when (method.name) { + "setObject" -> { + if (args?.size == 3 && args[0] is Int && args[2] is Type) { + boundValue = BoundTypedObject( + index = args[0] as Int, + value = args[1], + type = args[2] as Type + ) + } + null + } + + "toString" -> "YdbPreparedStatementProxy" + "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/YdbJsonDocumentStringColumnTypeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbJsonDocumentStringColumnTypeTest.kt index 62018794..c3e5f441 100644 --- 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 @@ -1,8 +1,10 @@ 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.types.YdbJsonDocumentStringColumnType +import tech.ydb.table.values.PrimitiveType class YdbJsonDocumentStringColumnTypeTest { @@ -31,4 +33,16 @@ class YdbJsonDocumentStringColumnTypeTest { val json = """{"name":"O'Brien"}""" assertEquals("""'{"name":"O''Brien"}'""", type.nonNullValueToString(json)) } + + @Test + fun `should bind json document with explicit YDB type`() { + val json = """{"name":"alice","active":true}""" + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, json) + + val actual = capture() + assertNotNull(actual) + assertEquals(BoundTypedObject(1, json, PrimitiveType.JsonDocument), 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 index 9d3c38a6..40f2d5f0 100644 --- 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 @@ -1,7 +1,9 @@ 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.table.values.PrimitiveType import tech.ydb.exposed.dialect.types.YdbJsonStringColumnType class YdbJsonStringColumnTypeTest { @@ -31,4 +33,16 @@ class YdbJsonStringColumnTypeTest { val json = """{"name":"O'Brien"}""" assertEquals("""'{"name":"O''Brien"}'""", type.nonNullValueToString(json)) } -} \ No newline at end of file + + @Test + fun `should bind json with explicit YDB type`() { + val json = """{"name":"alice","active":true}""" + val (stmt, capture) = ydbPreparedStatementCapture() + + type.setParameter(stmt, 1, json) + + val actual = capture() + assertNotNull(actual) + assertEquals(BoundTypedObject(1, json, PrimitiveType.Json), actual) + } +} From d47ca4387866db4c50b91f5d63c0fc2173c32ea3 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Mon, 18 May 2026 11:53:27 +0300 Subject: [PATCH 29/43] big refactoring --- kotlin-exposed-dialect/CHANGELOG.md | 22 +- kotlin-exposed-dialect/README.md | 92 ++--- .../ydb/exposed/dialect/example/DemoApp.kt | 6 +- .../ydb/exposed/dialect/example/DemoTables.kt | 2 +- kotlin-exposed-dialect/pom.xml | 43 ++- .../ydb/exposed/dialect/YdbColumnTypes.kt | 349 ++++++++++++++++++ .../tech/ydb/exposed/dialect/YdbDialect.kt | 75 ++-- .../ydb/exposed/dialect/YdbDialectProvider.kt | 100 ----- .../exposed/dialect/YdbDialectRegistration.kt | 102 +++++ .../ydb/exposed/dialect/YdbGeneratedIds.kt | 81 ---- .../tech/ydb/exposed/dialect/YdbIdTable.kt | 18 - .../ydb/exposed/dialect/YdbRetryConfig.kt | 48 +++ .../ydb/exposed/dialect/YdbRetryPolicy.kt | 128 +++++++ .../ydb/exposed/dialect/YdbStringIdTable.kt | 20 - .../tech/ydb/exposed/dialect/YdbTable.kt | 55 ++- .../ydb/exposed/dialect/YdbTableSupport.kt | 82 +--- .../ydb/exposed/dialect/YdbTransaction.kt | 137 +------ .../ydb/exposed/dialect/code/YdbJdbcCode.kt | 46 +++ .../ydb/exposed/dialect/code/YdbVendorCode.kt | 35 ++ .../javatime/YdbJavaTimeColumnTypes.kt | 100 +++++ .../dialect/types/YdbCustomColumnTypes.kt | 174 --------- .../dialect/types/YdbDecimalExpressions.kt | 26 -- .../dialect/integration/base/BaseYdbTest.kt | 8 +- .../basic/RegisterYdbDialectConnectIT.kt | 102 +++++ .../integration/basic/YdbDialectTtlSqlIT.kt | 8 +- .../dialect/integration/basic/YdbTableIT.kt | 6 +- .../dialect/integration/dao/DaoSmokeIT.kt | 20 +- .../dialect/integration/dao/GeneratedIdsIT.kt | 48 --- .../dialect/integration/dao/SerialDaoIT.kt | 48 +++ .../transaction/YdbRetryingTransactionsIT.kt | 18 +- .../integration/ttl/NumericTtlTypesIT.kt | 2 +- .../dialect/integration/ttl/TtlAlterSqlIT.kt | 4 +- .../dialect/integration/ttl/TtlTypesIT.kt | 4 +- .../integration/types/AllTypesRoundTripIT.kt | 219 +++++++++++ .../integration/types/BinaryHexToDbIT.kt | 89 +++++ .../integration/types/BinaryTypesIT.kt | 2 +- .../integration/types/DecimalTypesIT.kt | 2 +- .../integration/types/DecimalUpdateIT.kt | 4 +- .../types/ForceLegacyStandardTemporalIT.kt | 102 +++++ .../integration/types/IntervalTypesIT.kt | 48 ++- .../dialect/integration/types/JsonTypesIT.kt | 4 +- .../types/LegacyTemporalTypesIT.kt | 30 +- .../types/LegacyYdbTypesRoundTripIT.kt | 56 +++ .../integration/types/TemporalTypesIT.kt | 14 +- .../integration/types/Uint64TypesIT.kt | 2 +- .../dialect/integration/types/UuidTypesIT.kt | 2 +- .../unit/basic/YdbDialectProviderTest.kt | 60 --- .../dialect/unit/basic/YdbGeneratedIdsTest.kt | 23 -- .../dialect/unit/basic/YdbJdbcUrlTest.kt | 32 ++ .../dialect/unit/code/YdbJdbcCodeTest.kt | 24 ++ .../transaction/YdbRetryClassifierTest.kt | 84 ----- .../unit/transaction/YdbRetryPolicyTest.kt | 140 +++++++ .../dialect/unit/types/JdbcBindingCapture.kt | 22 +- .../unit/types/YdbDataTypeProviderTest.kt | 39 +- .../unit/types/YdbDecimalColumnTypeTest.kt | 16 +- .../unit/types/YdbDecimalLiteralTest.kt | 2 +- .../dialect/unit/types/YdbHexToDbTest.kt | 34 ++ .../unit/types/YdbIntervalColumnTypeTest.kt | 40 +- .../YdbJsonDocumentStringColumnTypeTest.kt | 8 +- .../unit/types/YdbJsonStringColumnTypeTest.kt | 8 +- .../unit/types/YdbTemporalColumnTypeTest.kt | 95 +++++ .../dialect/unit/types/YdbUintColumnTest.kt | 2 +- .../unit/types/YdbUnsignedColumnTypeTest.kt | 58 +++ .../unit/types/YdbUuidColumnTypeTest.kt | 2 +- 64 files changed, 2172 insertions(+), 1100 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbColumnTypes.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectRegistration.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryConfig.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryPolicy.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/code/YdbJdbcCode.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/code/YdbVendorCode.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/javatime/YdbJavaTimeColumnTypes.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/RegisterYdbDialectConnectIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/SerialDaoIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/AllTypesRoundTripIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryHexToDbIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/ForceLegacyStandardTemporalIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyYdbTypesRoundTripIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectProviderTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbJdbcUrlTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/code/YdbJdbcCodeTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryPolicyTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbHexToDbTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbTemporalColumnTypeTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUnsignedColumnTypeTest.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index f16e4a25..5c823c49 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -1,27 +1,25 @@ -## 0.1.0 +## 0.9.0 Initial release of the Kotlin Exposed dialect for YDB. ### Added -- YDB `VendorDialect` for Exposed JDBC, registered via `YdbDialectProvider.connect`. -- `ydbTransaction { ... }` / `ydbReadOnlyTransaction { ... }` — retryable transactions - that classify failures via `YdbStatusable` (no fragile error-message parsing) and apply - appropriate backoff for `ABORTED` / `OVERLOADED` / `BAD_SESSION` / `TRANSPORT_UNAVAILABLE` - / `TIMEOUT` (idempotent only) / `UNDETERMINED` (idempotent only). +- YDB `VendorDialect` for Exposed JDBC; `registerYdbDialect()` / `connectYdb()` for setup. +- `ydbTransaction { ... }` — retryable transactions with `readOnly` and `YdbRetryConfig` + (backoff/jitter aligned with [.NET YdbRetryPolicy](https://github.com/ydb-platform/ydb-dotnet-sdk/tree/main/src/Ydb.Sdk/src/Ado/RetryPolicy); + retries classified by JDBC `SQLException` vendor codes). - Native `UPSERT` / `REPLACE` rendering — wired into Exposed's standard `Table.upsert` and `Table.replace` DSL. - YDB-compatible `CREATE TABLE` generation with mandatory primary key. -- Secondary index DSL on `YdbTable` / `YdbIdTable` — global, async, cover columns, unique. +- Secondary index DSL on `YdbTable` — global, async, cover columns, unique. - TTL clause on `CREATE TABLE` / `ALTER TABLE`, plus numeric epoch modes. - JDBC metadata for reading existing indexes. -- Default temporal mapping to extended types: `Date32` / `Datetime64` / `Timestamp64`. - Legacy types are available via `YdbDialectProvider.connect(forceLegacyDatetimes = true)`. +- Temporal columns: unsigned (`YdbTable.date`, …) and signed (`date32`, …) extensions with + JDBC vendor codes; DDL `sqlType()` derived from the code. No connection-level temporal flag. - Custom column types for `Decimal`, `Interval`, `Json`, `JsonDocument`, three `Uuid` flavours and `Uint64`, plus a `ydbDecimalLiteral` helper for update expressions. -- Table base classes for generated identifiers: `YdbUuidIdTable`, `YdbUlidTable`, - `YdbStringIdTable`. -- Explicit rejection of `AUTO_INCREMENT` and ANSI `MERGE` (`UPSERT` covers the use case). +- `Serial` / `BigSerial` via Exposed `autoIncrement()` on `YdbTable`. +- Explicit rejection of ANSI `MERGE` (`UPSERT` covers the use case). - Console demo application showing CRUD, UPSERT and DDL. - Integration tests powered by testcontainers via `tech.ydb.test:ydb-junit5-support`. - GitHub Actions workflows for CI (`ci-exposed-ydb-dialect.yaml`) and Maven Central diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 6975a11f..6ac9813f 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -5,8 +5,7 @@ The module provides: - a Kotlin Exposed `VendorDialect` for YDB (DDL, SQL, type mapping, secondary indexes, TTL); - `Table.upsert` / `Table.replace` DSL backed by native YDB `UPSERT` / `REPLACE`; -- a retryable transaction wrapper that handles YDB's OCC retries transparently; -- table base classes for tables with generated identifiers (UUID, ULID, ...). +- a retryable transaction wrapper that handles YDB's OCC retries transparently. ## Requirements @@ -18,21 +17,20 @@ The module provides: ## Quick start ```kotlin -import tech.ydb.exposed.dialect.YdbDialectProvider +import tech.ydb.exposed.dialect.connectYdb import tech.ydb.exposed.dialect.ydbTransaction -val db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local" -) +val db = connectYdb(url = "jdbc:ydb:grpc://localhost:2136/local") ydbTransaction(db) { // Exposed DSL / DAO code } ``` -`YdbDialectProvider.connect` registers the YDB JDBC driver and dialect metadata exactly once, -then opens an Exposed `Database` with a sane default configuration -(`SERIALIZABLE` isolation, no nested transactions). +[connectYdb](src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectRegistration.kt) registers the YDB +JDBC driver and dialect metadata (idempotent), then opens an Exposed `Database` with defaults tuned +for YDB (`SERIALIZABLE` isolation, no nested transactions). Alternatively, call +`registerYdbDialect()` once and use `Database.connect("jdbc:ydb:...")`. ## Defining tables @@ -43,7 +41,7 @@ DDL helpers on top of the standard Exposed `Table`: import tech.ydb.exposed.dialect.YdbIndexScope import tech.ydb.exposed.dialect.YdbIndexSyncMode import tech.ydb.exposed.dialect.YdbTable -import tech.ydb.exposed.dialect.types.ydbDecimal +import tech.ydb.exposed.dialect.ydbDecimal object Products : YdbTable("products") { val id = integer("id") @@ -69,9 +67,6 @@ object Products : YdbTable("products") { } ``` -For tables that need to participate in Exposed DAO, use `YdbIdTable` (or its specializations -`YdbUuidIdTable`, `YdbUlidTable`, `YdbStringIdTable`). - ## Insert / upsert / replace / update / delete Exposed's standard DSL works as-is. YDB's native `UPSERT` and `REPLACE` are exposed via @@ -104,25 +99,30 @@ invalidated` under contention. Use `ydbTransaction` instead of plain `transactio the body on retryable YDB statuses (`ABORTED`, `OVERLOADED`, `BAD_SESSION`, ...): ```kotlin +import tech.ydb.exposed.dialect.YdbRetryConfig import tech.ydb.exposed.dialect.ydbTransaction -import tech.ydb.exposed.dialect.ydbReadOnlyTransaction ydbTransaction(db) { - // read-write, non-idempotent + // read-write; retries transient YDB statuses (ABORTED, OVERLOADED, BAD_SESSION, ...) } -ydbTransaction(db, idempotent = true) { - // single UPSERT / pure read body — TIMEOUT / UNDETERMINED also retried +ydbTransaction(db, retry = YdbRetryConfig.IDEMPOTENT) { + // idempotent body — UNDETERMINED and other non-transient retryable codes are retried too } -ydbReadOnlyTransaction(db) { - // shortcut for idempotent read-only work +ydbTransaction(db, readOnly = true, retry = YdbRetryConfig.IDEMPOTENT) { + // read-only snapshot work } ``` -Set `idempotent = true` only when the body can be safely re-executed (pure reads, single -`UPSERT` / `REPLACE`, idempotent business operation). The classifier inspects YDB status codes -via `YdbStatusable` rather than parsing error message text. +Backoff and jitter follow the [.NET YDB SDK retry policy](https://github.com/ydb-platform/ydb-dotnet-sdk/tree/main/src/Ydb.Sdk/src/Ado/RetryPolicy): +full jitter for `ABORTED` / `UNDETERMINED`, equal jitter for `UNAVAILABLE` / transport / +`OVERLOADED`, 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 @@ -139,21 +139,27 @@ Default mapping for standard Exposed types: | `varchar` / `text` | `Text` | | `binary` / `blob` | `String` | | `uuid` | `Uuid` | -| `date` | `Date32` | -| `datetime` | `Datetime64` | -| `timestamp` | `Timestamp64` | +| `date` | `Date` | +| `datetime` | `Datetime` | +| `timestamp` | `Timestamp` | | `json` | `JsonDocument` | -Temporal columns default to YDB **extended** types (`Date32`, `Datetime64`, `Timestamp64`). -To target the legacy unsigned types when integrating with an existing schema, pass -`forceLegacyDatetimes = true`: +Pick unsigned legacy or signed extended temporal types per column on any `Table` +(including `YdbTable`); JDBC vendor code drives both bind and DDL `sqlType()`: ```kotlin -val db = YdbDialectProvider.connect( - url = "jdbc:ydb:grpc://localhost:2136/local", - forceLegacyDatetimes = true // emits Date / Datetime / Timestamp -) +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDate32 +import tech.ydb.exposed.dialect.javatime.ydbDatetime64 + +object Events : YdbTable("events") { + val created = ydbDate("created") // Date + val expires = ydbDate32("expires") // Date32 + val updated = ydbDatetime64("updated") // Datetime64 +} ``` +`connectYdb` sets `forceSignedDatetimes=false` on the JDBC URL for driver compatibility; +per-column types are not controlled by a connection flag. Additional YDB-specific column types are available via extension functions on `Table`: @@ -172,30 +178,26 @@ ydbUint64("counter") For Decimal literals inside update expressions there's `ydbDecimalLiteral`: ```kotlin -import tech.ydb.exposed.dialect.types.ydbDecimalLiteral +import tech.ydb.exposed.dialect.ydbDecimalLiteral it.update(Products.price, ydbDecimalLiteral(BigDecimal("45.00"), 10, 2)) ``` ## Identifiers -YDB does not expose `AUTO_INCREMENT`. The dialect explicitly rejects `autoIncrement()`. -Use one of the IdTable base classes instead: - -- `YdbUuidIdTable` — native YDB `Uuid` column, auto-generated via `UUID.randomUUID()`; -- `YdbUlidTable` — 26-char [ULID](https://github.com/ulid/spec), lexicographically sortable; -- `YdbStringIdTable` — caller-provided business key. - -A top-level `ydbUlid()` is also exposed for generating ULIDs manually. +On `YdbTable`, Exposed `autoIncrement()` maps to YDB `Serial` / `BigSerial`: ```kotlin -import tech.ydb.exposed.dialect.YdbUlidTable - -object Events : YdbUlidTable("events") { - val payload = text("payload") +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. + ## TTL ```kotlin 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 index 8c27ea1b..e3831be2 100644 --- 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 @@ -6,13 +6,13 @@ 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 tech.ydb.exposed.dialect.YdbDialectProvider -import tech.ydb.exposed.dialect.types.ydbDecimalLiteral +import tech.ydb.exposed.dialect.connectYdb +import tech.ydb.exposed.dialect.ydbDecimalLiteral import tech.ydb.exposed.dialect.ydbTransaction import java.math.BigDecimal fun main() { - val db = YdbDialectProvider.connect( + val db = connectYdb( url = "jdbc:ydb:grpc://localhost:2136/local" ) 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 index 6f761dd6..e9ede561 100644 --- 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 @@ -3,7 +3,7 @@ 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.types.ydbDecimal +import tech.ydb.exposed.dialect.ydbDecimal object DemoProducts : YdbTable("demo_products") { val id = integer("id") diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index 570e22fc..ef90ada9 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -6,7 +6,7 @@ tech.ydb.dialects kotlin-exposed-ydb-dialect - 0.1.0 + 0.9.0 jar @@ -50,21 +50,21 @@ 17 2.2.20 - 1.0.0 + 1.3.0 5.10.2 2.17.2 - 2.3.22 + 2.4.3 2.3.22 - tech.ydb - ydb-sdk-bom - ${ydb.sdk.version} + org.jetbrains.exposed + exposed-bom + ${exposed.version} pom import @@ -81,34 +81,21 @@ org.jetbrains.exposed exposed-core - ${exposed.version} org.jetbrains.exposed exposed-jdbc - ${exposed.version} - - - - org.jetbrains.exposed - exposed-java-time - ${exposed.version} - - - - org.jetbrains.exposed - exposed-dao - ${exposed.version} + tech.ydb.jdbc ydb-jdbc-driver ${ydb.jdbc.version} + test - org.junit.jupiter junit-jupiter @@ -116,9 +103,22 @@ test + + org.jetbrains.exposed + exposed-java-time + test + + + + org.jetbrains.exposed + exposed-dao + test + + tech.ydb.test ydb-junit5-support + ${ydb.sdk.version} test @@ -232,7 +232,6 @@ true - ydbplatform/local-ydb:trunk 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..6ac06bcd --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbColumnTypes.kt @@ -0,0 +1,349 @@ +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.IColumnType +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.sql.PreparedStatement +import java.time.Duration +import java.util.UUID +import kotlin.UByte +import kotlin.UInt +import kotlin.ULong +import kotlin.UShort + +// region Table column extensions + +fun Table.ydbDecimal(name: String, precision: Int, scale: Int): Column = + registerColumn(name, YdbDecimalColumnType(precision, scale)) + +fun Table.ydbJson(name: String): Column = + registerColumn(name, YdbJsonStringColumnType()) + +fun Table.ydbJsonDocument(name: String): Column = + registerColumn(name, YdbJsonDocumentStringColumnType()) + +fun Table.ydbUuid(name: String): Column = + registerColumn(name, YdbUuidColumnType()) + +fun Table.ydbUint64(name: String): Column = + registerColumn(name, YdbUint64ColumnType()) + +fun Table.ydbUbyte(name: String): Column = + registerColumn(name, YdbUByteColumnType()) + +fun Table.ydbUshort(name: String): Column = + registerColumn(name, YdbUShortColumnType()) + +fun Table.ydbUint32(name: String): Column = + registerColumn(name, YdbUIntegerColumnType()) + +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)) + +fun ydbDecimalLiteral( + value: BigDecimal, + precision: Int, + scale: Int +): Expression = YdbDecimalLiteral(value, precision, scale) + +// endregion + +// region JDBC bind helper + +internal fun bindYdbParameter( + stmt: PreparedStatementApi, + index: Int, + value: Any?, + targetSqlType: Int, + columnType: IColumnType<*> +) { + if (value == null) { + stmt.setNull(index, columnType) + return + } + + val jdbcStatement = (stmt as? JdbcPreparedStatementImpl)?.statement + if (jdbcStatement is PreparedStatement) { + 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, this) + } + + 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, this) + } +} + +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, this) + } +} + +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, this) + } +} + +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, this) + } +} + +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, this) + } +} + +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, this) + } +} + +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 { + val longValue = value.toLong() + require(longValue >= 0) { "Uint64 column cannot store negative value: $value" } + return longValue + } + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + val dbValue = when (value) { + is ULong -> value.toLong() + else -> value + } + bindYdbParameter(stmt, index, dbValue, YdbJdbcCode.UINT64, this) + } +} + +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, this) + } +} + +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() +} + +// 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 index 77537a3d..a8011f26 100644 --- 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 @@ -18,56 +18,56 @@ import java.sql.DatabaseMetaData /** * Default YDB column type mappings used by Exposed when a column is declared via standard - * Exposed DSL (`integer`, `varchar`, `date`, ...) — see `YdbCustomColumnTypes.kt` for YDB-specific + * Exposed DSL (`integer`, `varchar`, `date`, ...) — see `YdbColumnTypes.kt` for YDB-specific * column types that have no direct Exposed equivalent (e.g. `JsonDocument`, `Interval`, `Uint64`). * - * Temporal columns default to YDB **extended** types (`Date32`, `Datetime64`, `Timestamp64`). - * Use `YdbDialectProvider.connect(..., forceLegacyDatetimes = true)` to fall back to the - * legacy unsigned range (`Date`, `Datetime`, `Timestamp`) when integrating with schemas - * that already use them. + * 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 forceLegacyDatetimes: Boolean -) : DataTypeProvider() { - override fun byteType(): String = "Int8" - override fun ubyteType(): String = "Uint8" - - override fun binaryType(): String = "String" - override fun binaryType(length: Int): String = "String" - - override fun blobType(): String = binaryType() - - override fun hexToDb(hexString: String): String = "'$hexString'" +internal class YdbDataTypeProvider : DataTypeProvider() { + override fun booleanType(): String = "Bool" + override fun byteType(): String = "Int8" override fun shortType(): String = "Int16" - override fun ushortType(): String = "Uint16" - override fun integerType(): String = "Int32" - override fun uintegerType(): String = "Uint32" - - override fun integerAutoincType(): String = - throw UnsupportedOperationException( - "YDB does not support AUTO_INCREMENT. Use YdbUuidIdTable, YdbUlidTable or YdbStringIdTable instead." - ) - override fun longType(): String = "Int64" - override fun booleanType(): String = "Bool" + + 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 varcharType(colLength: Int): String = "Text" + 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 dateType(): String = if (forceLegacyDatetimes) "Date" else "Date32" - override fun dateTimeType(): String = if (forceLegacyDatetimes) "Datetime" else "Datetime64" - override fun timestampType(): String = if (forceLegacyDatetimes) "Timestamp" else "Timestamp64" + 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 jsonType(): String = "JsonDocument" + override fun dateType(): String = "Date" + override fun dateTimeType(): String = "Datetime" + override fun timestampType(): String = "Timestamp" + + override fun hexToDb(hexString: String): String = "String::HexDecode('$hexString')" } internal object YdbFunctionProvider : FunctionProvider() { @@ -185,20 +185,15 @@ internal object YdbFunctionProvider : FunctionProvider() { /** * Exposed [VendorDialect] for YDB. * - * Usually obtained via [YdbDialectProvider.connect], which wires it into a [Database] together + * Usually obtained via [connectYdb], which wires it into a [Database] together * with a default [org.jetbrains.exposed.v1.core.DatabaseConfig] tuned for YDB * (SERIALIZABLE isolation, nested transactions disabled). */ -class YdbDialect internal constructor( - forceLegacyDatetimes: Boolean -) : VendorDialect( +class YdbDialect internal constructor() : VendorDialect( DIALECT_NAME, - YdbDataTypeProvider(forceLegacyDatetimes), + YdbDataTypeProvider(), YdbFunctionProvider ) { - - constructor() : this(forceLegacyDatetimes = false) - override fun createIndex(index: Index): String { val tr = runCatching { TransactionManager.current() }.getOrNull() if (!index.functions.isNullOrEmpty()) { diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt deleted file mode 100644 index bf6c01d1..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectProvider.kt +++ /dev/null @@ -1,100 +0,0 @@ -package tech.ydb.exposed.dialect - -import org.jetbrains.exposed.v1.core.DatabaseConfig -import org.jetbrains.exposed.v1.jdbc.Database -import java.sql.Connection -import java.util.concurrent.atomic.AtomicBoolean - -/** - * Entry point that wires the YDB dialect into Exposed and produces a [Database] tuned for YDB. - * - * Registration with Exposed is performed once per JVM in [init], which is automatically called - * by [connect]. After that, plain `Database.connect("jdbc:ydb:...")` also works because Exposed - * resolves dialect/driver via the registered prefix. - */ -object YdbDialectProvider { - private const val URL_PREFIX = "jdbc:ydb:" - private const val DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" - internal const val FORCE_SIGNED_DATETIMES_PROPERTY = "forceSignedDatetimes" - - private val initialized = AtomicBoolean(false) - - /** - * Registers the YDB JDBC driver, dialect name and dialect metadata with Exposed. - * Idempotent — repeated calls are no-ops. - */ - fun init() { - if (!initialized.compareAndSet(false, true)) return - - Database.registerJdbcDriver( - prefix = URL_PREFIX, - driverClassName = DRIVER_CLASS, - dialect = YdbDialect.DIALECT_NAME - ) - - Database.registerDialectMetadata(YdbDialect.DIALECT_NAME) { - YdbDialect.Metadata - } - } - - /** - * Opens a YDB-backed Exposed [Database]. - * - * @param url JDBC URL, e.g. `jdbc:ydb:grpc://localhost:2136/local`. - * @param forceLegacyDatetimes When `true`, the dialect emits legacy YDB temporal types - * (`Date`, `Datetime`, `Timestamp`) instead of the default extended ones - * (`Date32`, `Datetime64`, `Timestamp64`). The same mode is also propagated to the YDB JDBC - * driver via the `forceSignedDatetimes` URL flag, so column DDL and value binding stay in sync. - * Use legacy mode only when integrating with schemas that already rely on the unsigned range. - */ - fun connect( - url: String, - user: String = "", - password: String = "", - forceLegacyDatetimes: Boolean = false - ): Database { - init() - val driverUrl = withTemporalDriverMode(url, forceLegacyDatetimes) - - return Database.connect( - url = driverUrl, - driver = DRIVER_CLASS, - user = user, - password = password, - databaseConfig = DatabaseConfig { - explicitDialect = YdbDialect(forceLegacyDatetimes = forceLegacyDatetimes) - defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE - defaultReadOnly = false - useNestedTransactions = false - } - ) - } - - internal fun withTemporalDriverMode(url: String, forceLegacyDatetimes: Boolean): String = - withBooleanQueryParameter( - url = url, - name = FORCE_SIGNED_DATETIMES_PROPERTY, - value = !forceLegacyDatetimes - ) - - private fun withBooleanQueryParameter(url: String, name: String, value: Boolean): String { - val queryStart = url.indexOf('?') - if (queryStart < 0) { - return "$url?$name=$value" - } - - val base = url.substring(0, queryStart) - val query = url.substring(queryStart + 1) - val filteredParams = query - .split('&') - .filter { it.isNotBlank() } - .filterNot { param -> - val key = param.substringBefore('=') - key == name - } - .toMutableList() - - filteredParams += "$name=$value" - return "$base?${filteredParams.joinToString("&")}" - } -} 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..16a43d49 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectRegistration.kt @@ -0,0 +1,102 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.DatabaseApi +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.jdbc.Database +import java.sql.Connection +import java.util.concurrent.atomic.AtomicBoolean + +internal const val YDB_JDBC_URL_PREFIX = "jdbc:ydb:" +internal const val YDB_DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" +internal const val FORCE_SIGNED_DATETIMES_PROPERTY = "forceSignedDatetimes" + +private val dialectRegistered = AtomicBoolean(false) + +/** + * Registers the YDB JDBC driver and Exposed dialect (idempotent). + * + * After registration, `Database.connect("jdbc:ydb:...")` resolves [YdbDialect] automatically. + */ +fun registerYdbDialect() { + if (!dialectRegistered.compareAndSet(false, true)) { + return + } + + Database.registerJdbcDriver( + prefix = YDB_JDBC_URL_PREFIX, + driverClassName = YDB_DRIVER_CLASS, + dialect = YdbDialect.DIALECT_NAME + ) + + Database.registerDialectMetadata(YdbDialect.DIALECT_NAME) { + YdbDialect.Metadata + } + + DatabaseApi.registerDialect(YdbDialect.DIALECT_NAME) { + YdbDialect() + } +} + +/** + * Opens a YDB-backed Exposed [Database] with dialect defaults tuned for YDB. + * + * JDBC URL is augmented with `forceSignedDatetimes=false` for driver backward compatibility. + * Temporal column types (unsigned vs signed) are chosen per column via [tech.ydb.exposed.dialect.javatime.ydbDate] + * / [tech.ydb.exposed.dialect.javatime.ydbDate32], not via a connection flag. + */ +fun connectYdb( + url: String, + user: String = "", + password: String = "" +): Database { + ensureYdbDialectRegistered() + + return Database.connect( + url = ydbJdbcUrl(url), + driver = YDB_DRIVER_CLASS, + user = user, + password = password, + databaseConfig = ydbDatabaseConfig() + ) +} + +internal fun ydbDatabaseConfig(): DatabaseConfig = DatabaseConfig { + explicitDialect = YdbDialect() + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + defaultReadOnly = false + useNestedTransactions = false +} + +internal fun ydbJdbcUrl(url: String): String = + appendBooleanQueryParameter( + url = url, + name = FORCE_SIGNED_DATETIMES_PROPERTY, + value = false + ) + +private fun appendBooleanQueryParameter(url: String, name: String, value: Boolean): String { + val queryStart = url.indexOf('?') + if (queryStart < 0) { + return "$url?$name=$value" + } + + val base = url.substring(0, queryStart) + val query = url.substring(queryStart + 1) + val filteredParams = query + .split('&') + .filter { it.isNotBlank() } + .filterNot { param -> + param.substringBefore('=') == name + } + .toMutableList() + + filteredParams += "$name=$value" + return "$base?${filteredParams.joinToString("&")}" +} + +private fun ensureYdbDialectRegistered() { + if (dialectRegistered.get()) { + return + } + registerYdbDialect() +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt deleted file mode 100644 index bb1ab67f..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbGeneratedIds.kt +++ /dev/null @@ -1,81 +0,0 @@ -package tech.ydb.exposed.dialect - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.dao.id.EntityID -import tech.ydb.exposed.dialect.types.ydbUuid -import java.security.SecureRandom -import java.util.UUID - -private const val ULID_LENGTH = 26 -private const val ULID_RANDOM_BYTES = 10 -private val ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".toCharArray() -private val ULID_RANDOM = SecureRandom() - -/** - * Generates a [ULID](https://github.com/ulid/spec) encoded as a 26-character Crockford-base32 string. - * Time component is taken from [nowMillis] (defaults to current wall clock), random component is - * cryptographically random. Lexicographic ordering of ULIDs matches their time component. - */ -fun ydbUlid(nowMillis: Long = System.currentTimeMillis()): String { - require(nowMillis >= 0) { "ULID timestamp must be non-negative" } - - val chars = CharArray(ULID_LENGTH) - var timestamp = nowMillis - - for (i in 9 downTo 0) { - chars[i] = ULID_ALPHABET[(timestamp and 31L).toInt()] - timestamp = timestamp ushr 5 - } - - val bytes = ByteArray(ULID_RANDOM_BYTES) - ULID_RANDOM.nextBytes(bytes) - - var bitBuffer = 0 - var bitCount = 0 - var charIndex = 10 - - for (byte in bytes) { - bitBuffer = (bitBuffer shl 8) or (byte.toInt() and 0xff) - bitCount += 8 - - while (bitCount >= 5 && charIndex < ULID_LENGTH) { - bitCount -= 5 - chars[charIndex++] = ULID_ALPHABET[(bitBuffer ushr bitCount) and 31] - } - } - - if (charIndex < ULID_LENGTH) { - chars[charIndex] = ULID_ALPHABET[(bitBuffer shl (5 - bitCount)) and 31] - } - - return String(chars) -} - -/** - * IdTable with a native YDB `Uuid` primary key, auto-generated client-side via - * [UUID.randomUUID]. - */ -open class YdbUuidIdTable(name: String = "") : YdbIdTable(name) { - final override val id: Column> = ydbUuid("id") - .clientDefault { UUID.randomUUID() } - .entityId() - - final override val primaryKey = PrimaryKey(id) -} - -/** - * IdTable with a [ULID][ydbUlid] string primary key, auto-generated client-side. - * - * Pick this over [YdbUuidIdTable] when you want lexicographically-sortable identifiers - * (e.g. range scans by id approximate time-of-creation order). - */ -open class YdbUlidTable( - name: String = "", - idLength: Int = ULID_LENGTH -) : YdbIdTable(name) { - final override val id: Column> = varchar("id", idLength) - .clientDefault { ydbUlid() } - .entityId() - - final override val primaryKey = PrimaryKey(id) -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt deleted file mode 100644 index 61fddd05..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbIdTable.kt +++ /dev/null @@ -1,18 +0,0 @@ -package tech.ydb.exposed.dialect - -import org.jetbrains.exposed.v1.core.dao.id.IdTable - -/** - * Base class for YDB tables with a typed entity id, used with Exposed DAO. - * - * Same DDL surface as [YdbTable] — [ttl] / [secondaryIndex] come from [YdbTableDsl] via - * Kotlin delegation. Subclasses declare the primary key explicitly via `final override val id` - * / `primaryKey` (see [YdbUuidIdTable], [YdbUlidTable], [YdbStringIdTable]). - */ -abstract class YdbIdTable(name: String = "") : - IdTable(name), - YdbTableDsl by YdbTableFeatures() { - - override fun createStatement(): List = - buildYdbCreateStatement(this, ttlSettings, ydbSecondaryIndices) -} 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..bbae5f0b --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryConfig.kt @@ -0,0 +1,48 @@ +package tech.ydb.exposed.dialect + +/** + * Retry and backoff settings for [ydbTransaction], aligned with + * [YdbRetryPolicyConfig](https://github.com/ydb-platform/ydb-dotnet-sdk/blob/main/src/Ydb.Sdk/src/Ado/RetryPolicy/YdbRetryPolicyConfig.cs). + */ +data class YdbRetryConfig( + /** + * Total number of execution attempts (initial try + retries), same as .NET [maxAttempts]. + */ + val maxAttempts: Int = DEFAULT_MAX_ATTEMPTS, + val fastBackoffBaseMs: Int = DEFAULT_FAST_BACKOFF_BASE_MS, + val slowBackoffBaseMs: Int = DEFAULT_SLOW_BACKOFF_BASE_MS, + val fastCapBackoffMs: Int = DEFAULT_FAST_CAP_BACKOFF_MS, + val slowCapBackoffMs: Int = DEFAULT_SLOW_CAP_BACKOFF_MS, + /** + * When `true`, retryable statuses from [tech.ydb.exposed.dialect.YdbRetryPolicy] are retried + * even if they are not classified as transient. 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..56defcbe --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbRetryPolicy.kt @@ -0,0 +1,128 @@ +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 aligned with + * [YdbRetryPolicy](https://github.com/ydb-platform/ydb-dotnet-sdk/blob/main/src/Ydb.Sdk/src/Ado/RetryPolicy/YdbRetryPolicy.cs). + * + * - [fullJitterMillis] — `Aborted`, `Undetermined` + * - [equalJitterMillis] — `Unavailable`, transport errors, `Overloaded`, resource exhausted + * - zero delay — `BadSession`, `SessionBusy`, `SessionExpired` + */ +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 + } +} + +/** Mirrors .NET `YdbException.IsTransient` / [tech.ydb.core.StatusCode] always-retryable set. */ +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/YdbStringIdTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt deleted file mode 100644 index 48cba5c9..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbStringIdTable.kt +++ /dev/null @@ -1,20 +0,0 @@ -package tech.ydb.exposed.dialect - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.dao.id.EntityID - -/** - * IdTable with a user-provided string primary key (e.g. business keys, slugs). - * - * No client default — callers must set `[idColumn] = "..."` on every insert. - * For auto-generated ids prefer [YdbUuidIdTable] (native UUID) or [YdbUlidTable] (sortable). - */ -open class YdbStringIdTable( - name: String = "", - idLength: Int = 64 -) : YdbIdTable(name) { - - final override val id: Column> = varchar("id", idLength).entityId() - - final override val primaryKey = PrimaryKey(id) -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt index 61310e47..61e5ed45 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt @@ -1,20 +1,57 @@ package tech.ydb.exposed.dialect +import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Table /** * Base class for YDB row-oriented tables. * - * Adds YDB-specific DDL extensions on top of Exposed's [Table] (see [YdbTableDsl]): - * - [ttl] declares a TTL on a date/numeric column; - * - [secondaryIndex] declares a YDB secondary index with COVER / ASYNC / WITH params. - * - * Tables that need a generated primary key for DAO should use [YdbIdTable] (or one of its - * specializations: [YdbUuidIdTable], [YdbUlidTable], [YdbStringIdTable]). + * Adds YDB-specific DDL on top of Exposed [Table]: + * - [ttl] — TTL on a date/numeric column; + * - [secondaryIndex] — YDB secondary index with COVER / ASYNC / WITH params. */ -open class YdbTable(name: String = "") : - Table(name), - YdbTableDsl by YdbTableFeatures() { +open class YdbTable(name: String = "") : Table(name) { + + private var ttlSettingsState: YdbTtlSettings? = null + private val secondaryIndices = mutableListOf() + + fun ttl( + column: Column<*>, + intervalIso8601: String, + mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE + ) { + ttlSettingsState = YdbTtlSettings(column, normalizeTtlInterval(intervalIso8601), mode) + } + + fun secondaryIndex( + name: String, + vararg columns: Column<*>, + unique: Boolean = false, + scope: YdbIndexScope = YdbIndexScope.GLOBAL, + syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + indexType: String? = null, + coverColumns: List> = emptyList(), + withParams: Map = emptyMap() + ) { + require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } + + secondaryIndices += YdbSecondaryIndexSpec( + name = name, + columns = columns.toList(), + unique = unique, + scope = scope, + syncMode = syncMode, + indexType = indexType, + coverColumns = coverColumns, + withParams = withParams + ) + } + + internal val ttlSettings: YdbTtlSettings? + get() = ttlSettingsState + + private val ydbSecondaryIndices: List + get() = secondaryIndices override fun createStatement(): List = buildYdbCreateStatement(this, ttlSettings, ydbSecondaryIndices) diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt index 5fe6c168..66bd8ae7 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt @@ -1,81 +1,9 @@ package tech.ydb.exposed.dialect -import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import java.time.Duration -/** - * YDB-specific DDL surface shared by [YdbTable] and [YdbIdTable]. - * - * Both implement this interface through Kotlin delegation to a single [YdbTableFeatures] - * instance, so TTL/secondary-index state is collected and rendered in exactly one place. - */ -sealed interface YdbTableDsl { - fun ttl( - column: Column<*>, - intervalIso8601: String, - mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE - ) - - fun secondaryIndex( - name: String, - vararg columns: Column<*>, - unique: Boolean = false, - scope: YdbIndexScope = YdbIndexScope.GLOBAL, - syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, - indexType: String? = null, - coverColumns: List> = emptyList(), - withParams: Map = emptyMap() - ) - - val ttlSettings: YdbTtlSettings? - val ydbSecondaryIndices: List -} - -/** - * Default in-memory implementation of [YdbTableDsl] used as a delegate by [YdbTable] and - * [YdbIdTable]. Users typically don't need to reference this class directly. - */ -class YdbTableFeatures : YdbTableDsl { - private var ttlSettingsState: YdbTtlSettings? = null - private val secondaryIndices = mutableListOf() - - override fun ttl(column: Column<*>, intervalIso8601: String, mode: YdbTtlColumnMode) { - ttlSettingsState = YdbTtlSettings(column, normalizeTtlInterval(intervalIso8601), mode) - } - - override fun secondaryIndex( - name: String, - vararg columns: Column<*>, - unique: Boolean, - scope: YdbIndexScope, - syncMode: YdbIndexSyncMode, - indexType: String?, - coverColumns: List>, - withParams: Map - ) { - require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } - - secondaryIndices += YdbSecondaryIndexSpec( - name = name, - columns = columns.toList(), - unique = unique, - scope = scope, - syncMode = syncMode, - indexType = indexType, - coverColumns = coverColumns, - withParams = withParams - ) - } - - override val ttlSettings: YdbTtlSettings? - get() = ttlSettingsState - - override val ydbSecondaryIndices: List - get() = secondaryIndices -} - internal fun buildYdbCreateStatement( table: Table, ttlSettings: YdbTtlSettings?, @@ -150,11 +78,11 @@ internal fun validateYdbTtlColumn(ttl: YdbTtlSettings) { val supported = when (ttl.mode) { YdbTtlColumnMode.DATE_TYPE -> sqlType == "Date" || - sqlType == "Date32" || - sqlType == "Datetime" || - sqlType == "Datetime64" || - sqlType == "Timestamp" || - sqlType == "Timestamp64" + sqlType == "Date32" || + sqlType == "Datetime" || + sqlType == "Datetime64" || + sqlType == "Timestamp" || + sqlType == "Timestamp64" YdbTtlColumnMode.SECONDS, YdbTtlColumnMode.MILLISECONDS, 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 index 5054e615..7776ebcc 100644 --- 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 @@ -3,36 +3,24 @@ 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 -import tech.ydb.core.StatusCode -import tech.ydb.jdbc.exception.YdbStatusable -import kotlin.math.min -import kotlin.random.Random /** - * Runs [statement] inside an Exposed [transaction] and retries it on retryable YDB errors - * (ABORTED / OVERLOADED / BAD_SESSION / TRANSPORT_UNAVAILABLE / ...). + * Runs [statement] inside an Exposed [transaction] and retries it on retryable YDB errors. * - * This is the recommended way to execute a unit of work against YDB from Kotlin Exposed. - * YDB uses Optimistic Concurrency Control, so under contention a transaction can fail with - * `Transaction locks invalidated` — the retry loop here makes such conflicts transparent. - * - * Set [idempotent] to `true` if the transaction body has no externally observable side effects - * besides the database write (e.g. read-only queries, single UPSERT/REPLACE, idempotent writes). - * Errors with undetermined outcome (TIMEOUT / UNDETERMINED) are retried only when [idempotent] is `true`. + * Retry behaviour is controlled by [retry] ([YdbRetryConfig]), aligned with + * [YdbRetryPolicy](https://github.com/ydb-platform/ydb-dotnet-sdk/blob/main/src/Ydb.Sdk/src/Ado/RetryPolicy/YdbRetryPolicy.cs). */ fun ydbTransaction( db: Database? = null, - idempotent: Boolean = false, - maxAttempts: Int = 5, + retry: YdbRetryConfig = YdbRetryConfig.DEFAULT, readOnly: Boolean = false, statement: JdbcTransaction.() -> T ): T { - require(maxAttempts >= 1) { "maxAttempts must be >= 1" } + require(retry.maxAttempts >= 1) { "maxAttempts must be >= 1" } var lastError: Throwable? = null - repeat(maxAttempts) { index -> - val attempt = index + 1 + repeat(retry.maxAttempts) { attempt -> try { return transaction( db = db, @@ -43,15 +31,11 @@ fun ydbTransaction( } catch (t: Throwable) { lastError = t - val decision = classifyYdbError(t, idempotent) - if (!decision.retryable || attempt >= maxAttempts) { - throw t - } + val delayMs = getNextRetryDelayMs(t, attempt, retry) ?: throw t - val sleepMs = backoffMillis(decision.backoffKind, attempt) - if (sleepMs > 0) { + if (delayMs > 0) { try { - Thread.sleep(sleepMs) + Thread.sleep(delayMs) } catch (e: InterruptedException) { Thread.currentThread().interrupt() throw e @@ -62,106 +46,3 @@ fun ydbTransaction( throw lastError ?: IllegalStateException("Retry loop finished without result") } - -/** - * Shortcut for read-only retryable transactions. Reads in YDB are inherently idempotent, - * so timeouts and undetermined outcomes are also retried. - */ -fun ydbReadOnlyTransaction( - db: Database? = null, - maxAttempts: Int = 5, - statement: JdbcTransaction.() -> T -): T = ydbTransaction( - db = db, - idempotent = true, - maxAttempts = maxAttempts, - readOnly = true, - statement = statement -) - -internal enum class YdbBackoffKind { - NONE, - INSTANT, - FAST, - SLOW -} - -internal data class YdbRetryDecision( - val retryable: Boolean, - val backoffKind: YdbBackoffKind = YdbBackoffKind.NONE -) - -internal fun classifyYdbError(error: Throwable, idempotent: Boolean): YdbRetryDecision { - val code = extractStatusCode(error) ?: return YdbRetryDecision(retryable = false) - - return when (code) { - StatusCode.ABORTED, - StatusCode.UNAVAILABLE, - StatusCode.TRANSPORT_UNAVAILABLE, - StatusCode.CLIENT_DISCOVERY_FAILED, - StatusCode.CLIENT_GRPC_ERROR, - StatusCode.CLIENT_INTERNAL_ERROR -> - YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) - - StatusCode.OVERLOADED, - StatusCode.CLIENT_RESOURCE_EXHAUSTED, - StatusCode.CLIENT_LIMITS_REACHED -> - YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.SLOW) - - StatusCode.BAD_SESSION, - StatusCode.SESSION_EXPIRED -> - YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.INSTANT) - - StatusCode.SESSION_BUSY -> - YdbRetryDecision(retryable = true, backoffKind = YdbBackoffKind.FAST) - - StatusCode.TIMEOUT, - StatusCode.CLIENT_DEADLINE_EXCEEDED, - StatusCode.CLIENT_DEADLINE_EXPIRED -> - YdbRetryDecision( - retryable = idempotent, - backoffKind = if (idempotent) YdbBackoffKind.INSTANT else YdbBackoffKind.NONE - ) - - StatusCode.UNDETERMINED -> - YdbRetryDecision( - retryable = idempotent, - backoffKind = if (idempotent) YdbBackoffKind.FAST else YdbBackoffKind.NONE - ) - - else -> YdbRetryDecision(retryable = false) - } -} - -private fun extractStatusCode(error: Throwable): StatusCode? { - var current: Throwable? = error - while (current != null) { - if (current is YdbStatusable) { - return current.status.code - } - current = current.cause - } - return null -} - -internal fun backoffMillis(kind: YdbBackoffKind, attempt: Int): Long { - val n = attempt.coerceAtLeast(1) - return when (kind) { - YdbBackoffKind.NONE -> 0L - YdbBackoffKind.INSTANT -> 0L - YdbBackoffKind.FAST -> { - val base = 25L * (1L shl min(n - 1, 5)) - jitter(base, 15) - } - YdbBackoffKind.SLOW -> { - val base = 200L * (1L shl min(n - 1, 4)) - jitter(base, 50) - } - } -} - -private fun jitter(base: Long, spreadPercent: Int): Long { - if (base <= 0) return 0L - val spread = (base * spreadPercent) / 100 - return base + Random.nextLong(-spread, spread + 1) -} 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/javatime/YdbJavaTimeColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/javatime/YdbJavaTimeColumnTypes.kt new file mode 100644 index 00000000..e5de93dc --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/javatime/YdbJavaTimeColumnTypes.kt @@ -0,0 +1,100 @@ +@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, this) + } +} + +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, this) + } +} + +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, this) + } +} + +// endregion diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt deleted file mode 100644 index 68091bc2..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbCustomColumnTypes.kt +++ /dev/null @@ -1,174 +0,0 @@ -package tech.ydb.exposed.dialect.types - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.ColumnType -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.jdbc.YdbPreparedStatement -import tech.ydb.table.values.PrimitiveType -import java.math.BigDecimal -import java.math.BigInteger -import java.time.Duration -import java.util.UUID - -class YdbDecimalColumnType( - private val precision: Int, - private val scale: Int -) : ColumnType() { - - 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() - - 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) - } -} - -class YdbIntervalColumnType : ColumnType() { - override fun sqlType(): String = "Interval" - - 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'" -} - -abstract class YdbTypedStringColumnType( - private val ydbSqlType: String, - private val primitiveType: PrimitiveType -) : 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?) { - if (value == null) { - super.setParameter(stmt, index, null) - return - } - - val ydbStatement = (stmt as? JdbcPreparedStatementImpl)?.statement as? YdbPreparedStatement - if (ydbStatement != null) { - ydbStatement.setObject(index, value, primitiveType) - } else { - super.setParameter(stmt, index, value) - } - } -} - -class YdbJsonStringColumnType : YdbTypedStringColumnType("Json", PrimitiveType.Json) - -class YdbJsonDocumentStringColumnType : YdbTypedStringColumnType("JsonDocument", PrimitiveType.JsonDocument) - -/** - * Maps a Kotlin [UUID] to the native YDB `Uuid` type. - * - * Binds [java.util.UUID] directly via JDBC — no string conversion. Use this for new schemas; - * Exposed's built-in `uuid()` extension also produces a `Uuid` column under this dialect - * because [YdbDataTypeProvider.uuidType] maps to `Uuid`. - */ -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\")" -} - -/** - * Maps YDB `Uint64` to Kotlin [Long]. - * - * Only the non-negative subset that fits into [Long] (0..[Long.MAX_VALUE]) is supported. - * Use [BigInteger] mapping if you need values above [Long.MAX_VALUE]. - */ -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() - } -} - -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() -} - -fun Table.ydbDecimal(name: String, precision: Int, scale: Int): Column = - registerColumn(name, YdbDecimalColumnType(precision, scale)) - -fun Table.ydbInterval(name: String): Column = - registerColumn(name, YdbIntervalColumnType()) - -fun Table.ydbJson(name: String): Column = - registerColumn(name, YdbJsonStringColumnType()) - -/** Indexed JSON storage — analogous to PostgreSQL `jsonb`. */ -fun Table.ydbJsonDocument(name: String): Column = - registerColumn(name, YdbJsonDocumentStringColumnType()) - -/** Native YDB `Uuid` column. Equivalent to Exposed's `uuid()` under this dialect. */ -fun Table.ydbUuid(name: String): Column = - registerColumn(name, YdbUuidColumnType()) - -fun Table.ydbUint64(name: String): Column = - registerColumn(name, YdbUint64ColumnType()) diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt deleted file mode 100644 index 27139598..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/types/YdbDecimalExpressions.kt +++ /dev/null @@ -1,26 +0,0 @@ -package tech.ydb.exposed.dialect.types - -import org.jetbrains.exposed.v1.core.Expression -import org.jetbrains.exposed.v1.core.QueryBuilder -import java.math.BigDecimal - -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)""") - } -} - -fun ydbDecimalLiteral( - value: BigDecimal, - precision: Int, - scale: Int -): Expression = YdbDecimalLiteral(value, precision, scale) 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 index e15be60b..24e9ced8 100644 --- 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 @@ -8,7 +8,7 @@ import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.extension.RegisterExtension -import tech.ydb.exposed.dialect.YdbDialectProvider +import tech.ydb.exposed.dialect.connectYdb import tech.ydb.exposed.dialect.ydbTransaction import tech.ydb.test.junit5.YdbHelperExtension @@ -32,14 +32,14 @@ abstract class BaseYdbTest { append(if (ydb.useTls()) "grpcs://" else "grpc://") append(ydb.endpoint()) append(ydb.database()) - ydb.authToken()?.let { append("?token=").append(it) } + append("?disablePrepareDataQuery=true") } - db = YdbDialectProvider.connect(url = jdbcUrl) + db = connectYdb(url = jdbcUrl) if (tables.isNotEmpty()) { ydbTransaction(db) { - runCatching { SchemaUtils.drop(*tables.toTypedArray()) } + SchemaUtils.drop(*tables.toTypedArray()) SchemaUtils.create(*tables.toTypedArray()) } } 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..e1ded574 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/RegisterYdbDialectConnectIT.kt @@ -0,0 +1,102 @@ +package tech.ydb.exposed.dialect.integration.basic + +import org.jetbrains.exposed.v1.core.DatabaseConfig +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.YdbTable +import tech.ydb.exposed.dialect.registerYdbDialect +import tech.ydb.exposed.dialect.ydbJdbcUrl +import tech.ydb.exposed.dialect.ydbTransaction +import tech.ydb.test.junit5.YdbHelperExtension +import java.sql.Connection + +/** + * Verifies that after [registerYdbDialect] plain Exposed [Database.connect] and [YdbTable] + * work without [tech.ydb.exposed.dialect.connectYdb]. + */ +class RegisterYdbDialectConnectIT { + + object PlainConnectTable : YdbTable("register_ydb_dialect_plain_connect") { + val id = integer("id") + val label = varchar("label", 64) + + override val primaryKey = PrimaryKey(id) + } + + 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 = ydbJdbcUrl(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 `YdbTable 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")) + } + + companion object { + @JvmField + @RegisterExtension + val ydb: YdbHelperExtension = YdbHelperExtension() + } +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt index 5a5d3992..effc2af8 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt @@ -1,6 +1,5 @@ package tech.ydb.exposed.dialect.integration.basic -import org.jetbrains.exposed.v1.javatime.timestamp import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -8,13 +7,14 @@ import tech.ydb.exposed.dialect.YdbDialect import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.YdbTtlColumnMode import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbUint64 +import tech.ydb.exposed.dialect.ydbUint64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp class YdbDialectTtlSqlIT : BaseYdbTest() { object AlterTtlTimestampTable : YdbTable("unit_alter_ttl_timestamp_table") { val id = integer("id") - val expireAt = timestamp("expire_at") + val expireAt = ydbTimestamp("expire_at") override val primaryKey = PrimaryKey(id) @@ -66,7 +66,7 @@ class YdbDialectTtlSqlIT : BaseYdbTest() { val error = assertThrows(IllegalArgumentException::class.java) { object : YdbTable("invalid_ttl_interval_table") { val id = integer("id") - val expireAt = timestamp("expire_at") + val expireAt = ydbTimestamp("expire_at") override val primaryKey = PrimaryKey(id) 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 index 7e25a1bc..de7393cb 100644 --- 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 @@ -1,13 +1,13 @@ package tech.ydb.exposed.dialect.integration.basic -import org.jetbrains.exposed.v1.javatime.timestamp 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.YdbTable import tech.ydb.exposed.dialect.YdbTtlColumnMode import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbUint64 +import tech.ydb.exposed.dialect.ydbUint64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp class YdbTableIT : BaseYdbTest() { @@ -20,7 +20,7 @@ class YdbTableIT : BaseYdbTest() { object TtlTimestampTable : YdbTable("unit_ttl_timestamp_table") { val id = integer("id") - val expireAt = timestamp("expire_at") + val expireAt = ydbTimestamp("expire_at") override val primaryKey = PrimaryKey(id) 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 index a924de86..fb41e49f 100644 --- 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 @@ -2,6 +2,8 @@ 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.core.decimalLiteral import org.jetbrains.exposed.v1.dao.Entity import org.jetbrains.exposed.v1.dao.EntityClass import org.jetbrains.exposed.v1.jdbc.selectAll @@ -9,14 +11,24 @@ 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.YdbStringIdTable +import tech.ydb.exposed.dialect.buildYdbCreateStatement import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +/** + * Exposed [Entity] / [IdTable] work with YDB when table DDL uses YDB-style `PRIMARY KEY (cols)` + * (via [buildYdbCreateStatement]) instead of the default inline `PRIMARY KEY` on the column. + */ class DaoSmokeIT : BaseYdbTest() { - object Articles : YdbStringIdTable("dao_articles", idLength = 64) { + 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 = + buildYdbCreateStatement(this, ttlSettings = null, secondaryIndices = emptyList()) } class Article(id: EntityID) : Entity(id) { @@ -29,7 +41,7 @@ class DaoSmokeIT : BaseYdbTest() { override val tables: List
= listOf(Articles) @Test - fun `should support dao create read update delete`() { + fun `should support dao create read update delete with manual string id`() { tx { Article.new(id = "article-1") { title = "draft" @@ -60,4 +72,4 @@ class DaoSmokeIT : BaseYdbTest() { assertEquals(0, Articles.selectAll().count()) } } -} \ No newline at end of file +} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt deleted file mode 100644 index bbeb0a5e..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/GeneratedIdsIT.kt +++ /dev/null @@ -1,48 +0,0 @@ -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.Test -import tech.ydb.exposed.dialect.YdbUlidTable -import tech.ydb.exposed.dialect.YdbUuidIdTable -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest - -class GeneratedIdsIT : BaseYdbTest() { - - object UuidItems : YdbUuidIdTable("generated_uuid_items") { - val name = varchar("name", 255) - } - - object UlidItems : YdbUlidTable("generated_ulid_items") { - val name = varchar("name", 255) - } - - override val tables: List
= listOf(UuidItems, UlidItems) - - @Test - fun `YdbUuidIdTable generates a native UUID on insert`() = tx { - UuidItems.insert { - it[name] = "uuid-backed" - } - - val row = UuidItems.selectAll().single() - // The id is auto-generated by clientDefault — we just verify the row landed and - // the column is non-null. The actual id value is a fresh UUID. - val id = row[UuidItems.id].value - assertEquals(36, id.toString().length) - assertEquals("uuid-backed", row[UuidItems.name]) - } - - @Test - fun `YdbUlidTable generates a 26-char ULID on insert`() = tx { - UlidItems.insert { - it[name] = "ulid-backed" - } - - val row = UlidItems.selectAll().single() - assertEquals(26, row[UlidItems.id].value.length) - assertEquals("ulid-backed", row[UlidItems.name]) - } -} 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..e1d72813 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/dao/SerialDaoIT.kt @@ -0,0 +1,48 @@ +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.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest + +/** + * YDB `Serial` works with Exposed `autoIncrement()` on [YdbTable] (YDB-style DDL). + */ +class SerialDaoIT : BaseYdbTest() { + + object Events : YdbTable("serial_dao_events") { + val id = integer("id").autoIncrement() + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + } + + 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/transaction/YdbRetryingTransactionsIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/transaction/YdbRetryingTransactionsIT.kt index c804c510..932e7930 100644 --- 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 @@ -6,10 +6,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.ydbReadOnlyTransaction +import tech.ydb.exposed.dialect.code.YdbVendorCode import tech.ydb.exposed.dialect.ydbTransaction +import java.sql.SQLException import java.util.concurrent.atomic.AtomicInteger class YdbRetryingTransactionsIT : BaseYdbTest() { @@ -32,7 +34,7 @@ class YdbRetryingTransactionsIT : BaseYdbTest() { } } - val name = ydbReadOnlyTransaction(db) { + val name = ydbTransaction(db, readOnly = true, retry = YdbRetryConfig.IDEMPOTENT) { RetryItems.selectAll().single()[RetryItems.name] } assertEquals("alpha", name) @@ -42,7 +44,7 @@ class YdbRetryingTransactionsIT : BaseYdbTest() { fun `ydbTransaction retries the body on a retryable failure`() { val attempts = AtomicInteger() - ydbTransaction(db, idempotent = true, maxAttempts = 3) { + ydbTransaction(db, retry = YdbRetryConfig.IDEMPOTENT.copy(maxAttempts = 3)) { val attempt = attempts.incrementAndGet() if (attempt < 2) { throw FakeAbortedException() @@ -55,7 +57,7 @@ class YdbRetryingTransactionsIT : BaseYdbTest() { assertEquals(2, attempts.get()) - val stored = ydbReadOnlyTransaction(db) { + val stored = ydbTransaction(db, readOnly = true, retry = YdbRetryConfig.IDEMPOTENT) { RetryItems.selectAll().single()[RetryItems.name] } assertEquals("retried", stored) @@ -66,7 +68,7 @@ class YdbRetryingTransactionsIT : BaseYdbTest() { val attempts = AtomicInteger() assertThrows(IllegalStateException::class.java) { - ydbTransaction(db, maxAttempts = 3) { + ydbTransaction(db, retry = YdbRetryConfig.DEFAULT.copy(maxAttempts = 3)) { attempts.incrementAndGet() error("non-retryable") } @@ -74,8 +76,6 @@ class YdbRetryingTransactionsIT : BaseYdbTest() { assertEquals(1, attempts.get()) } - private class FakeAbortedException : RuntimeException("simulated"), tech.ydb.jdbc.exception.YdbStatusable { - override fun getStatus(): tech.ydb.core.Status = - tech.ydb.core.Status.of(tech.ydb.core.StatusCode.ABORTED) - } + 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 index 947496ef..a84f31d1 100644 --- 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 @@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.YdbTtlColumnMode import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbUint64 +import tech.ydb.exposed.dialect.ydbUint64 class NumericTtlTypesIT : BaseYdbTest() { diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt index 969e67c3..46379e6a 100644 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt @@ -1,18 +1,18 @@ package tech.ydb.exposed.dialect.integration.ttl import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.javatime.timestamp import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbDialect import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.javatime.ydbTimestamp class TtlAlterSqlIT : BaseYdbTest() { object AlterTtlItems : YdbTable("alter_ttl_items") { val id = integer("id") - val expireAt = timestamp("expire_at") + val expireAt = ydbTimestamp("expire_at") override val primaryKey = PrimaryKey(id) 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 index c56c4f33..c43ccf05 100644 --- 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 @@ -1,17 +1,17 @@ package tech.ydb.exposed.dialect.integration.ttl import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.javatime.timestamp import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.javatime.ydbTimestamp class TtlTypesIT : BaseYdbTest() { object ExpiringItems : YdbTable("expiring_items") { val id = integer("id") - val expireAt = timestamp("expire_at") + val expireAt = ydbTimestamp("expire_at") override val primaryKey = PrimaryKey(id) 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..fa93fe9e --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/AllTypesRoundTripIT.kt @@ -0,0 +1,219 @@ +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.YdbTable +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 : YdbTable("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) + } + + object StandardTemporal : YdbTable("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) + } + + object YdbExtensionTypes : YdbTable("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 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 Date32")) + assertTrue(stdDdl.contains("datetime_col Datetime64")) + assertTrue(stdDdl.contains("timestamp_col Timestamp64")) + + 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..6a4c60d4 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/BinaryHexToDbIT.kt @@ -0,0 +1,89 @@ +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.YdbTable +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 : YdbTable("binary_hex_to_db") { + val id = integer("id") + val payload = blob("payload") + + override val primaryKey = PrimaryKey(id) + } + + 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 index b211b5b3..afb65351 100644 --- 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 @@ -36,6 +36,6 @@ class BinaryTypesIT : BaseYdbTest() { @Test fun `should generate ddl for binary type`() = tx { val ddl = BinaryTypes.ddl.joinToString(" ") - assertTrue(ddl.contains("payload String")) + 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 index 01b8e504..fe032192 100644 --- 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 @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbDecimal +import tech.ydb.exposed.dialect.ydbDecimal import java.math.BigDecimal class DecimalTypesIT : BaseYdbTest() { 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 index 2f125286..7dbf4992 100644 --- 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 @@ -9,8 +9,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbDecimal -import tech.ydb.exposed.dialect.types.ydbDecimalLiteral +import tech.ydb.exposed.dialect.ydbDecimal +import tech.ydb.exposed.dialect.ydbDecimalLiteral import java.math.BigDecimal class DecimalUpdateIT : BaseYdbTest() { 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..e7c65251 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/ForceLegacyStandardTemporalIT.kt @@ -0,0 +1,102 @@ +package tech.ydb.exposed.dialect.integration.types + +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.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.connectYdb +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.ydbTransaction +import tech.ydb.test.junit5.YdbHelperExtension +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDatetime +import tech.ydb.exposed.dialect.javatime.ydbTimestamp + +/** + * [ydbDate] / [ydbDatetime] / [ydbTimestamp] — unsigned legacy types with JDBC vendor codes. + */ +class ForceLegacyStandardTemporalIT { + + object LegacyStdTemporal : YdbTable("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) + } + + private lateinit var db: Database + + @BeforeEach + fun setUp() { + val 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 = connectYdb(url = jdbcUrl) + } + + @AfterEach + fun tearDown() { + if (!::db.isInitialized) return + + runCatching { + ydbTransaction(db) { + SchemaUtils.drop(LegacyStdTemporal) + } + } + runCatching { TransactionManager.closeAndUnregister(db) } + } + + @Test + fun `should round-trip standard temporal columns as legacy types`() = ydbTransaction(db) { + 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`() = ydbTransaction(db) { + 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) + } + + companion object { + @JvmField + @RegisterExtension + val ydb: YdbHelperExtension = YdbHelperExtension() + } +} 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 index 34bcde8d..139f4428 100644 --- 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 @@ -8,36 +8,62 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbInterval +import tech.ydb.exposed.dialect.ydbInterval +import tech.ydb.exposed.dialect.ydbInterval64 import java.time.Duration class IntervalTypesIT : BaseYdbTest() { - object IntervalTypes : YdbTable("interval_types") { + object Interval64Types : YdbTable("interval64_types") { + val id = integer("id") + val durationCol = ydbInterval64("duration_col") + + override val primaryKey = PrimaryKey(id) + } + + object LegacyIntervalTypes : YdbTable("legacy_interval_types") { val id = integer("id") val durationCol = ydbInterval("duration_col") override val primaryKey = PrimaryKey(id) } - override val tables: List
= listOf(IntervalTypes) + override val tables: List
= listOf(Interval64Types, LegacyIntervalTypes) @Test - fun `should round-trip interval type`() = tx { + fun `should round-trip Interval64`() = tx { val duration = Duration.ofHours(26).plusMinutes(3).plusSeconds(4) - IntervalTypes.insert { + 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 } - val row = IntervalTypes.selectAll().single() - assertEquals(duration, row[IntervalTypes.durationCol]) + assertEquals(duration, LegacyIntervalTypes.selectAll().single()[LegacyIntervalTypes.durationCol]) } @Test - fun `should generate ddl for interval type`() = tx { - val ddl = IntervalTypes.ddl.joinToString(" ") - assertTrue(ddl.contains("duration_col Interval")) + fun `should generate ddl for legacy Interval`() = tx { + val ddl = LegacyIntervalTypes.ddl.joinToString(" ") + assertTrue(ddl.contains("duration_col Interval") && !ddl.contains("Interval64")) } -} \ No newline at end of file +} 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 index 749c688e..49c069b6 100644 --- 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 @@ -8,8 +8,8 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbJson -import tech.ydb.exposed.dialect.types.ydbJsonDocument +import tech.ydb.exposed.dialect.ydbJson +import tech.ydb.exposed.dialect.ydbJsonDocument class JsonTypesIT : BaseYdbTest() { 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 index ad5064ac..2a4c4912 100644 --- 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 @@ -1,9 +1,6 @@ package tech.ydb.exposed.dialect.integration.types import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.javatime.date -import org.jetbrains.exposed.v1.javatime.datetime -import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.SchemaUtils import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager @@ -12,25 +9,21 @@ 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.YdbDialectProvider +import tech.ydb.exposed.dialect.connectYdb import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.ydbTransaction import tech.ydb.test.junit5.YdbHelperExtension +import tech.ydb.exposed.dialect.javatime.ydbDate +import tech.ydb.exposed.dialect.javatime.ydbDatetime +import tech.ydb.exposed.dialect.javatime.ydbTimestamp -/** - * Verifies that opening the dialect with `forceLegacyDatetimes = true` emits - * the legacy YDB temporal types (`Date`, `Datetime`, `Timestamp`). - * - * Doesn't extend [BaseYdbTest] because it needs to control the [YdbDialectProvider.connect] - * call to flip the flag. - */ class LegacyTemporalTypesIT { object LegacyTemporal : YdbTable("legacy_temporal_types") { val id = integer("id") - val dateCol = date("date_col") - val dateTimeCol = datetime("datetime_col") - val timestampCol = timestamp("timestamp_col") + val dateCol = ydbDate("date_col") + val dateTimeCol = ydbDatetime("datetime_col") + val timestampCol = ydbTimestamp("timestamp_col") override val primaryKey = PrimaryKey(id) } @@ -44,9 +37,10 @@ class LegacyTemporalTypesIT { append(if (ydb.useTls()) "grpcs://" else "grpc://") append(ydb.endpoint()) append(ydb.database()) - ydb.authToken()?.let { append("?token=").append(it) } + append("?disablePrepareDataQuery=true") + ydb.authToken()?.let { append("&token=").append(it) } } - db = YdbDialectProvider.connect(url = jdbcUrl, forceLegacyDatetimes = true) + db = connectYdb(url = jdbcUrl) } @AfterEach @@ -62,7 +56,9 @@ class LegacyTemporalTypesIT { } @Test - fun `forceLegacyDatetimes emits Date Datetime Timestamp`() = ydbTransaction(db) { + fun `unsigned ydb temporal extensions emit Date Datetime Timestamp`() = ydbTransaction(db) { + 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) 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..08c165a1 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/LegacyYdbTypesRoundTripIT.kt @@ -0,0 +1,56 @@ +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.YdbTable +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 : YdbTable("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 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/TemporalTypesIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/TemporalTypesIT.kt index ffb4666e..99d016c5 100644 --- 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 @@ -1,9 +1,6 @@ package tech.ydb.exposed.dialect.integration.types import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.javatime.date -import org.jetbrains.exposed.v1.javatime.datetime -import org.jetbrains.exposed.v1.javatime.timestamp import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.junit.jupiter.api.Assertions.assertEquals @@ -14,14 +11,17 @@ 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 : YdbTable("temporal_types") { val id = integer("id") - val dateCol = date("date_col") - val dateTimeCol = datetime("datetime_col") - val timestampCol = timestamp("timestamp_col") + val dateCol = ydbDate32("date_col") + val dateTimeCol = ydbDatetime64("datetime_col") + val timestampCol = ydbTimestamp64("timestamp_col") override val primaryKey = PrimaryKey(id) } @@ -29,7 +29,7 @@ class TemporalTypesIT : BaseYdbTest() { override val tables: List
= listOf(TemporalTypes) @Test - fun `should round-trip temporal types`() = tx { + 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") 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 index 523ecb45..cc06bbd1 100644 --- 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 @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbUint64 +import tech.ydb.exposed.dialect.ydbUint64 class Uint64TypesIT : BaseYdbTest() { 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 index bf833fb4..63d9c245 100644 --- 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 @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.types.ydbUuid +import tech.ydb.exposed.dialect.ydbUuid import java.util.UUID class UuidTypesIT : BaseYdbTest() { diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectProviderTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectProviderTest.kt deleted file mode 100644 index 6d827099..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbDialectProviderTest.kt +++ /dev/null @@ -1,60 +0,0 @@ -package tech.ydb.exposed.dialect.unit - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.YdbDialectProvider - -class YdbDialectProviderTest { - - @Test - fun `default temporal mode enables driver flag for new datetime types`() { - val actual = YdbDialectProvider.withTemporalDriverMode( - url = "jdbc:ydb:grpc://localhost:2136/local", - forceLegacyDatetimes = false - ) - - assertEquals( - "jdbc:ydb:grpc://localhost:2136/local?forceSignedDatetimes=true", - actual - ) - } - - @Test - fun `legacy temporal mode disables driver flag`() { - val actual = YdbDialectProvider.withTemporalDriverMode( - url = "jdbc:ydb:grpc://localhost:2136/local", - forceLegacyDatetimes = true - ) - - assertEquals( - "jdbc:ydb:grpc://localhost:2136/local?forceSignedDatetimes=false", - actual - ) - } - - @Test - fun `temporal mode preserves existing query parameters`() { - val actual = YdbDialectProvider.withTemporalDriverMode( - url = "jdbc:ydb:grpc://localhost:2136/local?token=abc", - forceLegacyDatetimes = false - ) - - assertEquals( - "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=true", - actual - ) - } - - @Test - fun `temporal mode replaces existing driver flag`() { - val actual = YdbDialectProvider.withTemporalDriverMode( - url = "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=false", - forceLegacyDatetimes = false - ) - - assertEquals( - "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=true", - actual - ) - } -} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt deleted file mode 100644 index 980110d3..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbGeneratedIdsTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package tech.ydb.exposed.dialect.unit.basic - -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.ydbUlid - -class YdbGeneratedIdsTest { - - @Test - fun `ydbUlid generates a 26-char Crockford-base32 string`() { - val value = ydbUlid(nowMillis = 1_700_000_000_000) - assertEquals(26, value.length) - assertTrue(value.all { it in "0123456789ABCDEFGHJKMNPQRSTVWXYZ" }) - } - - @Test - fun `ydbUlid encodes timestamp in the lexicographic prefix`() { - val older = ydbUlid(nowMillis = 1_700_000_000_000) - val newer = ydbUlid(nowMillis = 1_700_000_000_001) - assertTrue(older.take(10) < newer.take(10)) - } -} diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbJdbcUrlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbJdbcUrlTest.kt new file mode 100644 index 00000000..87de0dca --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbJdbcUrlTest.kt @@ -0,0 +1,32 @@ +package tech.ydb.exposed.dialect.unit.basic + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import tech.ydb.exposed.dialect.ydbJdbcUrl + +class YdbJdbcUrlTest { + + @Test + fun `appends forceSignedDatetimes false for JDBC backward compatibility`() { + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?forceSignedDatetimes=false", + ydbJdbcUrl("jdbc:ydb:grpc://localhost:2136/local") + ) + } + + @Test + fun `preserves existing query parameters`() { + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=false", + ydbJdbcUrl("jdbc:ydb:grpc://localhost:2136/local?token=abc") + ) + } + + @Test + fun `replaces existing driver flag`() { + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=false", + ydbJdbcUrl("jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=true") + ) + } +} 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/transaction/YdbRetryClassifierTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt deleted file mode 100644 index a9ebde42..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/transaction/YdbRetryClassifierTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -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.assertTrue -import org.junit.jupiter.api.Test -import tech.ydb.core.Status -import tech.ydb.core.StatusCode -import tech.ydb.exposed.dialect.YdbBackoffKind -import tech.ydb.exposed.dialect.backoffMillis -import tech.ydb.exposed.dialect.classifyYdbError -import tech.ydb.jdbc.exception.YdbStatusable - -class YdbRetryClassifierTest { - - private class FakeStatusException(code: StatusCode) : - RuntimeException("fake"), YdbStatusable { - private val s = Status.of(code) - override fun getStatus(): Status = s - } - - @Test - fun `ABORTED is retryable with FAST backoff`() { - val d = classifyYdbError(FakeStatusException(StatusCode.ABORTED), idempotent = false) - assertTrue(d.retryable) - assertEquals(YdbBackoffKind.FAST, d.backoffKind) - } - - @Test - fun `OVERLOADED is retryable with SLOW backoff`() { - val d = classifyYdbError(FakeStatusException(StatusCode.OVERLOADED), idempotent = false) - assertTrue(d.retryable) - assertEquals(YdbBackoffKind.SLOW, d.backoffKind) - } - - @Test - fun `BAD_SESSION is retryable with INSTANT backoff`() { - val d = classifyYdbError(FakeStatusException(StatusCode.BAD_SESSION), idempotent = false) - assertTrue(d.retryable) - assertEquals(YdbBackoffKind.INSTANT, d.backoffKind) - } - - @Test - fun `PRECONDITION_FAILED is not retryable`() { - val d = classifyYdbError(FakeStatusException(StatusCode.PRECONDITION_FAILED), idempotent = true) - assertFalse(d.retryable) - } - - @Test - fun `TIMEOUT retries only when idempotent`() { - assertTrue(classifyYdbError(FakeStatusException(StatusCode.TIMEOUT), idempotent = true).retryable) - assertFalse(classifyYdbError(FakeStatusException(StatusCode.TIMEOUT), idempotent = false).retryable) - } - - @Test - fun `UNDETERMINED retries only when idempotent`() { - assertTrue(classifyYdbError(FakeStatusException(StatusCode.UNDETERMINED), idempotent = true).retryable) - assertFalse(classifyYdbError(FakeStatusException(StatusCode.UNDETERMINED), idempotent = false).retryable) - } - - @Test - fun `text-only error without YdbStatusable is treated as non-retryable`() { - val d = classifyYdbError(RuntimeException("Status{code = ABORTED}"), idempotent = true) - assertFalse(d.retryable) - } - - @Test - fun `walks cause chain to find a YdbStatusable`() { - val cause = FakeStatusException(StatusCode.ABORTED) - val wrapped = RuntimeException("outer", RuntimeException("middle", cause)) - - val d = classifyYdbError(wrapped, idempotent = false) - assertTrue(d.retryable) - assertEquals(YdbBackoffKind.FAST, d.backoffKind) - } - - @Test - fun `backoffMillis returns non-negative values`() { - assertTrue(backoffMillis(YdbBackoffKind.FAST, 1) >= 0) - assertTrue(backoffMillis(YdbBackoffKind.SLOW, 1) >= 0) - assertEquals(0L, backoffMillis(YdbBackoffKind.NONE, 1)) - assertEquals(0L, backoffMillis(YdbBackoffKind.INSTANT, 1)) - } -} 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..ce9373e8 --- /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 like dotnet SDK`() { + 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 match dotnet transient gate`() { + assertTrue(isTransientVendorCode(YdbVendorCode.ABORTED)) + assertFalse(isTransientVendorCode(YdbVendorCode.UNDETERMINED)) + } +} 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 index 9ba41a54..48cbf621 100644 --- 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 @@ -1,37 +1,35 @@ package tech.ydb.exposed.dialect.unit.types import org.jetbrains.exposed.v1.jdbc.statements.jdbc.JdbcPreparedStatementImpl -import tech.ydb.jdbc.YdbPreparedStatement -import tech.ydb.table.values.Type import java.lang.reflect.Proxy import java.sql.PreparedStatement -data class BoundTypedObject( +data class BoundSqlObject( val index: Int, val value: Any?, - val type: Type + val targetSqlType: Int ) -fun ydbPreparedStatementCapture(): Pair BoundTypedObject?> { - var boundValue: BoundTypedObject? = null +fun ydbPreparedStatementCapture(): Pair BoundSqlObject?> { + var boundValue: BoundSqlObject? = null val proxy = Proxy.newProxyInstance( - YdbPreparedStatement::class.java.classLoader, - arrayOf(PreparedStatement::class.java, YdbPreparedStatement::class.java) + 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 Type) { - boundValue = BoundTypedObject( + if (args?.size == 3 && args[0] is Int && args[2] is Int) { + boundValue = BoundSqlObject( index = args[0] as Int, value = args[1], - type = args[2] as Type + targetSqlType = args[2] as Int ) } null } - "toString" -> "YdbPreparedStatementProxy" + "toString" -> "PreparedStatementProxy" "hashCode" -> 0 "equals" -> false "isClosed" -> false 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 index 878c242b..965982e7 100644 --- 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 @@ -7,29 +7,32 @@ import tech.ydb.exposed.dialect.YdbDataTypeProvider class YdbDataTypeProviderTest { - private val provider = YdbDataTypeProvider(forceLegacyDatetimes = false) - private val legacyProvider = YdbDataTypeProvider(forceLegacyDatetimes = true) + private val provider = YdbDataTypeProvider() @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("String", provider.binaryType()) - assertEquals("String", provider.binaryType(100)) + 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("JsonDocument", provider.jsonType()) + assertEquals("Json", provider.jsonType()) } @Test @@ -39,28 +42,26 @@ class YdbDataTypeProviderTest { } @Test - fun `defaults temporal types to extended (Date32 Datetime64 Timestamp64)`() { - assertEquals("Date32", provider.dateType()) - assertEquals("Datetime64", provider.dateTimeType()) - assertEquals("Timestamp64", provider.timestampType()) + fun `maps standard temporal types to legacy Date Datetime Timestamp`() { + assertEquals("Date", provider.dateType()) + assertEquals("Datetime", provider.dateTimeType()) + assertEquals("Timestamp", provider.timestampType()) } @Test - fun `forceLegacyDatetimes switches to Date Datetime Timestamp`() { - assertEquals("Date", legacyProvider.dateType()) - assertEquals("Datetime", legacyProvider.dateTimeType()) - assertEquals("Timestamp", legacyProvider.timestampType()) + fun `maps autoincrement to Serial and BigSerial`() { + assertEquals("Serial", provider.integerAutoincType()) + assertEquals("BigSerial", provider.longAutoincType()) } @Test - fun `rejects autoincrement type`() { + fun `rejects unsigned autoincrement`() { assertThrows(UnsupportedOperationException::class.java) { - provider.integerAutoincType() + provider.uintegerAutoincType() + } + assertThrows(UnsupportedOperationException::class.java) { + provider.ulongAutoincType() } } - @Test - fun `hexToDb wraps in single quotes`() { - assertEquals("'0xABCD'", provider.hexToDb("0xABCD")) - } } 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 index edf4eae3..2b4aa35c 100644 --- 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 @@ -3,7 +3,8 @@ 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.types.YdbDecimalColumnType +import tech.ydb.exposed.dialect.YdbDecimalColumnType +import tech.ydb.exposed.dialect.code.YdbJdbcCode import java.math.BigDecimal class YdbDecimalColumnTypeTest { @@ -52,6 +53,19 @@ class YdbDecimalColumnTypeTest { 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) 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 index afb2e208..76ff4876 100644 --- 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 @@ -4,7 +4,7 @@ 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.types.YdbDecimalLiteral +import tech.ydb.exposed.dialect.YdbDecimalLiteral import java.math.BigDecimal class YdbDecimalLiteralTest { 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..2c6e1c60 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbHexToDbTest.kt @@ -0,0 +1,34 @@ +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] + * ([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). + */ +class YdbHexToDbTest { + + private val provider = YdbDataTypeProvider() + + @Test + fun `uses String HexDecode without cast`() { + assertEquals("String::HexDecode('deadbeef')", provider.hexToDb("deadbeef")) + assertEquals("String::HexDecode('')", 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("String::HexDecode('0102abcd')", 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 index 61968880..a446879e 100644 --- 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 @@ -1,32 +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.types.YdbIntervalColumnType +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 val type = YdbIntervalColumnType() + private object IntervalColumns : Table("interval_columns") { + val legacy = ydbInterval("legacy") + val extended = ydbInterval64("extended") + } @Test - fun `should return interval sql type`() { - assertEquals("Interval", type.sqlType()) + fun `legacy ydbInterval uses Interval sql type and vendor code`() { + assertEquals("Interval", IntervalColumns.legacy.columnType.sqlType()) + assertBinding(IntervalColumns.legacy, YdbJdbcCode.INTERVAL) } @Test - fun `should parse interval from db`() { - val duration = Duration.ofHours(1).plusMinutes(2).plusSeconds(3) - - assertEquals(duration, type.valueFromDB(duration)) - assertEquals(duration, type.valueFromDB("PT1H2M3S")) + fun `ydbInterval64 uses Interval64 sql type and vendor code`() { + assertEquals("Interval64", IntervalColumns.extended.columnType.sqlType()) + assertBinding(IntervalColumns.extended, YdbJdbcCode.INTERVAL64) } - @Test - fun `should convert interval to db`() { + 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) - assertEquals(duration, type.notNullValueToDB(duration)) - assertEquals("'PT1H30M'", type.nonNullValueToString(duration)) + val actual = capture() + assertNotNull(actual) + assertEquals(BoundSqlObject(1, duration, expectedVendorCode), actual) } -} \ No newline at end of file +} 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 index c3e5f441..9fc0cc84 100644 --- 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 @@ -3,8 +3,8 @@ 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.types.YdbJsonDocumentStringColumnType -import tech.ydb.table.values.PrimitiveType +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import tech.ydb.exposed.dialect.YdbJsonDocumentStringColumnType class YdbJsonDocumentStringColumnTypeTest { @@ -35,7 +35,7 @@ class YdbJsonDocumentStringColumnTypeTest { } @Test - fun `should bind json document with explicit YDB type`() { + fun `should bind json document with explicit JDBC vendor type`() { val json = """{"name":"alice","active":true}""" val (stmt, capture) = ydbPreparedStatementCapture() @@ -43,6 +43,6 @@ class YdbJsonDocumentStringColumnTypeTest { val actual = capture() assertNotNull(actual) - assertEquals(BoundTypedObject(1, json, PrimitiveType.JsonDocument), 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 index 40f2d5f0..26816abe 100644 --- 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 @@ -3,8 +3,8 @@ 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.table.values.PrimitiveType -import tech.ydb.exposed.dialect.types.YdbJsonStringColumnType +import tech.ydb.exposed.dialect.code.YdbJdbcCode +import tech.ydb.exposed.dialect.YdbJsonStringColumnType class YdbJsonStringColumnTypeTest { @@ -35,7 +35,7 @@ class YdbJsonStringColumnTypeTest { } @Test - fun `should bind json with explicit YDB type`() { + fun `should bind json with explicit JDBC vendor type`() { val json = """{"name":"alice","active":true}""" val (stmt, capture) = ydbPreparedStatementCapture() @@ -43,6 +43,6 @@ class YdbJsonStringColumnTypeTest { val actual = capture() assertNotNull(actual) - assertEquals(BoundTypedObject(1, json, PrimitiveType.Json), 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..41f13ba6 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbTemporalColumnTypeTest.kt @@ -0,0 +1,95 @@ +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.YdbTable +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 YdbTableColumns : YdbTable("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 `YdbTable ydbDate and ydbDate32 use unsigned and signed types`() { + assertEquals("Date", YdbTableColumns.legacyDate.columnType.sqlType()) + assertEquals("Date32", YdbTableColumns.signedDate.columnType.sqlType()) + assertEquals("Datetime64", YdbTableColumns.signedDatetime.columnType.sqlType()) + assertEquals("Timestamp64", YdbTableColumns.signedTimestamp.columnType.sqlType()) + } + + @Test + fun `ydbDate32 binds Date32 vendor code`() { + assertBinding( + column = YdbTableColumns.signedDate, + sqlType = "Date32", + vendorCode = YdbJdbcCode.DATE32, + value = LocalDate.of(2026, 4, 13) + ) + } + + @Test + fun `ydbDatetime64 binds Datetime64 vendor code`() { + assertBinding( + column = YdbTableColumns.signedDatetime, + sqlType = "Datetime64", + vendorCode = YdbJdbcCode.DATETIME64, + value = LocalDateTime.of(2026, 4, 13, 14, 30, 15) + ) + } + + @Test + fun `ydbTimestamp64 binds Timestamp64 vendor code`() { + assertBinding( + column = YdbTableColumns.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 index a94df142..f6affa20 100644 --- 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 @@ -3,7 +3,7 @@ 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.types.YdbUint64ColumnType +import tech.ydb.exposed.dialect.YdbUint64ColumnType class YdbUintColumnTest { 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..ce1c5645 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbUnsignedColumnTypeTest.kt @@ -0,0 +1,58 @@ +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.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()) + } +} 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 index 63e51b2f..3a6ab0fa 100644 --- 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 @@ -2,7 +2,7 @@ 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.types.YdbUuidColumnType +import tech.ydb.exposed.dialect.YdbUuidColumnType import java.util.UUID class YdbUuidColumnTypeTest { From bd6a94939d7493752b764aa0133a8bf61628ebec Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Mon, 18 May 2026 17:06:07 +0300 Subject: [PATCH 30/43] big refactoring --- kotlin-exposed-dialect/pom.xml | 6 + .../tech/ydb/exposed/dialect/YdbDialect.kt | 155 +++++++++---- .../ydb/exposed/dialect/YdbSqlFunctions.kt | 42 ++++ .../ydb/exposed/dialect/YdbTransaction.kt | 2 +- .../integration/basic/YdbDialectTtlSqlIT.kt | 81 ------- .../integration/basic/YdbIndexSqlIT.kt | 70 +----- .../dialect/integration/basic/YdbTableIT.kt | 18 ++ .../dialect/integration/ddl/IndexIT.kt | 20 -- .../functions/YdbSqlFunctionsIT.kt | 193 ++++++++++++++++ .../dialect/integration/ttl/TtlAlterSqlIT.kt | 36 --- .../dialect/unit/functions/FunctionTest.kt | 28 --- .../unit/functions/YdbFunctionProviderTest.kt | 214 ++++++++++++++++++ 12 files changed, 600 insertions(+), 265 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/YdbSqlFunctionsIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/YdbFunctionProviderTest.kt diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index ef90ada9..dc4b5592 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -115,6 +115,12 @@ test + + org.jetbrains.exposed + exposed-json + test + + tech.ydb.test ydb-junit5-support 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 index a8011f26..142240b1 100644 --- 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 @@ -1,10 +1,13 @@ 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 @@ -75,7 +78,119 @@ internal object YdbFunctionProvider : FunctionProvider() { private const val MERGE_UNSUPPORTED = "YDB dialect does not support ANSI MERGE through Exposed. Use UPSERT or batchUpsert instead." - override fun random(seed: Int?): String = "Random()" + 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("), '", escapeYqlStringLiteral(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 = escapeYqlStringLiteral(substring) + append("IF(Unicode::Find(", expr, ", '", needle, "') IS NULL, 0, ") + append("CAST(Unicode::Find(", expr, ", '", needle, "') + 1u AS Int32))") + } + + 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 = buildYdbJsonPath(*path) + append(if (toScalar) "JSON_VALUE" else "JSON_QUERY") + append("(", expression, ", '", escapeYqlStringLiteral(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 = buildYdbJsonPath(*path) + append("JSON_EXISTS(", expression, ", '", escapeYqlStringLiteral(jsonPath), "'") + optional?.let { append(" ", it) } + append(")") + } override fun upsert( table: Table, @@ -223,16 +338,6 @@ class YdbDialect internal constructor() : VendorDialect( } } - fun createSecondaryIndex(table: Table, spec: YdbSecondaryIndexSpec): String { - val tr = TransactionManager.current() - return buildString { - append("ALTER TABLE ") - append(tr.identity(table)) - append(" ADD ") - append(renderYdbSecondaryIndex(spec)) - } - } - override fun dropIndex( tableName: String, indexName: String, @@ -240,34 +345,6 @@ class YdbDialect internal constructor() : VendorDialect( isPartialOrFunctional: Boolean ): String = "ALTER TABLE $tableName DROP INDEX $indexName" - fun setTtl(table: YdbTable): String { - val tr = TransactionManager.current() - val ttl = table.ttlSettings - ?: error("TTL is not configured for table ${table.tableName}") - - validateYdbTtlColumn(ttl) - val normalizedInterval = normalizeTtlInterval(ttl.intervalIso8601) - - return buildString { - append("ALTER TABLE ") - append(tr.identity(table)) - append(" SET (TTL = Interval(\"") - append(normalizedInterval) - append("\") ON ") - append(tr.identity(ttl.column)) - ttl.mode.toSql()?.let { - append(" AS ") - append(it) - } - append(")") - } - } - - fun resetTtl(table: YdbTable): String { - val tr = TransactionManager.current() - return "ALTER TABLE ${tr.identity(table)} RESET (TTL)" - } - internal object Metadata : DatabaseDialectMetadata() { override fun existingIndices(vararg tables: Table): Map> { diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt new file mode 100644 index 00000000..25387c8e --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt @@ -0,0 +1,42 @@ +package tech.ydb.exposed.dialect + +/** + * Builds a [JsonPath](https://ydb.tech/docs/en/yql/reference/builtins/json) query for YDB + * `JSON_VALUE` / `JSON_QUERY` / `JSON_EXISTS` from Exposed path segments. + * + * Segments are usually object keys; all-digit segments become array indexes (`[0]`). + * A single segment that already starts with `$` is returned as-is. + */ +internal fun buildYdbJsonPath(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("\"", "\\\"")}\"" + } + +internal fun escapeYqlStringLiteral(value: String): String = + value.replace("'", "''") 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 index 7776ebcc..2da65619 100644 --- 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 @@ -7,7 +7,7 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction /** * Runs [statement] inside an Exposed [transaction] and retries it on retryable YDB errors. * - * Retry behaviour is controlled by [retry] ([YdbRetryConfig]), aligned with + * Retry behavior is controlled by [retry] ([YdbRetryConfig]), aligned with * [YdbRetryPolicy](https://github.com/ydb-platform/ydb-dotnet-sdk/blob/main/src/Ydb.Sdk/src/Ado/RetryPolicy/YdbRetryPolicy.cs). */ fun ydbTransaction( diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt deleted file mode 100644 index effc2af8..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbDialectTtlSqlIT.kt +++ /dev/null @@ -1,81 +0,0 @@ -package tech.ydb.exposed.dialect.integration.basic - -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.YdbTable -import tech.ydb.exposed.dialect.YdbTtlColumnMode -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.ydbUint64 -import tech.ydb.exposed.dialect.javatime.ydbTimestamp - -class YdbDialectTtlSqlIT : BaseYdbTest() { - - object AlterTtlTimestampTable : YdbTable("unit_alter_ttl_timestamp_table") { - val id = integer("id") - val expireAt = ydbTimestamp("expire_at") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(expireAt, "PT24H") - } - } - - object AlterTtlNumericTable : YdbTable("unit_alter_ttl_numeric_table") { - val id = integer("id") - val modifiedAtEpoch = ydbUint64("modified_at_epoch") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(modifiedAtEpoch, "PT2H", YdbTtlColumnMode.SECONDS) - } - } - - @Test - fun `generates ALTER TABLE SET TTL for a timestamp column`() = tx { - val dialect = db.dialect as YdbDialect - val sql = dialect.setTtl(AlterTtlTimestampTable) - - assertTrue(sql.contains("ALTER TABLE"), sql) - assertTrue(sql.contains("""SET (TTL = Interval("PT24H") ON expire_at)"""), sql) - } - - @Test - fun `generates ALTER TABLE SET TTL for a numeric epoch column`() = tx { - val dialect = db.dialect as YdbDialect - val sql = dialect.setTtl(AlterTtlNumericTable) - - assertTrue(sql.contains("ALTER TABLE"), sql) - assertTrue(sql.contains("""SET (TTL = Interval("PT2H") ON modified_at_epoch AS SECONDS)"""), sql) - } - - @Test - fun `generates ALTER TABLE RESET TTL`() = tx { - val dialect = db.dialect as YdbDialect - val sql = dialect.resetTtl(AlterTtlTimestampTable) - - assertTrue(sql.contains("ALTER TABLE"), sql) - assertTrue(sql.contains("RESET (TTL)"), sql) - } - - @Test - fun `rejects invalid TTL interval early`() { - val error = assertThrows(IllegalArgumentException::class.java) { - object : YdbTable("invalid_ttl_interval_table") { - val id = integer("id") - val expireAt = ydbTimestamp("expire_at") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(expireAt, """PT1H" ON hacked""") - } - } - } - - assertTrue(error.message?.contains("Invalid YDB TTL interval") == true, error.message) - } -} 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 index 24c4193d..35d87040 100644 --- 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 @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbDialect import tech.ydb.exposed.dialect.YdbIndexScope import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest @@ -34,6 +33,14 @@ class YdbIndexSqlIT : BaseYdbTest() { coverColumns = listOf(name), withParams = mapOf("foo" to "bar") ) + + secondaryIndex( + name = "email-cover-idx", + email, + unique = true, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.SYNC + ) } val emailIndexDefinition @@ -80,70 +87,13 @@ class YdbIndexSqlIT : BaseYdbTest() { @Test fun `renders YDB-specific inline secondary index`() = tx { val ddl = IndexedTable.ddl.joinToString(" ") + val expectedHyphenatedName = db.identifierManager.cutIfNecessaryAndQuote("email-cover-idx") assertTrue(ddl.contains("INDEX email_cover_idx"), ddl) assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) assertTrue(ddl.contains("WITH (foo = \"bar\")"), ddl) - } - - @Test - fun `renders YDB-specific ALTER TABLE secondary index SQL`() = tx { - val dialect = db.dialect as YdbDialect - - val sql = dialect.createSecondaryIndex( - table = IndexedTable, - spec = YdbSecondaryIndexSpec( - name = "email_lookup_idx", - columns = listOf(IndexedTable.email), - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) - ) - - assertTrue(sql.contains("ALTER TABLE"), sql) - assertTrue(sql.contains("ADD INDEX email_lookup_idx GLOBAL"), sql) - assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) - } - - @Test - fun `renders UNIQUE YDB-specific ALTER TABLE secondary index SQL`() = tx { - val dialect = db.dialect as YdbDialect - - val sql = dialect.createSecondaryIndex( - table = IndexedTable, - spec = YdbSecondaryIndexSpec( - name = "email_unique_lookup_idx", - columns = listOf(IndexedTable.email), - unique = true, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) - ) - - assertTrue(sql.contains("ALTER TABLE"), sql) - assertTrue(sql.contains("ADD INDEX email_unique_lookup_idx GLOBAL UNIQUE"), sql) - assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) - } - - @Test - fun `quotes secondary index name when needed`() = tx { - val dialect = db.dialect as YdbDialect - val expectedName = db.identifierManager.cutIfNecessaryAndQuote("email-cover-idx") - - val sql = dialect.createSecondaryIndex( - table = IndexedTable, - spec = YdbSecondaryIndexSpec( - name = "email-cover-idx", - columns = listOf(IndexedTable.email), - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) - ) - - assertTrue(sql.contains("ADD INDEX $expectedName GLOBAL"), sql) + assertTrue(ddl.contains("INDEX $expectedHyphenatedName GLOBAL UNIQUE"), ddl) } } 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 index de7393cb..991989fb 100644 --- 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 @@ -97,4 +97,22 @@ class YdbTableIT : BaseYdbTest() { InvalidNumericTtlTable.ddl } } + + @Test + fun `rejects invalid TTL interval early`() { + val error = assertThrows(IllegalArgumentException::class.java) { + object : YdbTable("invalid_ttl_interval_table") { + val id = integer("id") + val expireAt = ydbTimestamp("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, """PT1H" ON hacked""") + } + } + } + + assertTrue(error.message?.contains("Invalid YDB TTL interval") == true, error.message) + } } 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 index b1bd35c1..d69ce3f9 100644 --- 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 @@ -7,7 +7,6 @@ import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbDialect import tech.ydb.exposed.dialect.YdbIndexScope import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbSecondaryIndexSpec import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest @@ -61,25 +60,6 @@ class IndexIT : BaseYdbTest() { assertTrue(ddl.contains("PRIMARY KEY"), ddl) } - @Test - fun `should generate alter table sql for ydb specific secondary index`() = tx { - val dialect = db.dialect as YdbDialect - - val spec = YdbSecondaryIndexSpec( - name = "email_lookup_idx", - columns = listOf(Customers.email), - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) - - val sql = dialect.createSecondaryIndex(Customers, spec) - - assertTrue(sql.contains("ALTER TABLE"), sql) - assertTrue(sql.contains("ADD INDEX email_lookup_idx GLOBAL"), sql) - assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), sql) - } - @Test fun `should read existing indexes from jdbc metadata`() = tx { val indexes = db.dialectMetadata.existingIndices(Customers).getValue(Customers) 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..4014f41c --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/functions/YdbSqlFunctionsIT.kt @@ -0,0 +1,193 @@ +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.YdbTable +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 : YdbTable("fn_strings") { + val id = integer("id") + val value = text("value") + + override val primaryKey = PrimaryKey(id) + } + + object JsonRows : YdbTable("fn_json") { + val id = integer("id") + val payload = ydbJson("payload") + + override val primaryKey = PrimaryKey(id) + } + + 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/ttl/TtlAlterSqlIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt deleted file mode 100644 index 46379e6a..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlAlterSqlIT.kt +++ /dev/null @@ -1,36 +0,0 @@ -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.YdbDialect -import tech.ydb.exposed.dialect.YdbTable -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.javatime.ydbTimestamp - -class TtlAlterSqlIT : BaseYdbTest() { - - object AlterTtlItems : YdbTable("alter_ttl_items") { - val id = integer("id") - val expireAt = ydbTimestamp("expire_at") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(expireAt, "PT24H") - } - } - - override val tables: List
= listOf(AlterTtlItems) - - @Test - fun `should generate alter ttl sql`() = tx { - val dialect = db.dialect as YdbDialect - - val setSql = dialect.setTtl(AlterTtlItems) - val resetSql = dialect.resetTtl(AlterTtlItems) - - assertTrue(setSql.contains("""SET (TTL = Interval("PT24H") ON expire_at)""")) - assertTrue(resetSql.contains("RESET (TTL)")) - } -} \ No newline at end of file diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt deleted file mode 100644 index d6429c7f..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/functions/FunctionTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package tech.ydb.exposed.dialect.unit.functions - -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.YdbFunctionProvider - -class FunctionTest { - private val provider = YdbFunctionProvider - - @Test - fun `should generate limit only`() { - val sql = provider.queryLimitAndOffset(size = 10, offset = 0, alreadyOrdered = false) - assertTrue(sql.contains("LIMIT 10")) - } - - @Test - fun `should generate limit and offset`() { - val sql = provider.queryLimitAndOffset(size = 10, offset = 5, alreadyOrdered = false) - assertTrue(sql.contains("LIMIT 10")) - assertTrue(sql.contains("OFFSET 5")) - } - - @Test - fun `should generate offset without limit`() { - val sql = provider.queryLimitAndOffset(size = null, offset = 5, alreadyOrdered = false) - assertTrue(sql.contains("OFFSET 5")) - } -} 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..b33add1b --- /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 +import tech.ydb.exposed.dialect.buildYdbJsonPath + +class YdbFunctionProviderTest { + + private val provider = YdbFunctionProvider + + private fun sql(build: QueryBuilder.() -> Unit): String = + QueryBuilder(false).apply(build).toString() + + @Test + fun `buildYdbJsonPath maps object keys and array indexes`() { + assertEquals("$", buildYdbJsonPath()) + assertEquals("$.friends[0].name", buildYdbJsonPath("friends", "0", "name")) + assertEquals("$.title", buildYdbJsonPath("title")) + assertEquals("$.friends[*]", buildYdbJsonPath("friends", "[*]")) + } + + @Test + fun `buildYdbJsonPath quotes keys with special characters`() { + assertEquals("$.\"key-name\"", buildYdbJsonPath("key-name")) + } + + @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( + "IF(Unicode::Find('abcdef', 'cd') IS NULL, 0, CAST(Unicode::Find('abcdef', 'cd') + 1u AS Int32))", + result + ) + } + + @Test + fun `locate escapes quotes in needle`() { + val expr = stringLiteral("a'b") + val result = sql { provider.locate(this, expr, "x'y") } + assertEquals( + "IF(Unicode::Find('a''b', 'x''y') IS NULL, 0, CAST(Unicode::Find('a''b', 'x''y') + 1u AS Int32))", + 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 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)) + } +} From a9ccec0ef35295fe13e4a2731184fdab38f04dbd Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Mon, 18 May 2026 17:34:42 +0300 Subject: [PATCH 31/43] big refactoring --- kotlin-exposed-dialect/pom.xml | 7 +++++++ .../tech/ydb/exposed/dialect/YdbDialect.kt | 3 ++- .../dialect/integration/basic/YdbTableIT.kt | 6 +++--- .../integration/types/AllTypesRoundTripIT.kt | 6 +++--- .../dialect/unit/types/YdbHexToDbTest.kt | 18 ++++++++++++++---- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index dc4b5592..dcce6390 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -78,6 +78,13 @@ ${kotlin.version} + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + test + + org.jetbrains.exposed exposed-core 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 index 142240b1..08e32837 100644 --- 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 @@ -70,7 +70,8 @@ internal class YdbDataTypeProvider : DataTypeProvider() { override fun dateTimeType(): String = "Datetime" override fun timestampType(): String = "Timestamp" - override fun hexToDb(hexString: String): String = "String::HexDecode('$hexString')" + override fun hexToDb(hexString: String): String = + "Unwrap(String::HexDecode('$hexString'), 'invalid hex bytes literal')" } internal object YdbFunctionProvider : FunctionProvider() { 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 index 991989fb..1e7f8306 100644 --- 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 @@ -7,7 +7,7 @@ import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.YdbTtlColumnMode import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbUint64 -import tech.ydb.exposed.dialect.javatime.ydbTimestamp +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 class YdbTableIT : BaseYdbTest() { @@ -20,7 +20,7 @@ class YdbTableIT : BaseYdbTest() { object TtlTimestampTable : YdbTable("unit_ttl_timestamp_table") { val id = integer("id") - val expireAt = ydbTimestamp("expire_at") + val expireAt = ydbTimestamp64("expire_at") override val primaryKey = PrimaryKey(id) @@ -103,7 +103,7 @@ class YdbTableIT : BaseYdbTest() { val error = assertThrows(IllegalArgumentException::class.java) { object : YdbTable("invalid_ttl_interval_table") { val id = integer("id") - val expireAt = ydbTimestamp("expire_at") + val expireAt = ydbTimestamp64("expire_at") override val primaryKey = PrimaryKey(id) 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 index fa93fe9e..564f4a8e 100644 --- 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 @@ -201,9 +201,9 @@ class AllTypesRoundTripIT : BaseYdbTest() { @Test fun `should emit expected ddl for standard temporal and ydb extensions`() = tx { val stdDdl = StandardTemporal.ddl.joinToString(" ") - assertTrue(stdDdl.contains("date_col Date32")) - assertTrue(stdDdl.contains("datetime_col Datetime64")) - assertTrue(stdDdl.contains("timestamp_col Timestamp64")) + 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)")) 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 index 2c6e1c60..a5a0bfa8 100644 --- 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 @@ -11,7 +11,8 @@ import tech.ydb.exposed.dialect.YdbDataTypeProvider * Exposed calls it from [org.jetbrains.exposed.v1.core.BlobColumnType.nonNullValueToString] * ([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). + * [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 { @@ -19,8 +20,14 @@ class YdbHexToDbTest { @Test fun `uses String HexDecode without cast`() { - assertEquals("String::HexDecode('deadbeef')", provider.hexToDb("deadbeef")) - assertEquals("String::HexDecode('')", provider.hexToDb("")) + assertEquals( + "Unwrap(String::HexDecode('deadbeef'), 'invalid hex bytes literal')", + provider.hexToDb("deadbeef") + ) + assertEquals( + "Unwrap(String::HexDecode(''), 'invalid hex bytes literal')", + provider.hexToDb("") + ) } @Test @@ -29,6 +36,9 @@ class YdbHexToDbTest { val hex = ExposedBlob(bytes).hexString() assertEquals("0102abcd", hex) - assertEquals("String::HexDecode('0102abcd')", provider.hexToDb(hex)) + assertEquals( + "Unwrap(String::HexDecode('0102abcd'), 'invalid hex bytes literal')", + provider.hexToDb(hex) + ) } } From 819dce5f514adc0c36c164364caa05d67f6b6226 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Mon, 18 May 2026 17:48:51 +0300 Subject: [PATCH 32/43] fix issues copilot --- kotlin-exposed-dialect/example/pom.xml | 4 +- .../ydb/exposed/dialect/YdbColumnTypes.kt | 19 +++-- .../ydb/exposed/dialect/YdbSqlFunctions.kt | 3 + .../ydb/exposed/dialect/YdbTableSupport.kt | 2 +- .../dialect/integration/base/BaseYdbTest.kt | 7 +- .../integration/basic/YdbIndexSqlIT.kt | 79 ++++++++++--------- .../unit/types/YdbUnsignedColumnTypeTest.kt | 15 ++++ 7 files changed, 81 insertions(+), 48 deletions(-) diff --git a/kotlin-exposed-dialect/example/pom.xml b/kotlin-exposed-dialect/example/pom.xml index eed6c90e..ec3c848d 100644 --- a/kotlin-exposed-dialect/example/pom.xml +++ b/kotlin-exposed-dialect/example/pom.xml @@ -6,7 +6,7 @@ tech.ydb.dialects kotlin-exposed-ydb-dialect-example - 0.1.0 + 0.9.0 jar Kotlin Exposed YDB Dialect Example @@ -27,7 +27,7 @@ tech.ydb.dialects kotlin-exposed-ydb-dialect - 0.1.0 + 0.9.0 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 index 6ac06bcd..f183bc99 100644 --- 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 @@ -287,15 +287,12 @@ internal class YdbULongColumnType : ColumnType() { else -> error("Unexpected value for Uint64: $value of ${value::class}") } - override fun notNullValueToDB(value: ULong): Any { - val longValue = value.toLong() - require(longValue >= 0) { "Uint64 column cannot store negative value: $value" } - return longValue - } + override fun notNullValueToDB(value: ULong): Any = value.toLongCompatibleUint64() override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { val dbValue = when (value) { - is ULong -> value.toLong() + null -> null + is ULong -> value.toLongCompatibleUint64() else -> value } bindYdbParameter(stmt, index, dbValue, YdbJdbcCode.UINT64, this) @@ -346,4 +343,14 @@ private fun BigInteger.toLongCompatibleUint64(): Long { 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/YdbSqlFunctions.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt index 25387c8e..cbb01ddd 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt @@ -40,3 +40,6 @@ private fun quoteJsonPathKey(key: String): String = internal fun escapeYqlStringLiteral(value: String): String = value.replace("'", "''") + +internal fun escapeYqlDoubleQuotedLiteral(value: String): String = + value.replace("\\", "\\\\").replace("\"", "\\\"") diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt index 66bd8ae7..8aefbcb8 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt @@ -35,7 +35,7 @@ internal fun buildYdbCreateStatement( buildString { append(" WITH (TTL = Interval(\"") - append(normalizedInterval) + append(escapeYqlDoubleQuotedLiteral(normalizedInterval)) append("\") ON ") append(tr.identity(ttl.column)) ttl.mode.toSql()?.let { 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 index 24e9ced8..cab95451 100644 --- 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 @@ -5,6 +5,7 @@ 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.TransactionManager +import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.extension.RegisterExtension @@ -38,8 +39,8 @@ abstract class BaseYdbTest { db = connectYdb(url = jdbcUrl) if (tables.isNotEmpty()) { - ydbTransaction(db) { - SchemaUtils.drop(*tables.toTypedArray()) + transaction(db) { + runCatching { SchemaUtils.drop(*tables.toTypedArray()) } SchemaUtils.create(*tables.toTypedArray()) } } @@ -51,7 +52,7 @@ abstract class BaseYdbTest { if (tables.isNotEmpty()) { runCatching { - ydbTransaction(db) { + transaction(db) { SchemaUtils.drop(*tables.toTypedArray()) } } 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 index 35d87040..d5e4bcec 100644 --- 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 @@ -3,6 +3,7 @@ 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.jdbc.transactions.transaction import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -48,52 +49,58 @@ class YdbIndexSqlIT : BaseYdbTest() { } @Test - fun `renders a standard Exposed index as YDB ALTER TABLE`() = tx { - val dialect = db.dialect as YdbDialect - val sql = dialect.createIndex(IndexedTable.emailIndexDefinition) + 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) + 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`() = tx { - 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)") + 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 - ) + ), + functionsTable = IndexedTable + ) - val error = assertThrows(UnsupportedOperationException::class.java) { - dialect.createIndex(functionIndex) - } + val error = assertThrows(UnsupportedOperationException::class.java) { + dialect.createIndex(functionIndex) + } - assertTrue(error.message == "YDB dialect does not support functional indexes", error.message) + assertTrue(error.message == "YDB dialect does not support functional indexes", error.message) + } } @Test - fun `renders YDB-specific inline secondary index`() = tx { - val ddl = IndexedTable.ddl.joinToString(" ") - val expectedHyphenatedName = db.identifierManager.cutIfNecessaryAndQuote("email-cover-idx") + fun `renders YDB-specific inline secondary index`() { + transaction(db) { + val ddl = IndexedTable.ddl.joinToString(" ") + val expectedHyphenatedName = db.identifierManager.cutIfNecessaryAndQuote("email-cover-idx") - assertTrue(ddl.contains("INDEX email_cover_idx"), ddl) - assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) - assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) - assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) - assertTrue(ddl.contains("WITH (foo = \"bar\")"), ddl) - assertTrue(ddl.contains("INDEX $expectedHyphenatedName GLOBAL UNIQUE"), ddl) + assertTrue(ddl.contains("INDEX email_cover_idx"), ddl) + assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) + assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) + assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) + assertTrue(ddl.contains("WITH (foo = \"bar\")"), ddl) + assertTrue(ddl.contains("INDEX $expectedHyphenatedName GLOBAL UNIQUE"), ddl) + } } } 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 index ce1c5645..e1c4998f 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -55,4 +57,17 @@ class YdbUnsignedColumnTypeTest { 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())) + } } From 764847ca2d67f2036e3742d91a6f1e13c0bd4c9a Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Mon, 18 May 2026 18:18:40 +0300 Subject: [PATCH 33/43] fix issues copilot --- .../tech/ydb/exposed/dialect/YdbDialect.kt | 18 ++- .../exposed/dialect/YdbDialectRegistration.kt | 15 ++- .../types/SignedTemporalTypesIT.kt | 114 ++++++++++++++++++ .../unit/types/YdbDataTypeProviderTest.kt | 10 +- 4 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/SignedTemporalTypesIT.kt 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 index 08e32837..d72da5c2 100644 --- 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 @@ -28,7 +28,9 @@ import java.sql.DatabaseMetaData * [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 : DataTypeProvider() { +internal class YdbDataTypeProvider( + private val enableSignedDatetimes: Boolean = false +) : DataTypeProvider() { override fun booleanType(): String = "Bool" override fun byteType(): String = "Int8" @@ -66,9 +68,11 @@ internal class YdbDataTypeProvider : DataTypeProvider() { override fun ulongAutoincType(): String = throw UnsupportedOperationException("YDB does not support unsigned Serial columns") - override fun dateType(): String = "Date" - override fun dateTimeType(): String = "Datetime" - override fun timestampType(): String = "Timestamp" + 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" override fun hexToDb(hexString: String): String = "Unwrap(String::HexDecode('$hexString'), 'invalid hex bytes literal')" @@ -305,9 +309,11 @@ internal object YdbFunctionProvider : FunctionProvider() { * with a default [org.jetbrains.exposed.v1.core.DatabaseConfig] tuned for YDB * (SERIALIZABLE isolation, nested transactions disabled). */ -class YdbDialect internal constructor() : VendorDialect( +class YdbDialect internal constructor( + val enableSignedDatetimes: Boolean = false +) : VendorDialect( DIALECT_NAME, - YdbDataTypeProvider(), + YdbDataTypeProvider(enableSignedDatetimes), YdbFunctionProvider ) { override fun createIndex(index: Index): String { 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 index 16a43d49..0bca807c 100644 --- 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 @@ -40,28 +40,27 @@ fun registerYdbDialect() { /** * Opens a YDB-backed Exposed [Database] with dialect defaults tuned for YDB. * - * JDBC URL is augmented with `forceSignedDatetimes=false` for driver backward compatibility. - * Temporal column types (unsigned vs signed) are chosen per column via [tech.ydb.exposed.dialect.javatime.ydbDate] - * / [tech.ydb.exposed.dialect.javatime.ydbDate32], not via a connection flag. + * Add `forceSignedDatetimes=true` or `forceSignedDatetimes=false` to [url] when the JDBC driver requires it. */ fun connectYdb( url: String, user: String = "", - password: String = "" + password: String = "", + enableSignedDatetimes: Boolean = false ): Database { ensureYdbDialectRegistered() return Database.connect( - url = ydbJdbcUrl(url), + url = url, driver = YDB_DRIVER_CLASS, user = user, password = password, - databaseConfig = ydbDatabaseConfig() + databaseConfig = ydbDatabaseConfig(enableSignedDatetimes = enableSignedDatetimes) ) } -internal fun ydbDatabaseConfig(): DatabaseConfig = DatabaseConfig { - explicitDialect = YdbDialect() +internal fun ydbDatabaseConfig(enableSignedDatetimes: Boolean = false): DatabaseConfig = DatabaseConfig { + explicitDialect = YdbDialect(enableSignedDatetimes = enableSignedDatetimes) defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE defaultReadOnly = false useNestedTransactions = false 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..8bf62eb2 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/types/SignedTemporalTypesIT.kt @@ -0,0 +1,114 @@ +package tech.ydb.exposed.dialect.integration.types + +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.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +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.YdbDialect +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.connectYdb +import tech.ydb.exposed.dialect.javatime.ydbDate32 +import tech.ydb.exposed.dialect.javatime.ydbDatetime64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 +import tech.ydb.exposed.dialect.ydbTransaction +import tech.ydb.test.junit5.YdbHelperExtension +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * Signed temporal columns with [connectYdb] `enableSignedDatetimes = true` and + * `forceSignedDatetimes=true` on the JDBC URL (set explicitly by the caller). + */ +class SignedTemporalTypesIT { + + object SignedTemporal : YdbTable("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) + } + + private lateinit var db: Database + private lateinit var jdbcUrl: String + + @BeforeEach + fun setUp() { + jdbcUrl = buildString { + append("jdbc:ydb:") + append(if (ydb.useTls()) "grpcs://" else "grpc://") + append(ydb.endpoint()) + append(ydb.database()) + append("?disablePrepareDataQuery=true&forceSignedDatetimes=true") + ydb.authToken()?.let { append("&token=").append(it) } + } + + db = connectYdb(url = jdbcUrl, enableSignedDatetimes = true) + } + + @AfterEach + fun tearDown() { + if (!::db.isInitialized) return + + runCatching { + transaction(db) { + SchemaUtils.drop(SignedTemporal) + } + } + runCatching { TransactionManager.closeAndUnregister(db) } + } + + @Test + fun `connectYdb wires signed dialect and jdbc url has forceSignedDatetimes`() { + val dialect = db.dialect as YdbDialect + assertTrue(dialect.enableSignedDatetimes) + assertTrue(jdbcUrl.contains("forceSignedDatetimes=true"), jdbcUrl) + } + + @Test + fun `should round-trip signed temporal columns`() = ydbTransaction(db) { + SchemaUtils.create(SignedTemporal) + + 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`() = ydbTransaction(db) { + 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) + } + + companion object { + @JvmField + @RegisterExtension + val ydb: YdbHelperExtension = YdbHelperExtension() + } +} 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 index 965982e7..06c32477 100644 --- 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 @@ -7,7 +7,7 @@ import tech.ydb.exposed.dialect.YdbDataTypeProvider class YdbDataTypeProviderTest { - private val provider = YdbDataTypeProvider() + private val provider = YdbDataTypeProvider(enableSignedDatetimes = false) @Test fun `maps integer types`() { @@ -48,6 +48,14 @@ class YdbDataTypeProviderTest { 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()) From f87cf7dd774b7f409d93a100cd40f8183d4f87ea Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Tue, 19 May 2026 09:53:39 +0300 Subject: [PATCH 34/43] added docs --- kotlin-exposed-dialect/CHANGELOG.md | 6 +- kotlin-exposed-dialect/README.md | 70 ++++++++++++++++--- .../ydb/exposed/dialect/YdbColumnTypes.kt | 40 +++++++++-- .../tech/ydb/exposed/dialect/YdbDialect.kt | 32 +++++++-- .../exposed/dialect/YdbDialectRegistration.kt | 29 +++++++- .../ydb/exposed/dialect/YdbRetryConfig.kt | 18 +++-- .../ydb/exposed/dialect/YdbRetryPolicy.kt | 10 ++- .../ydb/exposed/dialect/YdbSecondaryIndex.kt | 9 +++ .../tech/ydb/exposed/dialect/YdbTable.kt | 15 ++++ .../ydb/exposed/dialect/YdbTableSupport.kt | 6 ++ .../ydb/exposed/dialect/YdbTransaction.kt | 13 +++- .../kotlin/tech/ydb/exposed/dialect/YdbTtl.kt | 9 +++ .../javatime/YdbJavaTimeColumnTypes.kt | 13 ++++ .../unit/functions/YdbFunctionProviderTest.kt | 4 +- .../unit/transaction/YdbRetryPolicyTest.kt | 4 +- .../unit/types/BindYdbParameterTest.kt | 28 ++++++++ 16 files changed, 266 insertions(+), 40 deletions(-) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/BindYdbParameterTest.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 5c823c49..0cc0b0c8 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -6,7 +6,7 @@ Initial release of the Kotlin Exposed dialect for YDB. - YDB `VendorDialect` for Exposed JDBC; `registerYdbDialect()` / `connectYdb()` for setup. - `ydbTransaction { ... }` — retryable transactions with `readOnly` and `YdbRetryConfig` - (backoff/jitter aligned with [.NET YdbRetryPolicy](https://github.com/ydb-platform/ydb-dotnet-sdk/tree/main/src/Ydb.Sdk/src/Ado/RetryPolicy); + (exponential backoff with full/equal jitter on YDB vendor codes); retries classified by JDBC `SQLException` vendor codes). - Native `UPSERT` / `REPLACE` rendering — wired into Exposed's standard `Table.upsert` and `Table.replace` DSL. @@ -16,8 +16,8 @@ Initial release of the Kotlin Exposed dialect for YDB. - JDBC metadata for reading existing indexes. - Temporal columns: unsigned (`YdbTable.date`, …) and signed (`date32`, …) extensions with JDBC vendor codes; DDL `sqlType()` derived from the code. No connection-level temporal flag. -- Custom column types for `Decimal`, `Interval`, `Json`, `JsonDocument`, three `Uuid` - flavours and `Uint64`, plus a `ydbDecimalLiteral` helper for update expressions. +- 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 `YdbTable`. - Explicit rejection of ANSI `MERGE` (`UPSERT` covers the use case). - Console demo application showing CRUD, UPSERT and DDL. diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 6ac9813f..40c94615 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -11,9 +11,22 @@ The module provides: - JDK 17+ - Maven -- YDB JDBC Driver +- [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 @@ -92,6 +105,10 @@ Products.replace { ANSI `MERGE` is intentionally rejected — `UPSERT` / `REPLACE` cover the same use cases. +YDB `UPSERT` always writes **all columns** from the DSL block (full row by primary key). Exposed's +`onUpdate` / `keyColumns` arguments are **ignored** — there is no MySQL-style partial +`ON DUPLICATE KEY UPDATE`. + ## Retryable transactions YDB uses Optimistic Concurrency Control, so a transaction can fail with `Transaction locks @@ -115,9 +132,8 @@ ydbTransaction(db, readOnly = true, retry = YdbRetryConfig.IDEMPOTENT) { } ``` -Backoff and jitter follow the [.NET YDB SDK retry policy](https://github.com/ydb-platform/ydb-dotnet-sdk/tree/main/src/Ydb.Sdk/src/Ado/RetryPolicy): -full jitter for `ABORTED` / `UNDETERMINED`, equal jitter for `UNAVAILABLE` / transport / -`OVERLOADED`, zero delay for session errors. Status codes are read from `SQLException.errorCode` +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, @@ -137,12 +153,22 @@ Default mapping for standard Exposed types: | `float` / `double` | `Float` / `Double` | | `bool` | `Bool` | | `varchar` / `text` | `Text` | -| `binary` / `blob` | `String` | +| `binary` / `blob` | `Bytes` | | `uuid` | `Uuid` | | `date` | `Date` | | `datetime` | `Datetime` | | `timestamp` | `Timestamp` | -| `json` | `JsonDocument` | +| `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` (including `YdbTable`); JDBC vendor code drives both bind and DDL `sqlType()`: @@ -158,8 +184,11 @@ object Events : YdbTable("events") { val updated = ydbDatetime64("updated") // Datetime64 } ``` -`connectYdb` sets `forceSignedDatetimes=false` on the JDBC URL for driver compatibility; -per-column types are not controlled by a connection flag. + +Optional: `connectYdb(..., 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`: @@ -172,8 +201,8 @@ ydbUuid("id") // native Uuid; same as Exposed uuid() unde ydbUint64("counter") ``` -`ydbUint64` is backed by `Long` and supports values `0..Long.MAX_VALUE`. Use a wider type -(`BigInteger`) if you need the full `Uint64` range. +`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`: @@ -198,6 +227,27 @@ object Orders : YdbTable("orders") { For UUID keys use `ydbUuid("id")` or Exposed `uuid()` under this dialect. Unsigned `Serial` columns are not supported. +## Indexes + +Two ways to declare secondary indexes: + +| Mechanism | SQL shape | Features | +|-----------|-----------|----------| +| `Table.index()` / `index(isUnique = …)` | `ALTER TABLE … ADD INDEX … GLOBAL` | Unique flag; no inline `ASYNC` / `COVER` / `WITH` | +| `YdbTable.secondaryIndex()` | Inline in `CREATE TABLE` | `ASYNC`, `COVER`, `WITH`, unique | + +## 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`. +- TTL is emitted on `CREATE TABLE` only (no `ALTER TABLE … SET (TTL)` DSL). +- No Yson / timezone-aware temporal types in this module. +- Functional secondary indexes are rejected. + ## TTL ```kotlin 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 index f183bc99..e1830eed 100644 --- 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 @@ -1,3 +1,12 @@ +/** + * 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 @@ -11,7 +20,6 @@ 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.sql.PreparedStatement import java.time.Duration import java.util.UUID import kotlin.UByte @@ -21,30 +29,42 @@ 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()) @@ -56,6 +76,10 @@ fun Table.ydbInterval(name: String): Column = 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, @@ -66,6 +90,12 @@ fun ydbDecimalLiteral( // 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, @@ -79,9 +109,11 @@ internal fun bindYdbParameter( } val jdbcStatement = (stmt as? JdbcPreparedStatementImpl)?.statement - if (jdbcStatement is PreparedStatement) { - jdbcStatement.setObject(index, value, targetSqlType) - } + ?: error( + "YDB column bind requires JdbcPreparedStatementImpl (got ${stmt::class.qualifiedName}); " + + "use ydb* / javatime.* column types with the YDB JDBC driver" + ) + jdbcStatement.setObject(index, value, targetSqlType) } // 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 index d72da5c2..6084243d 100644 --- 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 @@ -28,6 +28,12 @@ import java.sql.DatabaseMetaData * [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. */ +/** + * Maps generic Exposed column DSL types to YQL type names for DDL. + * + * @param enableSignedDatetimes When `true`, [dateType]/[dateTimeType]/[timestampType] use signed + * `Date32`/`Datetime64`/`Timestamp64`. Does not affect explicit `ydb*` / `javatime.*` columns. + */ internal class YdbDataTypeProvider( private val enableSignedDatetimes: Boolean = false ) : DataTypeProvider() { @@ -74,6 +80,7 @@ internal class YdbDataTypeProvider( 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')" } @@ -139,8 +146,7 @@ internal object YdbFunctionProvider : FunctionProvider() { substring: String ) = queryBuilder { val needle = escapeYqlStringLiteral(substring) - append("IF(Unicode::Find(", expr, ", '", needle, "') IS NULL, 0, ") - append("CAST(Unicode::Find(", expr, ", '", needle, "') + 1u AS Int32))") + append("COALESCE(CAST(Unicode::Find(", expr, ", '", needle, "') + 1u AS Int32), 0)") } override fun regexp( @@ -197,6 +203,12 @@ internal object YdbFunctionProvider : FunctionProvider() { append(")") } + /** + * Native YDB `UPSERT` — full row replace by primary key. + * + * Exposed's `onUpdate` / `keyColumns` / MySQL-style partial upsert are **not** used: + * every column in [data] is written; there is no `ON DUPLICATE KEY UPDATE` clause in YQL. + */ override fun upsert( table: Table, data: List, Any?>>, @@ -305,9 +317,14 @@ internal object YdbFunctionProvider : FunctionProvider() { /** * Exposed [VendorDialect] for YDB. * - * Usually obtained via [connectYdb], which wires it into a [Database] together - * with a default [org.jetbrains.exposed.v1.core.DatabaseConfig] tuned for YDB - * (SERIALIZABLE isolation, nested transactions disabled). + * Obtained via [connectYdb] (recommended) or [registerYdbDialect] + `Database.connect`. + * + * Notable behavior: + * - [upsert] → YQL `UPSERT` (full row replace by PK; Exposed `onUpdate` / partial columns ignored). + * - [createIndex] → `ALTER TABLE ... ADD INDEX ... GLOBAL`. + * - 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 @@ -316,6 +333,10 @@ class YdbDialect internal constructor( YdbDataTypeProvider(enableSignedDatetimes), YdbFunctionProvider ) { + /** + * Post-create index: `ALTER TABLE t ADD INDEX i GLOBAL [UNIQUE] ON (cols)`. + * Prefer [YdbTable.secondaryIndex] for indexes declared with the table. + */ override fun createIndex(index: Index): String { val tr = runCatching { TransactionManager.current() }.getOrNull() if (!index.functions.isNullOrEmpty()) { @@ -352,6 +373,7 @@ class YdbDialect internal constructor( isPartialOrFunctional: Boolean ): String = "ALTER TABLE $tableName DROP INDEX $indexName" + /** JDBC metadata bridge so Exposed can diff existing GLOBAL indexes on YDB. */ internal object Metadata : DatabaseDialectMetadata() { override fun existingIndices(vararg tables: Table): Map> { 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 index 0bca807c..94c0cafa 100644 --- 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 @@ -1,3 +1,11 @@ +/** + * Entry points for wiring YDB into Exposed: driver registration, [connectYdb], and JDBC URL helpers. + * + * Typical setup: + * 1. [registerYdbDialect] once (or let [connectYdb] do it). + * 2. [connectYdb] with a `jdbc:ydb:...` URL and optional [enableSignedDatetimes]. + * 3. Declare tables as [YdbTable] and use [ydbTransaction] for DML under YDB OCC. + */ package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.DatabaseApi @@ -6,8 +14,16 @@ import org.jetbrains.exposed.v1.jdbc.Database import java.sql.Connection import java.util.concurrent.atomic.AtomicBoolean +/** JDBC URL prefix registered with Exposed ([registerYdbDialect]). */ internal const val YDB_JDBC_URL_PREFIX = "jdbc:ydb:" + +/** YDB JDBC driver class ([tech.ydb.jdbc.YdbDriver]). */ internal const val YDB_DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" + +/** + * Query parameter for the YDB JDBC driver: signed vs legacy temporal wire format. + * Not set automatically by [connectYdb] — add `forceSignedDatetimes=true|false` to the URL explicitly. + */ internal const val FORCE_SIGNED_DATETIMES_PROPERTY = "forceSignedDatetimes" private val dialectRegistered = AtomicBoolean(false) @@ -40,7 +56,13 @@ fun registerYdbDialect() { /** * Opens a YDB-backed Exposed [Database] with dialect defaults tuned for YDB. * - * Add `forceSignedDatetimes=true` or `forceSignedDatetimes=false` to [url] when the JDBC driver requires it. + * @param url JDBC URL (`jdbc:ydb:grpc://...` or `grpcs://...`). Append `forceSignedDatetimes=true` + * or `false` when the driver requires it (not added automatically). + * @param enableSignedDatetimes When `true`, [YdbDialect] maps standard Exposed `date` / `datetime` / + * `timestamp` DDL names to `Date32` / `Datetime64` / `Timestamp64`. Per-column types remain + * explicit via [tech.ydb.exposed.dialect.javatime.ydbDate] vs [ydbDate32], etc. + * + * Configures `SERIALIZABLE` isolation and disables nested transactions (YDB snapshot semantics). */ fun connectYdb( url: String, @@ -59,6 +81,7 @@ fun connectYdb( ) } +/** [DatabaseConfig] used by [connectYdb] with an explicit [YdbDialect] instance. */ internal fun ydbDatabaseConfig(enableSignedDatetimes: Boolean = false): DatabaseConfig = DatabaseConfig { explicitDialect = YdbDialect(enableSignedDatetimes = enableSignedDatetimes) defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE @@ -66,6 +89,10 @@ internal fun ydbDatabaseConfig(enableSignedDatetimes: Boolean = false): Database useNestedTransactions = false } +/** + * Appends `forceSignedDatetimes=false` to [url] (replacing an existing value). + * Used by tests and [RegisterYdbDialectConnectIT]; [connectYdb] does not modify the URL. + */ internal fun ydbJdbcUrl(url: String): String = appendBooleanQueryParameter( url = url, 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 index bbae5f0b..1e37a856 100644 --- 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 @@ -1,21 +1,25 @@ package tech.ydb.exposed.dialect /** - * Retry and backoff settings for [ydbTransaction], aligned with - * [YdbRetryPolicyConfig](https://github.com/ydb-platform/ydb-dotnet-sdk/blob/main/src/Ydb.Sdk/src/Ado/RetryPolicy/YdbRetryPolicyConfig.cs). + * 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), same as .NET [maxAttempts]. - */ + /** 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`, retryable statuses from [tech.ydb.exposed.dialect.YdbRetryPolicy] are retried - * even if they are not classified as transient. Enable only for idempotent work. + * When `true`, statuses handled by [getNextRetryDelayMs] are retried even if they are not + * [isTransientVendorCode]. Enable only for idempotent work. */ val enableRetryIdempotence: Boolean = false, ) { 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 index 56defcbe..6dabd359 100644 --- 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 @@ -6,12 +6,16 @@ import kotlin.math.min import kotlin.random.Random /** - * Retry delay calculation aligned with - * [YdbRetryPolicy](https://github.com/ydb-platform/ydb-dotnet-sdk/blob/main/src/Ydb.Sdk/src/Ado/RetryPolicy/YdbRetryPolicy.cs). + * 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, @@ -69,7 +73,7 @@ internal fun getNextRetryDelayMs( } } -/** Mirrors .NET `YdbException.IsTransient` / [tech.ydb.core.StatusCode] always-retryable set. */ +/** Vendor codes retried without [YdbRetryConfig.enableRetryIdempotence]. */ internal fun isTransientVendorCode(vendorCode: Int): Boolean = vendorCode in TRANSIENT_VENDOR_CODES internal fun extractVendorCode(error: Throwable): Int? { diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt index c613050a..5c3258d9 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt @@ -1,17 +1,25 @@ +/** + * YDB secondary index declarations for [YdbTable.secondaryIndex]. + * + * Rendered into `CREATE TABLE` as `INDEX name GLOBAL [UNIQUE] [ASYNC] ON (...) [COVER (...)] [WITH (...)]`. + */ package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +/** Index visibility scope in YQL (`GLOBAL` for row-oriented tables). */ enum class YdbIndexScope { GLOBAL } +/** Whether the index is built synchronously with writes or in the background. */ enum class YdbIndexSyncMode { SYNC, ASYNC } +/** Internal model produced by [YdbTable.secondaryIndex] and rendered by [renderYdbSecondaryIndex]. */ data class YdbSecondaryIndexSpec( val name: String, val columns: List>, @@ -23,6 +31,7 @@ data class YdbSecondaryIndexSpec( val withParams: Map = emptyMap() ) +/** Builds the `INDEX ...` fragment for a single secondary index inside `CREATE TABLE`. */ internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { val tr = TransactionManager.current() diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt index 61e5ed45..030853a5 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt @@ -15,6 +15,12 @@ open class YdbTable(name: String = "") : Table(name) { private var ttlSettingsState: YdbTtlSettings? = null private val secondaryIndices = mutableListOf() + /** + * Declares row TTL on [column] (embedded in `CREATE TABLE ... WITH (TTL = ...)`). + * + * @param intervalIso8601 ISO-8601 duration (e.g. `P30D`, `PT1H`); normalized via [normalizeTtlInterval]. + * @param mode How [column] is interpreted — date/timestamp types vs numeric epoch units. + */ fun ttl( column: Column<*>, intervalIso8601: String, @@ -23,6 +29,15 @@ open class YdbTable(name: String = "") : Table(name) { ttlSettingsState = YdbTtlSettings(column, normalizeTtlInterval(intervalIso8601), mode) } + /** + * Declares a YDB secondary index inline in `CREATE TABLE` (not Exposed's generic [Index] DSL). + * + * @param scope Currently only [YdbIndexScope.GLOBAL] is supported for row tables. + * @param syncMode [YdbIndexSyncMode.ASYNC] for background index build. + * @param indexType Optional `USING` clause (e.g. vector index type when supported by YDB). + * @param coverColumns Included columns for covering index (`COVER (...)`). + * @param withParams Index-level `WITH (key = value)` parameters. + */ fun secondaryIndex( name: String, vararg columns: Column<*>, diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt index 8aefbcb8..01a6b9b4 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt @@ -1,9 +1,13 @@ +/** + * DDL helpers for [YdbTable]: single-statement `CREATE TABLE` with PK, secondary indexes, and TTL. + */ package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Table import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager import java.time.Duration +/** One `CREATE TABLE IF NOT EXISTS ...` including inline indexes and optional TTL clause. */ internal fun buildYdbCreateStatement( table: Table, ttlSettings: YdbTtlSettings?, @@ -66,12 +70,14 @@ internal fun buildYdbCreateStatement( return listOf(sql) } +/** Parses [intervalIso8601] as [Duration] and returns the canonical string for YQL `Interval("...")`. */ internal fun normalizeTtlInterval(intervalIso8601: String): String = runCatching { Duration.parse(intervalIso8601).toString() } .getOrElse { cause -> throw IllegalArgumentException("Invalid YDB TTL interval: '$intervalIso8601'", cause) } +/** Ensures [YdbTtlSettings.column] SQL type is compatible with [YdbTtlSettings.mode]. */ internal fun validateYdbTtlColumn(ttl: YdbTtlSettings) { val sqlType = ttl.column.columnType.sqlType() 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 index 2da65619..853582ab 100644 --- 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 @@ -5,10 +5,17 @@ import org.jetbrains.exposed.v1.jdbc.JdbcTransaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction /** - * Runs [statement] inside an Exposed [transaction] and retries it on retryable YDB errors. + * Runs [statement] inside an Exposed [transaction] with YDB-friendly defaults and retries. * - * Retry behavior is controlled by [retry] ([YdbRetryConfig]), aligned with - * [YdbRetryPolicy](https://github.com/ydb-platform/ydb-dotnet-sdk/blob/main/src/Ydb.Sdk/src/Ado/RetryPolicy/YdbRetryPolicy.cs). + * 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, diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt index b7fe9707..5698984b 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt @@ -1,9 +1,15 @@ +/** + * TTL settings for [YdbTable.ttl] — see YDB docs on `WITH (TTL = Interval(...) ON ...)`. + */ package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.Column +/** How the TTL source [Column] value is interpreted in YQL. */ enum class YdbTtlColumnMode { + /** `Date` / `Datetime` / `Timestamp` (and `*32`/`*64` variants) columns. */ DATE_TYPE, + /** `Uint32` / `Uint64` / `DyNumber` interpreted as Unix seconds. */ SECONDS, MILLISECONDS, MICROSECONDS, @@ -18,8 +24,11 @@ enum class YdbTtlColumnMode { } } +/** Resolved TTL clause for [YdbTable] DDL generation. */ data class YdbTtlSettings( + /** Column whose value drives expiration. */ val column: Column<*>, + /** ISO-8601 duration string passed to `Interval("...")`. */ val intervalIso8601: String, val mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE ) \ 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 index e5de93dc..7d6bf7bf 100644 --- 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 @@ -1,3 +1,16 @@ +/** + * 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`. + * + * [connectYdb] 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 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 index b33add1b..c756ccee 100644 --- 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 @@ -71,7 +71,7 @@ class YdbFunctionProviderTest { val expr = stringLiteral("abcdef") val result = sql { provider.locate(this, expr, "cd") } assertEquals( - "IF(Unicode::Find('abcdef', 'cd') IS NULL, 0, CAST(Unicode::Find('abcdef', 'cd') + 1u AS Int32))", + "COALESCE(CAST(Unicode::Find('abcdef', 'cd') + 1u AS Int32), 0)", result ) } @@ -81,7 +81,7 @@ class YdbFunctionProviderTest { val expr = stringLiteral("a'b") val result = sql { provider.locate(this, expr, "x'y") } assertEquals( - "IF(Unicode::Find('a''b', 'x''y') IS NULL, 0, CAST(Unicode::Find('a''b', 'x''y') + 1u AS Int32))", + "COALESCE(CAST(Unicode::Find('a''b', 'x''y') + 1u AS Int32), 0)", result ) } 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 index ce9373e8..eb36b2f9 100644 --- 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 @@ -23,7 +23,7 @@ class YdbRetryPolicyTest { private val fixedRandom = Random(42) @Test - fun `ceiling is derived from cap like dotnet SDK`() { + fun `ceiling is derived from cap backoff`() { assertEquals(9, ceilingFromCapBackoffMs(500)) assertEquals(13, ceilingFromCapBackoffMs(5_000)) } @@ -133,7 +133,7 @@ class YdbRetryPolicyTest { } @Test - fun `transient codes match dotnet transient gate`() { + 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..71f070fc --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/BindYdbParameterTest.kt @@ -0,0 +1,28 @@ +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.YdbUint64ColumnType +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, YdbUint64ColumnType()) + } + } +} From cd7bb24507d1b283e51623fc88c1a4992c338a17 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Tue, 19 May 2026 11:51:51 +0300 Subject: [PATCH 35/43] remove YdbTable.kt --- kotlin-exposed-dialect/CHANGELOG.md | 9 +- kotlin-exposed-dialect/README.md | 72 ++--- .../ydb/exposed/dialect/example/DemoApp.kt | 15 +- .../ydb/exposed/dialect/example/DemoTables.kt | 16 +- .../ydb/exposed/dialect/YdbColumnTypes.kt | 5 +- .../tech/ydb/exposed/dialect/YdbDialect.kt | 276 +++++++++++------- .../exposed/dialect/YdbDialectRegistration.kt | 110 +------ .../ydb/exposed/dialect/YdbSecondaryIndex.kt | 104 ------- .../ydb/exposed/dialect/YdbSqlFunctions.kt | 45 --- .../tech/ydb/exposed/dialect/YdbTable.kt | 73 ----- .../ydb/exposed/dialect/YdbTableSupport.kt | 103 ------- .../kotlin/tech/ydb/exposed/dialect/YdbTtl.kt | 34 --- .../javatime/YdbJavaTimeColumnTypes.kt | 6 +- .../dialect/integration/base/BaseYdbTest.kt | 23 +- .../basic/RegisterYdbDialectConnectIT.kt | 13 +- .../integration/basic/YdbIndexSqlIT.kt | 43 +-- .../dialect/integration/basic/YdbTableIT.kt | 118 -------- .../integration/basic/YdbUniqueIndexSqlIT.kt | 20 +- .../integration/batch/BatchOperationsIT.kt | 3 +- .../dialect/integration/crud/CrudIT.kt | 3 +- .../dialect/integration/dao/DaoSmokeIT.kt | 9 +- .../dialect/integration/dao/SerialDaoIT.kt | 5 +- .../dialect/integration/ddl/IndexIT.kt | 28 +- .../dialect/integration/ddl/UniqueIndexIT.kt | 19 +- .../functions/FunctionProviderIT.kt | 4 +- .../functions/YdbSqlFunctionsIT.kt | 5 +- .../dialect/integration/query/JoinIT.kt | 5 +- .../dialect/integration/query/ManyToManyIT.kt | 7 +- .../dialect/integration/query/SubqueryIT.kt | 3 +- .../scenario/UniversityScenarioIT.kt | 9 +- .../transaction/YdbRetryingTransactionsIT.kt | 3 +- .../integration/ttl/NumericTtlTypesIT.kt | 35 --- .../dialect/integration/ttl/TtlTypesIT.kt | 32 -- .../integration/types/AllTypesRoundTripIT.kt | 7 +- .../integration/types/BinaryHexToDbIT.kt | 3 +- .../integration/types/BinaryTypesIT.kt | 3 +- .../integration/types/DecimalTypesIT.kt | 3 +- .../integration/types/DecimalUpdateIT.kt | 3 +- .../types/ForceLegacyStandardTemporalIT.kt | 62 +--- .../integration/types/IntervalTypesIT.kt | 5 +- .../dialect/integration/types/JsonTypesIT.kt | 5 +- .../types/LegacyTemporalTypesIT.kt | 49 +--- .../types/LegacyYdbTypesRoundTripIT.kt | 3 +- .../types/SignedTemporalTypesIT.kt | 64 +--- .../integration/types/TemporalTypesIT.kt | 3 +- .../dialect/integration/types/TextTypesIT.kt | 3 +- .../dialect/integration/types/TypesIT.kt | 3 +- .../integration/types/Uint64TypesIT.kt | 3 +- .../dialect/integration/types/UuidTypesIT.kt | 3 +- .../dialect/integration/upsert/UpsertIT.kt | 3 +- .../dialect/unit/basic/YdbJdbcUrlTest.kt | 32 -- .../unit/functions/YdbFunctionProviderTest.kt | 30 +- .../dialect/unit/ttl/YdbTtlColumnModeTest.kt | 22 -- .../unit/types/YdbTemporalColumnTypeTest.kt | 19 +- 54 files changed, 363 insertions(+), 1220 deletions(-) delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt delete mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/NumericTtlTypesIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbJdbcUrlTest.kt delete mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 0cc0b0c8..0c173148 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -4,21 +4,20 @@ Initial release of the Kotlin Exposed dialect for YDB. ### Added -- YDB `VendorDialect` for Exposed JDBC; `registerYdbDialect()` / `connectYdb()` for setup. +- YDB `VendorDialect` for Exposed JDBC; `registerYdbDialect(enableSignedDatetimes = …)` for setup. - `ydbTransaction { ... }` — retryable transactions with `readOnly` and `YdbRetryConfig` (exponential backoff with full/equal jitter on YDB vendor codes); retries classified by JDBC `SQLException` vendor codes). - Native `UPSERT` / `REPLACE` rendering — wired into Exposed's standard `Table.upsert` and `Table.replace` DSL. - YDB-compatible `CREATE TABLE` generation with mandatory primary key. -- Secondary index DSL on `YdbTable` — global, async, cover columns, unique. -- TTL clause on `CREATE TABLE` / `ALTER TABLE`, plus numeric epoch modes. +- Secondary indexes via Exposed `Table.index()` → `ALTER TABLE … ADD INDEX … GLOBAL`. - JDBC metadata for reading existing indexes. -- Temporal columns: unsigned (`YdbTable.date`, …) and signed (`date32`, …) extensions with +- 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 `YdbTable`. +- `Serial` / `BigSerial` via Exposed `autoIncrement()` on `Table`. - Explicit rejection of ANSI `MERGE` (`UPSERT` covers the use case). - Console demo application showing CRUD, UPSERT and DDL. - Integration tests powered by testcontainers via `tech.ydb.test:ydb-junit5-support`. diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 40c94615..1ea55a6b 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -30,33 +30,30 @@ The module provides: ## Quick start ```kotlin -import tech.ydb.exposed.dialect.connectYdb +import org.jetbrains.exposed.v1.jdbc.Database +import tech.ydb.exposed.dialect.registerYdbDialect import tech.ydb.exposed.dialect.ydbTransaction -val db = connectYdb(url = "jdbc:ydb:grpc://localhost:2136/local") +registerYdbDialect() // or registerYdbDialect(enableSignedDatetimes = true) + +val db = Database.connect("jdbc:ydb:grpc://localhost:2136/local") ydbTransaction(db) { // Exposed DSL / DAO code } ``` -[connectYdb](src/main/kotlin/tech/ydb/exposed/dialect/YdbDialectRegistration.kt) registers the YDB -JDBC driver and dialect metadata (idempotent), then opens an Exposed `Database` with defaults tuned -for YDB (`SERIALIZABLE` isolation, no nested transactions). Alternatively, call -`registerYdbDialect()` once and use `Database.connect("jdbc:ydb:...")`. - ## Defining tables -YDB requires every table to declare a `PRIMARY KEY`. Inherit from `YdbTable` to get YDB-specific -DDL helpers on top of the standard Exposed `Table`: +YDB requires every table to declare a `PRIMARY KEY`. Use the standard Exposed `Table`; apply +custom DDL (TTL, inline indexes with `COVER` / `ASYNC`, etc.) via `transaction { exec("...") }` +when needed: ```kotlin -import tech.ydb.exposed.dialect.YdbIndexScope -import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbTable +import org.jetbrains.exposed.v1.core.Table import tech.ydb.exposed.dialect.ydbDecimal -object Products : YdbTable("products") { +object Products : Table("products") { val id = integer("id") val sku = varchar("sku", 64) val name = varchar("name", 255) @@ -67,15 +64,7 @@ object Products : YdbTable("products") { init { index(isUnique = false, sku) - - secondaryIndex( - name = "products_category_idx", - category, - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.ASYNC, - coverColumns = listOf(name, price) - ) + index("products_category_idx", isUnique = false, category) } } ``` @@ -170,22 +159,22 @@ They bind via YDB JDBC vendor type codes. Standard Exposed `date()`, `ubyte()`, 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` -(including `YdbTable`); JDBC vendor code drives both bind and DDL `sqlType()`: +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 : YdbTable("events") { +object Events : Table("events") { val created = ydbDate("created") // Date val expires = ydbDate32("expires") // Date32 val updated = ydbDatetime64("updated") // Datetime64 } ``` -Optional: `connectYdb(..., enableSignedDatetimes = true)` switches **dialect** DDL names for +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`). @@ -214,10 +203,10 @@ it.update(Products.price, ydbDecimalLiteral(BigDecimal("45.00"), 10, 2)) ## Identifiers -On `YdbTable`, Exposed `autoIncrement()` maps to YDB `Serial` / `BigSerial`: +Exposed `autoIncrement()` maps to YDB `Serial` / `BigSerial`: ```kotlin -object Orders : YdbTable("orders") { +object Orders : Table("orders") { val id = integer("id").autoIncrement() val total = ydbDecimal("total", precision = 12, scale = 2) override val primaryKey = PrimaryKey(id) @@ -229,12 +218,8 @@ columns are not supported. ## Indexes -Two ways to declare secondary indexes: - -| Mechanism | SQL shape | Features | -|-----------|-----------|----------| -| `Table.index()` / `index(isUnique = …)` | `ALTER TABLE … ADD INDEX … GLOBAL` | Unique flag; no inline `ASYNC` / `COVER` / `WITH` | -| `YdbTable.secondaryIndex()` | Inline in `CREATE TABLE` | `ASYNC`, `COVER`, `WITH`, unique | +Exposed `Table.index()` / `index(customName, isUnique, …)` is rendered as +`ALTER TABLE … ADD INDEX … GLOBAL [UNIQUE] ON (…)`. ## Known limitations @@ -244,27 +229,10 @@ that YDB does not support. This module overrides indexes, UPSERT/REPLACE, LIMIT/ functions, and YDB type names — not the entire DDL surface. - No ANSI `MERGE`; use `UPSERT` / `REPLACE`. -- TTL is emitted on `CREATE TABLE` only (no `ALTER TABLE … SET (TTL)` DSL). +- No DSL for TTL or inline `CREATE TABLE` indexes (`COVER`, `ASYNC`, `WITH`) — use raw YQL in `exec`. - No Yson / timezone-aware temporal types in this module. - Functional secondary indexes are rejected. -## TTL - -```kotlin -object Sessions : YdbTable("sessions") { - val id = integer("id") - val expireAt = timestamp("expire_at") - override val primaryKey = PrimaryKey(id) - - init { - ttl(expireAt, "PT1H") - } -} -``` - -Numeric epoch columns are also supported via `YdbTtlColumnMode.SECONDS` / -`MILLISECONDS` / `MICROSECONDS` / `NANOSECONDS`. - ## Tests Integration tests use [testcontainers](https://www.testcontainers.org/) via 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 index e3831be2..af24d982 100644 --- 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 @@ -6,14 +6,23 @@ 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 tech.ydb.exposed.dialect.connectYdb +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() { - val db = connectYdb( - url = "jdbc:ydb:grpc://localhost:2136/local" + 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) { 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 index e9ede561..79bf9e63 100644 --- 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 @@ -1,11 +1,9 @@ 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 org.jetbrains.exposed.v1.core.Table import tech.ydb.exposed.dialect.ydbDecimal -object DemoProducts : YdbTable("demo_products") { +object DemoProducts : Table("demo_products") { val id = integer("id") val sku = varchar("sku", 64) val name = varchar("name", 255) @@ -16,14 +14,6 @@ object DemoProducts : YdbTable("demo_products") { init { index(false, sku) - - secondaryIndex( - name = "demo_products_category_idx", - category, - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.ASYNC, - coverColumns = listOf(name, price) - ) + index("demo_products_category_idx", isUnique = false, category) } } 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 index e1830eed..dc0c3a18 100644 --- 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 @@ -109,10 +109,7 @@ internal fun bindYdbParameter( } val jdbcStatement = (stmt as? JdbcPreparedStatementImpl)?.statement - ?: error( - "YDB column bind requires JdbcPreparedStatementImpl (got ${stmt::class.qualifiedName}); " + - "use ydb* / javatime.* column types with the YDB JDBC driver" - ) + ?: error("YDB column bind requires JdbcPreparedStatementImpl (got ${stmt::class.qualifiedName});") jdbcStatement.setObject(index, value, targetSqlType) } 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 index 6084243d..cc50d5eb 100644 --- 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 @@ -18,6 +18,7 @@ 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 @@ -28,12 +29,6 @@ import java.sql.DatabaseMetaData * [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. */ -/** - * Maps generic Exposed column DSL types to YQL type names for DDL. - * - * @param enableSignedDatetimes When `true`, [dateType]/[dateTimeType]/[timestampType] use signed - * `Date32`/`Datetime64`/`Timestamp64`. Does not affect explicit `ydb*` / `javatime.*` columns. - */ internal class YdbDataTypeProvider( private val enableSignedDatetimes: Boolean = false ) : DataTypeProvider() { @@ -88,11 +83,14 @@ internal class YdbDataTypeProvider( internal object YdbFunctionProvider : FunctionProvider() { private const val MERGE_UNSUPPORTED = - "YDB dialect does not support ANSI MERGE through Exposed. Use UPSERT or batchUpsert instead." + "YDB does not support ANSI MERGE. Use upsert { } / replace { }, UPSERT INTO … , or UPDATE … ON — " + + "see https://ydb.tech/docs/en/yql/reference/syntax/upsert_into and …/update" private const val JSON_CONTAINS_UNSUPPORTED = "YDB does not support JSON_CONTAINS. Use JSON_EXISTS or compare JSON_VALUE / JSON_QUERY instead." + private const val INSERT_VALUE_CLASS = "org.jetbrains.exposed.v1.core.statements.InsertValue" + /** * Maps Exposed [org.jetbrains.exposed.v1.core.Random] to YQL [Random](https://ydb.tech/docs/en/yql/reference/builtins/basic). * @@ -131,7 +129,7 @@ internal object YdbFunctionProvider : FunctionProvider() { } else { append("Unicode::JoinFromList(AsList(") expr.appendTo { append("CAST(", it, " AS Utf8)") } - append("), '", escapeYqlStringLiteral(separator), "')") + append("), '", escapeStringLiteral(separator), "')") } } } @@ -145,7 +143,7 @@ internal object YdbFunctionProvider : FunctionProvider() { expr: Expression, substring: String ) = queryBuilder { - val needle = escapeYqlStringLiteral(substring) + val needle = escapeStringLiteral(substring) append("COALESCE(CAST(Unicode::Find(", expr, ", '", needle, "') + 1u AS Int32), 0)") } @@ -175,9 +173,9 @@ internal object YdbFunctionProvider : FunctionProvider() { jsonType: IColumnType<*>, queryBuilder: QueryBuilder ) = queryBuilder { - val jsonPath = buildYdbJsonPath(*path) + val jsonPath = buildJsonPath(*path) append(if (toScalar) "JSON_VALUE" else "JSON_QUERY") - append("(", expression, ", '", escapeYqlStringLiteral(jsonPath), "')") + append("(", expression, ", '", escapeStringLiteral(jsonPath), "')") } override fun jsonContains( @@ -197,17 +195,18 @@ internal object YdbFunctionProvider : FunctionProvider() { jsonType: IColumnType<*>, queryBuilder: QueryBuilder ) = queryBuilder { - val jsonPath = buildYdbJsonPath(*path) - append("JSON_EXISTS(", expression, ", '", escapeYqlStringLiteral(jsonPath), "'") + val jsonPath = buildJsonPath(*path) + append("JSON_EXISTS(", expression, ", '", escapeStringLiteral(jsonPath), "'") optional?.let { append(" ", it) } append(")") } /** - * Native YDB `UPSERT` — full row replace by primary key. + * Native YDB [UPSERT](https://ydb.tech/docs/en/yql/reference/syntax/upsert_into): only columns listed in + * the statement are written; on primary-key conflict, other columns stay unchanged. * - * Exposed's `onUpdate` / `keyColumns` / MySQL-style partial upsert are **not** used: - * every column in [data] is written; there is no `ON DUPLICATE KEY UPDATE` clause in YQL. + * Exposed `onUpdate` with `insertValue()` (default) maps to the same VALUES. Different insert vs update + * literals and `onUpdateExclude` are rejected. */ override fun upsert( table: Table, @@ -217,11 +216,54 @@ internal object YdbFunctionProvider : FunctionProvider() { keyColumns: List>, where: Op?, transaction: Transaction - ): String = renderUpsertOrReplace("UPSERT", table, data, expression, where, transaction) + ): String { + require(data.isNotEmpty()) { "UPSERT requires at least one column" } + if (keyColumns.isEmpty()) { + throw UnsupportedOperationException( + "YDB UPSERT requires a primary key (or explicit upsert keys); table ${table.tableName} has none" + ) + } + + validateUpsertOnUpdate(data, onUpdate, keyColumns, transaction) + + 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 }.distinct() + val columnList = columns.joinToString(", ") { transaction.identity(it) } + val dataByColumn = data.toMap() + val tableName = transaction.identity(table) + + if (expression.isNotBlank()) { + val valuesExpression = expression.trim() + val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { + "($columnList) $valuesExpression" + } else { + valuesExpression + } + return "UPSERT INTO $tableName $expressionWithColumns" + } + + val valueList = columns.joinToString(", ") { column -> + val value = dataByColumn[column] + if (value == null) { + "NULL" + } else { + @Suppress("UNCHECKED_CAST") + (column.columnType as IColumnType).valueToString(value) + } + } + + return "UPSERT INTO $tableName ($columnList) VALUES ($valueList)" + } /** - * YDB has native `REPLACE INTO` which has the same write semantics as INSERT-or-overwrite - * (key is the primary key, no need for an extra unique constraint). + * 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, @@ -230,6 +272,7 @@ internal object YdbFunctionProvider : FunctionProvider() { transaction: Transaction, prepared: Boolean ): String { + require(columns.isNotEmpty()) { "REPLACE requires at least one column" } val columnList = columns.joinToString(", ") { transaction.identity(it) } val valuesExpression = expression.trim() val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { @@ -272,55 +315,86 @@ internal object YdbFunctionProvider : FunctionProvider() { } } - private fun renderUpsertOrReplace( - operation: String, - table: Table, + private fun validateUpsertOnUpdate( data: List, Any?>>, - expression: String, - where: Op?, + onUpdate: List, Any?>>, + keyColumns: List>, transaction: Transaction - ): String { - require(where == null) { - "YDB $operation does not support WHERE clause" - } + ) { + if (onUpdate.isEmpty()) return - val columnList = data.joinToString(", ") { (column, _) -> - transaction.identity(column) + val dataByColumn = data.toMap() + val keySet = keyColumns.toSet() + + val conflicting = onUpdate.filter { (column, value) -> + !isInsertValueExpression(value) && dataByColumn[column] != value + } + if (conflicting.isNotEmpty()) { + val names = conflicting.joinToString { transaction.identity(it.first) } + throw UnsupportedOperationException( + "YDB UPSERT applies the same VALUES on insert and on conflict; onUpdate cannot set different " + + "values ($names). Use Table.update { } after upsert, or REPLACE for a full-row overwrite " + + "(https://ydb.tech/docs/en/yql/reference/syntax/replace_into)." + ) } - if (expression.isNotBlank()) { - val valuesExpression = expression.trim() - val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { - "($columnList) $valuesExpression" - } else { - valuesExpression - } - return "$operation INTO ${transaction.identity(table)} $expressionWithColumns" + val dataCols = data.map { it.first }.toSet() + val updateCols = onUpdate.map { it.first }.toSet() + val insertOnly = dataCols - updateCols - keySet + if (insertOnly.isNotEmpty() && onUpdate.any { !isInsertValueExpression(it.second) }) { + val names = insertOnly.joinToString { transaction.identity(it) } + throw UnsupportedOperationException( + "YDB UPSERT cannot insert column(s) $names while excluding them from the conflict update " + + "(Exposed onUpdateExclude). Omit those columns from the upsert body or include them in REPLACE." + ) } + } + + private fun isInsertValueExpression(value: Any?): Boolean = + value != null && value.javaClass.name == INSERT_VALUE_CLASS - val valueList = data.joinToString(", ") { (column, value) -> - valueToSqlLiteral(column, 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 } - return "$operation INTO ${transaction.identity(table)} ($columnList) VALUES ($valueList)" + 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() } - @Suppress("UNCHECKED_CAST") - private fun valueToSqlLiteral(column: Column<*>, value: Any?): String { - if (value == null) return "NULL" + private fun quoteJsonPathKey(key: String): String = + if (key.all { it.isLetterOrDigit() || it == '_' }) { + key + } else { + "\"${key.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + } - val columnType = column.columnType as IColumnType - return columnType.valueToString(value) - } + private fun escapeStringLiteral(value: String): String = value.replace("'", "''") } /** * Exposed [VendorDialect] for YDB. * - * Obtained via [connectYdb] (recommended) or [registerYdbDialect] + `Database.connect`. + * Use [registerYdbDialect] then `Database.connect("jdbc:ydb:...")`. * * Notable behavior: - * - [upsert] → YQL `UPSERT` (full row replace by PK; Exposed `onUpdate` / partial columns ignored). + * - [upsert] / [replace] → native YQL (partial columns vs defaults). * - [createIndex] → `ALTER TABLE ... ADD INDEX ... GLOBAL`. * - Functional indexes and ANSI `MERGE` are rejected. * @@ -335,7 +409,7 @@ class YdbDialect internal constructor( ) { /** * Post-create index: `ALTER TABLE t ADD INDEX i GLOBAL [UNIQUE] ON (cols)`. - * Prefer [YdbTable.secondaryIndex] for indexes declared with the table. + * 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() @@ -373,69 +447,69 @@ class YdbDialect internal constructor( isPartialOrFunctional: Boolean ): String = "ALTER TABLE $tableName DROP INDEX $indexName" - /** JDBC metadata bridge so Exposed can diff existing GLOBAL indexes on YDB. */ - internal object Metadata : DatabaseDialectMetadata() { + internal companion object { + const val DIALECT_NAME = "ydb" + } +} - override fun existingIndices(vararg tables: Table): Map> { - val connection = TransactionManager.current().connection.connection as Connection - val metadata = connection.metaData +/** JDBC metadata bridge so Exposed can read existing GLOBAL indexes on YDB. */ +internal object YdbDialectMetadata : DatabaseDialectMetadata() { - return tables.associateWith { table -> - readIndices(metadata, table) - } + 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>() + 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 + 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 column = table.columns.firstOrNull { it.name.equals(columnName, ignoreCase = true) } + ?: continue - val ordinal = rs.getShort("ORDINAL_POSITION").toInt() - val unique = !rs.getBoolean("NON_UNIQUE") + val ordinal = rs.getShort("ORDINAL_POSITION").toInt() + val unique = !rs.getBoolean("NON_UNIQUE") - indexColumns - .getOrPut(indexName) { mutableListOf() } - .add(IndexedColumn(column, ordinal, 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 - ) - } + 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 - ) } - internal companion object { - const val DIALECT_NAME = "ydb" - } + 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 index 94c0cafa..08d1713f 100644 --- 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 @@ -1,43 +1,25 @@ /** - * Entry points for wiring YDB into Exposed: driver registration, [connectYdb], and JDBC URL helpers. + * Registers the YDB JDBC driver and Exposed [YdbDialect]. * - * Typical setup: - * 1. [registerYdbDialect] once (or let [connectYdb] do it). - * 2. [connectYdb] with a `jdbc:ydb:...` URL and optional [enableSignedDatetimes]. - * 3. Declare tables as [YdbTable] and use [ydbTransaction] for DML under YDB OCC. + * 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.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database -import java.sql.Connection -import java.util.concurrent.atomic.AtomicBoolean -/** JDBC URL prefix registered with Exposed ([registerYdbDialect]). */ internal const val YDB_JDBC_URL_PREFIX = "jdbc:ydb:" -/** YDB JDBC driver class ([tech.ydb.jdbc.YdbDriver]). */ internal const val YDB_DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" /** - * Query parameter for the YDB JDBC driver: signed vs legacy temporal wire format. - * Not set automatically by [connectYdb] — add `forceSignedDatetimes=true|false` to the URL explicitly. - */ -internal const val FORCE_SIGNED_DATETIMES_PROPERTY = "forceSignedDatetimes" - -private val dialectRegistered = AtomicBoolean(false) - -/** - * Registers the YDB JDBC driver and Exposed dialect (idempotent). + * Registers the YDB JDBC driver and Exposed dialect. * - * After registration, `Database.connect("jdbc:ydb:...")` resolves [YdbDialect] automatically. + * @param enableSignedDatetimes When `true`, standard Exposed `date` / `datetime` / `timestamp` DDL + * uses `Date32` / `Datetime64` / `Timestamp64`. Per-column types stay explicit via `javatime.*`. */ -fun registerYdbDialect() { - if (!dialectRegistered.compareAndSet(false, true)) { - return - } - +fun registerYdbDialect(enableSignedDatetimes: Boolean = false) { Database.registerJdbcDriver( prefix = YDB_JDBC_URL_PREFIX, driverClassName = YDB_DRIVER_CLASS, @@ -45,84 +27,10 @@ fun registerYdbDialect() { ) Database.registerDialectMetadata(YdbDialect.DIALECT_NAME) { - YdbDialect.Metadata + YdbDialectMetadata } DatabaseApi.registerDialect(YdbDialect.DIALECT_NAME) { - YdbDialect() - } -} - -/** - * Opens a YDB-backed Exposed [Database] with dialect defaults tuned for YDB. - * - * @param url JDBC URL (`jdbc:ydb:grpc://...` or `grpcs://...`). Append `forceSignedDatetimes=true` - * or `false` when the driver requires it (not added automatically). - * @param enableSignedDatetimes When `true`, [YdbDialect] maps standard Exposed `date` / `datetime` / - * `timestamp` DDL names to `Date32` / `Datetime64` / `Timestamp64`. Per-column types remain - * explicit via [tech.ydb.exposed.dialect.javatime.ydbDate] vs [ydbDate32], etc. - * - * Configures `SERIALIZABLE` isolation and disables nested transactions (YDB snapshot semantics). - */ -fun connectYdb( - url: String, - user: String = "", - password: String = "", - enableSignedDatetimes: Boolean = false -): Database { - ensureYdbDialectRegistered() - - return Database.connect( - url = url, - driver = YDB_DRIVER_CLASS, - user = user, - password = password, - databaseConfig = ydbDatabaseConfig(enableSignedDatetimes = enableSignedDatetimes) - ) -} - -/** [DatabaseConfig] used by [connectYdb] with an explicit [YdbDialect] instance. */ -internal fun ydbDatabaseConfig(enableSignedDatetimes: Boolean = false): DatabaseConfig = DatabaseConfig { - explicitDialect = YdbDialect(enableSignedDatetimes = enableSignedDatetimes) - defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE - defaultReadOnly = false - useNestedTransactions = false -} - -/** - * Appends `forceSignedDatetimes=false` to [url] (replacing an existing value). - * Used by tests and [RegisterYdbDialectConnectIT]; [connectYdb] does not modify the URL. - */ -internal fun ydbJdbcUrl(url: String): String = - appendBooleanQueryParameter( - url = url, - name = FORCE_SIGNED_DATETIMES_PROPERTY, - value = false - ) - -private fun appendBooleanQueryParameter(url: String, name: String, value: Boolean): String { - val queryStart = url.indexOf('?') - if (queryStart < 0) { - return "$url?$name=$value" - } - - val base = url.substring(0, queryStart) - val query = url.substring(queryStart + 1) - val filteredParams = query - .split('&') - .filter { it.isNotBlank() } - .filterNot { param -> - param.substringBefore('=') == name - } - .toMutableList() - - filteredParams += "$name=$value" - return "$base?${filteredParams.joinToString("&")}" -} - -private fun ensureYdbDialectRegistered() { - if (dialectRegistered.get()) { - return + YdbDialect(enableSignedDatetimes) } - registerYdbDialect() } diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt deleted file mode 100644 index 5c3258d9..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt +++ /dev/null @@ -1,104 +0,0 @@ -/** - * YDB secondary index declarations for [YdbTable.secondaryIndex]. - * - * Rendered into `CREATE TABLE` as `INDEX name GLOBAL [UNIQUE] [ASYNC] ON (...) [COVER (...)] [WITH (...)]`. - */ -package tech.ydb.exposed.dialect - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager - -/** Index visibility scope in YQL (`GLOBAL` for row-oriented tables). */ -enum class YdbIndexScope { - GLOBAL -} - -/** Whether the index is built synchronously with writes or in the background. */ -enum class YdbIndexSyncMode { - SYNC, - ASYNC -} - -/** Internal model produced by [YdbTable.secondaryIndex] and rendered by [renderYdbSecondaryIndex]. */ -data class YdbSecondaryIndexSpec( - val name: String, - val columns: List>, - val unique: Boolean = false, - val scope: YdbIndexScope = YdbIndexScope.GLOBAL, - val syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, - val indexType: String? = null, - val coverColumns: List> = emptyList(), - val withParams: Map = emptyMap() -) - -/** Builds the `INDEX ...` fragment for a single secondary index inside `CREATE TABLE`. */ -internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { - val tr = TransactionManager.current() - - require(spec.columns.isNotEmpty()) { - "YDB secondary index must contain at least one column" - } - - require(spec.name.isNotBlank()) { - "YDB secondary index name must not be blank" - } - - require(spec.scope == YdbIndexScope.GLOBAL) { - "Only GLOBAL secondary indexes are supported by YDB row-oriented tables in this dialect" - } - - val indexName = tr.db.identifierManager.cutIfNecessaryAndQuote(spec.name) - - val columnsSql = spec.columns.joinToString(", ") { tr.identity(it) } - val coverSql = spec.coverColumns - .takeIf { it.isNotEmpty() } - ?.joinToString(", ") { tr.identity(it) } - - val withSql = spec.withParams - .takeIf { it.isNotEmpty() } - ?.entries - ?.joinToString(", ") { (k, v) -> "$k = ${renderYdbIndexParamValue(v)}" } - - return buildString { - append("INDEX ") - append(indexName) - append(" ") - append(spec.scope.name) - - if (spec.unique) { - append(" UNIQUE") - } - - if (spec.syncMode != YdbIndexSyncMode.SYNC) { - append(" ") - append(spec.syncMode.name) - } - - if (spec.indexType != null) { - append(" USING ") - append(spec.indexType) - } - - append(" ON (") - append(columnsSql) - append(")") - - if (coverSql != null) { - append(" COVER (") - append(coverSql) - append(")") - } - - if (withSql != null) { - append(" WITH (") - append(withSql) - append(")") - } - } -} - -private fun renderYdbIndexParamValue(value: Any): String = when (value) { - is Number -> value.toString() - is Boolean -> value.toString().uppercase() - else -> "\"${value.toString().replace("\"", "\\\"")}\"" -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt deleted file mode 100644 index cbb01ddd..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSqlFunctions.kt +++ /dev/null @@ -1,45 +0,0 @@ -package tech.ydb.exposed.dialect - -/** - * Builds a [JsonPath](https://ydb.tech/docs/en/yql/reference/builtins/json) query for YDB - * `JSON_VALUE` / `JSON_QUERY` / `JSON_EXISTS` from Exposed path segments. - * - * Segments are usually object keys; all-digit segments become array indexes (`[0]`). - * A single segment that already starts with `$` is returned as-is. - */ -internal fun buildYdbJsonPath(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("\"", "\\\"")}\"" - } - -internal fun escapeYqlStringLiteral(value: String): String = - value.replace("'", "''") - -internal fun escapeYqlDoubleQuotedLiteral(value: String): String = - value.replace("\\", "\\\\").replace("\"", "\\\"") diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt deleted file mode 100644 index 030853a5..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt +++ /dev/null @@ -1,73 +0,0 @@ -package tech.ydb.exposed.dialect - -import org.jetbrains.exposed.v1.core.Column -import org.jetbrains.exposed.v1.core.Table - -/** - * Base class for YDB row-oriented tables. - * - * Adds YDB-specific DDL on top of Exposed [Table]: - * - [ttl] — TTL on a date/numeric column; - * - [secondaryIndex] — YDB secondary index with COVER / ASYNC / WITH params. - */ -open class YdbTable(name: String = "") : Table(name) { - - private var ttlSettingsState: YdbTtlSettings? = null - private val secondaryIndices = mutableListOf() - - /** - * Declares row TTL on [column] (embedded in `CREATE TABLE ... WITH (TTL = ...)`). - * - * @param intervalIso8601 ISO-8601 duration (e.g. `P30D`, `PT1H`); normalized via [normalizeTtlInterval]. - * @param mode How [column] is interpreted — date/timestamp types vs numeric epoch units. - */ - fun ttl( - column: Column<*>, - intervalIso8601: String, - mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE - ) { - ttlSettingsState = YdbTtlSettings(column, normalizeTtlInterval(intervalIso8601), mode) - } - - /** - * Declares a YDB secondary index inline in `CREATE TABLE` (not Exposed's generic [Index] DSL). - * - * @param scope Currently only [YdbIndexScope.GLOBAL] is supported for row tables. - * @param syncMode [YdbIndexSyncMode.ASYNC] for background index build. - * @param indexType Optional `USING` clause (e.g. vector index type when supported by YDB). - * @param coverColumns Included columns for covering index (`COVER (...)`). - * @param withParams Index-level `WITH (key = value)` parameters. - */ - fun secondaryIndex( - name: String, - vararg columns: Column<*>, - unique: Boolean = false, - scope: YdbIndexScope = YdbIndexScope.GLOBAL, - syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, - indexType: String? = null, - coverColumns: List> = emptyList(), - withParams: Map = emptyMap() - ) { - require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } - - secondaryIndices += YdbSecondaryIndexSpec( - name = name, - columns = columns.toList(), - unique = unique, - scope = scope, - syncMode = syncMode, - indexType = indexType, - coverColumns = coverColumns, - withParams = withParams - ) - } - - internal val ttlSettings: YdbTtlSettings? - get() = ttlSettingsState - - private val ydbSecondaryIndices: List - get() = secondaryIndices - - override fun createStatement(): List = - buildYdbCreateStatement(this, ttlSettings, ydbSecondaryIndices) -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt deleted file mode 100644 index 01a6b9b4..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTableSupport.kt +++ /dev/null @@ -1,103 +0,0 @@ -/** - * DDL helpers for [YdbTable]: single-statement `CREATE TABLE` with PK, secondary indexes, and TTL. - */ -package tech.ydb.exposed.dialect - -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager -import java.time.Duration - -/** One `CREATE TABLE IF NOT EXISTS ...` including inline indexes and optional TTL clause. */ -internal fun buildYdbCreateStatement( - table: Table, - ttlSettings: YdbTtlSettings?, - secondaryIndices: List -): List { - val tr = TransactionManager.current() - - val pk = table.primaryKey - ?: error("YDB requires PRIMARY KEY for every table: ${table.tableName}") - - val columnsSql = table.columns.joinToString(", ") { column -> - buildString { - append(tr.identity(column)) - append(" ") - append(column.columnType.sqlType()) - - if (!column.columnType.nullable) { - append(" NOT NULL") - } - } - } - - val indexesSql = secondaryIndices.joinToString(", ") { renderYdbSecondaryIndex(it) } - val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } - - val ttlSql = ttlSettings?.let { ttl -> - validateYdbTtlColumn(ttl) - val normalizedInterval = normalizeTtlInterval(ttl.intervalIso8601) - - buildString { - append(" WITH (TTL = Interval(\"") - append(escapeYqlDoubleQuotedLiteral(normalizedInterval)) - append("\") ON ") - append(tr.identity(ttl.column)) - ttl.mode.toSql()?.let { - append(" AS ") - append(it) - } - append(")") - } - }.orEmpty() - - val sql = buildString { - append("CREATE TABLE IF NOT EXISTS ") - append(tr.identity(table)) - append(" (") - append(columnsSql) - - if (indexesSql.isNotEmpty()) { - append(", ") - append(indexesSql) - } - - append(", PRIMARY KEY (") - append(pkSql) - append("))") - append(ttlSql) - } - - return listOf(sql) -} - -/** Parses [intervalIso8601] as [Duration] and returns the canonical string for YQL `Interval("...")`. */ -internal fun normalizeTtlInterval(intervalIso8601: String): String = - runCatching { Duration.parse(intervalIso8601).toString() } - .getOrElse { cause -> - throw IllegalArgumentException("Invalid YDB TTL interval: '$intervalIso8601'", cause) - } - -/** Ensures [YdbTtlSettings.column] SQL type is compatible with [YdbTtlSettings.mode]. */ -internal fun validateYdbTtlColumn(ttl: YdbTtlSettings) { - val sqlType = ttl.column.columnType.sqlType() - - val supported = when (ttl.mode) { - YdbTtlColumnMode.DATE_TYPE -> - sqlType == "Date" || - sqlType == "Date32" || - sqlType == "Datetime" || - sqlType == "Datetime64" || - sqlType == "Timestamp" || - sqlType == "Timestamp64" - - YdbTtlColumnMode.SECONDS, - YdbTtlColumnMode.MILLISECONDS, - YdbTtlColumnMode.MICROSECONDS, - YdbTtlColumnMode.NANOSECONDS -> - sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" - } - - require(supported) { - "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" - } -} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt deleted file mode 100644 index 5698984b..00000000 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt +++ /dev/null @@ -1,34 +0,0 @@ -/** - * TTL settings for [YdbTable.ttl] — see YDB docs on `WITH (TTL = Interval(...) ON ...)`. - */ -package tech.ydb.exposed.dialect - -import org.jetbrains.exposed.v1.core.Column - -/** How the TTL source [Column] value is interpreted in YQL. */ -enum class YdbTtlColumnMode { - /** `Date` / `Datetime` / `Timestamp` (and `*32`/`*64` variants) columns. */ - DATE_TYPE, - /** `Uint32` / `Uint64` / `DyNumber` interpreted as Unix seconds. */ - SECONDS, - MILLISECONDS, - MICROSECONDS, - NANOSECONDS; - - fun toSql(): String? = when (this) { - DATE_TYPE -> null - SECONDS -> "SECONDS" - MILLISECONDS -> "MILLISECONDS" - MICROSECONDS -> "MICROSECONDS" - NANOSECONDS -> "NANOSECONDS" - } -} - -/** Resolved TTL clause for [YdbTable] DDL generation. */ -data class YdbTtlSettings( - /** Column whose value drives expiration. */ - val column: Column<*>, - /** ISO-8601 duration string passed to `Interval("...")`. */ - val intervalIso8601: String, - val mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE -) \ 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 index 7d6bf7bf..26dbbfad 100644 --- 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 @@ -7,9 +7,9 @@ * - Legacy unsigned: [ydbDate], [ydbDatetime], [ydbTimestamp] + `forceSignedDatetimes=false` (default). * - Extended signed: [ydbDate32], [ydbDatetime64], [ydbTimestamp64] + `forceSignedDatetimes=true`. * - * [connectYdb] 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.). + * [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) 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 index cab95451..2b5ed52e 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -9,9 +10,11 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.extension.RegisterExtension -import tech.ydb.exposed.dialect.connectYdb +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. @@ -26,17 +29,33 @@ abstract class BaseYdbTest { 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 = connectYdb(url = jdbcUrl) + db = Database.connect( + url = jdbcUrl, + driver = YDB_DRIVER_CLASS, + databaseConfig = DatabaseConfig { + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + useNestedTransactions = false + } + ) if (tables.isNotEmpty()) { transaction(db) { 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 index e1ded574..47be2f54 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -15,20 +16,18 @@ 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.YdbTable import tech.ydb.exposed.dialect.registerYdbDialect -import tech.ydb.exposed.dialect.ydbJdbcUrl import tech.ydb.exposed.dialect.ydbTransaction import tech.ydb.test.junit5.YdbHelperExtension import java.sql.Connection /** - * Verifies that after [registerYdbDialect] plain Exposed [Database.connect] and [YdbTable] - * work without [tech.ydb.exposed.dialect.connectYdb]. + * Verifies that after [registerYdbDialect] plain Exposed [Database.connect] works + * with the same [DatabaseConfig] defaults as integration tests. */ class RegisterYdbDialectConnectIT { - object PlainConnectTable : YdbTable("register_ydb_dialect_plain_connect") { + object PlainConnectTable : Table("register_ydb_dialect_plain_connect") { val id = integer("id") val label = varchar("label", 64) @@ -52,7 +51,7 @@ class RegisterYdbDialectConnectIT { } db = Database.connect( - url = ydbJdbcUrl(jdbcUrl), + url = jdbcUrl, driver = YDB_DRIVER_CLASS, databaseConfig = DatabaseConfig { defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE @@ -79,7 +78,7 @@ class RegisterYdbDialectConnectIT { } @Test - fun `YdbTable ddl insert and select work with plain Database connect`() = ydbTransaction(db) { + fun `Table ddl insert and select work with plain Database connect`() = ydbTransaction(db) { SchemaUtils.create(PlainConnectTable) PlainConnectTable.insert { 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 index d5e4bcec..7f838599 100644 --- 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 @@ -3,19 +3,17 @@ 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.YdbIndexScope -import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class YdbIndexSqlIT : BaseYdbTest() { - object IndexedTable : YdbTable("indexed_table") { + object IndexedTable : Table("indexed_table") { val id = integer("id") val email = varchar("email", 255) val name = varchar("name", 255) @@ -24,28 +22,11 @@ class YdbIndexSqlIT : BaseYdbTest() { init { index(false, email) - - secondaryIndex( - name = "email_cover_idx", - email, - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.ASYNC, - coverColumns = listOf(name), - withParams = mapOf("foo" to "bar") - ) - - secondaryIndex( - name = "email-cover-idx", - email, - unique = true, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) + index("email-cover-idx", isUnique = true, email) } val emailIndexDefinition - get() = indices.single { it.columns == listOf(email) } + get() = indices.single { !it.unique && it.columns == listOf(email) } } @Test @@ -90,17 +71,15 @@ class YdbIndexSqlIT : BaseYdbTest() { } @Test - fun `renders YDB-specific inline secondary index`() { + fun `renders unique index with custom name`() { transaction(db) { - val ddl = IndexedTable.ddl.joinToString(" ") - val expectedHyphenatedName = db.identifierManager.cutIfNecessaryAndQuote("email-cover-idx") + 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(ddl.contains("INDEX email_cover_idx"), ddl) - assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) - assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) - assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) - assertTrue(ddl.contains("WITH (foo = \"bar\")"), ddl) - assertTrue(ddl.contains("INDEX $expectedHyphenatedName GLOBAL UNIQUE"), ddl) + 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 deleted file mode 100644 index 1e7f8306..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt +++ /dev/null @@ -1,118 +0,0 @@ -package tech.ydb.exposed.dialect.integration.basic - -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.YdbTable -import tech.ydb.exposed.dialect.YdbTtlColumnMode -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.ydbUint64 -import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 - -class YdbTableIT : BaseYdbTest() { - - object BasicTable : YdbTable("unit_basic_table") { - val id = integer("id") - val name = varchar("name", 255) - - override val primaryKey = PrimaryKey(id) - } - - object TtlTimestampTable : YdbTable("unit_ttl_timestamp_table") { - val id = integer("id") - val expireAt = ydbTimestamp64("expire_at") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(expireAt, "PT1H") - } - } - - object TtlNumericTable : YdbTable("unit_ttl_numeric_table") { - val id = integer("id") - val modifiedAtEpoch = ydbUint64("modified_at_epoch") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) - } - } - - object NoPkTable : YdbTable("unit_no_pk_table") { - val id = integer("id") - val name = varchar("name", 255) - } - - object InvalidNumericTtlTable : YdbTable("unit_invalid_numeric_ttl_table") { - val id = integer("id") - val modifiedAtEpoch = integer("modified_at_epoch") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) - } - } - - @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 - } - } - - @Test - fun `fails when numeric TTL column type is unsupported`() = tx { - assertThrows(IllegalArgumentException::class.java) { - InvalidNumericTtlTable.ddl - } - } - - @Test - fun `rejects invalid TTL interval early`() { - val error = assertThrows(IllegalArgumentException::class.java) { - object : YdbTable("invalid_ttl_interval_table") { - val id = integer("id") - val expireAt = ydbTimestamp64("expire_at") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(expireAt, """PT1H" ON hacked""") - } - } - } - - assertTrue(error.message?.contains("Invalid YDB TTL interval") == true, error.message) - } -} 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 index 89ad2647..fb9d9dfb 100644 --- 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 @@ -1,17 +1,15 @@ 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.YdbSecondaryIndexSpec -import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.renderYdbSecondaryIndex class YdbUniqueIndexSqlIT : BaseYdbTest() { - object T : YdbTable("t_unique_idx_test") { + object T : Table("t_unique_idx_test") { val id = integer("id") val email = varchar("email", 255) @@ -54,18 +52,4 @@ class YdbUniqueIndexSqlIT : BaseYdbTest() { assertTrue(sql.contains("ADD INDEX"), sql) assertTrue(sql.contains("`select`") || sql.contains("\"select\""), sql) } - - @Test - fun `renders a unique YDB secondary index`() = tx { - val sql = renderYdbSecondaryIndex( - YdbSecondaryIndexSpec( - name = "email_unique_idx", - columns = listOf(T.email), - unique = true - ) - ) - - assertTrue(sql.contains("INDEX email_unique_idx GLOBAL UNIQUE"), sql) - assertTrue(sql.contains("ON (`email`)") || sql.contains("ON (email)"), 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 index 7cab3ba5..91f74e72 100644 --- 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 @@ -9,12 +9,11 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class BatchOperationsIT : BaseYdbTest() { - object BatchItems : YdbTable("batch_items") { + object BatchItems : Table("batch_items") { val id = integer("id") val name = varchar("name", 255) val quantity = integer("quantity") 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 index 00aa8ec5..fbd84edc 100644 --- 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 @@ -8,12 +8,11 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class CrudIT : BaseYdbTest() { - object Users : YdbTable("users") { + object Users : Table("users") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) 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 index fb41e49f..dc7ab061 100644 --- 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 @@ -11,13 +11,9 @@ 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.buildYdbCreateStatement import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -/** - * Exposed [Entity] / [IdTable] work with YDB when table DDL uses YDB-style `PRIMARY KEY (cols)` - * (via [buildYdbCreateStatement]) instead of the default inline `PRIMARY KEY` on the column. - */ +/** Exposed [Entity] / [IdTable] smoke test against YDB. */ class DaoSmokeIT : BaseYdbTest() { object Articles : IdTable("dao_articles") { @@ -26,9 +22,6 @@ class DaoSmokeIT : BaseYdbTest() { val body = text("body") override val primaryKey = PrimaryKey(id) - - override fun createStatement(): List = - buildYdbCreateStatement(this, ttlSettings = null, secondaryIndices = emptyList()) } class Article(id: EntityID) : Entity(id) { 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 index e1d72813..39193db2 100644 --- 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 @@ -6,15 +6,14 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest /** - * YDB `Serial` works with Exposed `autoIncrement()` on [YdbTable] (YDB-style DDL). + * YDB `Serial` works with Exposed `autoIncrement()` on a standard [Table]. */ class SerialDaoIT : BaseYdbTest() { - object Events : YdbTable("serial_dao_events") { + object Events : Table("serial_dao_events") { val id = integer("id").autoIncrement() val name = varchar("name", 255) 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 index d69ce3f9..38046b64 100644 --- 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 @@ -5,14 +5,11 @@ 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.YdbIndexScope -import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class IndexIT : BaseYdbTest() { - object Customers : YdbTable("customers") { + object Customers : Table("customers") { val id = integer("id") val name = varchar("name", 255) val email = varchar("email", 255) @@ -21,15 +18,6 @@ class IndexIT : BaseYdbTest() { init { index(false, email) - - secondaryIndex( - name = "email_name_cover_idx", - email, - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.ASYNC, - coverColumns = listOf(name) - ) } val emailIndexDefinition @@ -49,26 +37,12 @@ class IndexIT : BaseYdbTest() { assertTrue(sql.contains("email"), sql) } - @Test - fun `should generate inline ydb secondary index in create table ddl`() = tx { - val ddl = Customers.ddl.joinToString(" ") - - assertTrue(ddl.contains("INDEX email_name_cover_idx"), ddl) - assertTrue(ddl.contains("GLOBAL ASYNC"), ddl) - assertTrue(ddl.contains("ON (`email`)") || ddl.contains("ON (email)"), ddl) - assertTrue(ddl.contains("COVER (`name`)") || ddl.contains("COVER (name)"), ddl) - assertTrue(ddl.contains("PRIMARY KEY"), ddl) - } - @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 }) - assertTrue("email_name_cover_idx" in byName.keys, indexes.joinToString { it.indexName }) - assertEquals(listOf(Customers.email), byName.getValue("customers_email").columns) - assertEquals(listOf(Customers.email), byName.getValue("email_name_cover_idx").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 index 1b3657c8..dfa4ca09 100644 --- 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 @@ -8,14 +8,11 @@ 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.YdbIndexScope -import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UniqueIndexIT : BaseYdbTest() { - object UniqueCustomers : YdbTable("unique_customers") { + object UniqueCustomers : Table("unique_customers") { val id = integer("id") val email = varchar("email", 255) val name = varchar("name", 255) @@ -23,20 +20,14 @@ class UniqueIndexIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) init { - secondaryIndex( - name = "unique_email_idx", - email, - unique = true, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.SYNC - ) + index("unique_email_idx", isUnique = true, email) } } override val tables: List
= listOf(UniqueCustomers) @Test - fun `should reject duplicate value for unique secondary index`() { + fun `should reject duplicate value for unique index`() { tx { UniqueCustomers.insert { it[id] = 1 @@ -58,8 +49,8 @@ class UniqueIndexIT : BaseYdbTest() { val message = error.message.orEmpty() assertTrue( message.contains("PRECONDITION_FAILED") || - message.contains("duplicate", ignoreCase = true) || - message.contains("unique", ignoreCase = true), + message.contains("duplicate", ignoreCase = true) || + message.contains("unique", ignoreCase = true), message ) 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 index 41a4278b..1cf4e115 100644 --- 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 @@ -72,14 +72,14 @@ class FunctionProviderIT : BaseYdbTest() { @Test fun `should reject WHERE in UPSERT`() { - assertThrows(IllegalArgumentException::class.java) { + assertThrows(UnsupportedOperationException::class.java) { tx { provider.upsert( table = Users, data = listOf(Users.id to 1, Users.name to "Alice"), expression = "", onUpdate = emptyList(), - keyColumns = emptyList(), + keyColumns = listOf(Users.id), where = Users.id eq 1, transaction = this ) 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 index 4014f41c..2bde1510 100644 --- 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 @@ -16,7 +16,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbJson @@ -30,14 +29,14 @@ import tech.ydb.exposed.dialect.ydbJson */ class YdbSqlFunctionsIT : BaseYdbTest() { - object Strings : YdbTable("fn_strings") { + object Strings : Table("fn_strings") { val id = integer("id") val value = text("value") override val primaryKey = PrimaryKey(id) } - object JsonRows : YdbTable("fn_json") { + object JsonRows : Table("fn_json") { val id = integer("id") val payload = ydbJson("payload") 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 index 7db7a50d..0825e27f 100644 --- 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 @@ -8,18 +8,17 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class JoinIT : BaseYdbTest() { - object Authors : YdbTable("authors") { + object Authors : Table("authors") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - object Books : YdbTable("books") { + object Books : Table("books") { val id = integer("id") val title = varchar("title", 255) val authorId = integer("author_id") 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 index 9b7e77cf..8122bc8b 100644 --- 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 @@ -8,24 +8,23 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class ManyToManyIT : BaseYdbTest() { - object Students : YdbTable("students") { + object Students : Table("students") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - object Courses : YdbTable("courses") { + object Courses : Table("courses") { val id = integer("id") val title = varchar("title", 255) override val primaryKey = PrimaryKey(id) } - object StudentCourses : YdbTable("student_courses") { + object StudentCourses : Table("student_courses") { val id = integer("id") val studentId = integer("student_id") val courseId = integer("course_id") 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 index c66239fe..b9856e21 100644 --- 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 @@ -8,12 +8,11 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class SubqueryIT : BaseYdbTest() { - object Sales : YdbTable("sales") { + object Sales : Table("sales") { val id = integer("id") val customer = varchar("customer", 255) val amount = integer("amount") 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 index 5fb0ae4f..51df914f 100644 --- 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 @@ -8,19 +8,18 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UniversityScenarioIT : BaseYdbTest() { - object Departments : YdbTable("departments") { + object Departments : Table("departments") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - object Students : YdbTable("students") { + object Students : Table("students") { val id = integer("id") val name = varchar("name", 255) val departmentId = integer("department_id") @@ -28,7 +27,7 @@ class UniversityScenarioIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - object Courses : YdbTable("courses") { + object Courses : Table("courses") { val id = integer("id") val name = varchar("name", 255) val departmentId = integer("department_id") @@ -36,7 +35,7 @@ class UniversityScenarioIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - object Enrollments : YdbTable("enrollments") { + object Enrollments : Table("enrollments") { val id = integer("id") val studentId = integer("student_id") val courseId = integer("course_id") 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 index 932e7930..557c65ef 100644 --- 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 @@ -7,7 +7,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.code.YdbVendorCode import tech.ydb.exposed.dialect.ydbTransaction @@ -16,7 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger class YdbRetryingTransactionsIT : BaseYdbTest() { - object RetryItems : YdbTable("retry_items") { + object RetryItems : Table("retry_items") { val id = integer("id") val name = varchar("name", 255) 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 deleted file mode 100644 index a84f31d1..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/NumericTtlTypesIT.kt +++ /dev/null @@ -1,35 +0,0 @@ -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.YdbTable -import tech.ydb.exposed.dialect.YdbTtlColumnMode -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.ydbUint64 - -class NumericTtlTypesIT : BaseYdbTest() { - - object NumericTtlItems : YdbTable("numeric_ttl_items") { - val id = integer("id") - val modifiedAtEpoch = ydbUint64("modified_at_epoch") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) - } - } - - 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 deleted file mode 100644 index c43ccf05..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt +++ /dev/null @@ -1,32 +0,0 @@ -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.YdbTable -import tech.ydb.exposed.dialect.integration.base.BaseYdbTest -import tech.ydb.exposed.dialect.javatime.ydbTimestamp - -class TtlTypesIT : BaseYdbTest() { - - object ExpiringItems : YdbTable("expiring_items") { - val id = integer("id") - val expireAt = ydbTimestamp("expire_at") - - override val primaryKey = PrimaryKey(id) - - init { - ttl(expireAt, "PT1H") - } - } - - 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 index 564f4a8e..7776b65e 100644 --- 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 @@ -7,7 +7,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbDecimal import tech.ydb.exposed.dialect.ydbInterval64 @@ -39,7 +38,7 @@ import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 */ class AllTypesRoundTripIT : BaseYdbTest() { - object ScalarTypes : YdbTable("all_types_scalars") { + object ScalarTypes : Table("all_types_scalars") { val id = integer("id") val byteCol = byte("byte_col") val ubyteCol = ydbUbyte("ubyte_col") @@ -59,7 +58,7 @@ class AllTypesRoundTripIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - object StandardTemporal : YdbTable("all_types_std_temporal") { + object StandardTemporal : Table("all_types_std_temporal") { val id = integer("id") val dateCol = ydbDate("date_col") val dateTimeCol = ydbDatetime("datetime_col") @@ -68,7 +67,7 @@ class AllTypesRoundTripIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - object YdbExtensionTypes : YdbTable("all_types_ydb_ext") { + object YdbExtensionTypes : Table("all_types_ydb_ext") { val id = integer("id") val amount = ydbDecimal("amount", 12, 4) val jsonCol = ydbJson("json_col") 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 index 6a4c60d4..fa5eea7c 100644 --- 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 @@ -12,7 +12,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest /** @@ -22,7 +21,7 @@ import tech.ydb.exposed.dialect.integration.base.BaseYdbTest */ class BinaryHexToDbIT : BaseYdbTest() { - object BinaryHex : YdbTable("binary_hex_to_db") { + object BinaryHex : Table("binary_hex_to_db") { val id = integer("id") val payload = blob("payload") 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 index afb65351..0199c1cd 100644 --- 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 @@ -6,12 +6,11 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class BinaryTypesIT : BaseYdbTest() { - object BinaryTypes : YdbTable("binary_types") { + object BinaryTypes : Table("binary_types") { val id = integer("id") val payload = binary("payload") 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 index fe032192..25a2bb40 100644 --- 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 @@ -6,14 +6,13 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbDecimal import java.math.BigDecimal class DecimalTypesIT : BaseYdbTest() { - object DecimalTypes : YdbTable("decimal_types") { + object DecimalTypes : Table("decimal_types") { val id = integer("id") val amount = ydbDecimal("amount", 10, 2) 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 index 7dbf4992..d013f1b0 100644 --- 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 @@ -7,7 +7,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbDecimal import tech.ydb.exposed.dialect.ydbDecimalLiteral @@ -15,7 +14,7 @@ import java.math.BigDecimal class DecimalUpdateIT : BaseYdbTest() { - object DecimalItems : YdbTable("decimal_update_items") { + object DecimalItems : Table("decimal_update_items") { val id = integer("id") val name = varchar("name", 255) val price = ydbDecimal("price", 10, 2) 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 index e7c65251..d944e26e 100644 --- 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 @@ -1,33 +1,24 @@ package tech.ydb.exposed.dialect.integration.types -import org.jetbrains.exposed.v1.jdbc.Database +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.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.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.connectYdb -import tech.ydb.exposed.dialect.YdbTable -import tech.ydb.exposed.dialect.ydbTransaction -import tech.ydb.test.junit5.YdbHelperExtension -import java.time.Instant -import java.time.LocalDate -import java.time.LocalDateTime +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 { +/** [ydbDate] / [ydbDatetime] / [ydbTimestamp] — unsigned legacy types with JDBC vendor codes. */ +class ForceLegacyStandardTemporalIT : BaseYdbTest() { - object LegacyStdTemporal : YdbTable("force_legacy_std_temporal") { + object LegacyStdTemporal : Table("force_legacy_std_temporal") { val id = integer("id") val dateCol = ydbDate("date_col") val dateTimeCol = ydbDatetime("datetime_col") @@ -36,35 +27,10 @@ class ForceLegacyStandardTemporalIT { override val primaryKey = PrimaryKey(id) } - private lateinit var db: Database - - @BeforeEach - fun setUp() { - val 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 = connectYdb(url = jdbcUrl) - } - - @AfterEach - fun tearDown() { - if (!::db.isInitialized) return - - runCatching { - ydbTransaction(db) { - SchemaUtils.drop(LegacyStdTemporal) - } - } - runCatching { TransactionManager.closeAndUnregister(db) } - } + override val tables: List
= emptyList() @Test - fun `should round-trip standard temporal columns as legacy types`() = ydbTransaction(db) { + fun `should round-trip standard temporal columns as legacy types`() = tx { SchemaUtils.create(LegacyStdTemporal) val dateValue = LocalDate.of(2019, 12, 31) @@ -85,7 +51,7 @@ class ForceLegacyStandardTemporalIT { } @Test - fun `should emit Date Datetime Timestamp ddl`() = ydbTransaction(db) { + fun `should emit Date Datetime Timestamp ddl`() = tx { SchemaUtils.create(LegacyStdTemporal) val ddl = LegacyStdTemporal.ddl.joinToString(" ") @@ -93,10 +59,4 @@ class ForceLegacyStandardTemporalIT { assertTrue(ddl.contains("datetime_col Datetime") && !ddl.contains("Datetime64"), ddl) assertTrue(ddl.contains("timestamp_col Timestamp") && !ddl.contains("Timestamp64"), ddl) } - - companion object { - @JvmField - @RegisterExtension - val ydb: YdbHelperExtension = YdbHelperExtension() - } } 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 index 139f4428..332ed596 100644 --- 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 @@ -6,7 +6,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbInterval import tech.ydb.exposed.dialect.ydbInterval64 @@ -14,14 +13,14 @@ import java.time.Duration class IntervalTypesIT : BaseYdbTest() { - object Interval64Types : YdbTable("interval64_types") { + object Interval64Types : Table("interval64_types") { val id = integer("id") val durationCol = ydbInterval64("duration_col") override val primaryKey = PrimaryKey(id) } - object LegacyIntervalTypes : YdbTable("legacy_interval_types") { + object LegacyIntervalTypes : Table("legacy_interval_types") { val id = integer("id") val durationCol = ydbInterval("duration_col") 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 index 49c069b6..d8b00a8b 100644 --- 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 @@ -6,21 +6,20 @@ 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.YdbTable 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 : YdbTable("json_types") { + object JsonTypes : Table("json_types") { val id = integer("id") val payload = ydbJson("payload") override val primaryKey = PrimaryKey(id) } - object JsonDocumentTypes : YdbTable("json_document_types") { + object JsonDocumentTypes : Table("json_document_types") { val id = integer("id") val payload = ydbJsonDocument("payload") 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 index 2a4c4912..07d6eb50 100644 --- 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 @@ -1,25 +1,17 @@ package tech.ydb.exposed.dialect.integration.types 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.transactions.TransactionManager -import org.junit.jupiter.api.AfterEach 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.connectYdb -import tech.ydb.exposed.dialect.YdbTable -import tech.ydb.exposed.dialect.ydbTransaction -import tech.ydb.test.junit5.YdbHelperExtension +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 { +class LegacyTemporalTypesIT : BaseYdbTest() { - object LegacyTemporal : YdbTable("legacy_temporal_types") { + object LegacyTemporal : Table("legacy_temporal_types") { val id = integer("id") val dateCol = ydbDate("date_col") val dateTimeCol = ydbDatetime("datetime_col") @@ -28,35 +20,10 @@ class LegacyTemporalTypesIT { override val primaryKey = PrimaryKey(id) } - private lateinit var db: Database - - @BeforeEach - fun setUp() { - val 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 = connectYdb(url = jdbcUrl) - } - - @AfterEach - fun tearDown() { - if (!::db.isInitialized) return - - runCatching { - ydbTransaction(db) { - SchemaUtils.drop(LegacyTemporal) - } - } - runCatching { TransactionManager.closeAndUnregister(db) } - } + override val tables: List
= emptyList() @Test - fun `unsigned ydb temporal extensions emit Date Datetime Timestamp`() = ydbTransaction(db) { + fun `unsigned ydb temporal extensions emit Date Datetime Timestamp`() = tx { SchemaUtils.create(LegacyTemporal) val ddl = LegacyTemporal.ddl.joinToString(" ") @@ -64,10 +31,4 @@ class LegacyTemporalTypesIT { assertTrue(ddl.contains("datetime_col Datetime") && !ddl.contains("Datetime64"), ddl) assertTrue(ddl.contains("timestamp_col Timestamp") && !ddl.contains("Timestamp64"), ddl) } - - companion object { - @JvmField - @RegisterExtension - val ydb: YdbHelperExtension = YdbHelperExtension() - } } 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 index 08c165a1..d1cfb502 100644 --- 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 @@ -5,7 +5,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.javatime.ydbDate import tech.ydb.exposed.dialect.javatime.ydbDatetime @@ -19,7 +18,7 @@ import java.time.LocalDateTime /** Round-trip for legacy YDB temporal/interval types (`Date`, `Datetime`, `Timestamp`, `Interval`). */ class LegacyYdbTypesRoundTripIT : BaseYdbTest() { - object LegacyTypes : YdbTable("legacy_ydb_types_round_trip") { + object LegacyTypes : Table("legacy_ydb_types_round_trip") { val id = integer("id") val dateCol = ydbDate("date_col") val dateTimeCol = ydbDatetime("datetime_col") 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 index 8bf62eb2..b4c35ac0 100644 --- 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 @@ -1,36 +1,28 @@ package tech.ydb.exposed.dialect.integration.types -import org.jetbrains.exposed.v1.jdbc.Database +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.jetbrains.exposed.v1.jdbc.transactions.TransactionManager -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals 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.YdbDialect -import tech.ydb.exposed.dialect.YdbTable -import tech.ydb.exposed.dialect.connectYdb +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 tech.ydb.exposed.dialect.ydbTransaction -import tech.ydb.test.junit5.YdbHelperExtension import java.time.Instant import java.time.LocalDate import java.time.LocalDateTime /** - * Signed temporal columns with [connectYdb] `enableSignedDatetimes = true` and - * `forceSignedDatetimes=true` on the JDBC URL (set explicitly by the caller). + * Signed temporal columns with [registerYdbDialect] `enableSignedDatetimes = true` and + * `forceSignedDatetimes=true` on the JDBC URL (set explicitly in [jdbcUrlSuffix]). */ -class SignedTemporalTypesIT { +class SignedTemporalTypesIT : BaseYdbTest() { - object SignedTemporal : YdbTable("signed_temporal_types") { + object SignedTemporal : Table("signed_temporal_types") { val id = integer("id") val dateCol = ydbDate32("date_col") val dateTimeCol = ydbDatetime64("datetime_col") @@ -39,46 +31,20 @@ class SignedTemporalTypesIT { override val primaryKey = PrimaryKey(id) } - private lateinit var db: Database - private lateinit var jdbcUrl: String + override val enableSignedDatetimes: Boolean = true - @BeforeEach - fun setUp() { - jdbcUrl = buildString { - append("jdbc:ydb:") - append(if (ydb.useTls()) "grpcs://" else "grpc://") - append(ydb.endpoint()) - append(ydb.database()) - append("?disablePrepareDataQuery=true&forceSignedDatetimes=true") - ydb.authToken()?.let { append("&token=").append(it) } - } - - db = connectYdb(url = jdbcUrl, enableSignedDatetimes = true) - } + override val tables: List
= listOf(SignedTemporal) - @AfterEach - fun tearDown() { - if (!::db.isInitialized) return - - runCatching { - transaction(db) { - SchemaUtils.drop(SignedTemporal) - } - } - runCatching { TransactionManager.closeAndUnregister(db) } - } + override val jdbcUrlSuffix: String = "&forceSignedDatetimes=true" @Test - fun `connectYdb wires signed dialect and jdbc url has forceSignedDatetimes`() { + fun `registerYdbDialect wires signed dialect when forceSignedDatetimes in url`() { val dialect = db.dialect as YdbDialect assertTrue(dialect.enableSignedDatetimes) - assertTrue(jdbcUrl.contains("forceSignedDatetimes=true"), jdbcUrl) } @Test - fun `should round-trip signed temporal columns`() = ydbTransaction(db) { - SchemaUtils.create(SignedTemporal) - + 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") @@ -97,7 +63,7 @@ class SignedTemporalTypesIT { } @Test - fun `should emit Date32 Datetime64 Timestamp64 ddl`() = ydbTransaction(db) { + fun `should emit Date32 Datetime64 Timestamp64 ddl`() = tx { SchemaUtils.create(SignedTemporal) val ddl = SignedTemporal.ddl.joinToString(" ") @@ -105,10 +71,4 @@ class SignedTemporalTypesIT { assertTrue(ddl.contains("datetime_col Datetime64"), ddl) assertTrue(ddl.contains("timestamp_col Timestamp64"), ddl) } - - companion object { - @JvmField - @RegisterExtension - val ydb: YdbHelperExtension = YdbHelperExtension() - } } 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 index 99d016c5..1948611d 100644 --- 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 @@ -6,7 +6,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import java.time.Instant import java.time.LocalDate @@ -17,7 +16,7 @@ import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 class TemporalTypesIT : BaseYdbTest() { - object TemporalTypes : YdbTable("temporal_types") { + object TemporalTypes : Table("temporal_types") { val id = integer("id") val dateCol = ydbDate32("date_col") val dateTimeCol = ydbDatetime64("datetime_col") 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 index c1bf4504..0fcb39ed 100644 --- 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 @@ -6,12 +6,11 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class TextTypesIT : BaseYdbTest() { - object TextTypes : YdbTable("text_types") { + object TextTypes : Table("text_types") { val id = integer("id") val varcharCol = varchar("varchar_col", 255) val textCol = text("text_col") 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 index 6c5e9531..61117acb 100644 --- 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 @@ -6,12 +6,11 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class TypesIT : BaseYdbTest() { - object BasicTypes : YdbTable("basic_types") { + object BasicTypes : Table("basic_types") { val id = integer("id") val shortCol = short("short_col") val intCol = integer("int_col") 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 index cc06bbd1..513a5f22 100644 --- 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 @@ -6,13 +6,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbUint64 class Uint64TypesIT : BaseYdbTest() { - object Uint64Types : YdbTable("uint64_types") { + object Uint64Types : Table("uint64_types") { val id = integer("id") val valueCol = ydbUint64("value_col") 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 index 63d9c245..06e74092 100644 --- 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 @@ -6,14 +6,13 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbUuid import java.util.UUID class UuidTypesIT : BaseYdbTest() { - object NativeUuidTypes : YdbTable("native_uuid_types") { + object NativeUuidTypes : Table("native_uuid_types") { val id = integer("id") val uuidCol = ydbUuid("uuid_col") override val primaryKey = PrimaryKey(id) 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 index fc3c22c4..f9eea564 100644 --- 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 @@ -6,12 +6,11 @@ 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.Test -import tech.ydb.exposed.dialect.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UpsertIT : BaseYdbTest() { - object Products : YdbTable("products") { + object Products : Table("products") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbJdbcUrlTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbJdbcUrlTest.kt deleted file mode 100644 index 87de0dca..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/basic/YdbJdbcUrlTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package tech.ydb.exposed.dialect.unit.basic - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import tech.ydb.exposed.dialect.ydbJdbcUrl - -class YdbJdbcUrlTest { - - @Test - fun `appends forceSignedDatetimes false for JDBC backward compatibility`() { - assertEquals( - "jdbc:ydb:grpc://localhost:2136/local?forceSignedDatetimes=false", - ydbJdbcUrl("jdbc:ydb:grpc://localhost:2136/local") - ) - } - - @Test - fun `preserves existing query parameters`() { - assertEquals( - "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=false", - ydbJdbcUrl("jdbc:ydb:grpc://localhost:2136/local?token=abc") - ) - } - - @Test - fun `replaces existing driver flag`() { - assertEquals( - "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=false", - ydbJdbcUrl("jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=true") - ) - } -} 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 index c756ccee..01ed4d0e 100644 --- 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 @@ -9,8 +9,6 @@ import org.junit.jupiter.api.Test import tech.ydb.exposed.dialect.YdbFunctionProvider import tech.ydb.exposed.dialect.YdbJsonDocumentStringColumnType import tech.ydb.exposed.dialect.YdbJsonStringColumnType -import tech.ydb.exposed.dialect.buildYdbJsonPath - class YdbFunctionProviderTest { private val provider = YdbFunctionProvider @@ -18,19 +16,6 @@ class YdbFunctionProviderTest { private fun sql(build: QueryBuilder.() -> Unit): String = QueryBuilder(false).apply(build).toString() - @Test - fun `buildYdbJsonPath maps object keys and array indexes`() { - assertEquals("$", buildYdbJsonPath()) - assertEquals("$.friends[0].name", buildYdbJsonPath("friends", "0", "name")) - assertEquals("$.title", buildYdbJsonPath("title")) - assertEquals("$.friends[*]", buildYdbJsonPath("friends", "[*]")) - } - - @Test - fun `buildYdbJsonPath quotes keys with special characters`() { - assertEquals("$.\"key-name\"", buildYdbJsonPath("key-name")) - } - @Test fun `charLength uses Unicode GetLength`() { val expr = stringLiteral("hello") @@ -135,6 +120,21 @@ class YdbFunctionProviderTest { ) } + @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":[]}""") diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt deleted file mode 100644 index 49c1fe94..00000000 --- a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package tech.ydb.exposed.dialect.unit.ttl - -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.YdbTtlColumnMode - -class YdbTtlColumnModeTest { - - @Test - fun `should map date type mode to null suffix`() { - assertNull(YdbTtlColumnMode.DATE_TYPE.toSql()) - } - - @Test - fun `should map numeric ttl modes to sql suffix`() { - assertEquals("SECONDS", YdbTtlColumnMode.SECONDS.toSql()) - assertEquals("MILLISECONDS", YdbTtlColumnMode.MILLISECONDS.toSql()) - assertEquals("MICROSECONDS", YdbTtlColumnMode.MICROSECONDS.toSql()) - assertEquals("NANOSECONDS", YdbTtlColumnMode.NANOSECONDS.toSql()) - } -} \ No newline at end of file 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 index 41f13ba6..3a5428ba 100644 --- 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 @@ -4,7 +4,6 @@ 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.YdbTable import tech.ydb.exposed.dialect.code.YdbJdbcCode import tech.ydb.exposed.dialect.javatime.ydbDate import tech.ydb.exposed.dialect.javatime.ydbDate32 @@ -21,7 +20,7 @@ class YdbTemporalColumnTypeTest { val signedDate = ydbDate32("date32") } - private object YdbTableColumns : YdbTable("ydb_temporal_columns") { + private object TemporalColumnsTable : Table("ydb_temporal_columns") { val legacyDate = ydbDate("legacy_date") val signedDate = ydbDate32("date32") val signedDatetime = ydbDatetime64("datetime64") @@ -40,17 +39,17 @@ class YdbTemporalColumnTypeTest { } @Test - fun `YdbTable ydbDate and ydbDate32 use unsigned and signed types`() { - assertEquals("Date", YdbTableColumns.legacyDate.columnType.sqlType()) - assertEquals("Date32", YdbTableColumns.signedDate.columnType.sqlType()) - assertEquals("Datetime64", YdbTableColumns.signedDatetime.columnType.sqlType()) - assertEquals("Timestamp64", YdbTableColumns.signedTimestamp.columnType.sqlType()) + 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 = YdbTableColumns.signedDate, + column = TemporalColumnsTable.signedDate, sqlType = "Date32", vendorCode = YdbJdbcCode.DATE32, value = LocalDate.of(2026, 4, 13) @@ -60,7 +59,7 @@ class YdbTemporalColumnTypeTest { @Test fun `ydbDatetime64 binds Datetime64 vendor code`() { assertBinding( - column = YdbTableColumns.signedDatetime, + column = TemporalColumnsTable.signedDatetime, sqlType = "Datetime64", vendorCode = YdbJdbcCode.DATETIME64, value = LocalDateTime.of(2026, 4, 13, 14, 30, 15) @@ -70,7 +69,7 @@ class YdbTemporalColumnTypeTest { @Test fun `ydbTimestamp64 binds Timestamp64 vendor code`() { assertBinding( - column = YdbTableColumns.signedTimestamp, + column = TemporalColumnsTable.signedTimestamp, sqlType = "Timestamp64", vendorCode = YdbJdbcCode.TIMESTAMP64, value = Instant.parse("2026-04-13T11:30:15Z") From 10702bcbd65fe80a1d26f1c117cc853e81a6f909 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Tue, 19 May 2026 18:21:14 +0300 Subject: [PATCH 36/43] return YdbTable.kt... --- kotlin-exposed-dialect/CHANGELOG.md | 6 +- .../ydb/exposed/dialect/example/DemoTables.kt | 16 +- .../tech/ydb/exposed/dialect/YdbDialect.kt | 2 +- .../ydb/exposed/dialect/YdbSecondaryIndex.kt | 104 ++++++++++++ .../tech/ydb/exposed/dialect/YdbTable.kt | 153 ++++++++++++++++++ .../kotlin/tech/ydb/exposed/dialect/YdbTtl.kt | 34 ++++ .../dialect/integration/base/BaseYdbTest.kt | 2 +- .../dialect/integration/basic/YdbTableIT.kt | 118 ++++++++++++++ .../integration/ttl/NumericTtlTypesIT.kt | 35 ++++ .../dialect/integration/ttl/TtlTypesIT.kt | 32 ++++ .../dialect/unit/ttl/YdbTtlColumnModeTest.kt | 22 +++ 11 files changed, 517 insertions(+), 7 deletions(-) create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt create mode 100644 kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/NumericTtlTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 0c173148..e66fc1c7 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -10,8 +10,10 @@ Initial release of the Kotlin Exposed dialect for YDB. retries classified by JDBC `SQLException` vendor codes). - Native `UPSERT` / `REPLACE` rendering — wired into Exposed's standard `Table.upsert` and `Table.replace` DSL. -- YDB-compatible `CREATE TABLE` generation with mandatory primary key. -- Secondary indexes via Exposed `Table.index()` → `ALTER TABLE … ADD INDEX … GLOBAL`. +- `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. 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 index 79bf9e63..e9ede561 100644 --- 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 @@ -1,9 +1,11 @@ package tech.ydb.exposed.dialect.example -import org.jetbrains.exposed.v1.core.Table +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 : Table("demo_products") { +object DemoProducts : YdbTable("demo_products") { val id = integer("id") val sku = varchar("sku", 64) val name = varchar("name", 255) @@ -14,6 +16,14 @@ object DemoProducts : Table("demo_products") { init { index(false, sku) - index("demo_products_category_idx", isUnique = false, category) + + 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/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbDialect.kt index cc50d5eb..219b2f3b 100644 --- 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 @@ -394,7 +394,7 @@ internal object YdbFunctionProvider : FunctionProvider() { * Use [registerYdbDialect] then `Database.connect("jdbc:ydb:...")`. * * Notable behavior: - * - [upsert] / [replace] → native YQL (partial columns vs defaults). + * - [tech.ydb.exposed.dialect.YdbFunctionProvider.upsert] / [replace] → native YQL (partial columns vs defaults). * - [createIndex] → `ALTER TABLE ... ADD INDEX ... GLOBAL`. * - Functional indexes and ANSI `MERGE` are rejected. * diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt new file mode 100644 index 00000000..5c3258d9 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbSecondaryIndex.kt @@ -0,0 +1,104 @@ +/** + * YDB secondary index declarations for [YdbTable.secondaryIndex]. + * + * Rendered into `CREATE TABLE` as `INDEX name GLOBAL [UNIQUE] [ASYNC] ON (...) [COVER (...)] [WITH (...)]`. + */ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager + +/** Index visibility scope in YQL (`GLOBAL` for row-oriented tables). */ +enum class YdbIndexScope { + GLOBAL +} + +/** Whether the index is built synchronously with writes or in the background. */ +enum class YdbIndexSyncMode { + SYNC, + ASYNC +} + +/** Internal model produced by [YdbTable.secondaryIndex] and rendered by [renderYdbSecondaryIndex]. */ +data class YdbSecondaryIndexSpec( + val name: String, + val columns: List>, + val unique: Boolean = false, + val scope: YdbIndexScope = YdbIndexScope.GLOBAL, + val syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + val indexType: String? = null, + val coverColumns: List> = emptyList(), + val withParams: Map = emptyMap() +) + +/** Builds the `INDEX ...` fragment for a single secondary index inside `CREATE TABLE`. */ +internal fun renderYdbSecondaryIndex(spec: YdbSecondaryIndexSpec): String { + val tr = TransactionManager.current() + + require(spec.columns.isNotEmpty()) { + "YDB secondary index must contain at least one column" + } + + require(spec.name.isNotBlank()) { + "YDB secondary index name must not be blank" + } + + require(spec.scope == YdbIndexScope.GLOBAL) { + "Only GLOBAL secondary indexes are supported by YDB row-oriented tables in this dialect" + } + + val indexName = tr.db.identifierManager.cutIfNecessaryAndQuote(spec.name) + + val columnsSql = spec.columns.joinToString(", ") { tr.identity(it) } + val coverSql = spec.coverColumns + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") { tr.identity(it) } + + val withSql = spec.withParams + .takeIf { it.isNotEmpty() } + ?.entries + ?.joinToString(", ") { (k, v) -> "$k = ${renderYdbIndexParamValue(v)}" } + + return buildString { + append("INDEX ") + append(indexName) + append(" ") + append(spec.scope.name) + + if (spec.unique) { + append(" UNIQUE") + } + + if (spec.syncMode != YdbIndexSyncMode.SYNC) { + append(" ") + append(spec.syncMode.name) + } + + if (spec.indexType != null) { + append(" USING ") + append(spec.indexType) + } + + append(" ON (") + append(columnsSql) + append(")") + + if (coverSql != null) { + append(" COVER (") + append(coverSql) + append(")") + } + + if (withSql != null) { + append(" WITH (") + append(withSql) + append(")") + } + } +} + +private fun renderYdbIndexParamValue(value: Any): String = when (value) { + is Number -> value.toString() + is Boolean -> value.toString().uppercase() + else -> "\"${value.toString().replace("\"", "\\\"")}\"" +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt new file mode 100644 index 00000000..62231ad3 --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt @@ -0,0 +1,153 @@ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import java.time.Duration + +/** + * Base class for YDB row-oriented tables. + * + * Adds YDB-specific DDL on top of Exposed [Table]: + * - [ttl] — TTL on a date/numeric column; + * - [secondaryIndex] — YDB secondary index with COVER / ASYNC / WITH params. + */ +open class YdbTable(name: String = "") : Table(name) { + private var ttlSettings: YdbTtlSettings? = null + private val secondaryIndices = mutableListOf() + + /** + * Declares row TTL on [column] (embedded in `CREATE TABLE ... WITH (TTL = ...)`). + * + * @param intervalIso8601 ISO-8601 duration (e.g. `P30D`, `PT1H`). + * @param mode How [column] is interpreted — date/timestamp types vs numeric epoch units. + */ + fun ttl( + column: Column<*>, + intervalIso8601: String, + mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE + ) { + ttlSettings = YdbTtlSettings(column, normalizeTtlInterval(intervalIso8601), mode) + } + + /** + * Declares a YDB secondary index inline in `CREATE TABLE` (not Exposed's generic [Index] DSL). + * + * @param scope Currently only [YdbIndexScope.GLOBAL] is supported for row tables. + * @param syncMode [YdbIndexSyncMode.ASYNC] for background index build. + * @param indexType Optional `USING` clause (e.g. vector index type when supported by YDB). + * @param coverColumns Included columns for covering index (`COVER (...)`). + * @param withParams Index-level `WITH (key = value)` parameters. + */ + fun secondaryIndex( + name: String, + vararg columns: Column<*>, + unique: Boolean = false, + scope: YdbIndexScope = YdbIndexScope.GLOBAL, + syncMode: YdbIndexSyncMode = YdbIndexSyncMode.SYNC, + indexType: String? = null, + coverColumns: List> = emptyList(), + withParams: Map = emptyMap() + ) { + require(columns.isNotEmpty()) { "YDB secondary index must contain at least one column" } + + secondaryIndices += YdbSecondaryIndexSpec( + name = name, + columns = columns.toList(), + unique = unique, + scope = scope, + syncMode = syncMode, + indexType = indexType, + coverColumns = coverColumns, + withParams = withParams + ) + } + + override fun createStatement(): 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") + } + } + } + + val indexesSql = secondaryIndices.joinToString(", ") { renderYdbSecondaryIndex(it) } + val pkSql = pk.columns.joinToString(", ") { tr.identity(it) } + + val ttlSql = ttlSettings?.let { ttl -> + validateTtlColumn(ttl) + buildString { + append(" WITH (TTL = Interval(\"") + append(escapeYqlDoubleQuotedLiteral(ttl.intervalIso8601)) + append("\") ON ") + append(tr.identity(ttl.column)) + ttl.mode.toSql()?.let { + append(" AS ") + append(it) + } + append(")") + } + }.orEmpty() + + val sql = buildString { + append("CREATE TABLE IF NOT EXISTS ") + append(tr.identity(this@YdbTable)) + append(" (") + append(columnsSql) + if (indexesSql.isNotEmpty()) { + append(", ") + append(indexesSql) + } + append(", PRIMARY KEY (") + append(pkSql) + append("))") + append(ttlSql) + } + + return listOf(sql) + } + + companion object { + private fun normalizeTtlInterval(intervalIso8601: String): String = + runCatching { Duration.parse(intervalIso8601).toString() } + .getOrElse { cause -> + throw IllegalArgumentException("Invalid YDB TTL interval: '$intervalIso8601'", cause) + } + + private fun validateTtlColumn(ttl: YdbTtlSettings) { + val sqlType = ttl.column.columnType.sqlType() + + val supported = when (ttl.mode) { + YdbTtlColumnMode.DATE_TYPE -> + sqlType == "Date" || + sqlType == "Date32" || + sqlType == "Datetime" || + sqlType == "Datetime64" || + sqlType == "Timestamp" || + sqlType == "Timestamp64" + + YdbTtlColumnMode.SECONDS, + YdbTtlColumnMode.MILLISECONDS, + YdbTtlColumnMode.MICROSECONDS, + YdbTtlColumnMode.NANOSECONDS -> + sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" + } + + require(supported) { + "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" + } + } + + private fun escapeYqlDoubleQuotedLiteral(value: String): String = + value.replace("\\", "\\\\").replace("\"", "\\\"") + } +} diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt new file mode 100644 index 00000000..5698984b --- /dev/null +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTtl.kt @@ -0,0 +1,34 @@ +/** + * TTL settings for [YdbTable.ttl] — see YDB docs on `WITH (TTL = Interval(...) ON ...)`. + */ +package tech.ydb.exposed.dialect + +import org.jetbrains.exposed.v1.core.Column + +/** How the TTL source [Column] value is interpreted in YQL. */ +enum class YdbTtlColumnMode { + /** `Date` / `Datetime` / `Timestamp` (and `*32`/`*64` variants) columns. */ + DATE_TYPE, + /** `Uint32` / `Uint64` / `DyNumber` interpreted as Unix seconds. */ + SECONDS, + MILLISECONDS, + MICROSECONDS, + NANOSECONDS; + + fun toSql(): String? = when (this) { + DATE_TYPE -> null + SECONDS -> "SECONDS" + MILLISECONDS -> "MILLISECONDS" + MICROSECONDS -> "MICROSECONDS" + NANOSECONDS -> "NANOSECONDS" + } +} + +/** Resolved TTL clause for [YdbTable] DDL generation. */ +data class YdbTtlSettings( + /** Column whose value drives expiration. */ + val column: Column<*>, + /** ISO-8601 duration string passed to `Interval("...")`. */ + val intervalIso8601: String, + val mode: YdbTtlColumnMode = YdbTtlColumnMode.DATE_TYPE +) \ No newline at end of file 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 index 2b5ed52e..0369f949 100644 --- 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 @@ -4,13 +4,13 @@ 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.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.extension.RegisterExtension import tech.ydb.exposed.dialect.YDB_DRIVER_CLASS +import org.jetbrains.exposed.v1.jdbc.SchemaUtils import tech.ydb.exposed.dialect.registerYdbDialect import tech.ydb.exposed.dialect.ydbTransaction import tech.ydb.test.junit5.YdbHelperExtension 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..1e7f8306 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt @@ -0,0 +1,118 @@ +package tech.ydb.exposed.dialect.integration.basic + +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.YdbTable +import tech.ydb.exposed.dialect.YdbTtlColumnMode +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbUint64 +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 + +class YdbTableIT : BaseYdbTest() { + + object BasicTable : YdbTable("unit_basic_table") { + val id = integer("id") + val name = varchar("name", 255) + + override val primaryKey = PrimaryKey(id) + } + + object TtlTimestampTable : YdbTable("unit_ttl_timestamp_table") { + val id = integer("id") + val expireAt = ydbTimestamp64("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, "PT1H") + } + } + + object TtlNumericTable : YdbTable("unit_ttl_numeric_table") { + val id = integer("id") + val modifiedAtEpoch = ydbUint64("modified_at_epoch") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) + } + } + + object NoPkTable : YdbTable("unit_no_pk_table") { + val id = integer("id") + val name = varchar("name", 255) + } + + object InvalidNumericTtlTable : YdbTable("unit_invalid_numeric_ttl_table") { + val id = integer("id") + val modifiedAtEpoch = integer("modified_at_epoch") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) + } + } + + @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 + } + } + + @Test + fun `fails when numeric TTL column type is unsupported`() = tx { + assertThrows(IllegalArgumentException::class.java) { + InvalidNumericTtlTable.ddl + } + } + + @Test + fun `rejects invalid TTL interval early`() { + val error = assertThrows(IllegalArgumentException::class.java) { + object : YdbTable("invalid_ttl_interval_table") { + val id = integer("id") + val expireAt = ydbTimestamp64("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, """PT1H" ON hacked""") + } + } + } + + assertTrue(error.message?.contains("Invalid YDB TTL interval") == true, error.message) + } +} 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..a84f31d1 --- /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.YdbTable +import tech.ydb.exposed.dialect.YdbTtlColumnMode +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.ydbUint64 + +class NumericTtlTypesIT : BaseYdbTest() { + + object NumericTtlItems : YdbTable("numeric_ttl_items") { + val id = integer("id") + val modifiedAtEpoch = ydbUint64("modified_at_epoch") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(modifiedAtEpoch, "PT1H", YdbTtlColumnMode.SECONDS) + } + } + + 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..c43ccf05 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ttl/TtlTypesIT.kt @@ -0,0 +1,32 @@ +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.YdbTable +import tech.ydb.exposed.dialect.integration.base.BaseYdbTest +import tech.ydb.exposed.dialect.javatime.ydbTimestamp + +class TtlTypesIT : BaseYdbTest() { + + object ExpiringItems : YdbTable("expiring_items") { + val id = integer("id") + val expireAt = ydbTimestamp("expire_at") + + override val primaryKey = PrimaryKey(id) + + init { + ttl(expireAt, "PT1H") + } + } + + 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/unit/ttl/YdbTtlColumnModeTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt new file mode 100644 index 00000000..49c1fe94 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/ttl/YdbTtlColumnModeTest.kt @@ -0,0 +1,22 @@ +package tech.ydb.exposed.dialect.unit.ttl + +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.YdbTtlColumnMode + +class YdbTtlColumnModeTest { + + @Test + fun `should map date type mode to null suffix`() { + assertNull(YdbTtlColumnMode.DATE_TYPE.toSql()) + } + + @Test + fun `should map numeric ttl modes to sql suffix`() { + assertEquals("SECONDS", YdbTtlColumnMode.SECONDS.toSql()) + assertEquals("MILLISECONDS", YdbTtlColumnMode.MILLISECONDS.toSql()) + assertEquals("MICROSECONDS", YdbTtlColumnMode.MICROSECONDS.toSql()) + assertEquals("NANOSECONDS", YdbTtlColumnMode.NANOSECONDS.toSql()) + } +} \ No newline at end of file From be1be71eaa3a64670a0ec61e4c9b274655ff71b0 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Tue, 19 May 2026 18:46:43 +0300 Subject: [PATCH 37/43] sync .md files --- kotlin-exposed-dialect/CHANGELOG.md | 3 +- kotlin-exposed-dialect/README.md | 53 +++++-- .../tech/ydb/exposed/dialect/YdbDialect.kt | 142 +++++++----------- .../tech/ydb/exposed/dialect/YdbTable.kt | 60 ++++---- 4 files changed, 121 insertions(+), 137 deletions(-) diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index e66fc1c7..31ddc480 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -6,8 +6,7 @@ Initial release of the Kotlin Exposed dialect for YDB. - YDB `VendorDialect` for Exposed JDBC; `registerYdbDialect(enableSignedDatetimes = …)` for setup. - `ydbTransaction { ... }` — retryable transactions with `readOnly` and `YdbRetryConfig` - (exponential backoff with full/equal jitter on YDB vendor codes); - retries classified by JDBC `SQLException` vendor codes). + (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 diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index 1ea55a6b..e94f74ea 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -3,7 +3,8 @@ YDB integration for [JetBrains Exposed](https://github.com/JetBrains/Exposed) via JDBC. The module provides: -- a Kotlin Exposed `VendorDialect` for YDB (DDL, SQL, type mapping, secondary indexes, TTL); +- a Kotlin Exposed `VendorDialect` for YDB (SQL, type mapping, post-create indexes); +- [`YdbTable`](src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.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. @@ -45,26 +46,41 @@ ydbTransaction(db) { ## Defining tables -YDB requires every table to declare a `PRIMARY KEY`. Use the standard Exposed `Table`; apply -custom DDL (TTL, inline indexes with `COVER` / `ASYNC`, etc.) via `transaction { exec("...") }` -when needed: +YDB requires a table-level `PRIMARY KEY (…)` in `CREATE TABLE`, not `col Type PRIMARY KEY` on a column. +Use [`YdbTable`](src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt) for schema DDL; plain Exposed +`Table` + `SchemaUtils.create` still works for DML/tests but emits inline PK SQL that YDB rejects. ```kotlin -import org.jetbrains.exposed.v1.core.Table +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") { +object Products : YdbTable("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 { - index(isUnique = false, sku) - index("products_category_idx", isUnique = false, category) + // Post-create index (ALTER TABLE … ADD INDEX … GLOBAL) — same as on Exposed Table + index(false, sku) + + // Inline index in CREATE TABLE (COVER / ASYNC / WITH) + secondaryIndex( + name = "products_category_idx", + category, + scope = YdbIndexScope.GLOBAL, + syncMode = YdbIndexSyncMode.ASYNC, + coverColumns = listOf(name, price) + ) + + ttl(expiresAt, "P30D") } } ``` @@ -94,9 +110,9 @@ Products.replace { ANSI `MERGE` is intentionally rejected — `UPSERT` / `REPLACE` cover the same use cases. -YDB `UPSERT` always writes **all columns** from the DSL block (full row by primary key). Exposed's -`onUpdate` / `keyColumns` arguments are **ignored** — there is no MySQL-style partial -`ON DUPLICATE KEY UPDATE`. +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 @@ -206,7 +222,7 @@ it.update(Products.price, ydbDecimalLiteral(BigDecimal("45.00"), 10, 2)) Exposed `autoIncrement()` maps to YDB `Serial` / `BigSerial`: ```kotlin -object Orders : Table("orders") { +object Orders : YdbTable("orders") { val id = integer("id").autoIncrement() val total = ydbDecimal("total", precision = 12, scale = 2) override val primaryKey = PrimaryKey(id) @@ -218,8 +234,10 @@ columns are not supported. ## Indexes -Exposed `Table.index()` / `index(customName, isUnique, …)` is rendered as -`ALTER TABLE … ADD INDEX … GLOBAL [UNIQUE] ON (…)`. +- **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/YdbTable.kt) + with optional `COVER`, `ASYNC`, `WITH`. ## Known limitations @@ -229,9 +247,9 @@ that YDB does not support. This module overrides indexes, UPSERT/REPLACE, LIMIT/ functions, and YDB type names — not the entire DDL surface. - No ANSI `MERGE`; use `UPSERT` / `REPLACE`. -- No DSL for TTL or inline `CREATE TABLE` indexes (`COVER`, `ASYNC`, `WITH`) — use raw YQL in `exec`. +- 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 secondary indexes are rejected. +- Functional indexes (Exposed `index` with expressions) are rejected. ## Tests @@ -242,6 +260,9 @@ Integration tests use [testcontainers](https://www.testcontainers.org/) via 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: 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 index 219b2f3b..30dc66f1 100644 --- 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 @@ -66,6 +66,7 @@ internal class YdbDataTypeProvider( 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") @@ -83,14 +84,11 @@ internal class YdbDataTypeProvider( internal object YdbFunctionProvider : FunctionProvider() { private const val MERGE_UNSUPPORTED = - "YDB does not support ANSI MERGE. Use upsert { } / replace { }, UPSERT INTO … , or UPDATE … ON — " + - "see https://ydb.tech/docs/en/yql/reference/syntax/upsert_into and …/update" + "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." - private const val INSERT_VALUE_CLASS = "org.jetbrains.exposed.v1.core.statements.InsertValue" - /** * Maps Exposed [org.jetbrains.exposed.v1.core.Random] to YQL [Random](https://ydb.tech/docs/en/yql/reference/builtins/basic). * @@ -202,11 +200,10 @@ internal object YdbFunctionProvider : FunctionProvider() { } /** - * Native YDB [UPSERT](https://ydb.tech/docs/en/yql/reference/syntax/upsert_into): only columns listed in - * the statement are written; on primary-key conflict, other columns stay unchanged. + * 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` with `insertValue()` (default) maps to the same VALUES. Different insert vs update - * literals and `onUpdateExclude` are rejected. + * Exposed `onUpdate` and `keyColumns` are ignored. [where] must be `null`. */ override fun upsert( table: Table, @@ -217,48 +214,21 @@ internal object YdbFunctionProvider : FunctionProvider() { where: Op?, transaction: Transaction ): String { - require(data.isNotEmpty()) { "UPSERT requires at least one column" } - if (keyColumns.isEmpty()) { - throw UnsupportedOperationException( - "YDB UPSERT requires a primary key (or explicit upsert keys); table ${table.tableName} has none" - ) - } - - validateUpsertOnUpdate(data, onUpdate, keyColumns, transaction) - if (where != null) { throw UnsupportedOperationException( "YDB UPSERT does not support Exposed's upsert(where) (PostgreSQL ON CONFLICT … WHERE). " + - "Use Table.update { } for conditional updates." + "Use Table.update { } for conditional updates." ) } - val columns = data.map { it.first }.distinct() - val columnList = columns.joinToString(", ") { transaction.identity(it) } - val dataByColumn = data.toMap() - val tableName = transaction.identity(table) - - if (expression.isNotBlank()) { - val valuesExpression = expression.trim() - val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { - "($columnList) $valuesExpression" - } else { - valuesExpression + val columns = data.map { it.first } + val expr = expression.trim().ifBlank { + val literals = data.joinToString(", ") { (column, value) -> + valueToSqlLiteral(column, value) } - return "UPSERT INTO $tableName $expressionWithColumns" + "VALUES ($literals)" } - - val valueList = columns.joinToString(", ") { column -> - val value = dataByColumn[column] - if (value == null) { - "NULL" - } else { - @Suppress("UNCHECKED_CAST") - (column.columnType as IColumnType).valueToString(value) - } - } - - return "UPSERT INTO $tableName ($columnList) VALUES ($valueList)" + return buildYdbIntoStatement("UPSERT", table, columns, expr, transaction) } /** @@ -271,16 +241,41 @@ internal object YdbFunctionProvider : FunctionProvider() { 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 { - require(columns.isNotEmpty()) { "REPLACE requires at least one column" } - val columnList = columns.joinToString(", ") { transaction.identity(it) } - val valuesExpression = expression.trim() - val expressionWithColumns = if (valuesExpression.startsWith("VALUES", ignoreCase = true)) { - "($columnList) $valuesExpression" - } else { - valuesExpression + 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) } - return "REPLACE INTO ${transaction.identity(table)} $expressionWithColumns" } override fun merge( @@ -315,44 +310,13 @@ internal object YdbFunctionProvider : FunctionProvider() { } } - private fun validateUpsertOnUpdate( - data: List, Any?>>, - onUpdate: List, Any?>>, - keyColumns: List>, - transaction: Transaction - ) { - if (onUpdate.isEmpty()) return - - val dataByColumn = data.toMap() - val keySet = keyColumns.toSet() - - val conflicting = onUpdate.filter { (column, value) -> - !isInsertValueExpression(value) && dataByColumn[column] != value - } - if (conflicting.isNotEmpty()) { - val names = conflicting.joinToString { transaction.identity(it.first) } - throw UnsupportedOperationException( - "YDB UPSERT applies the same VALUES on insert and on conflict; onUpdate cannot set different " + - "values ($names). Use Table.update { } after upsert, or REPLACE for a full-row overwrite " + - "(https://ydb.tech/docs/en/yql/reference/syntax/replace_into)." - ) - } - - val dataCols = data.map { it.first }.toSet() - val updateCols = onUpdate.map { it.first }.toSet() - val insertOnly = dataCols - updateCols - keySet - if (insertOnly.isNotEmpty() && onUpdate.any { !isInsertValueExpression(it.second) }) { - val names = insertOnly.joinToString { transaction.identity(it) } - throw UnsupportedOperationException( - "YDB UPSERT cannot insert column(s) $names while excluding them from the conflict update " + - "(Exposed onUpdateExclude). Omit those columns from the upsert body or include them in REPLACE." - ) - } + @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) } - private fun isInsertValueExpression(value: Any?): Boolean = - value != null && value.javaClass.name == INSERT_VALUE_CLASS - /** [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 "$" @@ -394,8 +358,10 @@ internal object YdbFunctionProvider : FunctionProvider() { * Use [registerYdbDialect] then `Database.connect("jdbc:ydb:...")`. * * Notable behavior: - * - [tech.ydb.exposed.dialect.YdbFunctionProvider.upsert] / [replace] → native YQL (partial columns vs defaults). - * - [createIndex] → `ALTER TABLE ... ADD INDEX ... GLOBAL`. + * - [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. diff --git a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt index 62231ad3..70ee57b2 100644 --- a/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt +++ b/kotlin-exposed-dialect/src/main/kotlin/tech/ydb/exposed/dialect/YdbTable.kt @@ -115,39 +115,37 @@ open class YdbTable(name: String = "") : Table(name) { return listOf(sql) } +} - companion object { - private fun normalizeTtlInterval(intervalIso8601: String): String = - runCatching { Duration.parse(intervalIso8601).toString() } - .getOrElse { cause -> - throw IllegalArgumentException("Invalid YDB TTL interval: '$intervalIso8601'", cause) - } - - private fun validateTtlColumn(ttl: YdbTtlSettings) { - val sqlType = ttl.column.columnType.sqlType() - - val supported = when (ttl.mode) { - YdbTtlColumnMode.DATE_TYPE -> - sqlType == "Date" || - sqlType == "Date32" || - sqlType == "Datetime" || - sqlType == "Datetime64" || - sqlType == "Timestamp" || - sqlType == "Timestamp64" - - YdbTtlColumnMode.SECONDS, - YdbTtlColumnMode.MILLISECONDS, - YdbTtlColumnMode.MICROSECONDS, - YdbTtlColumnMode.NANOSECONDS -> - sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" - } - - require(supported) { - "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" - } +private fun normalizeTtlInterval(intervalIso8601: String): String = + runCatching { Duration.parse(intervalIso8601).toString() } + .getOrElse { cause -> + throw IllegalArgumentException("Invalid YDB TTL interval: '$intervalIso8601'", cause) } - private fun escapeYqlDoubleQuotedLiteral(value: String): String = - value.replace("\\", "\\\\").replace("\"", "\\\"") +private fun validateTtlColumn(ttl: YdbTtlSettings) { + val sqlType = ttl.column.columnType.sqlType() + + val supported = when (ttl.mode) { + YdbTtlColumnMode.DATE_TYPE -> + sqlType == "Date" || + sqlType == "Date32" || + sqlType == "Datetime" || + sqlType == "Datetime64" || + sqlType == "Timestamp" || + sqlType == "Timestamp64" + + YdbTtlColumnMode.SECONDS, + YdbTtlColumnMode.MILLISECONDS, + YdbTtlColumnMode.MICROSECONDS, + YdbTtlColumnMode.NANOSECONDS -> + sqlType == "Uint32" || sqlType == "Uint64" || sqlType == "DyNumber" + } + + require(supported) { + "YDB TTL does not support column '${ttl.column.name}' of type '$sqlType' for mode '${ttl.mode}'" } } + +private fun escapeYqlDoubleQuotedLiteral(value: String): String = + value.replace("\\", "\\\\").replace("\"", "\\\"") From a0c4287bdb5b2c28382374cde5d6eb4b2acdeaff Mon Sep 17 00:00:00 2001 From: s-markelova Date: Tue, 19 May 2026 21:48:20 +0300 Subject: [PATCH 38/43] add spring integration --- kotlin-exposed-dialect/CHANGELOG.md | 3 + kotlin-exposed-dialect/README.md | 24 ++ .../spring-boot-starter/README.md | 54 ++++ .../spring-boot-starter/pom.xml | 240 ++++++++++++++++++ .../autoconfigure/OnYdbJdbcUrlCondition.kt | 20 ++ .../YdbExposedAutoConfiguration.kt | 58 +++++ .../YdbExposedEnvironmentPostProcessor.kt | 52 ++++ .../autoconfigure/YdbExposedProperties.kt | 11 + .../autoconfigure/YdbTransactionOperations.kt | 22 ++ .../main/resources/META-INF/spring.factories | 2 + ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../YdbExposedAutoConfigurationTest.kt | 41 +++ .../YdbExposedEnvironmentPostProcessorTest.kt | 54 ++++ .../autoconfigure/YdbSpringBootContextTest.kt | 126 +++++++++ .../exposed/dialect/YdbDialectRegistration.kt | 55 +++- 15 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 kotlin-exposed-dialect/spring-boot-starter/README.md create mode 100644 kotlin-exposed-dialect/spring-boot-starter/pom.xml create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/OnYdbJdbcUrlCondition.kt create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfiguration.kt create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessor.kt create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedProperties.kt create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbTransactionOperations.kt create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/main/resources/META-INF/spring.factories create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfigurationTest.kt create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessorTest.kt create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbSpringBootContextTest.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 31ddc480..3d04efae 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -22,5 +22,8 @@ Initial release of the Kotlin Exposed dialect for YDB. - Explicit rejection of ANSI `MERGE` (`UPSERT` covers the use case). - Console demo application showing CRUD, UPSERT and DDL. - Integration tests powered by testcontainers via `tech.ydb.test:ydb-junit5-support`. +- Optional `kotlin-exposed-ydb-dialect-spring-boot-starter` module that layers YDB dialect + registration, datasource URL normalization, and retry-aware transactions on top of + Exposed's official Spring Boot starter. - GitHub Actions workflows for CI (`ci-exposed-ydb-dialect.yaml`) and Maven Central publishing (`publish-kotlin-exposed-dialect.yaml`). diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index e94f74ea..4512448c 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -8,6 +8,9 @@ The module provides: - `Table.upsert` / `Table.replace` DSL backed by native YDB `UPSERT` / `REPLACE`; - a retryable transaction wrapper that handles YDB's OCC retries transparently. +For Spring Boot applications there is a separate optional module: +`kotlin-exposed-ydb-dialect-spring-boot-starter`. + ## Requirements - JDK 17+ @@ -279,3 +282,24 @@ mvn exec:java -Dexec.mainClass=tech.ydb.exposed.dialect.example.DemoAppKt ``` It expects a YDB instance at `jdbc:ydb:grpc://localhost:2136/local`. + +## Spring Boot starter + +Spring support is published as a separate artifact so the core dialect stays Spring-neutral: + +```xml + + tech.ydb.dialects + kotlin-exposed-ydb-dialect-spring-boot-starter + 0.9.0 + +``` + +The starter builds on top of Exposed's own `exposed-spring-boot-starter` and adds: + +- YDB dialect registration during Spring Boot startup; +- `spring.datasource.url` normalization for `forceSignedDatetimes=...`; +- the recommended YDB `DatabaseConfig`; +- `YdbTransactionOperations` for retry-aware Exposed transactions inside Spring services. + +See [`spring-boot-starter/`](spring-boot-starter) for the dedicated module. diff --git a/kotlin-exposed-dialect/spring-boot-starter/README.md b/kotlin-exposed-dialect/spring-boot-starter/README.md new file mode 100644 index 00000000..63dcd166 --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/README.md @@ -0,0 +1,54 @@ +# Kotlin Exposed YDB Dialect Spring Boot Starter + +Optional Spring Boot integration for [`kotlin-exposed-ydb-dialect`](../). + +This module reuses Exposed's official Spring Boot starter and adds the YDB-specific pieces +that the generic starter does not know about: + +- registering the YDB JDBC dialect in Exposed; +- aligning `spring.datasource.url` with `forceSignedDatetimes=...`; +- supplying the recommended `DatabaseConfig` for YDB; +- exposing `YdbTransactionOperations` for retry-aware Exposed transactions. + +## Dependency + +```xml + + tech.ydb.dialects + kotlin-exposed-ydb-dialect-spring-boot-starter + 0.9.0 + +``` + +The starter pulls in: + +- `tech.ydb.dialects:kotlin-exposed-ydb-dialect` +- `org.jetbrains.exposed:exposed-spring-boot-starter` +- `tech.ydb.jdbc:ydb-jdbc-driver` + +## Minimal configuration + +```yaml +spring: + datasource: + url: jdbc:ydb:grpc://localhost:2136/local + exposed: + ydb: + enable-signed-datetimes: false +``` + +## Retry-aware transactions + +`@Transactional` gives you Spring-managed Exposed transactions, but it does not add YDB retry +policy for OCC conflicts. Use `YdbTransactionOperations` when you need the retrying path: + +```kotlin +@Service +class ProductService( + private val ydbTx: YdbTransactionOperations +) { + fun save() = ydbTx.execute { + // Exposed DSL + } +} +``` diff --git a/kotlin-exposed-dialect/spring-boot-starter/pom.xml b/kotlin-exposed-dialect/spring-boot-starter/pom.xml new file mode 100644 index 00000000..f2130086 --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/pom.xml @@ -0,0 +1,240 @@ + + + 4.0.0 + + tech.ydb.dialects + kotlin-exposed-ydb-dialect-spring-boot-starter + 0.9.0 + jar + + Kotlin Exposed YDB Dialect Spring Boot Starter + Spring Boot starter for the Kotlin Exposed YDB dialect + 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 + 3.3.13 + 1.3.0 + 2.3.22 + 2.4.3 + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + + tech.ydb.dialects + kotlin-exposed-ydb-dialect + ${project.version} + + + + org.jetbrains.exposed + exposed-spring-boot-starter + ${exposed.version} + + + + tech.ydb.jdbc + ydb-jdbc-driver + ${ydb.jdbc.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + + tech.ydb.test + ydb-junit5-support + ${ydb.sdk.version} + test + + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.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-surefire-plugin + 3.1.0 + + false + + true + + + + + + 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 + + + + + + + + + + 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/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/OnYdbJdbcUrlCondition.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/OnYdbJdbcUrlCondition.kt new file mode 100644 index 00000000..f4507afb --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/OnYdbJdbcUrlCondition.kt @@ -0,0 +1,20 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.springframework.context.annotation.Condition +import org.springframework.context.annotation.ConditionContext +import org.springframework.core.type.AnnotatedTypeMetadata +import tech.ydb.exposed.dialect.YDB_JDBC_URL_PREFIX + +internal class OnYdbJdbcUrlCondition : Condition { + + override fun matches(context: ConditionContext, metadata: AnnotatedTypeMetadata): Boolean { + val url = context.environment.getProperty(SPRING_DATASOURCE_URL_PROPERTY) + ?.trim() + ?: return false + + return url.startsWith(YDB_JDBC_URL_PREFIX) + } +} + +internal const val SPRING_DATASOURCE_URL_PROPERTY = "spring.datasource.url" +internal const val SPRING_DATASOURCE_DRIVER_PROPERTY = "spring.datasource.driver-class-name" diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfiguration.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfiguration.kt new file mode 100644 index 00000000..904a6956 --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfiguration.kt @@ -0,0 +1,58 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.jetbrains.exposed.v1.core.DatabaseConfig +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.spring.boot.autoconfigure.ExposedAutoConfiguration +import org.springframework.beans.factory.InitializingBean +import org.springframework.boot.autoconfigure.AutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.ApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Conditional +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.registerYdbDialect +import tech.ydb.exposed.dialect.ydbDatabaseConfig +import javax.sql.DataSource + +/** + * YDB-specific layer on top of Exposed's Spring Boot starter. + * + * Exposed already knows how to wire its Spring transaction manager; this configuration only adds + * the YDB dialect registration, YDB defaults, and a retry-aware transaction entrypoint. + */ +@AutoConfiguration(before = [DataSourceTransactionManagerAutoConfiguration::class]) +@ConditionalOnClass(Database::class, YdbDialect::class, ExposedAutoConfiguration::class) +@Conditional(OnYdbJdbcUrlCondition::class) +@EnableConfigurationProperties(YdbExposedProperties::class) +class YdbExposedAutoConfiguration( + applicationContext: ApplicationContext, + private val properties: YdbExposedProperties +) : ExposedAutoConfiguration(applicationContext) { + + @Bean + fun ydbDialectRegistration(): InitializingBean = InitializingBean { + registerYdbDialect(properties.enableSignedDatetimes) + } + + @Bean + @ConditionalOnMissingBean(DatabaseConfig::class) + override fun databaseConfig(): DatabaseConfig = + ydbDatabaseConfig(enableSignedDatetimes = properties.enableSignedDatetimes) + + @Bean + @ConditionalOnMissingBean(Database::class) + fun database(dataSource: DataSource, databaseConfig: DatabaseConfig): Database { + registerYdbDialect(properties.enableSignedDatetimes) + return Database.connect(dataSource, databaseConfig = databaseConfig) + } + + @Bean + @ConditionalOnBean(Database::class) + @ConditionalOnMissingBean + fun ydbTransactionOperations(database: Database): YdbTransactionOperations = + YdbTransactionOperations(database) +} diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessor.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessor.kt new file mode 100644 index 00000000..6d418204 --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessor.kt @@ -0,0 +1,52 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.springframework.boot.SpringApplication +import org.springframework.boot.env.EnvironmentPostProcessor +import org.springframework.core.Ordered +import org.springframework.core.env.ConfigurableEnvironment +import org.springframework.core.env.MapPropertySource +import tech.ydb.exposed.dialect.YDB_DRIVER_CLASS +import tech.ydb.exposed.dialect.YDB_JDBC_URL_PREFIX +import tech.ydb.exposed.dialect.ydbJdbcUrl + +class YdbExposedEnvironmentPostProcessor : EnvironmentPostProcessor, Ordered { + + override fun getOrder(): Int = Ordered.LOWEST_PRECEDENCE + + override fun postProcessEnvironment( + environment: ConfigurableEnvironment, + application: SpringApplication + ) { + val rawUrl = environment.getProperty(SPRING_DATASOURCE_URL_PROPERTY) + ?.trim() + ?: return + + if (!rawUrl.startsWith(YDB_JDBC_URL_PREFIX)) { + return + } + + val enableSignedDatetimes = environment.getProperty( + "spring.exposed.ydb.enable-signed-datetimes", + Boolean::class.java, + false + ) + + val overrides = linkedMapOf( + SPRING_DATASOURCE_URL_PROPERTY to ydbJdbcUrl( + url = rawUrl, + enableSignedDatetimes = enableSignedDatetimes + ) + ) + + if (environment.getProperty(SPRING_DATASOURCE_DRIVER_PROPERTY).isNullOrBlank()) { + overrides[SPRING_DATASOURCE_DRIVER_PROPERTY] = YDB_DRIVER_CLASS + } + + environment.propertySources.remove(PROPERTY_SOURCE_NAME) + environment.propertySources.addFirst(MapPropertySource(PROPERTY_SOURCE_NAME, overrides)) + } + + private companion object { + const val PROPERTY_SOURCE_NAME = "ydbExposedStarterOverrides" + } +} diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedProperties.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedProperties.kt new file mode 100644 index 00000000..f8aca700 --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedProperties.kt @@ -0,0 +1,11 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("spring.exposed.ydb") +data class YdbExposedProperties( + /** + * Enables signed temporal mode in both the Exposed dialect and the JDBC URL. + */ + var enableSignedDatetimes: Boolean = false +) diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbTransactionOperations.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbTransactionOperations.kt new file mode 100644 index 00000000..a7dd9eaf --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbTransactionOperations.kt @@ -0,0 +1,22 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +import tech.ydb.exposed.dialect.YdbRetryConfig +import tech.ydb.exposed.dialect.ydbTransaction + +class YdbTransactionOperations internal constructor( + private val database: Database +) { + + fun execute( + retry: YdbRetryConfig = YdbRetryConfig.DEFAULT, + readOnly: Boolean = false, + statement: JdbcTransaction.() -> T + ): T = ydbTransaction( + db = database, + retry = retry, + readOnly = readOnly, + statement = statement + ) +} diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/resources/META-INF/spring.factories b/kotlin-exposed-dialect/spring-boot-starter/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..b4a72d0d --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.boot.env.EnvironmentPostProcessor=\ +tech.ydb.exposed.dialect.spring.boot.autoconfigure.YdbExposedEnvironmentPostProcessor diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/kotlin-exposed-dialect/spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..25800581 --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +tech.ydb.exposed.dialect.spring.boot.autoconfigure.YdbExposedAutoConfiguration diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfigurationTest.kt b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfigurationTest.kt new file mode 100644 index 00000000..9207b006 --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfigurationTest.kt @@ -0,0 +1,41 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.context.support.StaticApplicationContext +import tech.ydb.exposed.dialect.YdbDialect +import java.sql.Connection + +class YdbExposedAutoConfigurationTest { + + @Test + fun `creates database config aligned with YDB defaults`() { + val autoConfiguration = YdbExposedAutoConfiguration( + applicationContext = StaticApplicationContext(), + properties = YdbExposedProperties() + ) + + val databaseConfig = autoConfiguration.databaseConfig() + + assertInstanceOf(YdbDialect::class.java, databaseConfig.explicitDialect) + assertEquals(Connection.TRANSACTION_SERIALIZABLE, databaseConfig.defaultIsolationLevel) + assertFalse(databaseConfig.defaultReadOnly) + assertFalse(databaseConfig.useNestedTransactions) + } + + @Test + fun `propagates signed datetime mode into explicit dialect`() { + val autoConfiguration = YdbExposedAutoConfiguration( + applicationContext = StaticApplicationContext(), + properties = YdbExposedProperties(enableSignedDatetimes = true) + ) + + val databaseConfig = autoConfiguration.databaseConfig() + val dialect = databaseConfig.explicitDialect as YdbDialect + + assertTrue(dialect.enableSignedDatetimes) + } +} diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessorTest.kt b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessorTest.kt new file mode 100644 index 00000000..8590a31b --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessorTest.kt @@ -0,0 +1,54 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.springframework.boot.SpringApplication +import org.springframework.mock.env.MockEnvironment + +class YdbExposedEnvironmentPostProcessorTest { + + private val postProcessor = YdbExposedEnvironmentPostProcessor() + + @Test + fun `normalizes ydb datasource url and default driver class`() { + val environment = MockEnvironment() + .withProperty(SPRING_DATASOURCE_URL_PROPERTY, "jdbc:ydb:grpc://localhost:2136/local") + + postProcessor.postProcessEnvironment(environment, SpringApplication()) + + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?forceSignedDatetimes=false", + environment.getProperty(SPRING_DATASOURCE_URL_PROPERTY) + ) + assertEquals( + "tech.ydb.jdbc.YdbDriver", + environment.getProperty(SPRING_DATASOURCE_DRIVER_PROPERTY) + ) + } + + @Test + fun `propagates signed temporal mode into datasource url`() { + val environment = MockEnvironment() + .withProperty(SPRING_DATASOURCE_URL_PROPERTY, "jdbc:ydb:grpc://localhost:2136/local?token=abc") + .withProperty("spring.exposed.ydb.enable-signed-datetimes", "true") + + postProcessor.postProcessEnvironment(environment, SpringApplication()) + + assertEquals( + "jdbc:ydb:grpc://localhost:2136/local?token=abc&forceSignedDatetimes=true", + environment.getProperty(SPRING_DATASOURCE_URL_PROPERTY) + ) + } + + @Test + fun `does not touch non ydb datasource`() { + val environment = MockEnvironment() + .withProperty(SPRING_DATASOURCE_URL_PROPERTY, "jdbc:h2:mem:testdb") + + postProcessor.postProcessEnvironment(environment, SpringApplication()) + + assertEquals("jdbc:h2:mem:testdb", environment.getProperty(SPRING_DATASOURCE_URL_PROPERTY)) + assertNull(environment.getProperty(SPRING_DATASOURCE_DRIVER_PROPERTY)) + } +} diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbSpringBootContextTest.kt b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbSpringBootContextTest.kt new file mode 100644 index 00000000..2bbf727d --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbSpringBootContextTest.kt @@ -0,0 +1,126 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.jetbrains.exposed.v1.core.DatabaseConfig +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.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.core.env.Environment +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import tech.ydb.exposed.dialect.YdbDialect +import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.test.junit5.YdbHelperExtension +import java.sql.Connection + +@SpringBootTest( + classes = [YdbSpringBootContextTest.TestApplication::class], + properties = [ + "spring.exposed.ydb.enable-signed-datetimes=true" + ] +) +class YdbSpringBootContextTest { + + object SpringBootTable : YdbTable("spring_boot_starter_table") { + val id = integer("id") + val name = varchar("name", 128) + + override val primaryKey = PrimaryKey(id) + } + + @Autowired + private lateinit var database: Database + + @Autowired + private lateinit var databaseConfig: DatabaseConfig + + @Autowired + private lateinit var environment: Environment + + @Autowired + private lateinit var ydbTx: YdbTransactionOperations + + @AfterEach + fun tearDown() { + if (!::ydbTx.isInitialized) return + + runCatching { + ydbTx.execute { + SchemaUtils.drop(SpringBootTable) + } + } + } + + @Test + fun `spring boot context wires YDB database and config`() { + val dialect = assertInstanceOf(YdbDialect::class.java, database.dialect) + assertTrue(dialect.enableSignedDatetimes) + assertEquals(Connection.TRANSACTION_SERIALIZABLE, databaseConfig.defaultIsolationLevel) + assertFalse(databaseConfig.useNestedTransactions) + assertTrue( + environment.getProperty("spring.datasource.url") + ?.contains("forceSignedDatetimes=true") == true + ) + } + + @Test + fun `spring boot context can run CRUD through YdbTransactionOperations`() { + ydbTx.execute { + runCatching { SchemaUtils.drop(SpringBootTable) } + SchemaUtils.create(SpringBootTable) + + SpringBootTable.insert { + it[id] = 1 + it[name] = "spring-boot" + } + } + + val actual = ydbTx.execute(readOnly = true) { + SpringBootTable.selectAll().single()[SpringBootTable.name] + } + + assertEquals("spring-boot", actual) + } + + @SpringBootApplication + class TestApplication + + companion object { + @JvmField + @RegisterExtension + val ydb: YdbHelperExtension = YdbHelperExtension() + + @JvmStatic + @DynamicPropertySource + fun springDatasourceProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url") { buildJdbcUrl() } + } + + private fun buildJdbcUrl(): String = buildString { + append("jdbc:ydb:") + append(if (ydb.useTls()) "grpcs://" else "grpc://") + append(ydb.endpoint()) + append(ydb.database()) + + val params = mutableListOf( + "disablePrepareDataQuery=true", + "disableAutoPreparedBatches=true" + ) + + ydb.authToken()?.let { params += "token=$it" } + + append("?") + append(params.joinToString("&")) + } + } +} 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 index 08d1713f..35160a0e 100644 --- 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 @@ -7,11 +7,15 @@ package tech.ydb.exposed.dialect import org.jetbrains.exposed.v1.core.DatabaseApi +import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database +import java.sql.Connection -internal const val YDB_JDBC_URL_PREFIX = "jdbc:ydb:" +const val YDB_JDBC_URL_PREFIX = "jdbc:ydb:" -internal const val YDB_DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" +const val YDB_DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" + +const val FORCE_SIGNED_DATETIMES_PROPERTY = "forceSignedDatetimes" /** * Registers the YDB JDBC driver and Exposed dialect. @@ -34,3 +38,50 @@ fun registerYdbDialect(enableSignedDatetimes: Boolean = false) { YdbDialect(enableSignedDatetimes) } } + +/** + * Recommended Exposed [DatabaseConfig] for YDB-backed JDBC databases. + * + * This helper is intentionally Spring-neutral so integrations outside of [registerYdbDialect] + * can reuse the same YDB defaults and explicit dialect selection. + */ +fun ydbDatabaseConfig(enableSignedDatetimes: Boolean = false): DatabaseConfig = DatabaseConfig { + explicitDialect = YdbDialect(enableSignedDatetimes = enableSignedDatetimes) + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + defaultReadOnly = false + useNestedTransactions = false +} + +/** + * Ensures that a YDB JDBC URL contains a `forceSignedDatetimes` flag aligned with the selected + * temporal mode. + */ +fun ydbJdbcUrl( + url: String, + enableSignedDatetimes: Boolean = false +): String = + appendBooleanQueryParameter( + url = url, + name = FORCE_SIGNED_DATETIMES_PROPERTY, + value = enableSignedDatetimes + ) + +private fun appendBooleanQueryParameter(url: String, name: String, value: Boolean): String { + val queryStart = url.indexOf('?') + if (queryStart < 0) { + return "$url?$name=$value" + } + + val base = url.substring(0, queryStart) + val query = url.substring(queryStart + 1) + val filteredParams = query + .split('&') + .filter { it.isNotBlank() } + .filterNot { param -> + param.substringBefore('=') == name + } + .toMutableList() + + filteredParams += "$name=$value" + return "$base?${filteredParams.joinToString("&")}" +} From 642382cef4639378258f3df1ab79eb6e9dcbd0b3 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Tue, 19 May 2026 22:32:40 +0300 Subject: [PATCH 39/43] refactoring --- kotlin-exposed-dialect/CHANGELOG.md | 4 - .../dialect/integration/base/BaseYdbTest.kt | 2 +- .../basic/RegisterYdbDialectConnectIT.kt | 11 +- .../dialect/integration/dao/DaoSmokeIT.kt | 1 - .../integration/upsert/TableBatchUpsertIT.kt | 61 ++++++ .../dialect/integration/upsert/UpsertIT.kt | 192 +++++++++++++++++- .../dialect/unit/types/YdbHexToDbTest.kt | 2 +- 7 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/TableBatchUpsertIT.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 31ddc480..432783c1 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -20,7 +20,3 @@ Initial release of the Kotlin Exposed dialect for YDB. 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). -- Console demo application showing CRUD, UPSERT and DDL. -- Integration tests powered by testcontainers via `tech.ydb.test:ydb-junit5-support`. -- GitHub Actions workflows for CI (`ci-exposed-ydb-dialect.yaml`) and Maven Central - publishing (`publish-kotlin-exposed-dialect.yaml`). 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 index 0369f949..2b5ed52e 100644 --- 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 @@ -4,13 +4,13 @@ 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.TransactionManager import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.extension.RegisterExtension import tech.ydb.exposed.dialect.YDB_DRIVER_CLASS -import org.jetbrains.exposed.v1.jdbc.SchemaUtils import tech.ydb.exposed.dialect.registerYdbDialect import tech.ydb.exposed.dialect.ydbTransaction import tech.ydb.test.junit5.YdbHelperExtension 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 index 47be2f54..64ab3827 100644 --- 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 @@ -27,6 +27,12 @@ import java.sql.Connection */ 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) @@ -93,9 +99,4 @@ class RegisterYdbDialectConnectIT { assertTrue(ddl.contains("label Text")) } - companion object { - @JvmField - @RegisterExtension - val ydb: YdbHelperExtension = YdbHelperExtension() - } } 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 index dc7ab061..258de442 100644 --- 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 @@ -3,7 +3,6 @@ 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.core.decimalLiteral import org.jetbrains.exposed.v1.dao.Entity import org.jetbrains.exposed.v1.dao.EntityClass import org.jetbrains.exposed.v1.jdbc.selectAll 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..0b6327dd --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/upsert/TableBatchUpsertIT.kt @@ -0,0 +1,61 @@ +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.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 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 index f9eea564..4c2439a7 100644 --- 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 @@ -1,41 +1,136 @@ 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.integration.base.BaseYdbTest +/** + * 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("products") { + object Products : Table("upsert_products") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - override val tables: List
= listOf(Products) + 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) + } + + object NullableItems : Table("upsert_nullable_items") { + val id = integer("id") + val note = varchar("note", 64).nullable() + override val primaryKey = PrimaryKey(id) + } + + override val tables: List
= listOf(Products, Inventory, NullableItems) @Test - fun `Table upsert inserts and then updates the same row`() = tx { + fun `Table upsert inserts a new row`() = tx { Products.upsert { it[id] = 1 - it[name] = "Item1" + 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] = "Item2" + it[name] = "second" } val row = Products.selectAll().single() - assertEquals("Item2", row[Products.name]) + assertEquals("second", row[Products.name]) } @Test - fun `Table replace overwrites an existing row by primary key`() = tx { + 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" @@ -50,4 +145,87 @@ class UpsertIT : BaseYdbTest() { 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] = "present" + } + 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/types/YdbHexToDbTest.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/unit/types/YdbHexToDbTest.kt index a5a0bfa8..6d3dbd16 100644 --- 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 @@ -9,7 +9,7 @@ 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] - * ([LiteralOp], inline UPSERT values, etc.). + * ([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. From 9632e6dcf75404a709072bb1d68e09fa5252cec2 Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Tue, 19 May 2026 22:41:35 +0300 Subject: [PATCH 40/43] refactoring --- .../integration/basic/RegisterYdbDialectConnectIT.kt | 3 ++- .../dialect/integration/basic/YdbUniqueIndexSqlIT.kt | 3 ++- .../dialect/integration/batch/BatchOperationsIT.kt | 3 ++- .../tech/ydb/exposed/dialect/integration/crud/CrudIT.kt | 3 ++- .../ydb/exposed/dialect/integration/dao/SerialDaoIT.kt | 3 ++- .../tech/ydb/exposed/dialect/integration/ddl/IndexIT.kt | 3 ++- .../ydb/exposed/dialect/integration/ddl/UniqueIndexIT.kt | 3 ++- .../dialect/integration/functions/FunctionProviderIT.kt | 5 +++-- .../dialect/integration/functions/YdbSqlFunctionsIT.kt | 5 +++-- .../tech/ydb/exposed/dialect/integration/query/JoinIT.kt | 5 +++-- .../exposed/dialect/integration/query/ManyToManyIT.kt | 7 ++++--- .../ydb/exposed/dialect/integration/query/SubqueryIT.kt | 3 ++- .../dialect/integration/scenario/UniversityScenarioIT.kt | 9 +++++---- .../integration/transaction/YdbRetryingTransactionsIT.kt | 3 ++- .../dialect/integration/types/AllTypesRoundTripIT.kt | 7 ++++--- .../exposed/dialect/integration/types/BinaryHexToDbIT.kt | 3 ++- .../exposed/dialect/integration/types/BinaryTypesIT.kt | 3 ++- .../exposed/dialect/integration/types/DecimalTypesIT.kt | 3 ++- .../exposed/dialect/integration/types/DecimalUpdateIT.kt | 3 ++- .../integration/types/ForceLegacyStandardTemporalIT.kt | 3 ++- .../exposed/dialect/integration/types/IntervalTypesIT.kt | 5 +++-- .../ydb/exposed/dialect/integration/types/JsonTypesIT.kt | 5 +++-- .../dialect/integration/types/LegacyTemporalTypesIT.kt | 3 ++- .../integration/types/LegacyYdbTypesRoundTripIT.kt | 3 ++- .../dialect/integration/types/SignedTemporalTypesIT.kt | 3 ++- .../exposed/dialect/integration/types/TemporalTypesIT.kt | 3 ++- .../ydb/exposed/dialect/integration/types/TextTypesIT.kt | 3 ++- .../ydb/exposed/dialect/integration/types/TypesIT.kt | 3 ++- .../exposed/dialect/integration/types/Uint64TypesIT.kt | 3 ++- .../ydb/exposed/dialect/integration/types/UuidTypesIT.kt | 3 ++- .../dialect/integration/upsert/TableBatchUpsertIT.kt | 3 ++- .../ydb/exposed/dialect/integration/upsert/UpsertIT.kt | 7 ++++--- .../dialect/unit/types/YdbIntervalColumnTypeTest.kt | 3 ++- .../dialect/unit/types/YdbTemporalColumnTypeTest.kt | 5 +++-- 34 files changed, 83 insertions(+), 49 deletions(-) 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 index 64ab3827..8c436bfb 100644 --- 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 @@ -16,6 +16,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.registerYdbDialect import tech.ydb.exposed.dialect.ydbTransaction import tech.ydb.test.junit5.YdbHelperExtension @@ -33,7 +34,7 @@ class RegisterYdbDialectConnectIT { val ydb: YdbHelperExtension = YdbHelperExtension() } - object PlainConnectTable : Table("register_ydb_dialect_plain_connect") { + object PlainConnectTable : YdbTable("register_ydb_dialect_plain_connect") { val id = integer("id") val label = varchar("label", 64) 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 index fb9d9dfb..44dda3bc 100644 --- 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 @@ -5,11 +5,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class YdbUniqueIndexSqlIT : BaseYdbTest() { - object T : Table("t_unique_idx_test") { + object T : YdbTable("t_unique_idx_test") { val id = integer("id") val email = varchar("email", 255) 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 index 91f74e72..7cab3ba5 100644 --- 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 @@ -9,11 +9,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class BatchOperationsIT : BaseYdbTest() { - object BatchItems : Table("batch_items") { + object BatchItems : YdbTable("batch_items") { val id = integer("id") val name = varchar("name", 255) val quantity = integer("quantity") 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 index fbd84edc..00aa8ec5 100644 --- 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 @@ -8,11 +8,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class CrudIT : BaseYdbTest() { - object Users : Table("users") { + object Users : YdbTable("users") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) 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 index 39193db2..1c85eb99 100644 --- 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 @@ -6,6 +6,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest /** @@ -13,7 +14,7 @@ import tech.ydb.exposed.dialect.integration.base.BaseYdbTest */ class SerialDaoIT : BaseYdbTest() { - object Events : Table("serial_dao_events") { + object Events : YdbTable("serial_dao_events") { val id = integer("id").autoIncrement() val name = varchar("name", 255) 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 index 38046b64..64570a93 100644 --- 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 @@ -5,11 +5,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class IndexIT : BaseYdbTest() { - object Customers : Table("customers") { + object Customers : YdbTable("customers") { val id = integer("id") val name = varchar("name", 255) val email = varchar("email", 255) 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 index dfa4ca09..c53a8e82 100644 --- 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 @@ -8,11 +8,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UniqueIndexIT : BaseYdbTest() { - object UniqueCustomers : Table("unique_customers") { + object UniqueCustomers : YdbTable("unique_customers") { val id = integer("id") val email = varchar("email", 255) val name = varchar("name", 255) 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 index 1cf4e115..4d16ab87 100644 --- 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 @@ -7,18 +7,19 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class FunctionProviderIT : BaseYdbTest() { - object Users : Table("users") { + object Users : YdbTable("users") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - object SourceUsers : Table("source_users") { + object SourceUsers : YdbTable("source_users") { val id = integer("id") val name = varchar("name", 255) 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 index 2bde1510..4014f41c 100644 --- 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 @@ -16,6 +16,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbJson @@ -29,14 +30,14 @@ import tech.ydb.exposed.dialect.ydbJson */ class YdbSqlFunctionsIT : BaseYdbTest() { - object Strings : Table("fn_strings") { + object Strings : YdbTable("fn_strings") { val id = integer("id") val value = text("value") override val primaryKey = PrimaryKey(id) } - object JsonRows : Table("fn_json") { + object JsonRows : YdbTable("fn_json") { val id = integer("id") val payload = ydbJson("payload") 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 index 0825e27f..7db7a50d 100644 --- 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 @@ -8,17 +8,18 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class JoinIT : BaseYdbTest() { - object Authors : Table("authors") { + object Authors : YdbTable("authors") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - object Books : Table("books") { + object Books : YdbTable("books") { val id = integer("id") val title = varchar("title", 255) val authorId = integer("author_id") 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 index 8122bc8b..9b7e77cf 100644 --- 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 @@ -8,23 +8,24 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class ManyToManyIT : BaseYdbTest() { - object Students : Table("students") { + object Students : YdbTable("students") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - object Courses : Table("courses") { + object Courses : YdbTable("courses") { val id = integer("id") val title = varchar("title", 255) override val primaryKey = PrimaryKey(id) } - object StudentCourses : Table("student_courses") { + object StudentCourses : YdbTable("student_courses") { val id = integer("id") val studentId = integer("student_id") val courseId = integer("course_id") 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 index b9856e21..c66239fe 100644 --- 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 @@ -8,11 +8,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class SubqueryIT : BaseYdbTest() { - object Sales : Table("sales") { + object Sales : YdbTable("sales") { val id = integer("id") val customer = varchar("customer", 255) val amount = integer("amount") 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 index 51df914f..5fb0ae4f 100644 --- 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 @@ -8,18 +8,19 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class UniversityScenarioIT : BaseYdbTest() { - object Departments : Table("departments") { + object Departments : YdbTable("departments") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - object Students : Table("students") { + object Students : YdbTable("students") { val id = integer("id") val name = varchar("name", 255) val departmentId = integer("department_id") @@ -27,7 +28,7 @@ class UniversityScenarioIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - object Courses : Table("courses") { + object Courses : YdbTable("courses") { val id = integer("id") val name = varchar("name", 255) val departmentId = integer("department_id") @@ -35,7 +36,7 @@ class UniversityScenarioIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - object Enrollments : Table("enrollments") { + object Enrollments : YdbTable("enrollments") { val id = integer("id") val studentId = integer("student_id") val courseId = integer("course_id") 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 index 557c65ef..932e7930 100644 --- 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 @@ -7,6 +7,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.code.YdbVendorCode import tech.ydb.exposed.dialect.ydbTransaction @@ -15,7 +16,7 @@ import java.util.concurrent.atomic.AtomicInteger class YdbRetryingTransactionsIT : BaseYdbTest() { - object RetryItems : Table("retry_items") { + object RetryItems : YdbTable("retry_items") { val id = integer("id") val name = varchar("name", 255) 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 index 7776b65e..564f4a8e 100644 --- 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 @@ -7,6 +7,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbDecimal import tech.ydb.exposed.dialect.ydbInterval64 @@ -38,7 +39,7 @@ import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 */ class AllTypesRoundTripIT : BaseYdbTest() { - object ScalarTypes : Table("all_types_scalars") { + object ScalarTypes : YdbTable("all_types_scalars") { val id = integer("id") val byteCol = byte("byte_col") val ubyteCol = ydbUbyte("ubyte_col") @@ -58,7 +59,7 @@ class AllTypesRoundTripIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - object StandardTemporal : Table("all_types_std_temporal") { + object StandardTemporal : YdbTable("all_types_std_temporal") { val id = integer("id") val dateCol = ydbDate("date_col") val dateTimeCol = ydbDatetime("datetime_col") @@ -67,7 +68,7 @@ class AllTypesRoundTripIT : BaseYdbTest() { override val primaryKey = PrimaryKey(id) } - object YdbExtensionTypes : Table("all_types_ydb_ext") { + object YdbExtensionTypes : YdbTable("all_types_ydb_ext") { val id = integer("id") val amount = ydbDecimal("amount", 12, 4) val jsonCol = ydbJson("json_col") 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 index fa5eea7c..6a4c60d4 100644 --- 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 @@ -12,6 +12,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest /** @@ -21,7 +22,7 @@ import tech.ydb.exposed.dialect.integration.base.BaseYdbTest */ class BinaryHexToDbIT : BaseYdbTest() { - object BinaryHex : Table("binary_hex_to_db") { + object BinaryHex : YdbTable("binary_hex_to_db") { val id = integer("id") val payload = blob("payload") 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 index 0199c1cd..afb65351 100644 --- 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 @@ -6,11 +6,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class BinaryTypesIT : BaseYdbTest() { - object BinaryTypes : Table("binary_types") { + object BinaryTypes : YdbTable("binary_types") { val id = integer("id") val payload = binary("payload") 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 index 25a2bb40..fe032192 100644 --- 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 @@ -6,13 +6,14 @@ 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.YdbTable 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") { + object DecimalTypes : YdbTable("decimal_types") { val id = integer("id") val amount = ydbDecimal("amount", 10, 2) 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 index d013f1b0..7dbf4992 100644 --- 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 @@ -7,6 +7,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbDecimal import tech.ydb.exposed.dialect.ydbDecimalLiteral @@ -14,7 +15,7 @@ import java.math.BigDecimal class DecimalUpdateIT : BaseYdbTest() { - object DecimalItems : Table("decimal_update_items") { + object DecimalItems : YdbTable("decimal_update_items") { val id = integer("id") val name = varchar("name", 255) val price = ydbDecimal("price", 10, 2) 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 index d944e26e..75982fee 100644 --- 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 @@ -7,6 +7,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.javatime.ydbDate import tech.ydb.exposed.dialect.javatime.ydbDatetime @@ -18,7 +19,7 @@ import java.time.LocalDateTime /** [ydbDate] / [ydbDatetime] / [ydbTimestamp] — unsigned legacy types with JDBC vendor codes. */ class ForceLegacyStandardTemporalIT : BaseYdbTest() { - object LegacyStdTemporal : Table("force_legacy_std_temporal") { + object LegacyStdTemporal : YdbTable("force_legacy_std_temporal") { val id = integer("id") val dateCol = ydbDate("date_col") val dateTimeCol = ydbDatetime("datetime_col") 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 index 332ed596..139f4428 100644 --- 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 @@ -6,6 +6,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbInterval import tech.ydb.exposed.dialect.ydbInterval64 @@ -13,14 +14,14 @@ import java.time.Duration class IntervalTypesIT : BaseYdbTest() { - object Interval64Types : Table("interval64_types") { + object Interval64Types : YdbTable("interval64_types") { val id = integer("id") val durationCol = ydbInterval64("duration_col") override val primaryKey = PrimaryKey(id) } - object LegacyIntervalTypes : Table("legacy_interval_types") { + object LegacyIntervalTypes : YdbTable("legacy_interval_types") { val id = integer("id") val durationCol = ydbInterval("duration_col") 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 index d8b00a8b..49c069b6 100644 --- 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 @@ -6,20 +6,21 @@ 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.YdbTable 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") { + object JsonTypes : YdbTable("json_types") { val id = integer("id") val payload = ydbJson("payload") override val primaryKey = PrimaryKey(id) } - object JsonDocumentTypes : Table("json_document_types") { + object JsonDocumentTypes : YdbTable("json_document_types") { val id = integer("id") val payload = ydbJsonDocument("payload") 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 index 07d6eb50..a3f88b1f 100644 --- 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 @@ -4,6 +4,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.javatime.ydbDate import tech.ydb.exposed.dialect.javatime.ydbDatetime @@ -11,7 +12,7 @@ import tech.ydb.exposed.dialect.javatime.ydbTimestamp class LegacyTemporalTypesIT : BaseYdbTest() { - object LegacyTemporal : Table("legacy_temporal_types") { + object LegacyTemporal : YdbTable("legacy_temporal_types") { val id = integer("id") val dateCol = ydbDate("date_col") val dateTimeCol = ydbDatetime("datetime_col") 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 index d1cfb502..08c165a1 100644 --- 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 @@ -5,6 +5,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.javatime.ydbDate import tech.ydb.exposed.dialect.javatime.ydbDatetime @@ -18,7 +19,7 @@ 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") { + object LegacyTypes : YdbTable("legacy_ydb_types_round_trip") { val id = integer("id") val dateCol = ydbDate("date_col") val dateTimeCol = ydbDatetime("datetime_col") 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 index b4c35ac0..4848e417 100644 --- 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 @@ -8,6 +8,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.javatime.ydbDate32 import tech.ydb.exposed.dialect.javatime.ydbDatetime64 @@ -22,7 +23,7 @@ import java.time.LocalDateTime */ class SignedTemporalTypesIT : BaseYdbTest() { - object SignedTemporal : Table("signed_temporal_types") { + object SignedTemporal : YdbTable("signed_temporal_types") { val id = integer("id") val dateCol = ydbDate32("date_col") val dateTimeCol = ydbDatetime64("datetime_col") 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 index 1948611d..99d016c5 100644 --- 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 @@ -6,6 +6,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import java.time.Instant import java.time.LocalDate @@ -16,7 +17,7 @@ import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 class TemporalTypesIT : BaseYdbTest() { - object TemporalTypes : Table("temporal_types") { + object TemporalTypes : YdbTable("temporal_types") { val id = integer("id") val dateCol = ydbDate32("date_col") val dateTimeCol = ydbDatetime64("datetime_col") 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 index 0fcb39ed..c1bf4504 100644 --- 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 @@ -6,11 +6,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class TextTypesIT : BaseYdbTest() { - object TextTypes : Table("text_types") { + object TextTypes : YdbTable("text_types") { val id = integer("id") val varcharCol = varchar("varchar_col", 255) val textCol = text("text_col") 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 index 61117acb..6c5e9531 100644 --- 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 @@ -6,11 +6,12 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest class TypesIT : BaseYdbTest() { - object BasicTypes : Table("basic_types") { + object BasicTypes : YdbTable("basic_types") { val id = integer("id") val shortCol = short("short_col") val intCol = integer("int_col") 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 index 513a5f22..cc06bbd1 100644 --- 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 @@ -6,12 +6,13 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest import tech.ydb.exposed.dialect.ydbUint64 class Uint64TypesIT : BaseYdbTest() { - object Uint64Types : Table("uint64_types") { + object Uint64Types : YdbTable("uint64_types") { val id = integer("id") val valueCol = ydbUint64("value_col") 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 index 06e74092..63d9c245 100644 --- 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 @@ -6,13 +6,14 @@ 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.YdbTable 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") { + object NativeUuidTypes : YdbTable("native_uuid_types") { val id = integer("id") val uuidCol = ydbUuid("uuid_col") override val primaryKey = PrimaryKey(id) 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 index 0b6327dd..c8970ea4 100644 --- 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 @@ -6,12 +6,13 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest /** [Table.batchUpsert] over native YDB UPSERT. */ class TableBatchUpsertIT : BaseYdbTest() { - object BatchItems : Table("table_batch_upsert_items") { + object BatchItems : YdbTable("table_batch_upsert_items") { val id = integer("id") val name = varchar("name", 255) val quantity = integer("quantity") 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 index 4c2439a7..ca83bdce 100644 --- 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 @@ -9,6 +9,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.integration.base.BaseYdbTest /** @@ -21,20 +22,20 @@ import tech.ydb.exposed.dialect.integration.base.BaseYdbTest */ class UpsertIT : BaseYdbTest() { - object Products : Table("upsert_products") { + object Products : YdbTable("upsert_products") { val id = integer("id") val name = varchar("name", 255) override val primaryKey = PrimaryKey(id) } - object Inventory : Table("upsert_inventory") { + object Inventory : YdbTable("upsert_inventory") { val id = integer("id") val name = varchar("name", 255) val quantity = integer("quantity").default(0) override val primaryKey = PrimaryKey(id) } - object NullableItems : Table("upsert_nullable_items") { + object NullableItems : YdbTable("upsert_nullable_items") { val id = integer("id") val note = varchar("note", 64).nullable() override val primaryKey = PrimaryKey(id) 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 index a446879e..311edced 100644 --- 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 @@ -4,6 +4,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.ydbInterval import tech.ydb.exposed.dialect.ydbInterval64 import tech.ydb.exposed.dialect.code.YdbJdbcCode @@ -11,7 +12,7 @@ import java.time.Duration class YdbIntervalColumnTypeTest { - private object IntervalColumns : Table("interval_columns") { + private object IntervalColumns : YdbTable("interval_columns") { val legacy = ydbInterval("legacy") val extended = ydbInterval64("extended") } 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 index 3a5428ba..9433dd22 100644 --- 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 @@ -4,6 +4,7 @@ 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.YdbTable import tech.ydb.exposed.dialect.code.YdbJdbcCode import tech.ydb.exposed.dialect.javatime.ydbDate import tech.ydb.exposed.dialect.javatime.ydbDate32 @@ -15,12 +16,12 @@ import java.time.LocalDateTime class YdbTemporalColumnTypeTest { - private object PlainTable : Table("temporal_columns") { + private object PlainTable : YdbTable("temporal_columns") { val legacyDate = ydbDate("legacy_date") val signedDate = ydbDate32("date32") } - private object TemporalColumnsTable : Table("ydb_temporal_columns") { + private object TemporalColumnsTable : YdbTable("ydb_temporal_columns") { val legacyDate = ydbDate("legacy_date") val signedDate = ydbDate32("date32") val signedDatetime = ydbDatetime64("datetime64") From eecbc3cc568d9025810111c16c0edfcf0480f94f Mon Sep 17 00:00:00 2001 From: Kirill Kurdyukov Date: Tue, 19 May 2026 22:45:03 +0300 Subject: [PATCH 41/43] refactoring --- kotlin-exposed-dialect/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index dcce6390..cd478aa5 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -245,6 +245,7 @@ true + ydbplatform/local-ydb:trunk From e2ad610576e9b1d79595b67a267bcb86bb234770 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Thu, 21 May 2026 01:01:31 +0300 Subject: [PATCH 42/43] partly rework dialect --- kotlin-exposed-dialect/CHANGELOG.md | 36 ++- kotlin-exposed-dialect/README.md | 291 ++++++++++-------- .../ydb/exposed/dialect/example/DemoApp.kt | 8 +- .../ydb/exposed/dialect/example/DemoTables.kt | 18 +- kotlin-exposed-dialect/pom.xml | 12 + .../tech/ydb/exposed/dialect/YdbDialect.kt | 3 +- .../ydb/exposed/dialect/createYdbStatement.kt | 22 +- ...{YdbTableIT.kt => CreateYdbStatementIT.kt} | 6 +- .../integration/ddl/MigrationValidationIT.kt | 144 +++++++++ 9 files changed, 361 insertions(+), 179 deletions(-) rename kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/{YdbTableIT.kt => CreateYdbStatementIT.kt} (96%) create mode 100644 kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/MigrationValidationIT.kt diff --git a/kotlin-exposed-dialect/CHANGELOG.md b/kotlin-exposed-dialect/CHANGELOG.md index 432783c1..03c1ff9c 100644 --- a/kotlin-exposed-dialect/CHANGELOG.md +++ b/kotlin-exposed-dialect/CHANGELOG.md @@ -1,22 +1,24 @@ -## 0.9.0 +## 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). +- YDB `VendorDialect` for Exposed JDBC and `registerYdbDialect(enableSignedDatetimes = …)` for setup. +- `ydbDatabaseConfig()` and `ydbJdbcUrl(...)` helpers for recommended Exposed and JDBC configuration. +- `ydbTransaction { ... }` with retry classification based on JDBC `SQLException` vendor codes. +- Native YDB `UPSERT` / `REPLACE` rendering wired into Exposed `Table.upsert` and `Table.replace`. +- `createYdbStatement()` for YDB-compatible `CREATE TABLE` rendering with a table-level `PRIMARY KEY (...)`. +- Post-create indexes through Exposed `Table.index()` в†’ `ALTER TABLE ... ADD INDEX ... GLOBAL`. +- JDBC metadata support for reading existing indexes from YDB. +- Temporal column extensions (`ydbDate`, `ydbDate32`, `ydbDatetime`, `ydbDatetime64`, `ydbTimestamp`, `ydbTimestamp64`) with JDBC vendor bindings. +- Custom YDB column types for `Decimal`, `Interval`, `Json`, `JsonDocument`, native `Uuid`, and unsigned values. +- `ydbDecimalLiteral` for decimal update expressions. +- `Serial` / `BigSerial` support via Exposed `autoIncrement()`. +- Explicit rejection of ANSI `MERGE`. + +### Notes + +- Exposed 1.3.0 does not provide a dialect hook for rendering a single-column PK inside `CREATE TABLE`, so schema generation for YDB is implemented through a `createStatement()` override and `createYdbStatement()`. +- Production schema management is expected to use external versioned migrations; the repository includes validation coverage for externally created schemas through YDB-compatible Exposed drift checks for missing columns and secondary indexes. + diff --git a/kotlin-exposed-dialect/README.md b/kotlin-exposed-dialect/README.md index c6e129d0..261c4823 100644 --- a/kotlin-exposed-dialect/README.md +++ b/kotlin-exposed-dialect/README.md @@ -1,19 +1,21 @@ -# Kotlin Exposed YDB Dialect +# 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. +- a Kotlin Exposed `VendorDialect` for YDB; +- `createYdbStatement()` for YDB-compatible `CREATE TABLE` rendering; +- native `UPSERT` / `REPLACE` support through Exposed's `Table.upsert` and `Table.replace`; +- retry-aware `ydbTransaction { ... }` for YDB OCC conflicts; +- YDB-specific column types for temporal, JSON, interval, decimal, UUID, and unsigned values. ## 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 +- [YDB JDBC driver](https://github.com/ydb-platform/ydb-jdbc-driver) on the application classpath +- JetBrains Exposed 1.3.x ```xml @@ -33,11 +35,16 @@ The module provides: ```kotlin import org.jetbrains.exposed.v1.jdbc.Database import tech.ydb.exposed.dialect.registerYdbDialect +import tech.ydb.exposed.dialect.ydbDatabaseConfig import tech.ydb.exposed.dialect.ydbTransaction -registerYdbDialect() // or registerYdbDialect(enableSignedDatetimes = true) +registerYdbDialect() -val db = Database.connect("jdbc:ydb:grpc://localhost:2136/local") +val db = Database.connect( + url = "jdbc:ydb:grpc://localhost:2136/local", + driver = "tech.ydb.jdbc.YdbDriver", + databaseConfig = ydbDatabaseConfig() +) ydbTransaction(db) { // Exposed DSL / DAO code @@ -46,14 +53,16 @@ ydbTransaction(db) { ## 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. +YDB requires a table-level `PRIMARY KEY (...)` in `CREATE TABLE`, not the inline +`column Type PRIMARY KEY` form that Exposed may generate for a single-column PK. + +Because Exposed 1.3.0 does not expose a dialect hook for this part of `CREATE TABLE`, +YDB schema generation is implemented as a local workaround: override `createStatement()` +and delegate to `createYdbStatement()`. ```kotlin -import tech.ydb.exposed.dialect.YdbIndexScope -import tech.ydb.exposed.dialect.YdbIndexSyncMode -import tech.ydb.exposed.dialect.YdbTable +import org.jetbrains.exposed.v1.core.Table +import tech.ydb.exposed.dialect.createYdbStatement import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 import tech.ydb.exposed.dialect.ydbDecimal @@ -68,18 +77,52 @@ object Products : Table("products") { 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() +} +``` + +`createYdbStatement()`: + +- renders all columns without inline PK declarations; +- appends a table-level `PRIMARY KEY (...)`; +- preserves `NOT NULL` and `DEFAULT`; +- preserves `storageParameters`, so YDB-specific `WITH (...)` clauses can still be used. + +Post-create indexes declared through `Table.index(...)` are still emitted through the dialect's +standard `ALTER TABLE ... ADD INDEX ... GLOBAL` path. + +### TTL via storage parameters + +If you need YDB-specific table options such as TTL, declare them through Exposed +`storageParameters` and keep the DDL override: + +```kotlin +import org.jetbrains.exposed.v1.core.RawTableStorageParameter +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.TableStorageParameter +import tech.ydb.exposed.dialect.createYdbStatement +import tech.ydb.exposed.dialect.javatime.ydbTimestamp64 + +object Sessions : Table("sessions") { + val id = integer("id") + val expireAt = ydbTimestamp64("expire_at") + + override val primaryKey = PrimaryKey(id) + + override val storageParameters: List = + listOf(RawTableStorageParameter("TTL = Interval(\"PT1H\") ON expire_at")) + override fun createStatement(): List = createYdbStatement() } ``` -## Insert / upsert / replace / update / delete +## Insert / upsert / replace -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: +Exposed's regular DSL works as-is. The dialect also maps Exposed's `upsert` / `replace` +to native YDB `UPSERT` / `REPLACE`. ```kotlin Products.upsert { @@ -99,174 +142,176 @@ Products.replace { } ``` -ANSI `MERGE` is intentionally rejected — `UPSERT` / `REPLACE` cover the same use cases. +Behavioral notes: -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. +- `UPSERT` writes only the columns listed in the statement; +- on PK conflict, columns omitted from `UPSERT` remain unchanged; +- `REPLACE` overwrites the row by PK, so omitted columns are reset to defaults; +- `upsert(where)` is not supported; +- ANSI `MERGE` is intentionally rejected. ## 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`, ...): +YDB uses Optimistic Concurrency Control, so a transaction can fail with a retryable status. +Use `ydbTransaction` instead of plain `transaction` when you want retries on retryable YDB errors. ```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, ...) + // read-write transaction } ydbTransaction(db, retry = YdbRetryConfig.IDEMPOTENT) { - // idempotent body — UNDETERMINED and other non-transient retryable codes are retried too + // safe-to-repeat body } ydbTransaction(db, readOnly = true, retry = YdbRetryConfig.IDEMPOTENT) { - // read-only snapshot work + // read-only transaction } ``` -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)`. +`YdbRetryConfig.IDEMPOTENT` should only be used when the body can be safely executed more than once. ## 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`: +Default Exposed type mapping: + +| 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` | + +YDB-specific extensions are available through `ydb*` and `javatime.*`, for example: ```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 +ydbJsonDocument("indexed_payload") +ydbUuid("id") 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. +Signed temporal mode can be enabled through: + +```kotlin +registerYdbDialect(enableSignedDatetimes = true) +``` -For Decimal literals inside update expressions there's `ydbDecimalLiteral`: +and, for JDBC URL normalization: ```kotlin -import tech.ydb.exposed.dialect.ydbDecimalLiteral +ydbJdbcUrl("jdbc:ydb:grpc://localhost:2136/local", enableSignedDatetimes = true) +``` + +## Schema management in production + +Schema generation through Exposed is supported, but for YDB it is intentionally treated as a +compatibility workaround rather than the primary schema-management model. -it.update(Products.price, ydbDecimalLiteral(BigDecimal("45.00"), 10, 2)) +In production, the recommended approach is: + +1. manage schema through versioned migrations such as Flyway or Liquibase; +2. keep Exposed table definitions aligned with that schema; +3. validate drift through Exposed migration helpers. + +If your application uses schema validation or migration diff generation through Exposed, +also add: + +```xml + + org.jetbrains.exposed + exposed-migration-core + ${exposed.version} + + + org.jetbrains.exposed + exposed-migration-jdbc + ${exposed.version} + ``` -## Identifiers +This repository includes integration coverage for: + +- manual schema creation through raw SQL; +- YDB-compatible drift detection for missing columns and secondary indexes; +- empty diff for matching schema; +- non-empty diff for drifted schema. -Exposed `autoIncrement()` maps to YDB `Serial` / `BigSerial`: +In Exposed 1.3.0, the full `MigrationUtils.statementsRequiredForDatabaseMigration(...)` path +unconditionally reads CHECK-constraint metadata from `INFORMATION_SCHEMA.CHECK_CONSTRAINTS`. +YDB does not expose that metadata through the current JDBC driver, so the repository validates +externally managed schemas through the compatible building blocks that Exposed already provides: ```kotlin -object Orders : YdbTable("orders") { - val id = integer("id").autoIncrement() - val total = ydbDecimal("total", precision = 12, scale = 2) - override val primaryKey = PrimaryKey(id) +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import tech.ydb.exposed.dialect.ydbTransaction + +ydbTransaction(db, readOnly = true) { + val missingColumns = SchemaUtils.addMissingColumnsStatements(Products, withLogs = true) + val existingIndexes = db.dialectMetadata.existingIndices(Products).getValue(Products) } ``` -For UUID keys use `ydbUuid("id")` or Exposed `uuid()` under this dialect. Unsigned `Serial` -columns are not supported. +When schema was changed through raw SQL just before validation, run the diff in a fresh transaction +so Exposed does not validate against stale metadata cache. -## 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`. +That validation path is the one that matters most in real projects, where schema changes are usually +applied by dedicated migration tools rather than by ORM-driven DDL generation. ## 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. +- Exposed 1.3.0 does not expose a dialect hook for PK rendering inside `CREATE TABLE`; +- every table intended for YDB DDL must override `createStatement()` and call `createYdbStatement()`; +- plain `Table` / `IdTable` DDL without that override emits inline PK SQL that YDB rejects; +- functional indexes are not supported; +- `ALTER TABLE ... ADD INDEX ... GLOBAL UNIQUE` depends on YDB support for unique indexes on existing tables; +- ANSI `MERGE` is not supported; +- `Uint64` binding is limited to the `0..Long.MAX_VALUE` range in the current implementation. ## Tests -Integration tests use [testcontainers](https://www.testcontainers.org/) via -`tech.ydb.test:ydb-junit5-support` — no manual Docker setup needed: +Unit tests: + +```bash +mvn test +``` + +Integration tests: ```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). +The build separates unit and integration tests through surefire/failsafe. Integration tests run +against YDB in testcontainers. ## Demo application -The `example/` module contains a runnable demo. Install the dialect first: +The `example/` module contains a small runnable demo. Install the dialect first: ```bash mvn -DskipTests -DskipITs install ``` -Then run the demo: +Then run: ```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/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 index af24d982..188e2da4 100644 --- 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 @@ -6,23 +6,19 @@ 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.ydbDatabaseConfig 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 - } + databaseConfig = ydbDatabaseConfig() ) ydbTransaction(db) { 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 index e9ede561..55954a74 100644 --- 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 @@ -1,11 +1,10 @@ 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 org.jetbrains.exposed.v1.core.Table +import tech.ydb.exposed.dialect.createYdbStatement import tech.ydb.exposed.dialect.ydbDecimal -object DemoProducts : YdbTable("demo_products") { +object DemoProducts : Table("demo_products") { val id = integer("id") val sku = varchar("sku", 64) val name = varchar("name", 255) @@ -16,14 +15,7 @@ object DemoProducts : YdbTable("demo_products") { init { index(false, sku) - - secondaryIndex( - name = "demo_products_category_idx", - category, - unique = false, - scope = YdbIndexScope.GLOBAL, - syncMode = YdbIndexSyncMode.ASYNC, - coverColumns = listOf(name, price) - ) } + + override fun createStatement(): List = createYdbStatement() } diff --git a/kotlin-exposed-dialect/pom.xml b/kotlin-exposed-dialect/pom.xml index cd478aa5..745713fd 100644 --- a/kotlin-exposed-dialect/pom.xml +++ b/kotlin-exposed-dialect/pom.xml @@ -128,6 +128,18 @@ test + + org.jetbrains.exposed + exposed-migration-core + test + + + + org.jetbrains.exposed + exposed-migration-jdbc + test + + tech.ydb.test ydb-junit5-support 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 index 30dc66f1..7197182e 100644 --- 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 @@ -358,7 +358,8 @@ internal object YdbFunctionProvider : FunctionProvider() { * Use [registerYdbDialect] then `Database.connect("jdbc:ydb:...")`. * * Notable behavior: - * - [YdbTable] — YQL `CREATE TABLE` with table-level PK, inline indexes, TTL. + * - [createYdbStatement] — helper for YQL `CREATE TABLE` with a table-level PK, + * intended for `Table.createStatement()` overrides. * - [tech.ydb.exposed.dialect.YdbFunctionProvider.upsert] / [replace] → native YQL `UPSERT` / `REPLACE` * (`onUpdate` / `keyColumns` ignored). * - [createIndex] → `ALTER TABLE ... ADD INDEX ... GLOBAL` (post-create indexes). 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 index 856c32a7..87b7672f 100644 --- 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 @@ -28,7 +28,6 @@ import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager * id Int32 NOT NULL, * email Utf8 NOT NULL, * name Utf8 NOT NULL, - * INDEX ..., * PRIMARY KEY (id) * ) * ``` @@ -39,12 +38,13 @@ import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager * - 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. + * - preserving Exposed [Table.storageParameters], so YDB-specific `WITH (...)` + * clauses can still be declared on the 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. + * The generated statement uses `CREATE TABLE IF NOT EXISTS`, matching + * Exposed's schema-creation flow. Post-create indexes declared through + * Exposed [Table.index] are still emitted separately through the dialect's + * `createIndex(...)` path. * * Example: * @@ -56,14 +56,6 @@ import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager * * 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() * } * ``` @@ -117,4 +109,4 @@ fun Table.createYdbStatement(): List { } return listOf(sql) -} \ No newline at end of file +} 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/CreateYdbStatementIT.kt similarity index 96% rename from kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/YdbTableIT.kt rename to kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/basic/CreateYdbStatementIT.kt index 4f6ac4ac..16a971a7 100644 --- 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/CreateYdbStatementIT.kt @@ -1,17 +1,15 @@ 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 +import tech.ydb.exposed.dialect.ydbUint64 -class YdbTableIT : BaseYdbTest() { +class CreateYdbStatementIT : BaseYdbTest() { object BasicTable : Table("unit_basic_table") { val id = integer("id") diff --git a/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/MigrationValidationIT.kt b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/MigrationValidationIT.kt new file mode 100644 index 00000000..27c1bc23 --- /dev/null +++ b/kotlin-exposed-dialect/src/test/kotlin/tech/ydb/exposed/dialect/integration/ddl/MigrationValidationIT.kt @@ -0,0 +1,144 @@ +package tech.ydb.exposed.dialect.integration.ddl + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.junit.jupiter.api.AfterEach +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 + +/** + * Migration validation for externally managed schemas. + * + * In production the schema is usually created by versioned migrations, while Exposed is used to + * validate that the current database layout still matches the application's table model. + * + * Exposed 1.3.0 provides [org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils], but its full + * JDBC diff path unconditionally queries CHECK-constraint metadata from INFORMATION_SCHEMA. + * YDB does not expose that metadata through the JDBC driver, so for YDB we validate drift through + * the compatible schema-diff primitives that Exposed already exposes: + * - [SchemaUtils.addMissingColumnsStatements] for missing columns; + * - [tech.ydb.exposed.dialect.YdbDialectMetadata] for existing secondary indexes. + * + * We intentionally apply schema changes through raw SQL first and only then ask Exposed for the + * statements that would be needed to bring the database schema back in sync with the table model. + */ +class MigrationValidationIT : BaseYdbTest() { + + object ExternalUsers : Table("external_users") { + val id = integer("id") + val name = varchar("name", 255) + val email = varchar("email", 255) + + override val primaryKey = PrimaryKey(id) + + init { + index(false, email) + } + + + override fun createStatement(): List = createYdbStatement() + } + + @AfterEach + fun tearDown() { + transaction(db) { + runCatching { SchemaUtils.drop(ExternalUsers) } + } + } + + @Test + fun `ydb-compatible schema validation returns no statements for a matching externally created schema`() { + applySchemaManually( + """ + CREATE TABLE IF NOT EXISTS external_users ( + id Int32 NOT NULL, + name Text NOT NULL, + email Text NOT NULL, + PRIMARY KEY (id) + ) + """.trimIndent(), + "ALTER TABLE external_users ADD INDEX external_users_email GLOBAL ON (email)" + ) + + val statements = requiredMigrationStatements() + + assertTrue(statements.isEmpty(), statements.joinToString(separator = "\n")) + } + + @Test + fun `ydb-compatible schema validation reports drift for an incomplete externally created schema`() { + applySchemaManually( + """ + CREATE TABLE IF NOT EXISTS external_users ( + id Int32 NOT NULL, + name Text NOT NULL, + PRIMARY KEY (id) + ) + """.trimIndent() + ) + + val statements = requiredMigrationStatements() + + assertTrue(statements.isNotEmpty(), "Expected schema drift to be reported") + assertTrue( + statements.any { statement -> + statement.contains("email", ignoreCase = true) || + statement.contains("ADD INDEX", ignoreCase = true) + }, + statements.joinToString(separator = "\n") + ) + } + + /** + * JetBrains Exposed warns that schema changes performed through raw SQL should be validated + * in a fresh transaction to avoid stale metadata cache. + */ + private fun applySchemaManually(vararg statements: String) { + transaction(db) { + runCatching { SchemaUtils.drop(ExternalUsers) } + statements.forEach { statement -> + exec(statement) + } + } + } + + private fun requiredMigrationStatements(): List = + transaction(db) { + buildList { + addAll(missingColumnStatements()) + addAll(missingIndexStatements()) + } + } + + private fun missingColumnStatements(): List { + val existingColumnNames = db.dialectMetadata + .tableColumns(ExternalUsers) + .getValue(ExternalUsers) + .map { metadata -> metadata.name.normalizedIdentifier() } + .toSet() + + return ExternalUsers.columns + .filterNot { column -> column.name.normalizedIdentifier() in existingColumnNames } + .map { column -> "MISSING COLUMN ${ExternalUsers.tableName}.${column.name}" } + } + + private fun missingIndexStatements(): List { + val existingIndexNames = db.dialectMetadata + .existingIndices(ExternalUsers) + .getValue(ExternalUsers) + .map { index -> index.indexName.normalizedIdentifier() } + .toSet() + + return ExternalUsers.indices + .filterNot { index -> index.indexName.normalizedIdentifier() in existingIndexNames } + .map { index -> "MISSING INDEX ${index.indexName}" } + } + + private fun String.normalizedIdentifier(): String = trim('`', '"').lowercase() +} + + + From 1494d1027e8bf99fe3ca681d0ff1474971bc0e31 Mon Sep 17 00:00:00 2001 From: s-markelova Date: Thu, 21 May 2026 02:39:14 +0300 Subject: [PATCH 43/43] fix errors in starter --- .../spring-boot-starter/README.md | 10 +++- .../autoconfigure/OnYdbJdbcUrlCondition.kt | 5 +- .../YdbExposedAutoConfiguration.kt | 40 +++++++------- .../YdbExposedEnvironmentPostProcessor.kt | 11 ++-- .../boot/autoconfigure/YdbStarterSupport.kt | 54 +++++++++++++++++++ .../YdbExposedAutoConfigurationTest.kt | 19 +++---- .../autoconfigure/YdbSpringBootContextTest.kt | 16 ++++-- 7 files changed, 107 insertions(+), 48 deletions(-) create mode 100644 kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbStarterSupport.kt diff --git a/kotlin-exposed-dialect/spring-boot-starter/README.md b/kotlin-exposed-dialect/spring-boot-starter/README.md index 63dcd166..096117b3 100644 --- a/kotlin-exposed-dialect/spring-boot-starter/README.md +++ b/kotlin-exposed-dialect/spring-boot-starter/README.md @@ -7,7 +7,9 @@ that the generic starter does not know about: - registering the YDB JDBC dialect in Exposed; - aligning `spring.datasource.url` with `forceSignedDatetimes=...`; -- supplying the recommended `DatabaseConfig` for YDB; +- defaulting `spring.datasource.driver-class-name` to `tech.ydb.jdbc.YdbDriver` when it is omitted; +- supplying the recommended `DatabaseConfig` for YDB as the primary Spring bean; +- creating an Exposed `Database` bean from the Spring-managed `DataSource`; - exposing `YdbTransactionOperations` for retry-aware Exposed transactions. ## Dependency @@ -37,6 +39,12 @@ spring: enable-signed-datetimes: false ``` +If `spring.datasource.driver-class-name` is not specified, the starter sets it to +`tech.ydb.jdbc.YdbDriver` automatically. + +When `spring.exposed.ydb.enable-signed-datetimes=true`, the starter also propagates the +matching `forceSignedDatetimes=true` flag into the normalized JDBC URL. + ## Retry-aware transactions `@Transactional` gives you Spring-managed Exposed transactions, but it does not add YDB retry diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/OnYdbJdbcUrlCondition.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/OnYdbJdbcUrlCondition.kt index f4507afb..0a47fc05 100644 --- a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/OnYdbJdbcUrlCondition.kt +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/OnYdbJdbcUrlCondition.kt @@ -1,9 +1,8 @@ -package tech.ydb.exposed.dialect.spring.boot.autoconfigure +package tech.ydb.exposed.dialect.spring.boot.autoconfigure import org.springframework.context.annotation.Condition import org.springframework.context.annotation.ConditionContext import org.springframework.core.type.AnnotatedTypeMetadata -import tech.ydb.exposed.dialect.YDB_JDBC_URL_PREFIX internal class OnYdbJdbcUrlCondition : Condition { @@ -12,7 +11,7 @@ internal class OnYdbJdbcUrlCondition : Condition { ?.trim() ?: return false - return url.startsWith(YDB_JDBC_URL_PREFIX) + return url.startsWith(STARTER_YDB_JDBC_URL_PREFIX) } } diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfiguration.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfiguration.kt index 904a6956..75e213fe 100644 --- a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfiguration.kt +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfiguration.kt @@ -1,4 +1,4 @@ -package tech.ydb.exposed.dialect.spring.boot.autoconfigure +package tech.ydb.exposed.dialect.spring.boot.autoconfigure import org.jetbrains.exposed.v1.core.DatabaseConfig import org.jetbrains.exposed.v1.jdbc.Database @@ -8,47 +8,47 @@ import org.springframework.boot.autoconfigure.AutoConfiguration import org.springframework.boot.autoconfigure.condition.ConditionalOnBean import org.springframework.boot.autoconfigure.condition.ConditionalOnClass import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean -import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration import org.springframework.boot.context.properties.EnableConfigurationProperties -import org.springframework.context.ApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Conditional +import org.springframework.context.annotation.DependsOn +import org.springframework.context.annotation.Primary import tech.ydb.exposed.dialect.YdbDialect import tech.ydb.exposed.dialect.registerYdbDialect -import tech.ydb.exposed.dialect.ydbDatabaseConfig import javax.sql.DataSource /** - * YDB-specific layer on top of Exposed's Spring Boot starter. + * Spring Boot bridge for the YDB Exposed dialect. * - * Exposed already knows how to wire its Spring transaction manager; this configuration only adds - * the YDB dialect registration, YDB defaults, and a retry-aware transaction entrypoint. + * The official Exposed Spring Boot starter knows how to integrate Exposed with Spring + * transactions, but it does not know anything about YDB dialect registration, YDB defaults, + * or the JDBC flag required for signed temporal mode. This auto-configuration layers those + * pieces on top as a separate optional artifact. */ -@AutoConfiguration(before = [DataSourceTransactionManagerAutoConfiguration::class]) +@AutoConfiguration @ConditionalOnClass(Database::class, YdbDialect::class, ExposedAutoConfiguration::class) @Conditional(OnYdbJdbcUrlCondition::class) @EnableConfigurationProperties(YdbExposedProperties::class) -class YdbExposedAutoConfiguration( - applicationContext: ApplicationContext, - private val properties: YdbExposedProperties -) : ExposedAutoConfiguration(applicationContext) { +class YdbExposedAutoConfiguration { @Bean - fun ydbDialectRegistration(): InitializingBean = InitializingBean { + fun ydbDialectRegistration(properties: YdbExposedProperties): InitializingBean = InitializingBean { registerYdbDialect(properties.enableSignedDatetimes) } @Bean - @ConditionalOnMissingBean(DatabaseConfig::class) - override fun databaseConfig(): DatabaseConfig = - ydbDatabaseConfig(enableSignedDatetimes = properties.enableSignedDatetimes) + @Primary + fun ydbDatabaseConfig(properties: YdbExposedProperties): DatabaseConfig = + ydbStarterDatabaseConfig(enableSignedDatetimes = properties.enableSignedDatetimes) @Bean + @DependsOn("ydbDialectRegistration") @ConditionalOnMissingBean(Database::class) - fun database(dataSource: DataSource, databaseConfig: DatabaseConfig): Database { - registerYdbDialect(properties.enableSignedDatetimes) - return Database.connect(dataSource, databaseConfig = databaseConfig) - } + fun database(dataSource: DataSource, ydbDatabaseConfig: DatabaseConfig): Database = + Database.connect( + datasource = dataSource, + databaseConfig = ydbDatabaseConfig + ) @Bean @ConditionalOnBean(Database::class) diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessor.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessor.kt index 6d418204..e1830c3b 100644 --- a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessor.kt +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedEnvironmentPostProcessor.kt @@ -1,13 +1,10 @@ -package tech.ydb.exposed.dialect.spring.boot.autoconfigure +package tech.ydb.exposed.dialect.spring.boot.autoconfigure import org.springframework.boot.SpringApplication import org.springframework.boot.env.EnvironmentPostProcessor import org.springframework.core.Ordered import org.springframework.core.env.ConfigurableEnvironment import org.springframework.core.env.MapPropertySource -import tech.ydb.exposed.dialect.YDB_DRIVER_CLASS -import tech.ydb.exposed.dialect.YDB_JDBC_URL_PREFIX -import tech.ydb.exposed.dialect.ydbJdbcUrl class YdbExposedEnvironmentPostProcessor : EnvironmentPostProcessor, Ordered { @@ -21,7 +18,7 @@ class YdbExposedEnvironmentPostProcessor : EnvironmentPostProcessor, Ordered { ?.trim() ?: return - if (!rawUrl.startsWith(YDB_JDBC_URL_PREFIX)) { + if (!rawUrl.startsWith(STARTER_YDB_JDBC_URL_PREFIX)) { return } @@ -32,14 +29,14 @@ class YdbExposedEnvironmentPostProcessor : EnvironmentPostProcessor, Ordered { ) val overrides = linkedMapOf( - SPRING_DATASOURCE_URL_PROPERTY to ydbJdbcUrl( + SPRING_DATASOURCE_URL_PROPERTY to ydbStarterJdbcUrl( url = rawUrl, enableSignedDatetimes = enableSignedDatetimes ) ) if (environment.getProperty(SPRING_DATASOURCE_DRIVER_PROPERTY).isNullOrBlank()) { - overrides[SPRING_DATASOURCE_DRIVER_PROPERTY] = YDB_DRIVER_CLASS + overrides[SPRING_DATASOURCE_DRIVER_PROPERTY] = STARTER_YDB_DRIVER_CLASS } environment.propertySources.remove(PROPERTY_SOURCE_NAME) diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbStarterSupport.kt b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbStarterSupport.kt new file mode 100644 index 00000000..00d6b266 --- /dev/null +++ b/kotlin-exposed-dialect/spring-boot-starter/src/main/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbStarterSupport.kt @@ -0,0 +1,54 @@ +package tech.ydb.exposed.dialect.spring.boot.autoconfigure + +import org.jetbrains.exposed.v1.core.DatabaseConfig +import tech.ydb.exposed.dialect.YdbDialect +import java.sql.Connection + +internal const val STARTER_YDB_JDBC_URL_PREFIX = "jdbc:ydb:" +internal const val STARTER_YDB_DRIVER_CLASS = "tech.ydb.jdbc.YdbDriver" + +internal fun ydbStarterJdbcUrl( + url: String, + enableSignedDatetimes: Boolean = false +): String { + val trimmedUrl = url.trim() + val flag = "forceSignedDatetimes=$enableSignedDatetimes" + + val hashIndex = trimmedUrl.indexOf('#') + val baseUrl = if (hashIndex >= 0) trimmedUrl.substring(0, hashIndex) else trimmedUrl + val fragment = if (hashIndex >= 0) trimmedUrl.substring(hashIndex) else "" + + val queryIndex = baseUrl.indexOf('?') + val path = if (queryIndex >= 0) baseUrl.substring(0, queryIndex) else baseUrl + val query = if (queryIndex >= 0) baseUrl.substring(queryIndex + 1) else "" + + val params = query + .split('&') + .filter { it.isNotBlank() } + .filterNot { it.substringBefore('=') == "forceSignedDatetimes" } + .toMutableList() + + params += flag + + return buildString { + append(path) + append('?') + append(params.joinToString("&")) + append(fragment) + } +} + +internal fun ydbStarterDatabaseConfig( + enableSignedDatetimes: Boolean = false +): DatabaseConfig = DatabaseConfig { + explicitDialect = instantiateYdbDialect(enableSignedDatetimes) + defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE + defaultReadOnly = false + useNestedTransactions = false +} + +private fun instantiateYdbDialect(enableSignedDatetimes: Boolean): YdbDialect { + val ctor = YdbDialect::class.java.getDeclaredConstructor(Boolean::class.javaPrimitiveType) + ctor.isAccessible = true + return ctor.newInstance(enableSignedDatetimes) +} diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfigurationTest.kt b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfigurationTest.kt index 9207b006..6c6f43cf 100644 --- a/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfigurationTest.kt +++ b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbExposedAutoConfigurationTest.kt @@ -1,11 +1,10 @@ -package tech.ydb.exposed.dialect.spring.boot.autoconfigure +package tech.ydb.exposed.dialect.spring.boot.autoconfigure import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import org.springframework.context.support.StaticApplicationContext import tech.ydb.exposed.dialect.YdbDialect import java.sql.Connection @@ -13,12 +12,8 @@ class YdbExposedAutoConfigurationTest { @Test fun `creates database config aligned with YDB defaults`() { - val autoConfiguration = YdbExposedAutoConfiguration( - applicationContext = StaticApplicationContext(), - properties = YdbExposedProperties() - ) - - val databaseConfig = autoConfiguration.databaseConfig() + val autoConfiguration = YdbExposedAutoConfiguration() + val databaseConfig = autoConfiguration.ydbDatabaseConfig(YdbExposedProperties()) assertInstanceOf(YdbDialect::class.java, databaseConfig.explicitDialect) assertEquals(Connection.TRANSACTION_SERIALIZABLE, databaseConfig.defaultIsolationLevel) @@ -28,12 +23,10 @@ class YdbExposedAutoConfigurationTest { @Test fun `propagates signed datetime mode into explicit dialect`() { - val autoConfiguration = YdbExposedAutoConfiguration( - applicationContext = StaticApplicationContext(), - properties = YdbExposedProperties(enableSignedDatetimes = true) + val autoConfiguration = YdbExposedAutoConfiguration() + val databaseConfig = autoConfiguration.ydbDatabaseConfig( + YdbExposedProperties(enableSignedDatetimes = true) ) - - val databaseConfig = autoConfiguration.databaseConfig() val dialect = databaseConfig.explicitDialect as YdbDialect assertTrue(dialect.enableSignedDatetimes) diff --git a/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbSpringBootContextTest.kt b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbSpringBootContextTest.kt index 2bbf727d..83885cc7 100644 --- a/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbSpringBootContextTest.kt +++ b/kotlin-exposed-dialect/spring-boot-starter/src/test/kotlin/tech/ydb/exposed/dialect/spring/boot/autoconfigure/YdbSpringBootContextTest.kt @@ -1,6 +1,7 @@ -package tech.ydb.exposed.dialect.spring.boot.autoconfigure +package tech.ydb.exposed.dialect.spring.boot.autoconfigure 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 @@ -19,7 +20,7 @@ import org.springframework.core.env.Environment import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource import tech.ydb.exposed.dialect.YdbDialect -import tech.ydb.exposed.dialect.YdbTable +import tech.ydb.exposed.dialect.createYdbStatement import tech.ydb.test.junit5.YdbHelperExtension import java.sql.Connection @@ -31,11 +32,12 @@ import java.sql.Connection ) class YdbSpringBootContextTest { - object SpringBootTable : YdbTable("spring_boot_starter_table") { + object SpringBootTable : Table("spring_boot_starter_table") { val id = integer("id") val name = varchar("name", 128) override val primaryKey = PrimaryKey(id) + override fun createStatement(): List = createYdbStatement() } @Autowired @@ -103,7 +105,13 @@ class YdbSpringBootContextTest { @JvmStatic @DynamicPropertySource fun springDatasourceProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url") { buildJdbcUrl() } + registry.add("spring.datasource.url") { + ydbStarterJdbcUrl( + url = buildJdbcUrl(), + enableSignedDatetimes = true + ) + } + registry.add("spring.datasource.driver-class-name") { "tech.ydb.jdbc.YdbDriver" } } private fun buildJdbcUrl(): String = buildString {