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:
2026-08-19 09:14:01 +08:00
commit afa6096ed2
28 changed files with 5822 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# 构建产物
target/
# 临时/日志
*.log
+405
View File
@@ -0,0 +1,405 @@
# SimOrm for Cangjiesimcu::orm
> 仓颉版 EF Core 风格 ORM:**基于数据模型(POCO + 注解)的映射、增删改查、数据库迁移**,移植自 C# 项目 [SimApi](https://github.com/SimcuTeam/simapi-net) 的 `EF Core + SimApi` 数据访问思路(`E:\simcu\simapi-net`)。
面向多数据库的方言架构:内置 PostgreSQL(`PostgreSqlDialect`)与 openGauss`OpenGaussDialect`,继承 PG)方言(对接 [opengauss-driver](../opengauss-driver)),可通过实现 `ISqlDialect` 接口接入 sqlite / mysql 等新数据库。包本身零外部依赖。
---
## 引入
```toml
[dependencies]
"simcu::orm" = { path = "../orm-cj" }
```
> 数据库驱动由使用者自行引入(如 `opengauss` path 依赖 openGauss 驱动);构建前需设置 `CANGJIE_STDX_PATH` 指向本地 stdx 的 `static/stdx` 目录。
---
## 快速开始
### 1. 定义实体(数据模型)
```cangjie
package your_app.models
import simcu::orm.*
@Table["users"] // 指定表名(不写则用类简单名)
public class User {
public var _id: Int64 = 0 // 整型 _id → 主键 + 数据库自增
public var _name: String = "" // 列名 name
@Column["full_name"]
public var _alias: String = "" // 注解覆盖列名
@Ignore
public var _temp: String = "" // 不映射
public var _age: Int32 = 0
}
```
### 2. 定义 DbContext 并增删改查
`@DbContext` 宏(推荐,EF Core 声明式体验):纯声明类即可,宏自动补 `<: DbContext`、把每个 `DbSet<T>` 声明变成 `public prop` 并注入 `this.set<T>()`、生成 `(driverName, connStr)` 构造与 `migrations()` override
```cangjie
import std.database.sql.*
import opengauss.driver.*
import simcu::orm.*
import simcu::orm.macros.*
@DbContext
public class AppDbContext {
public var users: DbSet<User>
public var orders: DbSet<Order>
}
main() {
// 1. 构造 DbContext"pgsql" 驱动 + 连接串),迁移 CLI 与 CRUD 共用同一个实例
let db = AppDbContext("pgsql", "Host=..;Database=app;Username=..;Password=..")
// 2. 增
let user = User()
user._name = "alice"
db.users.add(user) // 入队 INSERT
db.saveChanges() // 事务内执行,回读自增主键到 user._id
// 3. 改
user._age = 30
db.users.update(user) // 入队 UPDATE(全部非主键列)
db.saveChanges()
// 4. 查
let list = db.users.query()
.filter("_age", ">", Int64(18)) // 属性名/列名自动映射列名
.orderBy("_name")
.skip(0).take(10)
.toList() // ArrayList<User>
let one = db.users.find(Int64(1)) // ?User,按主键
let total = db.users.query().count()
// 5. 删
db.users.remove(user)
db.saveChanges()
}
```
> 不用宏时手动继承 `DbContext` 等价写法:补 `public init(...) { super(...) }` 构造,每个 DbSet 写成 `public prop users: DbSet<User> { get() { this.set<User>() } }`,并 override `migrations()`。宏就是把这几个样板步骤自动做完(详见「模块说明 3」)。
### 3. 迁移
```cangjie
import simcu::orm.migrations.*
// 定义迁移(migrationId 用 "yyyyMMddHHmmss_名称",按字典序应用)
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()
tb.column("active", ColumnTypes.BoolCol).withDefault(true)
}
builder.createIndex("ix_users_name", "users", idxCols)
}
public override func down(builder: MigrationBuilder): Unit {
builder.dropTable("users")
}
}
main() {
let idxCols = ArrayList<String>() // 索引列(API 为 ArrayList<String>
idxCols.add("name")
let migrator = Migrator(datasource)
let pending = migrator.pending(migrations) // 未执行的迁移(预览)
migrator.migrate(migrations) // 事务内按序执行并记录历史
}
```
迁移历史记录在 `simcu_orm_migrations` 表(id / name / applied_at),已应用的迁移自动跳过。
---
### 从数据模型生成迁移(MigrationGenerator
不用手写 `Migration` 子类,直接从实体模型生成(对齐 EF Core `migrations add`):
```cangjie
import simcu::orm.*
import simcu::orm.migrations.*
main() {
let gen = MigrationGenerator()
// 1. 初始迁移:模型集合 → 全部 CREATE TABLEdown 自动生成 DROP TABLE
let models = ArrayList<EntityModel>()
models.add(ModelCache.get<User>())
models.add(ModelCache.get<Product>())
let m0 = gen.initial("20250701000000_InitialCreate", "初始建表", models)
Migrator(datasource).migrate([m0])
// 2. 模型变更后:旧模型快照(上次的 models) vs 新模型 → 增量迁移(表/列增删改)
let models2 = ArrayList<EntityModel>()
models2.add(ModelCache.get<User>()) // 假设 User 新增了 _age 字段
models2.add(ModelCache.get<Product>())
let m1 = gen.diff("20250801000000_AddAge", "新增 age 列", models, models2)
Migrator(datasource).migrate([m1]) // 旧模型列表 models 由调用方保存,充当快照
}
```
### 快照持久化(ModelSnapshot
上面 `diff` 需要调用方手存旧模型列表。`ModelSnapshot` 把模型序列化成 JSON 持久化到文件,`diff` 不依赖手存:
```cangjie
import simcu::orm.*
import simcu::orm.migrations.*
main() {
let gen = MigrationGenerator()
// ensure: 快照文件不存在 → 生成初始迁移并保存快照;
// 已存在 → 载入旧快照 diff 出新迁移并覆盖保存(对齐 EF Core migrations add
let m = gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models())
Migrator(datasource).migrate([m])
}
```
- 快照 JSON 结构:`{"version":1,"models":[{"table":"users","columns":[{...}]}]}`,含表名、列名、类型、主键/自增/必填/最大长度;
- `ModelSnapshot` 也提供 `capture(models)` / `toJson()` / `fromJson(text)` / `save(path)` / `load(path)`(文件不存在返回 `None`),可自行接入版本化/比较逻辑;
- 零依赖:`MiniJson` 内置迷你 JSON 解析/序列化,不引入第三方包。
生成规则与限制(v1):
- **初始**`initial(id, desc, models)` → 每个模型一张 `CreateTable``down` 为逆序 `DropTable`
- **增量**`diff(id, desc, oldModels, newModels)` → 新增表 `CreateTable`、删除表 `DropTable`、列增删改(`AddColumn`/`DropColumn`/`AlterColumn`),`down` 自动按 up 逆序反转;
- 字段类型 → 列类型映射:`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`
- **不支持**(抛异常提示手写迁移):主键列名变更、主键/自增属性变更;模型无索引注解,不生成索引;`@Ignore`/`@Column`/`@Required`/`@MaxLength`/`@AutoIncrement` 均会反映到 DDL。
### 迁移 CLI(对齐 dotnet ef
`orm-cj` 附带迁移 CLI`simcu::orm.cli.MigrationCli`),把迁移落成 **.cj 文件** + **快照 JSON**,应用内嵌运行,无需安装外部工具。
**应用接线(一行接入)**`main()` 最上方先构造 `DbContext` 子类实例,`app orm <命令>` 时执行 CLI 并退出,否则走正常应用逻辑:
```cangjie
import simcu::orm.*
import simcu::orm.macros.*
import myapp.migrations.* // 注册文件所在子包(仅 import 触发注册)
@DbContext
public class MyDbContext {
public var users: DbSet<User>
public var orders: DbSet<Order>
}
main(args: Array<String>) {
let db = MyDbContext("pgsql", "Host=..;Database=..;Username=..;Password=..")
if (db.cli(args)) { // `app orm add/rm/update/downgrade/list/help`
return
}
// ... 正常应用启动逻辑(db 可继续用于 CRUD)
}
```
> `db.cli(args)` 是 DbContext 的实例方法:命中 `orm <命令>` 时执行 CLI 并返回 `true`,否则返回 `false`(正常启动)。模型从实例的 `DbSet` 属性反射收集,连接/驱动/方言来自构造参数,无需任何回调或宏(`@DbContext` 宏只是省去手写继承样板,CLI 不依赖它)。
编译后运行(`cjpm run -- <args>` 是把参数传给应用的方式):
| 命令 | 作用 |
|------|------|
| `cjpm run -- orm add <迁移名>` | 从模型生成迁移 .cj 文件 + 更新注册文件 + 写入快照;模型无变化则跳过 |
| `cjpm run -- orm rm <迁移名或id>` | 移除已生成的迁移(删除迁移文件 + 注册条目;注册表清空时重置快照) |
| `cjpm run -- orm update` | 连接数据库,按 `migrationId` 字典序应用所有未执行的迁移 |
| `cjpm run -- orm downgrade [目标]` | 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后(含该迁移的 down) |
| `cjpm run -- orm list` | 列出已注册迁移(需真实连接读取历史表) |
| `cjpm run -- orm help` | 显示帮助 |
**生成约定**
- **新项目必须先建 `src/migrations/` 空目录**并放一个占位文件(如 `_placeholder.cj`,内容仅 `package <应用包名>.migrations`),否则应用侧那行 `import <应用包名>.migrations.*` 会编译失败;首次 `add` 后迁移类与注册文件会加进该目录;
- 迁移 / 注册文件生成在 `src/migrations/`,该目录是**子包**,文件头 `package <应用包名>.migrations`(如应用包 `myapp``package myapp.migrations`);
- `add` 只生成不执行;执行迁移用 `update`(回退用 `downgrade`),或在应用启动时直接 `db.migrate()`
- 快照持久化在 `src/migrations/snapshot.json``add` 用「旧快照 vs 当前模型」做 diff,所以**改模型后重新编译应用再 `add`** 即生成增量迁移;
- 生成源码文本已包含 `import std.collection.*` 等标准库引用,应用无需额外配置。
---
## 项目结构
```
orm-cj/
├── cjpm.toml # 包配置(name = orm, organization = simcu
├── src/
│ ├── Orm.cj # 聚合导出:import simcu::orm.* 即全部可见
│ ├── annotations/ # @Table / @Column / @Key / @AutoIncrement / @Ignore / @Required / @MaxLength
│ ├── macros/ # @DbContext 类级宏(纯声明 DbSet 类 → 完整 DbContext 子类)
│ ├── model/ # EntityModel(反射映射)、ModelCache(模型缓存)、
│ │ # ColumnNamingPolicy(列命名策略)、ValueReader、ParamBinder、GuidUtil
│ ├── tracking/ # ChangeTracker(操作队列)、EntityState、EntityEntry
│ ├── sql/ # ISqlDialect 接口(独立文件)+ PostgreSqlDialectPG 实现)+
│ │ # OpenGaussDialect(继承 PG)、ColumnTypes(列类型)
│ ├── query/ # QueryBuilder<T>(条件/排序/分页)、PagedResult<T>
│ ├── db/ # DbContext(连接 + 提交 + 物化 + 数据库存在性/迁移状态检查)、DbSet<T>
│ ├── cli/ # MigrationCliadd/rm/update/downgrade/list 命令)、
│ │ # MigrationFileGenerator(迁移/注册文件源码生成)
│ ├── migrations/ # Migration 基类、MigrationBuilder、ColumnDefinition、
│ │ # DdlFactory(操作 → DDL SQL,差异语法委托方言)、
│ │ # MigrationGenerator(模型 → 迁移)、Migrator(历史表 + 执行)、
│ │ # ModelSnapshot(模型快照 JSON 持久化)+ MiniJson(零依赖 JSON 引擎)
│ └── tests/ # 单元测试(cjpm test,纯逻辑,不连库)
```
---
## 模块说明
### 1. 注解(对齐 .NET EF Core 数据注解)
| 注解 | 对齐 .NET | 位置 | 说明 |
|------|-----------|------|------|
| `@Table["users"]` | `[Table("users")]` | 类 | 指定表名 |
| `@Column["full_name"]` | `[Column("full_name")]` | 字段 | 指定列名 |
| `@Key` | `[Key]` | 字段 | 标记主键(字段名 `_id` 自动为主键) |
| `@AutoIncrement` | `[DatabaseGenerated(Identity)]` | 字段 | 整型主键自增(整型 `_id` 默认自增) |
| `@Ignore` | `[NotMapped]` | 字段 | 不映射该字段 |
| `@Required` | `[Required]` | 字段 | 非空列(仅影响建表 DDL) |
| `@MaxLength[100]` | `[MaxLength(100)]` | 字段 | 字符串列最大长度(仅影响建表 DDL) |
### 2. 模型映射约定(对齐 EF Core 数据模型)
- **表名**`@Table` 优先,否则类简单名;
- **列名**`@Column` 优先,否则按 `ColumnNamingPolicy`(默认 `StripUnderscore``_id``id`;可切换 `SnakeCase``_userName``user_name``Keep` 原样);
- **主键**`@Key` 标注,或字段名 `_id`v1 仅支持**单主键**
- **自增**`@AutoIncrement`,或整型(Int8-64`_id` 主键默认自增;INSERT 跳过该列并通过 `RETURNING` 回读;
- **客户端生成主键**`String` 主键默认客户端生成(`GuidUtil`:时间戳 + 自增序号 hex),INSERT 时为空自动生成;
- **实体要求**:字段必须是 `public var` 标量类型,且提供无参 `public init()`
支持的字段标量类型:`String` / `Bool` / `Int8~Int64` / `UInt8~UInt64` / `Float32` / `Float64` / `Rune` / `DateTime` / `Duration` / `Decimal` / `Array<Byte>`
### 3. DbContext / DbSet(对齐 EF Core
**推荐:`@DbContext` 宏**。把 `DbSet` 声明为属性(字段也行),宏自动补继承/构造/注入/`migrations()`
```cangjie
import simcu::orm.*
import simcu::orm.macros.*
import simcu::orm.migrations.* // migrations() 签名需要 Migration
import std.collection.* // migrations() 签名需要 ArrayList
@DbContext
public class AppDbContext {
public var users: DbSet<User>
public var orders: DbSet<Order>
}
// 用法(宏生成 (driverName, connStr) 构造)
let db = AppDbContext("pgsql", "Host=..;Database=app;Username=..;Password=..")
db.users.add(user)
db.saveChanges()
db.migrate() // 应用迁移(对齐 EF Database.Migrate
// 状态检查(应用启动时可选)
db.databaseExists() // 数据库是否存在(连系统库探测,不建库)
db.hasPendingMigrations() // 是否存在未应用的迁移(历史表不存在视为有)
if (!db.databaseExists() || db.hasPendingMigrations()) { db.migrate() }
```
**不用宏的等价手动写法**:继承 `DbContext``public prop xxx: DbSet<T> { get() { this.set<T>() } }`,补构造并 override `migrations()`
```cangjie
public class AppDbContext <: DbContext {
public let users: DbSet<User>
public let orders: DbSet<Order>
public init(driverName: String, connStr: String) { super(driverName, connStr) }
public init(datasource: Datasource) { super(datasource) }
public override func migrations(): ArrayList<Migration> { /* 迁移列表 */ }
}
```
| API | 说明 |
|-----|------|
| `AppDbContext("pgsql", connStr)` | 构造:驱动名 + 连接串(驱动构建收敛在 `DatasourceFactory`;驱动名归一化,`pgsql/pg/postgresql``postgres`,具体驱动由应用链接的驱动包注册) |
| `AppDbContext(connStr)` | 构造:只给连接串,自动探测驱动(默认 openGauss 方言) |
| `AppDbContext(datasource)` | 构造:直接给驱动 Datasource(默认 `OpenGaussDialect`,继承 PG |
| `AppDbContext(datasource, dialect)` | 构造:自定义方言,如 `PostgreSqlDialect()` |
| `db.set<T>()` | 获取实体的 DbSet(同类型复用同一实例;宏展开的 prop 内部即调用它) |
| `db.pendingCount()` | 待提交变更条数 |
| `db.saveChanges()` | 事务内按入队顺序执行全部变更,返回影响条数 |
| `db.getDialect()` | 当前方言(子类/应用可读取) |
| `db.migrate(migrations)` | 应用指定迁移(委托 `Migrator`),返回本次应用数量 |
| `db.migrate()` | 便捷版:应用子类 `migrations()` 提供的全部迁移 |
| `db.databaseExists()` | 数据库是否存在(连系统库参数化查询,需带连接串构造;sqlite 查文件) |
| `db.hasPendingMigrations()` | 是否存在未应用的迁移(数据库/历史表不存在视为有待应用迁移) |
| `set<T>().add(e)` | 入队新增 |
| `set<T>().update(e)` | 入队修改(UPDATE 全部非主键列) |
| `set<T>().remove(e)` | 入队删除 |
| `set<T>().find(key)` | 按主键查(`?T` |
| `set<T>().toList()` | 全表查询 |
| `set<T>().count()` | 全表计数 |
| `set<T>().query()` | 构建查询 |
> **语义说明(v1,操作队列模式)**`add/update/remove` 只入队,`saveChanges` 时开启事务按入队顺序逐条执行后清空队列。与 EF Core 的 identity map 不同,同一实体重复入队会重复执行(如先 `add` 再 `update` = INSERT + UPDATE),请勿对同一实体的同一种操作重复调用。
### 4. 查询构建器 — QueryBuilder<T>(对齐 EF Core IQueryable 常用子集)
```cangjie
let qb = db.users.query() // 宏方式直接 db.users;等价 db.set<User>().query()
qb.filter("_age", ">", Int64(18)) // (属性名, 操作符, 值):自动映射列名
let ps = ArrayList<Any>() // (SQL 片段, 参数):按 ? 顺序,写原始列名
ps.add(Int64(18))
ps.add(true)
qb.filter("age > ? AND active = ?", ps)
qb.orderBy("_name").orderByDesc("_age") // 排序
qb.skip(10).take(5) // 分页(LIMIT/OFFSET
qb.toList() // ArrayList<User>
qb.first() // ?User
qb.count() // Int64,满足条件的总数
qb.page(2, 10) // PagedResult<User>items/total/page/pageSize/totalPages
```
> 注意:`where` 是仓颉关键字,条件方法命名为 **`filter`**(两参原始片段 / 三参属性映射两种重载)。
### 5. 迁移(对齐 EF Core Migrations
- `Migration` 基类:`super("20250701000000_InitialCreate", "描述")`,实现 `up` / `down`
- `MigrationBuilder``createTable` / `dropTable` / `addColumn` / `dropColumn` / `alterColumn` / `renameColumn` / `createIndex` / `dropIndex` / `rawSql`
- `ColumnDefinition` 链式:`.primary()` `.autoInc()` `.notNull()` `.withUnique()` `.withMaxLength(n)` `.withDefault(value)`
- `ColumnTypes`(定义于 `simcu::orm.sql`):`BigIntCol`openGauss 自增 → BIGSERIAL/ `IntCol`(自增 → SERIAL/ `SmallIntCol` / `TinyIntCol` / `TextCol`VARCHAR,默认 255/ `BoolCol` / `FloatCol`DOUBLE PRECISION/ `RealCol` / `DateTimeCol` / `DecimalCol`DECIMAL(18,6)/ `BinaryCol`(BYTEA);实际 DDL 映射由方言 `columnTypeSql` 决定;
- `Migrator``pending(migrations)` 预览未执行项;`migrate(migrations)` 事务内按 `migrationId` 字典序应用未执行项并写入历史表 `simcu_orm_migrations`
---
## 运行测试
```bash
cjpm test # 纯逻辑单元测试(模型映射/SQL 生成/DDL 生成/QueryBuilder),无需数据库
```
---
## 已知限制(v1
- **cjc 1.1.3 编译器缺陷(重要)**:类上带 `@Table[...]` 注解时,若成员变量带 ≥2 个注解且用 `= ""` 做空字符串初始化(如 `@Required @MaxLength[100] public var _x: String = ""`),编译报 `expected expression after '=', found '<EOF>'`。规避:默认值改用 `= String()` 或直接省略初始化器。`orm-cj` 包内代码与 e2e 示例均按此规避写法;
- **仅单主键**;不支持复合主键;
- **不支持 Option 字段**`?String` 等请改用具体标量类型)与**父类字段**(不扫描继承字段);
- 不支持导航属性 / 延迟加载 / 级联删除(对齐 EF Core 这些能力属于 v2+);
- 不支持 LINQ 表达式树,查询以 `filter` 条件方法 + SQL 片段组合;
- `QueryBuilder` 的 SQL 片段形式(`filter(condition, params)`)需自行保证列名合法(会做标识符引号包裹校验外的处理)——推荐优先使用三参属性形式;
- 自增主键回读依赖驱动 `RETURNING` 支持(openGauss/PostgreSQL 原生支持;换方言时由 `ISqlDialect.buildInsert` 决定回读策略)。
---
## 依赖
| 依赖 | 用途 |
|------|------|
| 数据库驱动(使用者引入,如 `opengauss``../opengauss-driver` | 实现 `std.database.sql``Datasource`/`Connection`/`Statement` |
| `stdx`CANGJIE_STDX_PATH | 标准扩展库(std.database.sql 接口) |
> orm-cj 包本身零依赖:连接层走 `std.database.sql` 标准接口,SQL/DDL 生成走 `ISqlDialect` 方言接口。新数据库接入 = 实现 `ISqlDialect`CRUD + 列类型映射 + 差异 DDL 语句)+ 提供对应 `std.database.sql` 驱动。
---
## 许可证
MIT
+3
View File
@@ -0,0 +1,3 @@
version = 0
[requires]
+22
View File
@@ -0,0 +1,22 @@
[package]
cjc-version = "1.1.3"
name = "orm"
organization = "simcu"
description = "SimApi 数据访问层 ORM(对齐 .NET EF Core:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言)"
version = "1.0.0"
target-dir = ""
output-type = "static"
[target]
[target.x86_64-w64-mingw32]
compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated"
[target.x86_64-w64-mingw32.bin-dependencies]
path-option = [ "${CANGJIE_STDX_PATH}" ]
[target.x86_64-unknown-linux-gnu]
compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated"
[target.x86_64-unknown-linux-gnu.bin-dependencies]
path-option = [ "${CANGJIE_STDX_PATH}" ]
[target.aarch64-unknown-linux-gnu]
compile-option = "-Woff unused --diagnostic-format=noColor -Woff deprecated"
[target.aarch64-unknown-linux-gnu.bin-dependencies]
path-option = [ "${CANGJIE_STDX_PATH}" ]
+15
View File
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* Orm 聚合导出:使用 simcu::orm 包只需 import simcu::orm.*
*/
package simcu::orm
public import simcu::orm.annotations.*
public import simcu::orm.model.*
public import simcu::orm.tracking.*
public import simcu::orm.sql.*
public import simcu::orm.query.*
public import simcu::orm.db.*
public import simcu::orm.migrations.*
public import simcu::orm.cli.*
+93
View File
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* ORM 注解(对齐 .NET EF Core 数据注解特性)
* - [Table("xxx")] → @Table["xxx"] 指定表名(target: Type)
* - [Column("xxx")] → @Column["xxx"] 指定列名(target: MemberVariable)
* - [Key] → @Key 标记主键
* - [DatabaseGenerated(...)] → @AutoIncrement 整型主键自增
* - [NotMapped] → @Ignore 不映射该字段
* - [Required] → @Required 非空列
* - [MaxLength(n)] → @MaxLength[Int64] 字符串列最大长度
*
* 说明:类级注解的 target 是 AnnotationKind.TypeCangjie 的注解 target 枚举),
* 不是 Java/.NET 的 Class。
*/
package simcu::orm.annotations
/**
* 指定实体对应的表名(对齐 .NET TableAttribute)。
* 用法:@Table["users"] public class User { ... }
*/
@Annotation[target: [Type]]
public class Table {
public let name: String
public const init(name: String) {
this.name = name
}
}
/**
* 指定字段对应的列名(对齐 .NET ColumnAttribute)。
* 用法:@Column["user_name"] public var _username: String = ""
*/
@Annotation[target: [MemberVariable]]
public class Column {
public let name: String
public const init(name: String) {
this.name = name
}
}
/**
* 标记字段为主键(对齐 .NET KeyAttribute)。
* 未标注时,名为 _id 的字段默认为主键。
* 用法:@Key public var _id: Int64 = 0
*/
@Annotation[target: [MemberVariable]]
public class Key {
public const init() {}
}
/**
* 标记整型主键自增(对齐 .NET DatabaseGeneratedOption.Identity)。
* 名为 _id 的 Int64/Int32 主键默认自增,无需标注。
* 用法:@AutoIncrement public var _id: Int64 = 0
*/
@Annotation[target: [MemberVariable]]
public class AutoIncrement {
public const init() {}
}
/**
* 标记字段不映射到列(对齐 .NET NotMappedAttribute)。
* 用法:@Ignore public var _temp: String = ""
*/
@Annotation[target: [MemberVariable]]
public class Ignore {
public const init() {}
}
/**
* 标记列为非空(对齐 .NET RequiredAttribute),仅影响建表 DDL。
* 用法:@Required public var _name: String = ""
*/
@Annotation[target: [MemberVariable]]
public class Required {
public const init() {}
}
/**
* 指定字符串列最大长度(对齐 .NET MaxLengthAttribute),仅影响建表 DDL。
* 用法:@MaxLength[100] public var _name: String = ""
*/
@Annotation[target: [MemberVariable]]
public class MaxLength {
public let maxLength: Int64
public const init(maxLength: Int64) {
this.maxLength = maxLength
}
}
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* CLI 上下文契约:MigrationCli 只依赖本接口,不依赖 simcu::orm.db
* 从而避免 db → cli 与 cli → db 的包循环依赖。
*
* DbContext 实现本接口(DbContext.cli(args) 内部把 this 传给 MigrationCli.runOn)。
*/
package simcu::orm.cli
import std.collection.*
import std.database.sql.*
import simcu::orm.migrations.*
import simcu::orm.model.*
import simcu::orm.sql.*
public interface CliContext {
/// 模型列表(由实例的 DbSet<T> 属性反射收集)
func getModels(): ArrayList<EntityModel>
/// 应用主数据源
func getDatasource(): Datasource
/// SQL 方言
func getDialect(): ISqlDialect
/// 迁移列表(子类 override migrations() 提供)
func getMigrations(): ArrayList<Migration>
/// 应用所有迁移
func migrateAll(migrations: ArrayList<Migration>): Int64
}
+401
View File
@@ -0,0 +1,401 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 迁移 CLI(应用内驱动,对齐 dotnet ef 的命令体验)。
*
* 基于 DbContext 实例的一行接入(无需宏、无需回调):
* main(args: Array<String>) {
* let db = DataContext("pgsql", "Host=..;Database=..;Username=..;Password=..")
* if (db.cli(args)) { return }
* // 正常启动
* }
*
* 数据来源全部来自 DbContext 实例:
* - 模型:反射收集实例的 DbSet<T> 属性(getModels())
* - 连接/驱动/方言:构造参数
* - 迁移列表:子类 override migrations()(宏默认返回 orm-cj 全局注册表,
* CLI 生成的 src/migrations/MigrationRegistry.cj 在模块加载时自动注册)
*
* 注意:应用侧(DataContext.cj 或 main.cj)需要一行
* import <应用包名>.migrations.*
* 仅 import 即可触发 migrations 子包加载,注册文件(子包内)的顶层 let
* 才会在 main() 前执行并注册迁移;新项目需先建 src/migrations/ 目录(空包)。
*
* 命令:
* add <迁移名> 模型 → 迁移 .cj 文件(落盘) + 更新快照 + 更新迁移注册文件
* rm <迁移名/id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退到前一状态)
* update 应用所有未执行的迁移
* downgrade [目标] 回退迁移(执行 down;无目标=回退最近一个,有目标=回退到该迁移之后)
* list 列出已应用/待应用迁移
* help 显示用法
*/
package simcu::orm.cli
import std.collection.*
import std.database.sql.*
import std.fs.*
import std.time.*
import simcu::orm.migrations.*
import simcu::orm.model.*
import simcu::orm.sql.*
/**
* 迁移 CLI 入口。
*/
public class MigrationCli {
private var _migrationsDir = "src/migrations"
private var _snapshotPath = "src/migrations/snapshot.json"
// 注册文件与迁移文件同目录(src/migrations/,package = 应用包名.migrations)。
// 子包默认惰性加载,顶层 let 不会执行;应用侧需一行
// `import <应用包名>.migrations.*` 触发加载后,注册才生效。
private var _registryPath = "src/migrations/MigrationRegistry.cj"
public init() {}
/// 基于 CliContext 实例执行 CLI(DbContext.cli(args) 的内部实现)。
/// 模型/连接/方言/迁移全部来自 ctx。
public static func runOn(ctx: CliContext, args: ArrayList<String>): Int64 {
let cli = MigrationCli()
cli.dispatch(args, { => ctx.getModels() },
{ => ctx.getDatasource() }, { => ctx.getMigrations() }, ctx.getDialect())
}
/// 低层入口:无条件执行 CLI(命令:add/rm/update/downgrade/list/help)。
/// 应用侧推荐直接用 DbContext.cli(args) 一行接入。
public static func run(args: ArrayList<String>, models: () -> ArrayList<EntityModel>,
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>): Int64 {
let cli = MigrationCli()
cli.dispatch(args, models, datasource, migrations, OpenGaussDialect())
}
/// 应用入口一行接入:`cjpm run -- orm <命令>` 时执行 CLI 并返回 true;
/// 否则(正常启动)返回 false。用法:main() 最上方一行,如
/// if (MigrationCli.tryRun(ArrayList<String>(args), { => models() },
/// { => datasource() }, { => migrations()})) { return }
public static func tryRun(args: ArrayList<String>, models: () -> ArrayList<EntityModel>,
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>): Bool {
if (args.size == 0 || args[0] != "orm") {
return false
}
let cli = MigrationCli()
cli.dispatch(args[1..], models, datasource, migrations, OpenGaussDialect())
true
}
// ---------- 命令分发 ----------
private func dispatch(args: ArrayList<String>, models: () -> ArrayList<EntityModel>,
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect): Int64 {
if (args.size == 0) {
printHelp()
return 0
}
match (args[0]) {
case "add" => cmdAdd(args, models)
case "rm" => cmdRm(args)
case "update" => cmdUpdate(datasource, migrations, dialect)
case "downgrade" => cmdDowngrade(datasource, migrations, dialect, args)
case "list" => cmdList(datasource, migrations, dialect)
case "help" | "-h" | "--help" => printHelp(); 0
case _ =>
println("simorm: 未知命令 '${args[0]}',输入 'simorm help' 查看用法")
1
}
}
/// add <迁移名>:模型 → 迁移文件 + 快照 + 注册
private func cmdAdd(args: ArrayList<String>, models: () -> ArrayList<EntityModel>): Int64 {
if (args.size < 2) {
println("用法: simorm add <迁移名>")
return 1
}
let name = args[1]
if (!isValidIdentifier(name)) {
println("simorm: 迁移名 '${name}' 不是合法标识符(字母/数字/下划线,首字符不能是数字)")
return 1
}
// 迁移名以 test 结尾会生成 *_test.cj 文件,cjc 会把 *_test.cj 当作测试文件,
// 在 cjpm build(非 test)时排除,导致注册文件引用不到该类而编译失败。
if (name.endsWith("test")) {
println("simorm: 迁移名 '${name}' 以 test 结尾,生成的 *_test.cj 会被编译器当作测试文件排除;请换一个名称")
return 1
}
let appPkg = detectPackage()
let ts = DateTime.now().format("yyyyMMddHHmmss")
let id = "${ts}_${name}"
let gen = MigrationGenerator()
let current = models()
var m: Migration
if (let Some(snap) <- ModelSnapshot.load(_snapshotPath)) {
m = gen.diff(id, name, snap.toModels(), current)
} else {
m = gen.initial(id, name, current)
}
// 无变化检测(对齐 dotnet ef:模型未变则不生成)
let probe = MigrationBuilder()
m.up(probe)
if (probe.getOperations().size == 0) {
println("simorm: 未检测到模型变化,快照已是最新,跳过生成")
return 0
}
// 迁移文件与注册文件都落在 src/migrations 子目录(package = <appPkg>.migrations):
// 注册文件在子包内,顶层 let 引用同包迁移类,无需 import;
// 应用侧一行 `import <appPkg>.migrations.*` 触发子包加载后注册生效。
let migPkg = "${appPkg}.migrations"
let fg = MigrationFileGenerator()
let path = fg.writeMigration(migPkg, m, _migrationsDir)
fg.updateRegistry(migPkg, _registryPath, MigrationFileGenerator.classNameOf(id))
ModelSnapshot.capture(current).save(_snapshotPath)
println("simorm: 已生成迁移 ${id}")
println(" 迁移文件: ${path}")
println(" 注册文件: ${_registryPath}")
println(" 快照: ${_snapshotPath}")
println("执行 'simorm update' 应用该迁移")
0
}
/// update:应用所有未执行的迁移
private func cmdUpdate(datasource: () -> Datasource,
migrations: () -> ArrayList<Migration>, dialect: ISqlDialect): Int64 {
let list = migrations()
if (list.size == 0) {
println("simorm: 未注册任何迁移(检查 ${_registryPath})")
return 1
}
let migrator = Migrator(datasource(), dialect)
let n = migrator.migrate(list)
println("simorm: 已应用 ${n} 个迁移")
0
}
/// downgrade [目标]:回退迁移(执行 down + 删除历史记录;无目标 = 回退最近一个)
private func cmdDowngrade(datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect, args: ArrayList<String>): Int64 {
let list = migrations()
if (list.size == 0) {
println("simorm: 未注册任何迁移(检查 ${_registryPath})")
return 1
}
let target: ?String = if (args.size >= 2) { Some(args[1]) } else { None }
try {
let migrator = Migrator(datasource(), dialect)
let n = migrator.revert(list, target)
if (n == 0) {
println("simorm: 没有可回退的迁移(已处于${if (target.isSome()) { "目标" } else { "最初" }}状态)")
} else {
println("simorm: 已回退 ${n} 个迁移")
}
0
} catch (e: Exception) {
println("simorm: 回退迁移失败")
println(" ${e.message}")
1
}
}
/// rm <迁移名或id>:移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)
private func cmdRm(args: ArrayList<String>): Int64 {
if (args.size < 2) {
println("用法: simorm rm <迁移名或id>")
return 1
}
let target = args[1]
let dir = Path(_migrationsDir)
if (!exists(dir)) {
println("simorm: 迁移目录不存在(${_migrationsDir}),没有可移除的迁移")
return 1
}
// 扫描迁移目录,收集迁移 id(文件名去 .cj;排除注册文件/占位文件)
let ids = ArrayList<String>()
for (f in Directory.readFrom(dir)) {
if (f.isRegular()) {
let name = f.name
if (name.endsWith(".cj") && name != "MigrationRegistry.cj" && name != "_placeholder.cj") {
ids.add(name[..name.size - 3])
}
}
}
if (ids.size == 0) {
println("simorm: ${_migrationsDir} 下没有迁移文件")
return 1
}
// 匹配:精确(migrationId 或 类名)+ 唯一前缀(migrationId)
var hit: ?String = None
let prefixHits = ArrayList<String>()
for (id in ids) {
if (id == target || MigrationFileGenerator.classNameOf(id) == target) {
hit = Some(id)
} else if (id.startsWith(target)) {
prefixHits.add(id)
}
}
if (hit.isNone() && prefixHits.size == 1) {
hit = Some(prefixHits[0])
}
if (let Some(id) <- hit) {
// 1) 删除迁移文件
let fp = dir.join(MigrationFileGenerator.fileNameOf(id))
if (exists(fp)) {
remove(fp)
}
// 2) 从注册文件移除条目(重写注册文件)
let appPkg = detectPackage()
let migPkg = "${appPkg}.migrations"
let remaining = ArrayList<String>()
if (exists(Path(_registryPath))) {
let text = String.fromUtf8(File.readFrom(Path(_registryPath)))
for (e in MigrationFileGenerator.extractRegistryEntries(text)) {
if (e != MigrationFileGenerator.classNameOf(id)) {
remaining.add(e)
}
}
let code = MigrationFileGenerator.registrySource(migPkg, remaining)
remove(Path(_registryPath))
let f = File.create(Path(_registryPath))
f.write(code.toArray())
f.close()
}
// 3) 快照:注册表为空则重置快照(下次 add 从模型重新生成 initial)
if (remaining.size == 0) {
let sp = Path(_snapshotPath)
if (exists(sp)) {
remove(sp)
}
println("simorm: 已移除迁移 ${id}(注册表已空,快照已重置)")
} else {
println("simorm: 已移除迁移 ${id}")
}
return 0
}
println("simorm: 未找到迁移 '${target}'(${_migrationsDir} 下无匹配)")
1
}
/// list:显示已应用/待应用迁移
private func cmdList(datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect): Int64 {
let list = migrations()
if (list.size == 0) {
println("simorm: 未注册任何迁移(检查 ${_registryPath})")
return 1
}
try {
let migrator = Migrator(datasource(), dialect)
let applied = migrator.appliedMigrationIds()
println("simorm: 迁移列表(共 ${list.size} 个)")
for (m in list) {
let mark = if (applied.contains(m.migrationId)) { "[已应用]" } else { "[待应用]" }
println(" ${mark} ${m.migrationId} ${m.description}")
}
0
} catch (e: Exception) {
println("simorm: 读取迁移历史失败(数据库未创建?)")
println(" ${e.message}")
1
}
}
// ---------- 私有工具 ----------
/// 从应用根目录 cjpm.toml 推断包名(organization::name 或 name)
private func detectPackage(): String {
let p = Path("cjpm.toml")
if (!exists(p)) {
throw Exception("simorm: 未找到 cjpm.toml,请在应用根目录运行 add 命令")
}
let text = String.fromUtf8(File.readFrom(p))
var name = ""
var org = ""
for (raw in text.split("\n")) {
let line = trimSpaces(raw)
if (name.isEmpty()) {
name = tomlValue(line, "name")
}
if (org.isEmpty()) {
org = tomlValue(line, "organization")
}
}
if (name.isEmpty()) {
throw Exception("simorm: cjpm.toml 缺少 [package] name 字段")
}
if (org.isEmpty()) {
return name
}
"${org}::${name}"
}
/// 解析 TOML 简单值(name = "xxx"),行需已 trim;返回引号内字符串,失败返回 ""
private static func tomlValue(line: String, key: String): String {
if (!line.startsWith("${key} =")) {
return ""
}
let eq = line.indexOf("=").getOrThrow()
var rest = trimSpaces(line[eq + 1..])
if (rest.isEmpty() || rest[0] != UInt8(0x22)) {
return ""
}
var i: Int64 = 1
let bytes = rest.toArray()
while (i < bytes.size) {
if (bytes[i] == 0x22) {
return rest[1..i]
}
i += 1
}
""
}
/// 去除字符串首尾空白(空格/Tab/CR/LF)
private static func trimSpaces(s: String): String {
let bytes = s.toArray()
var start: Int64 = 0
var end = bytes.size
while (start < end && isSpace(bytes[start])) {
start += 1
}
while (end > start && isSpace(bytes[end - 1])) {
end -= 1
}
s[start..end]
}
private static func isSpace(b: UInt8): Bool {
b == 0x20 || b == 0x09 || b == 0x0D || b == 0x0A
}
/// 合法标识符:字母/数字/下划线,首字符不能是数字
private static func isValidIdentifier(s: String): Bool {
if (s.isEmpty()) {
return false
}
let bytes = s.toArray()
var i: Int64 = 0
while (i < bytes.size) {
let b = bytes[i]
let isDigit = b >= 0x30 && b <= 0x39
let isLetter = (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A)
let isUnderscore = b == 0x5F
if (i == 0 && isDigit) {
return false
}
if (!isDigit && !isLetter && !isUnderscore) {
return false
}
i += 1
}
true
}
private static func printHelp(): Unit {
println("simorm - orm-cj 迁移 CLI(对齐 dotnet ef)")
println("")
println("用法:")
println(" <app> add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)")
println(" <app> rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)")
println(" <app> update 应用所有未执行的迁移")
println(" <app> downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后")
println(" <app> list 列出已应用/待应用迁移")
println(" <app> help 显示本帮助")
}
}
+353
View File
@@ -0,0 +1,353 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 迁移落盘生成器(对齐 dotnet ef migrations add):
* - Migration 对象 → .cj 源文件文本(迁移类 up/down 用 MigrationBuilder 声明式重建),
* - 自动维护迁移注册文件 MigrationRegistry.cj(add 时插入新迁移实例)。
*
* 生成文件的格式:
* // 此文件由 simorm CLI 自动生成,请勿手动编辑
* package app
*
* import simcu::orm.migrations.*
* import simcu::orm.sql.*
*
* 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()
* }
* }
* public override func down(builder: MigrationBuilder): Unit {
* builder.dropTable("users")
* }
* }
*/
package simcu::orm.cli
import std.collection.*
import std.fs.*
import simcu::orm.migrations.*
import simcu::orm.sql.*
/**
* 迁移源码生成器:操作列表 → Cangjie 源码文本 + 注册文件维护。
*/
public class MigrationFileGenerator {
public init() {}
/// 迁移 → .cj 源文件写入 outDir,返回写入的完整文件路径
public func writeMigration(appPackage: String, m: Migration, outDir: String): String {
let className = classNameOf(m.migrationId)
let code = migrationSource(appPackage, className, m)
let dir = Path(outDir)
if (!exists(dir)) {
Directory.create(dir, recursive: true)
}
let path = dir.join(fileNameOf(m.migrationId))
if (exists(path)) {
remove(path)
}
let f = File.create(path)
f.write(code.toArray())
f.close()
path.toString()
}
/// 迁移 → .cj 源码文本(不落盘,供测试/预览)
public static func migrationSource(appPackage: String, className: String, m: Migration): String {
if (appPackage.isEmpty()) {
throw Exception("simorm: 应用包名不能为空,请在应用根目录(含 cjpm.toml)运行")
}
let sb = StringBuilder()
sb.append("// 此文件由 simorm CLI 自动生成,请勿手动编辑\n")
sb.append("package ${appPackage}\n\n")
sb.append("import std.collection.*\n")
sb.append("import simcu::orm.migrations.*\n")
sb.append("import simcu::orm.sql.*\n\n")
sb.append("public class ${className} <: Migration {\n")
sb.append(" public init() {\n")
sb.append(" super(\"${escape(m.migrationId)}\", \"${escape(m.description)}\")\n")
sb.append(" }\n\n")
sb.append(" public override func up(builder: MigrationBuilder): Unit {\n")
appendOps(sb, m, true)
sb.append(" }\n\n")
sb.append(" public override func down(builder: MigrationBuilder): Unit {\n")
appendOps(sb, m, false)
sb.append(" }\n")
sb.append("}\n")
sb.toString()
}
/// migrationId "20250701000000_InitialCreate" → 类名 "InitialCreate"
public static func classNameOf(migrationId: String): String {
if (let Some(idx) <- migrationId.lastIndexOf("_")) {
return migrationId[idx + 1..]
}
migrationId
}
/// migrationId → 文件名 "20250701000000_InitialCreate.cj"
public static func fileNameOf(migrationId: String): String {
"${migrationId}.cj"
}
/// 重写注册文件:旧文件(若存在)提取已有条目 + 追加新类名。
/// 新格式:每条顶层 let 在模块初始化时自动注册到 orm-cj 全局注册表。
/// 注意:migrationPackage 必须与迁移文件同包(应用包名.migrations):
/// 注册文件与迁移类同包,顶层 let 直接引用类名,无需额外 import;
/// 子包默认惰性加载,应用侧需一行 `import <应用包名>.migrations.*`
/// 触发加载后,顶层 let 才会执行。
/// 返回注册文件源码文本
public static func registrySource(migrationPackage: String, entries: ArrayList<String>): String {
let sb = StringBuilder()
sb.append("// 此文件由 simorm CLI 自动维护,请勿手动编辑\n")
sb.append("// 与迁移类同包(${migrationPackage}),模块加载时顶层 let 自动注册\n")
sb.append("// 迁移到 simcu::orm 全局注册表(按 migrationId 字典序应用)。\n")
sb.append("package ${migrationPackage}\n\n")
sb.append("import std.collection.*\n")
sb.append("import simcu::orm.migrations.*\n\n")
sb.append("/// 模块初始化时自动注册迁移到 simcu::orm 全局注册表(按 migrationId 字典序应用)\n")
var i: Int64 = 0
for (e in entries) {
sb.append("let _migrationRegistration${i} = registerMigration(${e}())\n")
i += 1
}
sb.toString()
}
/// 从旧注册文件文本提取已注册的迁移类名(按出现顺序去重)。
/// 解析 `let _r0 = registerMigration(Xxx())` 中的类名。
public static func extractRegistryEntries(text: String): ArrayList<String> {
let result = ArrayList<String>()
let bytes = text.toArray()
let marker = "registerMigration(".toArray()
var pos: Int64 = 0
while (true) {
let hit = indexOfBytes(bytes, marker, pos)
if (hit < 0) {
break
}
let nameStart = hit + marker.size
var end = nameStart
while (end < bytes.size && isIdentChar(bytes[end])) {
end += 1
}
let name = text[nameStart..end]
if (!name.isEmpty() && !result.contains(name)) {
result.add(name)
}
pos = end
}
result
}
/// 标识符字符:字母/数字/下划线
private static func isIdentChar(b: UInt8): Bool {
(b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) || b == 0x5F
}
/// 追加注册:旧文件 + 新类名 → 完整注册文件源码(已写新文件并返回路径)
public func updateRegistry(appPackage: String, registryPath: String, newClassName: String): String {
var entries = ArrayList<String>()
let p = Path(registryPath)
if (exists(p)) {
let text = String.fromUtf8(File.readFrom(p))
for (e in extractRegistryEntries(text)) {
entries.add(e)
}
}
if (!entries.contains(newClassName)) {
entries.add(newClassName)
}
let code = registrySource(appPackage, entries)
let dir = parentDirOf(registryPath)
let dirPath = Path(dir)
if (!exists(dirPath)) {
Directory.create(dirPath, recursive: true)
}
if (exists(p)) {
remove(p)
}
let f = File.create(p)
f.write(code.toArray())
f.close()
registryPath
}
// ---------- 私有 ----------
private static func appendOps(sb: StringBuilder, m: Migration, isUp: Bool): Unit {
let b = MigrationBuilder()
if (isUp) {
m.up(b)
} else {
m.down(b)
}
for (op in b.getOperations()) {
appendOp(sb, op, " ")
}
}
private static func appendOp(sb: StringBuilder, op: MigrationOperation, indent: String): Unit {
match (op.kind) {
case MigrationOperationKind.CreateTable =>
sb.append("${indent}builder.createTable(\"${escape(op.tableName)}\") { tb =>\n")
for (c in op.columnDefs) {
sb.append("${indent} tb.column(\"${escape(c.name)}\", ColumnTypes.${columnTypeName(c.columnType)})")
sb.append("${columnChainSuffix(c)}\n")
}
sb.append("${indent}}\n")
case MigrationOperationKind.DropTable =>
sb.append("${indent}builder.dropTable(\"${escape(op.tableName)}\")\n")
case MigrationOperationKind.AddColumn =>
sb.append("${indent}builder.addColumn(\"${escape(op.tableName)}\", ${columnDefExpr(op.column.getOrThrow())})\n")
case MigrationOperationKind.DropColumn =>
sb.append("${indent}builder.dropColumn(\"${escape(op.tableName)}\", \"${escape(op.columnName)}\")\n")
case MigrationOperationKind.AlterColumn =>
sb.append("${indent}builder.alterColumn(\"${escape(op.tableName)}\", ${columnDefExpr(op.column.getOrThrow())})\n")
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)}]")
if (op.unique) {
sb.append(", true")
}
sb.append(")\n")
case MigrationOperationKind.DropIndex =>
sb.append("${indent}builder.dropIndex(\"${escape(op.indexName)}\", \"${escape(op.tableName)}\")\n")
case MigrationOperationKind.RawSql =>
sb.append("${indent}builder.rawSql(\"${escape(op.sql)}\")\n")
}
}
/// ColumnDefinition → 内联表达式: ColumnDefinition("name", ColumnTypes.X).primary().notNull()...
private static func columnDefExpr(c: ColumnDefinition): String {
var s = "ColumnDefinition(\"${escape(c.name)}\", ColumnTypes.${columnTypeName(c.columnType)})"
s = s + columnChainSuffix(c)
s
}
/// 链式标记后缀(不生成默认值字面量,遇到则抛异常提示手写)
private static func columnChainSuffix(c: ColumnDefinition): String {
var s = ""
if (c.primaryKey) {
s = s + ".primary()"
}
if (c.autoIncrement) {
s = s + ".autoInc()"
}
if (!c.nullable) {
s = s + ".notNull()"
}
if (c.unique) {
s = s + ".withUnique()"
}
if (c.maxLength != 255) {
s = s + ".withMaxLength(${c.maxLength})"
}
if (let Some(_) <- c.defaultValue) {
throw Exception(
"simorm: CLI 生成的迁移不支持带默认值的列,请改用手写迁移(override up/down)")
}
s
}
private static func columnTypeName(t: ColumnTypes): String {
match (t) {
case ColumnTypes.BigIntCol => "BigIntCol"
case ColumnTypes.IntCol => "IntCol"
case ColumnTypes.SmallIntCol => "SmallIntCol"
case ColumnTypes.TinyIntCol => "TinyIntCol"
case ColumnTypes.TextCol => "TextCol"
case ColumnTypes.BoolCol => "BoolCol"
case ColumnTypes.FloatCol => "FloatCol"
case ColumnTypes.RealCol => "RealCol"
case ColumnTypes.DateTimeCol => "DateTimeCol"
case ColumnTypes.DecimalCol => "DecimalCol"
case ColumnTypes.BinaryCol => "BinaryCol"
}
}
private static func colList(cols: ArrayList<String>): String {
var sb = StringBuilder()
var first = true
for (c in cols) {
if (!first) {
sb.append(", ")
}
sb.append("\"${escape(c)}\"")
first = false
}
sb.toString()
}
/// 生成 Cangjie 字符串字面量转义
private static func escape(s: String): String {
var sb = StringBuilder()
for (c in s.runes()) {
if (c == Rune(0x5C)) {
sb.append("\\\\")
} else if (c == Rune(0x22)) {
sb.append("\\\"")
} 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 {
sb.append(c)
}
}
sb.toString()
}
/// 取路径的父目录(字符串级,兼容 "/" 与 "\");无分隔符返回 "."
private static func parentDirOf(path: String): String {
let s = match (path.lastIndexOf("/")) {
case Some(i) => i
case None => -1
}
let b = match (path.lastIndexOf("\\")) {
case Some(i) => i
case None => -1
}
let idx = if (s >= 0 && b >= 0) {
if (s > b) { s } else { b }
} else if (s >= 0) {
s
} else {
b
}
if (idx > 0) {
return path[..idx]
}
"."
}
/// 字节数组子串查找(返回起始字节索引,-1 表示未找到)
private static func indexOfBytes(hay: Array<UInt8>, needle: Array<UInt8>, from: Int64): Int64 {
if (needle.size == 0 || from > hay.size - needle.size) {
return -1
}
var i = from
while (i <= hay.size - needle.size) {
var j: Int64 = 0
var ok = true
while (j < needle.size) {
if (hay[i + j] != needle[j]) {
ok = false
break
}
j += 1
}
if (ok) {
return i
}
i += 1
}
-1
}
}
+114
View File
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 连接串 → Datasource 工厂:把"驱动与配置构建"收敛到 orm-cj,应用侧只需传连接字符串。
*
* 支持两种连接串格式:
* 1. URL 格式(推荐):postgres://user:pass@host:port/db?sslmode=disableopengauss:// 亦可)
* 2. ADO.NET 风格:Host=..;Port=..;Database=..;Username=..;Password=..(自动转 URL
*
* 驱动通过 std.database.sql.DriverManager 查找:应用只需引入并链接驱动包
* (如 opengauss-driver,其模块加载时自动注册 "postgres"/"opengauss"),
* orm-cj 本身不依赖任何具体驱动实现。
*/
package simcu::orm.db
import std.database.sql.*
/**
* 连接串 → Datasource 工厂(对齐 .NET NpgsqlDataSource.Create / EF Core UseNpgsql(connStr))。
*/
public class DatasourceFactory {
/// 默认驱动名(ADO 风格连接串无 scheme 时使用)
public static let DEFAULT_DRIVER = "postgres"
/**
* 由连接字符串创建 Datasource。驱动名自动推断:
* URL 格式按 schemepostgres/postgresql/pgsql/pg → postgresopengauss → opengauss);
* ADO 风格默认 postgres。
*/
public static func create(connStr: String): Datasource {
build(detectDriver(connStr, DEFAULT_DRIVER), toUrl(connStr))
}
/**
* 由连接字符串创建 Datasource,可显式指定驱动名(如 "pgsql"/"postgres"/"opengauss")。
* 显式驱动名优先于连接串内 scheme。
*/
public static func create(connStr: String, driverName: String): Datasource {
build(normalizeDriverName(driverName), toUrl(connStr))
}
private static func build(name: String, url: String): Datasource {
let driver = DriverManager.getDriver(name) ??
throw Exception(
"simorm: 未注册数据库驱动 '${name}'。请确认应用已引入并链接对应驱动包" +
"(如 opengauss-driver,其加载时自动注册 postgres/opengauss")
driver.open(url)
}
/**
* 从连接串推断驱动注册名。
*/
public static func detectDriver(connStr: String, defaultDriver: String): String {
if (connStr.contains("://")) {
let i = connStr.indexOf("://").getOrThrow()
let scheme = connStr[0..i].toAsciiLower()
match (scheme) {
case "postgresql" | "pgsql" | "pg" => "postgres"
case _ => scheme
}
} else {
normalizeDriverName(defaultDriver)
}
}
/**
* 驱动名别名归一化到 DriverManager 注册名。
*/
public static func normalizeDriverName(name: String): String {
match (name.trimAscii().toAsciiLower()) {
case "pgsql" | "pg" | "postgresql" => "postgres"
case _ => name.trimAscii()
}
}
/**
* 统一为驱动可解析的 URL 格式。
* ADO.NET 风格(.NET Npgsql 连接串)示例:
* Host=192.168.0.2;Port=5432;Database=lemon-lurmix;Username=postgres;Password=Love@1314
* → postgres://postgres:Love@1314@192.168.0.2:5432/lemon-lurmix?sslmode=disable
* 已是 URL(含 "://")则原样返回。
*/
public static func toUrl(connStr: String): String {
if (connStr.contains("://")) {
return connStr
}
var host = ""
var port = "5432"
var database = ""
var username = ""
var password = ""
for (part in connStr.split(";")) {
let seg = part.trimAscii()
if (seg.isEmpty()) {
continue
}
match (seg.indexOf("=")) {
case Some(i) =>
let key = seg[0..i].trimAscii().toAsciiLower()
let value = seg[i + 1..].trimAscii()
match (key) {
case "host" => host = value
case "port" => port = value
case "database" => database = value
case "username" | "user id" | "uid" => username = value
case "password" | "pwd" => password = value
case _ => ()
}
case None => ()
}
}
return "${DEFAULT_DRIVER}://${username}:${password}@${host}:${port}/${database}?sslmode=disable"
}
}
+554
View File
@@ -0,0 +1,554 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 数据库上下文:连接管理 + 变更提交 + 查询物化。
*
* 对齐 EF Core 的用法:
* class AppDbContext <: DbContext {
* public init(datasource: Datasource) { super(datasource) }
* }
* let db = AppDbContext(ds)
* db.set<User>().add(user)
* db.saveChanges()
*
* 语义说明(v1,操作队列模式):add/update/remove 只入队,
* saveChanges 开启事务按入队顺序逐条执行;执行后清空队列。
*/
package simcu::orm.db
import std.collection.*
import std.database.sql.*
import std.fs.*
import std.reflect.*
import simcu::orm.cli.{CliContext, MigrationCli}
import simcu::orm.model.*
import simcu::orm.query.*
import simcu::orm.sql.*
import simcu::orm.tracking.*
import simcu::orm.migrations.{Migrator, Migration}
/**
* 数据库上下文。
*
* 基于实例的 CLI 入口(对齐 EF Core):
* main(args: Array<String>) {
* let db = DataContext("pgsql", "Host=..;Database=..;Username=..;Password=..")
* if (db.cli(args)) { return } // `app orm <命令>` 时执行 CLI 并退出
* // 正常启动
* }
* cli() 的模型来自实例的 DbSet 属性(反射收集);迁移列表来自子类
* override migrations();连接/驱动来自构造参数。
*/
public open class DbContext <: CliContext {
internal let datasource: Datasource
internal let dialect: ISqlDialect
internal let tracker = ChangeTracker()
private let _sets = HashMap<String, Any>()
private var _driverName: String
private var _connString: String
public init(datasource: Datasource, dialect: ISqlDialect) {
this.datasource = datasource
this.dialect = dialect
this._driverName = ""
this._connString = ""
}
public init(datasource: Datasource) {
this(datasource, OpenGaussDialect())
}
/// 便捷构造:由连接字符串创建 Datasource(驱动构建收敛在 DatasourceFactory
public init(connStr: String, dialect: ISqlDialect) {
this(DatasourceFactory.create(connStr), dialect)
this._driverName = DatasourceFactory.detectDriver(connStr, DatasourceFactory.DEFAULT_DRIVER)
this._connString = connStr
}
public init(connStr: String) {
this(connStr, OpenGaussDialect())
}
/// 便捷构造:显式指定驱动类型(如 "pgsql"+ 连接字符串
public init(driverName: String, connStr: String, dialect: ISqlDialect) {
this(DatasourceFactory.create(connStr, driverName), dialect)
this._driverName = DatasourceFactory.normalizeDriverName(driverName)
this._connString = connStr
}
public init(driverName: String, connStr: String) {
this(driverName, connStr, OpenGaussDialect())
}
/// 应用提供的迁移列表(子类 override;由 CLI 维护的注册文件提供实例)。
/// 默认空——add 生成迁移不依赖它,update/list/create 需要。
public open func migrations(): ArrayList<Migration> {
ArrayList()
}
/// CLI 入口:`<app> orm <命令>` 时执行迁移 CLI 并返回 true;否则返回 false(正常启动)。
/// 模型从本实例的 DbSet 属性反射收集,连接/驱动/方言来自构造参数。
public func cli(args: Array<String>): Bool {
if (args.size == 0 || args[0] != "orm") {
return false
}
let list = ArrayList<String>()
for (i in 1..args.size) {
list.add(args[i])
}
MigrationCli.runOn(this, list)
true
}
/// 反射收集本实例所有 DbSet<T> 的模型(DataContext 的 DbSet 字段/prop 均可)
public func getModels(): ArrayList<EntityModel> {
let result = ArrayList<EntityModel>()
let ti = TypeInfo.of(this)
// propgetter 形式)与 var 字段形式都要支持
for (p in ti.instanceProperties) {
if (p.typeInfo.name.startsWith("DbSet")) {
let v = p.getValue(this)
match (v as DbSetBase) {
case Some(ds) => result.add(ds.getModel())
case None => ()
}
}
}
for (v in ti.instanceVariables) {
if (v.typeInfo.name.startsWith("DbSet")) {
let value = v.getValue(this)
match (value as DbSetBase) {
case Some(ds) => result.add(ds.getModel())
case None => ()
}
}
}
result
}
/// 驱动注册名(构造时归一化;Datasource 构造无连接串时为空)
public func getDriverName(): String {
_driverName
}
/// 原始连接字符串(Datasource 构造时为 ""
public func getConnString(): String {
_connString
}
/// CliContext:主数据源
public func getDatasource(): Datasource {
datasource
}
/// CliContext:迁移列表(委托子类的 migrations()
public func getMigrations(): ArrayList<Migration> {
migrations()
}
/// 系统库数据源(数据库存在性检查用)。
/// 由连接串 + 驱动派生:Database=xxx 换成系统库名(pgsql → postgresmysql → mysql)。
/// 无连接串或驱动不支持(sqlite 无系统库)时返回 None。
public func getAdminDatasource(): ?Datasource {
if (_connString.isEmpty() || _driverName.isEmpty()) {
return None
}
if (_driverName == "sqlite") {
return None
}
let adminDb = if (_driverName == "mysql") { "mysql" } else { "postgres" }
var result = ""
for (seg in _connString.split(";")) {
let t = seg.trimAscii()
if (t.isEmpty()) {
continue
}
if (t.toAsciiLower().startsWith("database=")) {
result += "Database=${adminDb};"
} else {
result += t
result += ";"
}
}
Some(DatasourceFactory.create(result, _driverName))
}
/// CliContext:应用所有迁移(委托 migrate)
public func migrateAll(migrations: ArrayList<Migration>): Int64 {
migrate(migrations)
}
/// 获取实体的 DbSet(同一实体类型复用同一个 DbSet 实例)
public func set<T>(): DbSet<T> {
let key = TypeInfo.of<T>().toString()
if (let Some(existing) <- _sets.get(key)) {
return (existing as DbSet<T>).getOrThrow()
}
let ds = DbSet<T>(this, ModelCache.get<T>())
_sets[key] = ds
ds
}
/// 待提交的变更条数
public func pendingCount(): Int64 {
tracker.count()
}
/// 当前方言(子类/应用可读取)
public func getDialect(): ISqlDialect {
dialect
}
/// 应用迁移(对齐 EF Core Database.Migrate),返回本次应用的数量
public func migrate(migrations: ArrayList<Migration>): Int64 {
Migrator(datasource, dialect).migrate(migrations)
}
/// 无参便捷版:应用子类 migrations() 提供的全部迁移(`db.migrate()` 即运行 update
public func migrate(): Int64 {
migrate(migrations())
}
/// 确认当前连接串指向的数据库是否存在(连系统库查询,不建库)。
/// 需带连接串构造(_connString 非空);sqlite 文件库返回文件是否存在。
public func databaseExists(): Bool {
if (_connString.isEmpty()) {
return false
}
if (_driverName == "sqlite") {
return exists(Path(sqliteFilePath()))
}
let dbName = databaseNameFromConnString()
if (dbName.isEmpty()) {
return false
}
match (getAdminDatasource()) {
case None => false
case Some(admin) =>
let conn = admin.connect()
try {
let stmt = conn.prepareStatement(dialect.databaseExistsSql())
try {
stmt.set<Any>(0, dbName)
let rs = stmt.query()
rs.next()
} finally {
stmt.close()
}
} finally {
conn.close()
}
}
}
/// 是否存在未应用的迁移(库未创建或历史表不存在视为有待应用迁移)
public func hasPendingMigrations(): Bool {
let list = migrations()
if (list.size == 0) {
return false
}
try {
let migrator = Migrator(datasource, dialect)
let applied = migrator.appliedMigrationIds()
for (m in list) {
if (!applied.contains(m.migrationId)) {
return true
}
}
false
} catch (_: Exception) {
// 数据库/历史表不存在 → 有待应用迁移
true
}
}
/// 提交全部待处理变更(事务内按入队顺序执行),返回影响的实体数
public func saveChanges(): Int64 {
var count: Int64 = 0
if (tracker.count() == 0) {
return 0
}
let conn = datasource.connect()
let tx = conn.createTransaction()
tx.begin()
try {
for (entry in tracker.getEntries()) {
match (entry.state) {
case EntityState.Added => count += insertOne(conn, entry)
case EntityState.Modified => count += updateOne(conn, entry)
case EntityState.Deleted => count += deleteOne(conn, entry)
case _ => ()
}
}
tx.commit()
tracker.clear()
count
} catch (e: Exception) {
try {
tx.rollback()
} catch (_) {
()
}
throw e
} finally {
conn.close()
}
}
internal func getTracker(): ChangeTracker {
tracker
}
/// 执行查询并物化实体(query 层回调)
internal func query<T>(model: EntityModel, sql: String, params: ArrayList<Any>): ArrayList<T> {
let conn = datasource.connect()
try {
let stmt = conn.prepareStatement(sql)
try {
ParamBinder.bind(stmt, params)
let rs = stmt.query()
let list = ArrayList<T>()
while (rs.next()) {
let instance = materialize(rs, model)
list.add((instance as T).getOrThrow())
}
list
} finally {
stmt.close()
}
} finally {
conn.close()
}
}
/// 执行 COUNT 查询(query 层回调)
internal func count(model: EntityModel, sql: String, params: ArrayList<Any>): Int64 {
let conn = datasource.connect()
try {
let stmt = conn.prepareStatement(sql)
try {
ParamBinder.bind(stmt, params)
let rs = stmt.query()
if (rs.next()) {
return rs.getOrNull<Int64>(0).getOrThrow()
}
0
} finally {
stmt.close()
}
} finally {
conn.close()
}
}
/// 结果集 → 实体实例(列顺序与 model.properties 一致)
internal func materialize(rs: QueryResult, model: EntityModel): Any {
let instance = model.createInstance()
var i = 0
for (p in model.properties) {
let v = ValueReader.read(rs, i, p.typeName())
i += 1
if (let Some(val) <- v) {
model.setValue(instance, p, val)
}
}
instance
}
private func insertOne(conn: Connection, entry: EntityEntry): Int64 {
let model = modelOf(entry.entity)
let insertCols = ArrayList<String>()
let values = ArrayList<Any>()
for (p in model.properties) {
if (p.autoIncrement) {
continue
}
var v = model.getValue(entry.entity, p)
// 客户端生成主键(String):为空则生成唯一串
if (p.clientGenerated && isBlankString(v)) {
v = GuidUtil.generate()
model.setValue(entry.entity, p, v)
}
insertCols.add(p.columnName)
values.add(v)
}
let kp = model.keyProperty.getOrThrow()
let returning = kp.autoIncrement
let sql = dialect.buildInsert(model.tableName, insertCols, kp.columnName, returning)
let stmt = conn.prepareStatement(sql)
try {
ParamBinder.bind(stmt, values)
if (returning) {
let rs = stmt.query()
if (rs.next()) {
let v = ValueReader.read(rs, 0, kp.typeName())
if (let Some(kv) <- v) {
model.setKeyValue(entry.entity, kv)
}
}
} else {
stmt.update()
}
1
} finally {
stmt.close()
}
}
private func updateOne(conn: Connection, entry: EntityEntry): Int64 {
let model = modelOf(entry.entity)
let setCols = ArrayList<String>()
let values = ArrayList<Any>()
for (p in model.properties) {
if (p.isKey) {
continue
}
setCols.add(p.columnName)
values.add(model.getValue(entry.entity, p))
}
let kp = model.keyProperty.getOrThrow()
values.add(model.getKeyValue(entry.entity))
let sql = dialect.buildUpdate(model.tableName, setCols, kp.columnName)
let stmt = conn.prepareStatement(sql)
try {
ParamBinder.bind(stmt, values)
stmt.update()
1
} finally {
stmt.close()
}
}
private func deleteOne(conn: Connection, entry: EntityEntry): Int64 {
let model = modelOf(entry.entity)
let kp = model.keyProperty.getOrThrow()
let sql = dialect.buildDelete(model.tableName, kp.columnName)
let stmt = conn.prepareStatement(sql)
try {
stmt.set<Any>(0, model.getKeyValue(entry.entity))
stmt.update()
1
} finally {
stmt.close()
}
}
private func modelOf(entity: Any): EntityModel {
ModelCache.getFor(TypeInfo.of(entity))
}
private static func isBlankString(v: Any): Bool {
if (let s: String <- v) {
return s.isEmpty()
}
false
}
/// 从连接串解析数据库名:
/// URL 格式(postgres://user:pass@host:port/dbname?params)→ 路径段(去 query);
/// ADO 风格(Host=..;Database=xxx;..)→ Database 值。
/// 解析不到返回 ""。
private func databaseNameFromConnString(): String {
if (_connString.contains("://")) {
let afterScheme = _connString.indexOf("://").getOrThrow() + 3
let rest = _connString[afterScheme..]
if (let Some(slash) <- rest.indexOf("/")) {
var path = rest[slash + 1..]
if (let Some(q) <- path.indexOf("?")) {
path = path[..q]
}
return path
}
return ""
}
for (seg in _connString.split(";")) {
let t = seg.trimAscii()
if (t.toAsciiLower().startsWith("database=")) {
return t[t.indexOf("=").getOrThrow() + 1..]
}
}
""
}
/// sqlite 连接串 → 数据库文件路径(去 sqlite:// 前缀与 query 参数)
private func sqliteFilePath(): String {
var p = _connString
if (p.startsWith("sqlite://")) {
p = p[9..]
} else if (p.startsWith("sqlite:")) {
p = p[7..]
}
if (let Some(q) <- p.indexOf("?")) {
p = p[..q]
}
p
}
}
/**
* DbSet 的非泛型访问基类:供 ORM CLI 通过反射拿到模型(EntityModel)。
*/
public abstract class DbSetBase {
public func getModel(): EntityModel
}
/**
* 实体的数据集(对齐 EF Core DbSet)。
*/
public class DbSet<T> <: DbSetBase {
private let _context: DbContext
private let _model: EntityModel
private let _tracker: ChangeTracker
public init(context: DbContext, model: EntityModel) {
this._context = context
this._model = model
this._tracker = context.getTracker()
}
public override func getModel(): EntityModel {
_model
}
/// 入队新增
public func add(entity: T): Unit {
_tracker.add(entity, EntityState.Added)
}
/// 入队修改(UPDATE 全部非主键列)
public func update(entity: T): Unit {
_tracker.add(entity, EntityState.Modified)
}
/// 入队删除
public func remove(entity: T): Unit {
_tracker.add(entity, EntityState.Deleted)
}
/// 构建查询
public func query(): QueryBuilder<T> {
QueryBuilder<T>(_model, _context.getDialect(),
{ sql, params => _context.query<T>(_model, sql, params) },
{ sql, params => _context.count(_model, sql, params) })
}
/// 按主键查询(无结果返回 None)
public func find(key: Any): ?T {
let kp = _model.keyProperty.getOrThrow()
let list = query().filter(kp.columnName, "=", key).take(1).toList()
if (list.size > 0) {
return Some(list[0])
}
None
}
/// 全表查询
public func toList(): ArrayList<T> {
query().toList()
}
/// 全表计数
public func count(): Int64 {
query().count()
}
}
+102
View File
@@ -0,0 +1,102 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* @DbContext 类级宏:把纯声明的 DataContext 类展开为完整的 DbContext 子类。
*
* 用法(对齐 EF Core 声明式体验):
* @DbContext
* public class DataContext {
* public var users: DbSet<User>
* public var bots: DbSet<Bot>
* }
*
* 展开行为:
* - 自动补 `<: DbContext`(标注的类不能自带继承)
* - 每个 DbSet<T> 字段/无 getter prop -> `public prop name: DbSet<T> { get() { this.set<T>() } }`
* - 自动生成 `init(driverName: String, connStr: String)` 调用 super
* - 自动生成 `migrations()` override,返回 orm-cj 全局注册表 allMigrations()
* CLI 生成的 src/migrations/MigrationRegistry.cj 在模块加载时自动注册;
* 无迁移文件的默认项目返回空列表,应用照常编译运行)
*
* 应用侧要求(DataContext.cj 所在文件):
* import simcu::orm.*
* import simcu::orm.macros.*
* import std.collection.* // migrations() 签名需要 ArrayList
* import simcu::orm.migrations.* // migrations() 签名需要 Migration
*/
macro package simcu::orm.macros
import std.ast.*
import std.collection.*
/**
* 把纯声明的 DataContext 类展开为完整 DbContext 子类。
* @param input 被标注的类声明。
* @return 注入继承/字段 prop/init/migrations 后的类声明。
*/
public macro DbContext(input: Tokens): Tokens {
let decl = parseDecl(input)
if (let cd: ClassDecl <- decl) {
if (cd.superTypes.size > 0) {
throw ASTException("@DbContext 标注的类不能自带继承,继承由宏自动补 <: DbContext")
}
// 1. 补继承 <: DbContext
cd.superTypes.add(RefType(cangjieLex("DbContext")))
cd.upperBound = Token(TokenKind.UPPERBOUND)
// 2. DbSet<T> 字段/无 getter prop -> public prop { get() { this.set<T>() } }
let newDecls = ArrayList<Decl>()
for (d in cd.body.decls) {
match (d) {
case vd: VarDecl =>
let typeName = vd.declType.toTokens().toString()
if (isDbSetType(typeName)) {
newDecls.add(buildSetProp(vd.identifier.value, typeName))
} else {
newDecls.add(d)
}
case pd: PropDecl =>
let typeName = pd.declType.toTokens().toString()
if (isDbSetType(typeName)) {
newDecls.add(buildSetProp(pd.identifier.value, typeName))
} else {
newDecls.add(d)
}
case _ => newDecls.add(d)
}
}
// 3. 生成 init + migrations
newDecls.add(parseDecl(cangjieLex(
"public init(driverName: String, connStr: String) {\n super(driverName, connStr)\n}")))
newDecls.add(parseDecl(cangjieLex(
"public override func migrations(): ArrayList<Migration> {\n allMigrations()\n}")))
cd.body.decls.clear()
for (d in newDecls) {
cd.body.decls.add(d)
}
return cd.toTokens()
}
throw ASTException("@DbContext 只能标注在 class 声明上")
}
/// 类型 tokens 形如 "DbSet < User >" / "DbSet<User>",判断是否为 DbSet 类型
private func isDbSetType(t: String): Bool {
t.indexOf("DbSet") == Some(0) && t.indexOf("<").getOrThrow() > 0
}
/// 生成 `public prop name: DbSet<T> { get() { this.set<T>() } }`
private func buildSetProp(name: String, typeName: String): Decl {
let inner = extractInnerType(typeName)
let propSrc = "public prop ${name}: ${typeName} { get() { this.set<${inner}>() } }"
parseDecl(cangjieLex(propSrc))
}
/// 提取泛型实参 tokens"DbSet < User >" -> " User ""DbSet < ArrayList < User > >" -> " ArrayList < User > "
/// 生成 `this.set< User >()` 时空格合法,无需去空格
private func extractInnerType(t: String): String {
let open = t.indexOf("<").getOrThrow()
let close = t.lastIndexOf(">").getOrThrow()
t[open + 1..close]
}
+262
View File
@@ -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() {}
/// 初始迁移:空库 → 当前模型(全部 CreateTabledown 为逆序 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/downdown 按 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
}
}
+48
View File
@@ -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
}
+402
View File
@@ -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.sqlDDL 映射由方言决定。
*/
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()
}
}
+278
View File
@@ -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
}
}
+323
View File
@@ -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 字符")
}
}
+191
View File
@@ -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
}
}
}
+372
View File
@@ -0,0 +1,372 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 数据模型层:实体类 → 表/列元数据 的反射映射与缓存。
*
* 约定(对齐 EF Core 数据模型):
* - 表名:@Table["name"] 优先,否则取类简单名;
* - 列名:@Column["name"] 优先,否则按 ColumnNamingPolicy(默认剥前导下划线,_id → id);
* - 主键:@Key 标注,或字段名 _id;
* - 自增:@AutoIncrement,或 整型 _id 主键 默认自增;
* - 客户端生成主键:String 主键默认生成唯一字符串(GuidUtil);
* - 实体字段必须是 public var 标量类型(v1 不支持 Option 字段、父类字段)。
*/
package simcu::orm.model
import std.collection.*
import std.reflect.*
import std.sync.*
import std.time.*
import simcu::orm.annotations.*
/**
* 列命名策略。
*/
public enum ColumnNamingPolicy {
/// 字段名原样作列名(含前导下划线)
| Keep
/// 剥除前导下划线(默认):_id → id、_name → name
| StripUnderscore
/// 剥除前导下划线 + 驼峰转下划线小写:_userName → user_name
| SnakeCase
}
/**
* 单个字段的映射元数据。
*/
public class PropertyModel {
/// 反射字段名(如 _id)
public var name: String = ""
/// 数据库列名(如 id)
public var columnName: String = ""
/// 是否主键
public var isKey: Bool = false
/// 是否数据库自增(INSERT 跳过该列,RETURNING 回读)
public var autoIncrement: Bool = false
/// 是否客户端生成值(String 主键:INSERT 时为空则生成唯一串)
public var clientGenerated: Bool = false
/// 是否非空列(@Required,仅影响 DDL
public var isRequired: Bool = false
/// 字符串列最大长度(@MaxLength,仅影响 DDL
public var maxLength: Int64 = 0
/// 字段类型信息
public var typeInfo: ?TypeInfo = None
/// 字段读写句柄
public var variable: ?InstanceVariableInfo = None
/// 是否可变(var
public var isMutable: Bool = false
/// 类型简单名覆盖(快照重建时替代反射,如 "Int64"、"String"
public var typeNameOverride: String = ""
/// 字段类型简单名(如 Int64、String):优先 typeNameOverride,否则走反射
public func typeName(): String {
if (typeNameOverride != "") {
return typeNameOverride
}
if (let Some(t) <- typeInfo) {
return TypeUtil.simpleName(t.toString())
}
""
}
}
/**
* 实体模型:一个实体类 ↔ 一张表。
*/
public class EntityModel {
/// 实体类类型信息(快照重建的轻量模型为 None,仅用于元数据比较/DDL)
public var typeInfo: ?ClassTypeInfo = None
/// 表名
public var tableName: String = ""
/// 字段映射列表
public var properties: ArrayList<PropertyModel> = ArrayList()
/// 主键映射
public var keyProperty: ?PropertyModel = None
/// 轻量构造:快照反序列化 / 人工组装时使用,createInstance 不可用
public init() {}
public init(typeInfo: ClassTypeInfo) {
this.typeInfo = Some(typeInfo)
}
/// 反射创建实体实例(调用无参构造,实体必须提供 public init();轻量模型不可用)
public func createInstance(): Any {
typeInfo.getOrThrow().construct([])
}
/// 读取字段值(Any 语义;Option 字段值为 None 时返回装箱 None)
public func getValue(instance: Any, propM: PropertyModel): Any {
propM.variable.getOrThrow().getValue(instance)
}
/// 写入字段值(实体必须为 public var 字段)
public func setValue(instance: Any, propM: PropertyModel, value: Any): Unit {
propM.variable.getOrThrow().setValue(instance, value)
}
/// 主键值
public func getKeyValue(instance: Any): Any {
getValue(instance, keyProperty.getOrThrow())
}
/// 写入主键值(INSERT RETURNING 回读)
public func setKeyValue(instance: Any, value: Any): Unit {
setValue(instance, keyProperty.getOrThrow(), value)
}
/// 属性名(字段名或列名)→ 列名
public func mapColumn(property: String): String {
for (p in properties) {
if (p.name == property || p.columnName == property) {
return p.columnName
}
}
property
}
}
/**
* 模型缓存:类 → EntityModel(首次反射构建,后续复用)。
* 非线程安全:请在使用前完成首次访问(或自行加锁)。
*/
public class ModelCache {
private init() {}
private static let _cache = HashMap<String, EntityModel>()
private static var _namingPolicy: ColumnNamingPolicy = ColumnNamingPolicy.StripUnderscore
/// 设置全局列命名策略(影响后续首次构建的模型)
public static func setNamingPolicy(policy: ColumnNamingPolicy): Unit {
_namingPolicy = policy
}
public static func getNamingPolicy(): ColumnNamingPolicy {
_namingPolicy
}
/// 泛型取模型
public static func get<T>(): EntityModel {
getFor(TypeInfo.of<T>())
}
/// 按类型取模型
public static func getFor(typeInfo: TypeInfo): EntityModel {
let key = typeInfo.toString()
if (let Some(cached) <- _cache.get(key)) {
return cached
}
let ct = (typeInfo as ClassTypeInfo).getOrThrow()
let model = build(ct)
_cache[key] = model
model
}
private static func build(ct: ClassTypeInfo): EntityModel {
var model = EntityModel(ct)
// 表名
model.tableName = TypeUtil.simpleName(ct.toString())
if (let Some(t) <- ct.findAnnotation<Table>()) {
model.tableName = t.name
}
// 字段映射
for (v in ct.instanceVariables) {
if (v.findAnnotation<Ignore>().isSome()) {
continue
}
var propM = PropertyModel()
propM.name = v.name
propM.variable = Some(v)
propM.typeInfo = Some(v.typeInfo)
propM.isMutable = v.isMutable()
let typeName = TypeUtil.simpleName(v.typeInfo.toString())
if (typeName.startsWith("Option<")) {
throw Exception(
"simorm: 字段 ${v.name} 是 Option 类型,v1 不支持 Option 字段,请改用具体标量类型(String/Int*/Float*/Bool/DateTime 等)")
}
if (!ValueReader.isSupported(typeName)) {
throw Exception(
"simorm: 字段 ${v.name} 的类型 ${typeName} 不受支持,支持的标量类型:String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array<Byte>")
}
// 列名
if (let Some(c) <- v.findAnnotation<Column>()) {
propM.columnName = c.name
} else {
propM.columnName = applyNamingPolicy(v.name, _namingPolicy)
}
// 主键
if (v.findAnnotation<Key>().isSome()) {
propM.isKey = true
} else if (v.name == "_id" || v.name == "id") {
propM.isKey = true
}
// 自增 / 客户端生成
if (v.findAnnotation<AutoIncrement>().isSome()) {
propM.autoIncrement = true
} else if (propM.isKey && isIntegerType(typeName)) {
propM.autoIncrement = true
}
if (propM.isKey && typeName == "String") {
propM.clientGenerated = true
}
// 必填 / 最大长度
propM.isRequired = v.findAnnotation<Required>().isSome()
if (let Some(m) <- v.findAnnotation<MaxLength>()) {
propM.maxLength = m.maxLength
}
model.properties.add(propM)
}
// 主键校验(v1 仅支持单主键)
var keys = ArrayList<PropertyModel>()
for (p in model.properties) {
if (p.isKey) {
keys.add(p)
}
}
if (keys.size == 0) {
throw Exception("simorm: 实体 ${model.tableName} 缺少主键,请用 @Key 标注或命名字段为 _id")
}
if (keys.size > 1) {
throw Exception("simorm: 实体 ${model.tableName} 有多个主键,v1 仅支持单主键")
}
model.keyProperty = Some(keys[0])
model
}
/// 应用列命名策略
public static func applyNamingPolicy(fieldName: String, policy: ColumnNamingPolicy): String {
match (policy) {
case ColumnNamingPolicy.Keep => fieldName
case ColumnNamingPolicy.StripUnderscore => stripUnderscore(fieldName)
case ColumnNamingPolicy.SnakeCase => toSnakeCase(stripUnderscore(fieldName))
}
}
private static func stripUnderscore(s: String): String {
var sb = StringBuilder()
var started = false
for (c in s.runes()) {
if (!started && c == Rune(0x5F)) {
continue
}
started = true
sb.append(c)
}
sb.toString()
}
private static func toSnakeCase(s: String): String {
var sb = StringBuilder()
var first = true
for (c in s.runes()) {
let ch = UInt32(c)
let isUpper = ch >= 0x41 && ch <= 0x5A
if (isUpper) {
if (!first) {
sb.append("_")
}
sb.append(Rune(ch + 0x20))
} else {
sb.append(c)
}
first = false
}
sb.toString()
}
private static func isIntegerType(typeName: String): Bool {
typeName == "Int8" || typeName == "Int16" || typeName == "Int32" || typeName == "Int64"
}
}
/**
* 类型名工具。
*/
public class TypeUtil {
private init() {}
/// 取类型简单名(去掉 包::/模块. 前缀):simcu::orm.User → User
public static func simpleName(typeName: String): String {
let dot = typeName.lastIndexOf(".")
let col = typeName.lastIndexOf("::")
let idx = match ((dot, col)) {
case (Some(d), Some(c)) => if (d > c) { d } else { c }
case (Some(d), None) => d
case (None, Some(c)) => c
case (None, None) => -1
}
if (idx >= 0) {
return typeName[idx + 1..]
}
typeName
}
/// 整型类型
public static func isIntegerType(typeName: String): Bool {
typeName == "Int8" || typeName == "Int16" || typeName == "Int32" || typeName == "Int64"
}
/// 浮点类型
public static func isFloatType(typeName: String): Bool {
typeName == "Float32" || typeName == "Float64"
}
/// 若值为 Option 的 Some(x) 返回 Some(x)None 或非 Option 返回 None
public static func unwrapOption(v: Any): ?Any {
if (let et: EnumTypeInfo <- TypeInfo.of(v)) {
let (ctor, values) = et.destruct(v)
if (ctor.name == "Some" && values.size > 0) {
return Some(values[0])
}
}
None
}
/// 值是否为 Option 类型
public static func isOptionValue(v: Any): Bool {
TypeInfo.of(v).toString().startsWith("Option<")
}
}
/**
* 客户端主键生成器:时间戳 + 自增序号(保证唯一,不需外部依赖)。
*/
public class GuidUtil {
private init() {}
private static let _seq = AtomicInt64(0)
public static func generate(): String {
let ts = DateTime.now().format("yyyyMMddHHmmssSSS")
let n = _seq.fetchAdd(1)
var hex = toHex(n)
while (hex.size < 8) {
hex = "0${hex}"
}
"${ts}-${hex}"
}
private static func toHex(v: Int64): String {
if (v == 0) {
return "0"
}
const digits = "0123456789abcdef"
var sb = StringBuilder()
var n = v
while (n > 0) {
let idx = Int64(n % 16)
sb.append(digits[idx..idx + 1])
n = n / 16
}
// 反转
var arr = ArrayList<Rune>()
for (c in sb.toString().runes()) {
arr.add(c)
}
var rev = StringBuilder()
for (i in 0..arr.size) {
rev.append(arr[arr.size - 1 - i])
}
rev.toString()
}
}
+98
View File
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 结果集读取器:按属性类型名分发调用驱动 Rows.getOrNull<T>(驱动按泛型参数 T 匹配转换),
* 以及参数绑定工具(Option 值解包 / null 走 setNull)。
*
* read 返回 ?AnyNone 表示数据库列值为 NULL(物化时跳过该字段赋值,保留默认值);
* Some(v) 表示读取到值。
*/
package simcu::orm.model
import std.collection.*
import std.database.sql.{QueryResult, Statement}
import std.math.numeric.*
import std.time.*
/**
* 结果集 → 实体字段值 的类型化读取。
*/
public class ValueReader {
private init() {}
/**
* 按类型名从结果集第 index 列读值。
* 返回 None 表示该列为 NULL。
*/
public static func read(rows: QueryResult, index: Int, typeName: String): ?Any {
match (typeName) {
case "String" => wrap(rows.getOrNull<String>(index))
case "Bool" => wrap(rows.getOrNull<Bool>(index))
case "Int8" => wrap(rows.getOrNull<Int8>(index))
case "Int16" => wrap(rows.getOrNull<Int16>(index))
case "Int32" => wrap(rows.getOrNull<Int32>(index))
case "Int64" => wrap(rows.getOrNull<Int64>(index))
case "UInt8" => wrap(rows.getOrNull<UInt8>(index))
case "UInt16" => wrap(rows.getOrNull<UInt16>(index))
case "UInt32" => wrap(rows.getOrNull<UInt32>(index))
case "UInt64" => wrap(rows.getOrNull<UInt64>(index))
case "Float32" => wrap(rows.getOrNull<Float32>(index))
case "Float64" => wrap(rows.getOrNull<Float64>(index))
case "Rune" => wrap(rows.getOrNull<Rune>(index))
case "DateTime" => wrap(rows.getOrNull<DateTime>(index))
case "Duration" => wrap(rows.getOrNull<Duration>(index))
case "Decimal" => wrap(rows.getOrNull<Decimal>(index))
case "Array<Byte>" => wrap(rows.getOrNull<Array<Byte>>(index))
case _ => throw Exception("simorm: 不支持的实体字段类型 ${typeName}")
}
}
/// ?T → ?AnyOption 不自动协变,需显式包装)
private static func wrap<T>(o: ?T): ?Any {
if (let Some(v) <- o) {
return Some((v as Any).getOrThrow())
}
None
}
/// 属性类型是否受支持(ModelCache 构建时预校验)
public static func isSupported(typeName: String): Bool {
match (typeName) {
case "String" | "Bool" | "Int8" | "Int16" | "Int32" | "Int64" =>
true
case "UInt8" | "UInt16" | "UInt32" | "UInt64" =>
true
case "Float32" | "Float64" | "Rune" =>
true
case "DateTime" | "Duration" | "Decimal" | "Array<Byte>" =>
true
case _ => false
}
}
}
/**
* 参数绑定:把 ORM 参数(Any,可能为装箱 None)按值绑定到 Statement。
* 驱动对 null 需显式 setNull,不能直接 set<Any>(None)。
*/
public class ParamBinder {
private init() {}
public static func bind(stmt: Statement, params: ArrayList<Any>): Unit {
for (i in 0..params.size) {
bindOne(stmt, i, params[i])
}
}
public static func bindOne(stmt: Statement, index: Int, raw: Any): Unit {
if (TypeUtil.isOptionValue(raw)) {
if (let Some(v) <- TypeUtil.unwrapOption(raw)) {
stmt.set<Any>(index, v)
} else {
stmt.setNull(index)
}
} else {
stmt.set<Any>(index, raw)
}
}
}
+188
View File
@@ -0,0 +1,188 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 查询构建器:条件/排序/分页 → SQL,委托执行回调物化结果。
*
* 解耦设计:QueryBuilder 不直接依赖 DbContext/驱动。
* 构造时传入 执行回调 (sql, params) -> ArrayList<T>,由 DbContext 提供。
*/
package simcu::orm.query
import std.collection.*
import simcu::orm.model.*
import simcu::orm.sql.*
/**
* 分页结果。
*/
public class PagedResult<T> {
/// 当前页数据
public var items: ArrayList<T> = ArrayList()
/// 满足条件的总条数
public var total: Int64 = 0
/// 页码(从 1 开始)
public var page: Int64 = 1
/// 每页条数
public var pageSize: Int64 = 20
public init() {}
public init(items: ArrayList<T>, total: Int64, page: Int64, pageSize: Int64) {
this.items = items
this.total = total
this.page = page
this.pageSize = pageSize
}
/// 总页数
public func totalPages(): Int64 {
if (pageSize <= 0) {
return 0
}
(total + pageSize - 1) / pageSize
}
}
/**
* 查询构建器(对齐 EF Core IQueryable 的常用子集)。
*/
public class QueryBuilder<T> {
private let _model: EntityModel
private let _dialect: ISqlDialect
private let _executor: (String, ArrayList<Any>) -> ArrayList<T>
private let _counter: (String, ArrayList<Any>) -> Int64
private let _whereParts = ArrayList<String>()
private let _params = ArrayList<Any>()
private let _orderByParts = ArrayList<String>()
private var _skip: Int64 = 0
private var _take: Int64 = 0
public init(model: EntityModel, dialect: ISqlDialect, executor: (String, ArrayList<Any>) -> ArrayList<T>,
counter: (String, ArrayList<Any>) -> Int64) {
this._model = model
this._dialect = dialect
this._executor = executor
this._counter = counter
}
/// filter("age > ?", [18]):原始片段,参数按 ? 顺序;片段中请直接写数据库列名
public func filter(condition: String, params: ArrayList<Any>): QueryBuilder<T> {
_whereParts.add("(${condition})")
for (p in params) {
_params.add(p)
}
this
}
/// filter("age", ">", 18):属性名/列名自动映射为列名
public func filter(property: String, op: String, value: Any): QueryBuilder<T> {
let col = _model.mapColumn(property)
_whereParts.add("(${_dialect.quoteName(col)} ${op} ?)")
_params.add(value)
this
}
public func orderBy(property: String): QueryBuilder<T> {
_orderByParts.add("${_dialect.quoteName(_model.mapColumn(property))} ASC")
this
}
public func orderByDesc(property: String): QueryBuilder<T> {
_orderByParts.add("${_dialect.quoteName(_model.mapColumn(property))} DESC")
this
}
public func skip(n: Int64): QueryBuilder<T> {
_skip = n
this
}
public func take(n: Int64): QueryBuilder<T> {
_take = n
this
}
/// 执行查询,返回实体列表
public func toList(): ArrayList<T> {
_executor(buildSelectSql(), copyParams())
}
/// 返回第一条(无结果返回 None)
public func first(): ?T {
let saved = _take
_take = 1
let list = toList()
_take = saved
if (list.size > 0) {
return Some(list[0])
}
None
}
/// 满足条件的总数
public func count(): Int64 {
let sql = _dialect.buildCount(_model.tableName, whereSql())
_counter(sql, copyParams())
}
/// 分页(page 从 1 开始)
public func page(page: Int64, pageSize: Int64): PagedResult<T> {
let total = count()
let savedSkip = _skip
let savedTake = _take
_skip = (page - 1) * pageSize
_take = pageSize
let items = toList()
_skip = savedSkip
_take = savedTake
PagedResult<T>(items, total, page, pageSize)
}
private func buildSelectSql(): String {
_dialect.buildSelect(_model.tableName, selectColumns(), whereSql(), orderBySql(), _skip, _take)
}
/// 物化列(与属性顺序一致,结果集按此索引读取)
private func selectColumns(): ArrayList<String> {
let cols = ArrayList<String>()
for (p in _model.properties) {
cols.add(p.columnName)
}
cols
}
private func whereSql(): String {
var sb = StringBuilder()
var first = true
for (w in _whereParts) {
if (!first) {
sb.append(" AND ")
}
sb.append(w)
first = false
}
sb.toString()
}
private func orderBySql(): String {
var sb = StringBuilder()
var first = true
for (o in _orderByParts) {
if (!first) {
sb.append(", ")
}
sb.append(o)
first = false
}
sb.toString()
}
private func copyParams(): ArrayList<Any> {
let copy = ArrayList<Any>()
for (p in _params) {
copy.add(p)
}
copy
}
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 列类型:与具体数据库无关的抽象,DDL 映射由 ISqlDialect 决定。
*/
package simcu::orm.sql
/**
* 列类型(语义化定义,具体 DDL 类型由方言 columnTypeSql 映射)。
*/
public enum ColumnTypes {
/// 大整数(openGauss/PG 自增 → BIGSERIAL
| BigIntCol
/// 整数(openGauss/PG 自增 → SERIAL
| IntCol
/// 小整数
| SmallIntCol
/// 微整数(部分数据库无 TINYINT,方言自行降级)
| TinyIntCol
/// 变长字符串,n = maxLength 或方言默认值
| TextCol
/// 布尔
| BoolCol
/// 双精度浮点
| FloatCol
/// 单精度浮点
| RealCol
/// 时间戳
| DateTimeCol
/// 高精度小数
| DecimalCol
/// 二进制
| BinaryCol
}
+56
View File
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* SQL 方言接口(I 前缀命名,对齐 EF Core 接口风格):标识符引用、增删改查 SQL 生成、DDL 类型/语句生成。
*
* 新数据库接入:实现 ISqlDialect 即可(参考 PostgreSqlDialect / OpenGaussDialect)。
* 内置实现见 PostgreSqlDialect.cjPostgreSQL)、OpenGaussDialect.cjopenGauss,继承 PG)。
*/
package simcu::orm.sql
import std.collection.*
/**
* SQL 方言接口。
*/
public interface ISqlDialect {
/// 引用标识符(表名/列名)
func quoteName(name: String): String
/// INSERTreturningKey 为 true 时追加 RETURNING "keyColumn" 回读自增主键
func buildInsert(table: String, columns: ArrayList<String>, keyColumn: String, returningKey: Bool): String
/// UPDATESET 全部非主键列,WHERE 主键
func buildUpdate(table: String, columns: ArrayList<String>, keyColumn: String): String
/// DELETEWHERE 主键
func buildDelete(table: String, keyColumn: String): String
/// SELECTtake <= 0 表示不限制
func buildSelect(table: String, columns: ArrayList<String>, whereSql: String, orderBySql: String,
skip: Int64, take: Int64): String
/// COUNT
func buildCount(table: String, whereSql: String): String
/// 列类型 → DDL 类型(自增列/长度由具体方言决定)
func columnTypeSql(t: ColumnTypes, autoIncrement: Bool, maxLength: Int64): String
/// DROP INDEX 语句(部分数据库如 MySQL 需要指定所属表名)
func buildDropIndex(indexName: String, table: String): String
/// ALTER COLUMN 语句(PostgreSQL/openGauss: "ALTER COLUMN ..."MySQL: "MODIFY COLUMN ..."
func buildAlterColumn(table: String, columnSql: String): String
/// CREATE DATABASE 语句(库名需调用方先做标识符校验,再经 quoteName 引用)
func createDatabaseSql(databaseName: String): String
/// DROP DATABASE 语句(库名需调用方先做标识符校验,再经 quoteName 引用)
func dropDatabaseSql(databaseName: String): String
/// 探测数据库是否存在的 SELECT(返回带 ? 占位符的语句,库名作为参数值传入避免拼接注入)
func databaseExistsSql(): String
}
/// 兼容别名:旧名称 SqlDialect 仍可用(新代码请使用 ISqlDialect
public type SqlDialect = ISqlDialect
+14
View File
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* openGauss 方言:SQL/DDL 语法与 PostgreSQL 兼容,直接复用 PostgreSqlDialect。
* 若未来遇到 openGauss 特有差异,在此 override 相应方法即可。
*/
package simcu::orm.sql
/**
* openGauss 方言。
*/
public class OpenGaussDialect <: PostgreSqlDialect {
public init() {}
}
+146
View File
@@ -0,0 +1,146 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* PostgreSQL 方言实现。
*
* 占位符 ?,标识符双引号引用,自增主键 SERIAL/BIGSERIAL
* INSERT 回读主键用 RETURNING,分页 LIMIT/OFFSET。
*/
package simcu::orm.sql
import std.collection.*
/**
* PostgreSQL 方言。
*/
public open class PostgreSqlDialect <: ISqlDialect {
public init() {}
public func quoteName(name: String): String {
"\"${name}\""
}
public func buildInsert(table: String, columns: ArrayList<String>, keyColumn: String,
returningKey: Bool): String {
let sb = StringBuilder()
sb.append("INSERT INTO ${quoteName(table)} (")
var first = true
for (c in columns) {
if (!first) {
sb.append(", ")
}
sb.append(quoteName(c))
first = false
}
sb.append(") VALUES (")
var firstP = true
for (i in 0..columns.size) {
if (!firstP) {
sb.append(", ")
}
sb.append("?")
firstP = false
}
sb.append(")")
if (returningKey) {
sb.append(" RETURNING ${quoteName(keyColumn)}")
}
sb.toString()
}
public func buildUpdate(table: String, columns: ArrayList<String>, keyColumn: String): String {
let sb = StringBuilder()
sb.append("UPDATE ${quoteName(table)} SET ")
var first = true
for (c in columns) {
if (!first) {
sb.append(", ")
}
sb.append("${quoteName(c)} = ?")
first = false
}
sb.append(" WHERE ${quoteName(keyColumn)} = ?")
sb.toString()
}
public func buildDelete(table: String, keyColumn: String): String {
"DELETE FROM ${quoteName(table)} WHERE ${quoteName(keyColumn)} = ?"
}
public func buildSelect(table: String, columns: ArrayList<String>, whereSql: String,
orderBySql: String, skip: Int64, take: Int64): String {
let sb = StringBuilder()
sb.append("SELECT ")
if (columns.size == 0) {
sb.append("*")
} else {
var first = true
for (c in columns) {
if (!first) {
sb.append(", ")
}
sb.append(quoteName(c))
first = false
}
}
sb.append(" FROM ${quoteName(table)}")
if (!whereSql.isEmpty()) {
sb.append(" WHERE ${whereSql}")
}
if (!orderBySql.isEmpty()) {
sb.append(" ORDER BY ${orderBySql}")
}
if (take > 0) {
sb.append(" LIMIT ${take}")
}
if (skip > 0) {
sb.append(" OFFSET ${skip}")
}
sb.toString()
}
public func buildCount(table: String, whereSql: String): String {
let sb = StringBuilder()
sb.append("SELECT COUNT(*) FROM ${quoteName(table)}")
if (!whereSql.isEmpty()) {
sb.append(" WHERE ${whereSql}")
}
sb.toString()
}
public func columnTypeSql(t: ColumnTypes, autoIncrement: Bool, maxLength: Int64): String {
match (t) {
case ColumnTypes.BigIntCol => if (autoIncrement) { "BIGSERIAL" } else { "BIGINT" }
case ColumnTypes.IntCol => if (autoIncrement) { "SERIAL" } else { "INTEGER" }
case ColumnTypes.SmallIntCol => "SMALLINT"
case ColumnTypes.TinyIntCol => "SMALLINT"
case ColumnTypes.TextCol => "VARCHAR(${if (maxLength > 0) { maxLength } else { 255 }})"
case ColumnTypes.BoolCol => "BOOLEAN"
case ColumnTypes.FloatCol => "DOUBLE PRECISION"
case ColumnTypes.RealCol => "REAL"
case ColumnTypes.DateTimeCol => "TIMESTAMP"
case ColumnTypes.DecimalCol => "DECIMAL(18, 6)"
case ColumnTypes.BinaryCol => "BYTEA"
}
}
public func buildDropIndex(indexName: String, table: String): String {
"DROP INDEX IF EXISTS ${quoteName(indexName)}"
}
public func buildAlterColumn(table: String, columnSql: String): String {
"ALTER TABLE ${quoteName(table)} ALTER COLUMN ${columnSql}"
}
public func createDatabaseSql(databaseName: String): String {
"CREATE DATABASE ${quoteName(databaseName)}"
}
public func dropDatabaseSql(databaseName: String): String {
"DROP DATABASE IF EXISTS ${quoteName(databaseName)}"
}
public func databaseExistsSql(): String {
"SELECT 1 FROM pg_database WHERE datname = ?"
}
}
+262
View File
@@ -0,0 +1,262 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 迁移 CLI 单元测试(纯逻辑 + 临时目录落盘,不连接数据库)。
* 覆盖:迁移源码生成(初始/增量/手写含索引与 rawSql)/ 注册文件重写 /
* className 与文件名推导 / CLI 命令分发(help/未知/缺参数)。
*/
package simcu::orm.tests
import std.collection.*
import std.fs.*
import std.unittest.*
import std.unittest.testmacro.*
import simcu::orm.*
import simcu::orm.cli.*
/// 手写迁移(验证源码生成覆盖索引与 rawSql 转义)
class HandWrittenMigration <: Migration {
public init() {
super("20250801000000_HandWritten", "手写迁移")
}
public override func up(builder: MigrationBuilder): Unit {
builder.createTable("tags") { tb =>
tb.column("id", ColumnTypes.BigIntCol).primary().autoInc().notNull()
tb.column("label", ColumnTypes.TextCol).withMaxLength(50).withUnique().notNull()
}
let idxCols = ArrayList<String>()
idxCols.add("label")
builder.createIndex("ix_tags_label", "tags", idxCols, true)
builder.rawSql("SELECT 1 -- comment \"quoted\"")
}
public override func down(builder: MigrationBuilder): Unit {
builder.dropTable("tags")
}
}
@Test
class MigrationFileGeneratorTests {
private func buildUserModel(): EntityModel {
let model = EntityModel()
model.tableName = "users"
let pk = PropertyModel()
pk.name = "_id"
pk.columnName = "id"
pk.isKey = true
pk.autoIncrement = true
pk.isRequired = true
pk.typeNameOverride = "Int64"
model.properties.add(pk)
let name = PropertyModel()
name.name = "_name"
name.columnName = "name"
name.isRequired = true
name.maxLength = 100
name.typeNameOverride = "String"
model.properties.add(name)
model.keyProperty = Some(pk)
model
}
private func buildOrderModel(): EntityModel {
let model = EntityModel()
model.tableName = "orders"
let pk = PropertyModel()
pk.name = "_oid"
pk.columnName = "oid"
pk.isKey = true
pk.clientGenerated = true
pk.isRequired = true
pk.typeNameOverride = "String"
model.properties.add(pk)
model.keyProperty = Some(pk)
model
}
@TestCase
public func testClassNameAndFileName(): Unit {
@Expect(MigrationFileGenerator.classNameOf("20250701000000_InitialCreate") == "InitialCreate")
@Expect(MigrationFileGenerator.fileNameOf("20250701000000_InitialCreate") == "20250701000000_InitialCreate.cj")
@Expect(MigrationFileGenerator.classNameOf("NoTimestamp") == "NoTimestamp")
}
@TestCase
public func testInitialMigrationSource(): Unit {
let models = ArrayList<EntityModel>()
models.add(buildUserModel())
let gen = MigrationGenerator()
let m = gen.initial("20250701000000_InitialCreate", "初始建表", models)
let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m)
@Expect(src.contains("package app"))
@Expect(src.contains("public class InitialCreate <: Migration"))
@Expect(src.contains("super(\"20250701000000_InitialCreate\", \"初始建表\")"))
@Expect(src.contains("builder.createTable(\"users\") { tb =>"))
@Expect(src.contains("tb.column(\"id\", ColumnTypes.BigIntCol).primary().autoInc().notNull()"))
@Expect(src.contains("tb.column(\"name\", ColumnTypes.TextCol).notNull().withMaxLength(100)"))
@Expect(src.contains("builder.dropTable(\"users\")"))
}
@TestCase
public func testDiffMigrationSource(): Unit {
let oldModels = ArrayList<EntityModel>()
oldModels.add(buildUserModel())
let newModels = ArrayList<EntityModel>()
newModels.add(buildUserModel())
newModels.add(buildOrderModel())
let gen = MigrationGenerator()
let m = gen.diff("20250801000000_AddOrders", "新增 orders 表", oldModels, newModels)
let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m)
@Expect(src.contains("builder.createTable(\"orders\") { tb =>"))
@Expect(src.contains("tb.column(\"oid\", ColumnTypes.TextCol).primary().notNull()"))
// down 反转:先 drop 新表
@Expect(src.contains("builder.dropTable(\"orders\")"))
}
@TestCase
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("tb.column(\"label\", ColumnTypes.TextCol).notNull().withUnique().withMaxLength(50)"))
// rawSql 双引号转义
@Expect(src.contains("builder.rawSql(\"SELECT 1 -- comment \\\"quoted\\\"\")"))
@Expect(src.contains("builder.dropTable(\"tags\")"))
}
@TestCase
public func testRegistryRoundTrip(): Unit {
let entries = ArrayList<String>()
entries.add("InitialCreate")
entries.add("AddAge")
let src = MigrationFileGenerator.registrySource("app", entries)
@Expect(src.contains("package app"))
@Expect(src.contains("registerMigration(InitialCreate())"))
@Expect(src.contains("registerMigration(AddAge())"))
// 提取回环
let extracted = MigrationFileGenerator.extractRegistryEntries(src)
@Expect(extracted.size == 2)
@Expect(extracted[0] == "InitialCreate")
@Expect(extracted[1] == "AddAge")
// 去重
entries.add("InitialCreate")
let src2 = MigrationFileGenerator.registrySource("app", entries)
@Expect(MigrationFileGenerator.extractRegistryEntries(src2).size == 2)
}
@TestCase
public func testWriteMigrationAndRegistryToDisk(): Unit {
let dir = "cli_migration_tmp"
if (exists(Path(dir))) {
remove(Path(dir), recursive: true)
}
try {
let models = ArrayList<EntityModel>()
models.add(buildUserModel())
let gen = MigrationGenerator()
let m = gen.initial("20250701000000_InitialCreate", "初始建表", models)
let fg = MigrationFileGenerator()
let path = fg.writeMigration("testpkg", m, dir)
@Expect(exists(Path(path)))
let text = String.fromUtf8(File.readFrom(Path(path)))
@Expect(text.contains("package testpkg"))
@Expect(text.contains("public class InitialCreate <: Migration"))
// 注册文件:首次创建 + 追加 + 幂等
let registryPath = "${dir}/MigrationRegistry.cj"
fg.updateRegistry("testpkg", registryPath, "InitialCreate")
fg.updateRegistry("testpkg", registryPath, "AddAge")
fg.updateRegistry("testpkg", registryPath, "AddAge")
let reg = String.fromUtf8(File.readFrom(Path(registryPath)))
@Expect(reg.contains("package testpkg"))
@Expect(MigrationFileGenerator.extractRegistryEntries(reg).size == 2)
} finally {
if (exists(Path(dir))) {
remove(Path(dir), recursive: true)
}
}
}
}
@Test
class MigrationCliDispatchTests {
@TestCase
public func testHelpReturnsZero(): Unit {
let rc = MigrationCli.run(ArrayList<String>(), { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(rc == 0)
}
@TestCase
public func testUnknownCommandReturnsOne(): Unit {
let args = ArrayList<String>()
args.add("bogus")
let rc = MigrationCli.run(args, { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(rc == 1)
}
@TestCase
public func testAddMissingNameReturnsOne(): Unit {
let args = ArrayList<String>()
args.add("add")
let rc = MigrationCli.run(args, { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(rc == 1)
}
@TestCase
public func testRmMissingNameReturnsOne(): Unit {
let args = ArrayList<String>()
args.add("rm")
let rc = MigrationCli.run(args, { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(rc == 1)
}
@TestCase
public func testDowngradeNoMigrationsReturnsOne(): Unit {
let args = ArrayList<String>()
args.add("downgrade")
let rc = MigrationCli.run(args, { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(rc == 1)
}
@TestCase
public func testDowngradeWithTargetConsumesArgs(): Unit {
let args = ArrayList<String>()
args.add("downgrade")
args.add("20260819061048_InitialCreate")
let rc = MigrationCli.run(args, { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(rc == 1)
}
@TestCase
public func testTryRunWithOrmPrefixConsumesArgs(): Unit {
let args = ArrayList<String>()
args.add("orm")
args.add("help")
let handled = MigrationCli.tryRun(args, { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(handled == true)
}
@TestCase
public func testTryRunWithoutOrmPrefixReturnsFalse(): Unit {
let args = ArrayList<String>()
args.add("serve")
let handled = MigrationCli.tryRun(args, { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(handled == false)
}
@TestCase
public func testTryRunEmptyArgsReturnsFalse(): Unit {
let handled = MigrationCli.tryRun(ArrayList<String>(), { => ArrayList<EntityModel>() },
{ => throw Exception("不应调用 datasource") }, { => ArrayList<Migration>() })
@Expect(handled == false)
}
}
+982
View File
@@ -0,0 +1,982 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* SimOrm 单元测试(cjpm test),全部为纯逻辑测试,不连接数据库。
* 覆盖:模型映射与命名策略 / 模型校验(无主键、多主键、Option、不支持类型)/
* ChangeTracker 操作队列 / OpenGaussDialect SQL 生成 / DdlFactory DDL 生成 /
* MigrationBuilder 链式调用 / QueryBuilder SQL 构建(假 executor 捕获 SQL)。
*/
package simcu::orm.tests
import std.collection.*
import std.database.sql.*
import std.fs.*
import std.unittest.*
import std.unittest.testmacro.*
import simcu::orm.*
// ---------- 测试实体 ----------
@Table["users"]
class User {
public var _id: Int64 = 0
public var _name: String = ""
public var _age: Int32 = 0
}
@Table["orders"]
class Order {
@Key
public var _orderId: String = ""
@Column["full_name"]
public var _name: String = ""
@Ignore
public var _temp: String = ""
}
class Product {
@Key
@AutoIncrement
public var _pid: Int64 = 0
@Required
@MaxLength[100]
public var _title: String = ""
public var _price: Float64 = 0.0
public var _active: Bool = true
}
class NoKeyEntity {
public var _name: String = ""
}
@Table["plain_keys"]
class PlainIdEntity {
public var id: String = ""
public var name: String = ""
}
class MultiKeyEntity {
@Key
public var _a: String = ""
@Key
public var _b: String = ""
}
class OptionEntity {
public var _id: Int64 = 0
public var _nick: ?String = None
}
enum TestColor {
| Red
| Green
}
class UnsupportedEntity {
public var _id: Int64 = 0
public var _color: TestColor = TestColor.Red
}
// ---------- 模型映射 ----------
@Test
class ModelMappingTests {
@TestCase
public func testUserModel(): Unit {
let model = ModelCache.get<User>()
@Expect(model.tableName, "users")
@Expect(model.properties.size, 3)
let kp = model.keyProperty.getOrThrow()
@Expect(kp.name, "_id")
@Expect(kp.columnName, "id")
@Expect(kp.isKey, true)
@Expect(kp.autoIncrement, true)
@Expect(kp.clientGenerated, false)
@Expect(model.properties[1].name, "_name")
@Expect(model.properties[1].columnName, "name")
@Expect(model.properties[2].columnName, "age")
@Expect(model.mapColumn("_age"), "age")
@Expect(model.mapColumn("age"), "age")
@Expect(model.mapColumn("_unknown"), "_unknown")
}
@TestCase
public func testOrderModel(): Unit {
let model = ModelCache.get<Order>()
@Expect(model.tableName, "orders")
// @Ignore 字段不映射
@Expect(model.properties.size, 2)
let kp = model.keyProperty.getOrThrow()
@Expect(kp.name, "_orderId")
@Expect(kp.columnName, "orderId")
@Expect(kp.isKey, true)
// String 主键 → 客户端生成,不自增
@Expect(kp.clientGenerated, true)
@Expect(kp.autoIncrement, false)
// @Column 覆盖列名
@Expect(model.properties[1].name, "_name")
@Expect(model.properties[1].columnName, "full_name")
@Expect(model.mapColumn("_name"), "full_name")
}
@TestCase
public func testProductModel(): Unit {
let model = ModelCache.get<Product>()
@Expect(model.properties.size, 4)
let kp = model.keyProperty.getOrThrow()
@Expect(kp.name, "_pid")
@Expect(kp.columnName, "pid")
@Expect(kp.isKey, true)
@Expect(kp.autoIncrement, true)
// @Required / @MaxLength
let title = model.properties[1]
@Expect(title.name, "_title")
@Expect(title.isRequired, true)
@Expect(title.maxLength, 100)
@Expect(model.properties[2].columnName, "price")
@Expect(model.properties[3].columnName, "active")
}
@TestCase
public func testEntityInstance(): Unit {
let model = ModelCache.get<User>()
let u = User()
u._name = "alice"
let instance = model.createInstance()
// createInstance 走无参构造,字段为默认值
model.setValue(instance, model.properties[1], "bob")
@Expect((model.getValue(instance, model.properties[1]) as String).getOrThrow(), "bob")
}
}
// ---------- 模型校验(异常) ----------
@Test
class ModelValidationTests {
@TestCase
public func testNoKeyThrows(): Unit {
let threw = try { ModelCache.get<NoKeyEntity>(); false } catch (_: Exception) { true }
@Expect(threw, true)
}
@TestCase
public func testPlainIdRecognizedAsKey(): Unit {
let model = ModelCache.get<PlainIdEntity>()
let kp = model.keyProperty.getOrThrow()
@Expect(kp.name, "id")
@Expect(kp.columnName, "id")
@Expect(kp.isKey, true)
@Expect(kp.clientGenerated, true)
}
@TestCase
public func testMultiKeyThrows(): Unit {
let threw = try { ModelCache.get<MultiKeyEntity>(); false } catch (_: Exception) { true }
@Expect(threw, true)
}
@TestCase
public func testOptionFieldThrows(): Unit {
let threw = try { ModelCache.get<OptionEntity>(); false } catch (_: Exception) { true }
@Expect(threw, true)
}
@TestCase
public func testUnsupportedTypeThrows(): Unit {
let threw = try { ModelCache.get<UnsupportedEntity>(); false } catch (_: Exception) { true }
@Expect(threw, true)
}
}
// ---------- 命名策略 ----------
@Test
class NamingPolicyTests {
@TestCase
public func testApplyNamingPolicy(): Unit {
@Expect(ModelCache.applyNamingPolicy("_userName", ColumnNamingPolicy.SnakeCase), "user_name")
@Expect(ModelCache.applyNamingPolicy("_id", ColumnNamingPolicy.SnakeCase), "id")
@Expect(ModelCache.applyNamingPolicy("_name", ColumnNamingPolicy.StripUnderscore), "name")
@Expect(ModelCache.applyNamingPolicy("_id", ColumnNamingPolicy.StripUnderscore), "id")
@Expect(ModelCache.applyNamingPolicy("_id", ColumnNamingPolicy.Keep), "_id")
@Expect(ModelCache.applyNamingPolicy("userName", ColumnNamingPolicy.Keep), "userName")
@Expect(ModelCache.applyNamingPolicy("__nickName", ColumnNamingPolicy.SnakeCase), "nick_name")
@Expect(ModelCache.applyNamingPolicy("name2", ColumnNamingPolicy.SnakeCase), "name2")
}
}
// ---------- ChangeTracker 操作队列 ----------
@Test
class ChangeTrackerTests {
@TestCase
public func testQueueOrder(): Unit {
let tracker = ChangeTracker()
let u1 = User()
let u2 = User()
tracker.add(u1, EntityState.Added)
tracker.add(u2, EntityState.Modified)
tracker.add(u1, EntityState.Deleted)
@Expect(tracker.count(), 3)
let entries = tracker.getEntries()
let s0 = match (entries[0].state) { case EntityState.Added => true; case _ => false }
let s1 = match (entries[1].state) { case EntityState.Modified => true; case _ => false }
let s2 = match (entries[2].state) { case EntityState.Deleted => true; case _ => false }
@Expect(s0, true)
@Expect(s1, true)
@Expect(s2, true)
tracker.clear()
@Expect(tracker.count(), 0)
}
}
// ---------- OpenGaussDialect SQL 生成 ----------
@Test
class ISqlDialectTests {
@TestCase
public func testQuoteInsertUpdateDelete(): Unit {
let d = OpenGaussDialect()
@Expect(d.quoteName("users"), "\"users\"")
let c = ArrayList<String>()
c.add("id")
c.add("name")
@Expect(d.buildInsert("users", c, "id", true),
"INSERT INTO \"users\" (\"id\", \"name\") VALUES (?, ?) RETURNING \"id\"")
@Expect(d.buildInsert("users", c, "id", false),
"INSERT INTO \"users\" (\"id\", \"name\") VALUES (?, ?)")
@Expect(d.buildUpdate("users", c, "id"),
"UPDATE \"users\" SET \"id\" = ?, \"name\" = ? WHERE \"id\" = ?")
@Expect(d.buildDelete("users", "id"),
"DELETE FROM \"users\" WHERE \"id\" = ?")
}
@TestCase
public func testSelectAndCount(): Unit {
let d = OpenGaussDialect()
let c = ArrayList<String>()
c.add("id")
c.add("name")
@Expect(d.buildSelect("users", c, "(\"age\" > ?)", "\"name\" ASC", 10, 5),
"SELECT \"id\", \"name\" FROM \"users\" WHERE (\"age\" > ?) ORDER BY \"name\" ASC LIMIT 5 OFFSET 10")
@Expect(d.buildSelect("users", c, "", "", 0, 0),
"SELECT \"id\", \"name\" FROM \"users\"")
let empty = ArrayList<String>()
@Expect(d.buildSelect("users", empty, "", "", 0, 0),
"SELECT * FROM \"users\"")
@Expect(d.buildCount("users", ""),
"SELECT COUNT(*) FROM \"users\"")
@Expect(d.buildCount("users", "(\"age\" > ?)"),
"SELECT COUNT(*) FROM \"users\" WHERE (\"age\" > ?)")
}
}
// ---------- DdlFactory DDL 生成 ----------
@Test
class DdlFactoryTests {
@TestCase
public func testColumnTypeSql(): Unit {
let d = OpenGaussDialect()
@Expect(d.columnTypeSql(ColumnTypes.BigIntCol, true, 0), "BIGSERIAL")
@Expect(d.columnTypeSql(ColumnTypes.BigIntCol, false, 0), "BIGINT")
@Expect(d.columnTypeSql(ColumnTypes.IntCol, true, 0), "SERIAL")
@Expect(d.columnTypeSql(ColumnTypes.IntCol, false, 0), "INTEGER")
@Expect(d.columnTypeSql(ColumnTypes.SmallIntCol, false, 0), "SMALLINT")
@Expect(d.columnTypeSql(ColumnTypes.TinyIntCol, false, 0), "SMALLINT")
@Expect(d.columnTypeSql(ColumnTypes.TextCol, false, 100), "VARCHAR(100)")
@Expect(d.columnTypeSql(ColumnTypes.TextCol, false, 0), "VARCHAR(255)")
@Expect(d.columnTypeSql(ColumnTypes.BoolCol, false, 0), "BOOLEAN")
@Expect(d.columnTypeSql(ColumnTypes.FloatCol, false, 0), "DOUBLE PRECISION")
@Expect(d.columnTypeSql(ColumnTypes.RealCol, false, 0), "REAL")
@Expect(d.columnTypeSql(ColumnTypes.DateTimeCol, false, 0), "TIMESTAMP")
@Expect(d.columnTypeSql(ColumnTypes.DecimalCol, false, 0), "DECIMAL(18, 6)")
@Expect(d.columnTypeSql(ColumnTypes.BinaryCol, false, 0), "BYTEA")
}
@TestCase
public func testDropIndexAndAlterColumn(): Unit {
let d = OpenGaussDialect()
@Expect(d.buildDropIndex("ix_users_name", "users"), "DROP INDEX IF EXISTS \"ix_users_name\"")
@Expect(d.buildAlterColumn("users", "\"name\" VARCHAR(128) NOT NULL"),
"ALTER TABLE \"users\" ALTER COLUMN \"name\" VARCHAR(128) NOT NULL")
}
@TestCase
public func testCreateTable(): Unit {
let f = DdlFactory()
let d = OpenGaussDialect()
let op = MigrationOperation(MigrationOperationKind.CreateTable)
op.tableName = "users"
let id = ColumnDefinition("id", ColumnTypes.BigIntCol)
id.primary()
id.autoInc()
id.notNull()
let name = ColumnDefinition("name", ColumnTypes.TextCol)
name.withMaxLength(100)
name.notNull()
let active = ColumnDefinition("active", ColumnTypes.BoolCol)
active.withDefault(true)
op.columnDefs.add(id)
op.columnDefs.add(name)
op.columnDefs.add(active)
let sql = f.toSql(op, d)
@Expect(sql.contains("CREATE TABLE IF NOT EXISTS \"users\""), true)
@Expect(sql.contains("\"id\" BIGSERIAL NOT NULL PRIMARY KEY"), true)
@Expect(sql.contains("\"name\" VARCHAR(100) NOT NULL"), true)
@Expect(sql.contains("\"active\" BOOLEAN DEFAULT TRUE"), true)
}
@TestCase
public func testAlterOperations(): Unit {
let f = DdlFactory()
let d = OpenGaussDialect()
// ADD COLUMN
let addOp = MigrationOperation(MigrationOperationKind.AddColumn)
addOp.tableName = "users"
let col = ColumnDefinition("email", ColumnTypes.TextCol)
col.withDefault("unknown")
addOp.column = Some(col)
@Expect(f.toSql(addOp, d),
"ALTER TABLE \"users\" ADD COLUMN \"email\" VARCHAR(255) DEFAULT 'unknown'")
// DROP COLUMN
let dropOp = MigrationOperation(MigrationOperationKind.DropColumn)
dropOp.tableName = "users"
dropOp.columnName = "email"
@Expect(f.toSql(dropOp, d), "ALTER TABLE \"users\" DROP COLUMN \"email\"")
// ALTER COLUMN
let alterOp = MigrationOperation(MigrationOperationKind.AlterColumn)
alterOp.tableName = "users"
let nc = ColumnDefinition("name", ColumnTypes.TextCol)
nc.withMaxLength(200)
nc.notNull()
alterOp.column = Some(nc)
@Expect(f.toSql(alterOp, d),
"ALTER TABLE \"users\" ALTER COLUMN \"name\" VARCHAR(200) NOT NULL")
// RENAME COLUMN
let renOp = MigrationOperation(MigrationOperationKind.RenameColumn)
renOp.tableName = "users"
renOp.columnName = "old_name"
renOp.newColumnName = "new_name"
@Expect(f.toSql(renOp, d),
"ALTER TABLE \"users\" RENAME COLUMN \"old_name\" TO \"new_name\"")
}
@TestCase
public func testTableAndIndexAndRaw(): Unit {
let f = DdlFactory()
let d = OpenGaussDialect()
// DROP TABLE
let dropOp = MigrationOperation(MigrationOperationKind.DropTable)
dropOp.tableName = "users"
@Expect(f.toSql(dropOp, d), "DROP TABLE IF EXISTS \"users\"")
// CREATE INDEX(非唯一)
let idxOp = MigrationOperation(MigrationOperationKind.CreateIndex)
idxOp.indexName = "ix_users_name"
idxOp.tableName = "users"
idxOp.columnNames.add("name")
@Expect(f.toSql(idxOp, d),
"CREATE INDEX \"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\")")
// DROP INDEX
let dropIdx = MigrationOperation(MigrationOperationKind.DropIndex)
dropIdx.indexName = "ix_users_name"
@Expect(f.toSql(dropIdx, d), "DROP INDEX IF EXISTS \"ix_users_name\"")
// RAW SQL
let rawOp = MigrationOperation(MigrationOperationKind.RawSql)
rawOp.sql = "SELECT 1"
@Expect(f.toSql(rawOp, d), "SELECT 1")
}
@TestCase
public func testDefaultValueEscape(): Unit {
let f = DdlFactory()
let d = OpenGaussDialect()
let op = MigrationOperation(MigrationOperationKind.AddColumn)
op.tableName = "t"
// 字符串默认值含单引号 → 转义
let s = ColumnDefinition("remark", ColumnTypes.TextCol)
s.withDefault("it's ok")
op.column = Some(s)
@Expect(f.toSql(op, d),
"ALTER TABLE \"t\" ADD COLUMN \"remark\" VARCHAR(255) DEFAULT 'it''s ok'")
// 数值默认值
let op2 = MigrationOperation(MigrationOperationKind.AddColumn)
op2.tableName = "t"
let n = ColumnDefinition("cnt", ColumnTypes.IntCol)
n.withDefault(Int64(18))
op2.column = Some(n)
@Expect(f.toSql(op2, d),
"ALTER TABLE \"t\" ADD COLUMN \"cnt\" INTEGER DEFAULT 18")
// 布尔默认值
let op3 = MigrationOperation(MigrationOperationKind.AddColumn)
op3.tableName = "t"
let b = ColumnDefinition("flag", ColumnTypes.BoolCol)
b.withDefault(false)
op3.column = Some(b)
@Expect(f.toSql(op3, d),
"ALTER TABLE \"t\" ADD COLUMN \"flag\" BOOLEAN DEFAULT FALSE")
}
}
// ---------- MigrationBuilder 链式调用 ----------
@Test
class MigrationBuilderTests {
@TestCase
public func testChainedCalls(): Unit {
// 链式调用 API 面验证:整条链执行不抛异常(内部 operations 由 DdlFactory/Migrator 消费)
let ok = try {
let b = MigrationBuilder()
b.createTable("t1") { tb =>
tb.column("id", ColumnTypes.BigIntCol).primary().autoInc().notNull()
tb.column("name", ColumnTypes.TextCol).withMaxLength(64).withDefault("none")
}
let idxCols = ArrayList<String>()
idxCols.add("name")
b.createIndex("ix_t1_name", "t1", idxCols)
b.addColumn("t1", ColumnDefinition("age", ColumnTypes.IntCol).withDefault(Int64(0)))
b.dropColumn("t1", "age")
b.alterColumn("t1", ColumnDefinition("name", ColumnTypes.TextCol).withMaxLength(128))
b.renameColumn("t1", "name", "nick")
b.dropIndex("ix_t1_name", "t1")
b.dropTable("t1")
b.rawSql("VACUUM")
true
} catch (_: Exception) {
false
}
@Expect(ok, true)
}
}
// ---------- QueryBuilder(假 executor 捕获 SQL ----------
@Test
class QueryBuilderTests {
@TestCase
public func testFilterAndSelect(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let paramsCaptured = ArrayList<ArrayList<Any>>()
let qb = QueryBuilder<User>(model, dialect,
{ sql, params => captured.add(sql); paramsCaptured.add(params); ArrayList<User>() },
{ sql, params => 42 })
qb.filter("_age", ">", Int64(18))
qb.filter("_name", "=", "alice")
let list = qb.toList()
@Expect(list.size, 0)
@Expect(captured.size, 1)
@Expect(captured[0],
"SELECT \"id\", \"name\", \"age\" FROM \"users\" WHERE (\"age\" > ?) AND (\"name\" = ?)")
@Expect(paramsCaptured[0].size, 2)
let p0 = (paramsCaptured[0][0] as Int64).getOrThrow()
@Expect(p0, 18)
let p1 = (paramsCaptured[0][1] as String).getOrThrow()
@Expect(p1, "alice")
}
@TestCase
public func testRawFilter(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let qb = QueryBuilder<User>(model, dialect,
{ sql, params => captured.add(sql); ArrayList<User>() },
{ sql, params => 42 })
let ps = ArrayList<Any>()
ps.add(Int64(18))
ps.add(true)
qb.filter("age > ? AND active = ?", ps)
qb.toList()
@Expect(captured[0],
"SELECT \"id\", \"name\", \"age\" FROM \"users\" WHERE (age > ? AND active = ?)")
}
@TestCase
public func testOrderPaging(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let qb = QueryBuilder<User>(model, dialect,
{ sql, params => captured.add(sql); ArrayList<User>() },
{ sql, params => 42 })
qb.filter("_age", ">", Int64(18))
qb.orderBy("_name")
qb.orderByDesc("_age")
qb.skip(10)
qb.take(5)
qb.toList()
@Expect(captured[0],
"SELECT \"id\", \"name\", \"age\" FROM \"users\" WHERE (\"age\" > ?) ORDER BY \"name\" ASC, \"age\" DESC LIMIT 5 OFFSET 10")
}
@TestCase
public func testCount(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let qb = QueryBuilder<User>(model, dialect,
{ sql, params => captured.add(sql); ArrayList<User>() },
{ sql, params => captured.add(sql); 42 })
qb.filter("_age", ">", Int64(18))
let n = qb.count()
@Expect(n, 42)
@Expect(captured.size, 1)
@Expect(captured[0], "SELECT COUNT(*) FROM \"users\" WHERE (\"age\" > ?)")
}
@TestCase
public func testFirstAndPage(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let qb = QueryBuilder<User>(model, dialect,
{ sql, params => captured.add(sql); ArrayList<User>() },
{ sql, params => captured.add(sql); 42 })
// first() 空结果 → None
let first = qb.filter("_age", ">", Int64(18)).first()
@Expect(first.isNone(), true)
@Expect(captured[0].contains("LIMIT 1"), true)
// page(2, 10):先 count 后 selecttotalPages
let result = qb.filter("_age", ">", Int64(18)).page(2, 10)
@Expect(result.total, 42)
@Expect(result.page, 2)
@Expect(result.pageSize, 10)
@Expect(result.totalPages(), 5)
@Expect(result.items.size, 0)
@Expect(captured[1].startsWith("SELECT COUNT(*)"), true)
@Expect(captured[2].contains("LIMIT 10 OFFSET 10"), true)
}
}
// ---------- 模型生成迁移(MigrationGenerator ----------
@Table["users"]
class UserV1 {
public var _id: Int64 = 0
public var _name: String = ""
}
@Table["users"]
class UserV2 {
public var _id: Int64 = 0
public var _name: String = ""
public var _age: Int32 = 0
}
@Table["items"]
class ItemV1 {
public var _id: Int64 = 0
public var _title: String = ""
}
@Table["items"]
class ItemV2 {
public var _id: Int64 = 0
@MaxLength[100]
public var _title: String = ""
}
@Table["extra"]
class ExtraTable {
public var _id: Int64 = 0
}
@Table["keys"]
class KeyV1 {
public var _id: Int64 = 0
}
@Table["keys"]
class KeyV2 {
@Key
public var _code: String = ""
}
@Test
class MigrationGeneratorTests {
private func gen(): MigrationGenerator {
MigrationGenerator()
}
private func upSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.up(builder)
let f = DdlFactory()
let sqls = ArrayList<String>()
for (op in builder.getOperations()) {
sqls.add(f.toSql(op, dialect))
}
sqls
}
private func downSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.down(builder)
let f = DdlFactory()
let sqls = ArrayList<String>()
for (op in builder.getOperations()) {
sqls.add(f.toSql(op, dialect))
}
sqls
}
@TestCase
public func testInitialMigration(): Unit {
let models = ArrayList<EntityModel>()
models.add(ModelCache.get<User>())
models.add(ModelCache.get<Product>())
let m = gen().initial("20250701000000_InitialCreate", "初始建表", models)
let d = OpenGaussDialect()
let up = upSql(m, d)
@Expect(up.size, 2)
@Expect(up[0], "CREATE TABLE IF NOT EXISTS \"users\" (\n" +
" \"id\" BIGSERIAL NOT NULL PRIMARY KEY,\n" +
" \"name\" VARCHAR(255),\n" +
" \"age\" INTEGER\n)")
@Expect(up[1], "CREATE TABLE IF NOT EXISTS \"Product\" (\n" +
" \"pid\" BIGSERIAL NOT NULL PRIMARY KEY,\n" +
" \"title\" VARCHAR(100) NOT NULL,\n" +
" \"price\" DOUBLE PRECISION,\n" +
" \"active\" BOOLEAN\n)")
// down 为 up 逆序反转
let down = downSql(m, d)
@Expect(down.size, 2)
@Expect(down[0], "DROP TABLE IF EXISTS \"Product\"")
@Expect(down[1], "DROP TABLE IF EXISTS \"users\"")
}
@TestCase
public func testDiffAddColumnAndDropTable(): Unit {
let oldModels = ArrayList<EntityModel>()
oldModels.add(ModelCache.get<UserV1>())
oldModels.add(ModelCache.get<ExtraTable>())
let newModels = ArrayList<EntityModel>()
newModels.add(ModelCache.get<UserV2>())
let m = gen().diff("20250801000000_AddAge", "新增 age 列并删表", oldModels, newModels)
let d = OpenGaussDialect()
let up = upSql(m, d)
@Expect(up.size, 2)
// 已有表加列
@Expect(up[0], "ALTER TABLE \"users\" ADD COLUMN \"age\" INTEGER")
// 删除表
@Expect(up[1], "DROP TABLE IF EXISTS \"extra\"")
// down 逆序反转:先恢复表,再删列
let down = downSql(m, d)
@Expect(down.size, 2)
@Expect(down[0], "CREATE TABLE IF NOT EXISTS \"extra\" (\n \"id\" BIGSERIAL NOT NULL PRIMARY KEY\n)")
@Expect(down[1], "ALTER TABLE \"users\" DROP COLUMN \"age\"")
}
@TestCase
public func testDiffAlterColumn(): Unit {
let oldModels = ArrayList<EntityModel>()
oldModels.add(ModelCache.get<ItemV1>())
let newModels = ArrayList<EntityModel>()
newModels.add(ModelCache.get<ItemV2>())
let m = gen().diff("20250802000000_ChangeTitle", "调整 title 长度", oldModels, newModels)
let d = OpenGaussDialect()
let up = upSql(m, d)
@Expect(up.size, 1)
@Expect(up[0], "ALTER TABLE \"items\" ALTER COLUMN \"title\" VARCHAR(100)")
let down = downSql(m, d)
@Expect(down.size, 1)
@Expect(down[0], "ALTER TABLE \"items\" ALTER COLUMN \"title\" VARCHAR(255)")
}
@TestCase
public func testDiffKeyChangeThrows(): Unit {
let oldModels = ArrayList<EntityModel>()
oldModels.add(ModelCache.get<KeyV1>())
let newModels = ArrayList<EntityModel>()
newModels.add(ModelCache.get<KeyV2>())
let threw = try {
gen().diff("20250803000000_KeyChange", "改主键", oldModels, newModels)
false
} catch (_: Exception) {
true
}
@Expect(threw, true)
}
@TestCase
public func testColumnTypeMapping(): Unit {
@Expect(match (MigrationGenerator.columnTypeFor("String")) { case ColumnTypes.TextCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Bool")) { case ColumnTypes.BoolCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Int8")) { case ColumnTypes.TinyIntCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Int32")) { case ColumnTypes.IntCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Int64")) { case ColumnTypes.BigIntCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Float32")) { case ColumnTypes.RealCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Float64")) { case ColumnTypes.FloatCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("DateTime")) { case ColumnTypes.DateTimeCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Decimal")) { case ColumnTypes.DecimalCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Array<Byte>")) { case ColumnTypes.BinaryCol => true; case _ => false }, true)
let threw = try {
MigrationGenerator.columnTypeFor("Unknown")
false
} catch (_: Exception) {
true
}
@Expect(threw, true)
}
}
// ---------- PostgreSQL 方言 ----------
@Test
class PostgreSqlDialectTests {
@TestCase
public func testPgInsertReturning(): Unit {
let d = PostgreSqlDialect()
let cols = ArrayList<String>()
cols.add("name")
cols.add("age")
@Expect(d.buildInsert("users", cols, "id", true),
"INSERT INTO \"users\" (\"name\", \"age\") VALUES (?, ?) RETURNING \"id\"")
@Expect(d.buildInsert("users", cols, "id", false),
"INSERT INTO \"users\" (\"name\", \"age\") VALUES (?, ?)")
}
@TestCase
public func testPgCrudSql(): Unit {
let d = PostgreSqlDialect()
let cols = ArrayList<String>()
cols.add("name")
@Expect(d.buildUpdate("users", cols, "id"),
"UPDATE \"users\" SET \"name\" = ? WHERE \"id\" = ?")
@Expect(d.buildDelete("users", "id"),
"DELETE FROM \"users\" WHERE \"id\" = ?")
@Expect(d.buildCount("users", ""),
"SELECT COUNT(*) FROM \"users\"")
@Expect(d.buildSelect("users", ArrayList<String>(), "\"age\" > ?", "\"id\"", 10, 5),
"SELECT * FROM \"users\" WHERE \"age\" > ? ORDER BY \"id\" LIMIT 5 OFFSET 10")
}
@TestCase
public func testPgDdlSql(): Unit {
let d = PostgreSqlDialect()
@Expect(d.columnTypeSql(ColumnTypes.BigIntCol, true, 0), "BIGSERIAL")
@Expect(d.columnTypeSql(ColumnTypes.IntCol, true, 0), "SERIAL")
@Expect(d.columnTypeSql(ColumnTypes.TextCol, false, 100), "VARCHAR(100)")
@Expect(d.columnTypeSql(ColumnTypes.TextCol, false, 0), "VARCHAR(255)")
@Expect(d.columnTypeSql(ColumnTypes.BinaryCol, false, 0), "BYTEA")
@Expect(d.buildAlterColumn("users", "VARCHAR(100)"),
"ALTER TABLE \"users\" ALTER COLUMN VARCHAR(100)")
@Expect(d.buildDropIndex("ix_users_name", "users"),
"DROP INDEX IF EXISTS \"ix_users_name\"")
}
@TestCase
public func testOpenGaussInheritsPg(): Unit {
let d = OpenGaussDialect()
let cols = ArrayList<String>()
cols.add("name")
@Expect(d.buildInsert("users", cols, "id", true),
"INSERT INTO \"users\" (\"name\") VALUES (?) RETURNING \"id\"")
}
@TestCase
public func testPgCreateDatabaseSql(): Unit {
let d = PostgreSqlDialect()
@Expect(d.createDatabaseSql("mydb"), "CREATE DATABASE \"mydb\"")
@Expect(d.createDatabaseSql("my_db"), "CREATE DATABASE \"my_db\"")
@Expect(d.databaseExistsSql(),
"SELECT 1 FROM pg_database WHERE datname = ?")
// openGauss 继承 PG
let og = OpenGaussDialect()
@Expect(og.createDatabaseSql("mydb"), "CREATE DATABASE \"mydb\"")
}
}
// ---------- 应用继承 DbContext ----------
class FakeDatasource <: Datasource {
public func setOption(key: String, value: String): Unit {}
public func connect(): Connection {
throw Exception("FakeDatasource 不支持真实连接")
}
public func isClosed(): Bool {
false
}
public func close(): Unit {}
public func ping(): Unit {}
}
class AppDbContext <: DbContext {
let users: DbSet<User>
let orders: DbSet<Order>
init(ds: Datasource) {
super(ds)
users = set<User>()
orders = set<Order>()
}
init(ds: Datasource, dialect: ISqlDialect) {
super(ds, dialect)
users = set<User>()
orders = set<Order>()
}
}
@Test
class DbContextInheritTests {
@TestCase
public func testInheritAndDbSets(): Unit {
let ctx = AppDbContext(FakeDatasource())
// 继承后暴露的 DbSet 属性可用
let users = ctx.users
let orders = ctx.orders
@Expect(users is DbSet<User>, true)
@Expect(orders is DbSet<Order>, true)
// DbSet 操作连通到 context 的变更队列
users.add(User())
@Expect(ctx.pendingCount(), 1)
orders.add(Order())
@Expect(ctx.pendingCount(), 2)
// 默认方言为 openGauss(继承 PG
@Expect(ctx.getDialect().quoteName("users"), "\"users\"")
}
@TestCase
public func testCustomDialectAndMigrateApi(): Unit {
let ctx = AppDbContext(FakeDatasource(), PostgreSqlDialect())
@Expect(ctx.getDialect().quoteName("users"), "\"users\"")
@Expect(ctx.pendingCount(), 0)
// migrate 委托 MigratorFakeDatasource 连接时会抛异常
let threw = try {
ctx.migrate(ArrayList<Migration>())
false
} catch (_: Exception) {
true
}
@Expect(threw, true)
}
}
// ---------- 模型快照 ----------
@Test
class ModelSnapshotTests {
private func gen(): MigrationGenerator {
MigrationGenerator()
}
private func models(): ArrayList<EntityModel> {
let ms = ArrayList<EntityModel>()
ms.add(ModelCache.get<User>())
ms.add(ModelCache.get<Product>())
ms
}
private func upSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.up(builder)
let f = DdlFactory()
let sqls = ArrayList<String>()
for (op in builder.getOperations()) {
sqls.add(f.toSql(op, dialect))
}
sqls
}
@TestCase
public func testCaptureToJsonRoundTrip(): Unit {
let snap = ModelSnapshot.capture(models())
let json = snap.toJson()
// 关键元数据落到 JSON
@Expect(json.contains("\"table\":\"users\""), true)
@Expect(json.contains("\"column\":\"id\""), true)
@Expect(json.contains("\"type\":\"Int64\""), true)
@Expect(json.contains("\"key\":true"), true)
@Expect(json.contains("\"maxLen\":100"), true)
@Expect(json.contains("\"required\":true"), true)
let back = ModelSnapshot.fromJson(json)
@Expect(back.models.size, 2)
@Expect(back.models[0].tableName, "users")
@Expect(back.models[1].tableName, "Product")
// 轻量模型无反射句柄,typeName 走 typeNameOverride
let lm = back.models[0]
@Expect(lm.typeInfo.isNone(), true)
@Expect(lm.keyProperty.getOrThrow().columnName, "id")
@Expect(lm.keyProperty.getOrThrow().autoIncrement, true)
// Product:类型 / 长度 / 必填 元数据保留
let pm = back.models[1]
@Expect(pm.properties[0].columnName, "pid")
@Expect(pm.properties[0].typeName(), "Int64")
@Expect(pm.properties[1].columnName, "title")
@Expect(pm.properties[1].typeName(), "String")
@Expect(pm.properties[1].maxLength, 100)
@Expect(pm.properties[1].isRequired, true)
}
@TestCase
public func testSaveLoadFile(): Unit {
let path = "snapshot_test_tmp.json"
if (exists(Path(path))) { remove(Path(path)) }
ModelSnapshot.capture(models()).save(path)
@Expect(exists(Path(path)), true)
let loaded = ModelSnapshot.load(path)
@Expect(loaded.isSome(), true)
@Expect(loaded.getOrThrow().models.size, 2)
@Expect(loaded.getOrThrow().models[1].properties[1].maxLength, 100)
// 文件不存在 → None
if (exists(Path(path))) { remove(Path(path)) }
@Expect(ModelSnapshot.load(path).isNone(), true)
}
@TestCase
public func testSnapshotModelsWorkWithDiff(): Unit {
let snap = ModelSnapshot.capture(models())
// 快照轻量模型 vs 反射模型:无变化 → 空操作
let m = gen().diff("20250901000000_NoChange", "无变化", snap.toModels(), models())
let d = OpenGaussDialect()
@Expect(upSql(m, d).size, 0)
}
@TestCase
public func testEnsureInitialThenNoChange(): Unit {
let path = "snapshot_test_tmp.json"
if (exists(Path(path))) { remove(Path(path)) }
let d = OpenGaussDialect()
// 首次:快照不存在 → initial 全量建表 + 保存快照
let m1 = gen().ensure("20250901000000_Initial", "初始建表", path, models())
let up1 = upSql(m1, d)
@Expect(up1.size, 2)
@Expect(up1[0].startsWith("CREATE TABLE IF NOT EXISTS \"users\""), true)
@Expect(exists(Path(path)), true)
@Expect(ModelSnapshot.load(path).getOrThrow().models.size, 2)
// 二次:模型未变 → diff 空操作(依赖已存快照,无需调用方手存旧模型)
let m2 = gen().ensure("20250902000000_NoChange", "无变化", path, models())
@Expect(upSql(m2, d).size, 0)
if (exists(Path(path))) { remove(Path(path)) }
}
@TestCase
public func testEnsureDiffWithNewTable(): Unit {
let path = "snapshot_test_tmp.json"
if (exists(Path(path))) { remove(Path(path)) }
let d = OpenGaussDialect()
gen().ensure("20250901000000_Initial", "初始建表", path, models())
// 模型新增 items 表后二次 ensure → 自动 diff 出 CreateTable
let ms2 = models()
ms2.add(ModelCache.get<ItemV2>())
let m = gen().ensure("20250903000000_AddItems", "新增 items", path, ms2)
let up = upSql(m, d)
@Expect(up.size, 1)
@Expect(up[0].startsWith("CREATE TABLE IF NOT EXISTS \"items\""), true)
// 快照已覆盖为 3 个模型
@Expect(ModelSnapshot.load(path).getOrThrow().models.size, 3)
if (exists(Path(path))) { remove(Path(path)) }
}
}
+72
View File
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 变更跟踪(ChangeTracker)。
*
* 设计说明:Cangjie 无内建对象身份比较(无 isSame/===/IdentityHashMap,类默认不实现 ==),
* 因此本库 v1 采用「操作队列」模型:add/update/remove 记录实体 + 状态快照,
* saveChanges 时按入队顺序逐条生成 SQL 执行。与 EF Core 的 identity map 不同,
* 同一实体重复入队会按顺序重复执行(如先 add 再 update = INSERT + UPDATE)。
* 请勿对同一实体的同一种操作重复调用。
*/
package simcu::orm.tracking
import std.collection.*
/**
* 实体状态(对齐 EF Core EntityState)。
*/
public enum EntityState {
/// 未跟踪
| Detached
/// 未变更
| Unchanged
/// 新增(INSERT
| Added
/// 修改(UPDATE 全列)
| Modified
/// 删除(DELETE
| Deleted
}
/**
* 一条待处理的实体操作记录。
*/
public class EntityEntry {
/// 实体实例
public let entity: Any
/// 操作状态
public let state: EntityState
public init(entity: Any, state: EntityState) {
this.entity = entity
this.state = state
}
}
/**
* 操作队列:保存待提交的实体变更。
*/
public class ChangeTracker {
private let _entries = ArrayList<EntityEntry>()
/// 入队一条变更
public func add(entity: Any, state: EntityState): Unit {
_entries.add(EntityEntry(entity, state))
}
/// 全部待处理条目(保序)
public func getEntries(): ArrayList<EntityEntry> {
_entries
}
/// 待处理条目数
public func count(): Int64 {
_entries.size
}
/// 清空队列(提交后调用)
public func clear(): Unit {
_entries.clear()
}
}