Skip to content

SqlSharp

这是什么

不用 ORM 的时候,手写 SQL 是绕不开的重复劳动——每个查询都要写一遍 SELECT 列名和 WHERE 参数绑定,每个 CRUD 都要拼 INSERT / UPDATE / DELETE,字段一变就要全局搜索修改。

更关键的是变更跟踪:更新一条记录时,你得自己知道哪些字段被改了,然后只更新那些字段。不写变更跟踪就直接 UPDATE SET 全部列,写了就一堆 if (changed) { sql += ... }

SqlSharp 帮你做两件事:

  1. 把对象映射为 SQL——context.products.toList() 自动生成 SELECTcontext.add(entity) + context.saveChanges() 自动生成 INSERT,只改了一个字段就只更新一列
  2. 用实体跟踪变化——属性写入时自动检测原值变化,saveChanges() 时只生成真正需要更新的列
cangjie
import sqlsharp.*
import sqlsharp.macros.*
import sqlsharp.annotations.*
import sqlsharp.serialization.*

// 定义实体
@Entity
@Serialization
public class Product {
    @Key
    private var _id: Int64 = 0
    private var _name: String = ""
    private var _price: Int64 = 0
}

// 查询:一行代码
let list = context.products.whereIf("price > @min").params(p).toList()

// 更新:改了什么就更新什么
let found = context.products.find([id]).getOrThrow()
found.price = 1099       // 只改 price
context.saveChanges()     // UPDATE products SET price = 1099 WHERE id = 1

快速开始

引入 sqlsharp 和数据库驱动后,3 步搞定:

cangjie
import sqlsharp.*
import sqlsharp.macros.*
import sqlsharp.annotations.*
import sqlsharp.serialization.*
import mariadb.cdbc.*

// 1. 定义实体
@Entity
@Serialization
public class Product {
    private var _id: Int64 = 0
    private var _name: String = ""
    private var _price: Int64 = 0
}

// 2. 定义 DbContext
public class AppDbContext <: DbContext {
    public prop products: DbSet<Product> {
        get() { set<Product>() }
    }

    public override func onConfiguring(b: DbContextOptionsBuilder): Unit {
        b.useMysql("mariadb://localhost:3306?username=root&password=1024&database=mydb", "mariadb")
    }
}

main(): Int64 {
    // 3. CRUD
    try (ctx = AppDbContext()) {
        // INSERT
        let p = Product(name: "Widget", price: 999)
        ctx.add(p)
        ctx.saveChanges()          // p.id 被写入数据库自增值

        // SELECT
        let found = ctx.products.find([p.id]).getOrThrow()

        // UPDATE(只更新修改过的 price 列)
        found.price = 1099
        ctx.saveChanges()

        // DELETE
        ctx.remove(found)
        ctx.saveChanges()
    }
    return 0
}

整个流程只有三个角色:

步骤做什么用的类型
定义实体标记类,声明字段@Entity + @Serialization
定义上下文暴露 DbSet<T>,配置数据库连接DbContext
操作数据查询 / 新增 / 修改 / 删除DbSet<T> + saveChanges()

下面逐一展开。


相关依赖

包名作用
sqlsharp核心:提供 DbContextDbSet<T>ChangeTrackerQueryable<T>
sqlsharp.macros宏:提供 @Entity@Model,同时重导出 @Serialization
sqlsharp.annotations注解:提供 @Table@Column@Key@ConcurrencyCheck
sqlsharp.serialization序列化:重导出 ISerialization<T>JsonSerializerDataModel
sqlsharp.converters类型映射:提供 TypeMapperValueConverter
soulsoft_extensions_sqlsharpDI 集成:提供 addDbContext<T>()
mariadb.cdbc(等)数据库驱动:提供底层连接能力

sqlsharp + sqlsharp.macros + sqlsharp.annotations 是基本组合。需要序列化能力(@SerializationJsonSerializer)引入 sqlsharp.serialization;需要自定义类型映射引入 sqlsharp.converters;需要 DI 管理生命周期引入 soulsoft_extensions_sqlsharp


核心设计

