commit afa6096ed223d1f4a0e3f163a5772f5f026383b4 Author: xRain Date: Wed Aug 19 09:14:01 2026 +0800 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 个单元测试全部通过 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c01bd4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# 构建产物 +target/ +# 临时/日志 +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a0788b --- /dev/null +++ b/README.md @@ -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` 声明变成 `public prop` 并注入 `this.set()`、生成 `(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 + public var orders: DbSet +} + +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 + 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 { get() { this.set() } }`,并 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() // 索引列(API 为 ArrayList) + 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() + models.add(ModelCache.get()) + models.add(ModelCache.get()) + let m0 = gen.initial("20250701000000_InitialCreate", "初始建表", models) + Migrator(datasource).migrate([m0]) + + // 2. 模型变更后:旧模型快照(上次的 models) vs 新模型 → 增量迁移(表/列增删改) + let models2 = ArrayList() + models2.add(ModelCache.get()) // 假设 User 新增了 _age 字段 + models2.add(ModelCache.get()) + 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→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 + public var orders: DbSet +} + +main(args: Array) { + 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 -- ` 是把参数传给应用的方式): + +| 命令 | 作用 | +|------|------| +| `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(条件/排序/分页)、PagedResult +│ ├── db/ # DbContext(连接 + 提交 + 物化 + 数据库存在性/迁移状态检查)、DbSet +│ ├── 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`。 + +### 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 + public var orders: DbSet +} + +// 用法(宏生成 (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 { get() { this.set() } }`,补构造并 override `migrations()`: + +```cangjie +public class AppDbContext <: DbContext { + public let users: DbSet + public let orders: DbSet + + public init(driverName: String, connStr: String) { super(driverName, connStr) } + public init(datasource: Datasource) { super(datasource) } + public override func migrations(): ArrayList { /* 迁移列表 */ } +} +``` + +| API | 说明 | +|-----|------| +| `AppDbContext("pgsql", connStr)` | 构造:驱动名 + 连接串(驱动构建收敛在 `DatasourceFactory`;驱动名归一化,`pgsql/pg/postgresql`→`postgres`,具体驱动由应用链接的驱动包注册) | +| `AppDbContext(connStr)` | 构造:只给连接串,自动探测驱动(默认 openGauss 方言) | +| `AppDbContext(datasource)` | 构造:直接给驱动 Datasource(默认 `OpenGaussDialect`,继承 PG) | +| `AppDbContext(datasource, dialect)` | 构造:自定义方言,如 `PostgreSqlDialect()` | +| `db.set()` | 获取实体的 DbSet(同类型复用同一实例;宏展开的 prop 内部即调用它) | +| `db.pendingCount()` | 待提交变更条数 | +| `db.saveChanges()` | 事务内按入队顺序执行全部变更,返回影响条数 | +| `db.getDialect()` | 当前方言(子类/应用可读取) | +| `db.migrate(migrations)` | 应用指定迁移(委托 `Migrator`),返回本次应用数量 | +| `db.migrate()` | 便捷版:应用子类 `migrations()` 提供的全部迁移 | +| `db.databaseExists()` | 数据库是否存在(连系统库参数化查询,需带连接串构造;sqlite 查文件) | +| `db.hasPendingMigrations()` | 是否存在未应用的迁移(数据库/历史表不存在视为有待应用迁移) | +| `set().add(e)` | 入队新增 | +| `set().update(e)` | 入队修改(UPDATE 全部非主键列) | +| `set().remove(e)` | 入队删除 | +| `set().find(key)` | 按主键查(`?T`) | +| `set().toList()` | 全表查询 | +| `set().count()` | 全表计数 | +| `set().query()` | 构建查询 | + +> **语义说明(v1,操作队列模式)**:`add/update/remove` 只入队,`saveChanges` 时开启事务按入队顺序逐条执行后清空队列。与 EF Core 的 identity map 不同,同一实体重复入队会重复执行(如先 `add` 再 `update` = INSERT + UPDATE),请勿对同一实体的同一种操作重复调用。 + +### 4. 查询构建器 — QueryBuilder(对齐 EF Core IQueryable 常用子集) + +```cangjie +let qb = db.users.query() // 宏方式直接 db.users;等价 db.set().query() + +qb.filter("_age", ">", Int64(18)) // (属性名, 操作符, 值):自动映射列名 +let ps = ArrayList() // (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 +qb.first() // ?User +qb.count() // Int64,满足条件的总数 +qb.page(2, 10) // PagedResult(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 ''`。规避:默认值改用 `= 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 diff --git a/cjpm.lock b/cjpm.lock new file mode 100644 index 0000000..c42311c --- /dev/null +++ b/cjpm.lock @@ -0,0 +1,3 @@ +version = 0 + +[requires] diff --git a/cjpm.toml b/cjpm.toml new file mode 100644 index 0000000..fe1e98b --- /dev/null +++ b/cjpm.toml @@ -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}" ] diff --git a/src/Orm.cj b/src/Orm.cj new file mode 100644 index 0000000..64b398d --- /dev/null +++ b/src/Orm.cj @@ -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.* diff --git a/src/annotations/Annotations.cj b/src/annotations/Annotations.cj new file mode 100644 index 0000000..86d34db --- /dev/null +++ b/src/annotations/Annotations.cj @@ -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 + } +} diff --git a/src/cli/CliContext.cj b/src/cli/CliContext.cj new file mode 100644 index 0000000..132193a --- /dev/null +++ b/src/cli/CliContext.cj @@ -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 属性反射收集) + func getModels(): ArrayList + /// 应用主数据源 + func getDatasource(): Datasource + /// SQL 方言 + func getDialect(): ISqlDialect + /// 迁移列表(子类 override migrations() 提供) + func getMigrations(): ArrayList + /// 应用所有迁移 + func migrateAll(migrations: ArrayList): Int64 +} diff --git a/src/cli/MigrationCli.cj b/src/cli/MigrationCli.cj new file mode 100644 index 0000000..1dbb9ee --- /dev/null +++ b/src/cli/MigrationCli.cj @@ -0,0 +1,401 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 迁移 CLI(应用内驱动,对齐 dotnet ef 的命令体验)。 + * + * 基于 DbContext 实例的一行接入(无需宏、无需回调): + * main(args: Array) { + * let db = DataContext("pgsql", "Host=..;Database=..;Username=..;Password=..") + * if (db.cli(args)) { return } + * // 正常启动 + * } + * + * 数据来源全部来自 DbContext 实例: + * - 模型:反射收集实例的 DbSet 属性(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): 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, models: () -> ArrayList, + datasource: () -> Datasource, migrations: () -> ArrayList): Int64 { + let cli = MigrationCli() + cli.dispatch(args, models, datasource, migrations, OpenGaussDialect()) + } + + /// 应用入口一行接入:`cjpm run -- orm <命令>` 时执行 CLI 并返回 true; + /// 否则(正常启动)返回 false。用法:main() 最上方一行,如 + /// if (MigrationCli.tryRun(ArrayList(args), { => models() }, + /// { => datasource() }, { => migrations()})) { return } + public static func tryRun(args: ArrayList, models: () -> ArrayList, + datasource: () -> Datasource, migrations: () -> ArrayList): 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, models: () -> ArrayList, + datasource: () -> Datasource, migrations: () -> ArrayList, + 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, models: () -> ArrayList): 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 = .migrations): + // 注册文件在子包内,顶层 let 引用同包迁移类,无需 import; + // 应用侧一行 `import .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, 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, + dialect: ISqlDialect, args: ArrayList): 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): 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() + 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() + 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() + 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, + 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(" add <迁移名> 从模型生成迁移 .cj 文件,更新快照与注册(无变化则跳过)") + println(" rm <迁移名或id> 移除已生成的迁移(删除迁移文件 + 注册条目 + 快照回退)") + println(" update 应用所有未执行的迁移") + println(" downgrade [目标] 回退迁移:无目标=回退最近一个;有目标=回退到该迁移之后") + println(" list 列出已应用/待应用迁移") + println(" help 显示本帮助") + } +} diff --git a/src/cli/MigrationFileGenerator.cj b/src/cli/MigrationFileGenerator.cj new file mode 100644 index 0000000..261ad88 --- /dev/null +++ b/src/cli/MigrationFileGenerator.cj @@ -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 { + 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 { + let result = ArrayList() + 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() + 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 { + 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, needle: Array, 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 + } +} diff --git a/src/db/DatasourceFactory.cj b/src/db/DatasourceFactory.cj new file mode 100644 index 0000000..ee56b15 --- /dev/null +++ b/src/db/DatasourceFactory.cj @@ -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" + } +} diff --git a/src/db/DbContext.cj b/src/db/DbContext.cj new file mode 100644 index 0000000..a14e60a --- /dev/null +++ b/src/db/DbContext.cj @@ -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().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) { + * 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() + 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 { + ArrayList() + } + + /// CLI 入口:` orm <命令>` 时执行迁移 CLI 并返回 true;否则返回 false(正常启动)。 + /// 模型从本实例的 DbSet 属性反射收集,连接/驱动/方言来自构造参数。 + public func cli(args: Array): Bool { + if (args.size == 0 || args[0] != "orm") { + return false + } + let list = ArrayList() + for (i in 1..args.size) { + list.add(args[i]) + } + MigrationCli.runOn(this, list) + true + } + + /// 反射收集本实例所有 DbSet 的模型(DataContext 的 DbSet 字段/prop 均可) + public func getModels(): ArrayList { + let result = ArrayList() + 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 { + 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): Int64 { + migrate(migrations) + } + + /// 获取实体的 DbSet(同一实体类型复用同一个 DbSet 实例) + public func set(): DbSet { + let key = TypeInfo.of().toString() + if (let Some(existing) <- _sets.get(key)) { + return (existing as DbSet).getOrThrow() + } + let ds = DbSet(this, ModelCache.get()) + _sets[key] = ds + ds + } + + /// 待提交的变更条数 + public func pendingCount(): Int64 { + tracker.count() + } + + /// 当前方言(子类/应用可读取) + public func getDialect(): ISqlDialect { + dialect + } + + /// 应用迁移(对齐 EF Core Database.Migrate),返回本次应用的数量 + public func migrate(migrations: ArrayList): 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(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(model: EntityModel, sql: String, params: ArrayList): ArrayList { + let conn = datasource.connect() + try { + let stmt = conn.prepareStatement(sql) + try { + ParamBinder.bind(stmt, params) + let rs = stmt.query() + let list = ArrayList() + 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): 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(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() + let values = ArrayList() + 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() + let values = ArrayList() + 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(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 <: 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 { + QueryBuilder(_model, _context.getDialect(), + { sql, params => _context.query(_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 { + query().toList() + } + + /// 全表计数 + public func count(): Int64 { + query().count() + } +} diff --git a/src/macros/DbContextMacro.cj b/src/macros/DbContextMacro.cj new file mode 100644 index 0000000..743d02a --- /dev/null +++ b/src/macros/DbContextMacro.cj @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * @DbContext 类级宏:把纯声明的 DataContext 类展开为完整的 DbContext 子类。 + * + * 用法(对齐 EF Core 声明式体验): + * @DbContext + * public class DataContext { + * public var users: DbSet + * public var bots: DbSet + * } + * + * 展开行为: + * - 自动补 `<: DbContext`(标注的类不能自带继承) + * - 每个 DbSet 字段/无 getter prop -> `public prop name: DbSet { get() { this.set() } }` + * - 自动生成 `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 字段/无 getter prop -> public prop { get() { this.set() } } + let newDecls = ArrayList() + 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 {\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",判断是否为 DbSet 类型 +private func isDbSetType(t: String): Bool { + t.indexOf("DbSet") == Some(0) && t.indexOf("<").getOrThrow() > 0 +} + +/// 生成 `public prop name: DbSet { get() { this.set() } }` +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] +} diff --git a/src/migrations/MigrationGenerator.cj b/src/migrations/MigrationGenerator.cj new file mode 100644 index 0000000..21018b1 --- /dev/null +++ b/src/migrations/MigrationGenerator.cj @@ -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): Migration { + let m = Migration(migrationId, description) + let upOps = ArrayList() + let downOps = ArrayList() + 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, newModels: ArrayList): Migration { let m = Migration(migrationId, description) + let upOps = ArrayList() + let downOps = ArrayList() + + 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): 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) + 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" => 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, downOps: ArrayList, + 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() + for (p in old.properties) { + oldCols[p.columnName] = p + } + var curCols = HashMap() + 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): HashMap { + let map = HashMap() + for (model in models) { + map[model.tableName] = model + } + map + } + + /// 注入 up/down:down 按 up 逆序反转后存储(down() 按列表顺序执行) + private static func setPresets(m: Migration, upOps: ArrayList, + downOps: ArrayList): 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 + } +} diff --git a/src/migrations/MigrationRegistry.cj b/src/migrations/MigrationRegistry.cj new file mode 100644 index 0000000..5a7c392 --- /dev/null +++ b/src/migrations/MigrationRegistry.cj @@ -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() + +/// 注册一个迁移实例(按 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 { + let list = ArrayList() + 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 +} diff --git a/src/migrations/Migrations.cj b/src/migrations/Migrations.cj new file mode 100644 index 0000000..4a96900 --- /dev/null +++ b/src/migrations/Migrations.cj @@ -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 = ArrayList() + public var indexName: String = "" + public var columnNames: ArrayList = 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() + + 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() + + public init() {} + + /// 当前累积的操作列表(供执行器/测试读取) + public func getOperations(): ArrayList { + 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): MigrationBuilder { + createIndex(indexName, table, columns, false) + } + + public func createIndex(indexName: String, table: String, columns: ArrayList, + 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() + /// 由 MigrationGenerator 注入的 down 操作(按序执行,已按 up 逆序反转) + internal var presetDownOperations = ArrayList() + + 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() + } +} diff --git a/src/migrations/Migrator.cj b/src/migrations/Migrator.cj new file mode 100644 index 0000000..72c763b --- /dev/null +++ b/src/migrations/Migrator.cj @@ -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): 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): ArrayList { + let applied = loadApplied() + let sorted = sortMigrations(migrations) + let result = ArrayList() + for (m in sorted) { + if (!applied.contains(m.migrationId)) { + result.add(m) + } + } + result + } + + /// 回退最近一个已应用的迁移(执行 down + 删除历史记录),返回回退的数量 + public func revert(migrations: ArrayList): Int64 { + revert(migrations, None) + } + + /// 回退到目标迁移(不含目标):执行目标之后全部已应用迁移的 down。 + /// target 匹配规则:精确(migrationId / description)或唯一前缀(migrationId)。 + /// 返回回退的数量;无已应用迁移或已处于目标时返回 0。 + public func revert(migrations: ArrayList, target: ?String): Int64 { + ensureHistoryTable() + let applied = loadApplied() + if (applied.size == 0) { + return 0 + } + let sorted = sortMigrations(migrations) + // 已应用迁移(按 id 升序) + let appliedList = ArrayList() + 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, target: String): Int64 { + var exact: Int64 = -1 + let prefixHits = ArrayList() + 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(0, m.migrationId) + stmt.update() + } finally { + stmt.close() + } + } + + /// 已应用的迁移 id 列表(字典序,供 CLI list 使用) + public func appliedMigrationIds(): ArrayList { + let ids = loadApplied() + let result = ArrayList() + 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 { + 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() + while (rs.next()) { + ids.add(rs.getOrNull(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(0, m.migrationId) + stmt.set(1, if (m.description.isEmpty()) { m.migrationId } else { m.description }) + stmt.update() + } finally { + stmt.close() + } + } + + private static func sortMigrations(migrations: ArrayList): ArrayList { + 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() + for (m in arr) { + result.add(m) + } + result + } +} diff --git a/src/migrations/MiniJson.cj b/src/migrations/MiniJson.cj new file mode 100644 index 0000000..f39c549 --- /dev/null +++ b/src/migrations/MiniJson.cj @@ -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) + | JArray(ArrayList) + | 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() + 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() + 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 字符") + } +} diff --git a/src/migrations/ModelSnapshot.cj b/src/migrations/ModelSnapshot.cj new file mode 100644 index 0000000..81b82d3 --- /dev/null +++ b/src/migrations/ModelSnapshot.cj @@ -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 = ArrayList() + /// 快照格式版本 + public static let formatVersion: Int64 = 1 + + public init() {} + + /// 从真实反射模型捕获元数据快照(剥离反射句柄) + public static func capture(models: ArrayList): 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 { + models + } + + /// 序列化为 JSON 文本 + public func toJson(): String { + let root = HashMap() + root["version"] = JNumber("${formatVersion}") + let modelsArr = ArrayList() + for (m in models) { + let mo = HashMap() + mo["table"] = JString(m.tableName) + let cols = ArrayList() + for (p in m.properties) { + let co = HashMap() + 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, k: String): String { + match (o.get(k)) { + case Some(JString(s)) => s + case _ => "" + } + } + + private static func getBool(o: HashMap, k: String): Bool { + match (o.get(k)) { + case Some(JBool(b)) => b + case _ => false + } + } + + private static func getLong(o: HashMap, k: String): Int64 { + match (o.get(k)) { + case Some(JNumber(n)) => Int64.parse(n) + case Some(JString(s)) => Int64.parse(s) + case _ => 0 + } + } +} diff --git a/src/model/EntityModel.cj b/src/model/EntityModel.cj new file mode 100644 index 0000000..b88e690 --- /dev/null +++ b/src/model/EntityModel.cj @@ -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 = 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() + 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(): EntityModel { + getFor(TypeInfo.of()) + } + + /// 按类型取模型 + 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()) { + model.tableName = t.name + } + // 字段映射 + for (v in ct.instanceVariables) { + if (v.findAnnotation().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") + } + // 列名 + if (let Some(c) <- v.findAnnotation()) { + propM.columnName = c.name + } else { + propM.columnName = applyNamingPolicy(v.name, _namingPolicy) + } + // 主键 + if (v.findAnnotation().isSome()) { + propM.isKey = true + } else if (v.name == "_id" || v.name == "id") { + propM.isKey = true + } + // 自增 / 客户端生成 + if (v.findAnnotation().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().isSome() + if (let Some(m) <- v.findAnnotation()) { + propM.maxLength = m.maxLength + } + model.properties.add(propM) + } + // 主键校验(v1 仅支持单主键) + var keys = ArrayList() + 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() + 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() + } +} diff --git a/src/model/ValueReader.cj b/src/model/ValueReader.cj new file mode 100644 index 0000000..9d88f0d --- /dev/null +++ b/src/model/ValueReader.cj @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 结果集读取器:按属性类型名分发调用驱动 Rows.getOrNull(驱动按泛型参数 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(index)) + case "Bool" => wrap(rows.getOrNull(index)) + case "Int8" => wrap(rows.getOrNull(index)) + case "Int16" => wrap(rows.getOrNull(index)) + case "Int32" => wrap(rows.getOrNull(index)) + case "Int64" => wrap(rows.getOrNull(index)) + case "UInt8" => wrap(rows.getOrNull(index)) + case "UInt16" => wrap(rows.getOrNull(index)) + case "UInt32" => wrap(rows.getOrNull(index)) + case "UInt64" => wrap(rows.getOrNull(index)) + case "Float32" => wrap(rows.getOrNull(index)) + case "Float64" => wrap(rows.getOrNull(index)) + case "Rune" => wrap(rows.getOrNull(index)) + case "DateTime" => wrap(rows.getOrNull(index)) + case "Duration" => wrap(rows.getOrNull(index)) + case "Decimal" => wrap(rows.getOrNull(index)) + case "Array" => wrap(rows.getOrNull>(index)) + case _ => throw Exception("simorm: 不支持的实体字段类型 ${typeName}") + } + } + + /// ?T → ?Any(Option 不自动协变,需显式包装) + private static func wrap(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" => + true + case _ => false + } + } +} + +/** + * 参数绑定:把 ORM 参数(Any,可能为装箱 None)按值绑定到 Statement。 + * 驱动对 null 需显式 setNull,不能直接 set(None)。 + */ +public class ParamBinder { + private init() {} + + public static func bind(stmt: Statement, params: ArrayList): 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(index, v) + } else { + stmt.setNull(index) + } + } else { + stmt.set(index, raw) + } + } +} diff --git a/src/query/QueryBuilder.cj b/src/query/QueryBuilder.cj new file mode 100644 index 0000000..88fa9d5 --- /dev/null +++ b/src/query/QueryBuilder.cj @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 查询构建器:条件/排序/分页 → SQL,委托执行回调物化结果。 + * + * 解耦设计:QueryBuilder 不直接依赖 DbContext/驱动。 + * 构造时传入 执行回调 (sql, params) -> ArrayList,由 DbContext 提供。 + */ + +package simcu::orm.query + +import std.collection.* +import simcu::orm.model.* +import simcu::orm.sql.* + +/** + * 分页结果。 + */ +public class PagedResult { + /// 当前页数据 + public var items: ArrayList = ArrayList() + /// 满足条件的总条数 + public var total: Int64 = 0 + /// 页码(从 1 开始) + public var page: Int64 = 1 + /// 每页条数 + public var pageSize: Int64 = 20 + + public init() {} + + public init(items: ArrayList, 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 { + private let _model: EntityModel + private let _dialect: ISqlDialect + private let _executor: (String, ArrayList) -> ArrayList + private let _counter: (String, ArrayList) -> Int64 + + private let _whereParts = ArrayList() + private let _params = ArrayList() + private let _orderByParts = ArrayList() + private var _skip: Int64 = 0 + private var _take: Int64 = 0 + + public init(model: EntityModel, dialect: ISqlDialect, executor: (String, ArrayList) -> ArrayList, + counter: (String, ArrayList) -> Int64) { + this._model = model + this._dialect = dialect + this._executor = executor + this._counter = counter + } + + /// filter("age > ?", [18]):原始片段,参数按 ? 顺序;片段中请直接写数据库列名 + public func filter(condition: String, params: ArrayList): QueryBuilder { + _whereParts.add("(${condition})") + for (p in params) { + _params.add(p) + } + this + } + + /// filter("age", ">", 18):属性名/列名自动映射为列名 + public func filter(property: String, op: String, value: Any): QueryBuilder { + let col = _model.mapColumn(property) + _whereParts.add("(${_dialect.quoteName(col)} ${op} ?)") + _params.add(value) + this + } + + public func orderBy(property: String): QueryBuilder { + _orderByParts.add("${_dialect.quoteName(_model.mapColumn(property))} ASC") + this + } + + public func orderByDesc(property: String): QueryBuilder { + _orderByParts.add("${_dialect.quoteName(_model.mapColumn(property))} DESC") + this + } + + public func skip(n: Int64): QueryBuilder { + _skip = n + this + } + + public func take(n: Int64): QueryBuilder { + _take = n + this + } + + /// 执行查询,返回实体列表 + public func toList(): ArrayList { + _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 { + let total = count() + let savedSkip = _skip + let savedTake = _take + _skip = (page - 1) * pageSize + _take = pageSize + let items = toList() + _skip = savedSkip + _take = savedTake + PagedResult(items, total, page, pageSize) + } + + private func buildSelectSql(): String { + _dialect.buildSelect(_model.tableName, selectColumns(), whereSql(), orderBySql(), _skip, _take) + } + + /// 物化列(与属性顺序一致,结果集按此索引读取) + private func selectColumns(): ArrayList { + let cols = ArrayList() + 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 { + let copy = ArrayList() + for (p in _params) { + copy.add(p) + } + copy + } +} diff --git a/src/sql/ColumnTypes.cj b/src/sql/ColumnTypes.cj new file mode 100644 index 0000000..516a6cd --- /dev/null +++ b/src/sql/ColumnTypes.cj @@ -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 +} diff --git a/src/sql/ISqlDialect.cj b/src/sql/ISqlDialect.cj new file mode 100644 index 0000000..68a5c4b --- /dev/null +++ b/src/sql/ISqlDialect.cj @@ -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, keyColumn: String, returningKey: Bool): String + + /// UPDATE:SET 全部非主键列,WHERE 主键 + func buildUpdate(table: String, columns: ArrayList, keyColumn: String): String + + /// DELETE:WHERE 主键 + func buildDelete(table: String, keyColumn: String): String + + /// SELECT;take <= 0 表示不限制 + func buildSelect(table: String, columns: ArrayList, 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 diff --git a/src/sql/OpenGaussDialect.cj b/src/sql/OpenGaussDialect.cj new file mode 100644 index 0000000..f31a378 --- /dev/null +++ b/src/sql/OpenGaussDialect.cj @@ -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() {} +} diff --git a/src/sql/PostgreSqlDialect.cj b/src/sql/PostgreSqlDialect.cj new file mode 100644 index 0000000..a714bb1 --- /dev/null +++ b/src/sql/PostgreSqlDialect.cj @@ -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, 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, 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, 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 = ?" + } +} diff --git a/src/tests/MigrationCli_test.cj b/src/tests/MigrationCli_test.cj new file mode 100644 index 0000000..9979056 --- /dev/null +++ b/src/tests/MigrationCli_test.cj @@ -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() + 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() + 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() + oldModels.add(buildUserModel()) + let newModels = ArrayList() + 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() + 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() + 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(), { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(rc == 0) + } + + @TestCase + public func testUnknownCommandReturnsOne(): Unit { + let args = ArrayList() + args.add("bogus") + let rc = MigrationCli.run(args, { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(rc == 1) + } + + @TestCase + public func testAddMissingNameReturnsOne(): Unit { + let args = ArrayList() + args.add("add") + let rc = MigrationCli.run(args, { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(rc == 1) + } + + @TestCase + public func testRmMissingNameReturnsOne(): Unit { + let args = ArrayList() + args.add("rm") + let rc = MigrationCli.run(args, { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(rc == 1) + } + + @TestCase + public func testDowngradeNoMigrationsReturnsOne(): Unit { + let args = ArrayList() + args.add("downgrade") + let rc = MigrationCli.run(args, { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(rc == 1) + } + + @TestCase + public func testDowngradeWithTargetConsumesArgs(): Unit { + let args = ArrayList() + args.add("downgrade") + args.add("20260819061048_InitialCreate") + let rc = MigrationCli.run(args, { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(rc == 1) + } + + @TestCase + public func testTryRunWithOrmPrefixConsumesArgs(): Unit { + let args = ArrayList() + args.add("orm") + args.add("help") + let handled = MigrationCli.tryRun(args, { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(handled == true) + } + + @TestCase + public func testTryRunWithoutOrmPrefixReturnsFalse(): Unit { + let args = ArrayList() + args.add("serve") + let handled = MigrationCli.tryRun(args, { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(handled == false) + } + + @TestCase + public func testTryRunEmptyArgsReturnsFalse(): Unit { + let handled = MigrationCli.tryRun(ArrayList(), { => ArrayList() }, + { => throw Exception("不应调用 datasource") }, { => ArrayList() }) + @Expect(handled == false) + } +} + + diff --git a/src/tests/SimOrm_test.cj b/src/tests/SimOrm_test.cj new file mode 100644 index 0000000..c2e1013 --- /dev/null +++ b/src/tests/SimOrm_test.cj @@ -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() + @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() + @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() + @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() + 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(); false } catch (_: Exception) { true } + @Expect(threw, true) + } + + @TestCase + public func testPlainIdRecognizedAsKey(): Unit { + let model = ModelCache.get() + 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(); false } catch (_: Exception) { true } + @Expect(threw, true) + } + + @TestCase + public func testOptionFieldThrows(): Unit { + let threw = try { ModelCache.get(); false } catch (_: Exception) { true } + @Expect(threw, true) + } + + @TestCase + public func testUnsupportedTypeThrows(): Unit { + let threw = try { ModelCache.get(); 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() + 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() + 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() + @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() + 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() + let dialect = OpenGaussDialect() + let captured = ArrayList() + let paramsCaptured = ArrayList>() + let qb = QueryBuilder(model, dialect, + { sql, params => captured.add(sql); paramsCaptured.add(params); ArrayList() }, + { 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() + let dialect = OpenGaussDialect() + let captured = ArrayList() + let qb = QueryBuilder(model, dialect, + { sql, params => captured.add(sql); ArrayList() }, + { sql, params => 42 }) + let ps = ArrayList() + 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() + let dialect = OpenGaussDialect() + let captured = ArrayList() + let qb = QueryBuilder(model, dialect, + { sql, params => captured.add(sql); ArrayList() }, + { 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() + let dialect = OpenGaussDialect() + let captured = ArrayList() + let qb = QueryBuilder(model, dialect, + { sql, params => captured.add(sql); ArrayList() }, + { 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() + let dialect = OpenGaussDialect() + let captured = ArrayList() + let qb = QueryBuilder(model, dialect, + { sql, params => captured.add(sql); ArrayList() }, + { 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 { + let builder = MigrationBuilder() + m.up(builder) + let f = DdlFactory() + let sqls = ArrayList() + for (op in builder.getOperations()) { + sqls.add(f.toSql(op, dialect)) + } + sqls + } + + private func downSql(m: Migration, dialect: ISqlDialect): ArrayList { + let builder = MigrationBuilder() + m.down(builder) + let f = DdlFactory() + let sqls = ArrayList() + for (op in builder.getOperations()) { + sqls.add(f.toSql(op, dialect)) + } + sqls + } + + @TestCase + public func testInitialMigration(): Unit { + let models = ArrayList() + models.add(ModelCache.get()) + models.add(ModelCache.get()) + 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() + oldModels.add(ModelCache.get()) + oldModels.add(ModelCache.get()) + let newModels = ArrayList() + newModels.add(ModelCache.get()) + 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() + oldModels.add(ModelCache.get()) + let newModels = ArrayList() + newModels.add(ModelCache.get()) + 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() + oldModels.add(ModelCache.get()) + let newModels = ArrayList() + newModels.add(ModelCache.get()) + 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")) { 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() + 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() + 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(), "\"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() + 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 + let orders: DbSet + + init(ds: Datasource) { + super(ds) + users = set() + orders = set() + } + + init(ds: Datasource, dialect: ISqlDialect) { + super(ds, dialect) + users = set() + orders = set() + } +} + +@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, true) + @Expect(orders is DbSet, 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()) + false + } catch (_: Exception) { + true + } + @Expect(threw, true) + } +} + +// ---------- 模型快照 ---------- + +@Test +class ModelSnapshotTests { + private func gen(): MigrationGenerator { + MigrationGenerator() + } + + private func models(): ArrayList { + let ms = ArrayList() + ms.add(ModelCache.get()) + ms.add(ModelCache.get()) + ms + } + + private func upSql(m: Migration, dialect: ISqlDialect): ArrayList { + let builder = MigrationBuilder() + m.up(builder) + let f = DdlFactory() + let sqls = ArrayList() + 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()) + 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)) } + } +} diff --git a/src/tracking/ChangeTracker.cj b/src/tracking/ChangeTracker.cj new file mode 100644 index 0000000..94f848b --- /dev/null +++ b/src/tracking/ChangeTracker.cj @@ -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() + + /// 入队一条变更 + public func add(entity: Any, state: EntityState): Unit { + _entries.add(EntityEntry(entity, state)) + } + + /// 全部待处理条目(保序) + public func getEntries(): ArrayList { + _entries + } + + /// 待处理条目数 + public func count(): Int64 { + _entries.size + } + + /// 清空队列(提交后调用) + public func clear(): Unit { + _entries.clear() + } +}