release: simcu::orm 1.2.1 — README 清理(去除 EF 对照、补充 @Index 索引注解)、迁移生成索引说明、包描述与版本更新

This commit is contained in:
2026-08-23 06:45:04 +08:00
parent 9d7b353e2f
commit 270a01fd49
10 changed files with 175 additions and 37 deletions
+27 -24
View File
@@ -1,6 +1,8 @@
# SimOrm for Cangjie(simcu::orm)
> 仓颉版 EF Core 风格 ORM:**基于数据模型(POCO + 注解)的映射、增删改查、数据库迁移**,移植自 C# 项目 [SimApi](https://github.com/SimcuTeam/simapi-net) 的 `EF Core + SimApi` 数据访问思路(`E:\simcu\simapi-net`)。
> 灵感来自于 EF Core。
> 仓颉版 ORM:**基于数据模型(POCO + 注解)的映射、增删改查、数据库迁移**。
面向多数据库的方言架构:内置 PostgreSQL(`PostgreSqlDialect`)与 openGauss(`OpenGaussDialect`,继承 PG)方言(对接 [opengauss-driver](../opengauss-driver)),可通过实现 `ISqlDialect` 接口接入 sqlite / mysql 等新数据库。包本身零外部依赖。
@@ -40,7 +42,7 @@ public class User {
### 2. 定义 DbContext 并增删改查
用 `@DbContext` 宏(推荐,EF Core 声明式体验):纯声明类即可,宏自动补 `<: DbContext`、把每个 `DbSet<T>` 声明变成 `public prop` 并注入 `this.set<T>()`、生成 `(driverName, connStr)` 构造与 `migrations()` override:
用 `@DbContext` 宏(推荐):纯声明类即可,宏自动补 `<: DbContext`、把每个 `DbSet<T>` 声明变成 `public prop` 并注入 `this.set<T>()`、生成 `(driverName, connStr)` 构造与 `migrations()` override:
```cangjie
import std.database.sql.*
@@ -122,7 +124,7 @@ main() {
### 从数据模型生成迁移(MigrationGenerator)
不用手写 `Migration` 子类,直接从实体模型生成(对齐 EF Core `migrations add`):
不用手写 `Migration` 子类,直接从实体模型生成:
```cangjie
import simcu::orm.*
@@ -158,7 +160,7 @@ import simcu::orm.migrations.*
main() {
let gen = MigrationGenerator()
// ensure: 快照文件不存在 → 生成初始迁移并保存快照;
// 已存在 → 载入旧快照 diff 出新迁移并覆盖保存(对齐 EF Core migrations add)
// 已存在 → 载入旧快照 diff 出新迁移并覆盖保存
let m = gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models())
Migrator(datasource).migrate([m])
}
@@ -173,9 +175,9 @@ main() {
- **增量**:`diff(id, desc, oldModels, newModels)` → 新增表 `CreateTable`、删除表 `DropTable`、列增删改(`AddColumn`/`DropColumn`/`AlterColumn`),`down` 自动按 up 逆序反转;
- **同名列修改数据结构**:`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`);
- **不支持**(抛异常提示手写迁移):主键列名变更、主键/自增属性变更;模型无索引注解,不生成索引;`@Ignore`/`@Column`/`@Required`/`@MaxLength`/`@AutoIncrement` 均会反映到 DDL。
- **不支持**(抛异常提示手写迁移):主键列名变更、主键/自增属性变更;`@Ignore`/`@Column`/`@Required`/`@MaxLength`/`@AutoIncrement`/`@Index` 均会反映到 DDL(`@Index` 生成 `CREATE INDEX`)。
### 迁移 CLI(对齐 dotnet ef)
### 迁移 CLI
`orm-cj` 附带迁移 CLI(`simcu::orm.cli.MigrationCli`),把迁移落成 **.cj 文件** + **快照 JSON**,应用内嵌运行,无需安装外部工具。
@@ -294,19 +296,20 @@ orm-cj/
## 模块说明
### 1. 注解(对齐 .NET EF Core 数据注解)
### 1. 注解
| 注解 | 对齐 .NET | 位置 | 说明 |
|------|-----------|------|------|
| `@Table["users"]` | `[Table("users")]` | 类 | 指定表名 |
| `@Column["full_name"]` | `[Column("full_name")]` | 字段 | 指定列名 |
| `@Key` | `[Key]` | 字段 | 标记主键(字段名 `id` / `_id` 自动为主键) |
| `@AutoIncrement` | `[DatabaseGenerated(Identity)]` | 字段 | 整型主键自增(整型 `id` 默认自增) |
| `@Ignore` | `[NotMapped]` | 字段 | 不映射该字段 |
| `@Required` | `[Required]` | 字段 | 非空列(仅影响建表 DDL) |
| `@MaxLength[100]` | `[MaxLength(100)]` | 字段 | 字符串列最大长度(仅影响建表 DDL) |
| 注解 | 位置 | 说明 |
|------|------|------|
| `@Table["users"]` | 类 | 指定表名 |
| `@Column["full_name"]` | 字段 | 指定列名 |
| `@Key` | 字段 | 标记主键(字段名 `id` / `_id` 自动为主键) |
| `@AutoIncrement` | 字段 | 整型主键自增(整型 `id` 默认自增) |
| `@Ignore` | 字段 | 不映射该字段 |
| `@Required` | 字段 | 非空列(仅影响建表 DDL) |
| `@MaxLength[100]` | 字段 | 字符串列最大长度(仅影响建表 DDL) |
| `@Index` | 字段 | 对该列建索引(索引名默认 `ix_<表名>_<列名>`,迁移 add 自动生成 CREATE INDEX) |
### 2. 模型映射约定(对齐 EF Core 数据模型)
### 2. 模型映射约定
- **表名**:`@Table` 优先,否则类简单名;
- **列名**:`@Column` 优先,否则按 `ColumnNamingPolicy`(默认 **`Keep`**:字段名原样即列名,不做任何转换;可切换 `SnakeCase`:`userName` → `user_name`;`StripUnderscore`:`_id` → `id`);
@@ -317,7 +320,7 @@ orm-cj/
支持的字段标量类型:`String` / `Bool` / `Int8~Int64` / `UInt8~UInt64` / `Float32` / `Float64` / `Rune` / `DateTime` / `Duration` / `Decimal` / `Array<Byte>`,以及上述类型的 **`Option<T>` 包装**(`None` ↔ `NULL`,DDL 列可空)。
### 3. DbContext / DbSet(对齐 EF Core)
### 3. DbContext / DbSet
**推荐:`@DbContext` 宏**。把 `DbSet` 声明为 `prop`,宏自动补继承/构造/注入/`migrations()`:
@@ -337,7 +340,7 @@ public class AppDbContext {
let db = AppDbContext("pgsql", "Host=..;Database=app;Username=..;Password=..")
db.users.add(user)
db.saveChanges()
db.migrate() // 应用迁移(对齐 EF Database.Migrate)
db.migrate() // 应用迁移
// 状态检查(应用启动时可选)
db.databaseExists() // 数据库是否存在(连系统库探测,不建库)
@@ -380,9 +383,9 @@ public class AppDbContext <: DbContext {
| `set<T>().count()` | 全表计数 |
| `set<T>().query()` | 构建查询 |
> **语义说明(v1,操作队列模式)**:`add/update/remove` 只入队,`saveChanges` 时开启事务按入队顺序逐条执行后清空队列。与 EF Core 的 identity map 不同,同一实体重复入队会重复执行(如先 `add` 再 `update` = INSERT + UPDATE),请勿对同一实体的同一种操作重复调用。
> **语义说明(v1,操作队列模式)**:`add/update/remove` 只入队,`saveChanges` 时开启事务按入队顺序逐条执行后清空队列。同一实体重复入队会重复执行(如先 `add` 再 `update` = INSERT + UPDATE),请勿对同一实体的同一种操作重复调用。
### 4. 查询构建器 — QueryBuilder<T>(对齐 EF Core IQueryable 常用子集)
### 4. 查询构建器 — QueryBuilder<T>
```cangjie
let qb = db.users.query() // 宏方式直接 db.users;等价 db.set<User>().query()
@@ -402,7 +405,7 @@ qb.page(2, 10) // PagedResult<User>(items/total/
> 注意:`where` 是仓颉关键字,条件方法命名为 **`filter`**(两参原始片段 / 三参属性映射两种重载)。
### 5. 迁移(对齐 EF Core Migrations)
### 5. 迁移
- `Migration` 基类:`super("20250701000000_InitialCreate", "描述")`,实现 `up` / `down`;
- `MigrationBuilder`:`createTable` / `dropTable` / `addColumn` / `dropColumn` / `alterColumn` / `renameColumn` / `createIndex` / `dropIndex` / `rawSql`;
@@ -431,8 +434,8 @@ cjpm test # 纯逻辑单元测试(模型映射/SQL 生成/DDL 生成/QueryB
- **cjc 1.1.3 编译器缺陷(重要)**:类上带 `@Table[...]` 注解时,若成员变量带 ≥2 个注解且用 `= ""` 做空字符串初始化(如 `@Required @MaxLength[100] public var x: String = ""`),编译报 `expected expression after '=', found '<EOF>'`。规避:默认值改用 `= String()` 或直接省略初始化器。`orm-cj` 包内代码与 e2e 示例均按此规避写法;
- **仅单主键**;不支持复合主键;
- **Option<T> 字段已支持**:`?String` 等可空字段 `None ↔ NULL`(详见「模型映射约定」);**父类字段暂不支持**(不扫描继承字段);
- 不支持导航属性 / 延迟加载 / 级联删除(对齐 EF Core 这些能力属于 v2+);
- **父类字段暂不支持**(不扫描继承字段);
- 不支持导航属性 / 延迟加载 / 级联删除(这些能力属于 v2+);
- 不支持 LINQ 表达式树,查询以 `filter` 条件方法 + SQL 片段组合;
- `QueryBuilder` 的 SQL 片段形式(`filter(condition, params)`)需自行保证列名合法(会做标识符引号包裹校验外的处理)——推荐优先使用三参属性形式;
- 自增主键回读依赖驱动 `RETURNING` 支持(openGauss/PostgreSQL 原生支持;换方言时由 `ISqlDialect.buildInsert` 决定回读策略)。
+2 -2
View File
@@ -2,8 +2,8 @@
cjc-version = "1.1.3"
name = "orm"
organization = "simcu"
description = "SimApi 数据访问层 ORM(对齐 .NET EF Core:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言)"
version = "1.2.0"
description = "SimApi 数据访问层 ORM:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言"
version = "1.2.1"
target-dir = ""
output-type = "static"
+10
View File
@@ -91,3 +91,13 @@ public class MaxLength {
this.maxLength = maxLength
}
}
/**
* 标记该字段所在列建索引(对齐 .NET IndexAttribute),
* 迁移 CLI(add) 会按模型自动生成 CREATE INDEX;索引名默认为 ix_<表名>_<列名>。
* 用法:@Index public var _sellerAccount: String = ""
*/
@Annotation[target: [MemberVariable]]
public class Index {
public const init() {}
}
+1 -1
View File
@@ -210,7 +210,7 @@ public class MigrationFileGenerator {
case MigrationOperationKind.RenameColumn =>
sb.append("${indent}builder.renameColumn(\"${escape(op.tableName)}\", \"${escape(op.columnName)}\", \"${escape(op.newColumnName)}\")\n")
case MigrationOperationKind.CreateIndex =>
sb.append("${indent}builder.createIndex(\"${escape(op.indexName)}\", \"${escape(op.tableName)}\", [${colList(op.columnNames)}]")
sb.append("${indent}builder.createIndex(\"${escape(op.indexName)}\", \"${escape(op.tableName)}\", ArrayList<String>([${colList(op.columnNames)}])")
if (op.unique) {
sb.append(", true")
}
+104 -2
View File
@@ -13,7 +13,8 @@
* let m1 = gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models1)
* migrator.migrate([m1])
*
* 限制(v1):不支持主键/自增属性变更(抛异常提示手写迁移);不生成索引(模型无索引注解)。
* 索引:模型字段用 @Index 标注即生成 CREATE [UNIQUE] INDEX(down 自动 DROP INDEX)。
* 限制(v1):不支持主键/自增属性变更(抛异常提示手写迁移)。
*/
package simcu::orm.migrations
@@ -36,6 +37,13 @@ public class MigrationGenerator {
for (model in models) {
upOps.add(createTableOp(model))
downOps.add(dropTableOp(model.tableName))
// 索引随建表一并创建:down 先 DROP INDEX 再 DROP TABLE(setPresets 已逆序处理)
for (op in createIndexOpsFor(model)) {
upOps.add(op)
}
for (op in dropIndexOpsFor(model)) {
downOps.add(op)
}
}
setPresets(m, upOps, downOps)
m
@@ -50,11 +58,17 @@ public class MigrationGenerator {
let oldByName = indexByTable(oldModels)
let newByName = indexByTable(newModels)
// 1. 新增表 → CreateTable
// 1. 新增表 → CreateTable(含索引)
for (n in newModels) {
if (!oldByName.contains(n.tableName)) {
upOps.add(createTableOp(n))
downOps.add(dropTableOp(n.tableName))
for (op in createIndexOpsFor(n)) {
upOps.add(op)
}
for (op in dropIndexOpsFor(n)) {
downOps.add(op)
}
}
}
// 2. 已有表 → 列增删改
@@ -196,6 +210,78 @@ public class MigrationGenerator {
}
}
}
// 索引增删改
diffIndexes(upOps, downOps, old, cur)
}
/// 索引 diff:按 indexName 匹配,处理新增/删除/变更(变更 = up 先 DROP 再 CREATE,down 反之)
private static func diffIndexes(upOps: ArrayList<MigrationOperation>, downOps: ArrayList<MigrationOperation>,
old: EntityModel, cur: EntityModel): Unit {
var oldIdx = HashMap<String, PropertyModel>()
for (p in old.properties) {
if (p.isIndex) {
oldIdx[p.indexName] = p
}
}
var curIdx = HashMap<String, PropertyModel>()
for (p in cur.properties) {
if (p.isIndex) {
curIdx[p.indexName] = p
}
}
// 新增 / 变更
for (p in cur.properties) {
if (!p.isIndex) {
continue
}
if (let Some(op) <- oldIdx.get(p.indexName)) {
if (!sameIndex(op, p)) {
upOps.add(dropIndexOp(cur.tableName, p.indexName))
downOps.add(createIndexOp(old.tableName, op))
upOps.add(createIndexOp(cur.tableName, p))
downOps.add(dropIndexOp(old.tableName, p.indexName))
}
} else {
upOps.add(createIndexOp(cur.tableName, p))
downOps.add(dropIndexOp(cur.tableName, p.indexName))
}
}
// 删除
for (p in old.properties) {
if (!p.isIndex) {
continue
}
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 {
a.columnName == b.columnName && a.indexUnique == b.indexUnique
}
/// 该模型所有索引列 → CREATE INDEX op(up 用)
private static func createIndexOpsFor(model: EntityModel): ArrayList<MigrationOperation> {
let ops = ArrayList<MigrationOperation>()
for (p in model.properties) {
if (p.isIndex) {
ops.add(createIndexOp(model.tableName, p))
}
}
ops
}
/// 该模型所有索引列 → DROP INDEX op(down 用,与 createIndexOpsFor 同序)
private static func dropIndexOpsFor(model: EntityModel): ArrayList<MigrationOperation> {
let ops = ArrayList<MigrationOperation>()
for (p in model.properties) {
if (p.isIndex) {
ops.add(dropIndexOp(model.tableName, p.indexName))
}
}
ops
}
private static func sameColumn(a: PropertyModel, b: PropertyModel): Bool {
@@ -275,4 +361,20 @@ public class MigrationGenerator {
op.column = Some(columnDefinition(p))
op
}
private static func createIndexOp(table: String, p: PropertyModel): MigrationOperation {
let op = MigrationOperation(MigrationOperationKind.CreateIndex)
op.tableName = table
op.indexName = p.indexName
op.unique = p.indexUnique
op.columnNames.add(p.columnName)
op
}
private static func dropIndexOp(table: String, indexName: String): MigrationOperation {
let op = MigrationOperation(MigrationOperationKind.DropIndex)
op.tableName = table
op.indexName = indexName
op
}
}
+1 -1
View File
@@ -381,7 +381,7 @@ public class DdlFactory {
if (op.unique) {
sb.append("UNIQUE ")
}
sb.append("INDEX ${dialect.quoteName(op.indexName)} ON ${dialect.quoteName(op.tableName)} (")
sb.append("INDEX IF NOT EXISTS ${dialect.quoteName(op.indexName)} ON ${dialect.quoteName(op.tableName)} (")
var first = true
for (c in op.columnNames) {
if (!first) {
+14 -4
View File
@@ -10,10 +10,11 @@
* // 模型变更后:快照存在 → 载入旧模型 diff,并覆盖保存新快照
* gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models1)
*
* 快照 JSON 结构(版本 2,较 v1 新增 enum 标记):
* {"version":2,"models":[{"table":"users","columns":[
* 快照 JSON 结构(版本 3,较 v2 新增索引标记 idx/ixName/ixUnique):
* {"version":3,"models":[{"table":"users","columns":[
* {"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}]}]}
*/
package simcu::orm.migrations
@@ -30,7 +31,7 @@ public class ModelSnapshot {
/// 快照内的轻量模型列表
public var models: ArrayList<EntityModel> = ArrayList()
/// 快照格式版本
public static let formatVersion: Int64 = 2
public static let formatVersion: Int64 = 3
public init() {}
@@ -51,6 +52,9 @@ public class ModelSnapshot {
lp.maxLength = p.maxLength
lp.typeNameOverride = p.typeName()
lp.isEnum = p.isEnum
lp.isIndex = p.isIndex
lp.indexName = p.indexName
lp.indexUnique = p.indexUnique
lm.properties.add(lp)
}
if (let Some(kp) <- m.keyProperty) {
@@ -90,6 +94,9 @@ public class ModelSnapshot {
co["required"] = JBool(p.isRequired)
co["maxLen"] = JNumber("${p.maxLength}")
co["enum"] = JBool(p.isEnum)
co["idx"] = JBool(p.isIndex)
co["ixName"] = JString(p.indexName)
co["ixUnique"] = JBool(p.indexUnique)
cols.add(JObject(co))
}
mo["columns"] = JArray(cols)
@@ -126,6 +133,9 @@ public class ModelSnapshot {
lp.isRequired = getBool(co, "required")
lp.maxLength = getLong(co, "maxLen")
lp.isEnum = getBool(co, "enum")
lp.isIndex = getBool(co, "idx")
lp.indexName = getString(co, "ixName")
lp.indexUnique = getBool(co, "ixUnique")
if (lp.isKey) {
lm.keyProperty = Some(lp)
}
+13
View File
@@ -61,6 +61,12 @@ public class PropertyModel {
public var isEnum: Bool = false
/// 枚举完整类型名(如 simcu::xxx.AssetKind;快照重建的轻量模型为空串)
public var enumTypeName: String = ""
/// 是否建索引(@Index 标注)
public var isIndex: Bool = false
/// 索引名(空则按 ix_<表名>_<列名> 自动生成)
public var indexName: String = ""
/// 是否唯一索引(当前 @Index 为 false,预留)
public var indexUnique: Bool = false
/// 字段类型简单名(如 Int64、String、Option<AssetKind>):优先 typeNameOverride,否则走反射
public func typeName(): String {
@@ -251,6 +257,13 @@ public class ModelCache {
if (let Some(m) <- v.findAnnotation<MaxLength>()) {
propM.maxLength = m.maxLength
}
// 索引(@Index 标注):索引名默认 ix_<表名>_<列名>
if (v.findAnnotation<Index>().isSome()) {
propM.isIndex = true
if (propM.indexName == "") {
propM.indexName = "ix_${model.tableName}_${propM.columnName}"
}
}
model.properties.add(propM)
}
// 主键校验(v1 仅支持单主键)
+1 -1
View File
@@ -116,7 +116,7 @@ class MigrationFileGeneratorTests {
public func testHandWrittenMigrationSource(): Unit {
let m = HandWrittenMigration()
let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m)
@Expect(src.contains("builder.createIndex(\"ix_tags_label\", \"tags\", [\"label\"], true)"))
@Expect(src.contains("builder.createIndex(\"ix_tags_label\", \"tags\", ArrayList<String>([\"label\"]), true)"))
@Expect(src.contains("tb.column(\"label\", ColumnTypes.TextCol).notNull().withUnique().withMaxLength(50)"))
// rawSql 双引号转义
@Expect(src.contains("builder.rawSql(\"SELECT 1 -- comment \\\"quoted\\\"\")"))
+2 -2
View File
@@ -444,11 +444,11 @@ class DdlFactoryTests {
idxOp.tableName = "users"
idxOp.columnNames.add("name")
@Expect(f.toSql(idxOp, d),
"CREATE INDEX \"ix_users_name\" ON \"users\" (\"name\")")
"CREATE INDEX IF NOT EXISTS \"ix_users_name\" ON \"users\" (\"name\")")
// CREATE UNIQUE INDEX
idxOp.unique = true
@Expect(f.toSql(idxOp, d),
"CREATE UNIQUE INDEX \"ix_users_name\" ON \"users\" (\"name\")")
"CREATE UNIQUE INDEX IF NOT EXISTS \"ix_users_name\" ON \"users\" (\"name\")")
// DROP INDEX
let dropIdx = MigrationOperation(MigrationOperationKind.DropIndex)
dropIdx.indexName = "ix_users_name"