/* * Copyright (c) 2025 SimcuTeam. All rights reserved. * 迁移 CLI(应用内驱动,对齐 dotnet ef 的命令体验)。 * * 基于 DbContext 实例的一行接入(无需宏、无需回调): * main(args: Array) { * let db = DataContext("pgsql", "Host=..;Database=..;Username=..;Password=..") * if (db.cli(args)) { return } * // 正常启动 * } * * 数据来源全部来自 DbContext 实例: * - 模型:反射收集实例的 DbSet 属性(getModels()) * - 连接/驱动/方言:构造参数 * - 迁移列表:子类 override migrations()(宏默认返回 orm-cj 全局注册表, * CLI 生成的 src/migrations/MigrationRegistry.cj 在模块加载时自动注册) * * 注意:应用侧(DataContext.cj 或 main.cj)需要一行 * import <应用包名>.migrations.* * 仅 import 即可触发 migrations 子包加载,注册文件(子包内)的顶层 let * 才会在 main() 前执行并注册迁移;新项目需先建 src/migrations/ 目录(空包)。 * * 命令: * add <迁移名> 模型 → 迁移 .cj 文件(落盘) + 更新快照 + 更新迁移注册文件 * rm <迁移名/id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退到前一状态) * update 应用所有未执行的迁移 * downgrade [目标] 回退迁移(执行 down;无目标=回退最近一个,有目标=回退到该迁移之后) * list 列出已应用/待应用迁移 * help 显示用法 */ package simcu::orm.cli import std.collection.* import std.database.sql.* import std.fs.* import std.time.* import simcu::orm.migrations.* import simcu::orm.model.* import simcu::orm.sql.* /** * 迁移 CLI 入口。 */ public class MigrationCli { private var _migrationsDir = "src/migrations" private var _snapshotPath = "src/migrations/snapshot.json" // 注册文件与迁移文件同目录(src/migrations/,package = 应用包名.migrations)。 // 子包默认惰性加载,顶层 let 不会执行;应用侧需一行 // `import <应用包名>.migrations.*` 触发加载后,注册才生效。 private var _registryPath = "src/migrations/MigrationRegistry.cj" public init() {} /// 基于 CliContext 实例执行 CLI(DbContext.cli(args) 的内部实现)。 /// 模型/连接/方言/迁移全部来自 ctx。 public static func runOn(ctx: CliContext, args: ArrayList): Int64 { let cli = MigrationCli() cli.dispatch(args, { => ctx.getModels() }, { => ctx.getDatasource() }, { => ctx.getMigrations() }, ctx.getDialect()) } /// 低层入口:无条件执行 CLI(命令:add/rm/update/downgrade/list/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()) } /// 应用入口一行接入:`cjpm run -- orm <命令>` 时执行 CLI 并返回 true; /// 否则(正常启动)返回 false。用法:main() 最上方一行,如 /// if (MigrationCli.tryRun(ArrayList(args), { => models() }, /// { => datasource() }, { => migrations()})) { return } public static func tryRun(args: ArrayList, models: () -> ArrayList, datasource: () -> Datasource, migrations: () -> ArrayList): Bool { if (args.size == 0 || args[0] != "orm") { return false } let cli = MigrationCli() cli.dispatch(args[1..], models, datasource, migrations, OpenGaussDialect()) true } // ---------- 命令分发 ---------- private func dispatch(args: ArrayList, models: () -> ArrayList, datasource: () -> Datasource, migrations: () -> ArrayList, dialect: ISqlDialect): Int64 { if (args.size == 0) { printHelp() return 0 } 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 "help" | "-h" | "--help" => printHelp(); 0 case _ => println("simorm: 未知命令 '${args[0]}',输入 'simorm help' 查看用法") 1 } } /// add <迁移名>:模型 → 迁移文件 + 快照 + 注册 private func cmdAdd(args: ArrayList, models: () -> ArrayList): Int64 { if (args.size < 2) { println("用法: simorm add <迁移名>") return 1 } let name = args[1] if (!isValidIdentifier(name)) { println("simorm: 迁移名 '${name}' 不是合法标识符(字母/数字/下划线,首字符不能是数字)") return 1 } // 迁移名以 test 结尾会生成 *_test.cj 文件,cjc 会把 *_test.cj 当作测试文件, // 在 cjpm build(非 test)时排除,导致注册文件引用不到该类而编译失败。 if (name.endsWith("test")) { println("simorm: 迁移名 '${name}' 以 test 结尾,生成的 *_test.cj 会被编译器当作测试文件排除;请换一个名称") return 1 } let appPkg = detectPackage() let ts = DateTime.now().format("yyyyMMddHHmmss") let id = "${ts}_${name}" let gen = MigrationGenerator() let current = models() var m: Migration if (let Some(snap) <- ModelSnapshot.load(_snapshotPath)) { m = gen.diff(id, name, snap.toModels(), current) } else { m = gen.initial(id, name, current) } // 无变化检测(对齐 dotnet ef:模型未变则不生成) let probe = MigrationBuilder() m.up(probe) if (probe.getOperations().size == 0) { println("simorm: 未检测到模型变化,快照已是最新,跳过生成") return 0 } // 迁移文件与注册文件都落在 src/migrations 子目录(package = .migrations): // 注册文件在子包内,顶层 let 引用同包迁移类,无需 import; // 应用侧一行 `import .migrations.*` 触发子包加载后注册生效。 let migPkg = "${appPkg}.migrations" let fg = MigrationFileGenerator() let path = fg.writeMigration(migPkg, m, _migrationsDir) fg.updateRegistry(migPkg, _registryPath, MigrationFileGenerator.classNameOf(id)) ModelSnapshot.capture(current).save(_snapshotPath) println("simorm: 已生成迁移 ${id}") println(" 迁移文件: ${path}") println(" 注册文件: ${_registryPath}") println(" 快照: ${_snapshotPath}") println("执行 'simorm update' 应用该迁移") 0 } /// update:应用所有未执行的迁移 private func cmdUpdate(datasource: () -> Datasource, migrations: () -> ArrayList, dialect: ISqlDialect): Int64 { let list = migrations() if (list.size == 0) { println("simorm: 未注册任何迁移(检查 ${_registryPath})") return 1 } let migrator = Migrator(datasource(), dialect) let n = migrator.migrate(list) println("simorm: 已应用 ${n} 个迁移") 0 } /// downgrade [目标]:回退迁移(执行 down + 删除历史记录;无目标 = 回退最近一个) private func cmdDowngrade(datasource: () -> Datasource, migrations: () -> ArrayList, dialect: ISqlDialect, args: ArrayList): Int64 { let list = migrations() if (list.size == 0) { println("simorm: 未注册任何迁移(检查 ${_registryPath})") return 1 } let target: ?String = if (args.size >= 2) { Some(args[1]) } else { None } try { let migrator = Migrator(datasource(), dialect) let n = migrator.revert(list, target) if (n == 0) { println("simorm: 没有可回退的迁移(已处于${if (target.isSome()) { "目标" } else { "最初" }}状态)") } else { println("simorm: 已回退 ${n} 个迁移") } 0 } catch (e: Exception) { println("simorm: 回退迁移失败") println(" ${e.message}") 1 } } /// rm <迁移名或id>:移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退) private func cmdRm(args: ArrayList): Int64 { if (args.size < 2) { println("用法: simorm rm <迁移名或id>") return 1 } let target = args[1] let dir = Path(_migrationsDir) if (!exists(dir)) { println("simorm: 迁移目录不存在(${_migrationsDir}),没有可移除的迁移") return 1 } // 扫描迁移目录,收集迁移 id(文件名去 .cj;排除注册文件/占位文件) let ids = ArrayList() for (f in Directory.readFrom(dir)) { if (f.isRegular()) { let name = f.name if (name.endsWith(".cj") && name != "MigrationRegistry.cj" && name != "_placeholder.cj") { ids.add(name[..name.size - 3]) } } } if (ids.size == 0) { println("simorm: ${_migrationsDir} 下没有迁移文件") return 1 } // 匹配:精确(migrationId 或 类名)+ 唯一前缀(migrationId) var hit: ?String = None let prefixHits = ArrayList() for (id in ids) { if (id == target || MigrationFileGenerator.classNameOf(id) == target) { hit = Some(id) } else if (id.startsWith(target)) { prefixHits.add(id) } } if (hit.isNone() && prefixHits.size == 1) { hit = Some(prefixHits[0]) } if (let Some(id) <- hit) { // 1) 删除迁移文件 let fp = dir.join(MigrationFileGenerator.fileNameOf(id)) if (exists(fp)) { remove(fp) } // 2) 从注册文件移除条目(重写注册文件) let appPkg = detectPackage() let migPkg = "${appPkg}.migrations" let remaining = ArrayList() if (exists(Path(_registryPath))) { let text = String.fromUtf8(File.readFrom(Path(_registryPath))) for (e in MigrationFileGenerator.extractRegistryEntries(text)) { if (e != MigrationFileGenerator.classNameOf(id)) { remaining.add(e) } } let code = MigrationFileGenerator.registrySource(migPkg, remaining) remove(Path(_registryPath)) let f = File.create(Path(_registryPath)) f.write(code.toArray()) f.close() } // 3) 快照:注册表为空则重置快照(下次 add 从模型重新生成 initial) if (remaining.size == 0) { let sp = Path(_snapshotPath) if (exists(sp)) { remove(sp) } println("simorm: 已移除迁移 ${id}(注册表已空,快照已重置)") } else { println("simorm: 已移除迁移 ${id}") } return 0 } println("simorm: 未找到迁移 '${target}'(${_migrationsDir} 下无匹配)") 1 } /// list:显示已应用/待应用迁移 private func cmdList(datasource: () -> Datasource, migrations: () -> ArrayList, dialect: ISqlDialect): Int64 { let list = migrations() if (list.size == 0) { println("simorm: 未注册任何迁移(检查 ${_registryPath})") return 1 } try { let migrator = Migrator(datasource(), dialect) let applied = migrator.appliedMigrationIds() println("simorm: 迁移列表(共 ${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(" ${e.message}") 1 } } // ---------- 私有工具 ---------- /// 从应用根目录 cjpm.toml 推断包名(organization::name 或 name) private func detectPackage(): String { let p = Path("cjpm.toml") if (!exists(p)) { throw Exception("simorm: 未找到 cjpm.toml,请在应用根目录运行 add 命令") } let text = String.fromUtf8(File.readFrom(p)) var name = "" var org = "" for (raw in text.split("\n")) { let line = trimSpaces(raw) if (name.isEmpty()) { name = tomlValue(line, "name") } if (org.isEmpty()) { org = tomlValue(line, "organization") } } if (name.isEmpty()) { throw Exception("simorm: cjpm.toml 缺少 [package] name 字段") } if (org.isEmpty()) { return name } "${org}::${name}" } /// 解析 TOML 简单值(name = "xxx"),行需已 trim;返回引号内字符串,失败返回 "" private static func tomlValue(line: String, key: String): String { if (!line.startsWith("${key} =")) { return "" } let eq = line.indexOf("=").getOrThrow() var rest = trimSpaces(line[eq + 1..]) if (rest.isEmpty() || rest[0] != UInt8(0x22)) { return "" } var i: Int64 = 1 let bytes = rest.toArray() while (i < bytes.size) { if (bytes[i] == 0x22) { return rest[1..i] } i += 1 } "" } /// 去除字符串首尾空白(空格/Tab/CR/LF) private static func trimSpaces(s: String): String { let bytes = s.toArray() var start: Int64 = 0 var end = bytes.size while (start < end && isSpace(bytes[start])) { start += 1 } while (end > start && isSpace(bytes[end - 1])) { end -= 1 } s[start..end] } private static func isSpace(b: UInt8): Bool { b == 0x20 || b == 0x09 || b == 0x0D || b == 0x0A } /// 合法标识符:字母/数字/下划线,首字符不能是数字 private static func isValidIdentifier(s: String): Bool { if (s.isEmpty()) { return false } let bytes = s.toArray() var i: Int64 = 0 while (i < bytes.size) { let b = bytes[i] let isDigit = b >= 0x30 && b <= 0x39 let isLetter = (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) let isUnderscore = b == 0x5F if (i == 0 && isDigit) { return false } if (!isDigit && !isLetter && !isUnderscore) { return false } i += 1 } true } private static func printHelp(): Unit { println("simorm - orm-cj 迁移 CLI(对齐 dotnet ef)") println("") println("用法:") println(" add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)") println(" rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)") println(" update 应用所有未执行的迁移") println(" downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后") println(" list 列出已应用/待应用迁移") println(" help 显示本帮助") } }