☰
Exposed DAO 实体关系实战指南:从表设计到关联查询的完整示例工程
2026/9/25 4:11:15 网站建设 项目流程
  • ORM
  • 后端
  • 数据存储

【免费下载链接】Exposed

Kotlin SQL Framework

项目地址:https://gitcode.com/gh_mirrors/ex/Exposed
点击查看免费下载

Exposed 的 DAO API 提供了一套面向对象的实体关系建模能力,让 Kotlin 开发者可以用类型安全的方式表达数据库表之间的关联。本文以官方示例工程exposed-dao-relationships为主线,完整讲解多对一、可选引用、有序引用、多对多、父子层级、复合主键引用与预加载(Eager Loading)七类关系的表定义、实体声明与查询写法,并给出可一键构建运行的 Gradle 工程,读完即可在自己的项目中落地这些关系模式。

示例工程是什么

exposed-dao-relationships是一个独立的 Gradle 示例应用,位于仓库的 documentation-website/Writerside/snippets/exposed-dao-relationships 目录下。它的唯一目的就是展示「如何用 Exposed DAO API 在实体之间建立关系」,其源码文件被官方文档主题 DAO-Relationships.topic 按行号直接引用,作为文档中所有代码块的权威来源。

工程采用标准的 Gradle 目录布局:

exposed-dao-relationships/ ├── README.md └── src/main/kotlin/org/example/ ├── App.kt # 程序入口:连接数据库、建表、依次运行各示例 ├── tables/ # 表定义(DSL 层) │ ├── UsersTable.kt │ ├── UserRatingsTable.kt │ ├── StarWarsFilmsTable.kt │ ├── ActorsTable.kt │ └── DirectorsTable.kt ├── entities/ # 实体定义(DAO 层) │ ├── UserEntity.kt │ ├── UserRatingEntity.kt │ ├── StarWarsFilmEntity.kt │ ├── ActorEntity.kt │ └── DirectorEntity.kt └── examples/ # 各关系的实际查询示例 ├── OneToManyExamples.kt ├── ManyToManyExamples.kt ├── ParentChildExamples.kt └── EagerLoadingExamples.kt

所有示例围绕一个主题域展开:Star Wars系列电影、用户评分、演员与导演。得益于这种统一的示例数据,每一种关系模式都能直观对照现实语义来理解。

构建与运行示例工程

按照 README.md 的说明,构建与运行都需要先切换到snippets目录(注意:不是示例工程自身目录,而是其上一级、包含settings.gradle.kts的 Gradle 根目录)。

构建整个工程:

./gradlew :exposed-dao-relationships:build

运行应用:

./gradlew :exposed-dao-relationships:run

运行时会执行以下流程(见 App.kt):

  1. 连接内存数据库jdbc:h2:mem:test(H2 驱动),并开启嵌套事务支持:
    Database.connect( "jdbc:h2:mem:test", "org.h2.Driver", databaseConfig = DatabaseConfig { useNestedTransactions = true } )
  2. 在transaction {}块内注册StdOutSqlLogger,将生成的 SQL 打印到控制台,便于观察每条关系查询对应的 SQL 语句。
  3. 通过SchemaUtils.create(...)依次创建所有涉及的表(ActorsTable、StarWarsFilmsTable、StarWarsFilmActorsTable、UserRatingsTable、UsersTable,以及父子关系示例中的DirectorsTable、StarWarsFilmsWithDirectorTable、StarWarsFilmRelationsTable)。
  4. 依次调用runOneToManyExample()、runManyToManyExample()、runParentChildExample()、runEagerLoadingExamples(),即运行examples目录下的全部函数。

只运行某一个示例也很简单:按 README 所述,直接修改App.kt,注释掉不需要的runXxxExample()调用后重新运行即可。例如只想观察多对一关系,可以只保留runOneToManyExample()。

表与实体的基本形态:IntIdTable + IntEntity

在进入关系定义之前,先看示例中最基础的表与实体配对(见 UsersTable.kt 与 UserEntity.kt):

object UsersTable : IntIdTable() { val name = varchar("name", MAX_USER_NAME_LENGTH) // MAX_USER_NAME_LENGTH = 50 } class UserEntity(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<UserEntity>(UsersTable) var name by UsersTable.name }

要点:

  • 表继承IntIdTable(),自动获得自增的Int主键列id;实体继承IntEntity,构造函数接收EntityID<Int>。
  • 实体通过by委托将表列映射为属性:var name by UsersTable.name表示可读写的普通字段。
  • 每张表/实体都配套一个companion object继承IntEntityClass<T>,它是后续执行new、findById、all、find等查询操作的入口。

多对一关系(Many-to-one)

多对一关系指「子表的多行记录可以引用父表的同一行记录」。示例中,用户UsersTable与评分UserRatingsTable就是典型的多对一:一个用户可以有多个评分,每个评分只属于一个用户;同时每一条评分还引用一部电影。

用 reference() 创建引用列

在子表UserRatingsTable中,通过reference()函数声明指向父表的列(见 UserRatingsTable.kt):

object UserRatingsTable : IntIdTable() { val value = long("value") val film = reference("film", StarWarsFilmsTable) val user = reference("user", UsersTable) }

reference(columnName, targetTable)会同时完成两件事:

  • 在UserRatingsTable上创建一个外键列(列类型与目标表主键一致,这里是Int);
  • 生成数据库层面的外键约束,保证引用的完整性。

在实体中使用 referencedOn

表定义完成后,在实体UserRatingEntity中通过referencedOn将引用列映射为强类型的实体属性(见 UserRatingEntity.kt):

class UserRatingEntity(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<UserRatingEntity>(UserRatingsTable) var value by UserRatingsTable.value var film by StarWarsFilmEntity referencedOn UserRatingsTable.film // 普通引用用 referencedOn var user by UserEntity referencedOn UserRatingsTable.user }

注意这里的关键字是referencedOn(表示「本实体的这个属性指向另一实体」,是多对一方向的映射)。

正向访问:像访问普通字段一样取值

一旦建立映射,获取关联对象与获取普通字段没有区别。见 OneToManyExamples.kt:

val filmRating = UserRatingEntity.new { value = MOVIE_RATING film = starWarsFilm user = user1 } // 返回一个 StarWarsFilmEntity 对象 val film = filmRating.film

创建评分实体时可以直接把starWarsFilm、user1实体对象赋给引用属性,Exposed 会自动把对应的主键值写入外键列。

反向访问:referrersOn 一对多

「给定一个用户,获取他的所有评分」属于一对多方向。虽然可以用UserRatingEntity.find { UserRatingsTable.user eq user.id }查询,但更优雅的做法是在父实体上声明referrersOn字段(见 StarWarsFilmEntity.kt):

class StarWarsFilmEntity(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<StarWarsFilmEntity>(StarWarsFilmsTable) var sequelId by StarWarsFilmsTable.sequelId var name by StarWarsFilmsTable.name var director by StarWarsFilmsTable.director val ratings by UserRatingEntity referrersOn UserRatingsTable.film // 必须用 val + referrersOn var actors by ActorEntity via StarWarsFilmActorsTable }

语法为val 属性 by 子实体类 referrersOn 子表引用列。此时在实体对象上访问film.ratings,就会返回该电影关联的所有UserRatingEntity的SizedIterable(见 OneToManyExamples.kt):

// 返回所有以该电影为 film 的 UserRatingEntity 对象 val filmRatings = starWarsFilm.ratings

代码注释强调:referrersOn属性必须使用val声明。

单值反向访问:backReferencedOn

backReferencedOn用于一对一的简化反向场景:当业务约束「每个用户只给一部电影评分」时,可在用户实体上直接暴露单个评分对象而非集合(见 UserEntity.kt):

class UserWithSingleRatingEntity(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<UserWithSingleRatingEntity>(UsersTable) var name by UsersTable.name val rating by UserRatingEntity backReferencedOn UserRatingsTable.user // 必须用 val + backReferencedOn }

访问方式:

user1.rating // 返回一个 UserRating 对象

backReferencedOn与referrersOn都要求val声明,二者区别在于返回的是单个实体对象(一对一语义)还是实体集合(一对多语义)。

可选引用(Optional reference)

如果某些评分来自匿名用户(没有注册账号),外键值可以为空。这时表与实体需要分别使用optReference()与optionalReferencedOn(见 UserRatingsTable.kt 与 UserRatingEntity.kt):

object UserRatingsWithOptionalUserTable : IntIdTable() { val value = long("value") val film = reference("film", StarWarsFilmsTable) val user = optReference("user", UsersTable) // 允许为空的外键 } class UserRatingWithOptionalUserEntity(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<UserRatingWithOptionalUserEntity>(UserRatingsWithOptionalUserTable) var value by UserRatingsWithOptionalUserTable.value var film by StarWarsFilmEntity referencedOn UserRatingsWithOptionalUserTable.film var user by UserEntity optionalReferencedOn UserRatingsWithOptionalUserTable.user }

对应的实体属性类型自动变为可空:user在未关联用户时返回null。optionalReferencedOn同样存在可选的反向变体optionalReferrersOn(用于一对多的可空反向引用),在复合主键一节还会再次遇到。

有序引用(Ordered reference)

默认情况下referrersOn返回的集合顺序由数据库决定。若希望固定排序,可在声明后追加orderBy(见 UserEntity.kt)。

单个列排序的最简形式:

val ratings by UserRatingEntity referrersOn UserRatingsTable.user orderBy UserRatingsTable.value

多列排序时传入列 to SortOrder的列表,可以混用升序(ASC)与降序(DESC):

val ratings by UserRatingEntity referrersOn UserRatingsTable.user orderBy listOf( UserRatingsTable.value to SortOrder.DESC, UserRatingsTable.id to SortOrder.ASC )

若不想使用中缀写法,等价于把orderBy作为普通方法链式调用:

val ratings by UserRating.referrersOn(UserRatings.user).orderBy( UserRatings.value to SortOrder.DESC, UserRatings.id to SortOrder.ASC )

多对多关系(Many-to-many)

多对多关系需要一张中间表来桥接两侧。示例中,电影与演员是多对多:一部电影有多个演员,一个演员也出演多部电影。

定义中间表

StarWarsFilmActorsTable用两条reference()分别指向电影表与演员表,并以这两列组成复合主键(见 ActorsTable.kt):

object StarWarsFilmActorsTable : Table() { val starWarsFilm = reference("starWarsFilm", StarWarsFilmsTable) val actor = reference("actor", ActorsTable) override val primaryKey = PrimaryKey(starWarsFilm, actor, name = "PK_StarWarsFilmActors_swf_act") // 主键名称可选 }

中间的连接表只需继承普通的Table,无需主键自增列。

在实体中用 via() 建立桥接

在StarWarsFilmEntity中通过via()声明集合属性(见 StarWarsFilmEntity.kt):

var actors by ActorEntity via StarWarsFilmActorsTable

写法与referrersOn不同:目标是另一端的实体类ActorEntity,中间经过连接表。之后就可以在创建电影时直接赋值演员集合(见 ManyToManyExamples.kt):

val actor = ActorEntity.new { firstname = "Daisy" lastname = "Ridley" } val film = StarWarsFilmEntity.new { name = "The Rise of Skywalker" sequelId = MOVIE2_SEQUEL_ID director = "J.J. Abrams" actors = SizedCollection(listOf(actor)) } val filmActors = film.actors filmActors.forEach { println(it.firstname) }

actors的类型是SizedIterable<ActorEntity>,写入时需要包装为SizedCollection(listOf(actor))。Exposed 会自动维护中间表:赋值即插入关联记录,读取即联表查询。

父子关系(Parent-child reference)

父子关系与多对多非常相似,区别在于中间表的两条引用都指向同一张表,用于表达层级结构(如续集/前传关系)。示例中StarWarsFilmRelationsTable的两列都指向StarWarsFilmsWithDirectorTable(见 StarWarsFilmsTable.kt):

object StarWarsFilmsWithDirectorTable : IntIdTable() { val name = varchar("name", MAX_VARCHAR_LENGTH) val director = reference("director", DirectorsTable) } object StarWarsFilmRelationsTable : Table() { val parentFilm = reference("parent_film_id", StarWarsFilmsWithDirectorTable) val childFilm = reference("child_film_id", StarWarsFilmsWithDirectorTable) override val primaryKey = PrimaryKey(parentFilm, childFilm, name = "PK_FilmRelations") }

这里parentFilm表示原版电影,childFilm表示续集/前传/衍生作品,中间表的两列只指向StarWarsFilmsWithDirectorTable,形成自引用。

实体层面,用两次方向相反的via()同时声明「续集」与「前传」两个视角(见 StarWarsFilmEntity.kt):

class StarWarsFilmWithParentAndChildEntity(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<StarWarsFilmWithParentAndChildEntity>(StarWarsFilmsWithDirectorTable) var name by StarWarsFilmsWithDirectorTable.name var director by DirectorEntity referencedOn StarWarsFilmsWithDirectorTable.director var sequels by StarWarsFilmWithParentAndChildEntity.via( StarWarsFilmRelationsTable.parentFilm, StarWarsFilmRelationsTable.childFilm ) var prequels by StarWarsFilmWithParentAndChildEntity.via( StarWarsFilmRelationsTable.childFilm, StarWarsFilmRelationsTable.parentFilm ) }

注意via()的两个参数顺序决定了方向:(parentFilm, childFilm)表示「从父到子」得到sequels,(childFilm, parentFilm)表示「从子到父」得到prequels。

创建与查询层级数据的完整过程见 ParentChildExamples.kt:

val director1 = DirectorEntity.new { name = "George Lucas" genre = Genre.SCI_FI } val film1 = StarWarsFilmWithParentAndChildEntity.new { name = "Star Wars: A New Hope"; director = director1 } val film2 = StarWarsFilmWithParentAndChildEntity.new { name = "Star Wars: The Empire Strikes Back"; director = director1 } val film3 = StarWarsFilmWithParentAndChildEntity.new { name = "Star Wars: Return of the Jedi"; director = director1 } // 建立父子关系 film2.prequels = SizedCollection(listOf(film1)) // Empire Strikes Back 是 A New Hope 的续集 film3.prequels = SizedCollection(listOf(film2)) // Return of the Jedi 是 Empire Strikes Back 的续集 film1.sequels = SizedCollection(listOf(film2, film3)) // A New Hope 有续集 Empire Strikes Back film2.sequels = SizedCollection(listOf(film3)) film1.sequels.forEach { sequel -> println("${sequel.name} is a sequel to ${film1.name}") } film3.prequels.forEach { prequel -> println("${film3.name} has a prequel: ${prequel.name}") }

复合主键引用(Composite primary key reference)

当目标表使用复合主键时,引用方式稍有不同。示例中DirectorsCompositeIdTable以「姓名 + 公会 ID」两列组成复合主键(见 DirectorsTable.kt):

object DirectorsCompositeIdTable : CompositeIdTable() { val name = varchar("name", NAME_LENGTH).entityId() val guildId = uuid("guild_id").autoGenerate().entityId() val genre = enumeration<Genre>("genre") override val primaryKey = PrimaryKey(name, guildId) }

要点:

  • 表继承CompositeIdTable,主键列需要调用.entityId()标记;
  • 实体继承CompositeEntity,构造函数接收EntityID<CompositeID>(见 DirectorEntity.kt)。

子表侧:表级外键约束 + referencedOn

引用复合主键表时,reference()不再适用,改为在子表中加入各主键对应的列,并在init块中通过foreignKey(...)声明表级外键约束(见 StarWarsFilmsTable.kt):

object StarWarsFilmsWithCompositeRefTable : IntIdTable() { val sequelId = integer("sequel_id").uniqueIndex() val name = varchar("name", MAX_VARCHAR_LENGTH) val directorName = varchar("director_name", MAX_VARCHAR_LENGTH) val directorGuildId = uuid("director_guild_id") init { foreignKey(directorName, directorGuildId, target = DirectorsCompositeIdTable.primaryKey) } }

关于表级外键约束的完整说明,可参考文档 Working-with-Tables.topic 中的 Foreign Key constraint 一节。

实体侧同样使用referencedOn,但传入的是整张表而非单个列(见 StarWarsFilmEntity.kt):

class StarWarsFilmWithCompositeRefEntity(id: EntityID<Int>) : IntEntity(id) { companion object : IntEntityClass<StarWarsFilmWithCompositeRefEntity>(StarWarsFilmsWithCompositeRefTable) var sequelId by StarWarsFilmsWithCompositeRefTable.sequelId var name by StarWarsFilmsWithCompositeRefTable.name var director by DirectorCompositeIDEntity referencedOn StarWarsFilmsWithCompositeRefTable }

访问与普通引用完全一致:

movie.director // 返回一个 Director 对象

反向访问复合主键引用

若想从导演侧获取其执导的所有电影,使用referrersOn并传入子表(见 DirectorEntity.kt):

class DirectorCompositeIDEntity(id: EntityID<CompositeID>) : CompositeEntity(id) { companion object : CompositeEntityClass<DirectorCompositeIDEntity>(DirectorsCompositeIdTable) var genre by DirectorsCompositeIdTable.genre val films by StarWarsFilmWithCompositeRefEntity referrersOn StarWarsFilmsWithCompositeRefTable }

访问:

director.films // 返回所有引用该导演的 StarWarsFilm 对象

此外,optionalReferencedOn、backReferencedOn、optionalReferrersOn等中缀函数同样支持CompositeEntity:只需要使用它们接受IdTable参数的重载,Exposed 会自动解析与复合主键关联的外键约束。

预加载(Eager Loading):消灭 N+1 查询

Exposed 的实体引用默认是懒加载的:第一次访问引用属性时才发起查询。当批量遍历实体并逐个访问引用时,会产生经典的「N+1」问题。若提前知道需要哪些引用,可以在父查询执行时一并加载,把多次查询合并为一次,见 EagerLoadingExamples.kt。

单个实体的 load()

使用load()并传入引用属性的KProperty:

UserEntity.findById(1)?.load(UserEntity::ratings)

load()支持引用套引用,例如UserRatingTable上有film引用,可以一次性加载两级:

UserEntity.findById(1)?.load(UserEntity::ratings, UserRatingEntity::film)

集合的 with()

对List、SizedIterable等实体集合,使用with()为集合中所有实体预加载指定引用,并返回原集合:

UserEntity.all().with(UserEntity::ratings)

SizedIterable的方法调用需要处于事务中,因此预加载后的集合不能直接在transaction块外使用,除非先转换为标准集合(如toList())。

注意:预加载结果与事务缓存

预加载的引用存储在事务缓存中,这意味着它们只在当前事务内有效,跨事务访问会拿不到。若需要在事务外读取引用值,可以在DatabaseConfig中开启keepLoadedReferencesOutOfTransaction。示例的App.kt中展示了DatabaseConfig的配置方式(目前配置的是useNestedTransactions = true)。

文本字段的预加载

另一个与懒加载相关的细节:部分数据库驱动出于性能与内存考虑,不会立即加载大文本内容,导致文本列只能在打开的transaction内取值。若希望文本内容在事务外也可用,在字段定义时开启eagerLoading:

object StarWarsFilmsTable : Table() { //... val description = text("name", eagerLoading = true) }

小结

关系模式表层 API实体层 API示例文件
多对一reference()referencedOnUserRatingsTable.kt、UserRatingEntity.kt
一对多(反向)—referrersOnStarWarsFilmEntity.kt
一对一(单值反向)—backReferencedOnUserEntity.kt
可选引用optReference()optionalReferencedOnUserRatingsTable.kt
有序引用—referrersOn+orderByUserEntity.kt
多对多中间表(复合主键)via()ActorsTable.kt、StarWarsFilmEntity.kt
父子层级自引用中间表via()(双向)StarWarsFilmsTable.kt、ParentChildExamples.kt
复合主键引用CompositeIdTable+foreignKeyreferencedOn/referrersOn(传表)DirectorsTable.kt、DirectorEntity.kt
预加载text(..., eagerLoading=true)load()/with()EagerLoadingExamples.kt

掌握referencedOn、referrersOn、backReferencedOn、optionalReferencedOn、via这五类中缀函数与load/with两个预加载入口,就能覆盖绝大多数实体关系建模场景。官方完整的 DAO 关系文档见 DAO-Relationships.topic,其中所有代码示例均直接来源于本文剖析的这个示例工程,按行号一一对应,可作为深入阅读的下一步资料。

  • ORM
  • 后端
  • 数据存储

【免费下载链接】Exposed

Kotlin SQL Framework

项目地址:https://gitcode.com/gh_mirrors/ex/Exposed
点击查看免费下载
上一篇:Gemma 3重磅发布:多模态AI新纪元,128K上下文与140+语言支持重塑开发范式
下一篇:终极指南:如何用 recast 构建智能代码质量检查工具

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询