diff --git a/README.md b/README.md index 75965f7..ed78c97 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,13 @@ main(args: Array) { | `cjpm run -- orm update` | 连接数据库,按 `migrationId` 字典序应用所有未执行的迁移 | | `cjpm run -- orm downgrade [目标]` | 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后(含该迁移的 down) | | `cjpm run -- orm list` | 列出已注册迁移(需真实连接读取历史表) | +| `cjpm run -- orm seed` | 列出数据填充;`seed ` 运行指定;`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) { - 快照持久化在 `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 ` | 运行指定填充(支持唯一前缀匹配) | +| `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(条件/排序/分页)、PagedResult │ ├── db/ # DbContext(连接 + 提交 + 物化 + 数据库存在性/迁移状态检查)、DbSet -│ ├── 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(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)`,描述不能含双引号/反斜杠/换行。 + --- ## 运行测试 diff --git a/cjpm.toml b/cjpm.toml index 3e053ce..7e8bd3b 100644 --- a/cjpm.toml +++ b/cjpm.toml @@ -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" diff --git a/src/cli/CliContext.cj b/src/cli/CliContext.cj index 132193a..d79dd5d 100644 --- a/src/cli/CliContext.cj +++ b/src/cli/CliContext.cj @@ -25,4 +25,6 @@ public interface CliContext { func getMigrations(): ArrayList /// 应用所有迁移 func migrateAll(migrations: ArrayList): Int64 + /// 数据填充运行上下文:返回应用自己的 DbContext 实例(seed 的 run(ctx) 参数) + func getSeedContext(): Any } diff --git a/src/cli/MigrationCli.cj b/src/cli/MigrationCli.cj index 1dbb9ee..98981de 100644 --- a/src/cli/MigrationCli.cj +++ b/src/cli/MigrationCli.cj @@ -26,7 +26,13 @@ * update 应用所有未执行的迁移 * downgrade [目标] 回退迁移(执行 down;无目标=回退最近一个,有目标=回退到该迁移之后) * list 列出已应用/待应用迁移 + * seed 列出数据填充(@Seed 标注的类);seed 运行指定;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): 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, models: () -> ArrayList, datasource: () -> Datasource, migrations: () -> ArrayList): 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,39 +94,91 @@ public class MigrationCli { private func dispatch(args: ArrayList, models: () -> ArrayList, datasource: () -> Datasource, migrations: () -> ArrayList, - 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, datasource: () -> Datasource, + dialect: ISqlDialect): Int64 { + if (args.size < 2) { + println("用法: simcu::orm database ") + 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 + } + } + /// add <迁移名>:模型 → 迁移文件 + 快照 + 注册 private func cmdAdd(args: ArrayList, models: () -> ArrayList): 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 = .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, 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): 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): 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, 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() + } + 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, + dialect: ISqlDialect, args: ArrayList, 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 运行指定填充; 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() + 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 运行指定;seed --all 运行全部)") + println(" help 显示本帮助") + return + } println("用法:") - println(" add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)") - println(" rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)") - println(" update 应用所有未执行的迁移") - println(" downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后") - println(" list 列出已应用/待应用迁移") - println(" help 显示本帮助") + println(" add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)") + println(" rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)") + println(" update 应用所有未执行的迁移") + println(" downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后") + println(" list 列出已应用/待应用迁移") + println(" seed 列出数据填充;seed 运行指定;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")) } } diff --git a/src/cli/MigrationFileGenerator.cj b/src/cli/MigrationFileGenerator.cj index 261ad88..0ae9684 100644 --- a/src/cli/MigrationFileGenerator.cj +++ b/src/cli/MigrationFileGenerator.cj @@ -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 { 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 } diff --git a/src/db/DatasourceFactory.cj b/src/db/DatasourceFactory.cj index d2c4c69..378aa81 100644 --- a/src/db/DatasourceFactory.cj +++ b/src/db/DatasourceFactory.cj @@ -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) } diff --git a/src/db/DbContext.cj b/src/db/DbContext.cj index 6a3b9cf..aabf17d 100644 --- a/src/db/DbContext.cj +++ b/src/db/DbContext.cj @@ -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) } diff --git a/src/macros/SeedMacro.cj b/src/macros/SeedMacro.cj new file mode 100644 index 0000000..410bfad --- /dev/null +++ b/src/macros/SeedMacro.cj @@ -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 + * - 生成顶层 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()] +} diff --git a/src/migrations/MigrationGenerator.cj b/src/migrations/MigrationGenerator.cj index 5fe109d..4a76e13 100644 --- a/src/migrations/MigrationGenerator.cj +++ b/src/migrations/MigrationGenerator.cj @@ -91,7 +91,8 @@ public class MigrationGenerator { } /// 字段类型简单名 → 列类型(String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array) - /// Option 按内层类型映射(Option 列默认 nullable,见 columnDefinition 的 isRequired/isKey 判定) + /// Option 按内层类型映射(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" => 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, downOps: ArrayList, @@ -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() @@ -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 } diff --git a/src/migrations/Migrations.cj b/src/migrations/Migrations.cj index 78f4eb4..4908388 100644 --- a/src/migrations/Migrations.cj +++ b/src/migrations/Migrations.cj @@ -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 { diff --git a/src/migrations/Migrator.cj b/src/migrations/Migrator.cj index d619a9b..3a39ded 100644 --- a/src/migrations/Migrator.cj +++ b/src/migrations/Migrator.cj @@ -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() + try { + let rs = stmt.query() + while (rs.next()) { + let n = rs.getOrNull(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() + try { + let rs = stmt.query() + while (rs.next()) { + names.add(rs.getOrNull(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(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 { diff --git a/src/migrations/ModelSnapshot.cj b/src/migrations/ModelSnapshot.cj index d5af79b..6756009 100644 --- a/src/migrations/ModelSnapshot.cj +++ b/src/migrations/ModelSnapshot.cj @@ -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 = 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 } diff --git a/src/model/EntityModel.cj b/src/model/EntityModel.cj index 1f81f0c..66154cb 100644 --- a/src/model/EntityModel.cj +++ b/src/model/EntityModel.cj @@ -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):优先 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 内层):校验无参构造器后标记 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 字段:放开校验,按内层类型处理(写库 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") + "simcu::orm: 字段 ${v.name} 的类型 ${typeName} 不受支持,支持的标量类型:String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array;枚举仅支持无参构造器(简单枚举)") } // 列名 if (let Some(c) <- v.findAnnotation()) { @@ -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" → "Option" + 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 + } } /** diff --git a/src/model/ValueReader.cj b/src/model/ValueReader.cj index 3eeecc9..808039a 100644 --- a/src/model/ValueReader.cj +++ b/src/model/ValueReader.cj @@ -23,6 +23,7 @@ public class ValueReader { /** * 按类型名从结果集第 index 列读值。 + * typeName 可为完整名(推荐,走 fullTypeName())或简单名;Option 的内层按完整名递归。 * 返回 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(index)) case "Bool" => wrap(rows.getOrNull(index)) case "Int8" => wrap(rows.getOrNull(index)) @@ -54,10 +55,31 @@ public class ValueReader { case "Duration" => wrap(rows.getOrNull(index)) case "Decimal" => wrap(rows.getOrNull(index)) case "Array" => wrap(rows.getOrNull>(index)) - case _ => throw Exception("simorm: 不支持的实体字段类型 ${typeName}") + case _ => + // 简单枚举:数据库存构造器名(VARCHAR),读回按名反射构造 + if (let et: EnumTypeInfo <- TypeInfo.get(typeName)) { + if (let Some(s) <- rows.getOrNull(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 不自动协变,需显式包装) private static func wrap(o: ?T): ?Any { if (let Some(v) <- o) { @@ -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(index, v) + // 内层可能是枚举值:转构造器名后绑定 + stmt.set(index, TypeUtil.enumToParamValue(v)) } else { stmt.setNull(index) } } else { - stmt.set(index, raw) + // 顶层可能是枚举值(如 filter("kind", "=", AssetKind.Package)) + stmt.set(index, TypeUtil.enumToParamValue(raw)) } } } diff --git a/src/seeds/Seed.cj b/src/seeds/Seed.cj new file mode 100644 index 0000000..1a831e9 --- /dev/null +++ b/src/seeds/Seed.cj @@ -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 +} diff --git a/src/seeds/SeedRegistry.cj b/src/seeds/SeedRegistry.cj new file mode 100644 index 0000000..12fd8ee --- /dev/null +++ b/src/seeds/SeedRegistry.cj @@ -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() + +/// 注册一个填充实例(按 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 { + let list = ArrayList() + 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 +} diff --git a/src/tests/SimOrm_test.cj b/src/tests/SimOrm_test.cj index 0e45d83..9ecaac1 100644 --- a/src/tests/SimOrm_test.cj +++ b/src/tests/SimOrm_test.cj @@ -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 = Array() +} + // ---------- 模型映射 ---------- @Test @@ -188,11 +205,55 @@ class ModelValidationTests { @Expect(nick.isRequired, false) } + @TestCase + public func testEnumFieldMaps(): Unit { + // 简单枚举(含 Option 内层枚举)受支持:标记 isEnum,记录完整类型名 + let model = ModelCache.get() + @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(); false } catch (_: Exception) { true } + // Array 不在白名单 → 抛异常 + let threw = try { ModelCache.get(); 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() + models.add(ModelCache.get()) + 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() + ms.add(ModelCache.get()) + 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() + oldModels.add(ModelCache.get()) + let curModels = ArrayList() + curModels.add(ModelCache.get()) + 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 方言 ----------