Skip to content

Neton 数据库规范

状态:正式规范
定位:Entity 纯数据模型 + Table 表级入口,无 companion、无反射、无动态魔法。
原则:Kotlin Native 友好、IDE 首次打开即友好、语义清晰、可长期维护。
标签:No companion — No reflection — Adapter-based — Stateless


目录

  1. 总览
  2. API 设计
  3. Query DSL
  4. 架构实现(sqlx4k)
  5. SqlxTableAdapter 内部接口
  6. Phase 1 执行规范
  7. Contract Tests
  8. 冻结约束
  9. 未来扩展性(预留)
  10. 参考实现
  11. 结论与建议
  12. Schema Migration(数据库演进规范)

一、总览

1.1 定型 API 总览

层级形态示例
实体纯 data class,无 companiondata class User(...)
表级入口object <Entity>Table : Table<Entity, ID>object UserTable : Table<User, Long>
表级调用UserTable.get / destroy / update / queryUserTable.get(id)
实例级user.save() / user.delete()user.save()

1.2 核心思想

neton-database 的「灵魂层」设计:

  • SQLx 只做 driver / pool
  • KSP 生成 glue code
  • API 以 Entity 为中心(而不是 Store / Repo / Impl)
  • 极轻量 + 强类型 + 零心智负担的 Query DSL

目标不是:

  • ❌ jOOQ(太重、DSL 过度工程化)
  • ❌ MyBatis Plus(Wrapper 太 Java 味)
  • ❌ 方法名爆炸

而是:

Laravel 手感 + Kotlin DSL + 编译期安全

1.3 与行业对标

框架对应形态
Exposedobject Users : Table
jOOQUSERS 常量
SQLDelightuserQueries
Prismaprisma.user
RoomUserDao
neton-databaseobject UserTable : Table<User, Long>

二、API 设计

2.1 实体层(不写 companion)

kotlin
@Serializable
@Table("users")
data class User(
    @Id val id: Long?,
    val name: String,
    val email: String,
    val status: Int,
    val age: Int
)
  • 实体 = 纯数据模型,不承担持久化语义
  • companion object
  • 无反射、无 KClass 扩展

2.2 KSP 生成

2.2.1 生成对象命名与类型

规则
对象命名<EntityName>Table
对象类型Table<Entity, ID>(不暴露底层实现)
示例object UserTable : Table<User, Long> by SqlxTableAdapter<User, Long>(...)
实现层neton.database.adapter.sqlx.SqlxTableAdapter<T, ID>(adapter 包)

原则

  • 对外只暴露 Table<T, ID> 接口,组合/委托而非继承
  • 无 UserTableImpl 等多余实体,直接 by SqlxTableAdapter(...) 实例
  • 实现归属 neton.database.adapter.sqlx,便于未来换引擎

2.2.2 生成结构

@Table("users") data class User
    ↓ KSP
UserMeta          (internal, 元数据 + 类型安全 ColumnRef 属性)
UserRowMapper     (internal, 行映射)
UserTable         (public, object : Table<User, Long> by SqlxTableAdapter<User, Long>(...))
UserExtensions    (UserUpdateScope + UserTable.update + User.save/delete)

KSP 自动检测:

  • @SoftDelete — 在生成的 SqlxTableAdapter 中传入 softDeleteConfig 参数,启用自动软删过滤
  • @Id — 推导主键列名与 ID 类型
  • @Column — 自定义列名映射

生成的 UserMeta 仅包含元数据,不暴露 ColumnRef 属性(用户层统一使用 Entity::property 列引用):

kotlin
internal object UserMeta : EntityMeta<User> {
    override val table = "users"
    override val idColumn = "id"
    override val columns = listOf("id", "name", "email", "status", "deleted")
    override val columnTypes = mapOf(...)
}

业务层使用 User::status eq 1 而非 ColumnRef("status") eq 1(见 §3.3.5 KProperty1 DSL 冻结规则)。

包与命名冻结

  • SQLx 实现:neton.database.adapter.sqlx.SqlxTableAdapter
  • 生成物:object &lt;Entity&gt;Table : Table&lt;Entity, ID&gt;(public,ID 由主键类型推导)

2.2.3 表级 API

kotlin
object UserTable : Table<User, Long> by SqlxTableAdapter<User, Long>(...)

// Table 接口提供(get/destroy 保留,符合 Laravel 风格):
UserTable.get(id)                              // 主键查询
UserTable.destroy(id)                          // 按主键删除
UserTable.update(id) { name = x; email = y }   // KSP 生成 mutate 风格
UserTable.query { where { User::status eq 1 } }.list()
UserTable.findAll()
UserTable.count()
UserTable.ensureTable()
UserTable.getOrThrow(id)   // 抛 NotFoundException,HTTP 层可映射 404
UserTable.many(ids)        // 批量按 id 取
UserTable.destroyMany(ids) // 批量删除(含软删语义)

2.2.4 AutoStore(legacy,不推荐新项目)

  • 主路径:KSP 生成 object UserTable : Table&lt;User&gt; by SqlxTableAdapter(...),无 AutoStore。
  • AutoStore:legacy,仅提供最小 CRUD + transaction;不提供 query(均 throw UnsupportedOperationException)。
  • 更新 / Query DSL:由 KSP Table(UserTable.update / UserTable.query)统一提供。

2.2.5 实例级 API

kotlin
// KSP 生成
suspend fun User.save(): User
suspend fun User.delete(): Boolean

2.3 Table 接口(Phase 1 冻结)

kotlin
interface Table<T : Any, ID : Any> {
    // ----- CRUD(id 为泛型 ID) -----
    suspend fun get(id: ID): T?
    suspend fun insert(entity: T): T
    suspend fun update(entity: T): Boolean
    suspend fun save(entity: T): T
    suspend fun destroy(id: ID): Boolean
    suspend fun delete(entity: T): Boolean
    suspend fun exists(id: ID): Boolean
    suspend fun transaction(block: suspend Table<T, ID>.() -> R): R

    // ----- 查询:唯一入口 query { } -----
    fun query(block: QueryScope<T>.() -> Unit): EntityQuery<T>

    // ----- 便捷:单条 / 存在 / 批量 -----
    suspend fun oneWhere(block: PredicateScope<T>.() -> Predicate): T?
    suspend fun existsWhere(block: PredicateScope<T>.() -> Predicate): Boolean
    suspend fun many(ids: Collection<ID>): List<T>
    suspend fun destroyMany(ids: Collection<ID>): Int
}

不暴露 updateById(id, block):更新统一由 KSP 生成的 UserTable.update(id) { ... }(强类型)提供。

2.4 应用分层架构(v1 冻结)

Store 层废除。Table 已升级为完整数据访问层(单表 CRUD + JOIN DSL + typed projection), Store 不再需要。详见 JOIN 查询规范

冻结分层

#层级职责依赖规则
1ControllerHTTP 端点、DTO 绑定、鉴权注解、参数校验只依赖 Logic,禁止直接引用 Table
2Logic(Service)业务用例:聚合/事务/缓存/事件/审计/权限策略依赖 Table(单表 CRUD + JOIN DSL)
3Table数据访问(KSP 生成):CRUD + query DSL + JOIN DSL + typed projection无业务规则
4Model实体:纯 @Table data class无依赖

硬约束

约束规则说明
C1Controller 禁止引用 Table防止"Controller 写 SQL"的失控。所有数据操作必须经过 Logic 层
C2Logic 是唯一业务聚合层事务、跨表用例、缓存/锁/审计/事件 全部在此层
C3Table 是唯一数据访问入口不允许 Logic 层直接拼 raw SQL。80% 用 DSL,20% 用 DbContext(逃生口)
C4Model 是纯 data class不含业务方法、不含数据访问代码
C5Logic 只依赖稳定面Logic 层只允许依赖 DbContextTableSelectBuilder(NetonSQL)。禁止依赖 adapter.sqlx.*(internal 包从模块边界阻断)
C6DbContext 是唯一 SQL 执行入口所有 SQL 执行必须经过 DbContext(或 TxContext)。SqlxDatabase.require() 只存在于 adapter 内部(internal),Logic/Controller 禁止直接调用
C7事务只有 transaction { } 一种写法DbContext.transaction { } 是唯一事务入口。禁止 begin() / commit() / rollback() 暴露给业务层

2.5 判定规则与反模式

判定规则

场景正确归属说明
单表筛选 + 分页 + DTO 映射Logic → Table典型读模型查询
多对多联表查询(如 UserWithRoles)Logic → Table(JOIN DSL)不再需要 Store
事务性写入(如创建用户 + 初始化角色)Logic(transaction 块)事务边界在 Logic 层
领域规则集中(如禁用用户 → 踢下线 + 撤销 token)Logic复合业务操作

反模式

  • Controller 直接调用 Table — 禁止(约束 C1)。即使是简单 CRUD 也必须经过 Logic 层。
  • Logic 直接拼 raw SQL — 禁止(约束 C3)。SQL 操作通过 Table DSL 或 DbContext。
  • 为每个 Table 创建同名 Logic — 退化成转发器,违反聚合语义。Logic 按业务用例组织,不按表组织。
  • Logic 引用 adapter.sqlx.* — 禁止(约束 C5)。Logic 只依赖 DbContextTableSelectBuilder
  • Logic/Controller 直接调用 SqlxDatabase.require() — 禁止(约束 C6)。连接获取只在 adapter 内部。
  • 暴露 begin() / commit() / rollback() 给业务层 — 禁止(约束 C7)。事务只有 transaction { } 一种写法。

Store 废除路径

阶段状态
v1(当前)Store 废除;Table 升级支持 JOIN DSL;所有聚合逻辑归属 Logic 层

推荐目录结构

app/src/commonMain/kotlin/
├── controller/
│   └── UserController.kt          # HTTP 端点
├── logic/
│   ├── UserLogic.kt                # 用户业务用例(分页/筛选/CRUD)
│   ├── AuthLogic.kt                # 认证用例(登录/token/权限)
│   └── RoleLogic.kt                # 角色业务用例(分配/撤销/联查)
├── model/
│   ├── SystemUser.kt               # @Table data class
│   ├── Role.kt                     # @Table data class
│   ├── UserRole.kt                 # @Table data class
│   └── dto/
│       ├── UserWithRoles.kt        # 聚合 DTO
│       └── LoginRequest.kt         # 请求 DTO
└── build/generated/ksp/.../
    ├── SystemUserTable.kt          # KSP 生成
    ├── RoleTable.kt                # KSP 生成
    └── UserRoleTable.kt            # KSP 生成

