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,262 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* 从数据模型生成迁移(对齐 EF Core migrations add):
|
||||
* - initial(...) 全模型 → 初始迁移(CreateTable + down DropTable)
|
||||
* - diff(...) 旧模型快照 vs 新模型 → 增量迁移(表/列增删改 + 自动 down 反转)
|
||||
*
|
||||
* 用法:
|
||||
* let gen = MigrationGenerator()
|
||||
* // 首次:快照文件不存在 → initial 全量迁移
|
||||
* let m0 = gen.ensure("20250701000000_InitialCreate", "初始建表", "snapshot.json", models0)
|
||||
* migrator.migrate([m0])
|
||||
* // 模型变更后:快照存在 → 载入旧模型自动 diff(snapshot.json 自动更新)
|
||||
* let m1 = gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models1)
|
||||
* migrator.migrate([m1])
|
||||
*
|
||||
* 限制(v1):不支持主键/自增属性变更(抛异常提示手写迁移);不生成索引(模型无索引注解)。
|
||||
*/
|
||||
|
||||
package simcu::orm.migrations
|
||||
|
||||
import std.collection.*
|
||||
import simcu::orm.model.*
|
||||
import simcu::orm.sql.ColumnTypes
|
||||
|
||||
/**
|
||||
* 模型 → 迁移操作生成器。
|
||||
*/
|
||||
public class MigrationGenerator {
|
||||
public init() {}
|
||||
|
||||
/// 初始迁移:空库 → 当前模型(全部 CreateTable;down 为逆序 DropTable)
|
||||
public func initial(migrationId: String, description: String, models: ArrayList<EntityModel>): Migration {
|
||||
let m = Migration(migrationId, description)
|
||||
let upOps = ArrayList<MigrationOperation>()
|
||||
let downOps = ArrayList<MigrationOperation>()
|
||||
for (model in models) {
|
||||
upOps.add(createTableOp(model))
|
||||
downOps.add(dropTableOp(model.tableName))
|
||||
}
|
||||
setPresets(m, upOps, downOps)
|
||||
m
|
||||
}
|
||||
|
||||
/// 增量迁移:旧模型(上次快照)→ 新模型,生成表/列增删改;主键/自增变更抛异常
|
||||
public func diff(migrationId: String, description: String,
|
||||
oldModels: ArrayList<EntityModel>, newModels: ArrayList<EntityModel>): Migration { let m = Migration(migrationId, description)
|
||||
let upOps = ArrayList<MigrationOperation>()
|
||||
let downOps = ArrayList<MigrationOperation>()
|
||||
|
||||
let oldByName = indexByTable(oldModels)
|
||||
let newByName = indexByTable(newModels)
|
||||
|
||||
// 1. 新增表 → CreateTable
|
||||
for (n in newModels) {
|
||||
if (!oldByName.contains(n.tableName)) {
|
||||
upOps.add(createTableOp(n))
|
||||
downOps.add(dropTableOp(n.tableName))
|
||||
}
|
||||
}
|
||||
// 2. 已有表 → 列增删改
|
||||
for (n in newModels) {
|
||||
if (let Some(old) <- oldByName.get(n.tableName)) {
|
||||
diffTable(upOps, downOps, old, n)
|
||||
}
|
||||
}
|
||||
// 3. 删除表 → DropTable
|
||||
for (o in oldModels) {
|
||||
if (!newByName.contains(o.tableName)) {
|
||||
upOps.add(dropTableOp(o.tableName))
|
||||
downOps.add(createTableOp(o))
|
||||
}
|
||||
}
|
||||
|
||||
setPresets(m, upOps, downOps)
|
||||
m
|
||||
}
|
||||
|
||||
/// 便捷入口(快照自动管理,diff 无需调用方手存模型列表):
|
||||
/// 快照文件不存在 → initial 全量迁移 + 保存快照;存在 → 载入旧快照 diff + 覆盖保存新快照。
|
||||
/// 返回迁移;若快照存在且模型无变化,返回的迁移 up/down 为空操作列表。
|
||||
public func ensure(migrationId: String, description: String, snapshotPath: String,
|
||||
models: ArrayList<EntityModel>): Migration {
|
||||
if (let Some(snap) <- ModelSnapshot.load(snapshotPath)) {
|
||||
let m = diff(migrationId, description, snap.toModels(), models)
|
||||
ModelSnapshot.capture(models).save(snapshotPath)
|
||||
return m
|
||||
}
|
||||
let m = initial(migrationId, description, models)
|
||||
ModelSnapshot.capture(models).save(snapshotPath)
|
||||
m
|
||||
}
|
||||
|
||||
/// 字段类型简单名 → 列类型(String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array<Byte>)
|
||||
public static func columnTypeFor(typeName: String): ColumnTypes {
|
||||
match (typeName) {
|
||||
case "String" => ColumnTypes.TextCol
|
||||
case "Bool" => ColumnTypes.BoolCol
|
||||
case "Int8" => ColumnTypes.TinyIntCol
|
||||
case "UInt8" => ColumnTypes.TinyIntCol
|
||||
case "Int16" => ColumnTypes.SmallIntCol
|
||||
case "UInt16" => ColumnTypes.SmallIntCol
|
||||
case "Int32" => ColumnTypes.IntCol
|
||||
case "UInt32" => ColumnTypes.IntCol
|
||||
case "Int64" => ColumnTypes.BigIntCol
|
||||
case "UInt64" => ColumnTypes.BigIntCol
|
||||
case "Float32" => ColumnTypes.RealCol
|
||||
case "Float64" => ColumnTypes.FloatCol
|
||||
case "Rune" => ColumnTypes.IntCol
|
||||
case "DateTime" => ColumnTypes.DateTimeCol
|
||||
case "Duration" => ColumnTypes.BigIntCol
|
||||
case "Decimal" => ColumnTypes.DecimalCol
|
||||
case "Array<Byte>" => ColumnTypes.BinaryCol
|
||||
case _ => throw Exception("simorm: 字段类型 ${typeName} 无法映射到列类型")
|
||||
}
|
||||
}
|
||||
|
||||
/// PropertyModel → ColumnDefinition(主键/自增/非空/最大长度 随注解映射)
|
||||
public static func columnDefinition(p: PropertyModel): ColumnDefinition {
|
||||
let c = ColumnDefinition(p.columnName, columnTypeFor(p.typeName()))
|
||||
if (p.isKey) {
|
||||
c.primary()
|
||||
}
|
||||
if (p.autoIncrement) {
|
||||
c.autoInc()
|
||||
}
|
||||
if (p.isRequired || p.isKey) {
|
||||
c.notNull()
|
||||
}
|
||||
if (p.maxLength > 0) {
|
||||
c.withMaxLength(p.maxLength)
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
// ---------- 私有 ----------
|
||||
|
||||
private func diffTable(upOps: ArrayList<MigrationOperation>, downOps: ArrayList<MigrationOperation>,
|
||||
old: EntityModel, cur: EntityModel): Unit {
|
||||
// 表级主键变更(主键列名不同)→ 不支持,抛异常
|
||||
let oldKey = old.keyProperty.getOrThrow().columnName
|
||||
let curKey = cur.keyProperty.getOrThrow().columnName
|
||||
if (oldKey != curKey) {
|
||||
throw Exception(
|
||||
"simorm: 表 ${cur.tableName} 主键从 ${oldKey} 变更为 ${curKey},v1 自动迁移不支持,请手写迁移")
|
||||
}
|
||||
|
||||
var oldCols = HashMap<String, PropertyModel>()
|
||||
for (p in old.properties) {
|
||||
oldCols[p.columnName] = p
|
||||
}
|
||||
var curCols = HashMap<String, PropertyModel>()
|
||||
for (p in cur.properties) {
|
||||
curCols[p.columnName] = p
|
||||
}
|
||||
|
||||
// 新增列
|
||||
for (p in cur.properties) {
|
||||
if (!oldCols.contains(p.columnName)) {
|
||||
upOps.add(addColumnOp(cur.tableName, p))
|
||||
downOps.add(dropColumnOp(cur.tableName, p.columnName))
|
||||
}
|
||||
}
|
||||
// 删除列
|
||||
for (p in old.properties) {
|
||||
if (!curCols.contains(p.columnName)) {
|
||||
upOps.add(dropColumnOp(old.tableName, p.columnName))
|
||||
downOps.add(addColumnOp(old.tableName, p))
|
||||
}
|
||||
}
|
||||
// 列定义变化(类型/长度/非空)
|
||||
for (p in cur.properties) {
|
||||
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 自动迁移不支持,请手写迁移")
|
||||
}
|
||||
if (!sameColumn(op, p)) {
|
||||
upOps.add(alterColumnOp(cur.tableName, p))
|
||||
downOps.add(alterColumnOp(old.tableName, op))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func sameColumn(a: PropertyModel, b: PropertyModel): Bool {
|
||||
typeRank(columnTypeFor(a.typeName())) == typeRank(columnTypeFor(b.typeName())) &&
|
||||
a.maxLength == b.maxLength &&
|
||||
a.isRequired == b.isRequired
|
||||
}
|
||||
|
||||
/// 列类型序号(仅用于同表列定义比较,Cangjie 枚举无 ==)
|
||||
private static func typeRank(t: ColumnTypes): Int64 {
|
||||
match (t) {
|
||||
case ColumnTypes.BigIntCol => 0
|
||||
case ColumnTypes.IntCol => 1
|
||||
case ColumnTypes.SmallIntCol => 2
|
||||
case ColumnTypes.TinyIntCol => 3
|
||||
case ColumnTypes.TextCol => 4
|
||||
case ColumnTypes.BoolCol => 5
|
||||
case ColumnTypes.FloatCol => 6
|
||||
case ColumnTypes.RealCol => 7
|
||||
case ColumnTypes.DateTimeCol => 8
|
||||
case ColumnTypes.DecimalCol => 9
|
||||
case ColumnTypes.BinaryCol => 10
|
||||
}
|
||||
}
|
||||
|
||||
private static func indexByTable(models: ArrayList<EntityModel>): HashMap<String, EntityModel> {
|
||||
let map = HashMap<String, EntityModel>()
|
||||
for (model in models) {
|
||||
map[model.tableName] = model
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// 注入 up/down:down 按 up 逆序反转后存储(down() 按列表顺序执行)
|
||||
private static func setPresets(m: Migration, upOps: ArrayList<MigrationOperation>,
|
||||
downOps: ArrayList<MigrationOperation>): Unit {
|
||||
for (op in upOps) {
|
||||
m.presetOperations.add(op)
|
||||
}
|
||||
for (i in 0..downOps.size) {
|
||||
m.presetDownOperations.add(downOps[downOps.size - 1 - i])
|
||||
}
|
||||
}
|
||||
|
||||
private static func createTableOp(model: EntityModel): MigrationOperation {
|
||||
let op = MigrationOperation(MigrationOperationKind.CreateTable)
|
||||
op.tableName = model.tableName
|
||||
for (p in model.properties) {
|
||||
op.columnDefs.add(columnDefinition(p))
|
||||
}
|
||||
op
|
||||
}
|
||||
|
||||
private static func dropTableOp(table: String): MigrationOperation {
|
||||
let op = MigrationOperation(MigrationOperationKind.DropTable)
|
||||
op.tableName = table
|
||||
op
|
||||
}
|
||||
|
||||
private static func addColumnOp(table: String, p: PropertyModel): MigrationOperation {
|
||||
let op = MigrationOperation(MigrationOperationKind.AddColumn)
|
||||
op.tableName = table
|
||||
op.column = Some(columnDefinition(p))
|
||||
op
|
||||
}
|
||||
|
||||
private static func dropColumnOp(table: String, column: String): MigrationOperation {
|
||||
let op = MigrationOperation(MigrationOperationKind.DropColumn)
|
||||
op.tableName = table
|
||||
op.columnName = column
|
||||
op
|
||||
}
|
||||
|
||||
private static func alterColumnOp(table: String, p: PropertyModel): MigrationOperation {
|
||||
let op = MigrationOperation(MigrationOperationKind.AlterColumn)
|
||||
op.tableName = table
|
||||
op.column = Some(columnDefinition(p))
|
||||
op
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* 进程级迁移注册表:
|
||||
* - CLI 生成的注册文件(src/migrations/MigrationRegistry.cj)在模块初始化时通过顶层
|
||||
* let 自动调用 registerMigration 注册迁移类实例;
|
||||
* - 没有迁移文件的默认项目无需注册任何迁移,应用照常编译运行(migrations() 返回空);
|
||||
* - allMigrations() 按 migrationId 字典序返回,供 DbContext.migrations() 使用。
|
||||
*/
|
||||
|
||||
package simcu::orm.migrations
|
||||
|
||||
import std.collection.*
|
||||
|
||||
/// 已注册的全部迁移(按注册顺序存储,读取时排序)
|
||||
private let _registeredMigrations = ArrayList<Migration>()
|
||||
|
||||
/// 注册一个迁移实例(按 migrationId 去重,幂等)。
|
||||
/// CLI 生成的注册文件在顶层 `let _r0 = registerMigration(Xxx())` 中调用。
|
||||
public func registerMigration(migration: Migration): Bool {
|
||||
for (m in _registeredMigrations) {
|
||||
if (m.migrationId == migration.migrationId) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_registeredMigrations.add(migration)
|
||||
true
|
||||
}
|
||||
|
||||
/// 全部已注册迁移,按 migrationId 字典序返回新列表(不修改注册表)
|
||||
public func allMigrations(): ArrayList<Migration> {
|
||||
let list = ArrayList<Migration>()
|
||||
for (m in _registeredMigrations) {
|
||||
list.add(m)
|
||||
}
|
||||
// 插入排序(迁移 id 按时间戳生成,基本有序)
|
||||
var i: Int64 = 1
|
||||
while (i < list.size) {
|
||||
let cur = list[i]
|
||||
var j = i - 1
|
||||
while (j >= 0 && list[j].migrationId > cur.migrationId) {
|
||||
list[j + 1] = list[j]
|
||||
j -= 1
|
||||
}
|
||||
list[j + 1] = cur
|
||||
i += 1
|
||||
}
|
||||
list
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* 迁移基础设施:Migration 基类 + MigrationBuilder 声明式操作 + DDL SQL 生成。
|
||||
*
|
||||
* 用法(对齐 EF Core 迁移):
|
||||
* 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()
|
||||
* tb.column("name", ColumnTypes.TextCol).withMaxLength(100).notNull()
|
||||
* }
|
||||
* builder.createIndex("ix_users_name", "users", ["name"])
|
||||
* }
|
||||
* public override func down(builder: MigrationBuilder): Unit {
|
||||
* builder.dropTable("users")
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
|
||||
package simcu::orm.migrations
|
||||
|
||||
import std.collection.*
|
||||
import std.time.*
|
||||
import simcu::orm.sql.*
|
||||
|
||||
/**
|
||||
* 列定义(表结构/迁移用)。列类型 ColumnTypes 定义于 simcu::orm.sql,DDL 映射由方言决定。
|
||||
*/
|
||||
public class ColumnDefinition {
|
||||
public var name: String = ""
|
||||
public var columnType: ColumnTypes = ColumnTypes.TextCol
|
||||
public var primaryKey: Bool = false
|
||||
public var autoIncrement: Bool = false
|
||||
public var nullable: Bool = true
|
||||
public var unique: Bool = false
|
||||
public var maxLength: Int64 = 255
|
||||
public var defaultValue: ?Any = None
|
||||
|
||||
public init(name: String, columnType: ColumnTypes) {
|
||||
this.name = name
|
||||
this.columnType = columnType
|
||||
}
|
||||
|
||||
/// 标记主键
|
||||
public func primary(): ColumnDefinition {
|
||||
this.primaryKey = true
|
||||
this
|
||||
}
|
||||
|
||||
/// 标记自增(仅整型列有效)
|
||||
public func autoInc(): ColumnDefinition {
|
||||
this.autoIncrement = true
|
||||
this
|
||||
}
|
||||
|
||||
/// 标记非空
|
||||
public func notNull(): ColumnDefinition {
|
||||
this.nullable = false
|
||||
this
|
||||
}
|
||||
|
||||
/// 标记唯一
|
||||
public func withUnique(): ColumnDefinition {
|
||||
this.unique = true
|
||||
this
|
||||
}
|
||||
|
||||
/// 指定字符串最大长度
|
||||
public func withMaxLength(n: Int64): ColumnDefinition {
|
||||
this.maxLength = n
|
||||
this
|
||||
}
|
||||
|
||||
/// 指定默认值
|
||||
public func withDefault(value: Any): ColumnDefinition {
|
||||
this.defaultValue = Some(value)
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移操作类型。
|
||||
*/
|
||||
public enum MigrationOperationKind {
|
||||
| CreateTable
|
||||
| DropTable
|
||||
| AddColumn
|
||||
| DropColumn
|
||||
| AlterColumn
|
||||
| RenameColumn
|
||||
| CreateIndex
|
||||
| DropIndex
|
||||
| RawSql
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条迁移操作(不同 kind 使用对应字段)。
|
||||
*/
|
||||
public class MigrationOperation {
|
||||
public let kind: MigrationOperationKind
|
||||
public var tableName: String = ""
|
||||
public var columnName: String = ""
|
||||
public var newColumnName: String = ""
|
||||
public var column: ?ColumnDefinition = None
|
||||
public var columnDefs: ArrayList<ColumnDefinition> = ArrayList()
|
||||
public var indexName: String = ""
|
||||
public var columnNames: ArrayList<String> = ArrayList()
|
||||
public var unique: Bool = false
|
||||
public var sql: String = ""
|
||||
|
||||
public init(kind: MigrationOperationKind) {
|
||||
this.kind = kind
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建表辅助(createTable 的闭包参数)。
|
||||
*/
|
||||
public class TableBuilder {
|
||||
public let tableName: String
|
||||
internal let columns = ArrayList<ColumnDefinition>()
|
||||
|
||||
public init(tableName: String) {
|
||||
this.tableName = tableName
|
||||
}
|
||||
|
||||
/// 声明一列(返回列定义以支持链式标记)
|
||||
public func column(name: String, columnType: ColumnTypes): ColumnDefinition {
|
||||
let c = ColumnDefinition(name, columnType)
|
||||
columns.add(c)
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移构建器:声明式累积操作(对齐 EF Core MigrationBuilder)。
|
||||
*/
|
||||
public class MigrationBuilder {
|
||||
internal let operations = ArrayList<MigrationOperation>()
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 当前累积的操作列表(供执行器/测试读取)
|
||||
public func getOperations(): ArrayList<MigrationOperation> {
|
||||
operations
|
||||
}
|
||||
|
||||
public func createTable(name: String, build: (TableBuilder) -> Unit): MigrationBuilder {
|
||||
let tb = TableBuilder(name)
|
||||
build(tb)
|
||||
let op = MigrationOperation(MigrationOperationKind.CreateTable)
|
||||
op.tableName = name
|
||||
op.columnDefs = tb.columns
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
|
||||
public func dropTable(name: String): MigrationBuilder {
|
||||
let op = MigrationOperation(MigrationOperationKind.DropTable)
|
||||
op.tableName = name
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
|
||||
public func addColumn(table: String, column: ColumnDefinition): MigrationBuilder {
|
||||
let op = MigrationOperation(MigrationOperationKind.AddColumn)
|
||||
op.tableName = table
|
||||
op.column = Some(column)
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
|
||||
public func dropColumn(table: String, columnName: String): MigrationBuilder {
|
||||
let op = MigrationOperation(MigrationOperationKind.DropColumn)
|
||||
op.tableName = table
|
||||
op.columnName = columnName
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
|
||||
public func alterColumn(table: String, column: ColumnDefinition): MigrationBuilder {
|
||||
let op = MigrationOperation(MigrationOperationKind.AlterColumn)
|
||||
op.tableName = table
|
||||
op.column = Some(column)
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
|
||||
public func renameColumn(table: String, oldName: String, newName: String): MigrationBuilder {
|
||||
let op = MigrationOperation(MigrationOperationKind.RenameColumn)
|
||||
op.tableName = table
|
||||
op.columnName = oldName
|
||||
op.newColumnName = newName
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
|
||||
public func createIndex(indexName: String, table: String, columns: ArrayList<String>): MigrationBuilder {
|
||||
createIndex(indexName, table, columns, false)
|
||||
}
|
||||
|
||||
public func createIndex(indexName: String, table: String, columns: ArrayList<String>,
|
||||
unique: Bool): MigrationBuilder {
|
||||
let op = MigrationOperation(MigrationOperationKind.CreateIndex)
|
||||
op.indexName = indexName
|
||||
op.tableName = table
|
||||
op.columnNames = columns
|
||||
op.unique = unique
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
|
||||
public func dropIndex(indexName: String, table: String): MigrationBuilder {
|
||||
let op = MigrationOperation(MigrationOperationKind.DropIndex)
|
||||
op.indexName = indexName
|
||||
op.tableName = table
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
|
||||
public func rawSql(sql: String): MigrationBuilder {
|
||||
let op = MigrationOperation(MigrationOperationKind.RawSql)
|
||||
op.sql = sql
|
||||
operations.add(op)
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移基类:migrationId 形如 "20250701000000_InitialCreate"(时间戳_名称),按字典序应用。
|
||||
* 两种使用方式:
|
||||
* 1. 子类 override up/down 手写声明式操作(MigrationBuilder);
|
||||
* 2. 由 MigrationGenerator 从模型生成时注入 preset 操作,基类 up/down 自动重放。
|
||||
*/
|
||||
public open class Migration {
|
||||
public let migrationId: String
|
||||
public let description: String
|
||||
|
||||
/// 由 MigrationGenerator 注入的 up 操作(按序执行)
|
||||
internal var presetOperations = ArrayList<MigrationOperation>()
|
||||
/// 由 MigrationGenerator 注入的 down 操作(按序执行,已按 up 逆序反转)
|
||||
internal var presetDownOperations = ArrayList<MigrationOperation>()
|
||||
|
||||
public init(migrationId: String, description: String) {
|
||||
this.migrationId = migrationId
|
||||
this.description = description
|
||||
}
|
||||
|
||||
public init(migrationId: String) {
|
||||
this.migrationId = migrationId
|
||||
this.description = ""
|
||||
}
|
||||
|
||||
public open func up(builder: MigrationBuilder): Unit {
|
||||
for (op in presetOperations) {
|
||||
builder.operations.add(op)
|
||||
}
|
||||
}
|
||||
|
||||
public open func down(builder: MigrationBuilder): Unit {
|
||||
for (op in presetDownOperations) {
|
||||
builder.operations.add(op)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DDL SQL 工厂:MigrationOperation → SQL(类型映射与差异语法委托给 ISqlDialect)。
|
||||
*/
|
||||
public class DdlFactory {
|
||||
public init() {}
|
||||
|
||||
public func toSql(op: MigrationOperation, dialect: ISqlDialect): String {
|
||||
match (op.kind) {
|
||||
case MigrationOperationKind.CreateTable => createTableSql(op, dialect)
|
||||
case MigrationOperationKind.DropTable => "DROP TABLE IF EXISTS ${dialect.quoteName(op.tableName)}"
|
||||
case MigrationOperationKind.AddColumn =>
|
||||
"ALTER TABLE ${dialect.quoteName(op.tableName)} ADD COLUMN ${columnDefSql(op.column.getOrThrow(), dialect)}"
|
||||
case MigrationOperationKind.DropColumn =>
|
||||
"ALTER TABLE ${dialect.quoteName(op.tableName)} DROP COLUMN ${dialect.quoteName(op.columnName)}"
|
||||
case MigrationOperationKind.AlterColumn =>
|
||||
dialect.buildAlterColumn(op.tableName, columnDefSql(op.column.getOrThrow(), dialect))
|
||||
case MigrationOperationKind.RenameColumn =>
|
||||
"ALTER TABLE ${dialect.quoteName(op.tableName)} RENAME COLUMN ${dialect.quoteName(op.columnName)} TO ${dialect.quoteName(op.newColumnName)}"
|
||||
case MigrationOperationKind.CreateIndex => createIndexSql(op, dialect)
|
||||
case MigrationOperationKind.DropIndex => dialect.buildDropIndex(op.indexName, op.tableName)
|
||||
case MigrationOperationKind.RawSql => op.sql
|
||||
}
|
||||
}
|
||||
|
||||
private func createTableSql(op: MigrationOperation, dialect: ISqlDialect): String {
|
||||
let sb = StringBuilder()
|
||||
sb.append("CREATE TABLE IF NOT EXISTS ${dialect.quoteName(op.tableName)} (\n")
|
||||
var first = true
|
||||
for (c in op.columnDefs) {
|
||||
if (!first) {
|
||||
sb.append(",\n")
|
||||
}
|
||||
sb.append(" ${columnDefSql(c, dialect)}")
|
||||
first = false
|
||||
}
|
||||
sb.append("\n)")
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
private func createIndexSql(op: MigrationOperation, dialect: ISqlDialect): String {
|
||||
let sb = StringBuilder()
|
||||
sb.append("CREATE ")
|
||||
if (op.unique) {
|
||||
sb.append("UNIQUE ")
|
||||
}
|
||||
sb.append("INDEX ${dialect.quoteName(op.indexName)} ON ${dialect.quoteName(op.tableName)} (")
|
||||
var first = true
|
||||
for (c in op.columnNames) {
|
||||
if (!first) {
|
||||
sb.append(", ")
|
||||
}
|
||||
sb.append(dialect.quoteName(c))
|
||||
first = false
|
||||
}
|
||||
sb.append(")")
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
private func columnDefSql(col: ColumnDefinition, dialect: ISqlDialect): String {
|
||||
let sb = StringBuilder()
|
||||
sb.append(dialect.quoteName(col.name))
|
||||
sb.append(" ${dialect.columnTypeSql(col.columnType, col.autoIncrement, col.maxLength)}")
|
||||
if (!col.nullable || col.primaryKey) {
|
||||
sb.append(" NOT NULL")
|
||||
}
|
||||
if (col.primaryKey) {
|
||||
sb.append(" PRIMARY KEY")
|
||||
}
|
||||
if (col.unique) {
|
||||
sb.append(" UNIQUE")
|
||||
}
|
||||
if (let Some(dv) <- col.defaultValue) {
|
||||
sb.append(" DEFAULT ${defaultValueSql(dv)}")
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
private func defaultValueSql(v: Any): String {
|
||||
if (let s: String <- v) {
|
||||
return "'${escapeQuotes(s)}'"
|
||||
}
|
||||
if (let b: Bool <- v) {
|
||||
return if (b) { "TRUE" } else { "FALSE" }
|
||||
}
|
||||
if (let i64: Int64 <- v) {
|
||||
return "${i64}"
|
||||
}
|
||||
if (let i32: Int32 <- v) {
|
||||
return "${i32}"
|
||||
}
|
||||
if (let i16: Int16 <- v) {
|
||||
return "${i16}"
|
||||
}
|
||||
if (let i8: Int8 <- v) {
|
||||
return "${i8}"
|
||||
}
|
||||
if (let u64: UInt64 <- v) {
|
||||
return "${u64}"
|
||||
}
|
||||
if (let u32: UInt32 <- v) {
|
||||
return "${u32}"
|
||||
}
|
||||
if (let u16: UInt16 <- v) {
|
||||
return "${u16}"
|
||||
}
|
||||
if (let u8: UInt8 <- v) {
|
||||
return "${u8}"
|
||||
}
|
||||
if (let f64: Float64 <- v) {
|
||||
return "${f64}"
|
||||
}
|
||||
if (let f32: Float32 <- v) {
|
||||
return "${f32}"
|
||||
}
|
||||
if (let r: Rune <- v) {
|
||||
return "'${r}'"
|
||||
}
|
||||
if (let d: DateTime <- v) {
|
||||
return "'${d.toString()}'"
|
||||
}
|
||||
throw Exception("simorm: 不支持的默认值类型,请使用 String/Bool/数值/Rune/DateTime")
|
||||
}
|
||||
|
||||
private func escapeQuotes(s: String): String {
|
||||
var sb = StringBuilder()
|
||||
for (c in s.runes()) {
|
||||
if (c == Rune(0x27)) {
|
||||
sb.append("''")
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* 迁移执行器:历史表 + 按 migrationId 排序应用未执行的迁移(事务内)。
|
||||
*
|
||||
* 历史表 simcu_orm_migrations (id VARCHAR(64) PK, name VARCHAR(256), applied_at TIMESTAMP),
|
||||
* 应用顺序 = migrationId 字典序(建议用 "yyyyMMddHHmmss_名称" 命名)。
|
||||
*/
|
||||
|
||||
package simcu::orm.migrations
|
||||
|
||||
import std.collection.*
|
||||
import std.database.sql.*
|
||||
import std.time.*
|
||||
import simcu::orm.sql.*
|
||||
|
||||
/**
|
||||
* 迁移执行器。
|
||||
*/
|
||||
public class Migrator {
|
||||
private let _datasource: Datasource
|
||||
private let _dialect: ISqlDialect
|
||||
private let _historyTable = "simcu_orm_migrations"
|
||||
|
||||
public init(datasource: Datasource, dialect: ISqlDialect) {
|
||||
this._datasource = datasource
|
||||
this._dialect = dialect
|
||||
}
|
||||
|
||||
public init(datasource: Datasource) {
|
||||
this(datasource, OpenGaussDialect())
|
||||
}
|
||||
|
||||
/// 应用所有未执行的迁移,返回本次应用的数量
|
||||
public func migrate(migrations: ArrayList<Migration>): Int64 {
|
||||
ensureHistoryTable()
|
||||
let applied = loadApplied()
|
||||
let sorted = sortMigrations(migrations)
|
||||
var count: Int64 = 0
|
||||
let conn = _datasource.connect()
|
||||
let tx = conn.createTransaction()
|
||||
tx.begin()
|
||||
try {
|
||||
for (m in sorted) {
|
||||
if (applied.contains(m.migrationId)) {
|
||||
continue
|
||||
}
|
||||
let builder = MigrationBuilder()
|
||||
m.up(builder)
|
||||
let factory = DdlFactory()
|
||||
for (op in builder.operations) {
|
||||
let stmt = conn.prepareStatement(factory.toSql(op, _dialect))
|
||||
try {
|
||||
stmt.update()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
recordApplied(conn, m)
|
||||
count += 1
|
||||
}
|
||||
tx.commit()
|
||||
count
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
tx.rollback()
|
||||
} catch (_) {
|
||||
()
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成"该执行但尚未执行"的迁移集合(不执行,供用户预览/校验)
|
||||
public func pending(migrations: ArrayList<Migration>): ArrayList<Migration> {
|
||||
let applied = loadApplied()
|
||||
let sorted = sortMigrations(migrations)
|
||||
let result = ArrayList<Migration>()
|
||||
for (m in sorted) {
|
||||
if (!applied.contains(m.migrationId)) {
|
||||
result.add(m)
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 回退最近一个已应用的迁移(执行 down + 删除历史记录),返回回退的数量
|
||||
public func revert(migrations: ArrayList<Migration>): Int64 {
|
||||
revert(migrations, None)
|
||||
}
|
||||
|
||||
/// 回退到目标迁移(不含目标):执行目标之后全部已应用迁移的 down。
|
||||
/// target 匹配规则:精确(migrationId / description)或唯一前缀(migrationId)。
|
||||
/// 返回回退的数量;无已应用迁移或已处于目标时返回 0。
|
||||
public func revert(migrations: ArrayList<Migration>, target: ?String): Int64 {
|
||||
ensureHistoryTable()
|
||||
let applied = loadApplied()
|
||||
if (applied.size == 0) {
|
||||
return 0
|
||||
}
|
||||
let sorted = sortMigrations(migrations)
|
||||
// 已应用迁移(按 id 升序)
|
||||
let appliedList = ArrayList<Migration>()
|
||||
for (m in sorted) {
|
||||
if (applied.contains(m.migrationId)) {
|
||||
appliedList.add(m)
|
||||
}
|
||||
}
|
||||
if (appliedList.size == 0) {
|
||||
return 0
|
||||
}
|
||||
// 目标下标:默认回退最新一个;指定目标时回退到目标之后(不含目标)
|
||||
var targetIdx = appliedList.size - 1
|
||||
if (let Some(t) <- target) {
|
||||
targetIdx = resolveTarget(appliedList, t) + 1
|
||||
if (targetIdx >= appliedList.size) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
var count: Int64 = 0
|
||||
let conn = _datasource.connect()
|
||||
let tx = conn.createTransaction()
|
||||
tx.begin()
|
||||
try {
|
||||
for (i in targetIdx..appliedList.size) {
|
||||
let m = appliedList[appliedList.size - 1 - (i - targetIdx)]
|
||||
let builder = MigrationBuilder()
|
||||
m.down(builder)
|
||||
let factory = DdlFactory()
|
||||
for (op in builder.operations) {
|
||||
let stmt = conn.prepareStatement(factory.toSql(op, _dialect))
|
||||
try {
|
||||
stmt.update()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
removeApplied(conn, m)
|
||||
count += 1
|
||||
}
|
||||
tx.commit()
|
||||
count
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
tx.rollback()
|
||||
} catch (_) {
|
||||
()
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveTarget(appliedList: ArrayList<Migration>, target: String): Int64 {
|
||||
var exact: Int64 = -1
|
||||
let prefixHits = ArrayList<Int64>()
|
||||
for (i in 0..appliedList.size) {
|
||||
let m = appliedList[i]
|
||||
if (m.migrationId == target || m.description == target) {
|
||||
exact = i
|
||||
} else if (m.migrationId.startsWith(target)) {
|
||||
prefixHits.add(i)
|
||||
}
|
||||
}
|
||||
if (exact >= 0) {
|
||||
return exact
|
||||
}
|
||||
if (prefixHits.size == 1) {
|
||||
return prefixHits[0]
|
||||
}
|
||||
if (prefixHits.size == 0) {
|
||||
throw Exception("simorm: 未找到迁移 '${target}'(已应用的迁移中无匹配)")
|
||||
}
|
||||
throw Exception("simorm: 迁移目标 '${target}' 不唯一,请使用完整 id")
|
||||
}
|
||||
|
||||
private func removeApplied(conn: Connection, m: Migration): Unit {
|
||||
let stmt = conn.prepareStatement(
|
||||
"DELETE FROM ${_dialect.quoteName(_historyTable)} WHERE ${_dialect.quoteName("id")} = ?")
|
||||
try {
|
||||
stmt.set<String>(0, m.migrationId)
|
||||
stmt.update()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
/// 已应用的迁移 id 列表(字典序,供 CLI list 使用)
|
||||
public func appliedMigrationIds(): ArrayList<String> {
|
||||
let ids = loadApplied()
|
||||
let result = ArrayList<String>()
|
||||
for (id in ids) {
|
||||
result.add(id)
|
||||
}
|
||||
// 插入排序(字典序)
|
||||
for (i in 1..result.size) {
|
||||
var j = i
|
||||
while (j > 0 && result[j] < result[j - 1]) {
|
||||
let t = result[j]
|
||||
result[j] = result[j - 1]
|
||||
result[j - 1] = t
|
||||
j -= 1
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
private func ensureHistoryTable(): Unit {
|
||||
let conn = _datasource.connect()
|
||||
try {
|
||||
let stmt = conn.prepareStatement(
|
||||
"CREATE TABLE IF NOT EXISTS ${_dialect.quoteName(_historyTable)} (\n" +
|
||||
" ${_dialect.quoteName("id")} VARCHAR(64) NOT NULL PRIMARY KEY,\n" +
|
||||
" ${_dialect.quoteName("name")} VARCHAR(256) NOT NULL,\n" +
|
||||
" ${_dialect.quoteName("applied_at")} TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP\n)")
|
||||
try {
|
||||
stmt.update()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadApplied(): HashSet<String> {
|
||||
let conn = _datasource.connect()
|
||||
try {
|
||||
let stmt = conn.prepareStatement(
|
||||
"SELECT ${_dialect.quoteName("id")} FROM ${_dialect.quoteName(_historyTable)}")
|
||||
try {
|
||||
let rs = stmt.query()
|
||||
let ids = HashSet<String>()
|
||||
while (rs.next()) {
|
||||
ids.add(rs.getOrNull<String>(0).getOrThrow())
|
||||
}
|
||||
ids
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
} finally {
|
||||
conn.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func recordApplied(conn: Connection, m: Migration): Unit {
|
||||
let stmt = conn.prepareStatement(
|
||||
"INSERT INTO ${_dialect.quoteName(_historyTable)} (${_dialect.quoteName("id")}, ${_dialect.quoteName("name")}) VALUES (?, ?)")
|
||||
try {
|
||||
stmt.set<String>(0, m.migrationId)
|
||||
stmt.set<String>(1, if (m.description.isEmpty()) { m.migrationId } else { m.description })
|
||||
stmt.update()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private static func sortMigrations(migrations: ArrayList<Migration>): ArrayList<Migration> {
|
||||
let arr = migrations.toArray()
|
||||
// 插入排序(migrationId 字典序)
|
||||
for (i in 1..arr.size) {
|
||||
var j = i
|
||||
while (j > 0 && arr[j].migrationId < arr[j - 1].migrationId) {
|
||||
let t = arr[j]
|
||||
arr[j] = arr[j - 1]
|
||||
arr[j - 1] = t
|
||||
j -= 1
|
||||
}
|
||||
}
|
||||
let result = ArrayList<Migration>()
|
||||
for (m in arr) {
|
||||
result.add(m)
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* 迷你 JSON 解析/序列化(orm-cj 零依赖,仅用于模型快照 / appsettings.json 这种结构固定的 JSON)。
|
||||
* 支持:object / array / string(含转义) / number / true / false / null。
|
||||
*/
|
||||
|
||||
package simcu::orm.migrations
|
||||
|
||||
import std.collection.*
|
||||
|
||||
/**
|
||||
* JSON 值(枚举承载,number 保留原始文本避免精度损失)。
|
||||
*/
|
||||
public enum JsonValue {
|
||||
| JObject(HashMap<String, JsonValue>)
|
||||
| JArray(ArrayList<JsonValue>)
|
||||
| JString(String)
|
||||
| JBool(Bool)
|
||||
| JNumber(String)
|
||||
| JNull
|
||||
}
|
||||
|
||||
/**
|
||||
* 迷你 JSON 工具。
|
||||
*/
|
||||
public class MiniJson {
|
||||
private init() {}
|
||||
|
||||
public static func parse(text: String): JsonValue {
|
||||
let p = JsonParser(text)
|
||||
p.skipWs()
|
||||
let v = p.parseValue()
|
||||
p.skipWs()
|
||||
if (p.pos < text.size) {
|
||||
throw Exception("mini-json: 尾随字符 @${p.pos}")
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
public static func stringify(v: JsonValue): String {
|
||||
let sb = StringBuilder()
|
||||
write(v, sb)
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
private static func write(v: JsonValue, sb: StringBuilder): Unit {
|
||||
match (v) {
|
||||
case JObject(map) =>
|
||||
sb.append("{")
|
||||
var first = true
|
||||
for ((k, v) in map) {
|
||||
if (!first) {
|
||||
sb.append(",")
|
||||
}
|
||||
first = false
|
||||
sb.append("\"")
|
||||
escape(k, sb)
|
||||
sb.append("\":")
|
||||
write(v, sb)
|
||||
}
|
||||
sb.append("}")
|
||||
case JArray(arr) =>
|
||||
sb.append("[")
|
||||
var first = true
|
||||
for (e in arr) {
|
||||
if (!first) {
|
||||
sb.append(",")
|
||||
}
|
||||
first = false
|
||||
write(e, sb)
|
||||
}
|
||||
sb.append("]")
|
||||
case JString(s) =>
|
||||
sb.append("\"")
|
||||
escape(s, sb)
|
||||
sb.append("\"")
|
||||
case JBool(b) => sb.append(if (b) { "true" } else { "false" })
|
||||
case JNumber(n) => sb.append(n)
|
||||
case JNull => sb.append("null")
|
||||
}
|
||||
}
|
||||
|
||||
private static func escape(s: String, sb: StringBuilder): Unit {
|
||||
for (c in s.runes()) {
|
||||
let ch = UInt32(c)
|
||||
if (c == Rune(0x22)) {
|
||||
sb.append("\\\"")
|
||||
} else if (c == Rune(0x5C)) {
|
||||
sb.append("\\\\")
|
||||
} else if (c == Rune(0x08)) {
|
||||
sb.append("\\b")
|
||||
} else if (c == Rune(0x0C)) {
|
||||
sb.append("\\f")
|
||||
} 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 if (ch < 0x20) {
|
||||
sb.append("\\u")
|
||||
sb.append(toHex4(ch))
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func toHex4(v: UInt32): String {
|
||||
const digits = "0123456789abcdef"
|
||||
var sb = StringBuilder()
|
||||
sb.append(digits[Int64((v >> 12) & 0xF)..Int64((v >> 12) & 0xF) + 1])
|
||||
sb.append(digits[Int64((v >> 8) & 0xF)..Int64((v >> 8) & 0xF) + 1])
|
||||
sb.append(digits[Int64((v >> 4) & 0xF)..Int64((v >> 4) & 0xF) + 1])
|
||||
sb.append(digits[Int64(v & 0xF)..Int64(v & 0xF) + 1])
|
||||
sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归下降解析器。
|
||||
*/
|
||||
internal class JsonParser {
|
||||
let text: String
|
||||
var pos: Int64 = 0
|
||||
|
||||
init(text: String) {
|
||||
this.text = text
|
||||
}
|
||||
|
||||
public func skipWs(): Unit {
|
||||
while (pos < text.size) {
|
||||
let c = text[pos]
|
||||
if (c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D) {
|
||||
pos += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func parseValue(): JsonValue {
|
||||
skipWs()
|
||||
if (pos >= text.size) {
|
||||
throw Exception("mini-json: 意外结尾")
|
||||
}
|
||||
let c = text[pos]
|
||||
if (c == 0x7B) {
|
||||
return parseObject()
|
||||
}
|
||||
if (c == 0x5B) {
|
||||
return parseArray()
|
||||
}
|
||||
if (c == 0x22) {
|
||||
return JString(parseString())
|
||||
}
|
||||
parseLiteralOrNumber()
|
||||
}
|
||||
|
||||
private func parseObject(): JsonValue {
|
||||
pos += 1 // {
|
||||
let map = HashMap<String, JsonValue>()
|
||||
skipWs()
|
||||
if (pos < text.size && text[pos] == 0x7D) {
|
||||
pos += 1
|
||||
return JObject(map)
|
||||
}
|
||||
while (true) {
|
||||
skipWs()
|
||||
if (pos >= text.size || text[pos] != 0x22) {
|
||||
throw Exception("mini-json: 期望字段名")
|
||||
}
|
||||
let key = parseString()
|
||||
skipWs()
|
||||
if (pos >= text.size || text[pos] != 0x3A) {
|
||||
throw Exception("mini-json: 期望 ':'")
|
||||
}
|
||||
pos += 1
|
||||
let v = parseValue()
|
||||
map[key] = v
|
||||
skipWs()
|
||||
if (pos >= text.size) {
|
||||
throw Exception("mini-json: object 未闭合")
|
||||
}
|
||||
if (text[pos] == 0x2C) {
|
||||
pos += 1
|
||||
continue
|
||||
}
|
||||
if (text[pos] == 0x7D) {
|
||||
pos += 1
|
||||
return JObject(map)
|
||||
}
|
||||
throw Exception("mini-json: 期望 ',' 或 '}'")
|
||||
}
|
||||
// 不可达:循环内所有退出路径均已 return/throw
|
||||
throw Exception("mini-json: object 解析异常")
|
||||
}
|
||||
|
||||
private func parseArray(): JsonValue {
|
||||
pos += 1 // [
|
||||
let arr = ArrayList<JsonValue>()
|
||||
skipWs()
|
||||
if (pos < text.size && text[pos] == 0x5D) {
|
||||
pos += 1
|
||||
return JArray(arr)
|
||||
}
|
||||
while (true) {
|
||||
let v = parseValue()
|
||||
arr.add(v)
|
||||
skipWs()
|
||||
if (pos >= text.size) {
|
||||
throw Exception("mini-json: array 未闭合")
|
||||
}
|
||||
if (text[pos] == 0x2C) {
|
||||
pos += 1
|
||||
continue
|
||||
}
|
||||
if (text[pos] == 0x5D) {
|
||||
pos += 1
|
||||
return JArray(arr)
|
||||
}
|
||||
throw Exception("mini-json: 期望 ',' 或 ']'")
|
||||
}
|
||||
// 不可达:循环内所有退出路径均已 return/throw
|
||||
throw Exception("mini-json: array 解析异常")
|
||||
}
|
||||
|
||||
private func parseString(): String {
|
||||
pos += 1 // "
|
||||
let sb = StringBuilder()
|
||||
while (pos < text.size) {
|
||||
let c = text[pos]
|
||||
if (c == 0x22) {
|
||||
pos += 1
|
||||
return sb.toString()
|
||||
}
|
||||
if (c == 0x5C) {
|
||||
pos += 1
|
||||
if (pos >= text.size) {
|
||||
throw Exception("mini-json: 转义不完整")
|
||||
}
|
||||
let e = text[pos]
|
||||
if (e == 0x22) {
|
||||
sb.append(Rune(0x22))
|
||||
} else if (e == 0x5C) {
|
||||
sb.append(Rune(0x5C))
|
||||
} else if (e == 0x2F) {
|
||||
sb.append(Rune(0x2F))
|
||||
} else if (e == 0x62) {
|
||||
sb.append(Rune(0x08))
|
||||
} else if (e == 0x66) {
|
||||
sb.append(Rune(0x0C))
|
||||
} else if (e == 0x6E) {
|
||||
sb.append(Rune(0x0A))
|
||||
} else if (e == 0x72) {
|
||||
sb.append(Rune(0x0D))
|
||||
} else if (e == 0x74) {
|
||||
sb.append(Rune(0x09))
|
||||
} else if (e == 0x75) {
|
||||
pos += 1
|
||||
if (pos + 4 > text.size) {
|
||||
throw Exception("mini-json: \\u 转义不完整")
|
||||
}
|
||||
var code: Int64 = 0
|
||||
for (i in 0..4) {
|
||||
code = code * 16 + hexDigit(text[pos + i])
|
||||
}
|
||||
sb.append(Rune(UInt32(code)))
|
||||
} else {
|
||||
throw Exception("mini-json: 非法转义 '\\${e}'")
|
||||
}
|
||||
pos += 1
|
||||
continue
|
||||
}
|
||||
sb.append(Rune(UInt32(c)))
|
||||
pos += 1
|
||||
}
|
||||
throw Exception("mini-json: 字符串未闭合")
|
||||
}
|
||||
|
||||
private func parseLiteralOrNumber(): JsonValue {
|
||||
let start = pos
|
||||
while (pos < text.size) {
|
||||
let c = text[pos]
|
||||
let ch = UInt32(c)
|
||||
let isLetter = (ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A)
|
||||
let isDigit = ch >= 0x30 && ch <= 0x39
|
||||
if (isLetter || isDigit || c == 0x2D || c == 0x2B || c == 0x2E) {
|
||||
pos += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
let raw = text[start..pos]
|
||||
if (raw == "true") {
|
||||
return JBool(true)
|
||||
}
|
||||
if (raw == "false") {
|
||||
return JBool(false)
|
||||
}
|
||||
if (raw == "null") {
|
||||
return JNull
|
||||
}
|
||||
if (raw == "") {
|
||||
throw Exception("mini-json: 无法识别的字面量 @${pos}")
|
||||
}
|
||||
JNumber(raw)
|
||||
}
|
||||
|
||||
private static func hexDigit(c: UInt8): Int64 {
|
||||
let ch = UInt32(c)
|
||||
if (ch >= 0x30 && ch <= 0x39) {
|
||||
return Int64(ch - 0x30)
|
||||
}
|
||||
if (ch >= 0x41 && ch <= 0x46) {
|
||||
return Int64(ch - 0x41 + 10)
|
||||
}
|
||||
if (ch >= 0x61 && ch <= 0x66) {
|
||||
return Int64(ch - 0x61 + 10)
|
||||
}
|
||||
throw Exception("mini-json: 非法 hex 字符")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (c) 2025 SimcuTeam. All rights reserved.
|
||||
* 模型快照:把反射构建的 EntityModel 序列化为轻量模型 + JSON 持久化,
|
||||
* 使 diff 不依赖调用方手存模型列表(对齐 EF Core 的快照文件)。
|
||||
*
|
||||
* 用法:
|
||||
* // 首次:快照文件不存在 → 生成 initial 迁移并保存快照
|
||||
* let gen = MigrationGenerator()
|
||||
* gen.ensure("20250701000000_InitialCreate", "初始建表", "snapshot.json", models0)
|
||||
* // 模型变更后:快照存在 → 载入旧模型 diff,并覆盖保存新快照
|
||||
* gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models1)
|
||||
*
|
||||
* 快照 JSON 结构(版本 1):
|
||||
* {"version":1,"models":[{"table":"users","columns":[
|
||||
* {"name":"_id","column":"id","type":"Int64","key":true,"auto":true,
|
||||
* "client":false,"required":false,"maxLen":0}]}]}
|
||||
*/
|
||||
|
||||
package simcu::orm.migrations
|
||||
|
||||
import std.collection.*
|
||||
import std.convert.*
|
||||
import std.fs.*
|
||||
import simcu::orm.model.*
|
||||
|
||||
/**
|
||||
* 模型快照:持有一组轻量 EntityModel(无反射句柄,仅元数据)。
|
||||
*/
|
||||
public class ModelSnapshot {
|
||||
/// 快照内的轻量模型列表
|
||||
public var models: ArrayList<EntityModel> = ArrayList()
|
||||
/// 快照格式版本
|
||||
public static let formatVersion: Int64 = 1
|
||||
|
||||
public init() {}
|
||||
|
||||
/// 从真实反射模型捕获元数据快照(剥离反射句柄)
|
||||
public static func capture(models: ArrayList<EntityModel>): ModelSnapshot {
|
||||
let snap = ModelSnapshot()
|
||||
for (m in models) {
|
||||
let lm = EntityModel()
|
||||
lm.tableName = m.tableName
|
||||
for (p in m.properties) {
|
||||
let lp = PropertyModel()
|
||||
lp.name = p.name
|
||||
lp.columnName = p.columnName
|
||||
lp.isKey = p.isKey
|
||||
lp.autoIncrement = p.autoIncrement
|
||||
lp.clientGenerated = p.clientGenerated
|
||||
lp.isRequired = p.isRequired
|
||||
lp.maxLength = p.maxLength
|
||||
lp.typeNameOverride = p.typeName()
|
||||
lm.properties.add(lp)
|
||||
}
|
||||
if (let Some(kp) <- m.keyProperty) {
|
||||
for (lp in lm.properties) {
|
||||
if (lp.columnName == kp.columnName) {
|
||||
lm.keyProperty = Some(lp)
|
||||
}
|
||||
}
|
||||
}
|
||||
snap.models.add(lm)
|
||||
}
|
||||
snap
|
||||
}
|
||||
|
||||
/// 快照内轻量模型列表(可直接作为 MigrationGenerator.diff 的 oldModels)
|
||||
public func toModels(): ArrayList<EntityModel> {
|
||||
models
|
||||
}
|
||||
|
||||
/// 序列化为 JSON 文本
|
||||
public func toJson(): String {
|
||||
let root = HashMap<String, JsonValue>()
|
||||
root["version"] = JNumber("${formatVersion}")
|
||||
let modelsArr = ArrayList<JsonValue>()
|
||||
for (m in models) {
|
||||
let mo = HashMap<String, JsonValue>()
|
||||
mo["table"] = JString(m.tableName)
|
||||
let cols = ArrayList<JsonValue>()
|
||||
for (p in m.properties) {
|
||||
let co = HashMap<String, JsonValue>()
|
||||
co["name"] = JString(p.name)
|
||||
co["column"] = JString(p.columnName)
|
||||
co["type"] = JString(p.typeName())
|
||||
co["key"] = JBool(p.isKey)
|
||||
co["auto"] = JBool(p.autoIncrement)
|
||||
co["client"] = JBool(p.clientGenerated)
|
||||
co["required"] = JBool(p.isRequired)
|
||||
co["maxLen"] = JNumber("${p.maxLength}")
|
||||
cols.add(JObject(co))
|
||||
}
|
||||
mo["columns"] = JArray(cols)
|
||||
modelsArr.add(JObject(mo))
|
||||
}
|
||||
root["models"] = JArray(modelsArr)
|
||||
MiniJson.stringify(JObject(root))
|
||||
}
|
||||
|
||||
/// 从 JSON 文本反序列化
|
||||
public static func fromJson(text: String): ModelSnapshot {
|
||||
let snap = ModelSnapshot()
|
||||
match (MiniJson.parse(text)) {
|
||||
case JObject(root) =>
|
||||
match (root.get("models")) {
|
||||
case Some(JArray(arr)) =>
|
||||
for (item in arr) {
|
||||
match (item) {
|
||||
case JObject(mo) =>
|
||||
let lm = EntityModel()
|
||||
lm.tableName = getString(mo, "table")
|
||||
match (mo.get("columns")) {
|
||||
case Some(JArray(cols)) =>
|
||||
for (c in cols) {
|
||||
match (c) {
|
||||
case JObject(co) =>
|
||||
let lp = PropertyModel()
|
||||
lp.name = getString(co, "name")
|
||||
lp.columnName = getString(co, "column")
|
||||
lp.typeNameOverride = getString(co, "type")
|
||||
lp.isKey = getBool(co, "key")
|
||||
lp.autoIncrement = getBool(co, "auto")
|
||||
lp.clientGenerated = getBool(co, "client")
|
||||
lp.isRequired = getBool(co, "required")
|
||||
lp.maxLength = getLong(co, "maxLen")
|
||||
if (lp.isKey) {
|
||||
lm.keyProperty = Some(lp)
|
||||
}
|
||||
lm.properties.add(lp)
|
||||
case _ => throw Exception(
|
||||
"simorm: 快照 columns 项必须是 object")
|
||||
}
|
||||
}
|
||||
case _ => throw Exception("simorm: 快照 models[].columns 必须是数组")
|
||||
}
|
||||
snap.models.add(lm)
|
||||
case _ => throw Exception("simorm: 快照 models[] 项必须是 object")
|
||||
}
|
||||
}
|
||||
case _ => throw Exception("simorm: 快照缺少 models 数组")
|
||||
}
|
||||
case _ => throw Exception("simorm: 快照根必须是 object")
|
||||
}
|
||||
snap
|
||||
}
|
||||
|
||||
/// 保存到文件(覆盖写入;父目录需存在)
|
||||
public func save(path: String): Unit {
|
||||
let p = Path(path)
|
||||
if (exists(p)) {
|
||||
remove(p)
|
||||
}
|
||||
let f = File.create(p)
|
||||
f.write(toJson().toArray())
|
||||
f.close()
|
||||
}
|
||||
|
||||
/// 从文件加载;文件不存在返回 None
|
||||
public static func load(path: String): ?ModelSnapshot {
|
||||
let p = Path(path)
|
||||
if (exists(p)) {
|
||||
let bytes = File.readFrom(p)
|
||||
return Some(fromJson(String.fromUtf8(bytes)))
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------- 私有 ----------
|
||||
|
||||
private static func getString(o: HashMap<String, JsonValue>, k: String): String {
|
||||
match (o.get(k)) {
|
||||
case Some(JString(s)) => s
|
||||
case _ => ""
|
||||
}
|
||||
}
|
||||
|
||||
private static func getBool(o: HashMap<String, JsonValue>, k: String): Bool {
|
||||
match (o.get(k)) {
|
||||
case Some(JBool(b)) => b
|
||||
case _ => false
|
||||
}
|
||||
}
|
||||
|
||||
private static func getLong(o: HashMap<String, JsonValue>, k: String): Int64 {
|
||||
match (o.get(k)) {
|
||||
case Some(JNumber(n)) => Int64.parse(n)
|
||||
case Some(JString(s)) => Int64.parse(s)
|
||||
case _ => 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user