SqlSharp 围绕 ChangeTracker 工作——所有实体变更集中在一个内存缓冲区里,saveChanges() 时一次性生成 SQL。

  add(entity) / find(entity)  /  entity.price = 200
         │                           │
         ▼                           ▼
  ChangeTracker                  detectChanges()
  │                             对比 originalValue 和 currentValue
  │  entries:                   有变化 → Modified
  │    IEntity → EntityEntry    全相同 → Unchanged
  │         │                   改回原值 → 自动回到 Unchanged
  │         │
  │         ▼
  │  EntityEntry ◀── 内存缓冲,add/find 时拍快照
  │  ├─ entity: IEntity
  │  ├─ state: Detached → Added → Unchanged → Modified → Deleted
  │  ├─ originalValues  ← 快照 {"name": "old", "price": 100, "version": 1}
  │  └─ PropertyEntry[]
  │       ├─ originalValue ← 来自快照
  │       ├─ currentValue  ← entity.getPropertyValue() 实时读
  │       └─ isModified → original != current

  │  saveChanges()

  遍历 entries,按 state 生成 SQL
  ├─ Added    → INSERT INTO ...
  ├─ Modified → UPDATE SET price=200 WHERE id=1 AND version=1
  │             只生成变化的列,并发标记列带原始值条件
  ├─ Deleted  → DELETE FROM ... WHERE id=1 AND version=1
  │             影响行数 0 → DbUpdateConcurrencyException
  └─ Unchanged → 跳过

三句话总结:EntityEntry 是内存缓冲(add/find 时拍下所有字段快照),detectChanges 自动判断(对比快照和当前值,改回原值就等于没改),saveChanges 按状态行事(只生成变化的列,并发列带原始值条件)。


三个核心宏

定义一个类需要加哪些宏,取决于这个类用来干什么

场景宏组合为什么
数据库表实体,需要增删改查@Entity + @Serialization@Entity 让 ChangeTracker 能跟踪变化;@Serialization 负责结果映射
只查不写(视图、报表、投影 DTO)@Serialization不需要变更跟踪,只要能映射查询结果就行
查询参数对象,传给 params()@Model + @Serialization@Model 让对象能被读字段值;@Serialization 加序列化能力
手动 DbParameters.add() 传参都不需要直接 p.add("key", value) 就行

三种宏各解决一个问题:

  • @Entity——把类变成一个可被跟踪的实体。加了之后,add() 会拍原始值快照,改属性时 ChangeTracker 自动发现变化,saveChanges() 只更新改过的列。不关心内部机制,记住一句话:需要变更跟踪就加 @Entity

    cangjie
    @Entity
    @Serialization
    public class Product {
        private var _id: Int64 = 0
        private var _name: String = ""
        private var _price: Int64 = 0
    }
  • @Model——把类变成一个可读取字段值的参数对象。比 @Entity 更轻,不生成变更跟踪相关代码。唯一的作用是让对象能被 params(model)DbParameters.from(model) 自动提取字段值。

    cangjie
    @Model
    @Serialization
    public class ProductFilter {
        private var _minPrice: Int64 = 0
        private var _keyword: ?String = None
    }
    
    let filter = ProductFilter(minPrice: 100, keyword: Some("Widget"))
    let list = ctx.products
        .whereIf("price >= @minPrice")
        .whereIf("name LIKE @keyword", filter.keyword.isSome())
        .params(filter)           // @Model 使 filter 可以直接传入 params()
        .toList()
  • @Serialization——把类变成一个可序列化的类型。SqlSharp 用它把数据库查询结果的列值映射到对象字段。无论用 @Entity 还是 @Model,都必须同时加 @Serialization,否则 toList() / query<T>() 等方法无法工作。来自 soulsoft_serialization,详见序列化文档


定义实体

字段约定

  • 字段名以下划线开头:_fieldName
  • 必须是 var,不能是 let
  • 宏自动生成去掉下划线的公开属性:_createdAtcreatedAt
  • 数据库列名默认 = 属性名,可通过 @Column 或 FluentAPI 覆盖
cangjie
@Entity
@Serialization
public class User {
    private var _id: Int64 = 0                         // 属性 id,列 id(自增主键)
    @Column["user_name"]
    private var _userName: String = ""                 // 属性 userName,列 user_name
    @Column["created_at"]
    private var _createdAt: DateTime = DateTime.now()  // 属性 createdAt,列 created_at
}

