4 Commits
30 changed files with 392 additions and 104 deletions

No files matched your search

+1
View File
@@ -3,5 +3,6 @@ target/
# 宏编译产物(构建时自动生成,不入库)
src/macros/*.cjo
src/macros/*.dll
src/tests/*.cj.macrocall
# 临时/日志
*.log
+9 -8
View File
@@ -4,7 +4,7 @@
> 仓颉版 ORM:**基于数据模型(POCO + 注解)的映射、增删改查、数据库迁移**。
面向多数据库的方言架构:内置 PostgreSQL(`PostgreSqlDialect`)与 openGauss(`OpenGaussDialect`,继承 PG)方言(对接 [opengauss-driver](../opengauss-driver)),可通过实现 `ISqlDialect` 接口接入 sqlite / mysql 等新数据库。包本身零外部依赖。
面向多数据库的方言架构:内置 PostgreSQL(`PostgreSqlDialect`)与 openGauss(`OpenGaussDialect`,继承 PG)方言(对接 [opengauss-driver](../opengauss-driver)),可通过实现 `SqlDialect` 接口接入 sqlite / mysql 等新数据库。包本身零外部依赖。
---
@@ -166,7 +166,7 @@ main() {
}
```
- 快照 JSON 结构:`{"version":1,"models":[{"table":"users","columns":[{...}]}]}`,含表名、列名、类型、主键/自增/必填/最大长度;
- 快照 JSON 结构:`{"version":4,"models":[{"table":"users","columns":[{...}],"indexes":[{...}]}]}`,含表名、列名、类型、主键/自增/必填/最大长度,以及实体级索引(名称/列/唯一/过滤条件);
- `ModelSnapshot` 也提供 `capture(models)` / `toJson()` / `fromJson(text)` / `save(path)` / `load(path)`(文件不存在返回 `None`),可自行接入版本化/比较逻辑;
- 零依赖:`MiniJson` 内置迷你 JSON 解析/序列化,不引入第三方包。
@@ -175,7 +175,7 @@ main() {
- **增量**:`diff(id, desc, oldModels, newModels)` → 新增表 `CreateTable`、删除表 `DropTable`、列增删改(`AddColumn`/`DropColumn`/`AlterColumn`),`down` 自动按 up 逆序反转;
- **同名列修改数据结构**:`Migrator` 执行 `AddColumn`/`AlterColumn` 前先查 `information_schema`,若库中已有同名列且类型与目标不一致,则**先 `DROP COLUMN` 再 `ADD COLUMN`**(重建该字段);类型一致时 `AddColumn` 幂等跳过、`AlterColumn` 正常执行(处理非空/长度等变化);
- 字段类型 → 列类型映射:`String→TextCol`、`Bool→BoolCol`、`Int8/16/32/64→Tiny/Small/Int/BigIntCol`、`Float32→RealCol`、`Float64→FloatCol`、`DateTime→DateTimeCol`、`Decimal→DecimalCol`、`Array<Byte>→BinaryCol`、`Rune→IntCol`、`Duration→BigIntCol`;`Option<X>` 按内层类型 X 映射且列可空(`None` ↔ `NULL`);
- **不支持**(抛异常提示手写迁移):主键列名变更、主键/自增属性变更;`@Ignore`/`@Column`/`@Required`/`@MaxLength`/`@AutoIncrement`/`@Index` 均会反映到 DDL(`@Index` 生成 `CREATE INDEX`)。
- **不支持**(抛异常提示手写迁移):主键列名变更、主键/自增属性变更;`@Ignore`/`@Column`/`@Required`/`@MaxLength`/`@AutoIncrement`/`@Index` 均会反映到 DDL(`@Index` 生成 `CREATE INDEX`);类级 `@TableIndex` 生成对应的 `CREATE [UNIQUE] INDEX ... [WHERE filter]`(复合/唯一/部分索引),字段级与实体级索引统一走 `IndexSpec` diff(快照 v4 持久化)。
### 迁移 CLI
@@ -272,14 +272,14 @@ public class InitGames {
orm-cj/
├── cjpm.toml # 包配置(name = orm, organization = simcu)
├── src/
│ ├── Orm.cj # 聚合导出:import simcu::orm.* 即全部可见
│ ├── annotations/ # @Table / @Column / @Key / @AutoIncrement / @Ignore / @Required / @MaxLength
│ ├── orm.cj # 聚合导出:import simcu::orm.* 即全部可见
│ ├── annotations/ # @Table / @Column / @Key / @AutoIncrement / @Ignore / @Required / @MaxLength / @Index / @TableIndex
│ ├── macros/ # @DbContext 类级宏 + @Seed 数据填充宏(自动注册,无需手写注册表)
│ ├── seeds/ # Seed 基类 + 进程级填充注册表(registerSeed / allSeeds)
│ ├── model/ # EntityModel(反射映射)、ModelCache(模型缓存)、
│ │ # ColumnNamingPolicy(列命名策略)、ValueReader、ParamBinder、GuidUtil
│ ├── tracking/ # ChangeTracker(操作队列)、EntityState、EntityEntry
│ ├── sql/ # ISqlDialect 接口(独立文件)+ PostgreSqlDialect(PG 实现)+
│ ├── sql/ # SqlDialect 接口(独立文件)+ PostgreSqlDialect(PG 实现)+
│ │ # OpenGaussDialect(继承 PG)、ColumnTypes(列类型)
│ ├── query/ # QueryBuilder<T>(条件/排序/分页)、PagedResult<T>
│ ├── db/ # DbContext(连接 + 提交 + 物化 + 数据库存在性/迁移状态检查)、DbSet<T>
@@ -308,6 +308,7 @@ orm-cj/
| `@Required` | 字段 | 非空列(仅影响建表 DDL) |
| `@MaxLength[100]` | 字段 | 字符串列最大长度(仅影响建表 DDL) |
| `@Index` | 字段 | 对该列建索引(索引名默认 `ix_<表名>_<列名>`,迁移 add 自动生成 CREATE INDEX) |
| `@TableIndex["col1,col2", true, "cond"]` | 类 | 类级复合/唯一/条件索引(对齐 .NET 类级 `[Index(...)]`);`columns` 为逗号分隔的列/字段名(字段名自动映射为列名),`unique` 是否唯一,`filter` 非空时生成 `... WHERE cond` 部分索引;索引名可省略(`name` 置于末位,留空则按 `ix_<表名>_<首列>` 自动生成),支持 `[columns]`、`[columns, unique]`、`[columns, unique, filter]`、`[columns, unique, filter, name]` 四种用法 |
### 2. 模型映射约定
@@ -438,7 +439,7 @@ cjpm test # 纯逻辑单元测试(模型映射/SQL 生成/DDL 生成/QueryB
- 不支持导航属性 / 延迟加载 / 级联删除(这些能力属于 v2+);
- 不支持 LINQ 表达式树,查询以 `filter` 条件方法 + SQL 片段组合;
- `QueryBuilder` 的 SQL 片段形式(`filter(condition, params)`)需自行保证列名合法(会做标识符引号包裹校验外的处理)——推荐优先使用三参属性形式;
- 自增主键回读依赖驱动 `RETURNING` 支持(openGauss/PostgreSQL 原生支持;换方言时由 `ISqlDialect.buildInsert` 决定回读策略)。
- 自增主键回读依赖驱动 `RETURNING` 支持(openGauss/PostgreSQL 原生支持;换方言时由 `SqlDialect.buildInsert` 决定回读策略)。
---
@@ -449,7 +450,7 @@ cjpm test # 纯逻辑单元测试(模型映射/SQL 生成/DDL 生成/QueryB
| 数据库驱动(使用者引入,如 `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` 驱动。
> orm-cj 包本身零依赖:连接层走 `std.database.sql` 标准接口,SQL/DDL 生成走 `SqlDialect` 方言接口。新数据库接入 = 实现 `SqlDialect`(CRUD + 列类型映射 + 差异 DDL 语句)+ 提供对应 `std.database.sql` 驱动。
---
+1 -1
View File
@@ -3,7 +3,7 @@
name = "orm"
organization = "simcu"
description = "SimApi 数据访问层 ORM:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言"
version = "1.2.1"
version = "2.0.0"
target-dir = ""
output-type = "static"
@@ -101,3 +101,55 @@ public class MaxLength {
public class Index {
public const init() {}
}
/**
* 类级复合/条件索引(对齐 .NET 类级 [Index(...)],支持多列 + 唯一 + 部分索引)。
* 标注在实体类上,一个类声明一个复合索引(Cangjie 反射 findAnnotation 在同类多标注时读不到)。
*
* 用法:
* @TableIndex["source_server,name", true, "kind = 'Passport'"]
* public class Asset { ... }
*
* 参数(顺序):
* - columns 逗号分隔的列名/字段名(字段名自动映射为列名,如 "source_server,name" 或 "_sourceServer,_name")
* - unique 是否唯一索引,默认 false
* - filter 部分索引过滤条件(原始 SQL,如 "kind = 'Passport'";空串表示全表索引,不生成 WHERE),默认空
* - name 索引名,可为空;为空时自动生成 ix_<表名>_<首列名>
*
* 重载:@TableIndex[columns](全默认)、[columns, unique]、[columns, unique, filter]、[columns, unique, filter, name]。
*/
@Annotation[target: [Type]]
public class TableIndex {
public let columns: String
public let unique: Bool
public let filter: String
public let name: String
public const init(columns: String, unique: Bool, filter: String, name: String) {
this.columns = columns
this.unique = unique
this.filter = filter
this.name = name
}
public const init(columns: String, unique: Bool, filter: String) {
this.columns = columns
this.unique = unique
this.filter = filter
this.name = ""
}
public const init(columns: String, unique: Bool) {
this.columns = columns
this.unique = unique
this.filter = ""
this.name = ""
}
public const init(columns: String) {
this.columns = columns
this.unique = false
this.filter = ""
this.name = ""
}
}
@@ -20,7 +20,7 @@ public interface CliContext {
/// 应用主数据源
func getDatasource(): Datasource
/// SQL 方言
func getDialect(): ISqlDialect
func getDialect(): SqlDialect
/// 迁移列表(子类 override migrations() 提供)
func getMigrations(): ArrayList<Migration>
/// 应用所有迁移
@@ -13,7 +13,7 @@
* - 模型:反射收集实例的 DbSet<T> 属性(getModels())
* - 连接/驱动/方言:构造参数
* - 迁移列表:子类 override migrations()(宏默认返回 orm-cj 全局注册表,
* CLI 生成的 src/migrations/MigrationRegistry.cj 在模块加载时自动注册)
* CLI 生成的 src/migrations/migration_registry.cj 在模块加载时自动注册)
*
* 注意:应用侧(DataContext.cj 或 main.cj)需要一行
* import <应用包名>.migrations.*
@@ -55,7 +55,7 @@ public class MigrationCli {
// 注册文件与迁移文件同目录(src/migrations/,package = 应用包名.migrations)。
// 子包默认惰性加载,顶层 let 不会执行;应用侧需一行
// `import <应用包名>.migrations.*` 触发加载后,注册才生效。
private var _registryPath = "src/migrations/MigrationRegistry.cj"
private var _registryPath = "src/migrations/migration_registry.cj"
public init() {}
@@ -94,7 +94,7 @@ public class MigrationCli {
private func dispatch(args: ArrayList<String>, models: () -> ArrayList<EntityModel>,
datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect, seedCtx: ?Any): Int64 {
dialect: SqlDialect, seedCtx: ?Any): Int64 {
if (args.size == 0) {
printHelp()
return 0
@@ -122,7 +122,7 @@ public class MigrationCli {
/// database <子命令>:数据库级操作(不需要迁移注册)
private func cmdDatabase(args: ArrayList<String>, datasource: () -> Datasource,
dialect: ISqlDialect): Int64 {
dialect: SqlDialect): Int64 {
if (args.size < 2) {
println("用法: simcu::orm database <truncate|drop>")
return 1
@@ -137,7 +137,7 @@ public class MigrationCli {
}
/// database truncate:清空所有业务表数据(保留表结构与迁移历史)
private func cmdTruncate(datasource: () -> Datasource, dialect: ISqlDialect): Int64 {
private func cmdTruncate(datasource: () -> Datasource, dialect: SqlDialect): Int64 {
try {
let migrator = Migrator(datasource(), dialect)
let n = migrator.truncateAll()
@@ -151,7 +151,7 @@ public class MigrationCli {
}
/// database drop:删除所有表(含迁移历史),完全清空数据库;之后可 update 重建
private func cmdDrop(datasource: () -> Datasource, dialect: ISqlDialect): Int64 {
private func cmdDrop(datasource: () -> Datasource, dialect: SqlDialect): Int64 {
try {
let migrator = Migrator(datasource(), dialect)
let n = migrator.dropAllTables()
@@ -217,7 +217,7 @@ public class MigrationCli {
/// update:应用所有未执行的迁移
private func cmdUpdate(datasource: () -> Datasource,
migrations: () -> ArrayList<Migration>, dialect: ISqlDialect): Int64 {
migrations: () -> ArrayList<Migration>, dialect: SqlDialect): Int64 {
let list = migrations()
if (list.size == 0) {
println("simcu::orm: 未注册任何迁移(检查 ${_registryPath})")
@@ -231,7 +231,7 @@ public class MigrationCli {
/// downgrade [目标]:回退迁移(执行 down + 删除历史记录;无目标 = 回退最近一个)
private func cmdDowngrade(datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect, args: ArrayList<String>): Int64 {
dialect: SqlDialect, args: ArrayList<String>): Int64 {
let list = migrations()
if (list.size == 0) {
println("simcu::orm: 未注册任何迁移(检查 ${_registryPath})")
@@ -271,7 +271,7 @@ public class MigrationCli {
for (f in Directory.readFrom(dir)) {
if (f.isRegular()) {
let name = f.name
if (name.endsWith(".cj") && name != "MigrationRegistry.cj" && name != "_placeholder.cj") {
if (name.endsWith(".cj") && name != "migration_registry.cj" && name != "_placeholder.cj") {
ids.add(name[..name.size - 3])
}
}
@@ -334,7 +334,7 @@ public class MigrationCli {
/// list:显示已应用/待应用迁移(空库/全新库时全部显示为待应用)
private func cmdList(datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect): Int64 {
dialect: SqlDialect): Int64 {
let list = migrations()
if (list.size == 0) {
println("simcu::orm: 未注册任何迁移(检查 ${_registryPath})")
@@ -365,7 +365,7 @@ public class MigrationCli {
/// seed:列出/运行数据填充(@Seed 标注的类,按 seedId 唯一前缀匹配;--all 全部;无参=列出)
private func cmdSeed(datasource: () -> Datasource, migrations: () -> ArrayList<Migration>,
dialect: ISqlDialect, args: ArrayList<String>, seedCtx: ?Any): Int64 {
dialect: SqlDialect, args: ArrayList<String>, seedCtx: ?Any): Int64 {
let list = allSeeds()
if (list.size == 0) {
println("simcu::orm: 未注册任何填充(检查 @Seed 标注的类是否存在,并确认应用已 import 对应包)")
@@ -2,7 +2,7 @@
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 迁移落盘生成器(对齐 dotnet ef migrations add):
* - Migration 对象 → .cj 源文件文本(迁移类 up/down 用 MigrationBuilder 声明式重建),
* - 自动维护迁移注册文件 MigrationRegistry.cj(add 时插入新迁移实例)。
* - 自动维护迁移注册文件 migration_registry.cj(add 时插入新迁移实例)。
*
* 生成文件的格式:
* // 此文件由 simcu::orm CLI 自动生成,请勿手动编辑
@@ -211,7 +211,10 @@ public class MigrationFileGenerator {
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)}\", ArrayList<String>([${colList(op.columnNames)}])")
if (op.unique) {
if (op.filter != "") {
let uniqueLiteral = if (op.unique) { "true" } else { "false" }
sb.append(", ${uniqueLiteral}, \"${escape(op.filter)}\"")
} else if (op.unique) {
sb.append(", true")
}
sb.append(")\n")
File renamed without changes.
+5 -5
View File
@@ -41,13 +41,13 @@ import simcu::orm.migrations.{Migrator, Migration}
*/
public open class DbContext <: CliContext {
internal let datasource: Datasource
internal let dialect: ISqlDialect
internal let dialect: SqlDialect
internal let tracker = ChangeTracker()
private let _sets = HashMap<String, Any>()
private var _driverName: String
private var _connString: String
public init(datasource: Datasource, dialect: ISqlDialect) {
public init(datasource: Datasource, dialect: SqlDialect) {
this.datasource = datasource
this.dialect = dialect
this._driverName = ""
@@ -59,7 +59,7 @@ public open class DbContext <: CliContext {
}
/// 便捷构造:由连接字符串创建 Datasource(驱动构建收敛在 DatasourceFactory)
public init(connStr: String, dialect: ISqlDialect) {
public init(connStr: String, dialect: SqlDialect) {
this(DatasourceFactory.create(connStr), dialect)
this._driverName = DatasourceFactory.detectDriver(connStr, DatasourceFactory.DEFAULT_DRIVER)
this._connString = connStr
@@ -70,7 +70,7 @@ public open class DbContext <: CliContext {
}
/// 便捷构造:显式指定驱动类型(如 "pgsql")+ 连接字符串
public init(driverName: String, connStr: String, dialect: ISqlDialect) {
public init(driverName: String, connStr: String, dialect: SqlDialect) {
this(DatasourceFactory.create(connStr, driverName), dialect)
this._driverName = DatasourceFactory.normalizeDriverName(driverName)
this._connString = connStr
@@ -200,7 +200,7 @@ public open class DbContext <: CliContext {
}
/// 当前方言(子类/应用可读取)
public func getDialect(): ISqlDialect {
public func getDialect(): SqlDialect {
dialect
}
File renamed without changes.
File renamed without changes.
@@ -214,72 +214,70 @@ public class MigrationGenerator {
diffIndexes(upOps, downOps, old, cur)
}
/// 索引 diff:按 indexName 匹配,处理新增/删除/变更(变更 = up 先 DROP 再 CREATE,down 反之)
/// 索引 diff:按 indexName 匹配,处理新增/删除/变更(变更 = up 先 DROP 再 CREATE,down 反之)。
/// 统一处理字段级(@Index)与实体级(@TableIndex)索引,均归一为 IndexSpec。
private static func diffIndexes(upOps: ArrayList<MigrationOperation>, downOps: ArrayList<MigrationOperation>,
old: EntityModel, cur: EntityModel): Unit {
var oldIdx = HashMap<String, PropertyModel>()
for (p in old.properties) {
if (p.isIndex) {
oldIdx[p.indexName] = p
}
var oldIdx = HashMap<String, IndexSpec>()
for (s in collectIndexSpecs(old)) {
oldIdx[s.indexName] = s
}
var curIdx = HashMap<String, PropertyModel>()
for (p in cur.properties) {
if (p.isIndex) {
curIdx[p.indexName] = p
}
var curIdx = HashMap<String, IndexSpec>()
for (s in collectIndexSpecs(cur)) {
curIdx[s.indexName] = s
}
// 新增 / 变更
for (p in cur.properties) {
if (!p.isIndex) {
continue
}
if (let Some(op) <- oldIdx.get(p.indexName)) {
if (!sameIndex(op, p)) {
upOps.add(dropIndexOp(cur.tableName, p.indexName))
downOps.add(createIndexOp(old.tableName, op))
upOps.add(createIndexOp(cur.tableName, p))
downOps.add(dropIndexOp(old.tableName, p.indexName))
for (s in collectIndexSpecs(cur)) {
if (let Some(os) <- oldIdx.get(s.indexName)) {
if (!sameIndex(os, s)) {
upOps.add(dropIndexOp(cur.tableName, s.indexName))
downOps.add(createIndexOp(os))
upOps.add(createIndexOp(s))
downOps.add(dropIndexOp(old.tableName, s.indexName))
}
} else {
upOps.add(createIndexOp(cur.tableName, p))
downOps.add(dropIndexOp(cur.tableName, p.indexName))
upOps.add(createIndexOp(s))
downOps.add(dropIndexOp(cur.tableName, s.indexName))
}
}
// 删除
for (p in old.properties) {
if (!p.isIndex) {
continue
}
if (!curIdx.contains(p.indexName)) {
upOps.add(dropIndexOp(old.tableName, p.indexName))
downOps.add(createIndexOp(old.tableName, p))
for (s in collectIndexSpecs(old)) {
if (!curIdx.contains(s.indexName)) {
upOps.add(dropIndexOp(old.tableName, s.indexName))
downOps.add(createIndexOp(s))
}
}
}
private static func sameIndex(a: PropertyModel, b: PropertyModel): Bool {
a.columnName == b.columnName && a.indexUnique == b.indexUnique
private static func sameIndex(a: IndexSpec, b: IndexSpec): Bool {
if (a.unique != b.unique || a.filter != b.filter) {
return false
}
if (a.columnNames.size != b.columnNames.size) {
return false
}
for (i in 0..a.columnNames.size) {
if (a.columnNames[i] != b.columnNames[i]) {
return false
}
}
true
}
/// 该模型所有索引列 → CREATE INDEX op(up 用)
/// 该模型所有索引(字段级 + 实体级)→ CREATE INDEX op(up 用)
private static func createIndexOpsFor(model: EntityModel): ArrayList<MigrationOperation> {
let ops = ArrayList<MigrationOperation>()
for (p in model.properties) {
if (p.isIndex) {
ops.add(createIndexOp(model.tableName, p))
}
for (s in collectIndexSpecs(model)) {
ops.add(createIndexOp(s))
}
ops
}
/// 该模型所有索引列 → DROP INDEX op(down 用,与 createIndexOpsFor 同序)
/// 该模型所有索引(字段级 + 实体级)→ DROP INDEX op(down 用,与 createIndexOpsFor 同序)
private static func dropIndexOpsFor(model: EntityModel): ArrayList<MigrationOperation> {
let ops = ArrayList<MigrationOperation>()
for (p in model.properties) {
if (p.isIndex) {
ops.add(dropIndexOp(model.tableName, p.indexName))
}
for (s in collectIndexSpecs(model)) {
ops.add(dropIndexOp(model.tableName, s.indexName))
}
ops
}
@@ -307,6 +305,35 @@ public class MigrationGenerator {
}
}
/// 收集模型上的全部索引规格:先字段级(@Index),再实体级(@TableIndex)。
/// 顺序即生成顺序,down 与之对应逆序销毁。
private static func collectIndexSpecs(model: EntityModel): ArrayList<IndexSpec> {
let specs = ArrayList<IndexSpec>()
for (p in model.properties) {
if (p.isIndex) {
let s = IndexSpec()
s.table = model.tableName
s.indexName = p.indexName
s.unique = p.indexUnique
s.filter = ""
s.columnNames.add(p.columnName)
specs.add(s)
}
}
for (e in model.indexes) {
let s = IndexSpec()
s.table = model.tableName
s.indexName = e.indexName
s.unique = e.unique
s.filter = e.filter
for (c in e.columnNames) {
s.columnNames.add(c)
}
specs.add(s)
}
specs
}
private static func indexByTable(models: ArrayList<EntityModel>): HashMap<String, EntityModel> {
let map = HashMap<String, EntityModel>()
for (model in models) {
@@ -362,12 +389,15 @@ public class MigrationGenerator {
op
}
private static func createIndexOp(table: String, p: PropertyModel): MigrationOperation {
private static func createIndexOp(s: IndexSpec): MigrationOperation {
let op = MigrationOperation(MigrationOperationKind.CreateIndex)
op.tableName = table
op.indexName = p.indexName
op.unique = p.indexUnique
op.columnNames.add(p.columnName)
op.tableName = s.table
op.indexName = s.indexName
op.unique = s.unique
op.filter = s.filter
for (c in s.columnNames) {
op.columnNames.add(c)
}
op
}
@@ -378,3 +408,21 @@ public class MigrationGenerator {
op
}
}
/**
* 内部统一的索引规格:把字段级(@Index)与实体级(@TableIndex)索引归一为同构描述,便于 diff 与生成。
*/
public class IndexSpec {
/// 所属表名
public var table: String = ""
/// 索引名
public var indexName: String = ""
/// 列名列表
public var columnNames: ArrayList<String> = ArrayList()
/// 是否唯一
public var unique: Bool = false
/// 部分索引过滤条件(空串表示全表索引)
public var filter: String = ""
public init() {}
}
File renamed without changes.
@@ -107,6 +107,8 @@ public class MigrationOperation {
public var indexName: String = ""
public var columnNames: ArrayList<String> = ArrayList()
public var unique: Bool = false
/// 部分索引过滤条件(CREATE ... WHERE filter;空串表示全表索引)
public var filter: String = ""
public var sql: String = ""
public init(kind: MigrationOperationKind) {
@@ -202,11 +204,18 @@ public class MigrationBuilder {
public func createIndex(indexName: String, table: String, columns: ArrayList<String>,
unique: Bool): MigrationBuilder {
createIndex(indexName, table, columns, unique, "")
}
/// 创建索引(可指定部分索引过滤条件;filter 非空时生成 CREATE ... WHERE filter)
public func createIndex(indexName: String, table: String, columns: ArrayList<String>,
unique: Bool, filter: String): MigrationBuilder {
let op = MigrationOperation(MigrationOperationKind.CreateIndex)
op.indexName = indexName
op.tableName = table
op.columnNames = columns
op.unique = unique
op.filter = filter
operations.add(op)
this
}
@@ -266,12 +275,12 @@ public open class Migration {
}
/**
* DDL SQL 工厂:MigrationOperation → SQL(类型映射与差异语法委托给 ISqlDialect)。
* DDL SQL 工厂:MigrationOperation → SQL(类型映射与差异语法委托给 SqlDialect)。
*/
public class DdlFactory {
public init() {}
public func toSql(op: MigrationOperation, dialect: ISqlDialect): String {
public func toSql(op: MigrationOperation, dialect: SqlDialect): String {
match (op.kind) {
case MigrationOperationKind.CreateTable => createTableSql(op, dialect)
case MigrationOperationKind.DropTable => "DROP TABLE IF EXISTS ${dialect.quoteName(op.tableName)}"
@@ -294,7 +303,7 @@ public class DdlFactory {
/// - 有同名列且类型一致 → AddColumn 幂等跳过(空列表);AlterColumn 保留原 SQL(处理非空/长度等变化)
/// - 有同名列但类型不同 → 先 DROP COLUMN 再 ADD COLUMN(重建该字段)
/// existingType 来自 information_schema.data_type;None 表示列不存在。
public func resolveColumnSqls(op: MigrationOperation, dialect: ISqlDialect,
public func resolveColumnSqls(op: MigrationOperation, dialect: SqlDialect,
existingType: ?String): ArrayList<String> {
let result = ArrayList<String>()
let col = op.column.getOrThrow()
@@ -360,7 +369,7 @@ public class DdlFactory {
sb.toString()
}
private func createTableSql(op: MigrationOperation, dialect: ISqlDialect): String {
private func createTableSql(op: MigrationOperation, dialect: SqlDialect): String {
let sb = StringBuilder()
sb.append("CREATE TABLE IF NOT EXISTS ${dialect.quoteName(op.tableName)} (\n")
var first = true
@@ -375,7 +384,7 @@ public class DdlFactory {
sb.toString()
}
private func createIndexSql(op: MigrationOperation, dialect: ISqlDialect): String {
private func createIndexSql(op: MigrationOperation, dialect: SqlDialect): String {
let sb = StringBuilder()
sb.append("CREATE ")
if (op.unique) {
@@ -391,10 +400,13 @@ public class DdlFactory {
first = false
}
sb.append(")")
if (op.filter != "") {
sb.append(" WHERE ${op.filter}")
}
sb.toString()
}
private func columnDefSql(col: ColumnDefinition, dialect: ISqlDialect): String {
private func columnDefSql(col: ColumnDefinition, dialect: SqlDialect): String {
let sb = StringBuilder()
sb.append(dialect.quoteName(col.name))
sb.append(" ${dialect.columnTypeSql(col.columnType, col.autoIncrement, col.maxLength)}")
@@ -18,10 +18,10 @@ import simcu::orm.sql.*
*/
public class Migrator {
private let _datasource: Datasource
private let _dialect: ISqlDialect
private let _dialect: SqlDialect
private let _historyTable = "simcu_orm_migrations"
public init(datasource: Datasource, dialect: ISqlDialect) {
public init(datasource: Datasource, dialect: SqlDialect) {
this._datasource = datasource
this._dialect = dialect
}
File renamed without changes.
@@ -10,11 +10,12 @@
* // 模型变更后:快照存在 → 载入旧模型 diff,并覆盖保存新快照
* gen.ensure("20250801000000_AddAge", "新增 age 列", "snapshot.json", models1)
*
* 快照 JSON 结构(版本 3,较 v2 新增索引标记 idx/ixName/ixUnique):
* {"version":3,"models":[{"table":"users","columns":[
* 快照 JSON 结构(版本 4,较 v3 新增实体级 indexes 数组;v3 的 idx/ixName/ixUnique 为字段级索引标记):
* {"version":4,"models":[{"table":"users","columns":[
* {"name":"id","column":"id","type":"Int64","key":true,"auto":true,
* "client":false,"required":false,"maxLen":0,"enum":false,
* "idx":false,"ixName":"","ixUnique":false}]}]}
* "idx":false,"ixName":"","ixUnique":false}],
* "indexes":[{"name":"ux","columns":["a","b"],"unique":true,"filter":"kind = 'Passport'"}]}]}
*/
package simcu::orm.migrations
@@ -31,7 +32,7 @@ public class ModelSnapshot {
/// 快照内的轻量模型列表
public var models: ArrayList<EntityModel> = ArrayList()
/// 快照格式版本
public static let formatVersion: Int64 = 3
public static let formatVersion: Int64 = 4
public init() {}
@@ -64,6 +65,16 @@ public class ModelSnapshot {
}
}
}
for (e in m.indexes) {
let li = EntityIndex()
li.indexName = e.indexName
li.unique = e.unique
li.filter = e.filter
for (c in e.columnNames) {
li.columnNames.add(c)
}
lm.indexes.add(li)
}
snap.models.add(lm)
}
snap
@@ -100,6 +111,20 @@ public class ModelSnapshot {
cols.add(JObject(co))
}
mo["columns"] = JArray(cols)
let idxs = ArrayList<JsonValue>()
for (e in m.indexes) {
let io = HashMap<String, JsonValue>()
io["name"] = JString(e.indexName)
let icols = ArrayList<JsonValue>()
for (c in e.columnNames) {
icols.add(JString(c))
}
io["columns"] = JArray(icols)
io["unique"] = JBool(e.unique)
io["filter"] = JString(e.filter)
idxs.add(JObject(io))
}
mo["indexes"] = JArray(idxs)
modelsArr.add(JObject(mo))
}
root["models"] = JArray(modelsArr)
@@ -146,6 +171,26 @@ public class ModelSnapshot {
}
case _ => throw Exception("simcu::orm: 快照 models[].columns 必须是数组")
}
// 实体级索引(v4 开始存在;缺失时容忍为空,兼容旧快照)
match (mo.get("indexes")) {
case Some(JArray(idxs)) =>
for (i in idxs) {
match (i) {
case JObject(io) =>
let li = EntityIndex()
li.indexName = getString(io, "name")
li.unique = getBool(io, "unique")
li.filter = getString(io, "filter")
for (c in getStringArray(io, "columns")) {
li.columnNames.add(c)
}
lm.indexes.add(li)
case _ => throw Exception(
"simcu::orm: 快照 indexes[] 项必须是 object")
}
}
case _ => ()
}
snap.models.add(lm)
case _ => throw Exception("simcu::orm: 快照 models[] 项必须是 object")
}
@@ -201,4 +246,20 @@ public class ModelSnapshot {
case _ => 0
}
}
/// 读取字符串数组(如索引列名列表);缺失或类型不符时返回空列表
private static func getStringArray(o: HashMap<String, JsonValue>, k: String): ArrayList<String> {
let list = ArrayList<String>()
match (o.get(k)) {
case Some(JArray(arr)) =>
for (e in arr) {
match (e) {
case JString(s) => list.add(s)
case _ => ()
}
}
case _ => ()
}
list
}
}
@@ -103,6 +103,22 @@ public class PropertyModel {
}
}
/**
* 类级声明的实体索引(复合/条件索引,来自 @TableIndex 注解或快照反序列化)。
*/
public class EntityIndex {
/// 索引名(空则按 ix_<表名>_<首列名> 自动生成)
public var indexName: String = ""
/// 列名列表(已映射为数据库列名)
public var columnNames: ArrayList<String> = ArrayList()
/// 是否唯一索引
public var unique: Bool = false
/// 部分索引过滤条件(空串表示全表索引)
public var filter: String = ""
public init() {}
}
/**
* 实体模型:一个实体类 ↔ 一张表。
*/
@@ -115,6 +131,8 @@ public class EntityModel {
public var properties: ArrayList<PropertyModel> = ArrayList()
/// 主键映射
public var keyProperty: ?PropertyModel = None
/// 类级声明的实体索引(复合/条件索引)
public var indexes: ArrayList<EntityIndex> = ArrayList()
/// 轻量构造:快照反序列化 / 人工组装时使用,createInstance 不可用
public init() {}
@@ -266,6 +284,21 @@ public class ModelCache {
}
model.properties.add(propM)
}
// 类级复合/条件索引(@TableIndex):列名映射为数据库列名
if (let Some(ti) <- ct.findAnnotation<TableIndex>()) {
let idx = EntityIndex()
if (ti.name != "") {
idx.indexName = ti.name
} else {
// 首个列名决定默认索引名
let firstCol = mapFirstColumn(ti.columns)
idx.indexName = "ix_${model.tableName}_${firstCol}"
}
idx.columnNames = splitColumns(ti.columns, model)
idx.unique = ti.unique
idx.filter = ti.filter
model.indexes.add(idx)
}
// 主键校验(v1 仅支持单主键)
var keys = ArrayList<PropertyModel>()
for (p in model.properties) {
@@ -299,6 +332,60 @@ public class ModelCache {
}
}
/// 把逗号分隔的列名/字段名字符串拆成数据库列名列表("source_server,name"、"_sourceServer, _name")。
/// 每一项先经 mapColumn 映射:字段名或列名都转到数据库列名。
private static func splitColumns(columns: String, model: EntityModel): ArrayList<String> {
var list = ArrayList<String>()
var sb = StringBuilder()
for (c in columns.runes()) {
if (c == Rune(0x2C)) {
// 逗号:收尾当前列
let col = model.mapColumn(trimWhitespace(sb.toString()))
if (col != "") {
list.add(col)
}
sb = StringBuilder()
} else {
sb.append(c)
}
}
let col = model.mapColumn(trimWhitespace(sb.toString()))
if (col != "") {
list.add(col)
}
list
}
/// 取逗号分隔列串中的首个列(用于默认索引名)。
private static func mapFirstColumn(columns: String): String {
var sb = StringBuilder()
for (c in columns.runes()) {
if (c == Rune(0x2C)) {
break
}
sb.append(c)
}
trimWhitespace(sb.toString())
}
/// 去除字符串首尾空白(仅空格/tab/换行,避免依赖 std.String.trim)
private static func trimWhitespace(s: String): String {
var start: Int64 = 0
var end = s.size
while (start < end && isWsAt(s, start)) {
start += 1
}
while (end > start && isWsAt(s, end - 1)) {
end -= 1
}
s[start..end]
}
private static func isWsAt(s: String, i: Int64): Bool {
let c = s[i]
c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D
}
/// 应用列命名策略
public static func applyNamingPolicy(fieldName: String, policy: ColumnNamingPolicy): String {
match (policy) {
File renamed without changes.
View File
File renamed without changes.
@@ -48,7 +48,7 @@ public class PagedResult<T> {
*/
public class QueryBuilder<T> {
private let _model: EntityModel
private let _dialect: ISqlDialect
private let _dialect: SqlDialect
private let _executor: (String, ArrayList<Any>) -> ArrayList<T>
private let _counter: (String, ArrayList<Any>) -> Int64
@@ -58,7 +58,7 @@ public class QueryBuilder<T> {
private var _skip: Int64 = 0
private var _take: Int64 = 0
public init(model: EntityModel, dialect: ISqlDialect, executor: (String, ArrayList<Any>) -> ArrayList<T>,
public init(model: EntityModel, dialect: SqlDialect, executor: (String, ArrayList<Any>) -> ArrayList<T>,
counter: (String, ArrayList<Any>) -> Int64) {
this._model = model
this._dialect = dialect
File renamed without changes.
File renamed without changes.
@@ -1,6 +1,6 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 列类型:与具体数据库无关的抽象,DDL 映射由 ISqlDialect 决定。
* 列类型:与具体数据库无关的抽象,DDL 映射由 SqlDialect 决定。
*/
package simcu::orm.sql
File renamed without changes.
@@ -13,7 +13,7 @@ import std.collection.*
/**
* PostgreSQL 方言。
*/
public open class PostgreSqlDialect <: ISqlDialect {
public open class PostgreSqlDialect <: SqlDialect {
public init() {}
public func quoteName(name: String): String {
@@ -1,9 +1,9 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* SQL 方言接口(I 前缀命名,对齐 EF Core 接口风格):标识符引用、增删改查 SQL 生成、DDL 类型/语句生成。
* SQL 方言接口:标识符引用、增删改查 SQL 生成、DDL 类型/语句生成。
*
* 新数据库接入:实现 ISqlDialect 即可(参考 PostgreSqlDialect / OpenGaussDialect)。
* 内置实现见 PostgreSqlDialect.cj(PostgreSQL)、OpenGaussDialect.cj(openGauss,继承 PG)。
* 新数据库接入:实现 SqlDialect 即可(参考 PostgreSqlDialect / OpenGaussDialect)。
* 内置实现见 postgre_sql_dialect.cj(PostgreSQL)、open_gauss_dialect.cj(openGauss,继承 PG)。
*/
package simcu::orm.sql
@@ -13,7 +13,7 @@ import std.collection.*
/**
* SQL 方言接口。
*/
public interface ISqlDialect {
public interface SqlDialect {
/// 引用标识符(表名/列名)
func quoteName(name: String): String
@@ -51,6 +51,3 @@ public interface ISqlDialect {
/// 探测数据库是否存在的 SELECT(返回带 ? 占位符的语句,库名作为参数值传入避免拼接注入)
func databaseExistsSql(): String
}
/// 兼容别名:旧名称 SqlDialect 仍可用(新代码请使用 ISqlDialect)
public type SqlDialect = ISqlDialect
@@ -34,6 +34,22 @@ class HandWrittenMigration <: Migration {
}
}
/// 手写迁移(验证部分索引 filter 还原:createIndex 5 参带 filter)
class FilteredIndexMigration <: Migration {
public init() {
super("20250802000000_FilteredIndex", "部分唯一索引")
}
public override func up(builder: MigrationBuilder): Unit {
let cols = ArrayList<String>()
cols.add("source_server")
cols.add("name")
builder.createIndex("ux_assets_passport_name", "assets", cols, true, "kind = 'Passport'")
}
public override func down(builder: MigrationBuilder): Unit {
builder.dropIndex("ux_assets_passport_name", "assets")
}
}
@Test
class MigrationFileGeneratorTests {
private func buildUserModel(): EntityModel {
@@ -123,6 +139,16 @@ class MigrationFileGeneratorTests {
@Expect(src.contains("builder.dropTable(\"tags\")"))
}
@TestCase
public func testFilteredIndexMigrationSource(): Unit {
let m = FilteredIndexMigration()
let src = MigrationFileGenerator.migrationSource("app", MigrationFileGenerator.classNameOf(m.migrationId), m)
// 生成的迁移源码应还原带 filter 的 5 参 createIndex(含 WHERE 条件)
@Expect(src.contains(
"builder.createIndex(\"ux_assets_passport_name\", \"assets\", ArrayList<String>([\"source_server\", \"name\"]), true, \"kind = 'Passport'\")"))
@Expect(src.contains("builder.dropIndex(\"ux_assets_passport_name\", \"assets\")"))
}
@TestCase
public func testRegistryRoundTrip(): Unit {
let entries = ArrayList<String>()
@@ -162,7 +188,7 @@ class MigrationFileGeneratorTests {
@Expect(text.contains("public class InitialCreate <: Migration"))
// 注册文件:首次创建 + 追加 + 幂等
let registryPath = "${dir}/MigrationRegistry.cj"
let registryPath = "${dir}/migration_registry.cj"
fg.updateRegistry("testpkg", registryPath, "InitialCreate")
fg.updateRegistry("testpkg", registryPath, "AddAge")
fg.updateRegistry("testpkg", registryPath, "AddAge")
@@ -301,7 +301,7 @@ class ChangeTrackerTests {
// ---------- OpenGaussDialect SQL 生成 ----------
@Test
class ISqlDialectTests {
class SqlDialectTests {
@TestCase
public func testQuoteInsertUpdateDelete(): Unit {
let d = OpenGaussDialect()
@@ -709,7 +709,7 @@ class MigrationGeneratorTests {
MigrationGenerator()
}
private func upSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
private func upSql(m: Migration, dialect: SqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.up(builder)
let f = DdlFactory()
@@ -720,7 +720,7 @@ class MigrationGeneratorTests {
sqls
}
private func downSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
private func downSql(m: Migration, dialect: SqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.down(builder)
let f = DdlFactory()
@@ -987,7 +987,7 @@ class AppDbContext <: DbContext {
orders = set<Order>()
}
init(ds: Datasource, dialect: ISqlDialect) {
init(ds: Datasource, dialect: SqlDialect) {
super(ds, dialect)
users = set<User>()
orders = set<Order>()
@@ -1044,7 +1044,7 @@ class ModelSnapshotTests {
ms
}
private func upSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
private func upSql(m: Migration, dialect: SqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.up(builder)
let f = DdlFactory()
File renamed without changes.