- ORM
- 后端
- 数据存储
【免费下载链接】Exposed
Kotlin SQL Framework
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):
- 连接内存数据库
jdbc:h2:mem:test(H2 驱动),并开启嵌套事务支持:Database.connect( "jdbc:h2:mem:test", "org.h2.Driver", databaseConfig = DatabaseConfig { useNestedTransactions = true } ) - 在
transaction {}块内注册StdOutSqlLogger,将生成的 SQL 打印到控制台,便于观察每条关系查询对应的 SQL 语句。 - 通过
SchemaUtils.create(...)依次创建所有涉及的表(ActorsTable、StarWarsFilmsTable、StarWarsFilmActorsTable、UserRatingsTable、UsersTable,以及父子关系示例中的DirectorsTable、StarWarsFilmsWithDirectorTable、StarWarsFilmRelationsTable)。 - 依次调用
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() | referencedOn | UserRatingsTable.kt、UserRatingEntity.kt |
| 一对多(反向) | — | referrersOn | StarWarsFilmEntity.kt |
| 一对一(单值反向) | — | backReferencedOn | UserEntity.kt |
| 可选引用 | optReference() | optionalReferencedOn | UserRatingsTable.kt |
| 有序引用 | — | referrersOn+orderBy | UserEntity.kt |
| 多对多 | 中间表(复合主键) | via() | ActorsTable.kt、StarWarsFilmEntity.kt |
| 父子层级 | 自引用中间表 | via()(双向) | StarWarsFilmsTable.kt、ParentChildExamples.kt |
| 复合主键引用 | CompositeIdTable+foreignKey | referencedOn/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
相关推荐
generator-nm与CI/CD集成:自动化测试与部署实战指南
generator nm与CI/CD集成:自动化测试与部署实战指南 generator nm是一款强大的Node.js模块脚手架工具,能够帮助开发者快速搭建标准
SQLCoder TABLE JOIN实战:多表关联查询指南
SQLCoder TABLE JOIN实战:多表关联查询指南 一、痛点解析:你还在为多表关联查询头疼吗? 在数据查询领域,多表关联(TABLE JOIN)是最常
如何掌握面向对象设计中的关联关系:从理论到实战的完整指南
如何掌握面向对象设计中的关联关系:从理论到实战的完整指南 面向对象编程(OOP)中的关联关系是构建灵活、可维护软件系统的核心基础。在GitHub推荐项目精选 a
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考