feat: 支持 Option<T> 字段(None↔NULL)、列命名默认原样(Keep)、迁移同名列先删后加,版本 1.1.0
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
# 构建产物
|
||||
target/
|
||||
# 宏编译产物(构建时自动生成,不入库)
|
||||
src/macros/*.cjo
|
||||
src/macros/*.dll
|
||||
# 临时/日志
|
||||
*.log
|
||||
|
||||
@@ -171,7 +171,8 @@ main() {
|
||||
生成规则与限制(v1):
|
||||
- **初始**:`initial(id, desc, models)` → 每个模型一张 `CreateTable`,`down` 为逆序 `DropTable`;
|
||||
- **增量**:`diff(id, desc, oldModels, newModels)` → 新增表 `CreateTable`、删除表 `DropTable`、列增删改(`AddColumn`/`DropColumn`/`AlterColumn`),`down` 自动按 up 逆序反转;
|
||||
- 字段类型 → 列类型映射:`String→TextCol`、`Bool→BoolCol`、`Int8/16/32/64→Tiny/Small/Int/BigIntCol`、`Float32→RealCol`、`Float64→FloatCol`、`DateTime→DateTimeCol`、`Decimal→DecimalCol`、`Array<Byte>→BinaryCol`、`Rune→IntCol`、`Duration→BigIntCol`;
|
||||
- **同名列修改数据结构**:`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` 均会反映到 DDL。
|
||||
|
||||
### 迁移 CLI(对齐 dotnet ef)
|
||||
@@ -267,13 +268,13 @@ orm-cj/
|
||||
### 2. 模型映射约定(对齐 EF Core 数据模型)
|
||||
|
||||
- **表名**:`@Table` 优先,否则类简单名;
|
||||
- **列名**:`@Column` 优先,否则按 `ColumnNamingPolicy`(默认 `StripUnderscore`:`_id` → `id`、`id` 原样;可切换 `SnakeCase`:`userName` → `user_name`;`Keep` 原样);
|
||||
- **主键**:`@Key` 标注,或字段名 `_id` / `id`;v1 仅支持**单主键**;
|
||||
- **列名**:`@Column` 优先,否则按 `ColumnNamingPolicy`(默认 **`Keep`**:字段名原样即列名,不做任何转换;可切换 `SnakeCase`:`userName` → `user_name`;`StripUnderscore`:`_id` → `id`);
|
||||
- **主键**:`@Key` 标注,或字段名 `id`;v1 仅支持**单主键**;
|
||||
- **自增**:`@AutoIncrement`,或整型(Int8-64)`id` 主键默认自增;INSERT 跳过该列并通过 `RETURNING` 回读;
|
||||
- **客户端生成主键**:`String` 主键默认客户端生成(`GuidUtil`:时间戳 + 自增序号 hex),INSERT 时为空自动生成;
|
||||
- **实体要求**:字段必须是 `public var` 标量类型,且提供无参 `public init()`。
|
||||
|
||||
支持的字段标量类型:`String` / `Bool` / `Int8~Int64` / `UInt8~UInt64` / `Float32` / `Float64` / `Rune` / `DateTime` / `Duration` / `Decimal` / `Array<Byte>`。
|
||||
支持的字段标量类型:`String` / `Bool` / `Int8~Int64` / `UInt8~UInt64` / `Float32` / `Float64` / `Rune` / `DateTime` / `Duration` / `Decimal` / `Array<Byte>`,以及上述类型的 **`Option<T>` 包装**(`None` ↔ `NULL`,DDL 列可空)。
|
||||
|
||||
### 3. DbContext / DbSet(对齐 EF Core)
|
||||
|
||||
@@ -382,7 +383,7 @@ cjpm test # 纯逻辑单元测试(模型映射/SQL 生成/DDL 生成/QueryB
|
||||
|
||||
- **cjc 1.1.3 编译器缺陷(重要)**:类上带 `@Table[...]` 注解时,若成员变量带 ≥2 个注解且用 `= ""` 做空字符串初始化(如 `@Required @MaxLength[100] public var x: String = ""`),编译报 `expected expression after '=', found '<EOF>'`。规避:默认值改用 `= String()` 或直接省略初始化器。`orm-cj` 包内代码与 e2e 示例均按此规避写法;
|
||||
- **仅单主键**;不支持复合主键;
|
||||
- **不支持 Option 字段**(`?String` 等请改用具体标量类型)与**父类字段**(不扫描继承字段);
|
||||
- **Option<T> 字段已支持**:`?String` 等可空字段 `None ↔ NULL`(详见「模型映射约定」);**父类字段暂不支持**(不扫描继承字段);
|
||||
- 不支持导航属性 / 延迟加载 / 级联删除(对齐 EF Core 这些能力属于 v2+);
|
||||
- 不支持 LINQ 表达式树,查询以 `filter` 条件方法 + SQL 片段组合;
|
||||
- `QueryBuilder` 的 SQL 片段形式(`filter(condition, params)`)需自行保证列名合法(会做标识符引号包裹校验外的处理)——推荐优先使用三参属性形式;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
name = "orm"
|
||||
organization = "simcu"
|
||||
description = "SimApi 数据访问层 ORM(对齐 .NET EF Core:数据模型映射 + 增删改查 + 数据库迁移;多方言架构,内置 openGauss/PostgreSQL 方言)"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
target-dir = ""
|
||||
output-type = "static"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import std.database.sql.*
|
||||
*/
|
||||
public class DatasourceFactory {
|
||||
/// 默认驱动名(ADO 风格连接串无 scheme 时使用)
|
||||
public static let DEFAULT_DRIVER = "postgres"
|
||||
public static let DEFAULT_DRIVER: String = "postgres"
|
||||
|
||||
/**
|
||||
* 由连接字符串创建 Datasource。驱动名自动推断:
|
||||
@@ -88,7 +88,7 @@ public class DatasourceFactory {
|
||||
var port = "5432"
|
||||
var database = ""
|
||||
var username = ""
|
||||
var password = ""
|
||||
var pwd = ""
|
||||
for (part in connStr.split(";")) {
|
||||
let seg = part.trimAscii()
|
||||
if (seg.isEmpty()) {
|
||||
@@ -103,12 +103,12 @@ public class DatasourceFactory {
|
||||
case "port" => port = value
|
||||
case "database" => database = value
|
||||
case "username" | "user id" | "uid" => username = value
|
||||
case "password" | "pwd" => password = value
|
||||
case "password" | "pwd" => pwd = value
|
||||
case _ => ()
|
||||
}
|
||||
case None => ()
|
||||
}
|
||||
}
|
||||
return "${DEFAULT_DRIVER}://${username}:${password}@${host}:${port}/${database}?sslmode=disable"
|
||||
return "${DEFAULT_DRIVER}://${username}:${pwd}@${host}:${port}/${database}?sslmode=disable"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -322,7 +322,7 @@ public open class DbContext <: CliContext {
|
||||
}
|
||||
|
||||
/// 执行 COUNT 查询(query 层回调)
|
||||
internal func count(model: EntityModel, sql: String, params: ArrayList<Any>): Int64 {
|
||||
internal func count(sql: String, params: ArrayList<Any>): Int64 {
|
||||
let conn = datasource.connect()
|
||||
try {
|
||||
let stmt = conn.prepareStatement(sql)
|
||||
@@ -529,7 +529,7 @@ public class DbSet<T> <: DbSetBase {
|
||||
public func query(): QueryBuilder<T> {
|
||||
QueryBuilder<T>(_model, _context.getDialect(),
|
||||
{ sql, params => _context.query<T>(_model, sql, params) },
|
||||
{ sql, params => _context.count(_model, sql, params) })
|
||||
{ sql, params => _context.count(sql, params) })
|
||||
}
|
||||
|
||||
/// 按主键查询(无结果返回 None)
|
||||
|
||||
@@ -91,8 +91,14 @@ public class MigrationGenerator {
|
||||
}
|
||||
|
||||
/// 字段类型简单名 → 列类型(String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array<Byte>)
|
||||
/// Option<X> 按内层类型映射(Option 列默认 nullable,见 columnDefinition 的 isRequired/isKey 判定)
|
||||
public static func columnTypeFor(typeName: String): ColumnTypes {
|
||||
match (typeName) {
|
||||
let effective = if (typeName.startsWith("Option<") && typeName.endsWith(">")) {
|
||||
typeName[7..typeName.size - 1]
|
||||
} else {
|
||||
typeName
|
||||
}
|
||||
match (effective) {
|
||||
case "String" => ColumnTypes.TextCol
|
||||
case "Bool" => ColumnTypes.BoolCol
|
||||
case "Int8" => ColumnTypes.TinyIntCol
|
||||
|
||||
@@ -289,6 +289,77 @@ public class DdlFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 AddColumn/AlterColumn 为可执行 SQL 列表(兼容"同名列修改数据结构"场景):
|
||||
/// - 库中无同名列 → 单条 ADD COLUMN
|
||||
/// - 有同名列且类型一致 → AddColumn 幂等跳过(空列表);AlterColumn 保留原 SQL(处理非空/长度等变化)
|
||||
/// - 有同名列但类型不同 → 先 DROP COLUMN 再 ADD COLUMN(重建该字段)
|
||||
/// existingType 来自 information_schema.data_type;None 表示列不存在。
|
||||
public func resolveColumnSqls(op: MigrationOperation, dialect: ISqlDialect,
|
||||
existingType: ?String): ArrayList<String> {
|
||||
let result = ArrayList<String>()
|
||||
let col = op.column.getOrThrow()
|
||||
match (existingType) {
|
||||
case None => result.add(toSql(op, dialect))
|
||||
case Some(dbType) =>
|
||||
let target = dialect.columnTypeSql(col.columnType, col.autoIncrement, col.maxLength)
|
||||
if (normalizeTypeName(dbType) == normalizeTypeName(target)) {
|
||||
// 类型一致:AddColumn 视为幂等跳过;AlterColumn 保留原 SQL
|
||||
let isAlter = match (op.kind) {
|
||||
case MigrationOperationKind.AlterColumn => true
|
||||
case _ => false
|
||||
}
|
||||
if (isAlter) {
|
||||
result.add(toSql(op, dialect))
|
||||
}
|
||||
} else {
|
||||
// 类型不同:先删除老字段,再新增新字段
|
||||
let drop = MigrationOperation(MigrationOperationKind.DropColumn)
|
||||
drop.tableName = op.tableName
|
||||
drop.columnName = col.name
|
||||
result.add(toSql(drop, dialect))
|
||||
result.add(toSql(op, dialect))
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 类型名归一化:方言 SQL 类型 ↔ information_schema.data_type,统一为小写基础类型(忽略长度/精度括号)。
|
||||
public static func normalizeTypeName(t: String): String {
|
||||
let lower = asciiLower(t)
|
||||
let base = if (let Some(i) <- lower.indexOf("(")) { lower[0..i] } else { lower }
|
||||
match (base) {
|
||||
case "bigserial" | "bigint" | "int8" => "bigint"
|
||||
case "serial" | "integer" | "int" | "int4" => "integer"
|
||||
case "smallint" | "int2" => "smallint"
|
||||
case "varchar" | "character varying" => "varchar"
|
||||
case "character" | "char" | "bpchar" => "char"
|
||||
case "text" => "text"
|
||||
case "boolean" | "bool" => "boolean"
|
||||
case "double precision" | "float8" => "double precision"
|
||||
case "real" | "float4" => "real"
|
||||
case "timestamp" | "timestamp without time zone" | "timestamp with time zone" => "timestamp"
|
||||
case "decimal" | "numeric" => "decimal"
|
||||
case "bytea" => "bytea"
|
||||
case _ => base
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII 大写转小写(A-Z → a-z,其余字符不变;仅依赖切片与 indexOf,避免 std String 缺失 API)
|
||||
private static func asciiLower(s: String): String {
|
||||
let upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
let lower = "abcdefghijklmnopqrstuvwxyz"
|
||||
let sb = StringBuilder()
|
||||
for (i in 0..s.size) {
|
||||
let c = s[i..i + 1]
|
||||
if (let Some(idx) <- upper.indexOf(c)) {
|
||||
sb.append(lower[idx..idx + 1])
|
||||
} else {
|
||||
sb.append(c)
|
||||
}
|
||||
}
|
||||
sb.toString()
|
||||
}
|
||||
|
||||
private func createTableSql(op: MigrationOperation, dialect: ISqlDialect): String {
|
||||
let sb = StringBuilder()
|
||||
sb.append("CREATE TABLE IF NOT EXISTS ${dialect.quoteName(op.tableName)} (\n")
|
||||
|
||||
@@ -48,11 +48,30 @@ public class Migrator {
|
||||
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()
|
||||
let sqls = ArrayList<String>()
|
||||
let isColumnOp = match (op.kind) {
|
||||
case MigrationOperationKind.AddColumn |
|
||||
MigrationOperationKind.AlterColumn => true
|
||||
case _ => false
|
||||
}
|
||||
if (isColumnOp) {
|
||||
// 同名列类型变更时先删后加(见 DdlFactory.resolveColumnSqls)
|
||||
let col = op.column.getOrThrow()
|
||||
let resolved = factory.resolveColumnSqls(op, _dialect,
|
||||
columnTypeInDb(conn, op.tableName, col.name))
|
||||
for (s in resolved) {
|
||||
sqls.add(s)
|
||||
}
|
||||
} else {
|
||||
sqls.add(factory.toSql(op, _dialect))
|
||||
}
|
||||
for (sql in sqls) {
|
||||
let stmt = conn.prepareStatement(sql)
|
||||
try {
|
||||
stmt.update()
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
recordApplied(conn, m)
|
||||
@@ -153,6 +172,26 @@ public class Migrator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询库中某列的数据类型(information_schema.data_type);列不存在返回 None。
|
||||
/// 执行 addColumn/alterColumn 前调用,用于识别"同名列修改数据结构"场景。
|
||||
private func columnTypeInDb(conn: Connection, table: String, column: String): ?String {
|
||||
let stmt = conn.prepareStatement(
|
||||
"SELECT data_type FROM information_schema.columns WHERE table_schema = current_schema() " +
|
||||
"AND table_name = ? AND column_name = ?")
|
||||
try {
|
||||
stmt.set<String>(0, table)
|
||||
stmt.set<String>(1, column)
|
||||
let rs = stmt.query()
|
||||
if (rs.next()) {
|
||||
rs.getOrNull<String>(0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} finally {
|
||||
stmt.close()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveTarget(appliedList: ArrayList<Migration>, target: String): Int64 {
|
||||
var exact: Int64 = -1
|
||||
let prefixHits = ArrayList<Int64>()
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*
|
||||
* 快照 JSON 结构(版本 1):
|
||||
* {"version":1,"models":[{"table":"users","columns":[
|
||||
* {"name":"_id","column":"id","type":"Int64","key":true,"auto":true,
|
||||
* {"name":"id","column":"id","type":"Int64","key":true,"auto":true,
|
||||
* "client":false,"required":false,"maxLen":0}]}]}
|
||||
*/
|
||||
|
||||
|
||||
+24
-13
@@ -4,11 +4,11 @@
|
||||
*
|
||||
* 约定(对齐 EF Core 数据模型):
|
||||
* - 表名:@Table["name"] 优先,否则取类简单名;
|
||||
* - 列名:@Column["name"] 优先,否则按 ColumnNamingPolicy(默认剥前导下划线,_id → id);
|
||||
* - 主键:@Key 标注,或字段名 _id;
|
||||
* - 自增:@AutoIncrement,或 整型 _id 主键 默认自增;
|
||||
* - 列名:@Column["name"] 优先,否则按 ColumnNamingPolicy(默认原样,字段名即列名,不做转换);
|
||||
* - 主键:@Key 标注,或字段名 id;
|
||||
* - 自增:@AutoIncrement,或 整型 id 主键 默认自增;
|
||||
* - 客户端生成主键:String 主键默认生成唯一字符串(GuidUtil);
|
||||
* - 实体字段必须是 public var 标量类型(v1 不支持 Option 字段、父类字段)。
|
||||
* - 实体字段必须是 public var 标量类型,支持 Option<T> 字段(None ↔ NULL,DDL 列可空)。
|
||||
*/
|
||||
|
||||
package simcu::orm.model
|
||||
@@ -25,7 +25,7 @@ import simcu::orm.annotations.*
|
||||
public enum ColumnNamingPolicy {
|
||||
/// 字段名原样作列名(含前导下划线)
|
||||
| Keep
|
||||
/// 剥除前导下划线(默认):_id → id、_name → name
|
||||
/// 剥除前导下划线:_id → id、_name → name
|
||||
| StripUnderscore
|
||||
/// 剥除前导下划线 + 驼峰转下划线小写:_userName → user_name
|
||||
| SnakeCase
|
||||
@@ -68,6 +68,20 @@ public class PropertyModel {
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
/// 字段是否为 Option 类型("Option<" 前缀,与快照存储的 typeName 一致)
|
||||
public func isOption(): Bool {
|
||||
typeName().startsWith("Option<")
|
||||
}
|
||||
|
||||
/// 内层类型简单名:Option<X> → X;非 Option 原样返回
|
||||
public func effectiveTypeName(): String {
|
||||
let tn = typeName()
|
||||
if (tn.startsWith("Option<") && tn.endsWith(">")) {
|
||||
return tn[7..tn.size - 1]
|
||||
}
|
||||
tn
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +148,7 @@ public class ModelCache {
|
||||
private init() {}
|
||||
|
||||
private static let _cache = HashMap<String, EntityModel>()
|
||||
private static var _namingPolicy: ColumnNamingPolicy = ColumnNamingPolicy.StripUnderscore
|
||||
private static var _namingPolicy: ColumnNamingPolicy = ColumnNamingPolicy.Keep
|
||||
|
||||
/// 设置全局列命名策略(影响后续首次构建的模型)
|
||||
public static func setNamingPolicy(policy: ColumnNamingPolicy): Unit {
|
||||
@@ -180,11 +194,8 @@ public class ModelCache {
|
||||
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)) {
|
||||
// Option<X> 字段:放开校验,按内层类型处理(写库 None → NULL,读库 NULL → None)
|
||||
if (!ValueReader.isSupported(propM.effectiveTypeName())) {
|
||||
throw Exception(
|
||||
"simorm: 字段 ${v.name} 的类型 ${typeName} 不受支持,支持的标量类型:String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array<Byte>")
|
||||
}
|
||||
@@ -203,10 +214,10 @@ public class ModelCache {
|
||||
// 自增 / 客户端生成
|
||||
if (v.findAnnotation<AutoIncrement>().isSome()) {
|
||||
propM.autoIncrement = true
|
||||
} else if (propM.isKey && isIntegerType(typeName)) {
|
||||
} else if (propM.isKey && isIntegerType(propM.effectiveTypeName())) {
|
||||
propM.autoIncrement = true
|
||||
}
|
||||
if (propM.isKey && typeName == "String") {
|
||||
if (propM.isKey && propM.effectiveTypeName() == "String") {
|
||||
propM.clientGenerated = true
|
||||
}
|
||||
// 必填 / 最大长度
|
||||
|
||||
@@ -12,6 +12,7 @@ package simcu::orm.model
|
||||
import std.collection.*
|
||||
import std.database.sql.{QueryResult, Statement}
|
||||
import std.math.numeric.*
|
||||
import std.reflect.*
|
||||
import std.time.*
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,16 @@ public class ValueReader {
|
||||
* 返回 None 表示该列为 NULL。
|
||||
*/
|
||||
public static func read(rows: QueryResult, index: Int, typeName: String): ?Any {
|
||||
// Option<X>:NULL → Some(装箱 None);有值 → Some(装箱 Some(x))。
|
||||
// 不能用 None(NULL 语义)表示 Option 字段的 None,否则物化时会跳过赋值、保留默认值。
|
||||
if (typeName.startsWith("Option<")) {
|
||||
let inner = typeName[7..typeName.size - 1]
|
||||
let raw = read(rows, index, inner)
|
||||
if (let Some(v) <- raw) {
|
||||
return Some(constructOption(typeName, "Some", v))
|
||||
}
|
||||
return Some(constructOption(typeName, "None", defaultValueFor(inner)))
|
||||
}
|
||||
match (typeName) {
|
||||
case "String" => wrap(rows.getOrNull<String>(index))
|
||||
case "Bool" => wrap(rows.getOrNull<Bool>(index))
|
||||
@@ -55,6 +66,50 @@ public class ValueReader {
|
||||
None
|
||||
}
|
||||
|
||||
/// 用反射枚举构造器创建 Option<X> 的 Some(x)/None。
|
||||
/// None 构造器的参数槽类型是内层 X(反射层统一登记为 1 参数槽),传占位值即可。
|
||||
private static func constructOption(optionTypeName: String, ctorName: String, arg: Any): Any {
|
||||
if (let et: EnumTypeInfo <- TypeInfo.get(optionTypeName)) {
|
||||
let ctor = et.getConstructor(ctorName, argsCount: 1)
|
||||
return ctor.apply([arg])
|
||||
}
|
||||
throw Exception("simorm: 无法构造 Option 类型 ${optionTypeName} 的 ${ctorName}")
|
||||
}
|
||||
|
||||
/// 内层类型占位默认值(构造 Option None 用;None 构造器忽略实参语义)
|
||||
private static func defaultValueFor(typeName: String): Any {
|
||||
match (typeName) {
|
||||
case "String" => return ""
|
||||
case "Bool" => return false
|
||||
case "Int8" => return Int8(0)
|
||||
case "Int16" => return Int16(0)
|
||||
case "Int32" => return Int32(0)
|
||||
case "Int64" => return Int64(0)
|
||||
case "UInt8" => return UInt8(0)
|
||||
case "UInt16" => return UInt16(0)
|
||||
case "UInt32" => return UInt32(0)
|
||||
case "UInt64" => return UInt64(0)
|
||||
case "Float32" => return Float32(0.0)
|
||||
case "Float64" => return Float64(0.0)
|
||||
case "Rune" => return Rune(0)
|
||||
case "DateTime" => return DateTime.parse("1970-01-01 00:00:00", "yyyy-MM-dd HH:mm:ss")
|
||||
case "Duration" => return Duration.Zero
|
||||
case "Decimal" => return Decimal(0)
|
||||
case "Array<Byte>" => return Array<Byte>()
|
||||
case _ => ()
|
||||
}
|
||||
// 其他(对象/枚举):反射无参构造;失败则抛异常
|
||||
if (let ct: ClassTypeInfo <- TypeInfo.get(typeName)) {
|
||||
return ct.construct([])
|
||||
}
|
||||
if (let et: EnumTypeInfo <- TypeInfo.get(typeName)) {
|
||||
let first = et.constructors.iterator().next().getOrThrow()
|
||||
let ctor = et.getConstructor(first.name, argsCount: 0)
|
||||
return ctor.apply([])
|
||||
}
|
||||
throw Exception("simorm: 无法为 Option 占位值构造类型 ${typeName}")
|
||||
}
|
||||
|
||||
/// 属性类型是否受支持(ModelCache 构建时预校验)
|
||||
public static func isSupported(typeName: String): Bool {
|
||||
match (typeName) {
|
||||
|
||||
@@ -124,7 +124,7 @@ public open class PostgreSqlDialect <: ISqlDialect {
|
||||
}
|
||||
}
|
||||
|
||||
public func buildDropIndex(indexName: String, table: String): String {
|
||||
public func buildDropIndex(indexName: String, _: String): String {
|
||||
"DROP INDEX IF EXISTS ${quoteName(indexName)}"
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class MigrationFileGeneratorTests {
|
||||
let model = EntityModel()
|
||||
model.tableName = "users"
|
||||
let pk = PropertyModel()
|
||||
pk.name = "_id"
|
||||
pk.name = "id"
|
||||
pk.columnName = "id"
|
||||
pk.isKey = true
|
||||
pk.autoIncrement = true
|
||||
@@ -48,7 +48,7 @@ class MigrationFileGeneratorTests {
|
||||
pk.typeNameOverride = "Int64"
|
||||
model.properties.add(pk)
|
||||
let name = PropertyModel()
|
||||
name.name = "_name"
|
||||
name.name = "name"
|
||||
name.columnName = "name"
|
||||
name.isRequired = true
|
||||
name.maxLength = 100
|
||||
@@ -62,7 +62,7 @@ class MigrationFileGeneratorTests {
|
||||
let model = EntityModel()
|
||||
model.tableName = "orders"
|
||||
let pk = PropertyModel()
|
||||
pk.name = "_oid"
|
||||
pk.name = "oid"
|
||||
pk.columnName = "oid"
|
||||
pk.isKey = true
|
||||
pk.clientGenerated = true
|
||||
|
||||
+113
-51
@@ -19,34 +19,34 @@ import simcu::orm.*
|
||||
|
||||
@Table["users"]
|
||||
class User {
|
||||
public var _id: Int64 = 0
|
||||
public var _name: String = ""
|
||||
public var _age: Int32 = 0
|
||||
public var id: Int64 = 0
|
||||
public var name: String = ""
|
||||
public var age: Int32 = 0
|
||||
}
|
||||
|
||||
@Table["orders"]
|
||||
class Order {
|
||||
@Key
|
||||
public var _orderId: String = ""
|
||||
public var orderId: String = ""
|
||||
@Column["full_name"]
|
||||
public var _name: String = ""
|
||||
public var name: String = ""
|
||||
@Ignore
|
||||
public var _temp: String = ""
|
||||
public var temp: String = ""
|
||||
}
|
||||
|
||||
class Product {
|
||||
@Key
|
||||
@AutoIncrement
|
||||
public var _pid: Int64 = 0
|
||||
public var pid: Int64 = 0
|
||||
@Required
|
||||
@MaxLength[100]
|
||||
public var _title: String = ""
|
||||
public var _price: Float64 = 0.0
|
||||
public var _active: Bool = true
|
||||
public var title: String = ""
|
||||
public var price: Float64 = 0.0
|
||||
public var active: Bool = true
|
||||
}
|
||||
|
||||
class NoKeyEntity {
|
||||
public var _name: String = ""
|
||||
public var name: String = ""
|
||||
}
|
||||
|
||||
@Table["plain_keys"]
|
||||
@@ -57,14 +57,14 @@ class PlainIdEntity {
|
||||
|
||||
class MultiKeyEntity {
|
||||
@Key
|
||||
public var _a: String = ""
|
||||
public var a: String = ""
|
||||
@Key
|
||||
public var _b: String = ""
|
||||
public var b: String = ""
|
||||
}
|
||||
|
||||
class OptionEntity {
|
||||
public var _id: Int64 = 0
|
||||
public var _nick: ?String = None
|
||||
public var id: Int64 = 0
|
||||
public var nick: ?String = None
|
||||
}
|
||||
|
||||
enum TestColor {
|
||||
@@ -73,8 +73,8 @@ enum TestColor {
|
||||
}
|
||||
|
||||
class UnsupportedEntity {
|
||||
public var _id: Int64 = 0
|
||||
public var _color: TestColor = TestColor.Red
|
||||
public var id: Int64 = 0
|
||||
public var color: TestColor = TestColor.Red
|
||||
}
|
||||
|
||||
// ---------- 模型映射 ----------
|
||||
@@ -87,17 +87,16 @@ class ModelMappingTests {
|
||||
@Expect(model.tableName, "users")
|
||||
@Expect(model.properties.size, 3)
|
||||
let kp = model.keyProperty.getOrThrow()
|
||||
@Expect(kp.name, "_id")
|
||||
@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].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")
|
||||
@Expect(model.mapColumn("unknown"), "unknown")
|
||||
}
|
||||
|
||||
@TestCase
|
||||
@@ -107,16 +106,16 @@ class ModelMappingTests {
|
||||
// @Ignore 字段不映射
|
||||
@Expect(model.properties.size, 2)
|
||||
let kp = model.keyProperty.getOrThrow()
|
||||
@Expect(kp.name, "_orderId")
|
||||
@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].name, "name")
|
||||
@Expect(model.properties[1].columnName, "full_name")
|
||||
@Expect(model.mapColumn("_name"), "full_name")
|
||||
@Expect(model.mapColumn("name"), "full_name")
|
||||
}
|
||||
|
||||
@TestCase
|
||||
@@ -124,13 +123,13 @@ class ModelMappingTests {
|
||||
let model = ModelCache.get<Product>()
|
||||
@Expect(model.properties.size, 4)
|
||||
let kp = model.keyProperty.getOrThrow()
|
||||
@Expect(kp.name, "_pid")
|
||||
@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.name, "title")
|
||||
@Expect(title.isRequired, true)
|
||||
@Expect(title.maxLength, 100)
|
||||
@Expect(model.properties[2].columnName, "price")
|
||||
@@ -141,7 +140,7 @@ class ModelMappingTests {
|
||||
public func testEntityInstance(): Unit {
|
||||
let model = ModelCache.get<User>()
|
||||
let u = User()
|
||||
u._name = "alice"
|
||||
u.name = "alice"
|
||||
let instance = model.createInstance()
|
||||
// createInstance 走无参构造,字段为默认值
|
||||
model.setValue(instance, model.properties[1], "bob")
|
||||
@@ -176,9 +175,17 @@ class ModelValidationTests {
|
||||
}
|
||||
|
||||
@TestCase
|
||||
public func testOptionFieldThrows(): Unit {
|
||||
let threw = try { ModelCache.get<OptionEntity>(); false } catch (_: Exception) { true }
|
||||
@Expect(threw, true)
|
||||
public func testOptionFieldMaps(): Unit {
|
||||
// Option 字段现在受支持:映射为可空列
|
||||
let model = ModelCache.get<OptionEntity>()
|
||||
@Expect(model.properties.size, 2)
|
||||
let nick = model.properties[1]
|
||||
@Expect(nick.name, "nick")
|
||||
@Expect(nick.columnName, "nick")
|
||||
@Expect(nick.typeName(), "Option<String>")
|
||||
@Expect(nick.isOption(), true)
|
||||
@Expect(nick.effectiveTypeName(), "String")
|
||||
@Expect(nick.isRequired, false)
|
||||
}
|
||||
|
||||
@TestCase
|
||||
@@ -420,6 +427,43 @@ class DdlFactoryTests {
|
||||
@Expect(f.toSql(op3, d),
|
||||
"ALTER TABLE \"t\" ADD COLUMN \"flag\" BOOLEAN DEFAULT FALSE")
|
||||
}
|
||||
|
||||
@TestCase
|
||||
public func testResolveColumnSqls(): Unit {
|
||||
let f = DdlFactory()
|
||||
let d = OpenGaussDialect()
|
||||
// 库中无同名列 → 直接 ADD
|
||||
let addOp = MigrationOperation(MigrationOperationKind.AddColumn)
|
||||
addOp.tableName = "users"
|
||||
let col = ColumnDefinition("email", ColumnTypes.TextCol)
|
||||
addOp.column = Some(col)
|
||||
let sqls1 = f.resolveColumnSqls(addOp, d, None)
|
||||
@Expect(sqls1.size, 1)
|
||||
@Expect(sqls1[0], "ALTER TABLE \"users\" ADD COLUMN \"email\" VARCHAR(255)")
|
||||
// 有同名列且类型一致 → 幂等跳过(0 条)
|
||||
let sqls2 = f.resolveColumnSqls(addOp, d, Some("character varying"))
|
||||
@Expect(sqls2.size, 0)
|
||||
// 有同名列但类型不同 → 先删后加(2 条)
|
||||
let sqls3 = f.resolveColumnSqls(addOp, d, Some("bigint"))
|
||||
@Expect(sqls3.size, 2)
|
||||
@Expect(sqls3[0], "ALTER TABLE \"users\" DROP COLUMN \"email\"")
|
||||
@Expect(sqls3[1], "ALTER TABLE \"users\" ADD COLUMN \"email\" VARCHAR(255)")
|
||||
// DateTime 列:库中为 BIGINT → 先删后加;库中已是 timestamp → 跳过
|
||||
let tsOp = MigrationOperation(MigrationOperationKind.AddColumn)
|
||||
tsOp.tableName = "t"
|
||||
let ts = ColumnDefinition("created_at", ColumnTypes.DateTimeCol)
|
||||
tsOp.column = Some(ts)
|
||||
let sqls4 = f.resolveColumnSqls(tsOp, d, Some("bigint"))
|
||||
@Expect(sqls4.size, 2)
|
||||
@Expect(sqls4[0], "ALTER TABLE \"t\" DROP COLUMN \"created_at\"")
|
||||
let sqls5 = f.resolveColumnSqls(tsOp, d, Some("timestamp without time zone"))
|
||||
@Expect(sqls5.size, 0)
|
||||
// 类型归一化:大小写/长度精度不影响比较
|
||||
@Expect(DdlFactory.normalizeTypeName("VARCHAR(100)"), "varchar")
|
||||
@Expect(DdlFactory.normalizeTypeName("BigInt"), "bigint")
|
||||
@Expect(DdlFactory.normalizeTypeName("timestamp without time zone"), "timestamp")
|
||||
@Expect(DdlFactory.normalizeTypeName("DECIMAL(18, 6)"), "decimal")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- MigrationBuilder 链式调用 ----------
|
||||
@@ -466,8 +510,8 @@ class QueryBuilderTests {
|
||||
let qb = QueryBuilder<User>(model, dialect,
|
||||
{ sql, params => captured.add(sql); paramsCaptured.add(params); ArrayList<User>() },
|
||||
{ sql, params => 42 })
|
||||
qb.filter("_age", ">", Int64(18))
|
||||
qb.filter("_name", "=", "alice")
|
||||
qb.filter("age", ">", Int64(18))
|
||||
qb.filter("name", "=", "alice")
|
||||
let list = qb.toList()
|
||||
@Expect(list.size, 0)
|
||||
@Expect(captured.size, 1)
|
||||
@@ -505,9 +549,9 @@ class QueryBuilderTests {
|
||||
let qb = QueryBuilder<User>(model, dialect,
|
||||
{ sql, params => captured.add(sql); ArrayList<User>() },
|
||||
{ sql, params => 42 })
|
||||
qb.filter("_age", ">", Int64(18))
|
||||
qb.orderBy("_name")
|
||||
qb.orderByDesc("_age")
|
||||
qb.filter("age", ">", Int64(18))
|
||||
qb.orderBy("name")
|
||||
qb.orderByDesc("age")
|
||||
qb.skip(10)
|
||||
qb.take(5)
|
||||
qb.toList()
|
||||
@@ -523,7 +567,7 @@ class QueryBuilderTests {
|
||||
let qb = QueryBuilder<User>(model, dialect,
|
||||
{ sql, params => captured.add(sql); ArrayList<User>() },
|
||||
{ sql, params => captured.add(sql); 42 })
|
||||
qb.filter("_age", ">", Int64(18))
|
||||
qb.filter("age", ">", Int64(18))
|
||||
let n = qb.count()
|
||||
@Expect(n, 42)
|
||||
@Expect(captured.size, 1)
|
||||
@@ -539,11 +583,11 @@ class QueryBuilderTests {
|
||||
{ sql, params => captured.add(sql); ArrayList<User>() },
|
||||
{ sql, params => captured.add(sql); 42 })
|
||||
// first() 空结果 → None
|
||||
let first = qb.filter("_age", ">", Int64(18)).first()
|
||||
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)
|
||||
let result = qb.filter("age", ">", Int64(18)).page(2, 10)
|
||||
@Expect(result.total, 42)
|
||||
@Expect(result.page, 2)
|
||||
@Expect(result.pageSize, 10)
|
||||
@@ -558,44 +602,44 @@ class QueryBuilderTests {
|
||||
|
||||
@Table["users"]
|
||||
class UserV1 {
|
||||
public var _id: Int64 = 0
|
||||
public var _name: String = ""
|
||||
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
|
||||
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 = ""
|
||||
public var id: Int64 = 0
|
||||
public var title: String = ""
|
||||
}
|
||||
|
||||
@Table["items"]
|
||||
class ItemV2 {
|
||||
public var _id: Int64 = 0
|
||||
public var id: Int64 = 0
|
||||
@MaxLength[100]
|
||||
public var _title: String = ""
|
||||
public var title: String = ""
|
||||
}
|
||||
|
||||
@Table["extra"]
|
||||
class ExtraTable {
|
||||
public var _id: Int64 = 0
|
||||
public var id: Int64 = 0
|
||||
}
|
||||
|
||||
@Table["keys"]
|
||||
class KeyV1 {
|
||||
public var _id: Int64 = 0
|
||||
public var id: Int64 = 0
|
||||
}
|
||||
|
||||
@Table["keys"]
|
||||
class KeyV2 {
|
||||
@Key
|
||||
public var _code: String = ""
|
||||
public var code: String = ""
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -716,6 +760,10 @@ class MigrationGeneratorTests {
|
||||
@Expect(match (MigrationGenerator.columnTypeFor("DateTime")) { case ColumnTypes.DateTimeCol => true; case _ => false }, true)
|
||||
@Expect(match (MigrationGenerator.columnTypeFor("Decimal")) { case ColumnTypes.DecimalCol => true; case _ => false }, true)
|
||||
@Expect(match (MigrationGenerator.columnTypeFor("Array<Byte>")) { case ColumnTypes.BinaryCol => true; case _ => false }, true)
|
||||
// Option<X> 按内层类型映射
|
||||
@Expect(match (MigrationGenerator.columnTypeFor("Option<String>")) { case ColumnTypes.TextCol => true; case _ => false }, true)
|
||||
@Expect(match (MigrationGenerator.columnTypeFor("Option<Int64>")) { case ColumnTypes.BigIntCol => true; case _ => false }, true)
|
||||
@Expect(match (MigrationGenerator.columnTypeFor("Option<DateTime>")) { case ColumnTypes.DateTimeCol => true; case _ => false }, true)
|
||||
let threw = try {
|
||||
MigrationGenerator.columnTypeFor("Unknown")
|
||||
false
|
||||
@@ -724,6 +772,20 @@ class MigrationGeneratorTests {
|
||||
}
|
||||
@Expect(threw, true)
|
||||
}
|
||||
|
||||
@TestCase
|
||||
public func testOptionColumnDdl(): Unit {
|
||||
let models = ArrayList<EntityModel>()
|
||||
models.add(ModelCache.get<OptionEntity>())
|
||||
let m = gen().initial("20250910000000_OptionTable", "Option 字段建表", models)
|
||||
let d = OpenGaussDialect()
|
||||
let up = upSql(m, d)
|
||||
@Expect(up.size, 1)
|
||||
// 主键列 NOT NULL;Option 列 nullable(无 NOT NULL)
|
||||
@Expect(up[0].contains("\"id\" BIGSERIAL NOT NULL PRIMARY KEY"), true)
|
||||
@Expect(up[0].contains("\"nick\" VARCHAR(255)"), true)
|
||||
@Expect(up[0].contains("\"nick\" VARCHAR(255) NOT NULL"), false)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- PostgreSQL 方言 ----------
|
||||
@@ -796,7 +858,7 @@ class PostgreSqlDialectTests {
|
||||
// ---------- 应用继承 DbContext ----------
|
||||
|
||||
class FakeDatasource <: Datasource {
|
||||
public func setOption(key: String, value: String): Unit {}
|
||||
public func setOption(_: String, _: String): Unit {}
|
||||
|
||||
public func connect(): Connection {
|
||||
throw Exception("FakeDatasource 不支持真实连接")
|
||||
|
||||
Reference in New Issue
Block a user