v1 推荐写法

kotlin
// Logic 层(手写)— 业务用例
class UserLogic(private val ctx: NetonContext) {

    // 单表分页(直接调 Table)
    suspend fun page(username: String?, status: Int?, page: Int, size: Int): Page<SystemUser> =
        SystemUserTable.query {
            where {
                and(
                    whenNotBlank(username) { SystemUser::username like "%$it%" },
                    whenPresent(status) { SystemUser::status eq it }
                )
            }
            orderBy(SystemUser::createdAt.desc())
        }.page(page, size)

    // 联表查询(v1 JOIN DSL,不再需要 Store)
    suspend fun getWithRoles(userId: Long): Pair<SystemUser, List<Role>>? {
        val (q, U) = from(SystemUserTable)
        val UR = q.leftJoin(UserRoleTable).on { U.id eq it.userId }
        val R  = q.leftJoin(RoleTable).on { UR.roleId eq it.id }

        val rows = q.where(U.id eq userId)
            .select(U.id, U.username, R.id, R.name)
            .fetch()

        return rows.firstOneToMany(
            one = { it.into<SystemUser>() },
            many = { it.intoOrNull<Role>("role_", Role::id) },
            manyKey = { it.id }
        )
    }
}

三、Query DSL

3.1 设计目标(必须满足)

1️⃣ 极简人体工程学

kotlin
UserTable.query { where { User::status eq 1 } }.list()

2️⃣ 强类型

  • User::age gt 18(where 块内使用 KProperty1 与 PredicateScope)

3️⃣ 不暴露 SQLx

用户永远不知道底层是 sqlx4k / jdbc / sqlite / pg。

4️⃣ 零对象创建负担

Query 是轻量 struct(builder),不是 ORM Session。

5️⃣ 90% CRUD 场景一行解决

3.2 最终 API 预览(完整使用形态)

查询

唯一入口query { where { } }。where 块内使用 ColumnRefPredicateScopeallandor 等。

基础:

kotlin
UserTable.get(id)           // 主键查询
UserTable.query { where { all() } }.list()
UserTable.count()

where:

kotlin
UserTable.query { where { User::status eq 1 } }.list()

多条件:

kotlin
UserTable.query {
    where { and(User::status eq 1, User::age gt 18) }
}.list()

like:

kotlin
UserTable.query { where { User::name like "%jack%" } }.list()

orderBy + limitOffset:

kotlin
UserTable.query {
    where { User::status eq 1 }
    orderBy(User::age.desc())
    limitOffset(20, 0)
}.list()

分页:

kotlin
UserTable.query { where { User::status eq 1 } }.page(1, 20)
// 返回:Page<User>(items, total, page, size, totalPages)

单条 / exists:

kotlin
UserTable.oneWhere { User::email eq email }
UserTable.existsWhere { User::email eq email }

删除(按 id / 实例)

kotlin
UserTable.destroy(id)       // 按主键删除一条
user.delete()               // 实例删除

定型 API(KSP 生成)

kotlin
UserTable.get(id)                           // 主键查询
UserTable.destroy(id)                       // 按 id 删除
UserTable.update(id) { name = x; email = y }  // mutate 风格:lambda 内直接赋值,copy 由 KSP 内部生成
UserTable.query { where { } }.list() / .page()
user.save()
user.delete()

按 id 更新(mutate 风格):KSP 为每个实体生成 XxxUpdateScope(仅非 id 的 var 属性),UserTable.update(id) { block: XxxUpdateScope.() -> Unit } 内部实现为:取当前实体 → 构造 Scope(initial) → 执行 block → current.copy(...) → 保存并返回。

3.3 核心 DSL 设计(类型结构)

1️⃣ EntityQuery(query { } 返回)

kotlin
interface EntityQuery<T : Any> {
    suspend fun list(): List<T>
    suspend fun count(): Long
    suspend fun page(page: Int, size: Int): Page<T>
    fun select(vararg columnNames: String): ProjectionQuery
}

2️⃣ QueryScope(query { } 块内)

kotlin
class QueryScope<T>(meta: QueryMeta<T>) {
    fun where(block: PredicateScope.() -> Predicate)
    fun orderBy(vararg os: Ordering)
    fun select(vararg cols: ColumnRef)
    fun limitOffset(limit: Int, offset: Int)
    fun withDeleted()
}

3️⃣ PredicateScope(where { } 块内)

kotlin
class PredicateScope {
    fun all(): Predicate
    fun and(vararg ps: Predicate): Predicate
    fun or(vararg ps: Predicate): Predicate
    fun whenPresent(v: V?, block: (V) -> Predicate): Predicate
    fun whenNotBlank(v: String?, block: (String) -> Predicate): Predicate
    fun whenNotEmpty(v: Collection<V>?, block: (Collection<V>) -> Predicate): Predicate
}

4️⃣ ColumnRef 与运算符

kotlin
infix fun ColumnRef.eq(v: Any?): Predicate
infix fun ColumnRef.gt(v: Any?): Predicate
infix fun ColumnRef.like(v: String): Predicate
fun ColumnRef.asc(): Ordering
fun ColumnRef.desc(): Ordering

5️⃣ KProperty1 DSL(v1 冻结 — 唯一合法列引用方式)

v1 只允许使用 Entity::property 作为列引用。ColumnRef 操作符为 internal,用户不可见。

kotlin
// 属性引用 → 自动 camelCase → snake_case → ColumnRef
SystemUser::username like "%admin%"   // → ColumnRef("username") like "%admin%"
SystemUser::status eq 1               // → ColumnRef("status") eq 1
SystemUser::createdAt.desc()          // → ColumnRef("created_at").desc()

支持的运算符

kotlin
infix fun KProperty1<*, *>.eq(v: Any?): Predicate
infix fun KProperty1<*, *>.like(v: String): Predicate
infix fun KProperty1<*, *>.`in`(vs: Collection<Any?>): Predicate
infix fun KProperty1<*, *>.gt(v: Any?): Predicate
infix fun KProperty1<*, *>.ge(v: Any?): Predicate
infix fun KProperty1<*, *>.lt(v: Any?): Predicate
infix fun KProperty1<*, *>.le(v: Any?): Predicate
fun KProperty1<*, *>.asc(): Ordering
fun KProperty1<*, *>.desc(): Ordering

实现原理KProperty1.name(Kotlin/Native stdlib,非反射)→ camelToSnake → ColumnRef

禁止其他列引用方式(冻结)

写法状态说明
SystemUser::username唯一合法IDE 重构安全、零字符串、编译期类型检查
SystemUserMeta.username禁止Meta 不再生成 ColumnRef 属性
ColumnRef("username")禁止ColumnRef 操作符已设为 internal
SystemUser.username (companion)禁止不要求实体声明 companion object

完整使用示例

kotlin
class UserService(private val log: Logger) {
    suspend fun page(page: Int, size: Int, username: String?, status: Int?): PageResponse<UserVO> {
        val query = SystemUserTable.query {
            where {
                and(
                    whenNotBlank(username) { SystemUser::username like "%$it%" },
                    whenPresent(status) { SystemUser::status eq it }
                )
            }
            orderBy(SystemUser::id.desc())
        }
        val result = query.page(page, size)
        // ...
    }
}

3.4 KSP 生成结构(关键)

每个 Entity 生成 UserTable(委托 SqlxTableAdapter)与 UserExtensions(UserUpdateScope + 实例级扩展):

kotlin
object UserTable : Table<User, Long> by SqlxTableAdapter<User, Long>(...)

class UserUpdateScope(initial: User) {
    var name: String
    var email: String
    var status: Int
    var age: Int
    init { name = initial.name; email = initial.email; ... }
}

suspend fun UserTable.update(id: Long, block: UserUpdateScope.() -> Unit): User?
suspend fun User.save(): User = UserTable.save(this)
suspend fun User.delete(): Boolean = UserTable.delete(this)

业务层写法UserTable.get(id)UserTable.query { where { } }.list()UserTable.update(id) { name = x }user.save()

3.5 内部实现层级(架构原则)

层级内容
上层(用户 API)UserTable.query { where { } }
中层(Query DSL)EntityQueryPredicateColumnRefQueryScope
底层(驱动)SqlxTableAdapterSqlBuilderDialect

只有底层依赖 SQLx。

Query 层 = 纯抽象
未来若要换 jdbc / native sqlite / postgres driver,可零改动。

3.6 查询类型与 Page

query { } 返回 EntityQuery<T>;调用 select(...) 后变为 ProjectionQuery,返回行数据,避免同一链上 list() 既返回 T 又返回 Row 的类型分叉。

Page 类型(冻结)

  • 分页页码:从 1 开始(符合后台习惯)。
kotlin
data class Page<T>(
    val items: List<T>,
    val total: Long,
    val page: Int,   // 从 1 开始
    val size: Int
) {
    val totalPages: Long get() = if (size > 0) (total + size - 1) / size else 0L
}

EntityQuery 与 ProjectionQuery(冻结)

kotlin
interface EntityQuery<T : Any> {
    suspend fun list(): List<T>
    suspend fun count(): Long
    suspend fun page(page: Int, size: Int): Page<T>

    /** 指定列后变为投影查询,返回 Row,不再返回 T */
    fun select(vararg cols: ColumnRef): ProjectionQuery
}

interface ProjectionQuery {
    suspend fun rows(): List<Row>
    suspend fun count(): Long
    suspend fun page(page: Int, size: Int): Page<Row>
}
  • count() 与当前 where 完全一致,只发 SELECT COUNT(*) ... WHERE ...
  • orderBy 最小能力(冻结):支持 .orderBy(User::id.desc()).orderBy(User::name.asc()),以及 vararg 多列排序。

3.7 条件可选(PredicateScope 内)

where { } 内部使用,值为 null/空时追加条件(不生成 = null)。

kotlin
// 语义:value 非 null 时才加 (User::status eq value)
inline fun <T : Any, V> PredicateScope<T>.whenPresent(value: V?, block: (V) -> Predicate): Predicate =
    if (value != null) block(value) else Predicate.True

