Files
orm-cj/src/tests/sim_orm_test.cj
T

1149 lines
43 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* SimOrm 单元测试(cjpm test),全部为纯逻辑测试,不连接数据库。
* 覆盖:模型映射与命名策略 / 模型校验(无主键、多主键、Option、不支持类型)/
* ChangeTracker 操作队列 / OpenGaussDialect SQL 生成 / DdlFactory DDL 生成 /
* MigrationBuilder 链式调用 / QueryBuilder SQL 构建(假 executor 捕获 SQL)。
*/
package simcu::orm.tests
import std.collection.*
import std.database.sql.*
import std.fs.*
import std.reflect.*
import std.unittest.*
import std.unittest.testmacro.*
import simcu::orm.*
// ---------- 测试实体 ----------
@Table["users"]
class User {
public var id: Int64 = 0
public var name: String = ""
public var age: Int32 = 0
}
@Table["orders"]
class Order {
@Key
public var orderId: String = ""
@Column["full_name"]
public var name: String = ""
@Ignore
public var temp: String = ""
}
class Product {
@Key
@AutoIncrement
public var pid: Int64 = 0
@Required
@MaxLength[100]
public var title: String = ""
public var price: Float64 = 0.0
public var active: Bool = true
}
class NoKeyEntity {
public var name: String = ""
}
@Table["plain_keys"]
class PlainIdEntity {
public var id: String = ""
public var name: String = ""
}
class MultiKeyEntity {
@Key
public var a: String = ""
@Key
public var b: String = ""
}
class OptionEntity {
public var id: Int64 = 0
public var nick: ?String = None
}
enum TestColor {
| Red
| Green
}
enum AssetKind {
| Passport
| Package
}
class UnsupportedEntity {
public var id: Int64 = 0
public var color: TestColor = TestColor.Red
}
class EnumEntity {
public var id: Int64 = 0
public var kind: AssetKind = AssetKind.Passport
public var optKind: ?AssetKind = None
}
class UnsupportedArrayEntity {
public var id: Int64 = 0
public var tags: Array<Int32> = Array<Int32>()
}
// ---------- 模型映射 ----------
@Test
class ModelMappingTests {
@TestCase
public func testUserModel(): Unit {
let model = ModelCache.get<User>()
@Expect(model.tableName, "users")
@Expect(model.properties.size, 3)
let kp = model.keyProperty.getOrThrow()
@Expect(kp.name, "id")
@Expect(kp.columnName, "id")
@Expect(kp.isKey, true)
@Expect(kp.autoIncrement, true)
@Expect(kp.clientGenerated, false)
@Expect(model.properties[1].name, "name")
@Expect(model.properties[1].columnName, "name")
@Expect(model.properties[2].columnName, "age")
@Expect(model.mapColumn("age"), "age")
@Expect(model.mapColumn("unknown"), "unknown")
}
@TestCase
public func testOrderModel(): Unit {
let model = ModelCache.get<Order>()
@Expect(model.tableName, "orders")
// @Ignore 字段不映射
@Expect(model.properties.size, 2)
let kp = model.keyProperty.getOrThrow()
@Expect(kp.name, "orderId")
@Expect(kp.columnName, "orderId")
@Expect(kp.isKey, true)
// String 主键 → 客户端生成,不自增
@Expect(kp.clientGenerated, true)
@Expect(kp.autoIncrement, false)
// @Column 覆盖列名
@Expect(model.properties[1].name, "name")
@Expect(model.properties[1].columnName, "full_name")
@Expect(model.mapColumn("name"), "full_name")
}
@TestCase
public func testProductModel(): Unit {
let model = ModelCache.get<Product>()
@Expect(model.properties.size, 4)
let kp = model.keyProperty.getOrThrow()
@Expect(kp.name, "pid")
@Expect(kp.columnName, "pid")
@Expect(kp.isKey, true)
@Expect(kp.autoIncrement, true)
// @Required / @MaxLength
let title = model.properties[1]
@Expect(title.name, "title")
@Expect(title.isRequired, true)
@Expect(title.maxLength, 100)
@Expect(model.properties[2].columnName, "price")
@Expect(model.properties[3].columnName, "active")
}
@TestCase
public func testEntityInstance(): Unit {
let model = ModelCache.get<User>()
let u = User()
u.name = "alice"
let instance = model.createInstance()
// createInstance 走无参构造,字段为默认值
model.setValue(instance, model.properties[1], "bob")
@Expect((model.getValue(instance, model.properties[1]) as String).getOrThrow(), "bob")
}
}
// ---------- 模型校验(异常) ----------
@Test
class ModelValidationTests {
@TestCase
public func testNoKeyThrows(): Unit {
let threw = try { ModelCache.get<NoKeyEntity>(); false } catch (_: Exception) { true }
@Expect(threw, true)
}
@TestCase
public func testPlainIdRecognizedAsKey(): Unit {
let model = ModelCache.get<PlainIdEntity>()
let kp = model.keyProperty.getOrThrow()
@Expect(kp.name, "id")
@Expect(kp.columnName, "id")
@Expect(kp.isKey, true)
@Expect(kp.clientGenerated, true)
}
@TestCase
public func testMultiKeyThrows(): Unit {
let threw = try { ModelCache.get<MultiKeyEntity>(); false } catch (_: Exception) { true }
@Expect(threw, true)
}
@TestCase
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
public func testEnumFieldMaps(): Unit {
// 简单枚举(含 Option 内层枚举)受支持:标记 isEnum,记录完整类型名
let model = ModelCache.get<EnumEntity>()
@Expect(model.properties.size, 3)
let kind = model.properties[1]
@Expect(kind.name, "kind")
@Expect(kind.isEnum, true)
@Expect(kind.enumTypeName != "", true)
@Expect(kind.typeName(), "AssetKind")
let optKind = model.properties[2]
@Expect(optKind.name, "optKind")
@Expect(optKind.isOption(), true)
@Expect(optKind.isEnum, true)
@Expect(optKind.enumTypeName != "", true)
}
@TestCase
public func testUnsupportedTypeThrows(): Unit {
// Array<Int32> 不在白名单 → 抛异常
let threw = try { ModelCache.get<UnsupportedArrayEntity>(); false } catch (_: Exception) { true }
@Expect(threw, true)
}
@TestCase
public func testEnumToParamValue(): Unit {
// 枚举值 → 构造器名(写库值)
@Expect((TypeUtil.enumToParamValue(TestColor.Red) as String).getOrThrow(), "Red")
@Expect((TypeUtil.enumToParamValue(TestColor.Green) as String).getOrThrow(), "Green")
// 非枚举值原样返回
@Expect((TypeUtil.enumToParamValue("abc") as String).getOrThrow(), "abc")
@Expect((TypeUtil.enumToParamValue(Int64(18)) as Int64).getOrThrow(), 18)
}
@TestCase
public func testEnumFromNameRoundTrip(): Unit {
// 完整类型名 + 构造器名 → 反射构造枚举实例(读库值),构造器名与写库一致
let full = TypeInfo.of(TestColor.Green).toString()
let v = ValueReader.enumFromName(full, "Green")
let et = if (let e: EnumTypeInfo <- TypeInfo.of(v)) { e } else { throw Exception("不是枚举") }
let (ctor, _) = et.destruct(v)
@Expect(ctor.name, "Green")
// 不存在的构造器 → 抛异常
let threw = try { ValueReader.enumFromName(full, "Blue"); false } catch (_: Exception) { true }
@Expect(threw, true)
// 非枚举类型名 → 抛异常
let threw2 = try { ValueReader.enumFromName("std.core.String", "x"); false } catch (_: Exception) { true }
@Expect(threw2, true)
}
}
// ---------- 命名策略 ----------
@Test
class NamingPolicyTests {
@TestCase
public func testApplyNamingPolicy(): Unit {
@Expect(ModelCache.applyNamingPolicy("_userName", ColumnNamingPolicy.SnakeCase), "user_name")
@Expect(ModelCache.applyNamingPolicy("_id", ColumnNamingPolicy.SnakeCase), "id")
@Expect(ModelCache.applyNamingPolicy("_name", ColumnNamingPolicy.StripUnderscore), "name")
@Expect(ModelCache.applyNamingPolicy("_id", ColumnNamingPolicy.StripUnderscore), "id")
@Expect(ModelCache.applyNamingPolicy("_id", ColumnNamingPolicy.Keep), "_id")
@Expect(ModelCache.applyNamingPolicy("userName", ColumnNamingPolicy.Keep), "userName")
@Expect(ModelCache.applyNamingPolicy("__nickName", ColumnNamingPolicy.SnakeCase), "nick_name")
@Expect(ModelCache.applyNamingPolicy("name2", ColumnNamingPolicy.SnakeCase), "name2")
}
}
// ---------- ChangeTracker 操作队列 ----------
@Test
class ChangeTrackerTests {
@TestCase
public func testQueueOrder(): Unit {
let tracker = ChangeTracker()
let u1 = User()
let u2 = User()
tracker.add(u1, EntityState.Added)
tracker.add(u2, EntityState.Modified)
tracker.add(u1, EntityState.Deleted)
@Expect(tracker.count(), 3)
let entries = tracker.getEntries()
let s0 = match (entries[0].state) { case EntityState.Added => true; case _ => false }
let s1 = match (entries[1].state) { case EntityState.Modified => true; case _ => false }
let s2 = match (entries[2].state) { case EntityState.Deleted => true; case _ => false }
@Expect(s0, true)
@Expect(s1, true)
@Expect(s2, true)
tracker.clear()
@Expect(tracker.count(), 0)
}
}
// ---------- OpenGaussDialect SQL 生成 ----------
@Test
class ISqlDialectTests {
@TestCase
public func testQuoteInsertUpdateDelete(): Unit {
let d = OpenGaussDialect()
@Expect(d.quoteName("users"), "\"users\"")
let c = ArrayList<String>()
c.add("id")
c.add("name")
@Expect(d.buildInsert("users", c, "id", true),
"INSERT INTO \"users\" (\"id\", \"name\") VALUES (?, ?) RETURNING \"id\"")
@Expect(d.buildInsert("users", c, "id", false),
"INSERT INTO \"users\" (\"id\", \"name\") VALUES (?, ?)")
@Expect(d.buildUpdate("users", c, "id"),
"UPDATE \"users\" SET \"id\" = ?, \"name\" = ? WHERE \"id\" = ?")
@Expect(d.buildDelete("users", "id"),
"DELETE FROM \"users\" WHERE \"id\" = ?")
}
@TestCase
public func testSelectAndCount(): Unit {
let d = OpenGaussDialect()
let c = ArrayList<String>()
c.add("id")
c.add("name")
@Expect(d.buildSelect("users", c, "(\"age\" > ?)", "\"name\" ASC", 10, 5),
"SELECT \"id\", \"name\" FROM \"users\" WHERE (\"age\" > ?) ORDER BY \"name\" ASC LIMIT 5 OFFSET 10")
@Expect(d.buildSelect("users", c, "", "", 0, 0),
"SELECT \"id\", \"name\" FROM \"users\"")
let empty = ArrayList<String>()
@Expect(d.buildSelect("users", empty, "", "", 0, 0),
"SELECT * FROM \"users\"")
@Expect(d.buildCount("users", ""),
"SELECT COUNT(*) FROM \"users\"")
@Expect(d.buildCount("users", "(\"age\" > ?)"),
"SELECT COUNT(*) FROM \"users\" WHERE (\"age\" > ?)")
}
}
// ---------- DdlFactory DDL 生成 ----------
@Test
class DdlFactoryTests {
@TestCase
public func testColumnTypeSql(): Unit {
let d = OpenGaussDialect()
@Expect(d.columnTypeSql(ColumnTypes.BigIntCol, true, 0), "BIGSERIAL")
@Expect(d.columnTypeSql(ColumnTypes.BigIntCol, false, 0), "BIGINT")
@Expect(d.columnTypeSql(ColumnTypes.IntCol, true, 0), "SERIAL")
@Expect(d.columnTypeSql(ColumnTypes.IntCol, false, 0), "INTEGER")
@Expect(d.columnTypeSql(ColumnTypes.SmallIntCol, false, 0), "SMALLINT")
@Expect(d.columnTypeSql(ColumnTypes.TinyIntCol, false, 0), "SMALLINT")
@Expect(d.columnTypeSql(ColumnTypes.TextCol, false, 100), "VARCHAR(100)")
@Expect(d.columnTypeSql(ColumnTypes.TextCol, false, 0), "VARCHAR(255)")
@Expect(d.columnTypeSql(ColumnTypes.BoolCol, false, 0), "BOOLEAN")
@Expect(d.columnTypeSql(ColumnTypes.FloatCol, false, 0), "DOUBLE PRECISION")
@Expect(d.columnTypeSql(ColumnTypes.RealCol, false, 0), "REAL")
@Expect(d.columnTypeSql(ColumnTypes.DateTimeCol, false, 0), "TIMESTAMP")
@Expect(d.columnTypeSql(ColumnTypes.DecimalCol, false, 0), "DECIMAL(18, 6)")
@Expect(d.columnTypeSql(ColumnTypes.BinaryCol, false, 0), "BYTEA")
}
@TestCase
public func testDropIndexAndAlterColumn(): Unit {
let d = OpenGaussDialect()
@Expect(d.buildDropIndex("ix_users_name", "users"), "DROP INDEX IF EXISTS \"ix_users_name\"")
@Expect(d.buildAlterColumn("users", "\"name\" VARCHAR(128) NOT NULL"),
"ALTER TABLE \"users\" ALTER COLUMN \"name\" VARCHAR(128) NOT NULL")
}
@TestCase
public func testCreateTable(): Unit {
let f = DdlFactory()
let d = OpenGaussDialect()
let op = MigrationOperation(MigrationOperationKind.CreateTable)
op.tableName = "users"
let id = ColumnDefinition("id", ColumnTypes.BigIntCol)
id.primary()
id.autoInc()
id.notNull()
let name = ColumnDefinition("name", ColumnTypes.TextCol)
name.withMaxLength(100)
name.notNull()
let active = ColumnDefinition("active", ColumnTypes.BoolCol)
active.withDefault(true)
op.columnDefs.add(id)
op.columnDefs.add(name)
op.columnDefs.add(active)
let sql = f.toSql(op, d)
@Expect(sql.contains("CREATE TABLE IF NOT EXISTS \"users\""), true)
@Expect(sql.contains("\"id\" BIGSERIAL NOT NULL PRIMARY KEY"), true)
@Expect(sql.contains("\"name\" VARCHAR(100) NOT NULL"), true)
@Expect(sql.contains("\"active\" BOOLEAN DEFAULT TRUE"), true)
}
@TestCase
public func testAlterOperations(): Unit {
let f = DdlFactory()
let d = OpenGaussDialect()
// ADD COLUMN
let addOp = MigrationOperation(MigrationOperationKind.AddColumn)
addOp.tableName = "users"
let col = ColumnDefinition("email", ColumnTypes.TextCol)
col.withDefault("unknown")
addOp.column = Some(col)
@Expect(f.toSql(addOp, d),
"ALTER TABLE \"users\" ADD COLUMN \"email\" VARCHAR(255) DEFAULT 'unknown'")
// DROP COLUMN
let dropOp = MigrationOperation(MigrationOperationKind.DropColumn)
dropOp.tableName = "users"
dropOp.columnName = "email"
@Expect(f.toSql(dropOp, d), "ALTER TABLE \"users\" DROP COLUMN \"email\"")
// ALTER COLUMN
let alterOp = MigrationOperation(MigrationOperationKind.AlterColumn)
alterOp.tableName = "users"
let nc = ColumnDefinition("name", ColumnTypes.TextCol)
nc.withMaxLength(200)
nc.notNull()
alterOp.column = Some(nc)
@Expect(f.toSql(alterOp, d),
"ALTER TABLE \"users\" ALTER COLUMN \"name\" VARCHAR(200) NOT NULL")
// RENAME COLUMN
let renOp = MigrationOperation(MigrationOperationKind.RenameColumn)
renOp.tableName = "users"
renOp.columnName = "old_name"
renOp.newColumnName = "new_name"
@Expect(f.toSql(renOp, d),
"ALTER TABLE \"users\" RENAME COLUMN \"old_name\" TO \"new_name\"")
}
@TestCase
public func testTableAndIndexAndRaw(): Unit {
let f = DdlFactory()
let d = OpenGaussDialect()
// DROP TABLE
let dropOp = MigrationOperation(MigrationOperationKind.DropTable)
dropOp.tableName = "users"
@Expect(f.toSql(dropOp, d), "DROP TABLE IF EXISTS \"users\"")
// CREATE INDEX(非唯一)
let idxOp = MigrationOperation(MigrationOperationKind.CreateIndex)
idxOp.indexName = "ix_users_name"
idxOp.tableName = "users"
idxOp.columnNames.add("name")
@Expect(f.toSql(idxOp, d),
"CREATE INDEX IF NOT EXISTS \"ix_users_name\" ON \"users\" (\"name\")")
// CREATE UNIQUE INDEX
idxOp.unique = true
@Expect(f.toSql(idxOp, d),
"CREATE UNIQUE INDEX IF NOT EXISTS \"ix_users_name\" ON \"users\" (\"name\")")
// DROP INDEX
let dropIdx = MigrationOperation(MigrationOperationKind.DropIndex)
dropIdx.indexName = "ix_users_name"
@Expect(f.toSql(dropIdx, d), "DROP INDEX IF EXISTS \"ix_users_name\"")
// RAW SQL
let rawOp = MigrationOperation(MigrationOperationKind.RawSql)
rawOp.sql = "SELECT 1"
@Expect(f.toSql(rawOp, d), "SELECT 1")
}
@TestCase
public func testDefaultValueEscape(): Unit {
let f = DdlFactory()
let d = OpenGaussDialect()
let op = MigrationOperation(MigrationOperationKind.AddColumn)
op.tableName = "t"
// 字符串默认值含单引号 → 转义
let s = ColumnDefinition("remark", ColumnTypes.TextCol)
s.withDefault("it's ok")
op.column = Some(s)
@Expect(f.toSql(op, d),
"ALTER TABLE \"t\" ADD COLUMN \"remark\" VARCHAR(255) DEFAULT 'it''s ok'")
// 数值默认值
let op2 = MigrationOperation(MigrationOperationKind.AddColumn)
op2.tableName = "t"
let n = ColumnDefinition("cnt", ColumnTypes.IntCol)
n.withDefault(Int64(18))
op2.column = Some(n)
@Expect(f.toSql(op2, d),
"ALTER TABLE \"t\" ADD COLUMN \"cnt\" INTEGER DEFAULT 18")
// 布尔默认值
let op3 = MigrationOperation(MigrationOperationKind.AddColumn)
op3.tableName = "t"
let b = ColumnDefinition("flag", ColumnTypes.BoolCol)
b.withDefault(false)
op3.column = Some(b)
@Expect(f.toSql(op3, d),
"ALTER TABLE \"t\" ADD COLUMN \"flag\" BOOLEAN DEFAULT FALSE")
}
@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 链式调用 ----------
@Test
class MigrationBuilderTests {
@TestCase
public func testChainedCalls(): Unit {
// 链式调用 API 面验证:整条链执行不抛异常(内部 operations 由 DdlFactory/Migrator 消费)
let ok = try {
let b = MigrationBuilder()
b.createTable("t1") { tb =>
tb.column("id", ColumnTypes.BigIntCol).primary().autoInc().notNull()
tb.column("name", ColumnTypes.TextCol).withMaxLength(64).withDefault("none")
}
let idxCols = ArrayList<String>()
idxCols.add("name")
b.createIndex("ix_t1_name", "t1", idxCols)
b.addColumn("t1", ColumnDefinition("age", ColumnTypes.IntCol).withDefault(Int64(0)))
b.dropColumn("t1", "age")
b.alterColumn("t1", ColumnDefinition("name", ColumnTypes.TextCol).withMaxLength(128))
b.renameColumn("t1", "name", "nick")
b.dropIndex("ix_t1_name", "t1")
b.dropTable("t1")
b.rawSql("VACUUM")
true
} catch (_: Exception) {
false
}
@Expect(ok, true)
}
}
// ---------- QueryBuilder(假 executor 捕获 SQL) ----------
@Test
class QueryBuilderTests {
@TestCase
public func testFilterAndSelect(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let paramsCaptured = ArrayList<ArrayList<Any>>()
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")
let list = qb.toList()
@Expect(list.size, 0)
@Expect(captured.size, 1)
@Expect(captured[0],
"SELECT \"id\", \"name\", \"age\" FROM \"users\" WHERE (\"age\" > ?) AND (\"name\" = ?)")
@Expect(paramsCaptured[0].size, 2)
let p0 = (paramsCaptured[0][0] as Int64).getOrThrow()
@Expect(p0, 18)
let p1 = (paramsCaptured[0][1] as String).getOrThrow()
@Expect(p1, "alice")
}
@TestCase
public func testRawFilter(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let qb = QueryBuilder<User>(model, dialect,
{ sql, params => captured.add(sql); ArrayList<User>() },
{ sql, params => 42 })
let ps = ArrayList<Any>()
ps.add(Int64(18))
ps.add(true)
qb.filter("age > ? AND active = ?", ps)
qb.toList()
@Expect(captured[0],
"SELECT \"id\", \"name\", \"age\" FROM \"users\" WHERE (age > ? AND active = ?)")
}
@TestCase
public func testOrderPaging(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
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.skip(10)
qb.take(5)
qb.toList()
@Expect(captured[0],
"SELECT \"id\", \"name\", \"age\" FROM \"users\" WHERE (\"age\" > ?) ORDER BY \"name\" ASC, \"age\" DESC LIMIT 5 OFFSET 10")
}
@TestCase
public func testCount(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let qb = QueryBuilder<User>(model, dialect,
{ sql, params => captured.add(sql); ArrayList<User>() },
{ sql, params => captured.add(sql); 42 })
qb.filter("age", ">", Int64(18))
let n = qb.count()
@Expect(n, 42)
@Expect(captured.size, 1)
@Expect(captured[0], "SELECT COUNT(*) FROM \"users\" WHERE (\"age\" > ?)")
}
@TestCase
public func testFirstAndPage(): Unit {
let model = ModelCache.get<User>()
let dialect = OpenGaussDialect()
let captured = ArrayList<String>()
let qb = QueryBuilder<User>(model, dialect,
{ sql, params => captured.add(sql); ArrayList<User>() },
{ sql, params => captured.add(sql); 42 })
// first() 空结果 → None
let first = qb.filter("age", ">", Int64(18)).first()
@Expect(first.isNone(), true)
@Expect(captured[0].contains("LIMIT 1"), true)
// page(2, 10):先 count 后 select,totalPages
let result = qb.filter("age", ">", Int64(18)).page(2, 10)
@Expect(result.total, 42)
@Expect(result.page, 2)
@Expect(result.pageSize, 10)
@Expect(result.totalPages(), 5)
@Expect(result.items.size, 0)
@Expect(captured[1].startsWith("SELECT COUNT(*)"), true)
@Expect(captured[2].contains("LIMIT 10 OFFSET 10"), true)
}
}
// ---------- 模型生成迁移(MigrationGenerator) ----------
@Table["users"]
class UserV1 {
public var id: Int64 = 0
public var name: String = ""
}
@Table["users"]
class UserV2 {
public var id: Int64 = 0
public var name: String = ""
public var age: Int32 = 0
}
@Table["items"]
class ItemV1 {
public var id: Int64 = 0
public var title: String = ""
}
@Table["items"]
class ItemV2 {
public var id: Int64 = 0
@MaxLength[100]
public var title: String = ""
}
@Table["extra"]
class ExtraTable {
public var id: Int64 = 0
}
@Table["keys"]
class KeyV1 {
public var id: Int64 = 0
}
@Table["keys"]
class KeyV2 {
@Key
public var code: String = ""
}
@Test
class MigrationGeneratorTests {
private func gen(): MigrationGenerator {
MigrationGenerator()
}
private func upSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.up(builder)
let f = DdlFactory()
let sqls = ArrayList<String>()
for (op in builder.getOperations()) {
sqls.add(f.toSql(op, dialect))
}
sqls
}
private func downSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.down(builder)
let f = DdlFactory()
let sqls = ArrayList<String>()
for (op in builder.getOperations()) {
sqls.add(f.toSql(op, dialect))
}
sqls
}
@TestCase
public func testInitialMigration(): Unit {
let models = ArrayList<EntityModel>()
models.add(ModelCache.get<User>())
models.add(ModelCache.get<Product>())
let m = gen().initial("20250701000000_InitialCreate", "初始建表", models)
let d = OpenGaussDialect()
let up = upSql(m, d)
@Expect(up.size, 2)
@Expect(up[0], "CREATE TABLE IF NOT EXISTS \"users\" (\n" +
" \"id\" BIGSERIAL NOT NULL PRIMARY KEY,\n" +
" \"name\" VARCHAR(255),\n" +
" \"age\" INTEGER\n)")
@Expect(up[1], "CREATE TABLE IF NOT EXISTS \"Product\" (\n" +
" \"pid\" BIGSERIAL NOT NULL PRIMARY KEY,\n" +
" \"title\" VARCHAR(100) NOT NULL,\n" +
" \"price\" DOUBLE PRECISION,\n" +
" \"active\" BOOLEAN\n)")
// down 为 up 逆序反转
let down = downSql(m, d)
@Expect(down.size, 2)
@Expect(down[0], "DROP TABLE IF EXISTS \"Product\"")
@Expect(down[1], "DROP TABLE IF EXISTS \"users\"")
}
@TestCase
public func testDiffAddColumnAndDropTable(): Unit {
let oldModels = ArrayList<EntityModel>()
oldModels.add(ModelCache.get<UserV1>())
oldModels.add(ModelCache.get<ExtraTable>())
let newModels = ArrayList<EntityModel>()
newModels.add(ModelCache.get<UserV2>())
let m = gen().diff("20250801000000_AddAge", "新增 age 列并删表", oldModels, newModels)
let d = OpenGaussDialect()
let up = upSql(m, d)
@Expect(up.size, 2)
// 已有表加列
@Expect(up[0], "ALTER TABLE \"users\" ADD COLUMN \"age\" INTEGER")
// 删除表
@Expect(up[1], "DROP TABLE IF EXISTS \"extra\"")
// down 逆序反转:先恢复表,再删列
let down = downSql(m, d)
@Expect(down.size, 2)
@Expect(down[0], "CREATE TABLE IF NOT EXISTS \"extra\" (\n \"id\" BIGSERIAL NOT NULL PRIMARY KEY\n)")
@Expect(down[1], "ALTER TABLE \"users\" DROP COLUMN \"age\"")
}
@TestCase
public func testDiffAlterColumn(): Unit {
let oldModels = ArrayList<EntityModel>()
oldModels.add(ModelCache.get<ItemV1>())
let newModels = ArrayList<EntityModel>()
newModels.add(ModelCache.get<ItemV2>())
let m = gen().diff("20250802000000_ChangeTitle", "调整 title 长度", oldModels, newModels)
let d = OpenGaussDialect()
let up = upSql(m, d)
@Expect(up.size, 1)
@Expect(up[0], "ALTER TABLE \"items\" ALTER COLUMN \"title\" VARCHAR(100)")
let down = downSql(m, d)
@Expect(down.size, 1)
@Expect(down[0], "ALTER TABLE \"items\" ALTER COLUMN \"title\" VARCHAR(255)")
}
@TestCase
public func testDiffKeyChangeThrows(): Unit {
let oldModels = ArrayList<EntityModel>()
oldModels.add(ModelCache.get<KeyV1>())
let newModels = ArrayList<EntityModel>()
newModels.add(ModelCache.get<KeyV2>())
let threw = try {
gen().diff("20250803000000_KeyChange", "改主键", oldModels, newModels)
false
} catch (_: Exception) {
true
}
@Expect(threw, true)
}
@TestCase
public func testColumnTypeMapping(): Unit {
@Expect(match (MigrationGenerator.columnTypeFor("String")) { case ColumnTypes.TextCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Bool")) { case ColumnTypes.BoolCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Int8")) { case ColumnTypes.TinyIntCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Int32")) { case ColumnTypes.IntCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Int64")) { case ColumnTypes.BigIntCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Float32")) { case ColumnTypes.RealCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Float64")) { case ColumnTypes.FloatCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("DateTime")) { case ColumnTypes.DateTimeCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Decimal")) { case ColumnTypes.DecimalCol => true; case _ => false }, true)
@Expect(match (MigrationGenerator.columnTypeFor("Array<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
} catch (_: Exception) {
true
}
@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)
}
@TestCase
public func testEnumColumnDdl(): Unit {
// 枚举列按构造器名存 VARCHAR;Option<枚举> 列 nullable
let models = ArrayList<EntityModel>()
models.add(ModelCache.get<EnumEntity>())
let m = gen().initial("20250911000000_EnumTable", "枚举字段建表", models)
let d = OpenGaussDialect()
let up = upSql(m, d)
@Expect(up.size, 1)
@Expect(up[0].contains("\"kind\" VARCHAR(255)"), true)
@Expect(up[0].contains("\"optKind\" VARCHAR(255)"), true)
@Expect(up[0].contains("\"optKind\" VARCHAR(255) NOT NULL"), false)
}
@TestCase
public func testEnumSnapshotRoundTrip(): Unit {
// 快照保留 isEnum 标记:diff 时枚举列能正确识别为 TextCol
let ms = ArrayList<EntityModel>()
ms.add(ModelCache.get<EnumEntity>())
let snap = ModelSnapshot.capture(ms)
let json = snap.toJson()
@Expect(json.contains("\"enum\":true"), true)
let back = ModelSnapshot.fromJson(json)
@Expect(back.models[0].properties[1].isEnum, true)
// 轻量模型 + 反射模型 diff:无变化 → 空操作
let m = gen().diff("20250912000000_NoChange", "无变化", back.toModels(), ms)
@Expect(upSql(m, OpenGaussDialect()).size, 0)
}
@TestCase
public func testStringToEnumNoAlter(): Unit {
// String 字段改为枚举:DB 层都是 VARCHAR → 无需 alter
let oldModels = ArrayList<EntityModel>()
oldModels.add(ModelCache.get<User>())
let curModels = ArrayList<EntityModel>()
curModels.add(ModelCache.get<EnumEntity>())
let m = gen().diff("20250913000000_TypeChange", "String→enum", oldModels, curModels)
// users 表被删、EnumEntity 表新增(表名不同),此用例只验证 columnDefinition 不抛异常
let d = OpenGaussDialect()
let sqls = upSql(m, d)
@Expect(sqls.size, 2)
}
}
// ---------- PostgreSQL 方言 ----------
@Test
class PostgreSqlDialectTests {
@TestCase
public func testPgInsertReturning(): Unit {
let d = PostgreSqlDialect()
let cols = ArrayList<String>()
cols.add("name")
cols.add("age")
@Expect(d.buildInsert("users", cols, "id", true),
"INSERT INTO \"users\" (\"name\", \"age\") VALUES (?, ?) RETURNING \"id\"")
@Expect(d.buildInsert("users", cols, "id", false),
"INSERT INTO \"users\" (\"name\", \"age\") VALUES (?, ?)")
}
@TestCase
public func testPgCrudSql(): Unit {
let d = PostgreSqlDialect()
let cols = ArrayList<String>()
cols.add("name")
@Expect(d.buildUpdate("users", cols, "id"),
"UPDATE \"users\" SET \"name\" = ? WHERE \"id\" = ?")
@Expect(d.buildDelete("users", "id"),
"DELETE FROM \"users\" WHERE \"id\" = ?")
@Expect(d.buildCount("users", ""),
"SELECT COUNT(*) FROM \"users\"")
@Expect(d.buildSelect("users", ArrayList<String>(), "\"age\" > ?", "\"id\"", 10, 5),
"SELECT * FROM \"users\" WHERE \"age\" > ? ORDER BY \"id\" LIMIT 5 OFFSET 10")
}
@TestCase
public func testPgDdlSql(): Unit {
let d = PostgreSqlDialect()
@Expect(d.columnTypeSql(ColumnTypes.BigIntCol, true, 0), "BIGSERIAL")
@Expect(d.columnTypeSql(ColumnTypes.IntCol, true, 0), "SERIAL")
@Expect(d.columnTypeSql(ColumnTypes.TextCol, false, 100), "VARCHAR(100)")
@Expect(d.columnTypeSql(ColumnTypes.TextCol, false, 0), "VARCHAR(255)")
@Expect(d.columnTypeSql(ColumnTypes.BinaryCol, false, 0), "BYTEA")
@Expect(d.buildAlterColumn("users", "VARCHAR(100)"),
"ALTER TABLE \"users\" ALTER COLUMN VARCHAR(100)")
@Expect(d.buildDropIndex("ix_users_name", "users"),
"DROP INDEX IF EXISTS \"ix_users_name\"")
}
@TestCase
public func testOpenGaussInheritsPg(): Unit {
let d = OpenGaussDialect()
let cols = ArrayList<String>()
cols.add("name")
@Expect(d.buildInsert("users", cols, "id", true),
"INSERT INTO \"users\" (\"name\") VALUES (?) RETURNING \"id\"")
}
@TestCase
public func testPgCreateDatabaseSql(): Unit {
let d = PostgreSqlDialect()
@Expect(d.createDatabaseSql("mydb"), "CREATE DATABASE \"mydb\"")
@Expect(d.createDatabaseSql("my_db"), "CREATE DATABASE \"my_db\"")
@Expect(d.databaseExistsSql(),
"SELECT 1 FROM pg_database WHERE datname = ?")
// openGauss 继承 PG
let og = OpenGaussDialect()
@Expect(og.createDatabaseSql("mydb"), "CREATE DATABASE \"mydb\"")
}
}
// ---------- 应用继承 DbContext ----------
class FakeDatasource <: Datasource {
public func setOption(_: String, _: String): Unit {}
public func connect(): Connection {
throw Exception("FakeDatasource 不支持真实连接")
}
public func isClosed(): Bool {
false
}
public func close(): Unit {}
public func ping(): Unit {}
}
class AppDbContext <: DbContext {
let users: DbSet<User>
let orders: DbSet<Order>
init(ds: Datasource) {
super(ds)
users = set<User>()
orders = set<Order>()
}
init(ds: Datasource, dialect: ISqlDialect) {
super(ds, dialect)
users = set<User>()
orders = set<Order>()
}
}
@Test
class DbContextInheritTests {
@TestCase
public func testInheritAndDbSets(): Unit {
let ctx = AppDbContext(FakeDatasource())
// 继承后暴露的 DbSet 属性可用
let users = ctx.users
let orders = ctx.orders
@Expect(users is DbSet<User>, true)
@Expect(orders is DbSet<Order>, true)
// DbSet 操作连通到 context 的变更队列
users.add(User())
@Expect(ctx.pendingCount(), 1)
orders.add(Order())
@Expect(ctx.pendingCount(), 2)
// 默认方言为 openGauss(继承 PG)
@Expect(ctx.getDialect().quoteName("users"), "\"users\"")
}
@TestCase
public func testCustomDialectAndMigrateApi(): Unit {
let ctx = AppDbContext(FakeDatasource(), PostgreSqlDialect())
@Expect(ctx.getDialect().quoteName("users"), "\"users\"")
@Expect(ctx.pendingCount(), 0)
// migrate 委托 Migrator;FakeDatasource 连接时会抛异常
let threw = try {
ctx.migrate(ArrayList<Migration>())
false
} catch (_: Exception) {
true
}
@Expect(threw, true)
}
}
// ---------- 模型快照 ----------
@Test
class ModelSnapshotTests {
private func gen(): MigrationGenerator {
MigrationGenerator()
}
private func models(): ArrayList<EntityModel> {
let ms = ArrayList<EntityModel>()
ms.add(ModelCache.get<User>())
ms.add(ModelCache.get<Product>())
ms
}
private func upSql(m: Migration, dialect: ISqlDialect): ArrayList<String> {
let builder = MigrationBuilder()
m.up(builder)
let f = DdlFactory()
let sqls = ArrayList<String>()
for (op in builder.getOperations()) {
sqls.add(f.toSql(op, dialect))
}
sqls
}
@TestCase
public func testCaptureToJsonRoundTrip(): Unit {
let snap = ModelSnapshot.capture(models())
let json = snap.toJson()
// 关键元数据落到 JSON
@Expect(json.contains("\"table\":\"users\""), true)
@Expect(json.contains("\"column\":\"id\""), true)
@Expect(json.contains("\"type\":\"Int64\""), true)
@Expect(json.contains("\"key\":true"), true)
@Expect(json.contains("\"maxLen\":100"), true)
@Expect(json.contains("\"required\":true"), true)
let back = ModelSnapshot.fromJson(json)
@Expect(back.models.size, 2)
@Expect(back.models[0].tableName, "users")
@Expect(back.models[1].tableName, "Product")
// 轻量模型无反射句柄,typeName 走 typeNameOverride
let lm = back.models[0]
@Expect(lm.typeInfo.isNone(), true)
@Expect(lm.keyProperty.getOrThrow().columnName, "id")
@Expect(lm.keyProperty.getOrThrow().autoIncrement, true)
// Product:类型 / 长度 / 必填 元数据保留
let pm = back.models[1]
@Expect(pm.properties[0].columnName, "pid")
@Expect(pm.properties[0].typeName(), "Int64")
@Expect(pm.properties[1].columnName, "title")
@Expect(pm.properties[1].typeName(), "String")
@Expect(pm.properties[1].maxLength, 100)
@Expect(pm.properties[1].isRequired, true)
}
@TestCase
public func testSaveLoadFile(): Unit {
let path = "snapshot_test_tmp.json"
if (exists(Path(path))) { remove(Path(path)) }
ModelSnapshot.capture(models()).save(path)
@Expect(exists(Path(path)), true)
let loaded = ModelSnapshot.load(path)
@Expect(loaded.isSome(), true)
@Expect(loaded.getOrThrow().models.size, 2)
@Expect(loaded.getOrThrow().models[1].properties[1].maxLength, 100)
// 文件不存在 → None
if (exists(Path(path))) { remove(Path(path)) }
@Expect(ModelSnapshot.load(path).isNone(), true)
}
@TestCase
public func testSnapshotModelsWorkWithDiff(): Unit {
let snap = ModelSnapshot.capture(models())
// 快照轻量模型 vs 反射模型:无变化 → 空操作
let m = gen().diff("20250901000000_NoChange", "无变化", snap.toModels(), models())
let d = OpenGaussDialect()
@Expect(upSql(m, d).size, 0)
}
@TestCase
public func testEnsureInitialThenNoChange(): Unit {
let path = "snapshot_test_tmp.json"
if (exists(Path(path))) { remove(Path(path)) }
let d = OpenGaussDialect()
// 首次:快照不存在 → initial 全量建表 + 保存快照
let m1 = gen().ensure("20250901000000_Initial", "初始建表", path, models())
let up1 = upSql(m1, d)
@Expect(up1.size, 2)
@Expect(up1[0].startsWith("CREATE TABLE IF NOT EXISTS \"users\""), true)
@Expect(exists(Path(path)), true)
@Expect(ModelSnapshot.load(path).getOrThrow().models.size, 2)
// 二次:模型未变 → diff 空操作(依赖已存快照,无需调用方手存旧模型)
let m2 = gen().ensure("20250902000000_NoChange", "无变化", path, models())
@Expect(upSql(m2, d).size, 0)
if (exists(Path(path))) { remove(Path(path)) }
}
@TestCase
public func testEnsureDiffWithNewTable(): Unit {
let path = "snapshot_test_tmp.json"
if (exists(Path(path))) { remove(Path(path)) }
let d = OpenGaussDialect()
gen().ensure("20250901000000_Initial", "初始建表", path, models())
// 模型新增 items 表后二次 ensure → 自动 diff 出 CreateTable
let ms2 = models()
ms2.add(ModelCache.get<ItemV2>())
let m = gen().ensure("20250903000000_AddItems", "新增 items", path, ms2)
let up = upSql(m, d)
@Expect(up.size, 1)
@Expect(up[0].startsWith("CREATE TABLE IF NOT EXISTS \"items\""), true)
// 快照已覆盖为 3 个模型
@Expect(ModelSnapshot.load(path).getOrThrow().models.size, 3)
if (exists(Path(path))) { remove(Path(path)) }
}
}