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