// 语义:text 非 null 且 isNotBlank 时才加 like
inline fun <T : Any> PredicateScope<T>.whenNotBlank(text: String?, block: (String) -> Predicate): Predicate =
    if (!text.isNullOrBlank()) block(text) else Predicate.True

// 语义:集合非空才加 in
inline fun <T : Any, V> PredicateScope<T>.whenNotEmpty(list: Collection<V>?, block: (Collection<V>) -> Predicate): Predicate =
    if (!list.isNullOrEmpty()) block(list) else Predicate.True

示例:

kotlin
UserTable.query {
    where {
        whenPresent(status) { User::status eq it }
        whenNotBlank(keyword) { User::name like "%$it%" }
        whenNotEmpty(ids) { User::id `in` it }
    }
    orderBy(User::id.desc())
}.page(page = 1, size = 20)

3.8 与目标对比

目标是否满足
Laravel 手感
Kotlin 风格
强类型
无字符串 SQL
无 Impl 类
不暴露 sqlx
list / count / page
低心智负担
可长期冻结

四、架构实现(sqlx4k)

4.1 当前架构概览

neton-database/
├── api/
│   ├── Table.kt              # 统一 CRUD + Query 接口
│   └── DbContext.kt          # raw SQL 执行上下文(Logic 层逃生口)
├── annotations/              # @Table, @Id, @Column (SOURCE)
├── config/                   # TOML 解析、DatabaseConfig
├── core/
│   └── AutoStore.kt          # legacy,委托 DatabaseManager
├── adapter/sqlx/             # SqlxTableAdapter + SqlxDatabase(主路径)
└── DatabaseExtensions.kt     # database { tableRegistry } DSL

4.2 设计目标

目标含义
更强生产级连接池、事务、迁移
更安全参数化 SQL、类型安全映射、无拼接
更易用保持 Store DSL,与 Neton Config SPI 集成
性能更好异步 I/O、连接池、预编译语句、KSP 零反射

4.3 目标架构(纯 sqlx4k)

依赖关系

neton-database
  └── sqlx4k-sqlite   // 或 sqlx4k-postgres / sqlx4k-mysql,按需选择
  └── sqlx4k-codegen  // KSP,可选,用于实体 ↔ SQL 生成
  └── neton-core

移除:自研 MemoryStore、SqliteConnectionFactory、任何非 sqlx4k 的 DB 实现。

目录结构

neton-database/
├── api/
│   ├── Table.kt              # 统一 CRUD + Query 接口
│   └── DbContext.kt          # raw SQL 执行上下文(Logic 层逃生口)
├── annotations/              # SOURCE,供 KSP 用
├── config/                   # TOML → sqlx4k 连接参数
├── core/
│   └── AutoStore.kt          # legacy,DatabaseManager 仅被 AutoStore 依赖
├── adapter/sqlx/             # SqlxTableAdapter + SqlxDbContext + SqlxDatabase(主路径)
├── query/                    # Query DSL、QueryRuntime、EntityPersistence
└── DatabaseExtensions.kt

数据流

UserTable (object 单例,KSP 生成) — 主路径;AutoStore 已 deprecated
    → SqlxTableAdapter<User, Long>(sqlxDatabase, UserMeta, UserRowMapper, ...)
    → sqlx4k: db.execute(stmt) / db.fetchAll(stmt, mapper)
  • Table 以 object 单例形式存在,KSP 生成 object UserTable : Table&lt;User, Long&gt; by SqlxTableAdapter(...)
  • SQL 由 SqlxTableAdapter 内部根据 EntityMeta 动态构建(参数化)

4.4 核心设计

sqlx4k 作为唯一底层

  • SQLite:开发/测试用 sqlite::memory:,生产用 sqlite://path/to/db
  • PostgreSQL / MySQL:通过 sqlx4k-postgres / sqlx4k-mysql
  • Memory:不再自研,统一用 sqlite::memory:

实体映射:KSP + RowMapper

方案 A(推荐):KSP 生成

  • 使用 sqlx4k-codegen:@Table + expect/actual 生成 insert()update()delete() Statement
  • 或自研 KSP:扫描 @Entity,生成 UserRowMapper、CRUD Statement 构建逻辑

方案 B:手写 RowMapper

  • 每个实体实现 RowMapper&lt;T&gt;
  • 适合实体少、结构稳定的场景

原则:避免运行时反射,优先 KSP 生成。

Table 实现:SqlxTableAdapter

kotlin
class SqlxTableAdapter<T : Any, ID : Any>(
    private val dbProvider: () -> Database = { SqlxDatabase.require() },
    private val meta: EntityMeta<T>,
    private val mapper: RowMapper<T>,
    private val toParams: (T) -> Map<String, Any?>,
    private val getId: (T) -> ID?,
    private val softDeleteConfig: SoftDeleteConfig? = null,
    private val autoFillConfig: AutoFillConfig? = null
) : Table<T, ID> {
    override suspend fun get(id: ID): T? = /* 参数化 SELECT WHERE id = ? */
    override suspend fun save(entity: T): T = /* INSERT + 返回生成 id */
    override suspend fun destroy(id: ID): Boolean = /* DELETE 或 UPDATE(软删) */
    // ...
}
  • CRUD 全部走参数化 SQL,无字符串拼接
  • 连接、事务由 sqlx4k 管理

QueryBuilder:生成 SQL + Statement

  • SqlxQueryBuilder 内部构建 WHEREORDER BYLIMIT
  • 输出 Statement + 参数列表,交给 db.fetchAll(stmt, mapper)
  • 禁止手拼 SQL,一律参数化

DatabaseManager 与生命周期(legacy)

  • 主路径database { tableRegistry = { clazz -> UserTable } },直接传入 KSP 生成的 Table,不依赖 DatabaseManager。
  • DatabaseManager:仅被 AutoStore 等 legacy 路径使用;ConnectionFactory 已移除,仅保留 tableRegistry 桥接。

事务

  • 使用 sqlx4k 的 db.transaction { }
  • Store 层可提供 suspend fun &lt;T&gt; transaction(block: suspend () -> T): T

4.5 安全性

措施说明
参数化查询全部使用 Statement.bind(),禁止 "$var" 拼接
类型安全映射RowMapper 或 KSP 生成,避免运行时反射
连接安全密码等敏感信息不落日志
迁移使用 sqlx4k db.migrate() 管理 schema 版本

4.6 易用性

改进说明
业务 APIUserTable.getUserTable.query { where { } }.list()user.save()
主路径KSP 生成 object UserTable : Table&lt;User, Long&gt; by SqlxTableAdapter(...),AutoStore 已 deprecated
@DatabaseConfig通过 Config SPI 注册数据源,与 security/routing 一致
URI 配置继续支持 database.conf 中的 uridriver
Memory 模式uri: sqlite::memory: 作为默认开发配置

4.7 性能

优化说明
连接池使用 sqlx4k 的 Pool.Options(maxConnections、idleTimeout 等)
预编译语句复用 Statement 结构,仅变化参数
异步 I/O全部 suspend,不阻塞线程
批量操作insertBatch 使用 sqlx4k 批量 API 或事务内循环
KSP 生成消除反射,减少运行时开销

4.8 与 sqlx4k 的映射

neton-databasesqlx4k
UserTable.get(id)db.fetchAll(stmt, mapper).firstOrNull()
UserTable.save(entity)db.execute(insertStmt)
UserTable.query { }.list()db.fetchAll(buildSelectStmt(), mapper)
事务db.transaction
连接池Driver.Pool.Options
迁移db.migrate(path)
MemorySQLite("sqlite::memory:")

4.9 设计原则(强制)

原则说明
Table 是唯一数据访问抽象业务层通过 Table(单表 CRUD + DSL)和 DbContext(raw SQL 逃生口)访问数据
禁止直接使用 sqlx Database业务层不得持有或调用 Database
禁止运行时反射实体映射用 KSP 或手写 RowMapper
禁止拼接 SQL一律参数化 Statement
单一实现只有 SqlxTableAdapter,无 memory/sqlite 多套
Table 必须无状态(stateless)不得在 Table 内缓存 entity 或持有 mutable 状态;Table = 纯函数式 + db 代理
Table 单例化使用 object UserTable 而非每次新建实例

4.10 不做的事情

  • 不自研数据库驱动
  • 不在运行时用反射解析实体
  • 不手拼 SQL 字符串
  • 不维护多套 Table 实现(memory/sqlite 等),统一为 SqlxTableAdapter + 不同 sqlx4k 后端

五、SqlxTableAdapter 内部接口

业务层请以 Entity 为中心 API 为准UserTable.get(id)UserTable.destroy(id)UserTable.update(id){ }UserTable.query { where { } }user.save()user.delete()
主路径:KSP 生成 object UserTable : Table&lt;User, Long&gt; by SqlxTableAdapter&lt;User, Long&gt;(...)。 本节为 SqlxTableAdapter 内部实现与设计原则 参考。

5.1 长期规范(铁律)

规则表述
Table 无状态 + 线程安全Table MUST be stateless and thread-safe. Table MUST NOT hold mutable state or cache entities.
SQL 编译期生成All SQL must be compile-time generated by KSP. Manual string concatenation SQL is forbidden.
唯一 Table 实现SqlxTableAdapter is the only official Table implementation. 缓存/多数据源应作为 Table 的包装层,而非替代实现。

5.2 SqlxTableAdapter 核心职责

SqlxTableAdapter&lt;T, ID&gt;Table&lt;T, ID&gt; 接口的唯一官方实现,由 KSP 生成的 object XxxTable 通过 by 委托使用。

kotlin
class SqlxTableAdapter<T : Any, ID : Any>(
    private val dbProvider: () -> Database = { SqlxDatabase.require() },
    private val meta: EntityMeta<T>,
    private val mapper: RowMapper<T>,
    private val toParams: (T) -> Map<String, Any?>,
    private val getId: (T) -> ID?,
    private val softDeleteConfig: SoftDeleteConfig? = null,
    private val autoFillConfig: AutoFillConfig? = null
) : Table<T, ID> {
    // CRUD 操作内部构建参数化 SQL,交由 sqlx4k 执行
    // Query DSL 通过 QueryAst → SqlBuilder → BuiltSql → sqlx4k 执行链路
}

