之前的文章已经带领大家学习了解了 Exposed 是什么,有什么用途,本文将重点学习 Exposed 中如何定义数据表结构,并进行数据库 Schema 的创建和迁移。

文本将重点放在继承 Table 类来定义表,定义各种类型的列,以及相关的约束 primaryKey, unique, nullable 等,如何定义复合主键。

如何使用 SchemaUtils.create 来创建表,了解 SchemaUtils.drop 方法。

在生产环境,通常和 [[Flyway]] 或 [[Liquibase]] 等数据库迁移工具结合使用。

定义表

在 Exposed 中提供了多种表定义的方式,我们先来看看最基本的表定义方法。

object Users : Table("users") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 50)
    val createdAt = datetime("created_at")

    override val primaryKey = PrimaryKey(id)
}

常用修饰:primaryKey, uniqueIndex, nullable(),支持复合主键(PrimaryKey(col1, col2))。

如果是继承自 Table 表,那么开发者需要自己手动创建和管理所有的约束。但是在绝大部分的场景下,我们可能会为表创建一个自增的 ID 作为主键,所以我们来总结一下 Exposed 中的其他几个定义。

表类型 场景 特点
Table 标准表定义,自主管理 最灵活,需要手动定义所有约束
IntIdTable 整数自增主键 自动生成 id: Int 作为主键
LongIdTable 长整数自增主键 自动生成 id: Long 作为主键
UUIDTable UUID 主键 自动生成 id: UUID 作为主键
CompositeIdTable 复合主键表 支持多列联合主键,需要使用 .entityId()

基于上面的定义,我们可以定义出如下的表结构。

object Authors : Table("authors") {
    val id = integer("id").autoIncrement()
    val name = varchar("name", 50)
    override val primaryKey = PrimaryKey(id)
}

// LongIdTable 简化写法
object Articles : LongIdTable("articles") {
    // id 列自动生成
    val title = varchar("title", 100)
    val authorId = reference("author_id", Users.id)
}

// 复合主键表
object UserRoles : CompositeIdTable("user_roles") {
    val userId = reference("user_id", Users.id).entityId()
    val roleId = reference("role_id", Roles.id).entityId()
    val assignedAt = datetime("assigned_at")
}

列类型支持

数值类型

函数 数据库类型 Kotlin 类型 范围/说明
byte() TINYINT Byte -128, 127
ubyte() TINYINT UNSIGNED UByte 0, 255
short() SMALLINT Short -32768, 32767
ushort() SMALLINT UNSIGNED UShort 0, 65535
integer() INT Int -2^31, 2^31-1
uinteger() INT UNSIGNED UInt 0, 2^32-1
long() BIGINT Long -2^63, 2^63-1
ulong() BIGINT UNSIGNED ULong 0, 2^64-1
float() FLOAT/REAL Float 单精度浮点数
double() DOUBLE Double 双精度浮点数
decimal(precision, scale) DECIMAL BigDecimal 精确小数,指定精度和小数位数

比如金融经常下使用高精度的类型。

object FinancialData : Table("financial_data") {
    val amount = decimal("amount", 15, 2)  // 精度15,小数点后2位
    val taxRate = float("tax_rate")
    val count = uinteger("count")  // 无符号整数
}

字符串类型

函数 数据库类型 用途
char(length) CHAR(n) 固定长度字符串
varchar(name, length) VARCHAR(n) 可变长度字符串,最大长度限制
text() TEXT 大文本数据,无长度限制
mediumText() MEDIUMTEXT 中等长度文本(MySQL)
largeText() LONGTEXT 超长文本(MySQL)

布尔类型

val active = bool("active")

不同的数据库会映射到不同的类型,Oracle CHAR(1),SQL Server(BIT),其他数据库 BOOLEAN。

二进制类型

函数 数据库类型 用途
binary(name) VARBINARY 可变长度二进制数据
binary(name, length) VARBINARY(n) 固定最大长度二进制数据
blob() BLOB 大二进制对象

通常情况下不建议直接将图片等大文件存储到数据库,可以使用外部的对象存储,但是如果有小于 1KB 的内容,也不妨考虑放入数据库中。

时间类型

对于时间类型,需要添加依赖

implementation("org.jetbrains.exposed:exposed-java-time:0.50.1")
函数 数据库类型 用途
date() LocalDate 日期,年月日
time() LocalTime 仅时间,时分秒
datetime() LocalDateTime 日期,时间,无时区
timestamp() Instant UTC 时间戳
timestampWithTimeZone() OffsetDateTime 带时区的时间戳

Example

object Events : Table("events") {
    val eventDate = date("event_date")                    // 2023-12-25
    val startTime = time("start_time")                    // 14:30:00
    val createdAt = datetime("created_at")                // 2023-12-25 14:30:00
    val occurredAt = timestamp("occurred_at")             // UTC 时间戳
    val scheduledAt = timestampWithTimeZone("scheduled_at") // 带时区
}

UUID

使用随机的 UUID

val id = uuid("id")
val clientId = uuid("client_id").clientDefault { UUID.randomUUID() }

JSON

添加依赖

implementation("org.jetbrains.exposed:exposed-json:0.50.1")
函数 数据库类型 用途
json() JSON/TEXT JSON 文本格式存储
jsonb() JSONB JSON 二进制格式(PostgreSQL)

