feat: orm-cj 仓颉版 EF Core 风格 ORM 初始版本
- 基于数据模型(POCO + 注解)的映射与增删改查:DbContext/DbSet/ChangeTracker/QueryBuilder - ISqlDialect 方言接口 + PostgreSQL/openGauss 实现,零外部依赖 - @DbContext 宏:纯声明 DbSet 类自动展开为完整 DbContext 子类 - 迁移:Migration/Migrator/ModelSnapshot(快照 JSON)/MiniJson,从模型生成迁移 - 迁移 CLI:add/rm/update/downgrade/list/help,应用内嵌一行 db.cli(args) 接入 - DbContext 便捷方法:databaseExists/hasPendingMigrations/migrate - 57 个单元测试全部通过
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* 迁移 CLI(应用内驱动,对齐 dotnet ef 的命令体验)。
|
||||
*
|
||||
* 基于 DbContext 实例的一行接入(无需宏、无需回调):
|
||||
* main(args: Array<String>) {
|
||||
* let db = DataContext("pgsql", "Host=..;Database=..;Username=..;Password=..")
|
||||
* if (db.cli(args)) { return }
|
||||
* // 正常启动
|
||||
* }
|
||||
*
|
||||
* 数据来源全部来自 DbContext 实例:
|
||||
* - 模型:反射收集实例的 DbSet<T> 属性(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<String>): 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<String>, models: () -> ArrayList<EntityModel>,
|
||||
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>): Int64 {
|
||||
let cli = MigrationCli()
|
||||
cli.dispatch(args, models, datasource, migrations, OpenGaussDialect())
|
||||
}
|
||||
|
||||
/// 应用入口一行接入:`cjpm run -- orm <命令>` 时执行 CLI 并返回 true;
|
||||
/// 否则(正常启动)返回 false。用法:main() 最上方一行,如
|
||||
/// if (MigrationCli.tryRun(ArrayList<String>(args), { => models() },
|
||||
/// { => datasource() }, { => migrations()})) { return }
|
||||
public static func tryRun(args: ArrayList<String>, models: () -> ArrayList<EntityModel>,
|
||||
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>): 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<String>, models: () -> ArrayList<EntityModel>,
|
||||
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
|
||||
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<String>, models: () -> ArrayList<EntityModel>): 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 = <appPkg>.migrations):
|
||||
// 注册文件在子包内,顶层 let 引用同包迁移类,无需 import;
|
||||
// 应用侧一行 `import <appPkg>.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<Migration>, 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<Migration>,
|
||||
dialect: ISqlDialect, args: ArrayList<String>): 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<String>): 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<String>()
|
||||
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<String>()
|
||||
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<String>()
|
||||
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<Migration>,
|
||||
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(" <app> add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)")
|
||||
println(" <app> rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)")
|
||||
println(" <app> update 应用所有未执行的迁移")
|
||||
println(" <app> downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后")
|
||||
println(" <app> list 列出已应用/待应用迁移")
|
||||
println(" <app> help 显示本帮助")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user