关键特性

  • 无状态:不缓存 entity、不持有可变状态
  • 参数化 SQL:全部通过 Statement.bind() 绑定参数,禁止字符串拼接
  • 软删自动注入:根据 softDeleteConfig 在查询阶段自动追加 AND deleted = ?
  • 审计字段:根据 autoFillConfig 在 insert/update 时自动填充时间戳

5.3 DbContext(SQL 执行 + 事务唯一入口)

DbContext 是 Logic 层的唯一 SQL 执行上下文,封装当前数据源、事务上下文、执行策略。 当 Table DSL 无法覆盖复杂场景(如多表 JOIN、动态 SQL)时,Logic 层通过 DbContext 执行原生参数化 SQL。

kotlin
interface DbContext {
    /** 执行查询,返回行列表 */
    suspend fun fetchAll(sql: String, params: Map<String, Any?> = emptyMap()): List<Row>

    /** 执行写操作,返回影响行数 */
    suspend fun execute(sql: String, params: Map<String, Any?> = emptyMap()): Long

    /** 唯一事务入口(约束 C7) */
    suspend fun <R> transaction(block: suspend DbContext.() -> R): R
}

职责边界(冻结)

规则说明
唯一执行入口所有 SQL 执行必须经过 DbContext(或事务内的 TxContext),禁止绕过直接拿连接/adapter(约束 C6)
SqlxDatabase.require() 仅 adapter 内部SqlxDatabaseadapter.sqlx 包内、internal 可见性,业务层/Logic 层不可直接调用
事务只有 transaction { }禁止 begin() / commit() / rollback() 暴露给业务层(约束 C7)。与 jOOQ dsl.transaction { } 对齐
未来可扩展DbContext 是 interceptor / slow SQL sampling / context condition injection / query cache 的注入点

工厂函数收口

kotlin
// 当前(v1):全局工厂,internal 可见性
internal fun dbContext(): DbContext = SqlxDbContext

// 未来(v3 multi-source):从 NetonContext 获取
// val db = ctx.get(DbContext::class)           // 默认数据源
// val db = ctx.get(DbContext::class, "analytics")  // 命名数据源

dbContext() 全局工厂标记为 internal。Logic 层通过构造函数注入 DbContext(默认值 dbContext()), 为未来 multi-source / transaction-scoped context 预留替换点,不会被全局工厂卡住。

使用方式

kotlin
class UserLogic(private val db: DbContext = dbContext()) : DbContext by db {

    // raw SQL 逃生口
    suspend fun getWithRoles(userId: Long): UserWithRoles? {
        val rows = fetchAll("SELECT ... FROM users u LEFT JOIN ...", mapOf("uid" to userId))
        // 手动映射
    }

    // 事务(唯一写法)
    suspend fun createWithRoles(user: User, roleIds: List<Long>) {
        db.transaction {
            val saved = UserTable.save(user)
            roleIds.forEach { roleId ->
                UserRoleTable.save(UserRole(null, saved.id!!, roleId))
            }
        }
    }
}

约束:DbContext 仅在 Logic 层使用,Controller 禁止直接持有 DbContext(约束 C6)。

5.4 Batch API 实现

kotlin
// Table 接口提供批量操作
suspend fun insertBatch(entities: List<T>): Int
suspend fun updateBatch(entities: List<T>): Int
suspend fun saveAll(entities: List<T>): List<T>
  • 批量操作在单事务内执行
  • 若 sqlx4k 提供 executeBatch,可再优化

5.5 接口定型清单

API说明
Table.get/findAll主键查询 / 全量查询
Table.insert/update/delete基础 CRUD,insert 返回 T(含生成 id)
Table.saveupsert 语义
Table.destroy(id)按主键删除(含软删语义)
Table.insertBatch/updateBatch/saveAll批量操作
Table.query { }Query DSL 入口
Table.transaction事务封装
DbContext.fetchAll/executeLogic 层 raw SQL 逃生口
user.save() / user.delete()KSP 生成的实例级扩展

5.6 实施优先级

  1. SqlxTableAdapter 核心 CRUD:get/save/update/destroy/findAll
  2. Query DSL 打通:query { where { } }.list() / .page() / .count()
  3. Batch API:insertBatch、updateBatch、saveAll
  4. ActiveRecord 扩展user.save()user.delete() 扩展函数
  5. transaction:Table 级事务封装
  6. DbContext:Logic 层 raw SQL 逃生口
  7. Stream/Flow 查询:v3 可选,大表场景

六、Phase 1 执行规范

目标:脚手架能落地的「底座」——缺一不可。
验收闭环:用 Postgres/MySQL 跑通「后台列表页」:分页 + 可选筛选 + 软删 + @CreatedAt/@UpdatedAt。
命名:统一用 Neton 风格。

6.1 前置结论(冻结)

数据库支持策略

