SqlSharp
这是什么
不用 ORM 的时候,手写 SQL 是绕不开的重复劳动——每个查询都要写一遍 SELECT 列名和 WHERE 参数绑定,每个 CRUD 都要拼 INSERT / UPDATE / DELETE,字段一变就要全局搜索修改。
更关键的是变更跟踪:更新一条记录时,你得自己知道哪些字段被改了,然后只更新那些字段。不写变更跟踪就直接 UPDATE SET 全部列,写了就一堆 if (changed) { sql += ... }。
SqlSharp 帮你做两件事:
- 把对象映射为 SQL——
context.products.toList()自动生成SELECT,context.add(entity)+context.saveChanges()自动生成INSERT,只改了一个字段就只更新一列 - 用实体跟踪变化——属性写入时自动检测原值变化,
saveChanges()时只生成真正需要更新的列
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 步搞定:
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 | 核心:提供 DbContext、DbSet<T>、ChangeTracker、Queryable<T> 等 |
sqlsharp.macros | 宏:提供 @Entity、@Model,同时重导出 @Serialization |
sqlsharp.annotations | 注解:提供 @Table、@Column、@Key、@ConcurrencyCheck 等 |
sqlsharp.serialization | 序列化:重导出 ISerialization<T>、JsonSerializer、DataModel 等 |
sqlsharp.converters | 类型映射:提供 TypeMapper、ValueConverter |
soulsoft_extensions_sqlsharp | DI 集成:提供 addDbContext<T>() |
mariadb.cdbc(等) | 数据库驱动:提供底层连接能力 |
sqlsharp + sqlsharp.macros + sqlsharp.annotations 是基本组合。需要序列化能力(@Serialization、JsonSerializer)引入 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 - 宏自动生成去掉下划线的公开属性:
_createdAt→createdAt - 数据库列名默认 = 属性名,可通过
@Column或 FluentAPI 覆盖
@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 带原始值条件 | 字段 |
注解示例
@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 注解 | 任意字段标记 @Key | Never(INSERT 包含此列) |
@Key + _id + 整数 | @Key 标记在满足约定规则的字段上 | OnAdd(约定优先) |
| FluentAPI | hasKey([...]) | Never |
String 类型 id | _id: String | Never(非整数不自增) |
复合主键用 FluentAPI:
mb.entity<OrderItem>().toTable("order_item").hasKey(["orderId", "lineNo"])支持的字段类型
| 仓颉类型 | 推荐数据库列类型 | 可空 |
|---|---|---|
Bool | BOOLEAN | ?Bool |
Int8 ~ Int64 | TINYINT ~ BIGINT | ?Int8 ~ ?Int64 |
UInt8 ~ UInt64 | TINYINT UNSIGNED ~ BIGINT UNSIGNED | ?UInt8 ~ ?UInt64 |
Float32 / Float64 | FLOAT / DOUBLE | ?Float32 / ?Float64 |
Decimal | DECIMAL(p,s) | ?Decimal |
String | VARCHAR(n) / TEXT | ?String |
DateTime | DATETIME(6) | ?DateTime |
JsonValue | JSON | ?JsonValue |
DbContext 配置
DbContext 通过两种方式发现实体:DbSet<T> 属性(最常用)和 onModelCreating 中的 entity<T>()(FluentAPI 配置时)。没有被这两种方式引用到的实体,即使加了 @Entity 也不会被 SqlSharp 识别——不会生成 SQL,也不会参与变更跟踪。
onConfiguring
配置数据库连接、日志、类型映射:
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 优先级高于注解:
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 | 未被跟踪 | 无操作 |
Added | add() 后 | 生成 INSERT |
Unchanged | 已持久化,无变化 | 无操作 |
Modified | 属性被修改 | 生成 UPDATE(只含变化的列) |
Deleted | remove() 后 | 生成 DELETE |
基本操作
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 但没查询到实体时,直接构造对象更新所有非主键列:
let detached = Product(id: knownId, name: "new", price: 999)
ctx.update(detached)
ctx.saveChanges() // UPDATE products SET name='new', price=999 WHERE id = knownIdasNoTracking
查询结果不进入 ChangeTracker,性能更优:
let list = ctx.products.asNoTracking().whereIf("price > @min").params(p).toList()并发更新
@ConcurrencyCheck 标记的字段在 UPDATE 和 DELETE 时会带上原始值作为 WHERE 条件。影响行数为 0 时抛 DbUpdateConcurrencyException——防止后提交者覆盖先提交者的数据。
@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
}let p = ctx.products.find([id]).getOrThrow() // 读到 version = 1
sleep(3000) // 期间另一个请求修改了同一行,version 变成 2
p.name = "新名称"
p.version = 2
ctx.saveChanges() // WHERE version = 1 → 影响 0 行
// → DbUpdateConcurrencyExceptionsaveChanges() 生成的 SQL 会把并发列的原始值作为条件:
UPDATE products SET name='新名称', version=2 WHERE id=1 AND version=1DELETE 同理。多个 @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 | 最小值 |
常用查询示例
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 加上带字段校验的智能排序和分页:
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)
}
}使用时就一行:
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
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 到实体列表
let list = ctx.query<Product>("SELECT id, name, price FROM products WHERE price > @min", p)executeScalar<T> —— 标量值
let cnt = ctx.executeScalar<Int64>("SELECT COUNT(*) FROM products")属性匹配规则
手写 SQL 时,数据库返回的列名可能与实体属性名不一致——比如数据库是 user_name,属性是 userName。SqlSharp 按以下优先级自动匹配:
| 优先级 | 规则 | 示例 |
|---|---|---|
| 1 | 列名 不区分大小写 精确匹配属性名 | id / ID / Id → 属性 id |
| 2 | 列名 去掉下划线后 不区分大小写匹配 | user_name → 去 _ → username → 属性 userName |
| - | 都不匹配 | 跳过该列,不抛异常 |
常用对照:
| 数据库列名 | 匹配属性 | 走的规则 |
|---|---|---|
id | id | ① 精确匹配 |
user_name | userName | ② 去下划线 |
created_at | createdAt | ② 去下划线 |
category_id | categoryId | ② 去下划线 |
unit_price | unitPrice | ② 去下划线 |
IN 查询
集合参数自动展开:
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:
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——正常退出自动提交,抛异常自动回滚:
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 容器:
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 把业务类型映射到数据库存储类型:
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) 以避免参数个数膨胀。