例子

@Serializable
data class Address(val street: String, val city: String, val zipCode: String)

object Customers : Table("customers") {
    val name = varchar("name", 100)
    val address = json<Address>("address")        // 使用 kotlinx.serialization
    val metadata = jsonb<Map<String, Any>>("metadata")  // PostgreSQL JSONB
}

数组类型

数组类型主要支持 PostgreSQL

fun <T> Table.array(name: String, columnType: ColumnType<T>): Column<Array<T>> =
    registerColumn(name, ArrayColumnType(columnType))

object Projects : Table("projects") {
    val tags = array("tags", VarCharColumnType(50))  // varchar[]
    val priorities = array("priorities", IntegerColumnType())  // int[]
}

表约束

单列主键

将某一列设置为主键

object SimpleTable : Table("simple_table") {
    val id = integer("id").autoIncrement()
    override val primaryKey = PrimaryKey(id, name = "simple_table_pkey")
}

复合主键

object OrderItems : Table("order_items") {
    val orderId = reference("order_id", Orders.id)
    val productId = reference("product_id", Products.id)
    val quantity = integer("quantity")

    override val primaryKey = PrimaryKey(orderId, productId, name = "order_items_pkey")
}

唯一约束

object Users : Table("users") {
    val email = varchar("email", 100).uniqueIndex()      // 单列唯一
    val firstName = varchar("first_name", 50)
    val lastName = varchar("last_name", 50)

    init {
        uniqueIndex("users_name_unique", firstName, lastName)  // 复合唯一约束
    }
}

外键约束

object Orders : Table("orders") {
    val customerId = reference(
        "customer_id",
        Customers.id,
        onDelete = ReferenceOption.CASCADE,    // 级联删除
        onUpdate = ReferenceOption.RESTRICT,   // 限制更新
        fkName = "fk_orders_customer"          // 外键名称
    )
}

// 可选外键(允许 NULL)
val categoryId = optReference("category_id", Categories.id)

ReferenceOption 选项

  • RESTRICT 限制操作
  • CASCADE 级联操作
  • SET_NULL,设置为 NULL
  • SET_DEFAULT,设置为默认值
  • NO_ACTION 无操作

复杂索引

object Products : Table("products") {
    val name = varchar("name", 100)
    val category = varchar("category", 50)
    val price = decimal("price", 10, 2)
    val isActive = bool("is_active")

    init {
        // 复合索引
        index("idx_category_price", false, category, price)

        // 唯一复合索引
        uniqueIndex("idx_name_category", name, category)

        // 带条件的部分索引
        index("idx_active_products", false, name, price) {
            isActive eq true
        }

        // Hash 索引
        index("idx_name_hash", false, name, indexType = "HASH")
    }
}

Check 约束

object Products : Table("products") {
    val price = decimal("price", 10, 2).check { it greater BigDecimal.ZERO }
    val name = varchar("name", 100).check { it.regexp("^[A-Za-z0-9 ]+$") }
    val status = enumerationByName("status", 10, Status::class)
        .check { it inList listOf("ACTIVE", "INACTIVE", "PENDING") }
}

默认值设置

object Users : Table("users") {
    val createdAt = datetime("created_at").defaultExpression(CurrentDateTime())
    val isActive = bool("is_active").default(true)
    val role = varchar("role", 20).clientDefault { "USER" }
    val uuid = uuid("uuid").clientDefault { UUID.randomUUID() }
}

利用代码创建表

SchemaUtils.create(Users) 创建表

Database.connect("jdbc:h2:mem:test", driver = "org.h2.Driver")

transaction {
    SchemaUtils.create(Users, Orders, OrderItems)  // 创建多张表

    // 检查表是否存在
    if (!Users.exists()) {
        SchemaUtils.create(Users)
    }
}

批量表管理

val allTables = arrayOf(Users, Products, Orders, OrderItems, Categories)

transaction {
    // 创建所有表
    SchemaUtils.create(*allTables)

    // 删除所有表(注意顺序,先删除有外键依赖的表)
    SchemaUtils.drop(*allTables.reversedArray())

    // 创建缺失的表
    SchemaUtils.createMissingTablesAndColumns(*allTables)
}

Exposed 自身不再维护自动迁移,可以结合 Flyway/Liquibase 等一起使用。

Flyway 集成

添加依赖

dependencies {
    implementation("org.flywaydb:flyway-core:9.22.3")
    implementation("org.jetbrains.exposed:exposed-migration:0.50.1")
}

配置 Flyway

class DatabaseMigration {
    fun setupDatabase(dataSource: DataSource) {
        // 先运行 Flyway 迁移
        val flyway = Flyway.configure()
            .dataSource(dataSource)
            .locations("db/migration")
            .load()

        flyway.migrate()

        // 再使用 Exposed 检查表结构差异
        Database.connect(dataSource)

        transaction {
            val tables = arrayOf(Users, Orders, Products)
            val statements = SchemaUtils.statementsRequiredToActualizeScheme(*tables)

            if (statements.isNotEmpty()) {
                logger.warn("数据库结构需要更新:\n${statements.joinToString("\n")}")
            }
        }
    }
}

创建迁移文件

-- db/migration/V1__Create_initial_tables.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
    total_amount DECIMAL(10,2) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);