diff --git a/README.md b/README.md index ed78c97..377e559 100644 --- a/README.md +++ b/README.md @@ -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` 声明变成 `public prop` 并注入 `this.set()`、生成 `(driverName, connStr)` 构造与 `migrations()` override: +用 `@DbContext` 宏(推荐):纯声明类即可,宏自动补 `<: DbContext`、把每个 `DbSet` 声明变成 `public prop` 并注入 `this.set()`、生成 `(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→BinaryCol`、`Rune→IntCol`、`Duration→BigIntCol`;`Option` 按内层类型 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`,以及上述类型的 **`Option` 包装**(`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().count()` | 全表计数 | | `set().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(对齐 EF Core IQueryable 常用子集) +### 4. 查询构建器 — QueryBuilder ```cangjie let qb = db.users.query() // 宏方式直接 db.users;等价 db.set().query() @@ -402,7 +405,7 @@ qb.page(2, 10) // PagedResult(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 ''`。规避:默认值改用 `= String()` 或直接省略初始化器。`orm-cj` 包内代码与 e2e 示例均按此规避写法; - **仅单主键**;不支持复合主键; -- **Option 字段已支持**:`?String` 等可空字段 `None ↔ NULL`(详见「模型映射约定」);**父类字段暂不支持**(不扫描继承字段); -- 不支持导航属性 / 延迟加载 / 级联删除(对齐 EF Core 这些能力属于 v2+); +- **父类字段暂不支持**(不扫描继承字段); +- 不支持导航属性 / 延迟加载 / 级联删除(这些能力属于 v2+); - 不支持 LINQ 表达式树,查询以 `filter` 条件方法 + SQL 片段组合; - `QueryBuilder` 的 SQL 片段形式(`filter(condition, params)`)需自行保证列名合法(会做标识符引号包裹校验外的处理)——推荐优先使用三参属性形式; - 自增主键回读依赖驱动 `RETURNING` 支持(openGauss/PostgreSQL 原生支持;换方言时由 `ISqlDialect.buildInsert` 决定回读策略)。 diff --git a/cjpm.toml b/cjpm.toml index 7e8bd3b..844f3a3 100644 --- a/cjpm.toml +++ b/cjpm.toml @@ -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" diff --git a/src/annotations/Annotations.cj b/src/annotations/Annotations.cj index 86d34db..8a3ca02 100644 --- a/src/annotations/Annotations.cj +++ b/src/annotations/Annotations.cj @@ -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() {} +} diff --git a/src/cli/MigrationFileGenerator.cj b/src/cli/MigrationFileGenerator.cj index 0ae9684..48cd5a5 100644 --- a/src/cli/MigrationFileGenerator.cj +++ b/src/cli/MigrationFileGenerator.cj @@ -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([${colList(op.columnNames)}])") if (op.unique) { sb.append(", true") } diff --git a/src/migrations/MigrationGenerator.cj b/src/migrations/MigrationGenerator.cj index 4a76e13..635e50b 100644 --- a/src/migrations/MigrationGenerator.cj +++ b/src/migrations/MigrationGenerator.cj @@ -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, downOps: ArrayList, + old: EntityModel, cur: EntityModel): Unit { + var oldIdx = HashMap() + for (p in old.properties) { + if (p.isIndex) { + oldIdx[p.indexName] = p + } + } + var curIdx = HashMap() + 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 { + let ops = ArrayList() + 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 { + let ops = ArrayList() + 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 + } } diff --git a/src/migrations/Migrations.cj b/src/migrations/Migrations.cj index 4908388..9874f25 100644 --- a/src/migrations/Migrations.cj +++ b/src/migrations/Migrations.cj @@ -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) { diff --git a/src/migrations/ModelSnapshot.cj b/src/migrations/ModelSnapshot.cj index 6756009..503bf41 100644 --- a/src/migrations/ModelSnapshot.cj +++ b/src/migrations/ModelSnapshot.cj @@ -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 = 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) } diff --git a/src/model/EntityModel.cj b/src/model/EntityModel.cj index 66154cb..5c8ab0a 100644 --- a/src/model/EntityModel.cj +++ b/src/model/EntityModel.cj @@ -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):优先 typeNameOverride,否则走反射 public func typeName(): String { @@ -251,6 +257,13 @@ public class ModelCache { if (let Some(m) <- v.findAnnotation()) { propM.maxLength = m.maxLength } + // 索引(@Index 标注):索引名默认 ix_<表名>_<列名> + if (v.findAnnotation().isSome()) { + propM.isIndex = true + if (propM.indexName == "") { + propM.indexName = "ix_${model.tableName}_${propM.columnName}" + } + } model.properties.add(propM) } // 主键校验(v1 仅支持单主键) diff --git a/src/tests/MigrationCli_test.cj b/src/tests/MigrationCli_test.cj index 571ecda..d4f2c0c 100644 --- a/src/tests/MigrationCli_test.cj +++ b/src/tests/MigrationCli_test.cj @@ -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([\"label\"]), true)")) @Expect(src.contains("tb.column(\"label\", ColumnTypes.TextCol).notNull().withUnique().withMaxLength(50)")) // rawSql 双引号转义 @Expect(src.contains("builder.rawSql(\"SELECT 1 -- comment \\\"quoted\\\"\")")) diff --git a/src/tests/SimOrm_test.cj b/src/tests/SimOrm_test.cj index 9ecaac1..427bac0 100644 --- a/src/tests/SimOrm_test.cj +++ b/src/tests/SimOrm_test.cj @@ -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"