diff --git a/README.md b/README.md index af859e8..58c514b 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,9 @@ main() { ```cangjie import simcu::orm.migrations.* -// 定义迁移(migrationId 用 "yyyyMMddHHmmss_名称",按字典序应用) -public class InitialCreate <: Migration { - public init() { super("20250701000000_InitialCreate", "创建用户表") } +// 定义迁移(migrationId 按字典序应用;CLI 生成 "yyyyMMddHHmmss" 时间戳格式,类名 MigrationId<时间戳>) +public class MigrationId20250701000000 <: Migration { + public init() { super("20250701000000", "创建用户表") } public override func up(builder: MigrationBuilder): Unit { builder.createTable("users") { tb => tb.column("id", ColumnTypes.BigIntCol).primary().autoInc().notNull() @@ -137,7 +137,7 @@ main() { let models = ArrayList() models.add(ModelCache.get()) models.add(ModelCache.get()) - let m0 = gen.initial("20250701000000_InitialCreate", "初始建表", models) + let m0 = gen.initial("20250701000000", "初始建表", models) Migrator(datasource).migrate([m0]) // 2. 模型变更后:旧模型快照(上次的 models) vs 新模型 → 增量迁移(表/列增删改) @@ -209,8 +209,8 @@ main(args: Array) { | 命令 | 作用 | |------|------| -| `cjpm run -- orm add <迁移名>` | 从模型生成迁移 .cj 文件 + 更新注册文件 + 写入快照;模型无变化则跳过 | -| `cjpm run -- orm rm <迁移名或id>` | 移除已生成的迁移(删除迁移文件 + 注册条目;注册表清空时重置快照) | +| `cjpm run -- orm add [描述]` | 从模型生成迁移 .cj 文件 + 更新注册文件 + 写入快照(描述可空,仅写入注释与历史记录);模型无变化则跳过 | +| `cjpm run -- orm rm <迁移id或类名>` | 移除已生成的迁移(删除迁移文件 + 注册条目;注册表清空时重置快照) | | `cjpm run -- orm update` | 连接数据库,按 `migrationId` 字典序应用所有未执行的迁移 | | `cjpm run -- orm downgrade [目标]` | 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后(含该迁移的 down) | | `cjpm run -- orm list` | 列出已注册迁移(需真实连接读取历史表) | @@ -225,6 +225,7 @@ main(args: Array) { - **首次使用(无迁移)**:不需要建 `src/migrations/` 目录,也不要写 `import <应用包名>.migrations.*`(该子包不存在会导致编译失败);首次 `add` 时 CLI 自动创建目录并生成迁移文件 + 注册文件; - **生成后手动加 import**:执行 `add` 生成首个迁移后,在 `main()` 所在文件手动加一行 `import <应用包名>.migrations.*`(如 `import myapp.migrations.*`)——注册文件在子包内惰性加载,只有 import 触发后 `update/downgrade/list` 才能看到迁移;之后新增迁移无需再动这行; - 迁移 / 注册文件生成在 `src/migrations/`,该目录是**子包**,文件头 `package <应用包名>.migrations`(如应用包 `myapp` → `package myapp.migrations`); +- 迁移文件命名为 **id 的蛇形形式**(如 `20250701000000.cj`),仅含 migrationId,不包含描述;CLI 生成的 migrationId 为 `yyyyMMddHHmmss` 时间戳,**迁移类名自动为 `MigrationId<时间戳>`**(如 `MigrationId20250701000000`);迁移描述可空,写入文件头部 `// 描述: ...` 注释与迁移类 `super("...", "...")` 中; - `add` 只生成不执行;执行迁移用 `update`(回退用 `downgrade`),或在应用启动时直接 `db.migrate()`; - 快照持久化在 `src/migrations/snapshot.json`;`add` 用「旧快照 vs 当前模型」做 diff,所以**改模型后重新编译应用再 `add`** 即生成增量迁移; - 生成源码文本已包含 `import std.collection.*` 等标准库引用,应用无需额外配置。 @@ -408,7 +409,7 @@ qb.page(2, 10) // PagedResult(items/total/ ### 5. 迁移 -- `Migration` 基类:`super("20250701000000_InitialCreate", "描述")`,实现 `up` / `down`; +- `Migration` 基类:`super("20250701000000", "描述")`,实现 `up` / `down`; - `MigrationBuilder`:`createTable` / `dropTable` / `addColumn` / `dropColumn` / `alterColumn` / `renameColumn` / `createIndex` / `dropIndex` / `rawSql`; - `ColumnDefinition` 链式:`.primary()` `.autoInc()` `.notNull()` `.withUnique()` `.withMaxLength(n)` `.withDefault(value)`; - `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` 决定; diff --git a/cjpm.toml b/cjpm.toml index 7b82263..3cd29c7 100644 --- a/cjpm.toml +++ b/cjpm.toml @@ -1,22 +1,22 @@ [package] - cjc-version = "1.1.3" - name = "orm" - organization = "simcu" - description = "SimApi 数据访问层 ORM:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言" - version = "2.0.0" - target-dir = "" - output-type = "static" +cjc-version = "1.1.3" +name = "orm" +organization = "simcu" +description = "SimApi 数据访问层 ORM:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言" +version = "1.4.0" +target-dir = "" +output-type = "static" [target] - [target.x86_64-w64-mingw32] - compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" - [target.x86_64-w64-mingw32.bin-dependencies] - path-option = [ "${CANGJIE_STDX_PATH}" ] - [target.x86_64-unknown-linux-gnu] - compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" - [target.x86_64-unknown-linux-gnu.bin-dependencies] - path-option = [ "${CANGJIE_STDX_PATH}" ] - [target.aarch64-unknown-linux-gnu] - compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" - [target.aarch64-unknown-linux-gnu.bin-dependencies] - path-option = [ "${CANGJIE_STDX_PATH}" ] +[target.x86_64-w64-mingw32] +compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" +[target.x86_64-w64-mingw32.bin-dependencies] +path-option = ["${CANGJIE_STDX_PATH}"] +[target.x86_64-unknown-linux-gnu] +compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" +[target.x86_64-unknown-linux-gnu.bin-dependencies] +path-option = ["${CANGJIE_STDX_PATH}"] +[target.aarch64-unknown-linux-gnu] +compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated" +[target.aarch64-unknown-linux-gnu.bin-dependencies] +path-option = ["${CANGJIE_STDX_PATH}"] diff --git a/src/cli/migration_cli.cj b/src/cli/migration_cli.cj index 729792f..2b48a25 100644 --- a/src/cli/migration_cli.cj +++ b/src/cli/migration_cli.cj @@ -21,8 +21,8 @@ * 才会在 main() 前执行并注册迁移;新项目需先建 src/migrations/ 目录(空包)。 * * 命令: - * add <迁移名> 模型 → 迁移 .cj 文件(落盘) + 更新快照 + 更新迁移注册文件 - * rm <迁移名/id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退到前一状态) + * add [描述] 模型 → 迁移 .cj 文件(落盘) + 更新快照 + 更新迁移注册文件(描述可空,仅写入注释与历史记录) + * rm <迁移id/类名> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退到前一状态) * update 应用所有未执行的迁移 * downgrade [目标] 回退迁移(执行 down;无目标=回退最近一个,有目标=回退到该迁移之后) * list 列出已应用/待应用迁移 @@ -164,33 +164,29 @@ public class MigrationCli { } } - /// add <迁移名>:模型 → 迁移文件 + 快照 + 注册 + /// add [描述]:模型 → 迁移文件 + 快照 + 注册。 + /// 描述可空(多词自动拼接),仅写入迁移文件头注释与迁移历史记录,不参与命名; + /// migrationId = 时间戳(如 20250701000000),类名自动生成 MigrationId<时间戳>。 private func cmdAdd(args: ArrayList, models: () -> ArrayList): Int64 { - if (args.size < 2) { - println("用法: simcu::orm add <迁移名>") - return 1 - } - let name = args[1] - if (!isValidIdentifier(name)) { - println("simcu::orm: 迁移名 '${name}' 不是合法标识符(字母/数字/下划线,首字符不能是数字)") - return 1 - } - // 迁移名以 test 结尾会生成 *_test.cj 文件,cjc 会把 *_test.cj 当作测试文件, - // 在 cjpm build(非 test)时排除,导致注册文件引用不到该类而编译失败。 - if (name.endsWith("test")) { - println("simcu::orm: 迁移名 '${name}' 以 test 结尾,生成的 *_test.cj 会被编译器当作测试文件排除;请换一个名称") - return 1 + var description = "" + if (args.size >= 2) { + for (i in 1..args.size) { + if (i > 1) { + description = description + " " + } + description = description + args[i] + } } let appPkg = detectPackage() let ts = DateTime.now().format("yyyyMMddHHmmss") - let id = "${ts}_${name}" + let id = ts let gen = MigrationGenerator() let current = models() var m: Migration if (let Some(snap) <- ModelSnapshot.load(_snapshotPath)) { - m = gen.diff(id, name, snap.toModels(), current) + m = gen.diff(id, description, snap.toModels(), current) } else { - m = gen.initial(id, name, current) + m = gen.initial(id, description, current) } // 无变化检测(对齐 dotnet ef:模型未变则不生成) let probe = MigrationBuilder() @@ -205,9 +201,11 @@ public class MigrationCli { let migPkg = "${appPkg}.migrations" let fg = MigrationFileGenerator() let path = fg.writeMigration(migPkg, m, _migrationsDir) - fg.updateRegistry(migPkg, _registryPath, MigrationFileGenerator.classNameOf(id)) + let className = MigrationFileGenerator.classNameOf(id) + fg.updateRegistry(migPkg, _registryPath, className) ModelSnapshot.capture(current).save(_snapshotPath) println("simcu::orm: 已生成迁移 ${id}") + println(" 迁移类: ${className}") println(" 迁移文件: ${path}") println(" 注册文件: ${_registryPath}") println(" 快照: ${_snapshotPath}") @@ -257,7 +255,7 @@ public class MigrationCli { /// rm <迁移名或id>:移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退) private func cmdRm(args: ArrayList): Int64 { if (args.size < 2) { - println("用法: simcu::orm rm <迁移名或id>") + println("用法: simcu::orm rm <迁移id或类名>") return 1 } let target = args[1] @@ -266,7 +264,7 @@ public class MigrationCli { println("simcu::orm: 迁移目录不存在(${_migrationsDir}),没有可移除的迁移") return 1 } - // 扫描迁移目录,收集迁移 id(文件名去 .cj;排除注册文件/占位文件) + // 扫描迁移目录,收集迁移 id(文件名去 .cj = migrationId;排除注册文件/占位文件) let ids = ArrayList() for (f in Directory.readFrom(dir)) { if (f.isRegular()) { @@ -524,29 +522,6 @@ public class MigrationCli { 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("simcu::orm cli - 数据库迁移管理程序") println("") @@ -559,8 +534,8 @@ public class MigrationCli { return } println("用法:") - println(" add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)") - println(" rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)") + println(" add [描述] 从模型生成迁移 .cj 文件,更新快照与注册(描述可空;无变化则跳过)") + println(" rm <迁移id或类名> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)") println(" update 应用所有未执行的迁移") println(" downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后") println(" list 列出已应用/待应用迁移") diff --git a/src/cli/migration_file_generator.cj b/src/cli/migration_file_generator.cj index 9039181..e49d767 100644 --- a/src/cli/migration_file_generator.cj +++ b/src/cli/migration_file_generator.cj @@ -11,8 +11,8 @@ * import simcu::orm.migrations.* * import simcu::orm.sql.* * - * public class InitialCreate <: Migration { - * public init() { super("20250701000000_InitialCreate", "初始建表") } + * public class MigrationId20250701000000 <: Migration { + * public init() { super("20250701000000", "初始建表") } * public override func up(builder: MigrationBuilder): Unit { * builder.createTable("users") { tb => * tb.column("id", ColumnTypes.BigIntCol).primary().autoInc().notNull() @@ -62,6 +62,9 @@ public class MigrationFileGenerator { } let sb = StringBuilder() sb.append("// 此文件由 simcu::orm CLI 自动生成,请勿手动编辑\n") + if (!m.description.isEmpty()) { + sb.append("// 描述: ${commentSafe(m.description)}\n") + } sb.append("package ${appPackage}\n\n") sb.append("import std.collection.*\n") sb.append("import simcu::orm.migrations.*\n") @@ -80,15 +83,12 @@ public class MigrationFileGenerator { sb.toString() } - /// migrationId "20250701000000_InitialCreate" → 类名 "InitialCreate" + /// migrationId(纯时间戳) → 类名: "20250701000000" → "MigrationId20250701000000" public static func classNameOf(migrationId: String): String { - if (let Some(idx) <- migrationId.lastIndexOf("_")) { - return migrationId[idx + 1..] - } - migrationId + "MigrationId${migrationId}" } - /// migrationId → 文件名 "20250701000000_InitialCreate.cj" + /// migrationId → 文件名: "20250701000000" → "20250701000000.cj" public static func fileNameOf(migrationId: String): String { "${migrationId}.cj" } @@ -148,6 +148,19 @@ public class MigrationFileGenerator { (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) || b == 0x5F } + /// 注释安全化:换行/回车替换为空格,避免破坏行注释 + private static func commentSafe(s: String): String { + var out = StringBuilder() + for (c in s.runes()) { + if (c == Rune(0x0A) || c == Rune(0x0D)) { + out.append(" ") + } else { + out.append(c) + } + } + out.toString() + } + /// 追加注册:旧文件 + 新类名 → 完整注册文件源码(已写新文件并返回路径) public func updateRegistry(appPackage: String, registryPath: String, newClassName: String): String { var entries = ArrayList() diff --git a/src/migrations/migrations.cj b/src/migrations/migrations.cj index 53a9c1b..e089a4f 100644 --- a/src/migrations/migrations.cj +++ b/src/migrations/migrations.cj @@ -237,7 +237,7 @@ public class MigrationBuilder { } /** - * 迁移基类:migrationId 形如 "20250701000000_InitialCreate"(时间戳_名称),按字典序应用。 + * 迁移基类:migrationId 按字典序应用(CLI 生成 "yyyyMMddHHmmss" 时间戳)。 * 两种使用方式: * 1. 子类 override up/down 手写声明式操作(MigrationBuilder); * 2. 由 MigrationGenerator 从模型生成时注入 preset 操作,基类 up/down 自动重放。 diff --git a/src/migrations/migrator.cj b/src/migrations/migrator.cj index f29e31d..e5a7f02 100644 --- a/src/migrations/migrator.cj +++ b/src/migrations/migrator.cj @@ -3,7 +3,7 @@ * 迁移执行器:历史表 + 按 migrationId 排序应用未执行的迁移(事务内)。 * * 历史表 simcu_orm_migrations (id VARCHAR(64) PK, name VARCHAR(256), applied_at TIMESTAMP), - * 应用顺序 = migrationId 字典序(建议用 "yyyyMMddHHmmss_名称" 命名)。 + * 应用顺序 = migrationId 字典序(CLI 生成的 migrationId 为 "yyyyMMddHHmmss" 时间戳)。 */ package simcu::orm.migrations diff --git a/src/tests/migration_cli_test.cj b/src/tests/migration_cli_test.cj index 7529668..76ca894 100644 --- a/src/tests/migration_cli_test.cj +++ b/src/tests/migration_cli_test.cj @@ -17,7 +17,7 @@ import simcu::orm.cli.* /// 手写迁移(验证源码生成覆盖索引与 rawSql 转义) class HandWrittenMigration <: Migration { public init() { - super("20250801000000_HandWritten", "手写迁移") + super("20250801000000", "手写迁移") } public override func up(builder: MigrationBuilder): Unit { builder.createTable("tags") { tb => @@ -37,7 +37,7 @@ class HandWrittenMigration <: Migration { /// 手写迁移(验证部分索引 filter 还原:createIndex 5 参带 filter) class FilteredIndexMigration <: Migration { public init() { - super("20250802000000_FilteredIndex", "部分唯一索引") + super("20250802000000", "部分唯一索引") } public override func up(builder: MigrationBuilder): Unit { let cols = ArrayList() @@ -91,9 +91,9 @@ class MigrationFileGeneratorTests { @TestCase public func testClassNameAndFileName(): Unit { - @Expect(MigrationFileGenerator.classNameOf("20250701000000_InitialCreate") == "InitialCreate") - @Expect(MigrationFileGenerator.fileNameOf("20250701000000_InitialCreate") == "20250701000000_InitialCreate.cj") - @Expect(MigrationFileGenerator.classNameOf("NoTimestamp") == "NoTimestamp") + // 时间戳 id → 类名 MigrationId<时间戳>,文件名 = id + @Expect(MigrationFileGenerator.classNameOf("20250701000000") == "MigrationId20250701000000") + @Expect(MigrationFileGenerator.fileNameOf("20250701000000") == "20250701000000.cj") } @TestCase @@ -101,11 +101,12 @@ class MigrationFileGeneratorTests { let models = ArrayList() models.add(buildUserModel()) let gen = MigrationGenerator() - let m = gen.initial("20250701000000_InitialCreate", "初始建表", models) + let m = gen.initial("20250701000000", "初始建表", models) let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m) + @Expect(src.contains("// 描述: 初始建表")) @Expect(src.contains("package app")) - @Expect(src.contains("public class InitialCreate <: Migration")) - @Expect(src.contains("super(\"20250701000000_InitialCreate\", \"初始建表\")")) + @Expect(src.contains("public class MigrationId20250701000000 <: Migration")) + @Expect(src.contains("super(\"20250701000000\", \"初始建表\")")) @Expect(src.contains("builder.createTable(\"users\") { tb =>")) @Expect(src.contains("tb.column(\"id\", ColumnTypes.BigIntCol).primary().autoInc().notNull()")) @Expect(src.contains("tb.column(\"name\", ColumnTypes.TextCol).notNull().withMaxLength(100)")) @@ -120,7 +121,7 @@ class MigrationFileGeneratorTests { newModels.add(buildUserModel()) newModels.add(buildOrderModel()) let gen = MigrationGenerator() - let m = gen.diff("20250801000000_AddOrders", "新增 orders 表", oldModels, newModels) + let m = gen.diff("20250801000000", "新增 orders 表", oldModels, newModels) let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m) @Expect(src.contains("builder.createTable(\"orders\") { tb =>")) @Expect(src.contains("tb.column(\"oid\", ColumnTypes.TextCol).primary().notNull()")) @@ -179,22 +180,24 @@ class MigrationFileGeneratorTests { let models = ArrayList() models.add(buildUserModel()) let gen = MigrationGenerator() - let m = gen.initial("20250701000000_InitialCreate", "初始建表", models) + let m = gen.initial("20250701000000", "初始建表", models) let fg = MigrationFileGenerator() let path = fg.writeMigration("testpkg", m, dir) @Expect(exists(Path(path))) + @Expect(path.endsWith("20250701000000.cj")) let text = String.fromUtf8(File.readFrom(Path(path))) @Expect(text.contains("package testpkg")) - @Expect(text.contains("public class InitialCreate <: Migration")) + @Expect(text.contains("public class MigrationId20250701000000 <: Migration")) // 注册文件:首次创建 + 追加 + 幂等 let registryPath = "${dir}/migration_registry.cj" - fg.updateRegistry("testpkg", registryPath, "InitialCreate") - fg.updateRegistry("testpkg", registryPath, "AddAge") - fg.updateRegistry("testpkg", registryPath, "AddAge") + let cls = MigrationFileGenerator.classNameOf(m.migrationId) + fg.updateRegistry("testpkg", registryPath, cls) + fg.updateRegistry("testpkg", registryPath, cls) let reg = String.fromUtf8(File.readFrom(Path(registryPath))) @Expect(reg.contains("package testpkg")) - @Expect(MigrationFileGenerator.extractRegistryEntries(reg).size == 2) + @Expect(reg.contains("registerMigration(MigrationId20250701000000())")) + @Expect(MigrationFileGenerator.extractRegistryEntries(reg).size == 1) } finally { if (exists(Path(dir))) { remove(Path(dir), recursive: true) @@ -222,12 +225,13 @@ class MigrationCliDispatchTests { } @TestCase - public func testAddMissingNameReturnsOne(): Unit { + public func testAddWithoutDescriptionIsOk(): Unit { + // add 无参数 = 描述可空;模型为空 → 无变化 → 跳过生成,返回 0 let args = ArrayList() args.add("add") let rc = MigrationCli.run(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) - @Expect(rc == 1) + @Expect(rc == 0) } @TestCase @@ -252,7 +256,7 @@ class MigrationCliDispatchTests { public func testDowngradeWithTargetConsumesArgs(): Unit { let args = ArrayList() args.add("downgrade") - args.add("20260819061048_InitialCreate") + args.add("20260819061048") let rc = MigrationCli.run(args, { => ArrayList() }, { => throw Exception("不应调用 datasource") }, { => ArrayList() }) @Expect(rc == 1)