feat: 新增数据填充 seed 命令(@Seed 宏自动注册,不记录运行历史)、database truncate/drop、打包只读模式,提示统一为 simcu::orm,版本 1.2.0

This commit is contained in:
2026-08-22 09:53:43 +08:00
parent 394c4a1fb5
commit 9d7b353e2f
17 changed files with 796 additions and 80 deletions
+50 -2
View File
@@ -212,8 +212,13 @@ main(args: Array<String>) {
| `cjpm run -- orm update` | 连接数据库,按 `migrationId` 字典序应用所有未执行的迁移 |
| `cjpm run -- orm downgrade [目标]` | 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后(含该迁移的 down) |
| `cjpm run -- orm list` | 列出已注册迁移(需真实连接读取历史表) |
| `cjpm run -- orm seed` | 列出数据填充;`seed <id>` 运行指定;`seed --all` 运行全部(见「数据填充」) |
| `cjpm run -- orm database truncate` | 清空所有业务表数据(保留表结构与迁移历史) |
| `cjpm run -- orm database drop` | 删除所有表(含迁移历史),完全清空数据库,之后 `update` 可重建 |
| `cjpm run -- orm help` | 显示帮助 |
**开发/部署模式**:在项目目录(含 `cjpm.toml`)通过 `cjpm run` 运行时全部命令可用;打包部署后(工作目录无 `cjpm.toml`)自动进入只读模式,仅保留 `list` / `update`(应用迁移)/ `seed`(数据填充,只写业务数据)。
**生成约定**:
- **首次使用(无迁移)**:不需要建 `src/migrations/` 目录,也不要写 `import <应用包名>.migrations.*`(该子包不存在会导致编译失败);首次 `add` 时 CLI 自动创建目录并生成迁移文件 + 注册文件;
- **生成后手动加 import**:执行 `add` 生成首个迁移后,在 `main()` 所在文件手动加一行 `import <应用包名>.migrations.*`(如 `import myapp.migrations.*`)——注册文件在子包内惰性加载,只有 import 触发后 `update/downgrade/list` 才能看到迁移;之后新增迁移无需再动这行;
@@ -222,6 +227,41 @@ main(args: Array<String>) {
- 快照持久化在 `src/migrations/snapshot.json`;`add` 用「旧快照 vs 当前模型」做 diff,所以**改模型后重新编译应用再 `add`** 即生成增量迁移;
- 生成源码文本已包含 `import std.collection.*` 等标准库引用,应用无需额外配置。
### 数据填充(Seed)
`orm seed` 提供数据填充:用 `@Seed` 类级宏标注普通类,`run(ctx)` 内用**应用自己的 DbContext** 直接操作 ORM。**无需注册表、不记录运行历史,可随时/多次运行,幂等由填充代码自行保证**(如按 id find 后不存在再插入)。
```cangjie
// src/seeds/InitGames.cj
import simcu::orm.macros.*
import simcu::orm.seeds.*
import myapp.models.*
@Seed["初始化游戏服务器配置"] // seedId 取类名 InitGames,参数仅作描述
public class InitGames {
public func run(ctx: MyDbContext): Unit {
if (ctx.games.find("game-1").isNone()) { // 自行保证幂等
let g = Game()
g.id = "game-1"
g.apiKey = "dev-key-1"
g.name = "测试服"
ctx.games.add(g)
ctx.saveChanges()
}
}
}
```
`@Seed` 宏在编译期自动展开:生成包装类(继承 `simcu::orm.seeds.Seed`,把 `ctx` 从 `Any` 转型为 run 参数类型后调用)和顶层 `let registerSeed(...)`,模块加载即自动注册,**无需手写注册文件**。应用侧只需在 `main()` 所在文件加一行 `import <应用包名>.seeds.*` 触发子包加载(与迁移注册文件同模式)。
| 命令 | 作用 |
|------|------|
| `cjpm run -- orm seed` | 列出全部已注册填充(seedId + 描述) |
| `cjpm run -- orm seed <id>` | 运行指定填充(支持唯一前缀匹配) |
| `cjpm run -- orm seed --all` | 依次运行全部填充(单个失败继续执行其余,最终非零退出) |
> 运行前自动检查迁移是否全部已应用(避免目标表不存在);打包环境同样可用。
---
## 项目结构
@@ -232,7 +272,8 @@ orm-cj/
├── src/
│ ├── Orm.cj # 聚合导出:import simcu::orm.* 即全部可见
│ ├── annotations/ # @Table / @Column / @Key / @AutoIncrement / @Ignore / @Required / @MaxLength
│ ├── macros/ # @DbContext 类级宏(纯声明 DbSet 类 → 完整 DbContext 子类)
│ ├── macros/ # @DbContext 类级宏 + @Seed 数据填充宏(自动注册,无需手写注册表)
│ ├── seeds/ # Seed 基类 + 进程级填充注册表(registerSeed / allSeeds)
│ ├── model/ # EntityModel(反射映射)、ModelCache(模型缓存)、
│ │ # ColumnNamingPolicy(列命名策略)、ValueReader、ParamBinder、GuidUtil
│ ├── tracking/ # ChangeTracker(操作队列)、EntityState、EntityEntry
@@ -240,7 +281,7 @@ orm-cj/
│ │ # OpenGaussDialect(继承 PG)、ColumnTypes(列类型)
│ ├── query/ # QueryBuilder<T>(条件/排序/分页)、PagedResult<T>
│ ├── db/ # DbContext(连接 + 提交 + 物化 + 数据库存在性/迁移状态检查)、DbSet<T>
│ ├── cli/ # MigrationCli(add/rm/update/downgrade/list 命令)、
│ ├── cli/ # MigrationCli(add/rm/update/downgrade/list/seed/database 命令)、
│ │ # MigrationFileGenerator(迁移/注册文件源码生成)
│ ├── migrations/ # Migration 基类、MigrationBuilder、ColumnDefinition、
│ │ # DdlFactory(操作 → DDL SQL,差异语法委托方言)、
@@ -369,6 +410,13 @@ qb.page(2, 10) // PagedResult<User>(items/total/
- `ColumnTypes`(定义于 `simcu::orm.sql`):`BigIntCol`(openGauss 自增 → BIGSERIAL)/ `IntCol`(自增 → SERIAL)/ `SmallIntCol` / `TinyIntCol` / `TextCol`(VARCHAR,默认 255)/ `BoolCol` / `FloatCol`(DOUBLE PRECISION)/ `RealCol` / `DateTimeCol` / `DecimalCol`(DECIMAL(18,6))/ `BinaryCol`(BYTEA);实际 DDL 映射由方言 `columnTypeSql` 决定;
- `Migrator`:`pending(migrations)` 预览未执行项;`migrate(migrations)` 事务内按 `migrationId` 字典序应用未执行项并写入历史表 `simcu_orm_migrations`。
### 6. 数据填充(@Seed 宏)
- `@Seed["描述"]` 标注普通类:**seedId 取类名**,属性参数仅作描述;类内 `run(ctx)` 用应用自己的 DbContext 直接增删改;
- 宏自动生成 `Seed` 子类包装(`run(ctx: Any)` 内转型调用)+ 顶层 `let registerSeed(...)`,模块加载即注册,**无需手写注册表**;
- 语义:**不记录运行历史**,可随时/多次运行,幂等由 `run` 内自行保证(如按 id find 后不存在再插入);
- 应用侧需 `import <应用包名>.seeds.*` 触发子包加载(与迁移注册文件同模式);CLI 见「数据填充(Seed)」。`@Seed` 要求类提供单参数 `run(ctx)`,描述不能含双引号/反斜杠/换行。
---
## 运行测试
+1 -1
View File
@@ -3,7 +3,7 @@
name = "orm"
organization = "simcu"
description = "SimApi 数据访问层 ORM(对齐 .NET EF Core:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言)"
version = "1.1.0"
version = "1.2.0"
target-dir = ""
output-type = "static"
+2
View File
@@ -25,4 +25,6 @@ public interface CliContext {
func getMigrations(): ArrayList<Migration>
/// 应用所有迁移
func migrateAll(migrations: ArrayList<Migration>): Int64
/// 数据填充运行上下文:返回应用自己的 DbContext 实例(seed 的 run(ctx) 参数)
func getSeedContext(): Any
}
+215 -38
View File
@@ -26,7 +26,13 @@
* update 应用所有未执行的迁移
* downgrade [目标] 回退迁移(执行 down;无目标=回退最近一个,有目标=回退到该迁移之后)
* list 列出已应用/待应用迁移
* seed 列出数据填充(@Seed 标注的类);seed <id> 运行指定;seed --all 运行全部
* database truncate 清空所有业务表数据(保留表结构与迁移历史)
* database drop 删除所有表(含迁移历史),完全清空数据库,之后 update 可重建
* help 显示用法
*
* 开发模式:在项目目录(含 cjpm.toml)通过 cjpm run 运行时开启全部命令;
* 打包部署后(工作目录无 cjpm.toml)自动只读,仅允许 list/update/seed(数据只读/填充)。
*/
package simcu::orm.cli
@@ -37,6 +43,7 @@ import std.fs.*
import std.time.*
import simcu::orm.migrations.*
import simcu::orm.model.*
import simcu::orm.seeds.*
import simcu::orm.sql.*
/**
@@ -57,15 +64,16 @@ public class MigrationCli {
public static func runOn(ctx: CliContext, args: ArrayList<String>): Int64 {
let cli = MigrationCli()
cli.dispatch(args, { => ctx.getModels() },
{ => ctx.getDatasource() }, { => ctx.getMigrations() }, ctx.getDialect())
{ => ctx.getDatasource() }, { => ctx.getMigrations() }, ctx.getDialect(),
Some(ctx.getSeedContext()))
}
/// 低层入口:无条件执行 CLI(命令:add/rm/update/downgrade/list/help)。
/// 低层入口:无条件执行 CLI(命令:add/rm/update/downgrade/list/seed/help)。
/// 应用侧推荐直接用 DbContext.cli(args) 一行接入。
public static func run(args: ArrayList<String>, models: () -> ArrayList<EntityModel>,
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>): Int64 {
let cli = MigrationCli()
cli.dispatch(args, models, datasource, migrations, OpenGaussDialect())
cli.dispatch(args, models, datasource, migrations, OpenGaussDialect(), None)
}
/// 应用入口一行接入:`cjpm run -- orm <命令>` 时执行 CLI 并返回 true;
@@ -78,7 +86,7 @@ public class MigrationCli {
return false
}
let cli = MigrationCli()
cli.dispatch(args[1..], models, datasource, migrations, OpenGaussDialect())
cli.dispatch(args[1..], models, datasource, migrations, OpenGaussDialect(), None)
true
}
@@ -86,20 +94,72 @@ public class MigrationCli {
private func dispatch(args: ArrayList<String>, models: () -> ArrayList<EntityModel>,
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect): Int64 {
dialect: ISqlDialect, seedCtx: ?Any): Int64 {
if (args.size == 0) {
printHelp()
return 0
}
// 开发模式(cjpm run 运行)允许全部迁移命令;打包部署后仅允许 list/update/seed(数据只读/填充)
let allowedInProd = args[0] == "list" || args[0] == "update" || args[0] == "seed" || args[0] == "help" || args[0] == "-h" || args[0] == "--help"
if (!isDevMode() && !allowedInProd) {
println("simcu::orm cli: 打包部署环境仅支持 list/update/seed 命令(迁移管理需在项目目录通过 cjpm run 运行)")
return 1
}
match (args[0]) {
case "add" => cmdAdd(args, models)
case "rm" => cmdRm(args)
case "update" => cmdUpdate(datasource, migrations, dialect)
case "downgrade" => cmdDowngrade(datasource, migrations, dialect, args)
case "list" => cmdList(datasource, migrations, dialect)
case "seed" => cmdSeed(datasource, migrations, dialect, args, seedCtx)
case "database" => cmdDatabase(args, datasource, dialect)
case "help" | "-h" | "--help" => printHelp(); 0
case _ =>
println("simorm: 未知命令 '${args[0]}',输入 'simorm help' 查看用法")
println("simcu::orm: 未知命令 '${args[0]}',输入 'simcu::orm help' 查看用法")
1
}
}
/// database <子命令>:数据库级操作(不需要迁移注册)
private func cmdDatabase(args: ArrayList<String>, datasource: () -> Datasource,
dialect: ISqlDialect): Int64 {
if (args.size < 2) {
println("用法: simcu::orm database <truncate|drop>")
return 1
}
match (args[1]) {
case "truncate" => cmdTruncate(datasource, dialect)
case "drop" => cmdDrop(datasource, dialect)
case _ =>
println("simcu::orm: 未知 database 子命令 '${args[1]}'(支持: truncate, drop)")
1
}
}
/// database truncate:清空所有业务表数据(保留表结构与迁移历史)
private func cmdTruncate(datasource: () -> Datasource, dialect: ISqlDialect): Int64 {
try {
let migrator = Migrator(datasource(), dialect)
let n = migrator.truncateAll()
println("simcu::orm: 已清空 ${n} 张业务表(表结构与迁移历史保留)")
0
} catch (e: Exception) {
println("simcu::orm: 清空数据库失败")
println(" ${e.message}")
1
}
}
/// database drop:删除所有表(含迁移历史),完全清空数据库;之后可 update 重建
private func cmdDrop(datasource: () -> Datasource, dialect: ISqlDialect): Int64 {
try {
let migrator = Migrator(datasource(), dialect)
let n = migrator.dropAllTables()
println("simcu::orm: 已删除 ${n} 张表,数据库已完全清空(执行 update 可重建全部表)")
0
} catch (e: Exception) {
println("simcu::orm: 清空数据库失败")
println(" ${e.message}")
1
}
}
@@ -107,18 +167,18 @@ public class MigrationCli {
/// add <迁移名>:模型 → 迁移文件 + 快照 + 注册
private func cmdAdd(args: ArrayList<String>, models: () -> ArrayList<EntityModel>): Int64 {
if (args.size < 2) {
println("用法: simorm add <迁移名>")
println("用法: simcu::orm add <迁移名>")
return 1
}
let name = args[1]
if (!isValidIdentifier(name)) {
println("simorm: 迁移名 '${name}' 不是合法标识符(字母/数字/下划线,首字符不能是数字)")
println("simcu::orm: 迁移名 '${name}' 不是合法标识符(字母/数字/下划线,首字符不能是数字)")
return 1
}
// 迁移名以 test 结尾会生成 *_test.cj 文件,cjc 会把 *_test.cj 当作测试文件,
// 在 cjpm build(非 test)时排除,导致注册文件引用不到该类而编译失败。
if (name.endsWith("test")) {
println("simorm: 迁移名 '${name}' 以 test 结尾,生成的 *_test.cj 会被编译器当作测试文件排除;请换一个名称")
println("simcu::orm: 迁移名 '${name}' 以 test 结尾,生成的 *_test.cj 会被编译器当作测试文件排除;请换一个名称")
return 1
}
let appPkg = detectPackage()
@@ -136,7 +196,7 @@ public class MigrationCli {
let probe = MigrationBuilder()
m.up(probe)
if (probe.getOperations().size == 0) {
println("simorm: 未检测到模型变化,快照已是最新,跳过生成")
println("simcu::orm: 未检测到模型变化,快照已是最新,跳过生成")
return 0
}
// 迁移文件与注册文件都落在 src/migrations 子目录(package = <appPkg>.migrations):
@@ -147,11 +207,11 @@ public class MigrationCli {
let path = fg.writeMigration(migPkg, m, _migrationsDir)
fg.updateRegistry(migPkg, _registryPath, MigrationFileGenerator.classNameOf(id))
ModelSnapshot.capture(current).save(_snapshotPath)
println("simorm: 已生成迁移 ${id}")
println("simcu::orm: 已生成迁移 ${id}")
println(" 迁移文件: ${path}")
println(" 注册文件: ${_registryPath}")
println(" 快照: ${_snapshotPath}")
println("执行 'simorm update' 应用该迁移")
println("执行 'simcu::orm update' 应用该迁移")
0
}
@@ -160,12 +220,12 @@ public class MigrationCli {
migrations: () -> ArrayList<Migration>, dialect: ISqlDialect): Int64 {
let list = migrations()
if (list.size == 0) {
println("simorm: 未注册任何迁移(检查 ${_registryPath})")
println("simcu::orm: 未注册任何迁移(检查 ${_registryPath})")
return 1
}
let migrator = Migrator(datasource(), dialect)
let n = migrator.migrate(list)
println("simorm: 已应用 ${n} 个迁移")
println("simcu::orm: 已应用 ${n} 个迁移")
0
}
@@ -174,7 +234,7 @@ public class MigrationCli {
dialect: ISqlDialect, args: ArrayList<String>): Int64 {
let list = migrations()
if (list.size == 0) {
println("simorm: 未注册任何迁移(检查 ${_registryPath})")
println("simcu::orm: 未注册任何迁移(检查 ${_registryPath})")
return 1
}
let target: ?String = if (args.size >= 2) { Some(args[1]) } else { None }
@@ -182,13 +242,13 @@ public class MigrationCli {
let migrator = Migrator(datasource(), dialect)
let n = migrator.revert(list, target)
if (n == 0) {
println("simorm: 没有可回退的迁移(已处于${if (target.isSome()) { "目标" } else { "最初" }}状态)")
println("simcu::orm: 没有可回退的迁移(已处于${if (target.isSome()) { "目标" } else { "最初" }}状态)")
} else {
println("simorm: 已回退 ${n} 个迁移")
println("simcu::orm: 已回退 ${n} 个迁移")
}
0
} catch (e: Exception) {
println("simorm: 回退迁移失败")
println("simcu::orm: 回退迁移失败")
println(" ${e.message}")
1
}
@@ -197,13 +257,13 @@ public class MigrationCli {
/// rm <迁移名或id>:移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)
private func cmdRm(args: ArrayList<String>): Int64 {
if (args.size < 2) {
println("用法: simorm rm <迁移名或id>")
println("用法: simcu::orm rm <迁移名或id>")
return 1
}
let target = args[1]
let dir = Path(_migrationsDir)
if (!exists(dir)) {
println("simorm: 迁移目录不存在(${_migrationsDir}),没有可移除的迁移")
println("simcu::orm: 迁移目录不存在(${_migrationsDir}),没有可移除的迁移")
return 1
}
// 扫描迁移目录,收集迁移 id(文件名去 .cj;排除注册文件/占位文件)
@@ -217,7 +277,7 @@ public class MigrationCli {
}
}
if (ids.size == 0) {
println("simorm: ${_migrationsDir} 下没有迁移文件")
println("simcu::orm: ${_migrationsDir} 下没有迁移文件")
return 1
}
// 匹配:精确(migrationId 或 类名)+ 唯一前缀(migrationId)
@@ -262,47 +322,147 @@ public class MigrationCli {
if (exists(sp)) {
remove(sp)
}
println("simorm: 已移除迁移 ${id}(注册表已空,快照已重置)")
println("simcu::orm: 已移除迁移 ${id}(注册表已空,快照已重置)")
} else {
println("simorm: 已移除迁移 ${id}")
println("simcu::orm: 已移除迁移 ${id}")
}
return 0
}
println("simorm: 未找到迁移 '${target}'(${_migrationsDir} 下无匹配)")
println("simcu::orm: 未找到迁移 '${target}'(${_migrationsDir} 下无匹配)")
1
}
/// list:显示已应用/待应用迁移
/// list:显示已应用/待应用迁移(空库/全新库时全部显示为待应用)
private func cmdList(datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect): Int64 {
let list = migrations()
if (list.size == 0) {
println("simorm: 未注册任何迁移(检查 ${_registryPath})")
println("simcu::orm: 未注册任何迁移(检查 ${_registryPath})")
return 1
}
try {
let migrator = Migrator(datasource(), dialect)
let applied = migrator.appliedMigrationIds()
println("simorm: 迁移列表(共 ${list.size} 个)")
let applied = if (migrator.hasHistoryTable()) {
migrator.appliedMigrationIds()
} else {
println("simcu::orm: 数据库为空库(尚未应用任何迁移),全部迁移均待应用")
ArrayList<String>()
}
println("simcu::orm: 迁移列表(共 ${list.size} 个)")
for (m in list) {
let mark = if (applied.contains(m.migrationId)) { "[已应用]" } else { "[待应用]" }
println(" ${mark} ${m.migrationId} ${m.description}")
}
0
} catch (e: Exception) {
println("simorm: 读取迁移历史失败(数据库未创建?)")
println("simcu::orm: 数据库连接失败,无法读取迁移历史")
println(" 请检查数据库配置(项目目录 appsettings.json 的 ConnectionStrings:Pgsql,")
println(" 或环境变量 ORM_CONNECTION_STRING)")
println(" ${e.message}")
1
}
}
/// seed:列出/运行数据填充(@Seed 标注的类,按 seedId 唯一前缀匹配;--all 全部;无参=列出)
private func cmdSeed(datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect, args: ArrayList<String>, seedCtx: ?Any): Int64 {
let list = allSeeds()
if (list.size == 0) {
println("simcu::orm: 未注册任何填充(检查 @Seed 标注的类是否存在,并确认应用已 import 对应包)")
return 1
}
if (args.size < 2 || args[1] == "list") {
println("simcu::orm: 填充列表(共 ${list.size} 个)")
for (s in list) {
println(" ${s.seedId} ${s.description}")
}
println("运行: simcu::orm seed <id> 运行指定填充; simcu::orm seed --all 运行全部")
return 0
}
// 运行前检查:迁移必须全部已应用(否则填充的表可能不存在)
try {
let migrator = Migrator(datasource(), dialect)
if (!migrator.hasHistoryTable()) {
println("simcu::orm: 数据库为空库,请先执行 'simcu::orm update' 应用迁移后再运行填充")
return 1
}
let applied = migrator.appliedMigrationIds()
var pending = 0
for (m in migrations()) {
if (!applied.contains(m.migrationId)) {
pending += 1
}
}
if (pending > 0) {
println("simcu::orm: 有 ${pending} 个迁移未应用,请先执行 'simcu::orm update' 再运行填充")
return 1
}
} catch (e: Exception) {
println("simcu::orm: 检查迁移状态失败(${e.message})")
return 1
}
if (args[1] == "--all") {
var failed = false
for (s in list) {
try {
runSeed(s, seedCtx)
println("simcu::orm: 已执行 ${s.seedId} ${s.description}")
} catch (e: Exception) {
println("simcu::orm: 执行 ${s.seedId} 失败: ${e.message}")
failed = true
}
}
if (failed) {
println("simcu::orm: 存在执行失败的填充,请检查上方错误")
return 1
}
println("simcu::orm: 全部 ${list.size} 个填充执行完成")
return 0
}
// 指定 id:精确(seedId)或唯一前缀
let target = args[1]
var hit: ?Seed = None
let prefixHits = ArrayList<Seed>()
for (s in list) {
if (s.seedId == target) {
hit = Some(s)
} else if (s.seedId.startsWith(target)) {
prefixHits.add(s)
}
}
if (hit.isNone() && prefixHits.size == 1) {
hit = Some(prefixHits[0])
}
if (let Some(s) <- hit) {
try {
runSeed(s, seedCtx)
println("simcu::orm: 已执行 ${s.seedId} ${s.description}")
0
} catch (e: Exception) {
println("simcu::orm: 执行 ${s.seedId} 失败: ${e.message}")
1
}
} else {
println("simcu::orm: 未找到填充 '${target}'(共 ${list.size} 个可用)")
1
}
}
/// 运行单个填充:ctx 为应用自己的 DbContext 实例(CLI 入口自带)
private func runSeed(seed: Seed, seedCtx: ?Any): Unit {
if (seedCtx.isNone()) {
throw Exception("simcu::orm: seed 需要应用 DbContext 上下文(请通过 DbContext.cli 入口运行)")
}
seed.run(seedCtx.getOrThrow())
}
// ---------- 私有工具 ----------
/// 从应用根目录 cjpm.toml 推断包名(organization::name 或 name)
private func detectPackage(): String {
let p = Path("cjpm.toml")
if (!exists(p)) {
throw Exception("simorm: 未找到 cjpm.toml,请在应用根目录运行 add 命令")
throw Exception("simcu::orm: 未找到 cjpm.toml,请在应用根目录运行 add 命令")
}
let text = String.fromUtf8(File.readFrom(p))
var name = ""
@@ -317,7 +477,7 @@ public class MigrationCli {
}
}
if (name.isEmpty()) {
throw Exception("simorm: cjpm.toml 缺少 [package] name 字段")
throw Exception("simcu::orm: cjpm.toml 缺少 [package] name 字段")
}
if (org.isEmpty()) {
return name
@@ -388,14 +548,31 @@ public class MigrationCli {
}
private static func printHelp(): Unit {
println("simorm - orm-cj 迁移 CLI(对齐 dotnet ef)")
println("simcu::orm cli - 数据库迁移管理程序")
println("")
if (!isDevMode()) {
println("当前为打包部署环境(未通过 cjpm run 运行),仅支持:")
println(" list 列出已应用/待应用迁移")
println(" update 应用所有未执行的迁移(将数据库升级到最新)")
println(" seed 列出/运行数据填充(seed <id> 运行指定;seed --all 运行全部)")
println(" help 显示本帮助")
return
}
println("用法:")
println(" <app> add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)")
println(" <app> rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)")
println(" <app> update 应用所有未执行的迁移")
println(" <app> downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后")
println(" <app> list 列出已应用/待应用迁移")
println(" <app> help 显示本帮助")
println(" add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)")
println(" rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)")
println(" update 应用所有未执行的迁移")
println(" downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后")
println(" list 列出已应用/待应用迁移")
println(" seed 列出数据填充;seed <id> 运行指定;seed --all 运行全部")
println(" database truncate 清空所有业务表数据(保留表结构与迁移历史)")
println(" database drop 删除所有表(含迁移历史),完全清空数据库,之后 update 可重建")
println(" help 显示本帮助")
}
/// 开发模式(通过 cjpm run 运行):当前工作目录存在 cjpm.toml。
/// 打包部署后 cjpm.toml 不在工作目录,自动进入只读模式(仅 list/update/seed)。
private static func isDevMode(): Bool {
exists(Path("cjpm.toml"))
}
}
+5 -5
View File
@@ -5,7 +5,7 @@
* - 自动维护迁移注册文件 MigrationRegistry.cj(add 时插入新迁移实例)。
*
* 生成文件的格式:
* // 此文件由 simorm CLI 自动生成,请勿手动编辑
* // 此文件由 simcu::orm CLI 自动生成,请勿手动编辑
* package app
*
* import simcu::orm.migrations.*
@@ -58,10 +58,10 @@ public class MigrationFileGenerator {
/// 迁移 → .cj 源码文本(不落盘,供测试/预览)
public static func migrationSource(appPackage: String, className: String, m: Migration): String {
if (appPackage.isEmpty()) {
throw Exception("simorm: 应用包名不能为空,请在应用根目录(含 cjpm.toml)运行")
throw Exception("simcu::orm: 应用包名不能为空,请在应用根目录(含 cjpm.toml)运行")
}
let sb = StringBuilder()
sb.append("// 此文件由 simorm CLI 自动生成,请勿手动编辑\n")
sb.append("// 此文件由 simcu::orm CLI 自动生成,请勿手动编辑\n")
sb.append("package ${appPackage}\n\n")
sb.append("import std.collection.*\n")
sb.append("import simcu::orm.migrations.*\n")
@@ -102,7 +102,7 @@ public class MigrationFileGenerator {
/// 返回注册文件源码文本
public static func registrySource(migrationPackage: String, entries: ArrayList<String>): String {
let sb = StringBuilder()
sb.append("// 此文件由 simorm CLI 自动维护,请勿手动编辑\n")
sb.append("// 此文件由 simcu::orm CLI 自动维护,请勿手动编辑\n")
sb.append("// 与迁移类同包(${migrationPackage}),模块加载时顶层 let 自动注册\n")
sb.append("// 迁移到 simcu::orm 全局注册表(按 migrationId 字典序应用)。\n")
sb.append("package ${migrationPackage}\n\n")
@@ -249,7 +249,7 @@ public class MigrationFileGenerator {
}
if (let Some(_) <- c.defaultValue) {
throw Exception(
"simorm: CLI 生成的迁移不支持带默认值的列,请改用手写迁移(override up/down)")
"simcu::orm: CLI 生成的迁移不支持带默认值的列,请改用手写迁移(override up/down)")
}
s
}
+1 -1
View File
@@ -42,7 +42,7 @@ public class DatasourceFactory {
private static func build(name: String, url: String): Datasource {
let driver = DriverManager.getDriver(name) ??
throw Exception(
"simorm: 未注册数据库驱动 '${name}'。请确认应用已引入并链接对应驱动包" +
"simcu::orm: 未注册数据库驱动 '${name}'。请确认应用已引入并链接对应驱动包" +
"(如 opengauss-driver,其加载时自动注册 postgres/opengauss)")
driver.open(url)
}
+7 -2
View File
@@ -146,6 +146,11 @@ public open class DbContext <: CliContext {
migrations()
}
/// CliContext:seed 运行上下文 = 本 DataContext 实例(seed 的 run(ctx) 用它操作 ORM)
public func getSeedContext(): Any {
this
}
/// 系统库数据源(数据库存在性检查用)。
/// 由连接串 + 驱动派生:Database=xxx 换成系统库名(pgsql → postgres,mysql → mysql)。
/// 无连接串或驱动不支持(sqlite 无系统库)时返回 None。
@@ -346,7 +351,7 @@ public open class DbContext <: CliContext {
let instance = model.createInstance()
var i = 0
for (p in model.properties) {
let v = ValueReader.read(rs, i, p.typeName())
let v = ValueReader.read(rs, i, p.fullTypeName())
i += 1
if (let Some(val) <- v) {
model.setValue(instance, p, val)
@@ -381,7 +386,7 @@ public open class DbContext <: CliContext {
if (returning) {
let rs = stmt.query()
if (rs.next()) {
let v = ValueReader.read(rs, 0, kp.typeName())
let v = ValueReader.read(rs, 0, kp.fullTypeName())
if (let Some(kv) <- v) {
model.setKeyValue(entry.entity, kv)
}
+87
View File
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* @Seed 类级属性宏:把标注的普通类展开为数据填充(自动注册,无需手写注册表)。
*
* 用法:
* @Seed["初始化游戏服务器配置"]
* public class InitGames {
* public func run(ctx: DataContext): Unit {
* // 用应用自己的 DbContext 直接操作 ORM,自行保证幂等
* }
* }
*
* 展开行为:
* - seedId 取类名(如 InitGames),属性参数仅作为描述
* - 保留原类(用户类不继承任何基类,run 参数类型为应用自己的 DbContext)
* - 生成包装类 <: simcu::orm.seeds.Seed,override run(ctx: Any),
* 内部 new 用户类并 `ctx as <run 参数类型>` 转型后调用用户 run
* - 生成顶层 let registerSeed(包装类实例),模块加载时自动注册
*
* 约束:
* - 属性参数为描述(不能包含双引号/反斜杠/换行)
* - 类必须提供单参数方法 run(ctx)
* - 应用侧需 import simcu::orm.macros.* 与 simcu::orm.seeds.*
*/
macro package simcu::orm.macros
import std.ast.*
import std.collection.*
/// @Seed 属性宏:属性(attrTokens)为 ["描述"],input 为类声明,seedId 取类名。
public macro Seed(attrTokens: Tokens, inputTokens: Tokens): Tokens {
let description = parseAttr(attrTokens.toString())
if (description.contains("\"") || description.contains("\\") || description.contains("\n")) {
throw ASTException("@Seed 参数描述不能包含双引号、反斜杠或换行")
}
let cd = (parseDecl(inputTokens) as ClassDecl).getOrThrow()
// 找 run 方法并提取第一个参数类型(任意类型,由包装类在应用侧转型)
var ctxType: ?String = None
for (d in cd.body.decls) {
if (let fd: FuncDecl <- d) {
if (fd.identifier.value == "run") {
if (fd.funcParams.size == 1) {
ctxType = Some(fd.funcParams[0].paramType.toTokens().toString())
}
}
}
}
let paramType = match (ctxType) {
case Some(t) => t
case None =>
throw ASTException("@Seed 类必须提供单参数方法 run(ctx),参数为应用自己的 DbContext 类型")
}
let className = cd.identifier.value
let seedId = className
let wrapperName = "${className}_SeedWrapper"
let regName = "_seedRegistration_${className}"
let code = """
public class ${wrapperName} <: Seed {
public init() {
super("${seedId}", "${description}")
}
public override func run(ctx: Any): Unit {
let inst = ${className}()
inst.run((ctx as ${paramType}).getOrThrow())
}
}
let ${regName} = registerSeed(${wrapperName}())
"""
// inputTokens 末尾 } 与展开内容之间需换行分隔(cangjieLex 保留 NL)
inputTokens + cangjieLex("\n" + code)
}
/// 解析属性 tokens 文本(形如 "描述")中的字符串字面量。
/// 按引号对子串定位后字节切片(引号为 ASCII,边界安全,中文等多字节内容原样保留)。
private func parseAttr(text: String): String {
let open = text.indexOf("\"")
if (open.isNone()) {
throw ASTException("@Seed 需要一个字符串属性: @Seed[\"描述\"]")
}
let start = open.getOrThrow() + 1
let close = text.indexOf("\"", start)
if (close.isNone()) {
throw ASTException("@Seed 需要一个字符串属性: @Seed[\"描述\"]")
}
text[start..close.getOrThrow()]
}
+16 -6
View File
@@ -91,7 +91,8 @@ public class MigrationGenerator {
}
/// 字段类型简单名 → 列类型(String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array<Byte>)
/// Option<X> 按内层类型映射(Option 列默认 nullable,见 columnDefinition 的 isRequired/isKey 判定)
/// Option<X> 按内层类型映射(Option 列默认 nullable,见 columnDefinition 的 isRequired/isKey 判定);
/// 简单枚举不在此映射(由 columnDefinition 按 isEnum 标记走 TextCol)。
public static func columnTypeFor(typeName: String): ColumnTypes {
let effective = if (typeName.startsWith("Option<") && typeName.endsWith(">")) {
typeName[7..typeName.size - 1]
@@ -116,13 +117,13 @@ public class MigrationGenerator {
case "Duration" => ColumnTypes.BigIntCol
case "Decimal" => ColumnTypes.DecimalCol
case "Array<Byte>" => ColumnTypes.BinaryCol
case _ => throw Exception("simorm: 字段类型 ${typeName} 无法映射到列类型")
case _ => throw Exception("simcu::orm: 字段类型 ${typeName} 无法映射到列类型")
}
}
/// PropertyModel → ColumnDefinition(主键/自增/非空/最大长度 随注解映射)
public static func columnDefinition(p: PropertyModel): ColumnDefinition {
let c = ColumnDefinition(p.columnName, columnTypeFor(p.typeName()))
let c = ColumnDefinition(p.columnName, columnTypeOf(p))
if (p.isKey) {
c.primary()
}
@@ -138,6 +139,15 @@ public class MigrationGenerator {
c
}
/// 列类型:枚举字段(含 Option 内层枚举)按构造器名存 VARCHAR,其余按类型名映射
private static func columnTypeOf(p: PropertyModel): ColumnTypes {
if (p.isEnum) {
ColumnTypes.TextCol
} else {
columnTypeFor(p.typeName())
}
}
// ---------- 私有 ----------
private func diffTable(upOps: ArrayList<MigrationOperation>, downOps: ArrayList<MigrationOperation>,
@@ -147,7 +157,7 @@ public class MigrationGenerator {
let curKey = cur.keyProperty.getOrThrow().columnName
if (oldKey != curKey) {
throw Exception(
"simorm: 表 ${cur.tableName} 主键从 ${oldKey} 变更为 ${curKey},v1 自动迁移不支持,请手写迁移")
"simcu::orm: 表 ${cur.tableName} 主键从 ${oldKey} 变更为 ${curKey},v1 自动迁移不支持,请手写迁移")
}
var oldCols = HashMap<String, PropertyModel>()
@@ -178,7 +188,7 @@ public class MigrationGenerator {
if (let Some(op) <- oldCols.get(p.columnName)) {
if (op.isKey != p.isKey || op.autoIncrement != p.autoIncrement) {
throw Exception(
"simorm: 表 ${cur.tableName} 列 ${p.columnName} 的主键/自增属性发生变化,v1 自动迁移不支持,请手写迁移")
"simcu::orm: 表 ${cur.tableName} 列 ${p.columnName} 的主键/自增属性发生变化,v1 自动迁移不支持,请手写迁移")
}
if (!sameColumn(op, p)) {
upOps.add(alterColumnOp(cur.tableName, p))
@@ -189,7 +199,7 @@ public class MigrationGenerator {
}
private static func sameColumn(a: PropertyModel, b: PropertyModel): Bool {
typeRank(columnTypeFor(a.typeName())) == typeRank(columnTypeFor(b.typeName())) &&
typeRank(columnTypeOf(a)) == typeRank(columnTypeOf(b)) &&
a.maxLength == b.maxLength &&
a.isRequired == b.isRequired
}
+1 -1
View File
@@ -456,7 +456,7 @@ public class DdlFactory {
if (let d: DateTime <- v) {
return "'${d.toString()}'"
}
throw Exception("simorm: 不支持的默认值类型,请使用 String/Bool/数值/Rune/DateTime")
throw Exception("simcu::orm: 不支持的默认值类型,请使用 String/Bool/数值/Rune/DateTime")
}
private func escapeQuotes(s: String): String {
+105 -2
View File
@@ -109,6 +109,109 @@ public class Migrator {
revert(migrations, None)
}
/// 清空所有业务表数据(TRUNCATE ... RESTART IDENTITY CASCADE,保留表结构与迁移历史表),
/// 返回被清空的表数量。供 CLI `database truncate` 使用。
public func truncateAll(): Int64 {
let conn = _datasource.connect()
try {
let stmt = conn.prepareStatement(
"SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()")
let names = ArrayList<String>()
try {
let rs = stmt.query()
while (rs.next()) {
let n = rs.getOrNull<String>(0).getOrThrow()
if (n != _historyTable) {
names.add(n)
}
}
} finally {
stmt.close()
}
if (names.size == 0) {
return 0
}
var sql = "TRUNCATE TABLE "
for (i in 0..names.size) {
if (i > 0) {
sql += ", "
}
sql += _dialect.quoteName(names[i])
}
sql += " RESTART IDENTITY CASCADE"
let ts = conn.prepareStatement(sql)
try {
ts.update()
} finally {
ts.close()
}
names.size
} finally {
conn.close()
}
}
/// 删除数据库内所有表(含迁移历史表),完全清空数据库(库本身保留)。
/// 返回删除的表数量。供 CLI `database drop` 使用;之后可 `update` 重建全部表。
public func dropAllTables(): Int64 {
let conn = _datasource.connect()
try {
let stmt = conn.prepareStatement(
"SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema()")
let names = ArrayList<String>()
try {
let rs = stmt.query()
while (rs.next()) {
names.add(rs.getOrNull<String>(0).getOrThrow())
}
} finally {
stmt.close()
}
if (names.size == 0) {
return 0
}
var sql = "DROP TABLE "
for (i in 0..names.size) {
if (i > 0) {
sql += ", "
}
sql += _dialect.quoteName(names[i])
}
sql += " CASCADE"
let ts = conn.prepareStatement(sql)
try {
ts.update()
} finally {
ts.close()
}
names.size
} finally {
conn.close()
}
}
/// 迁移历史表是否已存在(用于区分“空库/全新库”与“连接失败”:历史表不存在不视为错误)。
public func hasHistoryTable(): Bool {
let conn = _datasource.connect()
try {
// 注意:PG 中双引号是标识符引用,字符串比较必须用单引号(表名为内部常量,无注入风险)
let stmt = conn.prepareStatement(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() " +
"AND table_name = '${_historyTable}'")
try {
let rs = stmt.query()
if (!rs.next()) {
return false
}
rs.getOrNull<Int64>(0).getOrThrow() > 0
} finally {
stmt.close()
}
} finally {
conn.close()
}
}
/// 回退到目标迁移(不含目标):执行目标之后全部已应用迁移的 down。
/// target 匹配规则:精确(migrationId / description)或唯一前缀(migrationId)。
/// 返回回退的数量;无已应用迁移或已处于目标时返回 0。
@@ -210,9 +313,9 @@ public class Migrator {
return prefixHits[0]
}
if (prefixHits.size == 0) {
throw Exception("simorm: 未找到迁移 '${target}'(已应用的迁移中无匹配)")
throw Exception("simcu::orm: 未找到迁移 '${target}'(已应用的迁移中无匹配)")
}
throw Exception("simorm: 迁移目标 '${target}' 不唯一,请使用完整 id")
throw Exception("simcu::orm: 迁移目标 '${target}' 不唯一,请使用完整 id")
}
private func removeApplied(conn: Connection, m: Migration): Unit {
+12 -9
View File
@@ -10,10 +10,10 @@
* // 模型变更后:快照存在 → 载入旧模型 diff,并覆盖保存新快照
* gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models1)
*
* 快照 JSON 结构(版本 1):
* {"version":1,"models":[{"table":"users","columns":[
* 快照 JSON 结构(版本 2,较 v1 新增 enum 标记):
* {"version":2,"models":[{"table":"users","columns":[
* {"name":"id","column":"id","type":"Int64","key":true,"auto":true,
* "client":false,"required":false,"maxLen":0}]}]}
* "client":false,"required":false,"maxLen":0,"enum":false}]}]}
*/
package simcu::orm.migrations
@@ -30,7 +30,7 @@ public class ModelSnapshot {
/// 快照内的轻量模型列表
public var models: ArrayList<EntityModel> = ArrayList()
/// 快照格式版本
public static let formatVersion: Int64 = 1
public static let formatVersion: Int64 = 2
public init() {}
@@ -50,6 +50,7 @@ public class ModelSnapshot {
lp.isRequired = p.isRequired
lp.maxLength = p.maxLength
lp.typeNameOverride = p.typeName()
lp.isEnum = p.isEnum
lm.properties.add(lp)
}
if (let Some(kp) <- m.keyProperty) {
@@ -88,6 +89,7 @@ public class ModelSnapshot {
co["client"] = JBool(p.clientGenerated)
co["required"] = JBool(p.isRequired)
co["maxLen"] = JNumber("${p.maxLength}")
co["enum"] = JBool(p.isEnum)
cols.add(JObject(co))
}
mo["columns"] = JArray(cols)
@@ -123,23 +125,24 @@ public class ModelSnapshot {
lp.clientGenerated = getBool(co, "client")
lp.isRequired = getBool(co, "required")
lp.maxLength = getLong(co, "maxLen")
lp.isEnum = getBool(co, "enum")
if (lp.isKey) {
lm.keyProperty = Some(lp)
}
lm.properties.add(lp)
case _ => throw Exception(
"simorm: 快照 columns 项必须是 object")
"simcu::orm: 快照 columns 项必须是 object")
}
}
case _ => throw Exception("simorm: 快照 models[].columns 必须是数组")
case _ => throw Exception("simcu::orm: 快照 models[].columns 必须是数组")
}
snap.models.add(lm)
case _ => throw Exception("simorm: 快照 models[] 项必须是 object")
case _ => throw Exception("simcu::orm: 快照 models[] 项必须是 object")
}
}
case _ => throw Exception("simorm: 快照缺少 models 数组")
case _ => throw Exception("simcu::orm: 快照缺少 models 数组")
}
case _ => throw Exception("simorm: 快照根必须是 object")
case _ => throw Exception("simcu::orm: 快照根必须是 object")
}
snap
}
+67 -6
View File
@@ -57,18 +57,31 @@ public class PropertyModel {
public var isMutable: Bool = false
/// 类型简单名覆盖(快照重建时替代反射,如 "Int64"、"String")
public var typeNameOverride: String = ""
/// 字段是否为简单枚举(含 Option 内层):数据库按枚举构造器名存 VARCHAR
public var isEnum: Bool = false
/// 枚举完整类型名(如 simcu::xxx.AssetKind;快照重建的轻量模型为空串)
public var enumTypeName: String = ""
/// 字段类型简单名(如 Int64、String):优先 typeNameOverride,否则走反射
/// 字段类型简单名(如 Int64、String、Option<AssetKind>):优先 typeNameOverride,否则走反射
public func typeName(): String {
if (typeNameOverride != "") {
return typeNameOverride
}
if (let Some(t) <- typeInfo) {
return TypeUtil.simpleName(t.toString())
return TypeUtil.simpleTypeName(t.toString())
}
""
}
/// 字段完整类型名(反射句柄存在时返回全限定名,供反射构造枚举/递归读取):
/// 快照重建的轻量模型无反射句柄,退回简单名。
public func fullTypeName(): String {
if (let Some(t) <- typeInfo) {
return t.toString()
}
typeName()
}
/// 字段是否为 Option 类型("Option<" 前缀,与快照存储的 typeName 一致)
public func isOption(): Bool {
typeName().startsWith("Option<")
@@ -194,10 +207,23 @@ public class ModelCache {
propM.typeInfo = Some(v.typeInfo)
propM.isMutable = v.isMutable()
let typeName = TypeUtil.simpleName(v.typeInfo.toString())
// 简单枚举(含 Option<Enum> 内层):校验无参构造器后标记 isEnum,按构造器名存 VARCHAR。
// 必须排除 Option 本身(Option 也是枚举,走通用 Option 支持路径)。
let fullName = v.typeInfo.toString()
let innerFull = if (fullName.startsWith("Option<") && fullName.endsWith(">")) {
fullName[7..fullName.size - 1]
} else {
fullName
}
if (!innerFull.startsWith("Option<") && (let et: EnumTypeInfo <- TypeInfo.get(innerFull))) {
validateSimpleEnum(et, v.name)
propM.isEnum = true
propM.enumTypeName = innerFull
}
// Option<X> 字段:放开校验,按内层类型处理(写库 None → NULL,读库 NULL → None)
if (!ValueReader.isSupported(propM.effectiveTypeName())) {
if (!propM.isEnum && !ValueReader.isSupported(propM.effectiveTypeName())) {
throw Exception(
"simorm: 字段 ${v.name} 的类型 ${typeName} 不受支持,支持的标量类型:String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array<Byte>")
"simcu::orm: 字段 ${v.name} 的类型 ${typeName} 不受支持,支持的标量类型:String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array<Byte>;枚举仅支持无参构造器(简单枚举)")
}
// 列名
if (let Some(c) <- v.findAnnotation<Column>()) {
@@ -235,15 +261,31 @@ public class ModelCache {
}
}
if (keys.size == 0) {
throw Exception("simorm: 实体 ${model.tableName} 缺少主键,请用 @Key 标注或命名字段为 _id")
throw Exception("simcu::orm: 实体 ${model.tableName} 缺少主键,请用 @Key 标注或命名字段为 _id")
}
if (keys.size > 1) {
throw Exception("simorm: 实体 ${model.tableName} 有多个主键,v1 仅支持单主键")
throw Exception("simcu::orm: 实体 ${model.tableName} 有多个主键,v1 仅支持单主键")
}
model.keyProperty = Some(keys[0])
model
}
/// 校验枚举为简单枚举(全部构造器无参,即枚举成员不带数据)
private static func validateSimpleEnum(et: EnumTypeInfo, field: String): Unit {
for (c in et.constructors) {
let ok = try {
et.getConstructor(c.name, argsCount: 0)
true
} catch (_: Exception) {
false
}
if (!ok) {
throw Exception(
"simcu::orm: 字段 ${field} 的枚举类型仅支持无参构造器(简单枚举),不支持带数据的枚举变体")
}
}
}
/// 应用列命名策略
public static func applyNamingPolicy(fieldName: String, policy: ColumnNamingPolicy): String {
match (policy) {
@@ -312,6 +354,15 @@ public class TypeUtil {
typeName
}
/// 类型完整名 → 简单名,保留 Option 泛型壳(内层按 simpleName 处理):
/// "Option<simcu::orm.tests.AssetKind>" → "Option<AssetKind>"
public static func simpleTypeName(typeName: String): String {
if (typeName.startsWith("Option<") && typeName.endsWith(">")) {
return "Option<${simpleName(typeName[7..typeName.size - 1])}>"
}
simpleName(typeName)
}
/// 整型类型
public static func isIntegerType(typeName: String): Bool {
typeName == "Int8" || typeName == "Int16" || typeName == "Int32" || typeName == "Int64"
@@ -337,6 +388,16 @@ public class TypeUtil {
public static func isOptionValue(v: Any): Bool {
TypeInfo.of(v).toString().startsWith("Option<")
}
/// 枚举值 → 数据库参数(构造器名);非枚举值原样返回。
/// 简单枚举写库按成员名存 VARCHAR,读回时按名反射构造,保证往返一致。
public static func enumToParamValue(v: Any): Any {
if (let et: EnumTypeInfo <- TypeInfo.of(v)) {
let (ctor, _) = et.destruct(v)
return ctor.name
}
v
}
}
/**
+30 -6
View File
@@ -23,6 +23,7 @@ public class ValueReader {
/**
* 按类型名从结果集第 index 列读值。
* typeName 可为完整名(推荐,走 fullTypeName())或简单名;Option<X> 的内层按完整名递归。
* 返回 None 表示该列为 NULL。
*/
public static func read(rows: QueryResult, index: Int, typeName: String): ?Any {
@@ -36,7 +37,7 @@ public class ValueReader {
}
return Some(constructOption(typeName, "None", defaultValueFor(inner)))
}
match (typeName) {
match (TypeUtil.simpleTypeName(typeName)) {
case "String" => wrap(rows.getOrNull<String>(index))
case "Bool" => wrap(rows.getOrNull<Bool>(index))
case "Int8" => wrap(rows.getOrNull<Int8>(index))
@@ -54,8 +55,29 @@ public class ValueReader {
case "Duration" => wrap(rows.getOrNull<Duration>(index))
case "Decimal" => wrap(rows.getOrNull<Decimal>(index))
case "Array<Byte>" => wrap(rows.getOrNull<Array<Byte>>(index))
case _ => throw Exception("simorm: 不支持的实体字段类型 ${typeName}")
case _ =>
// 简单枚举:数据库存构造器名(VARCHAR),读回按名反射构造
if (let et: EnumTypeInfo <- TypeInfo.get(typeName)) {
if (let Some(s) <- rows.getOrNull<String>(index)) {
return Some(constructEnum(et, s))
}
return None
}
throw Exception("simcu::orm: 不支持的实体字段类型 ${typeName}")
}
}
/// 按枚举完整类型名 + 构造器名反射构造枚举实例(不存在时抛异常)
public static func enumFromName(enumTypeName: String, name: String): Any {
if (let et: EnumTypeInfo <- TypeInfo.get(enumTypeName)) {
return constructEnum(et, name)
}
throw Exception("simcu::orm: 类型 ${enumTypeName} 不是枚举")
}
/// 按枚举构造器名反射构造枚举实例(不存在该无参构造器时底层反射抛异常)
private static func constructEnum(et: EnumTypeInfo, name: String): Any {
et.getConstructor(name, argsCount: 0).apply([])
}
/// ?T → ?Any(Option 不自动协变,需显式包装)
@@ -73,7 +95,7 @@ public class ValueReader {
let ctor = et.getConstructor(ctorName, argsCount: 1)
return ctor.apply([arg])
}
throw Exception("simorm: 无法构造 Option 类型 ${optionTypeName} 的 ${ctorName}")
throw Exception("simcu::orm: 无法构造 Option 类型 ${optionTypeName} 的 ${ctorName}")
}
/// 内层类型占位默认值(构造 Option None 用;None 构造器忽略实参语义)
@@ -107,7 +129,7 @@ public class ValueReader {
let ctor = et.getConstructor(first.name, argsCount: 0)
return ctor.apply([])
}
throw Exception("simorm: 无法为 Option 占位值构造类型 ${typeName}")
throw Exception("simcu::orm: 无法为 Option 占位值构造类型 ${typeName}")
}
/// 属性类型是否受支持(ModelCache 构建时预校验)
@@ -142,12 +164,14 @@ public class ParamBinder {
public static func bindOne(stmt: Statement, index: Int, raw: Any): Unit {
if (TypeUtil.isOptionValue(raw)) {
if (let Some(v) <- TypeUtil.unwrapOption(raw)) {
stmt.set<Any>(index, v)
// 内层可能是枚举值:转构造器名后绑定
stmt.set<Any>(index, TypeUtil.enumToParamValue(v))
} else {
stmt.setNull(index)
}
} else {
stmt.set<Any>(index, raw)
// 顶层可能是枚举值(如 filter("kind", "=", AssetKind.Package))
stmt.set<Any>(index, TypeUtil.enumToParamValue(raw))
}
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 数据填充(Seed)抽象基类(内部接口,用户不直接继承)。
*
* 用户侧用法:@Seed 类级宏(见 simcu::orm.macros),标注一个普通类,
* seedId 取类名,属性参数仅作描述:
* @Seed["初始化游戏服务器配置"]
* public class InitGames {
* public func run(ctx: DataContext): Unit {
* if (ctx.games.find("game-1").isNone()) {
* let g = Game()
* g.id = "game-1"
* g.apiKey = "key-1"
* g.name = "测试服"
* ctx.games.add(g)
* ctx.saveChanges()
* }
* }
* }
*
* 宏展开自动生成包装类(继承本基类)+ 顶层 let 注册到全局注册表,
* 用户无需手写注册文件。CLI `orm seed` 运行时把应用自己的
* DataContext 实例作为 ctx 传入 run 方法。
*
* 语义:不记录运行历史,可随时/多次运行,幂等由用户代码自行保证
* (如按 id find 后不存在再插入)。
*/
package simcu::orm.seeds
/// 数据填充统一接口(CLI 内部使用)。
public abstract class Seed {
/// 唯一 id(取 @Seed 标注的类名)
public let seedId: String
/// 填充内容说明(CLI 列表展示)
public let description: String
public init(seedId: String, description: String) {
this.seedId = seedId
this.description = description
}
/// 执行填充。ctx 为应用自己的 DbContext 实例(宏生成的包装类负责转型)。
public func run(ctx: Any): Unit
}
+47
View File
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 进程级填充注册表:
* - 应用侧 src/seeds/SeedRegistry.cj 在模块初始化时通过顶层 let
* 自动调用 registerSeed 注册填充类实例(与迁移注册表同模式);
* - allSeeds() 按 seedId 字典序返回,供 CLI `orm seed` 使用。
*/
package simcu::orm.seeds
import std.collection.*
/// 已注册的全部填充(按注册顺序存储,读取时排序)
private let _registeredSeeds = ArrayList<Seed>()
/// 注册一个填充实例(按 seedId 去重,幂等)。
/// 应用侧注册文件在顶层 `let _r0 = registerSeed(Xxx())` 中调用。
public func registerSeed(seed: Seed): Bool {
for (s in _registeredSeeds) {
if (s.seedId == seed.seedId) {
return false
}
}
_registeredSeeds.add(seed)
true
}
/// 全部已注册填充,按 seedId 字典序返回新列表(不修改注册表)
public func allSeeds(): ArrayList<Seed> {
let list = ArrayList<Seed>()
for (s in _registeredSeeds) {
list.add(s)
}
// 插入排序(seedId 按时间戳生成,基本有序)
var i: Int64 = 1
while (i < list.size) {
let cur = list[i]
var j = i - 1
while (j >= 0 && list[j].seedId > cur.seedId) {
list[j + 1] = list[j]
j -= 1
}
list[j + 1] = cur
i += 1
}
list
}
+105 -1
View File
@@ -11,6 +11,7 @@ package simcu::orm.tests
import std.collection.*
import std.database.sql.*
import std.fs.*
import std.reflect.*
import std.unittest.*
import std.unittest.testmacro.*
import simcu::orm.*
@@ -72,11 +73,27 @@ enum TestColor {
| Green
}
enum AssetKind {
| Passport
| Package
}
class UnsupportedEntity {
public var id: Int64 = 0
public var color: TestColor = TestColor.Red
}
class EnumEntity {
public var id: Int64 = 0
public var kind: AssetKind = AssetKind.Passport
public var optKind: ?AssetKind = None
}
class UnsupportedArrayEntity {
public var id: Int64 = 0
public var tags: Array<Int32> = Array<Int32>()
}
// ---------- 模型映射 ----------
@Test
@@ -188,11 +205,55 @@ class ModelValidationTests {
@Expect(nick.isRequired, false)
}
@TestCase
public func testEnumFieldMaps(): Unit {
// 简单枚举(含 Option 内层枚举)受支持:标记 isEnum,记录完整类型名
let model = ModelCache.get<EnumEntity>()
@Expect(model.properties.size, 3)
let kind = model.properties[1]
@Expect(kind.name, "kind")
@Expect(kind.isEnum, true)
@Expect(kind.enumTypeName != "", true)
@Expect(kind.typeName(), "AssetKind")
let optKind = model.properties[2]
@Expect(optKind.name, "optKind")
@Expect(optKind.isOption(), true)
@Expect(optKind.isEnum, true)
@Expect(optKind.enumTypeName != "", true)
}
@TestCase
public func testUnsupportedTypeThrows(): Unit {
let threw = try { ModelCache.get<UnsupportedEntity>(); false } catch (_: Exception) { true }
// Array<Int32> 不在白名单 → 抛异常
let threw = try { ModelCache.get<UnsupportedArrayEntity>(); false } catch (_: Exception) { true }
@Expect(threw, true)
}
@TestCase
public func testEnumToParamValue(): Unit {
// 枚举值 → 构造器名(写库值)
@Expect((TypeUtil.enumToParamValue(TestColor.Red) as String).getOrThrow(), "Red")
@Expect((TypeUtil.enumToParamValue(TestColor.Green) as String).getOrThrow(), "Green")
// 非枚举值原样返回
@Expect((TypeUtil.enumToParamValue("abc") as String).getOrThrow(), "abc")
@Expect((TypeUtil.enumToParamValue(Int64(18)) as Int64).getOrThrow(), 18)
}
@TestCase
public func testEnumFromNameRoundTrip(): Unit {
// 完整类型名 + 构造器名 → 反射构造枚举实例(读库值),构造器名与写库一致
let full = TypeInfo.of(TestColor.Green).toString()
let v = ValueReader.enumFromName(full, "Green")
let et = if (let e: EnumTypeInfo <- TypeInfo.of(v)) { e } else { throw Exception("不是枚举") }
let (ctor, _) = et.destruct(v)
@Expect(ctor.name, "Green")
// 不存在的构造器 → 抛异常
let threw = try { ValueReader.enumFromName(full, "Blue"); false } catch (_: Exception) { true }
@Expect(threw, true)
// 非枚举类型名 → 抛异常
let threw2 = try { ValueReader.enumFromName("std.core.String", "x"); false } catch (_: Exception) { true }
@Expect(threw2, true)
}
}
// ---------- 命名策略 ----------
@@ -786,6 +847,49 @@ class MigrationGeneratorTests {
@Expect(up[0].contains("\"nick\" VARCHAR(255)"), true)
@Expect(up[0].contains("\"nick\" VARCHAR(255) NOT NULL"), false)
}
@TestCase
public func testEnumColumnDdl(): Unit {
// 枚举列按构造器名存 VARCHAR;Option<枚举> 列 nullable
let models = ArrayList<EntityModel>()
models.add(ModelCache.get<EnumEntity>())
let m = gen().initial("20250911000000_EnumTable", "枚举字段建表", models)
let d = OpenGaussDialect()
let up = upSql(m, d)
@Expect(up.size, 1)
@Expect(up[0].contains("\"kind\" VARCHAR(255)"), true)
@Expect(up[0].contains("\"optKind\" VARCHAR(255)"), true)
@Expect(up[0].contains("\"optKind\" VARCHAR(255) NOT NULL"), false)
}
@TestCase
public func testEnumSnapshotRoundTrip(): Unit {
// 快照保留 isEnum 标记:diff 时枚举列能正确识别为 TextCol
let ms = ArrayList<EntityModel>()
ms.add(ModelCache.get<EnumEntity>())
let snap = ModelSnapshot.capture(ms)
let json = snap.toJson()
@Expect(json.contains("\"enum\":true"), true)
let back = ModelSnapshot.fromJson(json)
@Expect(back.models[0].properties[1].isEnum, true)
// 轻量模型 + 反射模型 diff:无变化 → 空操作
let m = gen().diff("20250912000000_NoChange", "无变化", back.toModels(), ms)
@Expect(upSql(m, OpenGaussDialect()).size, 0)
}
@TestCase
public func testStringToEnumNoAlter(): Unit {
// String 字段改为枚举:DB 层都是 VARCHAR → 无需 alter
let oldModels = ArrayList<EntityModel>()
oldModels.add(ModelCache.get<User>())
let curModels = ArrayList<EntityModel>()
curModels.add(ModelCache.get<EnumEntity>())
let m = gen().diff("20250913000000_TypeChange", "String→enum", oldModels, curModels)
// users 表被删、EnumEntity 表新增(表名不同),此用例只验证 columnDefinition 不抛异常
let d = OpenGaussDialect()
let sqls = upSql(m, d)
@Expect(sqls.size, 2)
}
}
// ---------- PostgreSQL 方言 ----------