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,28 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* CLI 上下文契约:MigrationCli 只依赖本接口,不依赖 simcu::orm.db,
|
||||
* 从而避免 db → cli 与 cli → db 的包循环依赖。
|
||||
*
|
||||
* DbContext 实现本接口(DbContext.cli(args) 内部把 this 传给 MigrationCli.runOn)。
|
||||
*/
|
||||
|
||||
package simcu::orm.cli
|
||||
|
||||
import std.collection.*
|
||||
import std.database.sql.*
|
||||
import simcu::orm.migrations.*
|
||||
import simcu::orm.model.*
|
||||
import simcu::orm.sql.*
|
||||
|
||||
public interface CliContext {
|
||||
/// 模型列表(由实例的 DbSet<T> 属性反射收集)
|
||||
func getModels(): ArrayList<EntityModel>
|
||||
/// 应用主数据源
|
||||
func getDatasource(): Datasource
|
||||
/// SQL 方言
|
||||
func getDialect(): ISqlDialect
|
||||
/// 迁移列表(子类 override migrations() 提供)
|
||||
func getMigrations(): ArrayList<Migration>
|
||||
/// 应用所有迁移
|
||||
func migrateAll(migrations: ArrayList<Migration>): Int64
|
||||
}
|
||||
@@ -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 显示本帮助")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* 迁移落盘生成器(对齐 dotnet ef migrations add):
|
||||
* - Migration 对象 → .cj 源文件文本(迁移类 up/down 用 MigrationBuilder 声明式重建),
|
||||
* - 自动维护迁移注册文件 MigrationRegistry.cj(add 时插入新迁移实例)。
|
||||
*
|
||||
* 生成文件的格式:
|
||||
* // 此文件由 simorm CLI 自动生成,请勿手动编辑
|
||||
* package app
|
||||
*
|
||||
* import simcu::orm.migrations.*
|
||||
* import simcu::orm.sql.*
|
||||
*
|
||||
* public class InitialCreate <: Migration {
|
||||
* public init() { super("20250701000000_InitialCreate", "初始建表") }
|
||||
* public override func up(builder: MigrationBuilder): Unit {
|
||||
* builder.createTable("users") { tb =>
|
||||
* tb.column("id", ColumnTypes.BigIntCol).primary().autoInc().notNull()
|
||||
* }
|
||||
* }
|
||||
* public override func down(builder: MigrationBuilder): Unit {
|
||||
* builder.dropTable("users")
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
package simcu::orm.cli
|
||||
|
||||
import std.collection.*
|
||||
import std.fs.*
|
||||
import simcu::orm.migrations.*
|
||||
import simcu::orm.sql.*
|
||||
|
||||
/**
|
||||
* 迁移源码生成器:操作列表 → Cangjie 源码文本 + 注册文件维护。
|
||||
*/
|
||||
public class MigrationFileGenerator {
|
||||
public init() {}
|
||||
|
||||
/// 迁移 → .cj 源文件写入 outDir,返回写入的完整文件路径
|
||||
public func writeMigration(appPackage: String, m: Migration, outDir: String): String {
|
||||
let className = classNameOf(m.migrationId)
|
||||
let code = migrationSource(appPackage, className, m)
|
||||
let dir = Path(outDir)
|
||||
if (!exists(dir)) {
|
||||
Directory.create(dir, recursive: true)
|
||||
}
|
||||
let path = dir.join(fileNameOf(m.migrationId))
|
||||
if (exists(path)) {
|
||||
remove(path)
|
||||
}
|
||||
let f = File.create(path)
|
||||
f.write(code.toArray())
|
||||
f.close()
|
||||
path.toString()
|
||||
}
|
||||
|
||||
/// 迁移 → .cj 源码文本(不落盘,供测试/预览)
|
||||
public static func migrationSource(appPackage: String, className: String, m: Migration): String {
|
||||
if (appPackage.isEmpty()) {
|
||||
throw Exception("simorm: 应用包名不能为空,请在应用根目录(含 cjpm.toml)运行")
|
||||
}
|
||||
let sb = StringBuilder()
|
||||
sb.append("// 此文件由 simorm CLI 自动生成,请勿手动编辑\n")
|
||||
sb.append("package ${appPackage}\n\n")
|
||||
sb.append("import std.collection.*\n")
|
||||
sb.append("import simcu::orm.migrations.*\n")
|
||||
sb.append("import simcu::orm.sql.*\n\n")
|
||||
sb.append("public class ${className} <: Migration {\n")
|
||||
sb.append(" public init() {\n")
|
||||
sb.append(" super(\"${escape(m.migrationId)}\", \"${escape(m.description)}\")\n")
|
||||
sb.append(" }\n\n")
|
||||
sb.append(" public override func up(builder: MigrationBuilder): Unit {\n")
|
||||
appendOps(sb, m, true)
|
||||
sb.append(" }\n\n")
|
||||
sb.append(" public override func down(builder: MigrationBuilder): Unit {\n")
|
||||
appendOps(sb, m, false)
|
||||
sb.append(" }\n")
|
||||
sb.append("}\n")
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
/// migrationId "20250701000000_InitialCreate" → 类名 "InitialCreate"
|
||||
public static func classNameOf(migrationId: String): String {
|
||||
if (let Some(idx) <- migrationId.lastIndexOf("_")) {
|
||||
return migrationId[idx + 1..]
|
||||
}
|
||||
migrationId
|
||||
}
|
||||
|
||||
/// migrationId → 文件名 "20250701000000_InitialCreate.cj"
|
||||
public static func fileNameOf(migrationId: String): String {
|
||||
"${migrationId}.cj"
|
||||
}
|
||||
|
||||
/// 重写注册文件:旧文件(若存在)提取已有条目 + 追加新类名。
|
||||
/// 新格式:每条顶层 let 在模块初始化时自动注册到 orm-cj 全局注册表。
|
||||
/// 注意:migrationPackage 必须与迁移文件同包(应用包名.migrations):
|
||||
/// 注册文件与迁移类同包,顶层 let 直接引用类名,无需额外 import;
|
||||
/// 子包默认惰性加载,应用侧需一行 `import <应用包名>.migrations.*`
|
||||
/// 触发加载后,顶层 let 才会执行。
|
||||
/// 返回注册文件源码文本
|
||||
public static func registrySource(migrationPackage: String, entries: ArrayList<String>): String {
|
||||
let sb = StringBuilder()
|
||||
sb.append("// 此文件由 simorm CLI 自动维护,请勿手动编辑\n")
|
||||
sb.append("// 与迁移类同包(${migrationPackage}),模块加载时顶层 let 自动注册\n")
|
||||
sb.append("// 迁移到 simcu::orm 全局注册表(按 migrationId 字典序应用)。\n")
|
||||
sb.append("package ${migrationPackage}\n\n")
|
||||
sb.append("import std.collection.*\n")
|
||||
sb.append("import simcu::orm.migrations.*\n\n")
|
||||
sb.append("/// 模块初始化时自动注册迁移到 simcu::orm 全局注册表(按 migrationId 字典序应用)\n")
|
||||
var i: Int64 = 0
|
||||
for (e in entries) {
|
||||
sb.append("let _migrationRegistration${i} = registerMigration(${e}())\n")
|
||||
i += 1
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
/// 从旧注册文件文本提取已注册的迁移类名(按出现顺序去重)。
|
||||
/// 解析 `let _r0 = registerMigration(Xxx())` 中的类名。
|
||||
public static func extractRegistryEntries(text: String): ArrayList<String> {
|
||||
let result = ArrayList<String>()
|
||||
let bytes = text.toArray()
|
||||
let marker = "registerMigration(".toArray()
|
||||
var pos: Int64 = 0
|
||||
while (true) {
|
||||
let hit = indexOfBytes(bytes, marker, pos)
|
||||
if (hit < 0) {
|
||||
break
|
||||
}
|
||||
let nameStart = hit + marker.size
|
||||
var end = nameStart
|
||||
while (end < bytes.size && isIdentChar(bytes[end])) {
|
||||
end += 1
|
||||
}
|
||||
let name = text[nameStart..end]
|
||||
if (!name.isEmpty() && !result.contains(name)) {
|
||||
result.add(name)
|
||||
}
|
||||
pos = end
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 标识符字符:字母/数字/下划线
|
||||
private static func isIdentChar(b: UInt8): Bool {
|
||||
(b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) || b == 0x5F
|
||||
}
|
||||
|
||||
/// 追加注册:旧文件 + 新类名 → 完整注册文件源码(已写新文件并返回路径)
|
||||
public func updateRegistry(appPackage: String, registryPath: String, newClassName: String): String {
|
||||
var entries = ArrayList<String>()
|
||||
let p = Path(registryPath)
|
||||
if (exists(p)) {
|
||||
let text = String.fromUtf8(File.readFrom(p))
|
||||
for (e in extractRegistryEntries(text)) {
|
||||
entries.add(e)
|
||||
}
|
||||
}
|
||||
if (!entries.contains(newClassName)) {
|
||||
entries.add(newClassName)
|
||||
}
|
||||
let code = registrySource(appPackage, entries)
|
||||
let dir = parentDirOf(registryPath)
|
||||
let dirPath = Path(dir)
|
||||
if (!exists(dirPath)) {
|
||||
Directory.create(dirPath, recursive: true)
|
||||
}
|
||||
if (exists(p)) {
|
||||
remove(p)
|
||||
}
|
||||
let f = File.create(p)
|
||||
f.write(code.toArray())
|
||||
f.close()
|
||||
registryPath
|
||||
}
|
||||
|
||||
// ---------- 私有 ----------
|
||||
|
||||
private static func appendOps(sb: StringBuilder, m: Migration, isUp: Bool): Unit {
|
||||
let b = MigrationBuilder()
|
||||
if (isUp) {
|
||||
m.up(b)
|
||||
} else {
|
||||
m.down(b)
|
||||
}
|
||||
for (op in b.getOperations()) {
|
||||
appendOp(sb, op, " ")
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendOp(sb: StringBuilder, op: MigrationOperation, indent: String): Unit {
|
||||
match (op.kind) {
|
||||
case MigrationOperationKind.CreateTable =>
|
||||
sb.append("${indent}builder.createTable(\"${escape(op.tableName)}\") { tb =>\n")
|
||||
for (c in op.columnDefs) {
|
||||
sb.append("${indent} tb.column(\"${escape(c.name)}\", ColumnTypes.${columnTypeName(c.columnType)})")
|
||||
sb.append("${columnChainSuffix(c)}\n")
|
||||
}
|
||||
sb.append("${indent}}\n")
|
||||
case MigrationOperationKind.DropTable =>
|
||||
sb.append("${indent}builder.dropTable(\"${escape(op.tableName)}\")\n")
|
||||
case MigrationOperationKind.AddColumn =>
|
||||
sb.append("${indent}builder.addColumn(\"${escape(op.tableName)}\", ${columnDefExpr(op.column.getOrThrow())})\n")
|
||||
case MigrationOperationKind.DropColumn =>
|
||||
sb.append("${indent}builder.dropColumn(\"${escape(op.tableName)}\", \"${escape(op.columnName)}\")\n")
|
||||
case MigrationOperationKind.AlterColumn =>
|
||||
sb.append("${indent}builder.alterColumn(\"${escape(op.tableName)}\", ${columnDefExpr(op.column.getOrThrow())})\n")
|
||||
case MigrationOperationKind.RenameColumn =>
|
||||
sb.append("${indent}builder.renameColumn(\"${escape(op.tableName)}\", \"${escape(op.columnName)}\", \"${escape(op.newColumnName)}\")\n")
|
||||
case MigrationOperationKind.CreateIndex =>
|
||||
sb.append("${indent}builder.createIndex(\"${escape(op.indexName)}\", \"${escape(op.tableName)}\", [${colList(op.columnNames)}]")
|
||||
if (op.unique) {
|
||||
sb.append(", true")
|
||||
}
|
||||
sb.append(")\n")
|
||||
case MigrationOperationKind.DropIndex =>
|
||||
sb.append("${indent}builder.dropIndex(\"${escape(op.indexName)}\", \"${escape(op.tableName)}\")\n")
|
||||
case MigrationOperationKind.RawSql =>
|
||||
sb.append("${indent}builder.rawSql(\"${escape(op.sql)}\")\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// ColumnDefinition → 内联表达式: ColumnDefinition("name", ColumnTypes.X).primary().notNull()...
|
||||
private static func columnDefExpr(c: ColumnDefinition): String {
|
||||
var s = "ColumnDefinition(\"${escape(c.name)}\", ColumnTypes.${columnTypeName(c.columnType)})"
|
||||
s = s + columnChainSuffix(c)
|
||||
s
|
||||
}
|
||||
|
||||
/// 链式标记后缀(不生成默认值字面量,遇到则抛异常提示手写)
|
||||
private static func columnChainSuffix(c: ColumnDefinition): String {
|
||||
var s = ""
|
||||
if (c.primaryKey) {
|
||||
s = s + ".primary()"
|
||||
}
|
||||
if (c.autoIncrement) {
|
||||
s = s + ".autoInc()"
|
||||
}
|
||||
if (!c.nullable) {
|
||||
s = s + ".notNull()"
|
||||
}
|
||||
if (c.unique) {
|
||||
s = s + ".withUnique()"
|
||||
}
|
||||
if (c.maxLength != 255) {
|
||||
s = s + ".withMaxLength(${c.maxLength})"
|
||||
}
|
||||
if (let Some(_) <- c.defaultValue) {
|
||||
throw Exception(
|
||||
"simorm: CLI 生成的迁移不支持带默认值的列,请改用手写迁移(override up/down)")
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
private static func columnTypeName(t: ColumnTypes): String {
|
||||
match (t) {
|
||||
case ColumnTypes.BigIntCol => "BigIntCol"
|
||||
case ColumnTypes.IntCol => "IntCol"
|
||||
case ColumnTypes.SmallIntCol => "SmallIntCol"
|
||||
case ColumnTypes.TinyIntCol => "TinyIntCol"
|
||||
case ColumnTypes.TextCol => "TextCol"
|
||||
case ColumnTypes.BoolCol => "BoolCol"
|
||||
case ColumnTypes.FloatCol => "FloatCol"
|
||||
case ColumnTypes.RealCol => "RealCol"
|
||||
case ColumnTypes.DateTimeCol => "DateTimeCol"
|
||||
case ColumnTypes.DecimalCol => "DecimalCol"
|
||||
case ColumnTypes.BinaryCol => "BinaryCol"
|
||||
}
|
||||
}
|
||||
|
||||
private static func colList(cols: ArrayList<String>): String {
|
||||
var sb = StringBuilder()
|
||||
var first = true
|
||||
for (c in cols) {
|
||||
if (!first) {
|
||||
sb.append(", ")
|
||||
}
|
||||
sb.append("\"${escape(c)}\"")
|
||||
first = false
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
/// 生成 Cangjie 字符串字面量转义
|
||||
private static func escape(s: String): String {
|
||||
var sb = StringBuilder()
|
||||
for (c in s.runes()) {
|
||||
if (c == Rune(0x5C)) {
|
||||
sb.append("\\\\")
|
||||
} else if (c == Rune(0x22)) {
|
||||
sb.append("\\\"")
|
||||
} else if (c == Rune(0x0A)) {
|
||||
sb.append("\\n")
|
||||
} else if (c == Rune(0x0D)) {
|
||||
sb.append("\\r")
|
||||
} else if (c == Rune(0x09)) {
|
||||
sb.append("\\t")
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
/// 取路径的父目录(字符串级,兼容 "/" 与 "\");无分隔符返回 "."
|
||||
private static func parentDirOf(path: String): String {
|
||||
let s = match (path.lastIndexOf("/")) {
|
||||
case Some(i) => i
|
||||
case None => -1
|
||||
}
|
||||
let b = match (path.lastIndexOf("\\")) {
|
||||
case Some(i) => i
|
||||
case None => -1
|
||||
}
|
||||
let idx = if (s >= 0 && b >= 0) {
|
||||
if (s > b) { s } else { b }
|
||||
} else if (s >= 0) {
|
||||
s
|
||||
} else {
|
||||
b
|
||||
}
|
||||
if (idx > 0) {
|
||||
return path[..idx]
|
||||
}
|
||||
"."
|
||||
}
|
||||
|
||||
/// 字节数组子串查找(返回起始字节索引,-1 表示未找到)
|
||||
private static func indexOfBytes(hay: Array<UInt8>, needle: Array<UInt8>, from: Int64): Int64 {
|
||||
if (needle.size == 0 || from > hay.size - needle.size) {
|
||||
return -1
|
||||
}
|
||||
var i = from
|
||||
while (i <= hay.size - needle.size) {
|
||||
var j: Int64 = 0
|
||||
var ok = true
|
||||
while (j < needle.size) {
|
||||
if (hay[i + j] != needle[j]) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
j += 1
|
||||
}
|
||||
if (ok) {
|
||||
return i
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
-1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user