注解速查

注解作用位置
@Table["name"]指定表名,可选 scheme 参数
@Column["col_name"]指定列名字段
@Key标记为主键(非 id 命名时使用)字段
@NotMapped排除字段,不参与数据库映射字段
@DatabaseGenerated[option]覆盖值生成策略(Identity / Never字段
@ConcurrencyCheck标记为并发检查列,UPDATE/DELETE 带原始值条件字段

注解示例

cangjie
@Entity
@Serialization
@Table["t_order"]
public class Order {
    @Key
    @Column["order_no"]
    private var _orderNo: String = ""       // 非 id 命名主键

    @Column["total_amount"]
    private var _totalAmount: Int64 = 0     // 列名 ≠ 属性名

    @NotMapped
    private var _tempCache: ?String = None   // 不持久化

    @ConcurrencyCheck
    private var _version: Int64 = 0          // 并发检查

    @DatabaseGenerated[DatabaseGeneratedOption.Never]
    private var _manualId: Int64 = 0         // 取消自增,INSERT 包含此列

    private var _remark: String = ""         // 默认列名 = remark
}

主键策略

SqlSharp 按以下规则识别主键:

规则识别条件ValueGenerated
默认约定_id 字段 + 整数类型OnAdd(INSERT 不含此列,数据库自增)
@Key 注解任意字段标记 @KeyNever(INSERT 包含此列)
@Key + _id + 整数@Key 标记在满足约定规则的字段上OnAdd(约定优先)
FluentAPIhasKey([...])Never
String 类型 id_id: StringNever(非整数不自增)

复合主键用 FluentAPI:

cangjie
mb.entity<OrderItem>().toTable("order_item").hasKey(["orderId", "lineNo"])

支持的字段类型

仓颉类型推荐数据库列类型可空
BoolBOOLEAN?Bool
Int8 ~ Int64TINYINT ~ BIGINT?Int8 ~ ?Int64
UInt8 ~ UInt64TINYINT UNSIGNED ~ BIGINT UNSIGNED?UInt8 ~ ?UInt64
Float32 / Float64FLOAT / DOUBLE?Float32 / ?Float64
DecimalDECIMAL(p,s)?Decimal
StringVARCHAR(n) / TEXT?String
DateTimeDATETIME(6)?DateTime
JsonValueJSON?JsonValue

DbContext 配置

DbContext 通过两种方式发现实体:DbSet<T> 属性(最常用)和 onModelCreating 中的 entity<T>()(FluentAPI 配置时)。没有被这两种方式引用到的实体,即使加了 @Entity 也不会被 SqlSharp 识别——不会生成 SQL,也不会参与变更跟踪。

onConfiguring

配置数据库连接、日志、类型映射:

cangjie
public class AppDbContext <: DbContext {
    public override func onConfiguring(b: DbContextOptionsBuilder): Unit {
        b.useMysql("mariadb://localhost:3306?username=root&password=1024&database=mydb", "mariadb")
        b.logTo { sql, params => println("[SQL] ${sql}") }

        let mapper = TypeMapper()
        mapper.register<Address, String>(AddressJsonConverter())
        b.useTypeMapper(mapper)

        b.useQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
    }
}

可用配置项

方法说明
.useMysql(connectionString, driverName?)MySQL / MariaDB,driverName 默认 "mysql"
.usePostgres(connectionString)PostgreSQL,自动设置 returningInsert = true
.useOpenGauss(connectionString)OpenGauss,自动设置 returningInsert = true
.useDatasource(connectionString, driverName)任意已注册的数据库驱动
.logTo { sql, params => ... }SQL 日志回调,可输出 SQL 和参数
.useTypeMapper(mapper)注册自定义 TypeMapper(含 ValueConverter
.usePooling(options?)连接池配置(超时、最大连接数、最大空闲数)
.useQueryTrackingBehavior(behavior)全局跟踪策略:TrackAll(默认)/ NoTracking

onModelCreating (FluentAPI)

注解之外的补充配置,FluentAPI 优先级高于注解:

cangjie
protected override func onModelCreating(mb: ModelBuilder): Unit {
    let entity = mb.entity<Product>()
    entity.toTable("t_product")                       // 表名
    entity.hasKey(["sku"])                             // 主键
    entity.property("name").hasColumnName("p_name")   // 列名
    entity.property("version").isConcurrencyToken()   // 并发列
    entity.ignore("tempField")                        // 排除字段
}
配置项低 → 高优先级
表名属性名 < @Table < FluentAPI toTable()
列名属性名 < @Column < FluentAPI hasColumnName()
主键id 自动识别 / @Key < FluentAPI hasKey()

变更跟踪

实体状态

状态含义saveChanges() 行为
Detached未被跟踪无操作
Addedadd()生成 INSERT
Unchanged已持久化,无变化无操作
Modified属性被修改生成 UPDATE(只含变化的列)
Deletedremove()生成 DELETE

基本操作

cangjie
try (ctx = AppDbContext()) {
    // INSERT
    let p = Product(name: "A", price: 100)
    ctx.add(p)
    ctx.saveChanges()           // INSERT INTO ... ; p.id = 自增值

    // UPDATE(自动检测变化)
    p.price = 200
    ctx.saveChanges()           // UPDATE ... SET price = 200 WHERE id = 1

    // 恢复原值 → 状态回到 Unchanged
    p.price = 100
    // ctx.saveChanges() 不会生成 SQL

    // DELETE
    ctx.remove(p)
    ctx.saveChanges()           // DELETE FROM ... WHERE id = 1
}

显式 update(跨 Context 场景)

已知 id 但没查询到实体时,直接构造对象更新所有非主键列:

cangjie
let detached = Product(id: knownId, name: "new", price: 999)
ctx.update(detached)
ctx.saveChanges()              // UPDATE products SET name='new', price=999 WHERE id = knownId

asNoTracking

查询结果不进入 ChangeTracker,性能更优:

cangjie
let list = ctx.products.asNoTracking().whereIf("price > @min").params(p).toList()

并发更新

@ConcurrencyCheck 标记的字段在 UPDATE 和 DELETE 时会带上原始值作为 WHERE 条件。影响行数为 0 时抛 DbUpdateConcurrencyException——防止后提交者覆盖先提交者的数据。

cangjie
@Entity
@Serialization
public class Product {
    private var _id: Int64 = 0
    private var _name: String = ""
    private var _price: Int64 = 0
    @ConcurrencyCheck
    private var _version: Int64 = 0        // 并发检查列,每次更新 +1
}
cangjie
let p = ctx.products.find([id]).getOrThrow()   // 读到 version = 1

sleep(3000)                                     // 期间另一个请求修改了同一行,version 变成 2

p.name = "新名称"
p.version = 2
ctx.saveChanges()                               // WHERE version = 1 → 影响 0 行
                                                // → DbUpdateConcurrencyException

saveChanges() 生成的 SQL 会把并发列的原始值作为条件:

sql
UPDATE products SET name='新名称', version=2 WHERE id=1 AND version=1

DELETE 同理。多个 @ConcurrencyCheck 字段都生效,全部加入 WHERE 条件。


Queryable 查询

DbSet<T> 继承 Queryable<T>,提供链式查询。以下方法均返回 Queryable<T> 支持链式,末尾方法返回最终结果。

链式构建

方法说明
.whereIf("sql", flag?)条件过滤,flag 默认 true,多个自动 AND
.orderBy("col") / orderBy("col DESC")排序
.groupBy("col") + .having("cond")分组 + 条件
.skip(n) + .take(m)分页偏移
.asNoTracking()查询结果不跟踪
.params(DbParameters)绑定参数
.params(model)绑定参数(model 需加 @Model@Entity
.select<TResult>(["col1", "col2"])投影到 DTO
.alias("name")表别名

末尾方法

方法返回说明
.toList()ArrayList<T>全部结果
.toPageResult(index, size)(ArrayList<T>, Int64)分页 + 总数
.find([keyValues])?T按主键查找
.first()T第一行,无结果抛异常
.firstOrDefault()?T第一行,无结果返回 None
.any()Bool存在性判定
.count(["*"])Int64行数
.sum<T>(["col"])T求和
.avg<T>(["col"])T平均值
.max<T>(["col"])T最大值
.min<T>(["col"])T最小值

常用查询示例

cangjie
let p = DbParameters()
p.add("minPrice", Int64(100))

// 条件过滤
ctx.products.whereIf("price >= @minPrice").params(p).toList()

// 多条件(自动 AND)
ctx.products
    .whereIf("price >= @minPrice")
    .whereIf("name IS NOT NULL")
    .params(p)
    .toList()

// 条件开关 — flag=false 时自动跳过
ctx.products.whereIf("name LIKE @kw", keyword.isSome()).params(p).toList()

// 分页
let (list, total) = ctx.products
    .whereIf("price > @minPrice").params(p)
    .orderBy("price DESC")
    .toPageResult(1, 20)

// 投影到 DTO
@Serialization
public class ProductSummary {
    private var _id: Int64 = 0
    private var _price: Int64 = 0
}
let dtos = ctx.products.select<ProductSummary>(["id", "price"]).toList()

扩展 Queryable

Queryable<T> 支持用 extend 添加自定义方法,把通用查询逻辑收拢到一处。下面是一个实际案例——给所有 DbSet 加上带字段校验的智能排序和分页:

cangjie
import sqlsharp.*
import sqlsharp.macros.*
import sqlsharp.serialization.*
import std.reflect.*

// 查询参数(@Model 使其可直接传入 params())
@Model
@Serialization
public class QueryModel {
    private var _pageIndex: Int64 = 1
    private var _pageSize: Int64 = 10
    private var _search: ?String = None
    private var _orderBy: ?String = None       // "price" / "created_at DESC"
}

// 扩展所有 Queryable<T>
extend<T> Queryable<T> where T <: ISerialization<T> {

    public func smartOrderBy(model: QueryModel): Queryable<T> {
        if (let Some(orderBy) <- model.orderBy) {
            // 用实体元数据做白名单校验,只允许合法字段名作为排序条件
            let entityType = this.context.model.getEntityType(TypeInfo.of<T>())
            if (entityType.findProperty(orderBy.split(" ")[0]).isSome()) {
                this.orderBy(orderBy)
            }
        }
        return this   // 返回 this 继续链式调用
    }

    public func toPageResult(model: QueryModel): (ArrayList<T>, Int64) {
        return this.smartOrderBy(model).toPageResult(model.pageIndex, model.pageSize)
    }
}

使用时就一行:

cangjie
let model = QueryModel(orderBy: Some("created_at DESC"), search: Some("关键词"))
let (list, total) = ctx.blogPosts
    .params(model)
    .whereIf("POSITION(@search IN title) > 0", model.search.isSome())
    .toPageResult(model)   // 自动排序 + 分页

关键点:this.context.model 可以拿到实体元数据,遍历字段、校验合法性、做白名单过滤——避免用户传入的排序字段直接拼进 SQL。


手写 SQL

DbContext 提供了三个入口直接执行 SQL:execute() 用于增删改,query<T>() 查询列表,executeScalar<T>() 返回标量。

execute —— UPDATE / DELETE / DDL

cangjie
let p = DbParameters()
p.add("id", Int64(1))
p.add("price", Int64(999))
ctx.execute("UPDATE products SET price = @price WHERE id = @id", p)

query<T> —— SELECT 到实体列表

cangjie
let list = ctx.query<Product>("SELECT id, name, price FROM products WHERE price > @min", p)

executeScalar<T> —— 标量值

cangjie
let cnt = ctx.executeScalar<Int64>("SELECT COUNT(*) FROM products")

属性匹配规则

手写 SQL 时,数据库返回的列名可能与实体属性名不一致——比如数据库是 user_name,属性是 userName。SqlSharp 按以下优先级自动匹配:

优先级规则示例
1列名 不区分大小写 精确匹配属性名id / ID / Id → 属性 id
2列名 去掉下划线后 不区分大小写匹配user_name → 去 _username → 属性 userName
-都不匹配跳过该列,不抛异常

常用对照:

数据库列名匹配属性走的规则
idid① 精确匹配
user_nameuserName② 去下划线
created_atcreatedAt② 去下划线
category_idcategoryId② 去下划线
unit_priceunitPrice② 去下划线

IN 查询

集合参数自动展开:

cangjie
let p = DbParameters()
p.add("ids", [1, 2, 3])
let list = ctx.query<Product>("SELECT * FROM products WHERE id IN @ids", p)
// 整型数组直接内联为 SQL:WHERE id IN (1, 2, 3)
// 非整型展开为多个参数:WHERE name IN (@ids_0, @ids_1, @ids_2)

SqlBuilder

动态拼 SQL:

cangjie
let sb = SqlBuilder()
sb.whereIf("price > @minPrice", minPrice > 0)
sb.whereIf("name IS NOT NULL", needName)
sb.orderBy("price DESC")

let sql = "SELECT * FROM products${sb.whereSql}${sb.orderSql}"
ctx.query<Product>(sql, params)

事务

beginTransaction() 返回 Resource,支持 try-with-resource——正常退出自动提交,抛异常自动回滚:

cangjie
try (tx = ctx.beginTransaction()) {
    ctx.add(Product(name: "A", price: 100))
    ctx.add(Product(name: "B", price: 200))
    ctx.saveChanges()
    // 正常退出 → 自动提交
}
// 抛出异常 → 自动回滚

与依赖注入集成

引入 soulsoft_extensions_sqlsharp 后,DbContext 可以注册到 DI 容器:

cangjie
import soulsoft_extensions_sqlsharp.*
import soulsoft_extensions_injection.*

public class WebDbContext <: DbContext & IServiceFactory<WebDbContext> {
    public init(options: DbContextOptionsOf<WebDbContext>) { super(options) }

    public static func createInstance(sp: IServiceProvider): WebDbContext {
        let options = sp.getOrThrow<DbContextOptionsOf<WebDbContext>>()
        return WebDbContext(options)
    }

    public prop users: DbSet<User> {
        get() { set<User>() }
    }
}

// 注册
let services = ServiceCollection()
services.addDbContext<WebDbContext> { options =>
    options.useMysql("mariadb://localhost:3306?username=root&password=1024&database=mydb", "mariadb")
}

// 使用(DbContext 生命周期为 Scoped)
let provider = services.build()
try (scope = provider.createScope()) {
    let ctx = scope.services.getOrThrow<WebDbContext>()
    let list = ctx.users.toList()
}

DbContext 在 DI 容器中注册为 Scoped 生命周期——每个请求/作用域一个实例,符合 Web 开发惯例。


类型映射

通过 TypeMapper + ValueConverter 把业务类型映射到数据库存储类型:

cangjie
import sqlsharp.converters.*

// 自定义类型
@Serialization
public class Address {
    private var _city: String = ""
    private var _street: String = ""
}

// 转换器:Address ↔ JSON 字符串
public class AddressJsonConverter <: ValueConverter<Address, String> {
    public override func convertToProvider(value: Address): String {
        JsonSerializer.serializeObject(value)
    }

    public override func convertFromProvider(value: String): Address {
        JsonSerializer.deserializeObject<Address>(value)
    }
}

// 在 DbContext.onConfiguring 内注册
public override func onConfiguring(b: DbContextOptionsBuilder): Unit {
    // ... 数据库连接等配置

    let mapper = TypeMapper()
    mapper.register<Address, String>(AddressJsonConverter())
    b.useTypeMapper(mapper)
}

注册后,这个映射同时生效于:实体查询、DTO 查询、executeScalar<T>()、SQL 参数绑定、更改跟踪。


常见问题

改了字段值,saveChanges() 没生成 UPDATE

检查 ChangeTracker 是否认出了变化:先 find() 拿到被跟踪的实体再改字段;new 出来的实体没被跟踪,改字段不会触发变更检测。

想取消 id 字段的自增

@DatabaseGenerated[DatabaseGeneratedOption.Never] 或 FluentAPI 调 .valueGeneratedNever()

@Entity 和 @Model 怎么选

需要 add() / update() / remove()@Entity。只是查询参数绑定 → @Model。两个都需要 @Serialization

投影时 DTO 怎么写

DTO 只需要 @Serialization,不需要 @Entity。字段名必须和 select() 里的列名对应(支持下划线转驼峰)。

IN 查询参数怎么传

DbParameters.add("key", array) 传入数组,SqlSharp 自动展开。整型数组内联为 IN (1, 2, 3) 以避免参数个数膨胀。