数据库策略说明
PostgreSQLP0,参考实现/默认优先保证行为一致、测试覆盖
MySQLP0,必须同批支持中国生态现实
SQLite可选保留仅用于 demo / local / CI;脚手架默认不用;若维护成本大可后续移除
  • 方言层:Dialect(Postgres / MySQL / SQLite optional)
  • 占位符:Postgres $1,$2...,MySQL ?
  • 分页语义:统一为 limit + offset;SQL 语法由 Dialect 输出(PG: LIMIT x OFFSET y;MySQL: LIMIT y, x
  • 时间/布尔类型:冻结映射规则,避免边缘 bug

Phase 1 方言边界(冻结):仅保证分页语义(limit+offset)、LIKEINCOUNT(*)、基础比较运算在 PG/MySQL 一致;不做复杂 JSON/ARRAY/RETURNING 等差异处理,留 P2。

命名冻结表

能力命名说明
查询入口query { }构造查询,内含 where / orderBy / select
列表.list()取列表
分页.page(page, size)返回 Page&lt;T&gt;
计数.count()与 where 一致,走 SELECT COUNT(*)
单条oneWhere { }单条件/多条件返回一条
存在existsWhere { }条件是否存在
批量 getmany(ids)批量按 id 取
批量删除destroyMany(ids)批量删除(含软删语义)
条件可选whenPresent(value) { field eq it }值非 null 才加条件
条件可选whenNotBlank(text) { field like "%$it%" }非空字符串才 like
条件可选whenNotEmpty(list) { field in it }非空集合才 in
投影select(prop1, prop2)指定列,避免 SELECT *
软删destroy(id)行为由 @SoftDelete 决定:UPDATE 或 DELETE
审计@CreatedAt / @UpdatedAt自动填 createdAt/updatedAt(epoch millis)

保留不动get(id)destroy(id)save(entity)update(entity)exists(id)transaction { }

主键与批量类型

  • Table&lt;T, ID : Any&gt; 泛型主键。ID 类型由实体主键字段决定(如 LongStringUUID)。
  • get(id: ID), destroy(id: ID), exists(id: ID) — id 类型为泛型 ID
  • many(ids: Collection&lt;ID&gt;), destroyMany(ids: Collection&lt;ID&gt;) — 批量 API 与主键类型一致
  • Phase 1 脚手架默认主键类型:Long。KSP 从实体的 @Id 字段推导 ID 类型,常见为 Long。

6.2 Phase 1(P0)能力清单

编号能力验收标准
P0-1PostgreSQL + MySQL 支持同一套 Table/Query 在 Postgres、MySQL 均可运行;Dialect + 占位符 + 分页 + 类型映射冻结
P0-2where DSL 打通query { [where { };] orderBy(...) }.list()/.page()/.count() 全链路可用;where 可选;count 为真 COUNT(*)
P0-3@SoftDeletedestroy → UPDATE;所有 SELECT 默认加 deleted = ?(参数绑定 false);可逃逸查询已删(如 withDeleted { }
P0-4@CreatedAt / @UpdatedAtinsert 自动填 createdAt + updatedAt;update 自动填 updatedAt(epoch millis, UTC)
P0-5条件可选whenPresent / whenNotBlank / whenNotEmpty 在 where 块内可用
P0-6SELECT 指定列select(prop1, prop2) 得到 ProjectionQuery,用 .rows() / .page()List&lt;Row&gt; / Page&lt;Row&gt;
P0-7count 真实现与 where 完全一致,仅发 SELECT COUNT(*),禁止 findAll().size

6.3 @SoftDelete

脚手架默认(冻结)

  • 注解名:@SoftDelete(字段级)。
  • 支持两种字段类型
    • deleted: Booleanfalse = 未删除,notDeletedValue = false
    • deleted: Int0 = 未删除,notDeletedValue = 0
    • deletedAt: Long?(可选),软删时填 epoch millis。
  • KSP 根据 @SoftDelete 标注字段的类型自动推导 notDeletedValue:Int/Long → 0,Boolean → false
  • 默认过滤语义:WHERE ... AND deleted = ?(参数绑定 notDeletedValue,Phase 1 全部走参数绑定,不拼 literal)。

行为(冻结)

操作有 @SoftDelete无 @SoftDelete
destroy(id)UPDATE t SET deleted = true [, deletedAt = ?] WHERE id = ?DELETE FROM t WHERE id = ?
所有 SELECT(get/query/oneWhere 等)自动追加 AND deleted = ?(参数绑定 false)不追加
逃逸withDeleted { query { }.list() } 可查已删
  • SoftDelete 条件注入位置(冻结):注入发生在 QueryBuilder 构建 SQL 阶段,不写在 PredicateScope 内,避免污染用户条件。最终 SQL 形态为 WHERE (user_conditions) AND deleted = ?(参数绑定 false),软删条件始终位于用户条件之后、以 AND 形式追加。Phase 1 建议全部走参数绑定,不拼 literal。
  • withDeleted:为 QueryBuilder 层级的开关(非 Predicate 层),用于逃逸时跳过上述注入。
  • destroy 时由 Adapter 走 UPDATE 分支。

6.4 @CreatedAt / @UpdatedAt(v1 冻结)

注解定义

kotlin
@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
annotation class CreatedAt   // insert 时自动填充

@Target(AnnotationTarget.PROPERTY)
@Retention(AnnotationRetention.SOURCE)
annotation class UpdatedAt   // insert/update 时自动填充

字段类型(冻结)

  • 类型:Long(epoch millis, UTC)。
  • 时间统一用 epoch millis,避免 PG/MySQL 时间类型与时区差异;Phase 1 求稳。

行为(冻结)

操作填充字段
insert@CreatedAt + @UpdatedAt(均填当前时间)
update@UpdatedAt(填当前时间)

实体示例

kotlin
@Table("system_users")
data class SystemUser(
    @Id val id: Long?,
    val username: String,
    @CreatedAt val createdAt: Long = 0,
    @UpdatedAt val updatedAt: Long = 0
)

KSP 自动生成 AutoFillConfig(createdAtColumn = "created_at", updatedAtColumn = "updated_at")SqlxTableAdapter 在 insert/update 时自动覆盖对应列值为 Clock.System.now().toEpochMilliseconds()

v1 不内建用户审计

  • 不内建 @CreatedBy / @UpdatedBy,不提供默认 actor 语义。
  • 若业务需要 createdBy/updatedBy,在应用层自行实现(service 层手动赋值)。

6.5 SELECT 指定列

  • query { } 内调用 select("id", "name") 后,返回类型变为 ProjectionQuery
  • 生成 SQL:SELECT id, name FROM users WHERE ...,禁止该路径下 SELECT *
  • 取数据用 .rows(): List&lt;Row&gt;,分页用 .page(page, size): Page&lt;Row&gt;;不与 EntityQuery 的 .list(): List&lt;T&gt; 混用,类型自洽。

6.6 最终验收闭环(Phase 1 完成标准)

以下用 PostgreSQL 或 MySQL 跑通即视为 Phase 1 达标:

  1. 后台列表页

    • GET /users?page=1&size=20&status=1&keyword=tom
    • 实体列表:UserTable.query { where { ... }; orderBy(User::id.desc()) }.page(1, 20)Page&lt;User&gt;
    • 指定列列表:UserTable.query { where { ... }; orderBy(User::id.desc()) }.select("id", "name", "status").page(1, 20)Page&lt;Row&gt;(ProjectionQuery)。
    • 返回:items、total(count)、page(从 1 开始)、size、totalPages。
  2. 删除

    • destroy(id: ID) 对带 @SoftDelete 的表执行软删(UPDATE deleted = true)。
  3. 更新

    • update(entity) 时,updatedAt 由 @UpdatedAt 自动填充(类型 Long)。
  4. count

    • 列表与 total 使用同一 where 条件,total 来自 query { ... }.count(),且为 SELECT COUNT(*)

6.7 Phase 1 实现层结构

保持 Neton 风格:少抽象、可维护、便于 debug。实现落点稳定在 4 个核心组件。

四层总结构

UserTable.query { ... }  →  QueryAst(纯数据结构)
                          →  SqlBuilder + Dialect  →  BuiltSql(sql, args)
                          →  SqlxTableAdapter      →  list() / count() / page() / rows()
层级职责
Query AST纯数据结构,描述 query;可打印便于 debug
Dialect占位符、标识符 quoting、分页语法差异
SqlBuilderAST → SQL 字符串 + 参数列表
SqlxTableAdapter执行 SQL、映射结果,不膨胀为巨型类

Query AST(Phase 1 最小形态)

kotlin
data class QueryAst<T : Any>(
    val table: TableMeta,
    val where: Predicate? = null,           // 可空,无 where 则不生成 WHERE
    val orderBy: List<Ordering> = emptyList(),
    val limit: Int? = null,
    val offset: Int? = null,
    val projection: List<ColumnRef> = emptyList(),  // 空 = SELECT *
    val includeDeleted: Boolean = false              // withDeleted 开关
)

data class Ordering(val column: ColumnRef, val dir: Dir)  // ASC / DESC
data class ColumnRef(val name: String)  // 实际 SQL 列名,由 KSP TableMeta/UserMeta 提供

Predicate(与现有 PredicateScope 对应):Phase 1 最小集——True、And(list)、Or(list)(可选)、Eq/Like/In、Gt/Lt/Ge/Le;whenPresent 系列返回 Predicate.True,SqlBuilder 阶段将 True 视为无条件。ColumnRef 与列名映射由 KSP 生成的 EntityMeta 提供。

SoftDelete 注入落点

  • 注入写在 PredicateScope,写在 SqlBuilder 使用 AST 之前
  • 对 QueryAst 做一次 normalize:若实体带 @SoftDelete 且 includeDeleted == false,则将 where 置为 And(原 where, Eq(deletedColumn, false));若无原 where,则 where = Eq(deletedColumn, false)。SqlBuilder 将 Eq(col, false) 输出为 column = ?,args 含 false(参数绑定,不拼 literal)。
  • 接口建议:fun QueryAst&lt;T&gt;.normalizeForSoftDelete(meta: EntityMeta): QueryAst&lt;T&gt;,在 buildSelect / buildCount 前调用。

Dialect 抽象(冻结)

方言层设计决定后续 JOIN / JSON / RETURNING 等能否平稳扩展;Phase 1 冻结接口与 PG/MySQL 实现。

接口(冻结)

kotlin
interface Dialect {
    val name: String

    /** 占位符 */
    fun placeholder(index: Int): String

    /** 标识符引用(列名 / 表名) */
    fun quoteIdent(name: String): String

    /** LIMIT/OFFSET 语法:入参为已替换占位符的字符串(如 "$1" 或 "?") */
    fun limitOffset(limit: String?, offset: String?): String

    /** LIKE 表达式(列名与占位符已 quote/placeholder);若需 ESCAPE 子句由实现决定 */
    fun likeExpression(column: String, placeholder: String): String
}

PostgreSQL

kotlin
object PostgresDialect : Dialect {
    override val name = "postgres"
    override fun placeholder(index: Int) = "$$index"
    override fun quoteIdent(name: String) = "\"$name\""
    override fun limitOffset(limit: String?, offset: String?): String = when {
        limit != null && offset != null -> "LIMIT $limit OFFSET $offset"
        limit != null -> "LIMIT $limit"
        else -> ""
    }
    override fun likeExpression(column: String, placeholder: String) = "$column LIKE $placeholder"
}

MySQL

kotlin
object MySqlDialect : Dialect {
    override val name = "mysql"
    override fun placeholder(index: Int) = "?"
    override fun quoteIdent(name: String) = "`$name`"
    override fun limitOffset(limit: String?, offset: String?): String = when {
        limit != null && offset != null -> "LIMIT $offset, $limit"
        limit != null -> "LIMIT $limit"
        else -> ""
    }
    override fun likeExpression(column: String, placeholder: String) = "$column LIKE $placeholder"
}

⚠️ LIMIT 语法差异(必须遵守):MySQL 为 LIMIT offset, limit(先 offset 后 limit),PostgreSQL 为 LIMIT limit OFFSET offset。SqlBuilder 调用 dialect.limitOffset(limitPh, offsetPh) 时传入的占位符顺序与参数列表顺序必须与 Dialect 约定一致,避免分页错乱。

SqlBuilder 输出

  • 输出类型冻结:data class BuiltSql(val sql: String, val args: List&lt;Any?&gt;)
  • WHERE 子句生成时参数按递增编号;In(list) 展开为 IN ($1,$2,...)IN (?,?,...)
  • Phase 1 主键为泛型 ID(Long/String/UUID 等),条件值与参数类型由 Dialect 统一处理。
  • LIKE 需做 escape 处理(规范层可注明:Phase 1 至少对 %/_ 做转义,避免注入与误匹配)。

SqlxTableAdapter 职责

  • 接收 QueryAst(或已包装的 EntityQuery/ProjectionQuery 持有 AST)。
  • 调用 SqlBuilder 得到 BuiltSql,再交 sqlx4k 执行。
  • EntityQuerylist() = buildSelect(select *) + 执行 + RowMapper → List<T>;count() = buildCount + 执行取 Long;page() = 先 count 再 buildSelect(limit/offset)。
  • ProjectionQueryrows() = buildSelect(projection 列) + 执行 → List<Row>;page() 同理。Row 使用 neton 自有轻量接口,不泄漏 sqlx4k Row。

page() 执行策略(冻结)

  • Phase 1 明确执行两条 SQL:先 COUNT(*),再 SELECT ... LIMIT ? OFFSET ?
  • 不做窗口函数或 COUNT(*) OVER(),留 P2。

DSL 层:ColumnRef 与 KSP(推荐)

  • v1 冻结:where / orderBy 统一使用 Entity::property(KProperty1)作为列引用。
  • KProperty1.name → camelToSnake → ColumnRef(框架内部转换),无反射(Native 友好)。

DSL 写法

kotlin
UserTable.query {
    where {
        User::status eq 1
        whenPresent(keyword) { User::name like "%$it%" }
    }
    orderBy(User::id.desc())
}.page(1, 20)
  • PredicateScope 内使用 Entity::propertyeq/like/in 等组合,框架内部转为 Predicate AST,SqlBuilder 用 quoteIdent(column.name) 生成 SQL。

契约测试:COUNT 与 page().total 一致

必须有一条契约测试,保证同一 where 条件下 count 与分页 total 一致,否则将来 count 与 page 条件易分叉:

kotlin
val page = UserTable.query {
    where { User::status eq 1 }
}.page(1, 10)

val manualCount = UserTable.query {
    where { User::status eq 1 }
}.count()

assert(page.total == manualCount)
  • 实现 Phase 1 时将此测试加入必跑用例;CI 通过作为「count 与 list/page 同源」的验收依据。

6.8 Phase 1 实现检查清单

实现者按此清单逐项验收,避免走偏。

类别检查项
数据库底座[ ] PostgreSQL 通过
[ ] MySQL 通过
[ ] LIMIT/OFFSET 语法按 Dialect(PG: LIMIT x OFFSET y;MySQL: LIMIT y, x)
[ ] LIKE 统一
[ ] COUNT(*) 正确(与 where 一致,非 findAll().size)
[ ] 契约测试:同一 where 下 page().total == count()
DSL[ ] query { } 支持空 where(无 where 时不生成 WHERE 子句)
[ ] whenPresent / whenNotBlank / whenNotEmpty 正确
[ ] orderBy asc/desc、vararg 多列
[ ] many(ids) / destroyMany(ids) 的 IN 生成正确占位符(ID)
软删[ ] destroy(id) → UPDATE deleted = true(及可选 deletedAt)
[ ] 所有 SELECT 自动过滤 deleted = ?(参数绑定 false,注入在 QueryBuilder 构建阶段,AND 追加)
[ ] withDeleted { } 可逃逸(QueryBuilder 层级开关)
@CreatedAt/@UpdatedAt[ ] insert 自动填 createdAt、updatedAt(Long,epoch millis UTC)
[ ] update 自动填 updatedAt
投影[ ] select(...) 返回 ProjectionQuery
[ ] ProjectionQuery.rows() 返回 List<Row>
[ ] EntityQuery.list() 仅返回 List<T>,不返回 Row
类型与入口[ ] 主键与批量 API 均为泛型 ID / Collection<ID>
[ ] 无 Table.updateById;更新仅 KSP UserTable.update(id){ } 与 update(entity)
[ ] 唯一条件查询入口为 query

6.9 后续 Phase 简述

  • Phase 2~4(JOIN 查询):强类型列引用、Typed Projection、JOIN AST,详见 JOIN 查询规范
  • 执行链与约束:DbContext 统一执行门面、QueryInterceptor 拦截链,详见 执行链与约束规范

七、Contract Tests(语义升级锁死)

测试目的验证方式
生成物 ContractKSP 对 @Table 实体必须生成 object UserTable : Table&lt;User, Long&gt;mvc 编译通过 + TableUserContractTest
禁止回潮Store 不实现 Table,无法作为 tableRegistry 返回值Table 为唯一单表接口;Store 不实现 Table 类型约束
COUNT 一致性同一 where 条件下 count 与分页 total 一致契约测试:page().total == query().count()
[[sources]] 配置契约database/redis/storage 三模块配置解析一致缺 sources / 空 sources / 无 default / duplicate name → fail-fast
  • Test 1neton-database/commonTestTableUserContractTest 验证 Table 接口契约(get/where 等)。
  • Test 2:通过约束 A(Store 废除),编译期天然防止「Store 被当作 Table 使用」。
  • Test 3:契约测试保证 count 与 list/page 同源,防止未来条件分叉。
  • Test 4[[sources]] 配置契约测试(database / redis / storage 每个模块一份),验证:
    • 缺少 [[sources]] → fail-fast with &lt;module&gt;.conf: missing [[sources]]
    • [[sources]] → fail-fast
    • default 数据源 → fail-fast with &lt;module&gt;.conf: no default source
    • name 重复 → fail-fast with &lt;module&gt;.conf: duplicate source name '&lt;name&gt;'
    • 错误消息必须含 &lt;module&gt;.conf: 前缀 + 缺失项,三模块保持一致格式

八、冻结约束(必须遵守)

8.1 单 DB 与多 DB

  • 默认单 DBSqlxDatabase.require() 单例,KSP 生成 dbProvider = { SqlxDatabase.require() }(Table 适配器)
  • 多 DB(命名连接/读写分离/上下文路由):留 v3,不在此版实现
  • 扩展点已预留SqlxTableAdapter(dbProvider: () -> SQLite = { SqlxDatabase.require() }, ...),v3 可传 { SqlxDatabase.get("analytics") } 等,无需重构

8.2 SqlxTableAdapter 必须无状态(stateless)

  • 不得持有可变状态
  • 不缓存 query 结果
  • 不缓存 entity
  • 仅允许缓存:prepared statement / SQL 字符串(若需)
  • 违反将导致一致性 bug,禁止。

8.3 id 类型与强类型

  • Table<T, ID : Any>:主键为泛型 ID,get(id: ID)destroy(id: ID)exists(id: ID)many(ids: Collection&lt;ID&gt;)destroyMany(ids: Collection&lt;ID&gt;) 均为强类型
  • KSP 生成的 Table:从实体主键字段推导 ID 类型(如 Long、String、UUID),生成 Table&lt;User, Long&gt;
  • getOrThrowgetOrThrow(id: ID) 泛型扩展,调用处类型推导安全;异常 message 含 id 便于排查

8.4 NotFoundException 与 HTTP 404

  • NotFoundException 位于 neton.core.http,继承 HttpException(HttpStatus.NOT_FOUND)
  • HTTP 适配器:catch 到 NotFoundException 时统一映射 404,禁止出现「有的 NotFound 走 500」的分裂

8.5 AutoStore 异常文案(便于日志聚合)

  • 格式AutoStoreFeatureNotSupported: &lt;method&gt;. Use KSP Table (UserTable.xxx)
  • 示例query 等均使用此格式,便于日志聚合「哪些项目误用 AutoStore」

8.6 ensureTable 行为

  • Table 接口默认ensureTable() 默认 no-op(空实现)
  • SqlxTableAdapter 实现:仅当实体存在 DDL 元数据时(如 UserMeta 含列定义)才执行建表
  • 用户不得假定 ensureTable 必然成功;无 DDL 元数据时可为 no-op 或抛异常

8.7 约束 A:Store 废除,Logic 层替代

  • Table&lt;T, ID&gt; 是唯一数据访问入口(单表 CRUD + Query DSL + JOIN DSL)。
  • Store 作为框架层概念已废除。Store 的唯一存在理由(手写 JOIN SQL)已被 NetonSQL v1 JOIN DSL 和 DbContext 取代。
  • 跨表/聚合逻辑归属 Logic 层getWithRoleslistUsersWithRolesassignRole/removeRole 等用例在 Logic 层实现,通过 Table DSL 或 DbContext 访问数据。
  • Controller 禁止直接引用 Table(约束 C1),所有数据操作必须经过 Logic 层。

8.8 约束 B:KSP 生成物命名规则写死

规则禁止
表级生成物EntityNameTable(单数)UsersTableUserTablesUsers
Logic 层EntityNameLogicDomainLogicUserStoreImplUserRepository
  • KSP 对 @Table("users") data class User 必须生成object UserTable : Table&lt;User, Long&gt;
  • 禁止复数UsersUserTables
  • 禁止歧义UserTables 易与「多个 UserTable」混淆。
  • 违反后果:命名漂移、双轨回潮,禁止。

8.9 实现来源约束(语义约束)

  • Table&lt;T&gt; 仅由 KSP 生成物或框架内部 Adapter 实现。
  • 业务代码不得自行实现 Table&lt;T&gt;
  • 本约束为语义冻结约束,不通过 sealed/interface 机制强制。

8.10 约束 C:列引用冻结规则(v1)

  • v1 只允许使用 Entity::property(KProperty1)作为列引用。
  • ColumnRef 操作符(eq/like/gt/ge/lt/le/in/asc/desc)为 internal,用户层不可见。
  • KSP 生成的 Meta 不包含 ColumnRef 属性。
  • 禁止 ColumnRef("xxx")XxxMeta.xxxEntity.xxx(companion)等其他列引用方式。
  • KProperty1.name → camelToSnake → ColumnRef 为唯一映射路径,由框架内部 toColumnRef() 实现。
  • 违反后果:设计分裂,多种列引用风格共存,禁止。

8.11 长期规范(铁律,3~5 年稳定性的保险)

规则表述
Table 无状态 + 线程安全Table MUST be stateless and thread-safe. Table MUST NOT hold mutable state or cache entities.
SQL 编译期生成All SQL must be compile-time generated by KSP. Manual string concatenation SQL is forbidden.
唯一 Table 实现SqlxTableAdapter is the only official Table implementation. 缓存/多数据源应作为 Table 的包装层,而非替代实现。

九、未来扩展性(预留)

  • UserTable.columns.id / UserTable.columns.name — 可引入 Schema 时使用
  • UserTable.insert { ... } / UserTable.batchInsert { ... }
  • UserTable.indexes / UserTable.migrations

若引入真正的 TableSchema(DDL/列定义),建议单独 UserSchemaTableMeta,不与 Table 混用。


十、参考实现

  • neton/examples/mvc — 完整 MVC 示例(Controller → Logic → Table → Model)
  • neton/neton-ksp/EntityTableProcessor.kt — KSP 生成逻辑

十一、结论与建议

建议:将本套 API 定为 neton-database 正式规范。

然后:

  • KSP 生成 UserTable(get / destroy / update / query / save / delete)
  • Query 作为唯一 DSL
  • Store 退到 internal,不暴露
  • 禁止 Repository / Impl,API 以 Entity 为中心

已落地:
query 包(QueryAst、Predicate、ColumnRef、EntityQuery、Page)、KSP 生成 UserTable + XxxUpdateScope + user.save/delete。


十二、Schema Migration(数据库演进规范)

原为独立的 migration.md spec,2026-06-07 合并入本文档。涵盖 migration engine 边界、命令集、history 表 contract、部署 workflow、状态模型、操作员恢复路径等完整规范。

目录

  1. Canonical Architecture(2026-06-01 update)
  2. 设计原则
  3. 模块边界
  4. SQL 资源约定
  5. ensureTable() 定位
  6. application.kexe migrate 子命令
  7. 版本表规范
  8. 明确禁止事项
  9. 冻结约束
  10. Canonical Deployment Workflow

附录:


零、Canonical Architecture

Migration engine lives inside neton-database. The canonical (and only) execution entry is application.kexe migrate.

历史说明 (2026-06-07):曾存在的独立 neton-migrate CLI binary 已于 2026-06-01 logical 弃用,2026-06-07 物理删除 (neton 仓 commit a6908bc)。本规范内不再保留 legacy CLI 的命令集 / 配置 / 退出码描述,正式部署一律用 application.kexe migrate

0.1 边界

关注点归属
Migration engine(扫描、计算 diff、写 history、事务、checksum)neton-database/.../migration/
SQL migration files各 application module (<module>/sql/{dialect}/V*.sql)
模块声明 migration sourceModuleInitializer.migrations(): List<MigrationSource>
正式执行入口application.kexe migrate <subcommand>
正常启动./application.kexe绝不自动 migrate
全局历史表neton_schema_historyUNIQUE(module_id, version)

0.2 正式命令

bash
./application.kexe migrate status   # 列已应用 / 待应用
./application.kexe migrate verify   # 校验 checksum
./application.kexe migrate up       # 跑所有 pending
./application.kexe                  # 正常启动 server (不 migrate)

明确禁止:

bash
./application.kexe
# must not auto migrate. schema 落后必须 fail-fast 报错退出。

0.3 MigrationSource API

模块显式声明自己的 SQL 路径,框架不硬编码约定:

kotlin
interface ModuleInitializer {
    val moduleId: String
    val dependsOn: List<String>
    fun initialize(ctx: ModuleContext)

    /** Migration sources for this module. Empty list = module has no schema. */
    fun migrations(): List<MigrationSource> = emptyList()
}

data class MigrationSource(
    val moduleId: String,            // 用于 history table 的 module_id 列
    val dialect: String,             // "postgresql" | "mysql" | "sqlite"
    val resourcePath: String,        // 相对路径, e.g. "sql/postgresql"
)

0.4 全局 history 表(表名可配,框架不锁死

每个 application 一张全局 history 表(跨模块共享),表名由 application 配置,框架提供默认 neton_schema_history

config/database.conf(per-application 配):

toml
[migration]
# 可选, 默认 neton_schema_history
history_table = "privchat_migrations"

不同应用按业务命名约定:

应用history_table
privchat-applicationprivchat_migrations(沿用业务现有约定)
game-application(若分离)game_migrations
其它 Neton 应用未配neton_schema_history(默认)

关键:框架代码不允许硬编码 neton_schema_history 等具体名字,必须读 config。

表结构(框架内置):

<history_table>
  module_id     VARCHAR(64)   NOT NULL
  version       VARCHAR(32)   NOT NULL
  description   VARCHAR(255)
  checksum      VARCHAR(128)
  installed_at  BIGINT
  execution_ms  BIGINT
  success       BOOLEAN
  error_message TEXT
  UNIQUE(module_id, version)

废弃旧的 per-module neton_schema_history_<module> 表(_game / _member / _payment / _platform)——一个应用就一张全局表,用 module_id 列区分。

0.5 Application 启动行为(红线)

./application.kexe(无 migrate 子命令)启动时:

  1. 读 config / 初始化 database
  2. 检查 schema 版本(读 configured history table vs 模块声明的 latest version)
  3. 如果任一模块有 pending migration → fail-fast,打印 please run ./application.kexe migrate up 并 exit 非 0
  4. 绝不自动跑 migration

至少有一个 boot smoke test 守住这条红线。


一、设计原则

1.1 为什么 Neton 不做运行时迁移

Schema 变更的本质是 部署决策,不是 运行时行为。把它放进 app 启动流程意味着:

风险说明
不可审阅DDL 由代码隐式生成,无法在 PR/CR 阶段被人工 review
不可灰度多实例并发启动时,谁先抢到锁、谁执行成功、谁失败回滚,皆不可控
不可回滚应用启动失败 ≠ schema 已回滚;schema 已变更 ≠ 应用必然成功
数据库差异MySQL/PostgreSQL/SQLite 的 DDL 方言、约束语义、ALTER 行为不一致,自动化掩盖差异
复杂演进无解表拆分、列改名、数据回填、双写双读、灰度发布等场景,运行时自动迁移完全表达不了

1.2 核心立场

  • Schema 演进 = 人工审阅 + 显式执行 + 版本化记录
  • Neton 框架运行时只关心"连接 DB、读写数据",不关心"DB 长什么样"
  • 框架可以检查 schema 状态(连接探活、版本一致性校验),但不执行变更

二、模块边界

模块职责不做
neton-database运行时 DB 访问(连接、查询、事务、Entity ↔ Row 映射) + migration engine / history / checksum / script execution不在 app 正常启动期自动 migrate
application(业务应用)启动 HTTP/database/scheduler 等组件;提供 migrate 子命令;收集 ModuleInitializer.migrations() 调用 engine正常启动绝不 ALTER、不自动 migrate;只检查 pending 决定是否 fail-fast

关键约束:

  • migration engine neton-database;applicationmigrate 子命令里调用它,不重新实现
  • ModuleInitializer.initialize() 不允许触发 migration;只在 application.kexe migrate 子命令路径才执行
  • application 正常启动(无 migrate arg)只允许检查 schema 状态,不允许执行任何 DDL

三、SQL 资源约定

3.1 目录(模块自决,框架不锁死)

每个 application module 在 ModuleInitializer.migrations()显式声明资源路径。沿用业务现有约定:

<module>/sql/
├── mysql/
│   ├── V001__create_tables.sql
│   ├── V002__init_data.sql
│   └── V003__add_indexes.sql
├── postgresql/
│   └── ...
└── sqlite/
    └── ...
kotlin
override fun migrations() = listOf(
    MigrationSource(
        moduleId   = "privchat-application",
        dialect    = "postgresql",
        resourcePath = "sql/postgresql",
    ),
)

3.2 命名规范

V<version>__<description>.sql

  • V 大写前缀
  • <version> 三位以上零填充数字(001002、…),保证字典序 = 执行序
  • 双下划线 __ 分隔
  • <description> 用 snake_case,简短描述

命名格式与 Flyway 兼容,但不引入 Flyway 依赖;neton-database/.../migration/ 自己解析。

3.3 执行规则

  • 本地开发:开发者跑 ./application.kexe migrate up(或 IDE / dev script)
  • 测试环境:CI pipeline 跑 ./application.kexe migrate up(必要时 status dry-run)
  • 生产环境:CI/CD 部署流水线在 service start 之前执行 ./application.kexe migrate up,由 DBA / SRE 把关
  • 顺序:同 module 内按 version 升序,跨模块按 dependsOn 拓扑序;不允许跳跃或乱序

3.4 与应用启动(serve 模式)的关系

./application.kexe(无 migrate 子命令)启动时只做:

  • 数据库连接探活(连不上 fail-fast)
  • 读 configured history 表,与各 module 声明的 latest version 对比
  • 任一模块有 pending → fail-fast,打印 please run ./application.kexe migrate up 并 exit 非 0

./application.kexe(serve)不做:

  • 创建表、修改表、删除表
  • 写入 history 表
  • 任何形式的 DDL 执行

四、ensureTable() 定位

Table.ensureTable()neton-database 中保留,但严格限定用途。

4.1 仅用于

  • demo 工程
  • 本地开发的临时调试
  • 单元测试 / 集成测试中的 ephemeral 数据库(如 sqlite::memory:

4.2 禁止用于

  • 生产环境的 schema 创建
  • 生产环境的 schema 演进(它根本做不了 ALTER)
  • CI release 部署
  • 任何带"持久化"语义的数据库

4.3 能力清单

ensureTable() 只能做:

  • CREATE TABLE IF NOT EXISTS,仅含主键列与从 EntityMeta 推导的基础列

ensureTable() 永远不会做:

  • 新增/删除/修改字段
  • 索引、唯一约束、外键
  • 数据迁移、回填
  • 表已存在时的任何 schema 调整

4.4 文档与代码标注

  • 该方法的 KDoc 必须包含 dev/demo only, not for production migration
  • Main.ktModuleInitializer不允许调用 ensureTable()
  • examples 工程中调用时应附注释说明"仅 demo 用途"

五、application.kexe migrate 子命令

正式 application migration 执行入口。Engine 在 neton-database,application 主入口判 args.firstOrNull() == "migrate" 进入 migration 模式(不启动 server / scheduler / WebSocket)。

5.1 命令集(最小集)

命令行为备注
application.kexe migrate status显示已应用 / 待应用 / changed(checksum 不一致)脚本列表只读
application.kexe migrate up(moduleId, version) 顺序执行所有 pending 脚本,任意失败中断默认部署命令
application.kexe migrate verify校验 history 表中已执行脚本的 checksum 是否与磁盘一致只读,检测脚本被篡改

子命令以外的 application.kexe(无 args)永远不执行 migration —— 见 §0.6 / §七。

5.2 down 不在最小集内

  • 生产环境通常不允许自动回滚(数据可能已写入新结构,回滚会丢数据)
  • 如果未来要支持 down,必须设为 opt-in 且默认禁用
  • 可以提供"生成 down SQL 模板"的能力,由人工执行

5.3 执行机制

1. 加载 application config (database / migration history_table)
2. 初始化 neton-database (driver = config 单一 driver, 与 serve 模式同一个)
3. 收集所有 ModuleInitializer.migrations() → List<MigrationSource>
4. 确保 configured history table 存在 (默认 neton_schema_history, 见 §0.4)
5. 按 (moduleId, version) 排序, 与 history 表对比, 找出 pending
6. 顺序执行 per (moduleId, version):
   a. 读 SQL 文件 + 计算 checksum
   b. 执行 SQL (在 transaction 内, 如果方言支持 DDL transaction)
   c. 写 history 一行 (module_id, version, checksum, installed_at, execution_ms, success=true)
7. 任意一步失败 → 中断, 写入失败记录, 非 0 exit code

约束:

  • migration 模式只在显式 migrate 子命令下运行;Neton.run { } 正常启动路径不允许触发 engine
  • 同一 application 进程只链一个 sqlx4k driver(参见 NETON-DB-VARIANT),migration 和 serve 共享 driver,避免 K/N 链接器多 driver 撞 rust_eh_personality 等符号

5.4 退出码契约

Exit Code含义
0全部成功(或已无需执行)
1status 检测到有 pending 脚本(CI dry-run 使用)
2up 执行中失败
3checksum 校验失败(脚本被篡改 / history 表不一致)
4数据库连接失败
64命令行参数错误

CI/CD 可基于退出码判断是否阻塞部署。


六、版本表规范

6.1 表名(可配,框架不锁死)

每个 application 一张全局history 表,跨模块共享。表名由 application config 决定,框架提供默认值 neton_schema_history。详见 §0.4。

toml
# config/database.conf
[migration]
history_table = "privchat_migrations"   # 可选, 默认 neton_schema_history

废弃 per-module 拆分(如 neton_schema_history_game / _member / _payment / _platform)——一个 application 就一张表,用 module_id 列区分模块。

6.2 表结构(框架内置)

字段类型说明
module_idVARCHAR(64) NOT NULL来源模块(对齐 ModuleInitializer.moduleId)
versionVARCHAR(32) NOT NULL版本号,如 001002
descriptionVARCHAR(255)脚本描述(取自文件名 __ 后的部分)
checksumVARCHAR(128)SHA-256 hex(或更长,容纳未来 hash 升级)
installed_atBIGINT执行 epoch millis
execution_msBIGINT执行耗时 ms
successBOOLEAN是否成功
error_messageTEXT NULL失败时的错误信息

主键约束:UNIQUE(module_id, version) —— 同一 module 不允许同 version 出现两次;跨模块允许版本号重复(member.V001payment.V001 互不冲突)。

6.3 写入规则

  • 每个脚本执行前后写一条记录
  • 失败的执行也要记录(success=false + error_message)
  • 已记录 success=true(module_id, version) 不再重复执行
  • 已记录 success=false(module_id, version):默认要求人工介入(不自动重试)
  • 框架代码禁止硬编码 "neton_schema_history" 字符串,所有访问必须经 config 读出

6.4 状态模型(DB-MIG-2 frozen)

MigrationEngine 给每个 (module_id, version) 返回 5 种可能状态:

磁盘historyhistory.successstateSPEC 术语
PENDINGpending
true,checksum 同EXECUTEDapplied
true,checksum 不同CHECKSUM_MISMATCHchanged
falseFAILEDfailed
MISSING_ON_DISK
(不进状态机)

优先级冻结: 同一 row 上 FAILED 优先于 CHECKSUM_MISMATCHhistory.success=false 且磁盘 checksum 也漂移时,engine 报 FAILED 而非 CHECKSUM_MISMATCH,因为 failed migration 必须先由操作员解决,checksum 漂移在那之后才有意义。

6.5 命令对状态的反应(三命令一致性)

状态statusverifyup
PENDING列示忽略顺序执行
EXECUTED列示校验 ok跳过
CHECKSUM_MISMATCH列示 + 标 changed列入 mismatchesAborted (在 pending 执行前)
MISSING_ON_DISK列示列入 missing忽略(已应用的脚本被人为删,UP 不重发)
FAILED列示 + error_message忽略(verify 只看 success=true)Aborted ("manual intervention required")

三命令对 CHECKSUM_MISMATCH 的反应严格递进: status=show,verify=flag,up=block。

6.6 操作员从 FAILED 状态的恢复路径

框架提供 migrate reset / migrate retry 命令(危险操作不暴露)。操作员步骤:

  1. 排查失败原因(查 error_message + 数据库实际状态)
  2. 手工修复 schema(回滚已部分 commit 的 DDL,尤其 MySQL — DDL autocommit 后无法 rollback)
  3. 手工 DELETE FROM <history_table> WHERE module_id=? AND version=?
  4. 重新 ./application.kexe migrate up

MySQL 警告: 多语句脚本中第 K 条 DDL 失败时,前 K-1 条已 autocommit,schema 处于半成状态。这是 MySQL 限制,不是 engine bug。建议 MySQL migration 拆细颗粒(单脚本只做一个 DDL)以缩小破坏面。PG/SQLite 走事务整体回滚,不受影响。

6.7 DDL bit-exact(三方言)

historyTableDdl(dialect, tableName) 输出由 SchemaHistoryContractTest golden 锁定。任何 DDL 字节级改动必须先更新该测试。约束:

  • 三方言 UNIQUE 都用行内 UNIQUE(...)(MySQL 不再用 UNIQUE KEY uq_${table}_... 命名索引,避免 table 名接近 63 char 上限时索引名超过 MySQL 64 char 限制)
  • success 列类型按方言原生表示(SQLite INTEGER / PG BOOLEAN / MySQL TINYINT(1)),写入走 dialect-specific literal(TRUE/FALSE vs 1/0),读出兼容 t/true/1
  • MySQL 显式 COLLATE=utf8mb4_bin — 防 collation 漂移影响 UNIQUE 大小写敏感性

七、明确禁止事项

以下事项是架构红线,任何 PR 都不允许引入。

禁止:

- application.kexe 正常启动(无 migrate 子命令)自动执行 migration
- 在 ModuleInitializer.initialize() 内触发 migration / 调 MigrationEngine
- 提供 `Neton.run { migrateAutomatically() }` 这种 opt-in 自动迁移 API
- 运行时(serve 模式)执行 ALTER TABLE / DROP / RENAME 等 DDL
- ensureTable() 隐式升级已存在的 schema
- 在 ModuleInitializer 中调用 ensureTable()
- 将 Flyway / Liquibase / sqlx::migrate! 等第三方运行时迁移工具内置进 neton-database
  (Neton 自带的 MigrationEngine 是 neton-database 内部能力, 不算第三方)
- 从远程 URL / 配置中心下载 SQL 脚本执行
- 多节点并发抢跑 migration —— 即使有 `application.kexe migrate up`, 部署流程也须保证单点执行
- 框架代码硬编码 history 表名(必须读 config, 默认 `neton_schema_history`)
- 用 ORM 反向工程(reverse engineering)从 entity 推导出"应有"的 schema 并自动应用
- 引入任何 standalone 的 migration CLI 作为 application 部署入口
  (canonical entry 是 `application.kexe migrate`;同进程同 driver,K/N 链接器约束之一)

八、冻结约束

维度冻结内容
运行时行为application.kexe(serve 模式)永远不执行 schema 变更;startup 只检查 pending → fail-fast
Migration engine 归属neton-database/.../migration/(MigrationEngine / MigrationSource / SchemaHistoryRepository / MigrationCommand)
正式执行入口**`application.kexe migrate up
ensureTable()能力不再扩展,文档明确"非生产"
SQL 资源路径ModuleInitializer.migrations() 中的 MigrationSource.resourcePath 显式声明(框架不锁死目录约定);沿用现状 <module>/sql/{dialect}/V*.sql
History 表每 application 一张全局表(UNIQUE(module_id, version));表名由 config 配,默认 neton_schema_history,框架不硬编码具体名字
默认命令集status / up / verify;down 不承诺

九、Canonical Deployment Workflow

前置: SQL 文件由各模块自管(<module-repo>/sql/postgresql/V*.sql)。application 的 copyModuleMigrations Gradle task 在构建期把各模块 SQL 聚合到 application/migrations/<moduleId>/postgresql/。运行期由各 ModuleInitializer.migrations() 声明的 resourcePath = "migrations/<moduleId>/postgresql" 让 engine 在 application cwd 下读到。

9.1 构建

bash
./gradlew :application:copyModuleMigrations \
          :application:linkReleaseExecutableLinuxX64

部署产物布局:

application/
├── application.kexe                       # 单一 binary,既是 server 也是 migrate
├── config/
│   ├── database.conf                      # [default].driver/uri + [migration].history_table (可选)
│   ├── application.conf
│   └── ...
└── migrations/
    ├── infra/postgresql/V001+V002         # 由 InfraModuleInitializer.migrations() 引用
    ├── member/postgresql/V001             # MemberModuleInitializer.migrations() 引用
    ├── payment/postgresql/V001            # ...
    ├── platform/postgresql/V001
    ├── game/postgresql/V001+V002
    └── privchat/postgresql/V001+V002

9.2 部署流程(空库 / 升级 统一形态)

bash
cd application/                           # ★ 必须 cd, resourcePath 是 cwd-relative

./application.kexe migrate status         # exit 1 = pending; exit 0 = nothing to do
./application.kexe migrate up             # 执行所有 pending
./application.kexe migrate verify         # 校验 checksum
./application.kexe                        # 启动 server

关键: 始终在 application/ 目录下执行。从其它目录调用 /path/to/application.kexe migrate 会让 resourcePath 相对解析到错误目录。后续可加 --workdir 或 executable-path 推导,现阶段约束部署脚本必须 cd

9.3 启动期红线(SPEC §0.6)

./application.kexe(无 migrate arg)启动时:

  1. 读 config / 初始化 database 连接(只连,不写)
  2. 按各模块声明的 dialect 过滤后聚合 MigrationSource
  3. MigrationCommand.STATUS(read-only)
  4. 任一模块有 pending / failed / checksum mismatch / history 表不存在 → fail-fast 打印诊断 + please run ./application.kexe migrate up + exit 1
  5. 绝不创建 history 表,绝不执行 V*.sql,绝不写业务表

9.4 验收 checklist(部署冒烟)

#场景操作预期
1空库./application.kexeexit 1,"schema not initialized; please run ./application.kexe migrate up"
2空库查库: \dt (psql)history 表不存在,业务表不存在
3空库./application.kexe migrate upexit 0,所有 V*.sql 应用
4up 后./application.kexe migrate verifyexit 0,"All checksums match"
5up 后./application.kexeserver 启动,banner 打印
6漂移修改任一已应用 V*.sql 内容 → ./application.kexe migrate verifyexit 3,列出 mismatch
7漂移./application.kexe(同上漂移)fail-fast,"checksum mismatch"
8部分应用migrate up 跑到一半 SQL 报错history 写 success=false,exit 2
9failed 状态接 #8,再跑 ./application.kexe migrate upAborted,要求 manual intervention(SPEC §6.6)
10增量升级添加新模块 V003,./application.kexe migrate up只跑 V003,其它跳过

9.5 操作员从 FAILED 恢复

(详见 SPEC §6.6)

bash
# 1. 排查
psql ... -c "SELECT * FROM neton_schema_history WHERE success=false;"

# 2. 修复 schema(尤其 MySQL DDL autocommit 后的半成状态)

# 3. 清账
psql ... -c "DELETE FROM neton_schema_history WHERE module_id='X' AND version='Y';"

# 4. 重试
./application.kexe migrate up

框架提供 migrate reset / migrate retry 子命令 — 危险操作必须显式 SQL。


附录 A:常见误区

Q:现在前端 Vue 项目都能 hot-reload,为什么数据库不能 hot-migrate? A:前端 hot-reload 影响的是单个浏览器 session;数据库 schema 变更影响的是所有现存与未来的应用实例 + 已存数据。两者风险量级完全不同。

Q:开发环境很方便啊,启动就建表,为什么不延伸到生产? A:开发环境的便利来自"数据可以随时丢"。生产数据不能丢。区分开发/生产是有意为之,不是缺陷。

Q:用 Flyway/Liquibase 不就解决了?为什么不直接集成? A:不引入 Java 生态依赖。Neton 自带的 MigrationEngine 在 neton-database/.../migration/ 内,与 application 共享同一个 sqlx4k driver(K/N 链接器约束,见 NETON-DB-VARIANT),通过 application.kexe migrate 子命令调用,无需第三方运行时迁移工具。

Q:那 Rails、Django 都有 rake db:migrate / manage.py migrate,它们也是运行时执行? A:不是,且 Neton 与之一致 —— application.kexe migrate显式子命令,由部署人员或 CI 显式调用,与 application.kexe serve 模式是两件事;serve 模式永不自动 migrate。Rails / Django 的 migrate 也是同一形态。


Neton Framework Documentation