feat: 新增类级 @TableIndex 注解(复合/唯一/部分索引,对齐 .NET [Index(...)])、索引 diff 统一为 IndexSpec、快照升级 v4、迁移源码还原 filter,版本 1.3.0
This commit is contained in:
@@ -3,5 +3,6 @@ target/
|
|||||||
# 宏编译产物(构建时自动生成,不入库)
|
# 宏编译产物(构建时自动生成,不入库)
|
||||||
src/macros/*.cjo
|
src/macros/*.cjo
|
||||||
src/macros/*.dll
|
src/macros/*.dll
|
||||||
|
src/tests/*.cj.macrocall
|
||||||
# 临时/日志
|
# 临时/日志
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- 快照 JSON 结构:`{"version":1,"models":[{"table":"users","columns":[{...}]}]}`,含表名、列名、类型、主键/自增/必填/最大长度;
|
- 快照 JSON 结构:`{"version":4,"models":[{"table":"users","columns":[{...}],"indexes":[{...}]}]}`,含表名、列名、类型、主键/自增/必填/最大长度,以及实体级索引(名称/列/唯一/过滤条件);
|
||||||
- `ModelSnapshot` 也提供 `capture(models)` / `toJson()` / `fromJson(text)` / `save(path)` / `load(path)`(文件不存在返回 `None`),可自行接入版本化/比较逻辑;
|
- `ModelSnapshot` 也提供 `capture(models)` / `toJson()` / `fromJson(text)` / `save(path)` / `load(path)`(文件不存在返回 `None`),可自行接入版本化/比较逻辑;
|
||||||
- 零依赖:`MiniJson` 内置迷你 JSON 解析/序列化,不引入第三方包。
|
- 零依赖:`MiniJson` 内置迷你 JSON 解析/序列化,不引入第三方包。
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ main() {
|
|||||||
- **增量**:`diff(id, desc, oldModels, newModels)` → 新增表 `CreateTable`、删除表 `DropTable`、列增删改(`AddColumn`/`DropColumn`/`AlterColumn`),`down` 自动按 up 逆序反转;
|
- **增量**:`diff(id, desc, oldModels, newModels)` → 新增表 `CreateTable`、删除表 `DropTable`、列增删改(`AddColumn`/`DropColumn`/`AlterColumn`),`down` 自动按 up 逆序反转;
|
||||||
- **同名列修改数据结构**:`Migrator` 执行 `AddColumn`/`AlterColumn` 前先查 `information_schema`,若库中已有同名列且类型与目标不一致,则**先 `DROP COLUMN` 再 `ADD COLUMN`**(重建该字段);类型一致时 `AddColumn` 幂等跳过、`AlterColumn` 正常执行(处理非空/长度等变化);
|
- **同名列修改数据结构**:`Migrator` 执行 `AddColumn`/`AlterColumn` 前先查 `information_schema`,若库中已有同名列且类型与目标不一致,则**先 `DROP COLUMN` 再 `ADD COLUMN`**(重建该字段);类型一致时 `AddColumn` 幂等跳过、`AlterColumn` 正常执行(处理非空/长度等变化);
|
||||||
- 字段类型 → 列类型映射:`String→TextCol`、`Bool→BoolCol`、`Int8/16/32/64→Tiny/Small/Int/BigIntCol`、`Float32→RealCol`、`Float64→FloatCol`、`DateTime→DateTimeCol`、`Decimal→DecimalCol`、`Array<Byte>→BinaryCol`、`Rune→IntCol`、`Duration→BigIntCol`;`Option<X>` 按内层类型 X 映射且列可空(`None` ↔ `NULL`);
|
- 字段类型 → 列类型映射:`String→TextCol`、`Bool→BoolCol`、`Int8/16/32/64→Tiny/Small/Int/BigIntCol`、`Float32→RealCol`、`Float64→FloatCol`、`DateTime→DateTimeCol`、`Decimal→DecimalCol`、`Array<Byte>→BinaryCol`、`Rune→IntCol`、`Duration→BigIntCol`;`Option<X>` 按内层类型 X 映射且列可空(`None` ↔ `NULL`);
|
||||||
- **不支持**(抛异常提示手写迁移):主键列名变更、主键/自增属性变更;`@Ignore`/`@Column`/`@Required`/`@MaxLength`/`@AutoIncrement`/`@Index` 均会反映到 DDL(`@Index` 生成 `CREATE INDEX`)。
|
- **不支持**(抛异常提示手写迁移):主键列名变更、主键/自增属性变更;`@Ignore`/`@Column`/`@Required`/`@MaxLength`/`@AutoIncrement`/`@Index` 均会反映到 DDL(`@Index` 生成 `CREATE INDEX`);类级 `@TableIndex` 生成对应的 `CREATE [UNIQUE] INDEX ... [WHERE filter]`(复合/唯一/部分索引),字段级与实体级索引统一走 `IndexSpec` diff(快照 v4 持久化)。
|
||||||
|
|
||||||
### 迁移 CLI
|
### 迁移 CLI
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ orm-cj/
|
|||||||
├── cjpm.toml # 包配置(name = orm, organization = simcu)
|
├── cjpm.toml # 包配置(name = orm, organization = simcu)
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── Orm.cj # 聚合导出:import simcu::orm.* 即全部可见
|
│ ├── Orm.cj # 聚合导出:import simcu::orm.* 即全部可见
|
||||||
│ ├── annotations/ # @Table / @Column / @Key / @AutoIncrement / @Ignore / @Required / @MaxLength
|
│ ├── annotations/ # @Table / @Column / @Key / @AutoIncrement / @Ignore / @Required / @MaxLength / @Index / @TableIndex
|
||||||
│ ├── macros/ # @DbContext 类级宏 + @Seed 数据填充宏(自动注册,无需手写注册表)
|
│ ├── macros/ # @DbContext 类级宏 + @Seed 数据填充宏(自动注册,无需手写注册表)
|
||||||
│ ├── seeds/ # Seed 基类 + 进程级填充注册表(registerSeed / allSeeds)
|
│ ├── seeds/ # Seed 基类 + 进程级填充注册表(registerSeed / allSeeds)
|
||||||
│ ├── model/ # EntityModel(反射映射)、ModelCache(模型缓存)、
|
│ ├── model/ # EntityModel(反射映射)、ModelCache(模型缓存)、
|
||||||
@@ -308,6 +308,7 @@ orm-cj/
|
|||||||
| `@Required` | 字段 | 非空列(仅影响建表 DDL) |
|
| `@Required` | 字段 | 非空列(仅影响建表 DDL) |
|
||||||
| `@MaxLength[100]` | 字段 | 字符串列最大长度(仅影响建表 DDL) |
|
| `@MaxLength[100]` | 字段 | 字符串列最大长度(仅影响建表 DDL) |
|
||||||
| `@Index` | 字段 | 对该列建索引(索引名默认 `ix_<表名>_<列名>`,迁移 add 自动生成 CREATE INDEX) |
|
| `@Index` | 字段 | 对该列建索引(索引名默认 `ix_<表名>_<列名>`,迁移 add 自动生成 CREATE INDEX) |
|
||||||
|
| `@TableIndex["col1,col2", true, "cond"]` | 类 | 类级复合/唯一/条件索引(对齐 .NET 类级 `[Index(...)]`);`columns` 为逗号分隔的列/字段名(字段名自动映射为列名),`unique` 是否唯一,`filter` 非空时生成 `... WHERE cond` 部分索引;索引名可省略(`name` 置于末位,留空则按 `ix_<表名>_<首列>` 自动生成),支持 `[columns]`、`[columns, unique]`、`[columns, unique, filter]`、`[columns, unique, filter, name]` 四种用法 |
|
||||||
|
|
||||||
### 2. 模型映射约定
|
### 2. 模型映射约定
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
name = "orm"
|
name = "orm"
|
||||||
organization = "simcu"
|
organization = "simcu"
|
||||||
description = "SimApi 数据访问层 ORM:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言"
|
description = "SimApi 数据访问层 ORM:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言"
|
||||||
version = "1.2.1"
|
version = "1.3.0"
|
||||||
target-dir = ""
|
target-dir = ""
|
||||||
output-type = "static"
|
output-type = "static"
|
||||||
|
|
||||||
|
|||||||
@@ -101,3 +101,55 @@ public class MaxLength {
|
|||||||
public class Index {
|
public class Index {
|
||||||
public const init() {}
|
public const init() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类级复合/条件索引(对齐 .NET 类级 [Index(...)],支持多列 + 唯一 + 部分索引)。
|
||||||
|
* 标注在实体类上,一个类声明一个复合索引(Cangjie 反射 findAnnotation 在同类多标注时读不到)。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* @TableIndex["source_server,name", true, "kind = 'Passport'"]
|
||||||
|
* public class Asset { ... }
|
||||||
|
*
|
||||||
|
* 参数(顺序):
|
||||||
|
* - columns 逗号分隔的列名/字段名(字段名自动映射为列名,如 "source_server,name" 或 "_sourceServer,_name")
|
||||||
|
* - unique 是否唯一索引,默认 false
|
||||||
|
* - filter 部分索引过滤条件(原始 SQL,如 "kind = 'Passport'";空串表示全表索引,不生成 WHERE),默认空
|
||||||
|
* - name 索引名,可为空;为空时自动生成 ix_<表名>_<首列名>
|
||||||
|
*
|
||||||
|
* 重载:@TableIndex[columns](全默认)、[columns, unique]、[columns, unique, filter]、[columns, unique, filter, name]。
|
||||||
|
*/
|
||||||
|
@Annotation[target: [Type]]
|
||||||
|
public class TableIndex {
|
||||||
|
public let columns: String
|
||||||
|
public let unique: Bool
|
||||||
|
public let filter: String
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
public const init(columns: String, unique: Bool, filter: String, name: String) {
|
||||||
|
this.columns = columns
|
||||||
|
this.unique = unique
|
||||||
|
this.filter = filter
|
||||||
|
this.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
public const init(columns: String, unique: Bool, filter: String) {
|
||||||
|
this.columns = columns
|
||||||
|
this.unique = unique
|
||||||
|
this.filter = filter
|
||||||
|
this.name = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
public const init(columns: String, unique: Bool) {
|
||||||
|
this.columns = columns
|
||||||
|
this.unique = unique
|
||||||
|
this.filter = ""
|
||||||
|
this.name = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
public const init(columns: String) {
|
||||||
|
this.columns = columns
|
||||||
|
this.unique = false
|
||||||
|
this.filter = ""
|
||||||
|
this.name = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,7 +211,10 @@ public class MigrationFileGenerator {
|
|||||||
sb.append("${indent}builder.renameColumn(\"${escape(op.tableName)}\", \"${escape(op.columnName)}\", \"${escape(op.newColumnName)}\")\n")
|
sb.append("${indent}builder.renameColumn(\"${escape(op.tableName)}\", \"${escape(op.columnName)}\", \"${escape(op.newColumnName)}\")\n")
|
||||||
case MigrationOperationKind.CreateIndex =>
|
case MigrationOperationKind.CreateIndex =>
|
||||||
sb.append("${indent}builder.createIndex(\"${escape(op.indexName)}\", \"${escape(op.tableName)}\", ArrayList<String>([${colList(op.columnNames)}])")
|
sb.append("${indent}builder.createIndex(\"${escape(op.indexName)}\", \"${escape(op.tableName)}\", ArrayList<String>([${colList(op.columnNames)}])")
|
||||||
if (op.unique) {
|
if (op.filter != "") {
|
||||||
|
let uniqueLiteral = if (op.unique) { "true" } else { "false" }
|
||||||
|
sb.append(", ${uniqueLiteral}, \"${escape(op.filter)}\"")
|
||||||
|
} else if (op.unique) {
|
||||||
sb.append(", true")
|
sb.append(", true")
|
||||||
}
|
}
|
||||||
sb.append(")\n")
|
sb.append(")\n")
|
||||||
|
|||||||
@@ -214,72 +214,70 @@ public class MigrationGenerator {
|
|||||||
diffIndexes(upOps, downOps, old, cur)
|
diffIndexes(upOps, downOps, old, cur)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 索引 diff:按 indexName 匹配,处理新增/删除/变更(变更 = up 先 DROP 再 CREATE,down 反之)
|
/// 索引 diff:按 indexName 匹配,处理新增/删除/变更(变更 = up 先 DROP 再 CREATE,down 反之)。
|
||||||
|
/// 统一处理字段级(@Index)与实体级(@TableIndex)索引,均归一为 IndexSpec。
|
||||||
private static func diffIndexes(upOps: ArrayList<MigrationOperation>, downOps: ArrayList<MigrationOperation>,
|
private static func diffIndexes(upOps: ArrayList<MigrationOperation>, downOps: ArrayList<MigrationOperation>,
|
||||||
old: EntityModel, cur: EntityModel): Unit {
|
old: EntityModel, cur: EntityModel): Unit {
|
||||||
var oldIdx = HashMap<String, PropertyModel>()
|
var oldIdx = HashMap<String, IndexSpec>()
|
||||||
for (p in old.properties) {
|
for (s in collectIndexSpecs(old)) {
|
||||||
if (p.isIndex) {
|
oldIdx[s.indexName] = s
|
||||||
oldIdx[p.indexName] = p
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var curIdx = HashMap<String, PropertyModel>()
|
var curIdx = HashMap<String, IndexSpec>()
|
||||||
for (p in cur.properties) {
|
for (s in collectIndexSpecs(cur)) {
|
||||||
if (p.isIndex) {
|
curIdx[s.indexName] = s
|
||||||
curIdx[p.indexName] = p
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 新增 / 变更
|
// 新增 / 变更
|
||||||
for (p in cur.properties) {
|
for (s in collectIndexSpecs(cur)) {
|
||||||
if (!p.isIndex) {
|
if (let Some(os) <- oldIdx.get(s.indexName)) {
|
||||||
continue
|
if (!sameIndex(os, s)) {
|
||||||
}
|
upOps.add(dropIndexOp(cur.tableName, s.indexName))
|
||||||
if (let Some(op) <- oldIdx.get(p.indexName)) {
|
downOps.add(createIndexOp(os))
|
||||||
if (!sameIndex(op, p)) {
|
upOps.add(createIndexOp(s))
|
||||||
upOps.add(dropIndexOp(cur.tableName, p.indexName))
|
downOps.add(dropIndexOp(old.tableName, s.indexName))
|
||||||
downOps.add(createIndexOp(old.tableName, op))
|
|
||||||
upOps.add(createIndexOp(cur.tableName, p))
|
|
||||||
downOps.add(dropIndexOp(old.tableName, p.indexName))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
upOps.add(createIndexOp(cur.tableName, p))
|
upOps.add(createIndexOp(s))
|
||||||
downOps.add(dropIndexOp(cur.tableName, p.indexName))
|
downOps.add(dropIndexOp(cur.tableName, s.indexName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 删除
|
// 删除
|
||||||
for (p in old.properties) {
|
for (s in collectIndexSpecs(old)) {
|
||||||
if (!p.isIndex) {
|
if (!curIdx.contains(s.indexName)) {
|
||||||
continue
|
upOps.add(dropIndexOp(old.tableName, s.indexName))
|
||||||
}
|
downOps.add(createIndexOp(s))
|
||||||
if (!curIdx.contains(p.indexName)) {
|
|
||||||
upOps.add(dropIndexOp(old.tableName, p.indexName))
|
|
||||||
downOps.add(createIndexOp(old.tableName, p))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func sameIndex(a: PropertyModel, b: PropertyModel): Bool {
|
private static func sameIndex(a: IndexSpec, b: IndexSpec): Bool {
|
||||||
a.columnName == b.columnName && a.indexUnique == b.indexUnique
|
if (a.unique != b.unique || a.filter != b.filter) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (a.columnNames.size != b.columnNames.size) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (i in 0..a.columnNames.size) {
|
||||||
|
if (a.columnNames[i] != b.columnNames[i]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该模型所有索引列 → CREATE INDEX op(up 用)
|
/// 该模型所有索引(字段级 + 实体级)→ CREATE INDEX op(up 用)
|
||||||
private static func createIndexOpsFor(model: EntityModel): ArrayList<MigrationOperation> {
|
private static func createIndexOpsFor(model: EntityModel): ArrayList<MigrationOperation> {
|
||||||
let ops = ArrayList<MigrationOperation>()
|
let ops = ArrayList<MigrationOperation>()
|
||||||
for (p in model.properties) {
|
for (s in collectIndexSpecs(model)) {
|
||||||
if (p.isIndex) {
|
ops.add(createIndexOp(s))
|
||||||
ops.add(createIndexOp(model.tableName, p))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ops
|
ops
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 该模型所有索引列 → DROP INDEX op(down 用,与 createIndexOpsFor 同序)
|
/// 该模型所有索引(字段级 + 实体级)→ DROP INDEX op(down 用,与 createIndexOpsFor 同序)
|
||||||
private static func dropIndexOpsFor(model: EntityModel): ArrayList<MigrationOperation> {
|
private static func dropIndexOpsFor(model: EntityModel): ArrayList<MigrationOperation> {
|
||||||
let ops = ArrayList<MigrationOperation>()
|
let ops = ArrayList<MigrationOperation>()
|
||||||
for (p in model.properties) {
|
for (s in collectIndexSpecs(model)) {
|
||||||
if (p.isIndex) {
|
ops.add(dropIndexOp(model.tableName, s.indexName))
|
||||||
ops.add(dropIndexOp(model.tableName, p.indexName))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ops
|
ops
|
||||||
}
|
}
|
||||||
@@ -307,6 +305,35 @@ public class MigrationGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 收集模型上的全部索引规格:先字段级(@Index),再实体级(@TableIndex)。
|
||||||
|
/// 顺序即生成顺序,down 与之对应逆序销毁。
|
||||||
|
private static func collectIndexSpecs(model: EntityModel): ArrayList<IndexSpec> {
|
||||||
|
let specs = ArrayList<IndexSpec>()
|
||||||
|
for (p in model.properties) {
|
||||||
|
if (p.isIndex) {
|
||||||
|
let s = IndexSpec()
|
||||||
|
s.table = model.tableName
|
||||||
|
s.indexName = p.indexName
|
||||||
|
s.unique = p.indexUnique
|
||||||
|
s.filter = ""
|
||||||
|
s.columnNames.add(p.columnName)
|
||||||
|
specs.add(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (e in model.indexes) {
|
||||||
|
let s = IndexSpec()
|
||||||
|
s.table = model.tableName
|
||||||
|
s.indexName = e.indexName
|
||||||
|
s.unique = e.unique
|
||||||
|
s.filter = e.filter
|
||||||
|
for (c in e.columnNames) {
|
||||||
|
s.columnNames.add(c)
|
||||||
|
}
|
||||||
|
specs.add(s)
|
||||||
|
}
|
||||||
|
specs
|
||||||
|
}
|
||||||
|
|
||||||
private static func indexByTable(models: ArrayList<EntityModel>): HashMap<String, EntityModel> {
|
private static func indexByTable(models: ArrayList<EntityModel>): HashMap<String, EntityModel> {
|
||||||
let map = HashMap<String, EntityModel>()
|
let map = HashMap<String, EntityModel>()
|
||||||
for (model in models) {
|
for (model in models) {
|
||||||
@@ -362,12 +389,15 @@ public class MigrationGenerator {
|
|||||||
op
|
op
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func createIndexOp(table: String, p: PropertyModel): MigrationOperation {
|
private static func createIndexOp(s: IndexSpec): MigrationOperation {
|
||||||
let op = MigrationOperation(MigrationOperationKind.CreateIndex)
|
let op = MigrationOperation(MigrationOperationKind.CreateIndex)
|
||||||
op.tableName = table
|
op.tableName = s.table
|
||||||
op.indexName = p.indexName
|
op.indexName = s.indexName
|
||||||
op.unique = p.indexUnique
|
op.unique = s.unique
|
||||||
op.columnNames.add(p.columnName)
|
op.filter = s.filter
|
||||||
|
for (c in s.columnNames) {
|
||||||
|
op.columnNames.add(c)
|
||||||
|
}
|
||||||
op
|
op
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,3 +408,21 @@ public class MigrationGenerator {
|
|||||||
op
|
op
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 内部统一的索引规格:把字段级(@Index)与实体级(@TableIndex)索引归一为同构描述,便于 diff 与生成。
|
||||||
|
*/
|
||||||
|
public class IndexSpec {
|
||||||
|
/// 所属表名
|
||||||
|
public var table: String = ""
|
||||||
|
/// 索引名
|
||||||
|
public var indexName: String = ""
|
||||||
|
/// 列名列表
|
||||||
|
public var columnNames: ArrayList<String> = ArrayList()
|
||||||
|
/// 是否唯一
|
||||||
|
public var unique: Bool = false
|
||||||
|
/// 部分索引过滤条件(空串表示全表索引)
|
||||||
|
public var filter: String = ""
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ public class MigrationOperation {
|
|||||||
public var indexName: String = ""
|
public var indexName: String = ""
|
||||||
public var columnNames: ArrayList<String> = ArrayList()
|
public var columnNames: ArrayList<String> = ArrayList()
|
||||||
public var unique: Bool = false
|
public var unique: Bool = false
|
||||||
|
/// 部分索引过滤条件(CREATE ... WHERE filter;空串表示全表索引)
|
||||||
|
public var filter: String = ""
|
||||||
public var sql: String = ""
|
public var sql: String = ""
|
||||||
|
|
||||||
public init(kind: MigrationOperationKind) {
|
public init(kind: MigrationOperationKind) {
|
||||||
@@ -202,11 +204,18 @@ public class MigrationBuilder {
|
|||||||
|
|
||||||
public func createIndex(indexName: String, table: String, columns: ArrayList<String>,
|
public func createIndex(indexName: String, table: String, columns: ArrayList<String>,
|
||||||
unique: Bool): MigrationBuilder {
|
unique: Bool): MigrationBuilder {
|
||||||
|
createIndex(indexName, table, columns, unique, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建索引(可指定部分索引过滤条件;filter 非空时生成 CREATE ... WHERE filter)
|
||||||
|
public func createIndex(indexName: String, table: String, columns: ArrayList<String>,
|
||||||
|
unique: Bool, filter: String): MigrationBuilder {
|
||||||
let op = MigrationOperation(MigrationOperationKind.CreateIndex)
|
let op = MigrationOperation(MigrationOperationKind.CreateIndex)
|
||||||
op.indexName = indexName
|
op.indexName = indexName
|
||||||
op.tableName = table
|
op.tableName = table
|
||||||
op.columnNames = columns
|
op.columnNames = columns
|
||||||
op.unique = unique
|
op.unique = unique
|
||||||
|
op.filter = filter
|
||||||
operations.add(op)
|
operations.add(op)
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
@@ -391,6 +400,9 @@ public class DdlFactory {
|
|||||||
first = false
|
first = false
|
||||||
}
|
}
|
||||||
sb.append(")")
|
sb.append(")")
|
||||||
|
if (op.filter != "") {
|
||||||
|
sb.append(" WHERE ${op.filter}")
|
||||||
|
}
|
||||||
sb.toString()
|
sb.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
* // 模型变更后:快照存在 → 载入旧模型 diff,并覆盖保存新快照
|
* // 模型变更后:快照存在 → 载入旧模型 diff,并覆盖保存新快照
|
||||||
* gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models1)
|
* gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models1)
|
||||||
*
|
*
|
||||||
* 快照 JSON 结构(版本 3,较 v2 新增索引标记 idx/ixName/ixUnique):
|
* 快照 JSON 结构(版本 4,较 v3 新增实体级 indexes 数组;v3 的 idx/ixName/ixUnique 为字段级索引标记):
|
||||||
* {"version":3,"models":[{"table":"users","columns":[
|
* {"version":4,"models":[{"table":"users","columns":[
|
||||||
* {"name":"id","column":"id","type":"Int64","key":true,"auto":true,
|
* {"name":"id","column":"id","type":"Int64","key":true,"auto":true,
|
||||||
* "client":false,"required":false,"maxLen":0,"enum":false,
|
* "client":false,"required":false,"maxLen":0,"enum":false,
|
||||||
* "idx":false,"ixName":"","ixUnique":false}]}]}
|
* "idx":false,"ixName":"","ixUnique":false}],
|
||||||
|
* "indexes":[{"name":"ux","columns":["a","b"],"unique":true,"filter":"kind = 'Passport'"}]}]}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package simcu::orm.migrations
|
package simcu::orm.migrations
|
||||||
@@ -31,7 +32,7 @@ public class ModelSnapshot {
|
|||||||
/// 快照内的轻量模型列表
|
/// 快照内的轻量模型列表
|
||||||
public var models: ArrayList<EntityModel> = ArrayList()
|
public var models: ArrayList<EntityModel> = ArrayList()
|
||||||
/// 快照格式版本
|
/// 快照格式版本
|
||||||
public static let formatVersion: Int64 = 3
|
public static let formatVersion: Int64 = 4
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
@@ -64,6 +65,16 @@ public class ModelSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (e in m.indexes) {
|
||||||
|
let li = EntityIndex()
|
||||||
|
li.indexName = e.indexName
|
||||||
|
li.unique = e.unique
|
||||||
|
li.filter = e.filter
|
||||||
|
for (c in e.columnNames) {
|
||||||
|
li.columnNames.add(c)
|
||||||
|
}
|
||||||
|
lm.indexes.add(li)
|
||||||
|
}
|
||||||
snap.models.add(lm)
|
snap.models.add(lm)
|
||||||
}
|
}
|
||||||
snap
|
snap
|
||||||
@@ -100,6 +111,20 @@ public class ModelSnapshot {
|
|||||||
cols.add(JObject(co))
|
cols.add(JObject(co))
|
||||||
}
|
}
|
||||||
mo["columns"] = JArray(cols)
|
mo["columns"] = JArray(cols)
|
||||||
|
let idxs = ArrayList<JsonValue>()
|
||||||
|
for (e in m.indexes) {
|
||||||
|
let io = HashMap<String, JsonValue>()
|
||||||
|
io["name"] = JString(e.indexName)
|
||||||
|
let icols = ArrayList<JsonValue>()
|
||||||
|
for (c in e.columnNames) {
|
||||||
|
icols.add(JString(c))
|
||||||
|
}
|
||||||
|
io["columns"] = JArray(icols)
|
||||||
|
io["unique"] = JBool(e.unique)
|
||||||
|
io["filter"] = JString(e.filter)
|
||||||
|
idxs.add(JObject(io))
|
||||||
|
}
|
||||||
|
mo["indexes"] = JArray(idxs)
|
||||||
modelsArr.add(JObject(mo))
|
modelsArr.add(JObject(mo))
|
||||||
}
|
}
|
||||||
root["models"] = JArray(modelsArr)
|
root["models"] = JArray(modelsArr)
|
||||||
@@ -146,6 +171,26 @@ public class ModelSnapshot {
|
|||||||
}
|
}
|
||||||
case _ => throw Exception("simcu::orm: 快照 models[].columns 必须是数组")
|
case _ => throw Exception("simcu::orm: 快照 models[].columns 必须是数组")
|
||||||
}
|
}
|
||||||
|
// 实体级索引(v4 开始存在;缺失时容忍为空,兼容旧快照)
|
||||||
|
match (mo.get("indexes")) {
|
||||||
|
case Some(JArray(idxs)) =>
|
||||||
|
for (i in idxs) {
|
||||||
|
match (i) {
|
||||||
|
case JObject(io) =>
|
||||||
|
let li = EntityIndex()
|
||||||
|
li.indexName = getString(io, "name")
|
||||||
|
li.unique = getBool(io, "unique")
|
||||||
|
li.filter = getString(io, "filter")
|
||||||
|
for (c in getStringArray(io, "columns")) {
|
||||||
|
li.columnNames.add(c)
|
||||||
|
}
|
||||||
|
lm.indexes.add(li)
|
||||||
|
case _ => throw Exception(
|
||||||
|
"simcu::orm: 快照 indexes[] 项必须是 object")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case _ => ()
|
||||||
|
}
|
||||||
snap.models.add(lm)
|
snap.models.add(lm)
|
||||||
case _ => throw Exception("simcu::orm: 快照 models[] 项必须是 object")
|
case _ => throw Exception("simcu::orm: 快照 models[] 项必须是 object")
|
||||||
}
|
}
|
||||||
@@ -201,4 +246,20 @@ public class ModelSnapshot {
|
|||||||
case _ => 0
|
case _ => 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取字符串数组(如索引列名列表);缺失或类型不符时返回空列表
|
||||||
|
private static func getStringArray(o: HashMap<String, JsonValue>, k: String): ArrayList<String> {
|
||||||
|
let list = ArrayList<String>()
|
||||||
|
match (o.get(k)) {
|
||||||
|
case Some(JArray(arr)) =>
|
||||||
|
for (e in arr) {
|
||||||
|
match (e) {
|
||||||
|
case JString(s) => list.add(s)
|
||||||
|
case _ => ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case _ => ()
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,22 @@ public class PropertyModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类级声明的实体索引(复合/条件索引,来自 @TableIndex 注解或快照反序列化)。
|
||||||
|
*/
|
||||||
|
public class EntityIndex {
|
||||||
|
/// 索引名(空则按 ix_<表名>_<首列名> 自动生成)
|
||||||
|
public var indexName: String = ""
|
||||||
|
/// 列名列表(已映射为数据库列名)
|
||||||
|
public var columnNames: ArrayList<String> = ArrayList()
|
||||||
|
/// 是否唯一索引
|
||||||
|
public var unique: Bool = false
|
||||||
|
/// 部分索引过滤条件(空串表示全表索引)
|
||||||
|
public var filter: String = ""
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 实体模型:一个实体类 ↔ 一张表。
|
* 实体模型:一个实体类 ↔ 一张表。
|
||||||
*/
|
*/
|
||||||
@@ -115,6 +131,8 @@ public class EntityModel {
|
|||||||
public var properties: ArrayList<PropertyModel> = ArrayList()
|
public var properties: ArrayList<PropertyModel> = ArrayList()
|
||||||
/// 主键映射
|
/// 主键映射
|
||||||
public var keyProperty: ?PropertyModel = None
|
public var keyProperty: ?PropertyModel = None
|
||||||
|
/// 类级声明的实体索引(复合/条件索引)
|
||||||
|
public var indexes: ArrayList<EntityIndex> = ArrayList()
|
||||||
|
|
||||||
/// 轻量构造:快照反序列化 / 人工组装时使用,createInstance 不可用
|
/// 轻量构造:快照反序列化 / 人工组装时使用,createInstance 不可用
|
||||||
public init() {}
|
public init() {}
|
||||||
@@ -266,6 +284,21 @@ public class ModelCache {
|
|||||||
}
|
}
|
||||||
model.properties.add(propM)
|
model.properties.add(propM)
|
||||||
}
|
}
|
||||||
|
// 类级复合/条件索引(@TableIndex):列名映射为数据库列名
|
||||||
|
if (let Some(ti) <- ct.findAnnotation<TableIndex>()) {
|
||||||
|
let idx = EntityIndex()
|
||||||
|
if (ti.name != "") {
|
||||||
|
idx.indexName = ti.name
|
||||||
|
} else {
|
||||||
|
// 首个列名决定默认索引名
|
||||||
|
let firstCol = mapFirstColumn(ti.columns)
|
||||||
|
idx.indexName = "ix_${model.tableName}_${firstCol}"
|
||||||
|
}
|
||||||
|
idx.columnNames = splitColumns(ti.columns, model)
|
||||||
|
idx.unique = ti.unique
|
||||||
|
idx.filter = ti.filter
|
||||||
|
model.indexes.add(idx)
|
||||||
|
}
|
||||||
// 主键校验(v1 仅支持单主键)
|
// 主键校验(v1 仅支持单主键)
|
||||||
var keys = ArrayList<PropertyModel>()
|
var keys = ArrayList<PropertyModel>()
|
||||||
for (p in model.properties) {
|
for (p in model.properties) {
|
||||||
@@ -299,6 +332,60 @@ public class ModelCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 把逗号分隔的列名/字段名字符串拆成数据库列名列表("source_server,name"、"_sourceServer, _name")。
|
||||||
|
/// 每一项先经 mapColumn 映射:字段名或列名都转到数据库列名。
|
||||||
|
private static func splitColumns(columns: String, model: EntityModel): ArrayList<String> {
|
||||||
|
var list = ArrayList<String>()
|
||||||
|
var sb = StringBuilder()
|
||||||
|
for (c in columns.runes()) {
|
||||||
|
if (c == Rune(0x2C)) {
|
||||||
|
// 逗号:收尾当前列
|
||||||
|
let col = model.mapColumn(trimWhitespace(sb.toString()))
|
||||||
|
if (col != "") {
|
||||||
|
list.add(col)
|
||||||
|
}
|
||||||
|
sb = StringBuilder()
|
||||||
|
} else {
|
||||||
|
sb.append(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let col = model.mapColumn(trimWhitespace(sb.toString()))
|
||||||
|
if (col != "") {
|
||||||
|
list.add(col)
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取逗号分隔列串中的首个列(用于默认索引名)。
|
||||||
|
private static func mapFirstColumn(columns: String): String {
|
||||||
|
var sb = StringBuilder()
|
||||||
|
for (c in columns.runes()) {
|
||||||
|
if (c == Rune(0x2C)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.append(c)
|
||||||
|
}
|
||||||
|
trimWhitespace(sb.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 去除字符串首尾空白(仅空格/tab/换行,避免依赖 std.String.trim)
|
||||||
|
private static func trimWhitespace(s: String): String {
|
||||||
|
var start: Int64 = 0
|
||||||
|
var end = s.size
|
||||||
|
while (start < end && isWsAt(s, start)) {
|
||||||
|
start += 1
|
||||||
|
}
|
||||||
|
while (end > start && isWsAt(s, end - 1)) {
|
||||||
|
end -= 1
|
||||||
|
}
|
||||||
|
s[start..end]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isWsAt(s: String, i: Int64): Bool {
|
||||||
|
let c = s[i]
|
||||||
|
c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D
|
||||||
|
}
|
||||||
|
|
||||||
/// 应用列命名策略
|
/// 应用列命名策略
|
||||||
public static func applyNamingPolicy(fieldName: String, policy: ColumnNamingPolicy): String {
|
public static func applyNamingPolicy(fieldName: String, policy: ColumnNamingPolicy): String {
|
||||||
match (policy) {
|
match (policy) {
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ class HandWrittenMigration <: Migration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 手写迁移(验证部分索引 filter 还原:createIndex 5 参带 filter)
|
||||||
|
class FilteredIndexMigration <: Migration {
|
||||||
|
public init() {
|
||||||
|
super("20250802000000_FilteredIndex", "部分唯一索引")
|
||||||
|
}
|
||||||
|
public override func up(builder: MigrationBuilder): Unit {
|
||||||
|
let cols = ArrayList<String>()
|
||||||
|
cols.add("source_server")
|
||||||
|
cols.add("name")
|
||||||
|
builder.createIndex("ux_assets_passport_name", "assets", cols, true, "kind = 'Passport'")
|
||||||
|
}
|
||||||
|
public override func down(builder: MigrationBuilder): Unit {
|
||||||
|
builder.dropIndex("ux_assets_passport_name", "assets")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
class MigrationFileGeneratorTests {
|
class MigrationFileGeneratorTests {
|
||||||
private func buildUserModel(): EntityModel {
|
private func buildUserModel(): EntityModel {
|
||||||
@@ -123,6 +139,16 @@ class MigrationFileGeneratorTests {
|
|||||||
@Expect(src.contains("builder.dropTable(\"tags\")"))
|
@Expect(src.contains("builder.dropTable(\"tags\")"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@TestCase
|
||||||
|
public func testFilteredIndexMigrationSource(): Unit {
|
||||||
|
let m = FilteredIndexMigration()
|
||||||
|
let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m)
|
||||||
|
// 生成的迁移源码应还原带 filter 的 5 参 createIndex(含 WHERE 条件)
|
||||||
|
@Expect(src.contains(
|
||||||
|
"builder.createIndex(\"ux_assets_passport_name\", \"assets\", ArrayList<String>([\"source_server\", \"name\"]), true, \"kind = 'Passport'\")"))
|
||||||
|
@Expect(src.contains("builder.dropIndex(\"ux_assets_passport_name\", \"assets\")"))
|
||||||
|
}
|
||||||
|
|
||||||
@TestCase
|
@TestCase
|
||||||
public func testRegistryRoundTrip(): Unit {
|
public func testRegistryRoundTrip(): Unit {
|
||||||
let entries = ArrayList<String>()
|
let entries = ArrayList<String>()
|
||||||
|
|||||||
Reference in New Issue
Block a user