feat: 支持 Option<T> 字段(None↔NULL)、列命名默认原样(Keep)、迁移同名列先删后加,版本 1.1.0

This commit is contained in:
2026-08-21 03:41:53 +08:00
parent 132aae865b
commit 394c4a1fb5
14 changed files with 335 additions and 87 deletions
+3
View File
@@ -1,4 +1,7 @@
# 构建产物
target/
# 宏编译产物(构建时自动生成,不入库)
src/macros/*.cjo
src/macros/*.dll
# 临时/日志
*.log
+6 -5
View File
@@ -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)`)需自行保证列名合法(会做标识符引号包裹校验外的处理)——推荐优先使用三参属性形式;
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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
View File
@@ -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)
+7 -1
View File
@@ -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
+71
View File
@@ -289,6 +289,77 @@ public class DdlFactory {
}
}
/// 解析 AddColumn/AlterColumn 为可执行 SQL 列表(兼容"同名列修改数据结构"场景):
/// - 库中无同名列 → 单条 ADD COLUMN
/// - 有同名列且类型一致 → AddColumn 幂等跳过(空列表);AlterColumn 保留原 SQL(处理非空/长度等变化)
/// - 有同名列但类型不同 → 先 DROP COLUMN 再 ADD COLUMN(重建该字段)
/// existingType 来自 information_schema.data_typeNone 表示列不存在。
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")
+44 -5
View File
@@ -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>()
+1 -1
View File
@@ -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
View File
@@ -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 ↔ NULLDDL 列可空)。
*/
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
}
// 必填 / 最大长度
+55
View File
@@ -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))。
// 不能用 NoneNULL 语义)表示 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) {
+1 -1
View File
@@ -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)}"
}
+3 -3
View File
@@ -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
View File
@@ -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 后 selecttotalPages
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 NULLOption 列 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 不支持真实连接")