diff --git a/.gitignore b/.gitignore index c01bd4c..1e14935 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ # 构建产物 target/ +# 宏编译产物(构建时自动生成,不入库) +src/macros/*.cjo +src/macros/*.dll # 临时/日志 *.log diff --git a/README.md b/README.md index af9d03d..75965f7 100644 --- a/README.md +++ b/README.md @@ -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→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→BinaryCol`、`Rune→IntCol`、`Duration→BigIntCol`;`Option` 按内层类型 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`。 +支持的字段标量类型:`String` / `Bool` / `Int8~Int64` / `UInt8~UInt64` / `Float32` / `Float64` / `Rune` / `DateTime` / `Duration` / `Decimal` / `Array`,以及上述类型的 **`Option` 包装**(`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 ''`。规避:默认值改用 `= String()` 或直接省略初始化器。`orm-cj` 包内代码与 e2e 示例均按此规避写法; - **仅单主键**;不支持复合主键; -- **不支持 Option 字段**(`?String` 等请改用具体标量类型)与**父类字段**(不扫描继承字段); +- **Option 字段已支持**:`?String` 等可空字段 `None ↔ NULL`(详见「模型映射约定」);**父类字段暂不支持**(不扫描继承字段); - 不支持导航属性 / 延迟加载 / 级联删除(对齐 EF Core 这些能力属于 v2+); - 不支持 LINQ 表达式树,查询以 `filter` 条件方法 + SQL 片段组合; - `QueryBuilder` 的 SQL 片段形式(`filter(condition, params)`)需自行保证列名合法(会做标识符引号包裹校验外的处理)——推荐优先使用三参属性形式; diff --git a/cjpm.toml b/cjpm.toml index fe1e98b..3e053ce 100644 --- a/cjpm.toml +++ b/cjpm.toml @@ -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" diff --git a/src/db/DatasourceFactory.cj b/src/db/DatasourceFactory.cj index ee56b15..d2c4c69 100644 --- a/src/db/DatasourceFactory.cj +++ b/src/db/DatasourceFactory.cj @@ -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" } } diff --git a/src/db/DbContext.cj b/src/db/DbContext.cj index a14e60a..6a3b9cf 100644 --- a/src/db/DbContext.cj +++ b/src/db/DbContext.cj @@ -322,7 +322,7 @@ public open class DbContext <: CliContext { } /// 执行 COUNT 查询(query 层回调) - internal func count(model: EntityModel, sql: String, params: ArrayList): Int64 { + internal func count(sql: String, params: ArrayList): Int64 { let conn = datasource.connect() try { let stmt = conn.prepareStatement(sql) @@ -529,7 +529,7 @@ public class DbSet <: DbSetBase { public func query(): QueryBuilder { QueryBuilder(_model, _context.getDialect(), { sql, params => _context.query(_model, sql, params) }, - { sql, params => _context.count(_model, sql, params) }) + { sql, params => _context.count(sql, params) }) } /// 按主键查询(无结果返回 None) diff --git a/src/migrations/MigrationGenerator.cj b/src/migrations/MigrationGenerator.cj index 21018b1..5fe109d 100644 --- a/src/migrations/MigrationGenerator.cj +++ b/src/migrations/MigrationGenerator.cj @@ -91,8 +91,14 @@ public class MigrationGenerator { } /// 字段类型简单名 → 列类型(String/Bool/Int8-64/UInt8-64/Float32-64/Rune/DateTime/Duration/Decimal/Array) + /// Option 按内层类型映射(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 diff --git a/src/migrations/Migrations.cj b/src/migrations/Migrations.cj index 4a96900..78f4eb4 100644 --- a/src/migrations/Migrations.cj +++ b/src/migrations/Migrations.cj @@ -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 { + let result = ArrayList() + 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") diff --git a/src/migrations/Migrator.cj b/src/migrations/Migrator.cj index 72c763b..d619a9b 100644 --- a/src/migrations/Migrator.cj +++ b/src/migrations/Migrator.cj @@ -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() + 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(0, table) + stmt.set(1, column) + let rs = stmt.query() + if (rs.next()) { + rs.getOrNull(0) + } else { + None + } + } finally { + stmt.close() + } + } + private func resolveTarget(appliedList: ArrayList, target: String): Int64 { var exact: Int64 = -1 let prefixHits = ArrayList() diff --git a/src/migrations/ModelSnapshot.cj b/src/migrations/ModelSnapshot.cj index 81b82d3..d5af79b 100644 --- a/src/migrations/ModelSnapshot.cj +++ b/src/migrations/ModelSnapshot.cj @@ -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}]}]} */ diff --git a/src/model/EntityModel.cj b/src/model/EntityModel.cj index b88e690..1f81f0c 100644 --- a/src/model/EntityModel.cj +++ b/src/model/EntityModel.cj @@ -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 字段(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;非 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() - 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 字段:放开校验,按内层类型处理(写库 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") } @@ -203,10 +214,10 @@ public class ModelCache { // 自增 / 客户端生成 if (v.findAnnotation().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 } // 必填 / 最大长度 diff --git a/src/model/ValueReader.cj b/src/model/ValueReader.cj index 9d88f0d..3eeecc9 100644 --- a/src/model/ValueReader.cj +++ b/src/model/ValueReader.cj @@ -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: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(index)) case "Bool" => wrap(rows.getOrNull(index)) @@ -55,6 +66,50 @@ public class ValueReader { None } + /// 用反射枚举构造器创建 Option 的 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" => return Array() + 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) { diff --git a/src/sql/PostgreSqlDialect.cj b/src/sql/PostgreSqlDialect.cj index a714bb1..12f0f99 100644 --- a/src/sql/PostgreSqlDialect.cj +++ b/src/sql/PostgreSqlDialect.cj @@ -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)}" } diff --git a/src/tests/MigrationCli_test.cj b/src/tests/MigrationCli_test.cj index 9979056..571ecda 100644 --- a/src/tests/MigrationCli_test.cj +++ b/src/tests/MigrationCli_test.cj @@ -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 diff --git a/src/tests/SimOrm_test.cj b/src/tests/SimOrm_test.cj index c2e1013..0e45d83 100644 --- a/src/tests/SimOrm_test.cj +++ b/src/tests/SimOrm_test.cj @@ -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() @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() 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(); false } catch (_: Exception) { true } - @Expect(threw, true) + public func testOptionFieldMaps(): Unit { + // Option 字段现在受支持:映射为可空列 + let model = ModelCache.get() + @Expect(model.properties.size, 2) + let nick = model.properties[1] + @Expect(nick.name, "nick") + @Expect(nick.columnName, "nick") + @Expect(nick.typeName(), "Option") + @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(model, dialect, { sql, params => captured.add(sql); paramsCaptured.add(params); ArrayList() }, { 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(model, dialect, { sql, params => captured.add(sql); ArrayList() }, { 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(model, dialect, { sql, params => captured.add(sql); ArrayList() }, { 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() }, { 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")) { case ColumnTypes.BinaryCol => true; case _ => false }, true) + // Option 按内层类型映射 + @Expect(match (MigrationGenerator.columnTypeFor("Option")) { case ColumnTypes.TextCol => true; case _ => false }, true) + @Expect(match (MigrationGenerator.columnTypeFor("Option")) { case ColumnTypes.BigIntCol => true; case _ => false }, true) + @Expect(match (MigrationGenerator.columnTypeFor("Option")) { 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() + models.add(ModelCache.get()) + 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 不支持真实连接")