diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a2172f0c5..7fee3eb45 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,6 +31,7 @@ jobs: dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out XCode.SqlServer/XCode.SqlServer.csproj dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out XCode.Oracle/XCode.Oracle.csproj dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out XCode.PostgreSQL/XCode.PostgreSQL.csproj + dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out XCode.SonnetDB/XCode.SonnetDB.csproj dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out XCode.DaMeng/XCode.DaMeng.csproj dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out XCode.DB2/XCode.DB2.csproj dotnet pack --version-suffix ${{ env.VERSION }} -c Release -o out XCode.KingBase/XCode.KingBase.csproj diff --git a/ChangeLog.md b/ChangeLog.md index 9b2fd05e4..26ee5c2b9 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,11 @@ # NewLife.XCode 更新日志 +## [Unreleased] + +- 新增 SonnetDB 官方 ADO.NET 驱动适配和 net10.0 的 XCode.SonnetDB 依赖包,支持关系表元数据、常用 CRUD、自增、分页、批量 Insert/InsertIgnore/Upsert、单主键有界删除及基本迁移。 +- 对未完成的 Decimal/时间精度、长度、注释、Replace、复合主键删除和隔离等级明确保护或记录;能力与 SonnetDB issue/发布依赖见 `Doc/SonnetDB.md`,不宣称完整等价支持。 +- 修复实体表达式批量删除生成参数后未传给执行接口的问题,使用数据库方言生成可执行条件。 + ## v12.2.2026.0901 (2026-09-01) ### 数据权限(DataScope)增强 diff --git a/Doc/SonnetDB.md b/Doc/SonnetDB.md new file mode 100644 index 000000000..5313cb642 --- /dev/null +++ b/Doc/SonnetDB.md @@ -0,0 +1,95 @@ +# SonnetDB 接入与能力验收 + +本适配使用官方 `SonnetDB` NuGet 包中的 `SonnetDB.Data.SndbProviderFactory`,支持嵌入式数据库目录和远程 Server。数据库基线为已经发布的 **4.0.0**,运行时需要 **.NET 10**。XCode 主包仍保留原有目标框架;驱动依赖包装项目 `XCode.SonnetDB` 使用 net10.0。 + +跟踪 issue:[NewLife.XCode #100](https://github.com/NewLifeX/NewLife.XCode/issues/100)。适配仍在验收中,数据库缺口解决并发布、XCode 集成验证完成前,不宣称与 PostgreSQL / SQLite 的全部 ORM 能力等价。 + +## 使用 + +本 PR 的代码可通过项目引用构建。`XCode.SonnetDB` 正式发布后可安装此适配包;它依赖 XCode 主包和 SonnetDB 4.0.0。 + +```csharp +using XCode.DataAccessLayer; + +// Data Source 指向数据库目录。 +DAL.AddConnStr("app", "Data Source=./data/app", null, "SonnetDB"); +var dal = DAL.Create("app"); +var dbType = dal.DbType; // DatabaseType.SonnetDB + +// 远程连接:使用部署提供的 Token,不将真实凭据提交到源代码。 +DAL.AddConnStr("remote", + "Data Source=sonnetdb+http://localhost:5000/app;Token=;Protocol=rest", + null, "SonnetDB"); +``` + +提供者名称可用 `SonnetDB` / `SonnetDB.Data`。远程连接的 `rest`、`auto`、`frame-http2` 遵循官方驱动合同;强制 HTTP/2 必须连接配置了 HTTP/2 的端点。DML 写入使用官方 REST SQL 通道,不把只读 Frame 查询端点当作写入协议。 + +## 已验证的适配范围 + +✅ 表示指定场景通过真实正式包或 Server 验证;🧪 表示仍需验证;🛠️ 表示数据库尚需实现或修复;📦 表示仍需发布;⛔ 表示当前版本不能完整提供该能力。实现、验证、正式发布和 ORM 集成分别确认,跳过测试不计为通过。 + +| 能力 | 当前适配与验证范围 | +| --- | --- | +| 驱动接入 | ✅ DatabaseType / DbFactory、提供者识别、官方工厂、嵌入式与远程连接 | +| 自动建表与反向工程 | ✅ 关系表、标量列、主键、自增、默认值、普通及复合唯一索引;原生 GetSchema,不依赖 pg_catalog | +| 视图目录 | ✅ 视图名称和定义已可读取;⛔ 声明列类型不完整,反向工程明确拒绝,见 #261 | +| 实体 CRUD | ✅ 同步/异步插入、更新、删除,identity 非首列正确回填,Unicode/引号/NULL/BLOB 往返 | +| 标量值 | ✅ 有符号整数、布尔、常用 Decimal 值和毫秒 DateTime;高精度边界见下表,不能由这项推断全部 Decimal / DateTime 已支持 | +| 查询与分页 | ✅ 参数、COUNT/SUM、LIMIT/OFFSET、仅 OFFSET、基础 LINQ、JOIN、ROW_NUMBER 和表前缀 | +| DataSet 查询 | ✅ 会话用 DbDataReader / DataTable.Load 填充单结果集,补齐官方工厂没有 DataAdapter 的接入差异 | +| 批量写入 | ✅ Insert、InsertIgnore、Upsert;支持冲突更新和累加字段,不将 Upsert 冒称 Replace | +| 有界删除 | ✅ 单主键子查询确保 maximumRows 生效;表达式删除同时修复未传递参数的问题 | +| 迁移与清空 | ✅ 添加/删除普通列、修改可空性/默认值、创建/删除索引、保留已有数据、TRUNCATE 重置自增 | +| 事务 | ✅ ReadCommitted 提交、回滚和读取自身写入;远程使用真实 Server 会话 | +| 模型与代码生成 | ✅ DatabaseType 可序列化为 SonnetDB,继续使用现有通用模型/代码生成链路;🧪 大规模跨库迁移和所有业务实体组合未逐项验证 | +| 缓存、分表、权限与租户 | 使用已有 XCode 运行时;✅ 独立实体 split/表前缀路径,🧪 各业务模块与 SonnetDB 的完整组合仍需验收 | + +## 数据库缺口和后续发布 + +以下 issue 都是明确的能力缺口,不能因本适配成功构建而标为完成。适配中的保护异常表示能力尚未开放。 + +| 能力 | 4.0.0 行为与适配处理 | 后续跟踪 | +| --- | --- | --- | +| 表/列描述 | ⛔ 不持久化注释。直接注释 API 拒绝;自动迁移输出未完成日志,不将其计为成功 | 🛠️ [#237](https://github.com/IoTSharp/SonnetDB/issues/237) → 🧪 验证 → 📦 发布 → 🔧 集成 | +| 字符串长度 | ⛔ STRING 无界,不能保存或约束模型 Length;建模明确日志,长度变更直接拒绝 | 🛠️ [#240](https://github.com/IoTSharp/SonnetDB/issues/240) → 🧪 → 📦 → 🔧 | +| Decimal 参数完整精度 | ⛔ 高精度值被底层转换为浮点;适配拒绝不能保真的输入,常用低精度值继续使用 | 🛠️ [#235](https://github.com/IoTSharp/SonnetDB/issues/235) → 🧪 → 📦 → 🔧 | +| DATETIME 亚毫秒精度 | ⛔ 存储截到毫秒;适配拒绝带亚毫秒的 DateTime / DateTimeOffset | 🛠️ [#236](https://github.com/IoTSharp/SonnetDB/issues/236) → 🧪 → 📦 → 🔧 | +| Decimal 列迁移精度 | ⛔ ADD/ALTER 的显式 p/s 丢成 (38,28);拒绝受影响迁移,CREATE 的显式精度单独验证 | 🛠️ [#241](https://github.com/IoTSharp/SonnetDB/issues/241) → 🧪 → 📦 → 🔧 | +| BatchReplace | ⛔ 没有 delete + insert 的原子替换;明确拒绝,BatchCapability 不宣称 Replace | 🛠️ [#242](https://github.com/IoTSharp/SonnetDB/issues/242) → 🧪 → 📦 → 🔧 | +| 复合/无主键有界删除 | ⛔ 缺少等价原子 SQL;明确拒绝,避免退回无界 DELETE | 🛠️ [#243](https://github.com/IoTSharp/SonnetDB/issues/243) → 🧪 → 📦 → 🔧 | +| 无主键表写入 | ⛔ 建空表后 INSERT 被拒绝;不暗加业务主键 | 🛠️ [#255](https://github.com/IoTSharp/SonnetDB/issues/255) → 🧪 → 📦 → 🔧 | +| 无条件全表 Update | ⛔ 原生 UPDATE 要求 WHERE,现有静态批量更新的空条件调用仍有差距 | 🛠️ [#257](https://github.com/IoTSharp/SonnetDB/issues/257) → 🧪 → 📦 → 🔧 | +| 大于 Int32 的分页位置 | ⛔ 原生 LIMIT/OFFSET 限为 Int32;适配明确拒绝更大 Int64 值 | 🛠️ [#256](https://github.com/IoTSharp/SonnetDB/issues/256) → 🧪 → 📦 → 🔧 | +| Rank / DenseRank | ⛔ XCode WindowFunction 已有这些接口,SonnetDB 当前窗口不支持 | 🛠️ [#222](https://github.com/IoTSharp/SonnetDB/issues/222) → 🧪 → 📦 → 🔧 | +| 更高隔离等级 | ⛔ Serializable 标签无对应完整隔离;适配仅开放 ReadCommitted / Unspecified | 🛠️ [#225](https://github.com/IoTSharp/SonnetDB/issues/225) → 🧪 → 📦 → 🔧 | +| 事务内锁定读 | ⛔ 尚无真实锁定读,乐观并发不能替代此合同 | 🛠️ [#233](https://github.com/IoTSharp/SonnetDB/issues/233) → 🧪 → 📦 → 🔧 | +| 主键演进 | ⛔ 现有列主键/自增定义变化不由普通 ALTER COLUMN 完成,需要完整迁移合同 | 🛠️ [#226](https://github.com/IoTSharp/SonnetDB/issues/226) → 🧪 → 📦 → 🔧 | +| 事务内 DDL | ⛔ 原生轻事务拒绝 DDL,不能称迁移支持事务回滚 | 🛠️ [#227](https://github.com/IoTSharp/SonnetDB/issues/227) → 🧪 → 📦 → 🔧 | +| 多结果集 | ⛔ 多条 SELECT / NextResult 不完整;单结果集 DataSet 补齐不代表这项完成 | 🛠️ [#229](https://github.com/IoTSharp/SonnetDB/issues/229) → 🧪 → 📦 → 🔧 | +| schema 限定表名 | ⛔ 原生关系表 namespace 与限定表名不完整,表名前缀不等于 schema | 🛠️ [#232](https://github.com/IoTSharp/SonnetDB/issues/232) → 🧪 → 📦 → 🔧 | +| 精确引用标识符 | ⛔ 4.0.0 未包含完整大小写/歧义合同;✅ main 已有 #211 实现,尚未正式交付 | 📦 [#230](https://github.com/IoTSharp/SonnetDB/issues/230) 等待 SonnetDB 发布 → 🧪 XCode 验证 → 🔧 集成 | +| 视图声明列 | ⛔ Columns 缺少视图列,零行读取器返回 Object 类型;反向工程拒绝猜类型,含此类视图的全表元数据枚举也受影响 | 🛠️ [#261](https://github.com/IoTSharp/SonnetDB/issues/261) → 🧪 → 📦 → 🔧 | + +SonnetDB 4.0.0 的 RETURNING、ON CONFLICT、GetSchema 和基础 DECIMAL 已经发布,✅ 这些能力不标为等待发版。上述缺口尚未实现时以 🛠️ 为主;只有修复已经合入但正式包未包含时,才改为“📦 等待 SonnetDB 发布”。本适配及 `XCode.SonnetDB` 自身仍为 📦 待合入发布。 + +嵌入式建库通过 Data Source 目录打开,远程建库使用官方控制面 SQL。嵌入式 DropDatabase 不自动删除数据库目录,当前明确拒绝;备份、恢复及各业务模块的全部组合未在此次验证中冒称通过。 + +## 验证入口 + +嵌入式测试安装正式 SonnetDB 4.0.0,逐项使用隔离临时目录。未完成的未来合同测试有显式 Skip 及 issue 链接;同时执行拒绝保护测试,保证失败不会偷偷写坏数据。 + +2026-10-09 验收记录:SonnetDB 嵌入式及真实 Server 共 31 项,27 通过、4 个未来能力合同明确跳过;现有 SQLite / DbBase / DAL / WindowFunction 回归 70 项中 69 通过、1 项 `SQLiteTests.MetaTest` 因空的 Membership 数据库缺少表而失败。原始提交 `280b2a2f` 在隔离 worktree 同样复现该项失败;不将它归为新适配回归,也不把此次检查称为全套测试通过。PostgreSQL 的真实服务回归尚未执行。 + +```powershell +dotnet test XUnitTest.XCode/XUnitTest.XCode.csproj --filter FullyQualifiedName~SonnetDBTests +``` + +远程测试需要真实 Server 的 HTTP 和 HTTP/2 端点,凭据来自测试环境: + +```powershell +$env:SONNETDB_TEST_CONNECTION = 'Data Source=sonnetdb+http://localhost:5000/app;Token=' +$env:SONNETDB_TEST_HTTP2_CONNECTION = 'Data Source=sonnetdb+http://localhost:5001/app;Token=' +dotnet test XUnitTest.XCode/XUnitTest.XCode.csproj --filter FullyQualifiedName~SonnetDBRemoteTests +``` + +没有上述环境配置时,远程测试明确跳过。它分别验证 REST、Auto、强制 HTTP/2 的元数据、同步/异步 identity、DataSet、分页、事务读写/回滚及有界删除;本地验收使用官方 v4.0.0 Windows Server bundle 并验证发布 SHA-256。 diff --git a/NewLife.XCode.sln b/NewLife.XCode.sln index e55a0a6ed..b0c8cbc0c 100644 --- a/NewLife.XCode.sln +++ b/NewLife.XCode.sln @@ -38,6 +38,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XCode.Oracle", "XCode.Oracl EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XCode.PostgreSQL", "XCode.PostgreSQL\XCode.PostgreSQL.csproj", "{7586B34D-FFF3-4BD5-ADD6-3CA46F69FA27}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XCode.SonnetDB", "XCode.SonnetDB\XCode.SonnetDB.csproj", "{CD9982A2-9683-4A0B-9F20-77D6E4DA704C}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XCode.DaMeng", "XCode.DaMeng\XCode.DaMeng.csproj", "{B5C56F0D-3213-4CF6-8C06-9811859DA572}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XCode.DB2", "XCode.DB2\XCode.DB2.csproj", "{588C600A-2139-480D-90D2-AD820D6F2ADD}" @@ -234,6 +236,18 @@ Global {7586B34D-FFF3-4BD5-ADD6-3CA46F69FA27}.Release|iPhone.Build.0 = Release|Any CPU {7586B34D-FFF3-4BD5-ADD6-3CA46F69FA27}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU {7586B34D-FFF3-4BD5-ADD6-3CA46F69FA27}.Release|iPhoneSimulator.Build.0 = Release|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Debug|iPhone.ActiveCfg = Debug|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Debug|iPhone.Build.0 = Debug|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Release|Any CPU.Build.0 = Release|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Release|iPhone.ActiveCfg = Release|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Release|iPhone.Build.0 = Release|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C}.Release|iPhoneSimulator.Build.0 = Release|Any CPU {7586B34D-FFF3-4BD5-ADD6-3CA46F69FA27}.Release|x64.ActiveCfg = Release|Any CPU {7586B34D-FFF3-4BD5-ADD6-3CA46F69FA27}.Release|x64.Build.0 = Release|Any CPU {7586B34D-FFF3-4BD5-ADD6-3CA46F69FA27}.Release|x86.ActiveCfg = Release|Any CPU @@ -349,6 +363,7 @@ Global {F6BC8B18-4DF7-412D-B701-62285C45EA79} = {EFEB543C-229E-43FC-8284-844460E263BA} {477F54B1-DA8C-4F59-AB21-93E2C78E2AE6} = {EFEB543C-229E-43FC-8284-844460E263BA} {7586B34D-FFF3-4BD5-ADD6-3CA46F69FA27} = {EFEB543C-229E-43FC-8284-844460E263BA} + {CD9982A2-9683-4A0B-9F20-77D6E4DA704C} = {EFEB543C-229E-43FC-8284-844460E263BA} {B5C56F0D-3213-4CF6-8C06-9811859DA572} = {EFEB543C-229E-43FC-8284-844460E263BA} {588C600A-2139-480D-90D2-AD820D6F2ADD} = {EFEB543C-229E-43FC-8284-844460E263BA} {C13F390F-E5C9-41F1-813B-9D8FE67E3025} = {EFEB543C-229E-43FC-8284-844460E263BA} diff --git a/Readme.MD b/Readme.MD index 13345b8f1..bf635c712 100644 --- a/Readme.MD +++ b/Readme.MD @@ -6,7 +6,7 @@ ![Nuget](https://img.shields.io/nuget/v/newlife.xcode?logo=nuget) ![Nuget (with prereleases)](https://img.shields.io/nuget/vpre/newlife.xcode?label=dev%20nuget&logo=nuget) -高性能 .NET 数据中间件,聚焦"添删改查 + 极致性能 + 海量数据",内置多级缓存、自动建模/迁移、分表分库、强类型查询、跨库迁移,支持 MySQL / SQLite / SqlServer / Oracle / PostgreSQL / TDengine / InfluxDB / 达梦 / 金仓 / 瀚高 / DB2 / ClickHouse / DuckDB / MongoDB / Hana (SAP HANA) / IRIS (InterSystems) / VastBase / Network 网络虚拟数据库 / NovaDb 自研数据库等。单表生产实践达百亿级,查询吞吐可达十亿级 QPS(配合缓存策略)。 +高性能 .NET 数据中间件,聚焦"添删改查 + 极致性能 + 海量数据",内置多级缓存、自动建模/迁移、分表分库、强类型查询、跨库迁移,支持 MySQL / SQLite / SqlServer / Oracle / PostgreSQL / TDengine / InfluxDB / 达梦 / 金仓 / 瀚高 / DB2 / ClickHouse / DuckDB / MongoDB / Hana (SAP HANA) / IRIS (InterSystems) / VastBase / Network 网络虚拟数据库 / NovaDb 自研数据库等,并提供 [SonnetDB 适配与能力验收](Doc/SonnetDB.md)。单表生产实践达百亿级,查询吞吐可达十亿级 QPS(配合缓存策略)。 [English](README.en.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) diff --git a/XCode.SonnetDB/XCode.SonnetDB.csproj b/XCode.SonnetDB/XCode.SonnetDB.csproj new file mode 100644 index 000000000..344816782 --- /dev/null +++ b/XCode.SonnetDB/XCode.SonnetDB.csproj @@ -0,0 +1,31 @@ + + + net10.0 + XCode SonnetDB适配包 + 为 XCode 提供 SonnetDB 嵌入式和远程数据库驱动接入、SQL方言与自动建模支持。 + 新生命开发团队 + ©2002-2026 NewLife + 11.25 + $([System.DateTime]::Now.ToString(`yyyy.MMdd`)) + $(VersionPrefix).$(VersionSuffix) + $(Version) + $(VersionPrefix).$([System.DateTime]::Now.ToString(`yyyy.MMdd`)) + false + $(AssemblyName) + $(Company) + https://newlifex.com/xcode + leaf.png + https://github.com/NewLifeX/NewLife.XCode + git + 新生命团队;X组件;NewLife;SonnetDB;ORM + 新增 SonnetDB 4.0.0 驱动接入。能力验收与数据库缺口见 Doc/SonnetDB.md。 + MIT + Readme.MD + + + + + + + + diff --git a/XCode/DataAccessLayer/Common/DatabaseType.cs b/XCode/DataAccessLayer/Common/DatabaseType.cs index c8125da8d..d4a91a409 100644 --- a/XCode/DataAccessLayer/Common/DatabaseType.cs +++ b/XCode/DataAccessLayer/Common/DatabaseType.cs @@ -93,6 +93,10 @@ public enum DatabaseType [Description("MongoDB文档数据库")] MongoDB = 21, + /// SonnetDB数据库。支持嵌入式目录和远程连接 + [Description("SonnetDB数据库")] + SonnetDB = 22, + /// 网络虚拟数据库。通过HTTP接口转发数据库操作到远端服务执行 [Description("网络虚拟数据库")] Network = 100, diff --git a/XCode/DataAccessLayer/Common/DbFactory.cs b/XCode/DataAccessLayer/Common/DbFactory.cs index 5b6876161..49aedb4b5 100644 --- a/XCode/DataAccessLayer/Common/DbFactory.cs +++ b/XCode/DataAccessLayer/Common/DbFactory.cs @@ -28,6 +28,7 @@ static DbFactory() Register(DatabaseType.ClickHouse); Register(DatabaseType.DuckDB); Register(DatabaseType.MongoDB); + Register(DatabaseType.SonnetDB); Register(DatabaseType.Network); //Register(DatabaseType.Access); //Register(DatabaseType.SqlCe); diff --git a/XCode/DataAccessLayer/Database/SonnetDB.cs b/XCode/DataAccessLayer/Database/SonnetDB.cs new file mode 100644 index 000000000..da3b325bc --- /dev/null +++ b/XCode/DataAccessLayer/Database/SonnetDB.cs @@ -0,0 +1,609 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using NewLife.Data; + +namespace XCode.DataAccessLayer; + +/// SonnetDB关系数据库适配,支持嵌入式目录和远程服务。 +internal class SonnetDB : RemoteDb +{ + /// + public override DatabaseType Type => DatabaseType.SonnetDB; + + /// + public override BatchCapability BatchCapability => BatchCapability.Insert | BatchCapability.InsertIgnore | BatchCapability.Upsert; + + /// + public override String SystemDatabaseName => String.Empty; + + /// 默认使用参数,并拒绝SonnetDB 4.0.0无法保真的数值与时间。 + public SonnetDB() => UseParameter = true; + + protected override DbProviderFactory? CreateFactory() => GetProviderFactory(null, "SonnetDB.Data.dll", "SonnetDB.Data.SndbProviderFactory"); + protected override IDbSession OnCreateSession() => new SonnetDBSession(this); + protected override IMetaData OnCreateMetaData() => new SonnetDBMetaData(); + + /// + public override Boolean Support(String providerName) => providerName.IndexOf("SonnetDB", StringComparison.OrdinalIgnoreCase) >= 0; + + protected override void OnSetConnectionString(ConnectionStringBuilder builder) + { + base.OnSetConnectionString(builder); + if (String.IsNullOrEmpty(DatabaseName)) + { + var source = builder["Data Source"]; + var uriSource = source?.StartsWith("sonnetdb+", StringComparison.OrdinalIgnoreCase) == true ? source.Substring("sonnetdb+".Length) : source; + DatabaseName = Uri.TryCreate(uriSource, UriKind.Absolute, out var uri) && (uri.Scheme == "http" || uri.Scheme == "https") + ? Uri.UnescapeDataString(uri.AbsolutePath.Trim('/')) : source; + } + } + + internal Boolean IsRemote + { + get + { + var builder = new ConnectionStringBuilder(ConnectionString); + var mode = builder["Mode"]; + if (!String.IsNullOrEmpty(mode)) return mode.Equals("Remote", StringComparison.OrdinalIgnoreCase); + if (new[] { "Host", "Username", "User ID", "UserId", "Uid" }.Any(key => !String.IsNullOrWhiteSpace(builder[key]))) return true; + var source = builder["Data Source"] ?? String.Empty; + return new[] { "sonnetdb+http://", "sonnetdb+https://", "http://", "https://" }.Any(prefix => source.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + } + + /// + public override String FormatKeyWord(String keyWord) => String.IsNullOrEmpty(keyWord) || keyWord.StartsWith("\"") && keyWord.EndsWith("\"") ? keyWord : $"\"{keyWord.Replace("\"", "\"\"")}\""; + + /// + public override String FormatName(String name) => FormatKeyWord(base.FormatName(name)); + + /// + public override String PageSplit(String sql, Int64 startRowIndex, Int64 maximumRows, String? keyColumn) => sql + Pagination(startRowIndex, maximumRows); + + /// + public override SelectBuilder PageSplit(SelectBuilder builder, Int64 startRowIndex, Int64 maximumRows) + { + var pagination = Pagination(startRowIndex, maximumRows); + if (pagination.Length > 0) builder.Limit = pagination.TrimStart(); + return builder; + } + + private static String Pagination(Int64 startRowIndex, Int64 maximumRows) + { + if (startRowIndex > Int32.MaxValue || maximumRows > Int32.MaxValue) + throw new NotSupportedException("SonnetDB LIMIT/OFFSET目前不能超过Int32.MaxValue。参见SonnetDB issue #256。"); + var sql = maximumRows > 0 ? $" LIMIT {maximumRows}" : String.Empty; + if (startRowIndex > 0) sql += $" OFFSET {startRowIndex}"; + return sql; + } + + /// + public override String StringConcat(String left, String right) => $"CONCAT({(String.IsNullOrEmpty(left) ? "''" : left)},{(String.IsNullOrEmpty(right) ? "''" : right)})"; + + /// + public override String FormatValue(IDataColumn column, Object? value) + { + if (value == null || value == DBNull.Value) return "NULL"; + var type = column?.DataType ?? value.GetType(); + type = Nullable.GetUnderlyingType(type) ?? type; + CheckValue(value, type.IsEnum ? Enum.GetUnderlyingType(type) : type); + if (type.IsEnum) return Convert.ToInt64(value, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); + if (type == typeof(Boolean)) return Convert.ToBoolean(value, CultureInfo.InvariantCulture) ? "TRUE" : "FALSE"; + if (value is Byte[] bytes) return Quote(Convert.ToBase64String(bytes)); + if (type == typeof(DateTime) || value is DateTime) return FormatDateTime(column!, Convert.ToDateTime(value, CultureInfo.InvariantCulture)); + if (type == typeof(DateTimeOffset) || value is DateTimeOffset) + { + var offset = value is DateTimeOffset dateOffset ? dateOffset : DateTimeOffset.Parse(value.ToString()!, CultureInfo.InvariantCulture); + CheckValue(offset, typeof(DateTimeOffset)); + return Quote(offset.UtcDateTime.ToString("O", CultureInfo.InvariantCulture)); + } + if (type == typeof(TimeSpan) || value is TimeSpan) + { + var span = value is TimeSpan timeSpan ? timeSpan : TimeSpan.Parse(value.ToString()!, CultureInfo.InvariantCulture); + return Quote(span.ToString("c", CultureInfo.InvariantCulture)); + } + if (type == typeof(Guid) || value is Guid) return Quote(Guid.Parse(value.ToString()!).ToString("D")); + if (type == typeof(String) || type == typeof(Char)) return Quote(Convert.ToString(value, CultureInfo.InvariantCulture)!); + if (value is Decimal number) return number.ToString(CultureInfo.InvariantCulture); + if (value is Double dbl) return dbl.ToString("R", CultureInfo.InvariantCulture); + if (value is Single single) return single.ToString("R", CultureInfo.InvariantCulture); + return Convert.ToString(value, CultureInfo.InvariantCulture)!; + } + + internal static String Quote(String value) => "'" + value.Replace("'", "''") + "'"; + + /// + public override String FormatDateTime(DateTime dateTime) => FormatDateTime(null!, dateTime); + + /// + public override String FormatDateTime(IDataColumn column, DateTime dateTime) + { + CheckValue(dateTime, typeof(DateTime)); + return Quote(dateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffK", CultureInfo.InvariantCulture)); + } + + private static void CheckValue(Object value, Type? type) + { + if (value == DBNull.Value) return; + if (type == typeof(Decimal) || value is Decimal) + { + var number = Convert.ToDecimal(value, CultureInfo.InvariantCulture); + // 4.0.0参数与数字字面量先经Float64,再由Convert.ToDecimal恢复约15位有效数字。 + try + { + if (Convert.ToDecimal((Double)number) == number) return; + } + catch (OverflowException) { } + throw new NotSupportedException("SonnetDB 4.0.0无法精确保留此Decimal值,已拒绝写入。参见SonnetDB issue #235,尚需实现精确十进制参数支持并正式发布。"); + } + if (value is DateTimeOffset offset) + { + CheckTime(offset.UtcDateTime.Ticks); + } + else if (type == typeof(DateTime) || value is DateTime) + { + CheckTime(Convert.ToDateTime(value, CultureInfo.InvariantCulture).Ticks); + } + if ((type == typeof(UInt64) || value is UInt64) && Convert.ToUInt64(value, CultureInfo.InvariantCulture) > Int64.MaxValue) + throw new NotSupportedException("SonnetDB INT使用有符号Int64,仅支持不超过Int64.MaxValue的UInt64值。"); + } + + private static void CheckTime(Int64 ticks) + { + if (ticks % TimeSpan.TicksPerMillisecond != 0) + throw new NotSupportedException("SonnetDB 4.0.0 DATETIME只能保留毫秒精度,已拒绝亚毫秒时间。参见SonnetDB issue #236,尚需实现完整时间精度支持并正式发布。"); + } + + /// + public override IDataParameter CreateParameter(String name, Object? value, Type? type = null) + { + type = type == null ? value?.GetType() : Nullable.GetUnderlyingType(type) ?? type; + if (type?.IsEnum == true) + { + type = Enum.GetUnderlyingType(type); + if (value != null && value != DBNull.Value) value = Convert.ChangeType(value, type, CultureInfo.InvariantCulture); + } + if (value != null) CheckValue(value, type); + if (type == typeof(SByte) || type == typeof(UInt16) || type == typeof(UInt32) || type == typeof(UInt64)) + { + if (value != null && value != DBNull.Value) value = Convert.ToInt64(value, CultureInfo.InvariantCulture); + type = typeof(Int64); + } + if (type == typeof(Guid)) + { + if (value != null && value != DBNull.Value) value = value.ToString(); + type = typeof(String); + } + else if (type == typeof(Char)) + { + if (value != null && value != DBNull.Value) value = value.ToString(); + type = typeof(String); + } + else if (type == typeof(DateTimeOffset)) + { + value = value is DateTimeOffset offset ? offset.UtcDateTime : value; + type = typeof(DateTime); + } + else if (type == typeof(TimeSpan)) + { + var parameter = Factory.CreateParameter()!; + parameter.ParameterName = FormatParameterName(name); + parameter.SourceColumn = name; + parameter.DbType = DbType.Time; + parameter.Value = value ?? DBNull.Value; + return parameter; + } + var result = base.CreateParameter(name, value, type); + if (value == null || value == DBNull.Value) result.Value = DBNull.Value; + return result; + } + + /// + public override String? BuildDeleteSql(String tableName, String where, Int32 batchSize) + { + if (batchSize <= 0) return base.BuildDeleteSql(tableName, where, batchSize); + var name = tableName.Trim('"').Replace("\"\"", "\""); + var table = ((SonnetDBMetaData)CreateMetaData()).FindTable(name); + if (table == null || table.PrimaryKeys.Length != 1) + throw new NotSupportedException("SonnetDB有界批量删除目前需要单列主键,复合主键或无主键表不能降级为无界删除。参见SonnetDB issue #243。"); + var primaryKey = FormatName(table.PrimaryKeys[0]); + var condition = String.IsNullOrWhiteSpace(where) ? String.Empty : " WHERE " + where; + var tableSql = FormatName(tableName); + return $"DELETE FROM {tableSql} WHERE {primaryKey} IN (SELECT {primaryKey} FROM {tableSql}{condition} LIMIT {batchSize})"; + } +} + +/// SonnetDB会话,使用ADO读取器执行查询。 +internal class SonnetDBSession : RemoteDbSession +{ + public SonnetDBSession(IDatabase database) : base(database) { } + + /// + public override DataSet Query(DbCommand cmd) => Execute(cmd, true, command => + { + using var reader = command.ExecuteReader(); + var result = new DataSet(); + var table = new DataTable(); + table.Load(reader); + result.Tables.Add(table); + return result; + }); + + /// + public override Int32 BeginTransaction(IsolationLevel level) + { + if (level != IsolationLevel.Unspecified && level != IsolationLevel.ReadCommitted) + throw new NotSupportedException("SonnetDB尚未满足此事务隔离级别的完整语义,请使用ReadCommitted。参见SonnetDB issue #225。"); + return base.BeginTransaction(level); + } + + protected override DbCommand OnCreateCommand(String sql, CommandType type = CommandType.Text, params IDataParameter[]? ps) + { + // 仅含自增字段的实体由XCode生成空字段/值列表,SonnetDB原生语法使用DEFAULT VALUES。 + if (type == CommandType.Text && Regex.IsMatch(sql, @"^\s*INSERT\s+INTO\b", RegexOptions.IgnoreCase)) + sql = Regex.Replace(sql, @"\(\s*\)\s*VALUES\s*\(\s*\)(?=\s*(?:RETURNING\b|;|$))", "DEFAULT VALUES", RegexOptions.IgnoreCase); + return base.OnCreateCommand(sql, type, ps); + } + + /// + public override Int64 InsertAndGetIdentity(String sql, CommandType type = CommandType.Text, params IDataParameter[]? ps) + { + using var cmd = OnCreateCommand(WithReturning(sql), type, ps); + return Execute(cmd, false, command => + { + using var reader = command.ExecuteReader(); + var ordinal = IdentityOrdinal(reader); + return reader.Read() && !reader.IsDBNull(ordinal) ? Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture) : 0; + }); + } + + /// + public override async Task InsertAndGetIdentityAsync(String sql, CommandType type = CommandType.Text, params IDataParameter[]? ps) + { + using var cmd = OnCreateCommand(WithReturning(sql), type, ps); + return await ExecuteAsync(cmd, false, async command => + { + using var reader = await command.ExecuteReaderAsync().ConfigureAwait(false); + var ordinal = IdentityOrdinal(reader); + return await reader.ReadAsync().ConfigureAwait(false) && !reader.IsDBNull(ordinal) ? Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture) : 0; + }).ConfigureAwait(false); + } + + private static String WithReturning(String sql) => sql.TrimEnd().TrimEnd(';') + " RETURNING *"; + + private static Int32 IdentityOrdinal(DbDataReader reader) + { + var schema = reader.GetSchemaTable(); + if (schema != null && schema.Columns.Contains(SchemaTableOptionalColumn.IsAutoIncrement)) + { + foreach (DataRow row in schema.Rows) + if (row[SchemaTableOptionalColumn.IsAutoIncrement] is Boolean identity && identity) + return Convert.ToInt32(row[SchemaTableColumn.ColumnOrdinal], CultureInfo.InvariantCulture); + } + throw new NotSupportedException("SonnetDB返回结果未标识自增列,无法安全取得自动编号。请升级包含RETURNING列元数据的SonnetDB版本。"); + } + + /// + public override Int32 Insert(IDataTable table, IDataColumn[] columns, IEnumerable list) => ExecuteBatch(table, columns, list, null, null, false); + + /// + public override Int32 InsertIgnore(IDataTable table, IDataColumn[] columns, IEnumerable list) => ExecuteBatch(table, columns, list, null, null, true); + + /// + public override Int32 Replace(IDataTable table, IDataColumn[] columns, IEnumerable list) => throw new NotSupportedException("SonnetDB尚未支持删除冲突行后插入新行的Replace语义。参见SonnetDB issue #242。"); + + /// + public override Int32 Upsert(IDataTable table, IDataColumn[] columns, ICollection? updateColumns, ICollection? addColumns, IEnumerable list) => ExecuteBatch(table, columns, list, updateColumns, addColumns, false); + + private Int32 ExecuteBatch(IDataTable table, IDataColumn[] columns, IEnumerable list, ICollection? updateColumns, ICollection? addColumns, Boolean ignore) + { + var rows = list.ToArray(); + if (rows.Length == 0) return 0; + columns ??= table.Columns.ToArray(); + if (columns.Length == 0) throw new ArgumentException("批量写入必须包含字段。", nameof(columns)); + + var db = (SonnetDB)Database; + var sql = new StringBuilder(); + sql.Append("INSERT INTO ").Append(db.FormatName(table)).Append('(').Append(String.Join(",", columns.Select(db.FormatName))).Append(") VALUES "); + var parameters = new List(rows.Length * columns.Length); + for (var rowIndex = 0; rowIndex < rows.Length; rowIndex++) + { + if (rowIndex > 0) sql.Append(','); + sql.Append('('); + for (var columnIndex = 0; columnIndex < columns.Length; columnIndex++) + { + if (columnIndex > 0) sql.Append(','); + var column = columns[columnIndex]; + var name = "p" + parameters.Count; + sql.Append(db.FormatParameterName(name)); + parameters.Add(db.CreateParameter(name, rows[rowIndex][column.Name], column)); + } + sql.Append(')'); + } + if (ignore) sql.Append(" ON CONFLICT DO NOTHING"); + else if (updateColumns is { Count: > 0 } || addColumns is { Count: > 0 }) + { + var keys = table.Indexes.FirstOrDefault(index => index.Unique && index.Columns.Length > 0 && index.Columns.All(name => columns.Any(column => column.ColumnName.Equals(name, StringComparison.OrdinalIgnoreCase))))?.Columns + ?? table.PrimaryKeys.Select(column => column.ColumnName).ToArray(); + if (keys.Length == 0) throw new NotSupportedException("SonnetDB Upsert需要主键或完整的唯一索引冲突目标。"); + var setters = new List(); + foreach (var column in columns) + { + if (column.Identity || column.PrimaryKey || keys.Contains(column.ColumnName, StringComparer.OrdinalIgnoreCase)) continue; + var name = db.FormatName(column); + if (addColumns?.Contains(column.Name) == true) setters.Add($"{name}={name}+excluded.{name}"); + else if (updateColumns?.Contains(column.Name) == true) setters.Add($"{name}=excluded.{name}"); + } + sql.Append(" ON CONFLICT (").Append(String.Join(",", table.GetColumns(keys).Select(db.FormatName))).Append(") "); + sql.Append(setters.Count == 0 ? "DO NOTHING" : "DO UPDATE SET " + String.Join(",", setters)); + } + return Execute(sql.ToString(), CommandType.Text, parameters.ToArray()); + } +} + +/// 读取SonnetDB原生关系表元数据并生成建模SQL。 +internal class SonnetDBMetaData : RemoteDbMetaData +{ + private readonly HashSet _reportedLengths = new(StringComparer.Ordinal); + + public SonnetDBMetaData() => Types = new Dictionary + { + [typeof(Int64)] = ["INT"], [typeof(Int32)] = ["INT"], [typeof(Int16)] = ["INT"], + [typeof(Byte)] = ["INT"], [typeof(SByte)] = ["INT"], [typeof(UInt16)] = ["INT"], [typeof(UInt32)] = ["INT"], [typeof(UInt64)] = ["INT"], + [typeof(Double)] = ["FLOAT"], [typeof(Single)] = ["FLOAT"], [typeof(Decimal)] = ["DECIMAL"], + [typeof(Boolean)] = ["BOOL"], [typeof(String)] = ["STRING"], [typeof(Guid)] = ["STRING"], [typeof(Char)] = ["STRING"], + [typeof(DateTime)] = ["DATETIME"], [typeof(DateTimeOffset)] = ["DATETIME"], [typeof(TimeSpan)] = ["TIME"], [typeof(Byte[])] = ["BLOB"], + }; + + /// + public override IList GetTableNames() => GetSchema("Tables", null)!.Rows.Cast().Where(IsRelational).Select(row => (String)row["TABLE_NAME"]).ToList(); + + private static Boolean IsRelational(DataRow row) => (String)row["TABLE_TYPE"] is "BASE TABLE" or "VIEW" or "MATERIALIZED VIEW"; + + protected override List OnGetTables(String[]? names) + { + var tables = GetSchema("Tables", null)!; + var columns = GetSchema("Columns", null)!; + var indexes = GetSchema("Indexes", null)!; + var requested = names == null ? null : new HashSet(names, StringComparer.OrdinalIgnoreCase); + var result = new List(); + foreach (DataRow row in tables.Rows) + { + if (!IsRelational(row)) continue; + var name = (String)row["TABLE_NAME"]; + if (requested != null && !requested.Contains(name)) continue; + var table = DAL.CreateTable(); + table.TableName = name; + table.DbType = Database.Type; + table.IsView = (String)row["TABLE_TYPE"] != "BASE TABLE"; + foreach (var item in columns.Rows.Cast().Where(column => (String)column["TABLE_NAME"] == name).OrderBy(column => (Int32)column["ORDINAL_POSITION"])) + { + var column = table.CreateColumn(); + column.ColumnName = (String)item["COLUMN_NAME"]; + column.RawType = (String)item["DATA_TYPE"]; + column.DataType = GetDataType(column) ?? throw new NotSupportedException($"SonnetDB字段类型尚未映射:{column.RawType}"); + column.Identity = (Boolean)item["IS_AUTO_INCREMENT"]; + column.PrimaryKey = (Boolean)item["IS_PRIMARY_KEY"]; + column.Nullable = (Boolean)item["IS_NULLABLE"]; + column.DefaultValue = item.IsNull("COLUMN_DEFAULT") ? null : (String)item["COLUMN_DEFAULT"]; + column.Length = Math.Max(0, Convert.ToInt32(item["CHARACTER_MAXIMUM_LENGTH"], CultureInfo.InvariantCulture)); + column.Precision = Convert.ToInt32(item["NUMERIC_PRECISION"], CultureInfo.InvariantCulture); + column.Scale = Convert.ToInt32(item["NUMERIC_SCALE"], CultureInfo.InvariantCulture); + column.Fix(); + table.Columns.Add(column); + } + if (table.IsView && table.Columns.Count == 0) ReadViewColumns(table); + foreach (var group in indexes.Rows.Cast().Where(index => (String)index["TABLE_NAME"] == name && String.IsNullOrEmpty(Convert.ToString(index["JSON_PATH"]))).GroupBy(index => (String)index["INDEX_NAME"])) + { + var index = table.CreateIndex(); + index.Name = group.Key; + index.Unique = (Boolean)group.First()["IS_UNIQUE"]; + index.Columns = group.OrderBy(item => (Int32)item["ORDINAL_POSITION"]).Select(item => (String)item["COLUMN_NAME"]).ToArray(); + table.Indexes.Add(index); + } + table.Fix(); + result.Add(table); + } + return result; + } + + internal IDataTable? FindTable(String name) => OnGetTables([name]).FirstOrDefault(); + + private void ReadViewColumns(IDataTable table) + { + // GetSchema("Columns")只包含关系表;尝试读取视图列元数据,声明类型缺失时明确拒绝。 + using var connection = Database.OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT * FROM {Database.FormatName(table.TableName)} LIMIT 0"; + using var reader = command.ExecuteReader(); + var schema = reader.GetSchemaTable() ?? throw new NotSupportedException($"SonnetDB视图{table.TableName}未提供字段元数据。"); + foreach (var row in schema.Rows.Cast().OrderBy(row => (Int32)row[SchemaTableColumn.ColumnOrdinal])) + { + var column = table.CreateColumn(); + column.ColumnName = (String)row[SchemaTableColumn.ColumnName]; + column.DataType = (Type)row[SchemaTableColumn.DataType]; + if (column.DataType.FullName == "System.TimeOnly") column.DataType = typeof(TimeSpan); + column.RawType = GetFieldType(column) ?? throw new NotSupportedException($"SonnetDB视图{table.TableName}字段{column.ColumnName}未提供可映射的声明类型。参见SonnetDB issue #261,尚需补齐视图声明元数据并正式发布。"); + column.Nullable = (Boolean)row[SchemaTableColumn.AllowDBNull]; + column.PrimaryKey = (Boolean)row[SchemaTableColumn.IsKey]; + column.Identity = (Boolean)row[SchemaTableOptionalColumn.IsAutoIncrement]; + column.Length = Math.Max(0, Convert.ToInt32(row[SchemaTableColumn.ColumnSize], CultureInfo.InvariantCulture)); + column.Precision = Convert.ToInt32(row[SchemaTableColumn.NumericPrecision], CultureInfo.InvariantCulture); + column.Scale = Convert.ToInt32(row[SchemaTableColumn.NumericScale], CultureInfo.InvariantCulture); + column.Fix(); + table.Columns.Add(column); + } + } + + protected override Type? GetDataType(IDataColumn field) => field.RawType?.Equals("JSON", StringComparison.OrdinalIgnoreCase) == true ? typeof(String) : base.GetDataType(field); + + protected override String? GetFieldType(IDataColumn field) + { + if (field.RawType?.Equals("JSON", StringComparison.OrdinalIgnoreCase) == true && field.Table.DbType == Database.Type) return "JSON"; + if (field.DataType == typeof(Decimal) && field.Precision > 0) return $"DECIMAL({field.Precision},{field.Scale})"; + return base.GetFieldType(field); + } + + protected override Boolean IsColumnTypeChanged(IDataColumn entityColumn, IDataColumn dbColumn) => !String.Equals(GetFieldType(entityColumn), GetFieldType(dbColumn), StringComparison.OrdinalIgnoreCase); + protected override Boolean IsColumnLengthChanged(IDataColumn entityColumn, IDataColumn dbColumn, IDatabase? entityDb) + { + ReportLength(entityColumn); + return false; + } + protected override Boolean IsColumnChanged(IDataColumn entityColumn, IDataColumn dbColumn, IDatabase? entityDb) + { + var expectedDefault = GetDefault(entityColumn, false); + if (expectedDefault != null) expectedDefault = expectedDefault.Substring(" DEFAULT ".Length); + return entityColumn.Identity != dbColumn.Identity || entityColumn.PrimaryKey != dbColumn.PrimaryKey + || (entityColumn.Nullable && !entityColumn.PrimaryKey && !entityColumn.Identity) != dbColumn.Nullable + || !String.Equals(expectedDefault?.Trim(), dbColumn.DefaultValue?.Trim(), StringComparison.OrdinalIgnoreCase); + } + + /// + public override String FieldClause(IDataColumn field, Boolean onlyDefine) + { + ReportLength(field); + var sql = $"{FormatName(field)} {GetFieldType(field) ?? throw new NotSupportedException($"SonnetDB不支持字段类型{field.DataType}")}"; + if (field.Identity) return sql + " AUTO_INCREMENT"; + sql += field.Nullable && !field.PrimaryKey ? " NULL" : " NOT NULL"; + return sql + GetDefault(field, onlyDefine); + } + + private void ReportLength(IDataColumn field) + { + if (field.DataType == typeof(String) && field.Length > 0 && field.Length < Int32.MaxValue + && _reportedLengths.Add($"{field.Table.TableName}.{field.ColumnName}:{field.Length}")) + WriteLog("[SonnetDB]字段{0}.{1}声明长度{2},当前STRING不支持长度约束;未完成,参见SonnetDB issue #240。", field.Table.TableName, field.ColumnName, field.Length); + } + + protected override String? GetDefault(IDataColumn field, Boolean onlyDefine) + { + if (field.Identity) return null; + var value = field.DefaultValue; + if (!String.IsNullOrEmpty(value)) + { + if ((field.DataType == typeof(DateTime) || field.DataType == typeof(DateTimeOffset) || field.DataType == typeof(TimeSpan)) + && !value.StartsWith("'") && value.IndexOf('(') < 0) + value = SonnetDB.Quote(value); + return " DEFAULT " + value; + } + if (field.Nullable) return null; + if (field.DataType == typeof(String) || field.DataType == typeof(Guid)) return " DEFAULT ''"; + if (field.DataType == typeof(Boolean)) return " DEFAULT FALSE"; + if (field.DataType == typeof(DateTime) || field.DataType == typeof(DateTimeOffset)) return " DEFAULT '0001-01-01T00:00:00'"; + if (field.DataType == typeof(TimeSpan)) return " DEFAULT '00:00:00'"; + if (field.DataType.IsEnum || field.DataType == typeof(Decimal) || field.DataType == typeof(Double) || field.DataType == typeof(Single) || field.DataType == typeof(Int64) || field.DataType == typeof(Int32) || field.DataType == typeof(Int16) || field.DataType == typeof(Byte) || field.DataType == typeof(SByte) || field.DataType == typeof(UInt16) || field.DataType == typeof(UInt32) || field.DataType == typeof(UInt64)) return " DEFAULT 0"; + return null; + } + + /// + public override String CreateTableSQL(IDataTable table) + { + var clauses = table.Columns.Select(column => FieldClause(column, true)).ToList(); + if (table.PrimaryKeys.Length > 0) clauses.Add("PRIMARY KEY (" + String.Join(",", table.PrimaryKeys.Select(FormatName)) + ")"); + return $"CREATE TABLE {FormatName(table)} ({String.Join(",", clauses)})"; + } + + /// + public override String? AlterColumnSQL(IDataColumn field, IDataColumn? oldfield) + { + if (oldfield != null && field.DataType == typeof(String) && field.Length != oldfield.Length && field.Length > 0 && field.Length < Int32.MaxValue) + throw new NotSupportedException("SonnetDB STRING尚未支持长度约束变更。参见SonnetDB issue #240,尚需实现对应能力并正式发布。"); + CheckDecimalMigration(field); + if (oldfield != null && (field.Identity != oldfield.Identity || field.PrimaryKey != oldfield.PrimaryKey)) + throw new NotSupportedException("SonnetDB现有列的自增或主键变更需要单独迁移,不能通过ALTER COLUMN完成。"); + var sql = $"ALTER TABLE {FormatName(field.Table)} ALTER COLUMN {FormatName(field)} TYPE {GetFieldType(field)}"; + sql += field.Nullable && !field.PrimaryKey && !field.Identity ? " DROP NOT NULL" : " SET NOT NULL"; + if (!field.Identity) + { + var defaultSql = GetDefault(field, false); + sql += defaultSql == null ? " DROP DEFAULT" : " SET" + defaultSql; + } + return sql; + } + + /// + public override String? AddColumnSQL(IDataColumn field) + { + CheckDecimalMigration(field); + return base.AddColumnSQL(field); + } + + private static void CheckDecimalMigration(IDataColumn field) + { + if (field.DataType == typeof(Decimal) && field.Precision > 0 && (field.Precision != 38 || field.Scale != 28)) + throw new NotSupportedException("SonnetDB 4.0.0的DECIMAL ADD/ALTER会丢失显式精度,已拒绝执行以保留模型语义。参见SonnetDB issue #241,尚需修复并正式发布。"); + } + + /// + public override String? CreateIndexSQL(IDataIndex index) => $"CREATE {(index.Unique ? "UNIQUE " : String.Empty)}INDEX {Database.FormatName(index.Name!)} ON {FormatName(index.Table)} ({String.Join(",", index.Table.GetColumns(index.Columns).Select(FormatName))})"; + + /// + public override String DropIndexSQL(IDataIndex index) => $"DROP INDEX {Database.FormatName(index.Name!)} ON {FormatName(index.Table)}"; + + /// + public override Boolean DatabaseExist(String? databaseName) + { + var db = (SonnetDB)Database; + if (!db.IsRemote) + { + if (!String.IsNullOrEmpty(databaseName) && databaseName != db.DatabaseName) return false; + return !String.IsNullOrEmpty(db.DatabaseName) && Directory.Exists(db.DatabaseName); + } + var rows = db.CreateSession().Query("SHOW DATABASES", null); + databaseName ??= db.DatabaseName; + return rows.Rows.Any(row => row.Length > 0 && String.Equals(Convert.ToString(row[0]), databaseName, StringComparison.Ordinal)); + } + + /// + public override Boolean CreateDatabase(String databaseName, String? file = null) + { + if (((SonnetDB)Database).IsRemote) + { + if (DatabaseExist(databaseName)) return true; + Database.CreateSession().Execute(CreateDatabaseSQL(databaseName, file)); + return true; + } + if (databaseName != Database.DatabaseName) throw new NotSupportedException("嵌入式SonnetDB通过Data Source目录创建数据库,请为目标目录配置独立连接。"); + using var connection = Database.OpenConnection(); + return connection.State == ConnectionState.Open; + } + + /// + public override Boolean DropDatabase(String databaseName) + { + if (!((SonnetDB)Database).IsRemote) throw new NotSupportedException("嵌入式SonnetDB不支持通过SQL删除数据库目录。"); + if (!DatabaseExist(databaseName)) return true; + Database.CreateSession().Execute(DropDatabaseSQL(databaseName)); + return true; + } + + /// + public override String CreateDatabaseSQL(String dbname, String? file) => $"CREATE DATABASE {Database.FormatName(dbname)}"; + /// + public override String DropDatabaseSQL(String dbname) => $"DROP DATABASE {Database.FormatName(dbname)}"; + + /// + public override String? AddTableDescriptionSQL(IDataTable table) => throw CommentsUnsupported(); + /// + public override String? DropTableDescriptionSQL(IDataTable table) => throw CommentsUnsupported(); + /// + public override String? AddColumnDescriptionSQL(IDataColumn field) => throw CommentsUnsupported(); + /// + public override String? DropColumnDescriptionSQL(IDataColumn field) => throw CommentsUnsupported(); + + private static NotSupportedException CommentsUnsupported() => new("SonnetDB尚未支持表和字段注释的持久化。参见SonnetDB issue #237,尚需实现对应能力并正式发布。"); + + protected override Boolean PerformSchema(StringBuilder sb, Boolean onlySql, DDLSchema schema, params Object[] values) + { + if (schema is DDLSchema.AddTableDescription or DDLSchema.DropTableDescription or DDLSchema.AddColumnDescription or DDLSchema.DropColumnDescription) + { + WriteLog("[SonnetDB]未完成{0}:尚未支持表和字段注释持久化,需要SonnetDB实现并正式发布,参见issue #237。", schema); + return false; + } + return base.PerformSchema(sb, onlySql, schema, values); + } +} diff --git a/XCode/Entity/Entity.cs b/XCode/Entity/Entity.cs index 6a451bed4..1ba33046b 100644 --- a/XCode/Entity/Entity.cs +++ b/XCode/Entity/Entity.cs @@ -1862,10 +1862,9 @@ public static Int32 Delete(Expression? where, Int32 maximumRows = 0) var shards = Meta.InShard || where == null ? null : Meta.ShardPolicy?.Shards(where); if (shards == null || shards.Length == 0) { - // 非分表情况,直接调用现有的参数化Delete方法 + // Persistence.Delete仅接收SQL条件,不接收参数,使用数据库方言格式化值。 var db = session.Dal.Db; - var ps = db.UseParameter ? new Dictionary() : null; - var whereClause = where?.GetString(db, ps) ?? String.Empty; + var whereClause = where?.GetString(db, null) ?? String.Empty; return Persistence.Delete(session, whereClause, maximumRows); } diff --git a/XUnitTest.XCode/DataAccessLayer/SonnetDBRemoteTests.cs b/XUnitTest.XCode/DataAccessLayer/SonnetDBRemoteTests.cs new file mode 100644 index 000000000..6c8301ba9 --- /dev/null +++ b/XUnitTest.XCode/DataAccessLayer/SonnetDBRemoteTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using XCode.DataAccessLayer; +using XCode.Model; +using Xunit; + +namespace XUnitTest.XCode.DataAccessLayer; + +/// 通过正式 Server 验证远程接入,连接字符串由测试环境提供。 +public class SonnetDBRemoteTests +{ + [SonnetServerTheory] + [InlineData("rest")] + public void DatabaseLifecycle(String protocol) + { + var connName = "SonnetDatabase_" + Guid.NewGuid().ToString("N"); + DAL.AddConnStr(connName, Environment.GetEnvironmentVariable("SONNETDB_TEST_CONNECTION") + ";Protocol=" + protocol, null, "SonnetDB"); + var dal = DAL.Create(connName); + var meta = dal.Db.CreateMetaData(); + var databaseName = "db_" + Guid.NewGuid().ToString("N"); + try + { + Assert.False(meta.DatabaseExist(databaseName)); + Assert.True(meta.CreateDatabase(databaseName)); + Assert.True(meta.CreateDatabase(databaseName)); + Assert.True(meta.DatabaseExist(databaseName)); + Assert.True(meta.DropDatabase(databaseName)); + Assert.False(meta.DatabaseExist(databaseName)); + Assert.True(meta.DropDatabase(databaseName)); + } + finally + { + if (meta.DatabaseExist(databaseName)) meta.DropDatabase(databaseName); + dal.Db.Dispose(); + } + } + + [SonnetServerTheory] + [InlineData("rest")] + [InlineData("auto")] + [InlineData("frame-http2")] + public async Task SchemaCrudAndTransactions(String protocol) + { + var connName = "SonnetRemote_" + Guid.NewGuid().ToString("N"); + var setting = protocol == "frame-http2" ? "SONNETDB_TEST_HTTP2_CONNECTION" : "SONNETDB_TEST_CONNECTION"; + var connectionString = Environment.GetEnvironmentVariable(setting) + ";Protocol=" + protocol; + DAL.AddConnStr(connName, connectionString, null, "SonnetDB"); + var dal = DAL.Create(connName); + var meta = dal.Db.CreateMetaData(); + var table = new XTable("Remote_" + Guid.NewGuid().ToString("N")); + var name = table.CreateColumn(); + name.Name = name.ColumnName = "Name"; + name.DataType = typeof(String); + name.Nullable = false; + table.Columns.Add(name); + var id = table.CreateColumn(); + id.Name = id.ColumnName = "Id"; + id.DataType = typeof(Int64); + id.PrimaryKey = id.Identity = true; + table.Columns.Add(id); + table.Fix(); + var formatted = dal.Db.FormatName(table.TableName); + try + { + Assert.True(meta.CreateTable(table)); + var actual = Assert.Single(meta.GetTables(), item => item.TableName == table.TableName); + Assert.True(actual.Columns.Single(column => column.ColumnName == "Id").Identity); + var first = await dal.InsertAndGetIdentityAsync($"INSERT INTO {formatted}(Name) VALUES(@name)", + CommandType.Text, dal.Db.CreateParameter("name", "远程 O'Brien 😀")); + Assert.True(first > 0); + var second = dal.InsertAndGetIdentity($"INSERT INTO {formatted}(Name) VALUES('second')"); + Assert.True(second > first); + var data = dal.Session.Query($"SELECT * FROM {formatted} ORDER BY Id", CommandType.Text); + Assert.Equal(2, data.Tables[0].Rows.Count); + Assert.Equal("远程 O'Brien 😀", data.Tables[0].Rows[0]["Name"]); + var page = dal.Query(dal.Db.PageSplit($"SELECT * FROM {formatted} ORDER BY Id", 1, 1, "Id")); + Assert.Single(page.Rows); + Assert.Equal("second", page.Rows[0][0]); + var session = dal.Session; + session.BeginTransaction(IsolationLevel.ReadCommitted); + var pending = session.InsertAndGetIdentity($"INSERT INTO {formatted}(Name) VALUES('pending')"); + Assert.True(pending > second); + Assert.Equal(3, session.ExecuteScalar($"SELECT COUNT(*) FROM {formatted}")); + session.Rollback(false); + Assert.Equal(2, session.ExecuteScalar($"SELECT COUNT(*) FROM {formatted}")); + session.BeginTransaction(IsolationLevel.ReadCommitted); + session.Execute($"UPDATE {formatted} SET Name='committed' WHERE Id={second}"); + session.Commit(); + Assert.Equal("committed", session.ExecuteScalar($"SELECT Name FROM {formatted} WHERE Id={second}")); + Assert.Equal(1, dal.Execute(dal.Db.BuildDeleteSql(table.TableName, "Id>0", 1))); + Assert.Equal(1, session.ExecuteScalar($"SELECT COUNT(*) FROM {formatted}")); + } + finally + { + if (dal.Session.Transaction != null) dal.Session.Rollback(false); + meta.DropTable(table); + dal.Db.Dispose(); + } + } +} + +/// 没有真实 SonnetDB Server 时明确跳过远程验收。 +public sealed class SonnetServerTheoryAttribute : TheoryAttribute +{ + public SonnetServerTheoryAttribute() + { + if (String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SONNETDB_TEST_CONNECTION")) || + String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SONNETDB_TEST_HTTP2_CONNECTION"))) + Skip = "🧪 远程验收需要 SONNETDB_TEST_CONNECTION 和 SONNETDB_TEST_HTTP2_CONNECTION。"; + } +} diff --git a/XUnitTest.XCode/DataAccessLayer/SonnetDBTests.cs b/XUnitTest.XCode/DataAccessLayer/SonnetDBTests.cs new file mode 100644 index 000000000..7e5aa5867 --- /dev/null +++ b/XUnitTest.XCode/DataAccessLayer/SonnetDBTests.cs @@ -0,0 +1,707 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using NewLife; +using XCode; +using XCode.Configuration; +using XCode.DataAccessLayer; +using XCode.Model; +using Xunit; + +namespace XUnitTest.XCode.DataAccessLayer; + +/// 通过正式 ADO.NET 驱动验证 SonnetDB 的 XCode 接入;每个用例使用独立嵌入式数据库。 +[Collection("Database")] +public class SonnetDBTests +{ + [Fact] + public void PrimitiveParametersPreserveValuesAndNulls() + { + using var scope = new DatabaseScope(); + var values = new Object[] { (SByte)(-7), (UInt16)65535, UInt32.MaxValue, (UInt64)Int64.MaxValue, '中' }; + foreach (var value in values) + { + var parameter = scope.Dal.Db.CreateParameter("value", value); + var result = scope.Dal.Session.ExecuteScalar("SELECT @value", CommandType.Text, parameter); + if (value is Char) Assert.Equal("中", result); + else Assert.Equal(Convert.ToInt64(value), Convert.ToInt64(result)); + } + var nullableGuid = scope.Dal.Db.CreateParameter("value", DBNull.Value, typeof(Guid)); + Assert.Same(DBNull.Value, nullableGuid.Value); + Assert.Throws(() => scope.Dal.Db.CreateParameter("value", UInt64.MaxValue)); + } + + [Fact] + public void UpsertUsesFormattedPhysicalKeyColumns() + { + using var scope = new DatabaseScope(); + scope.Dal.Db.NameFormat = NameFormats.Underline; + var table = CreateTable("ConflictRows"); + table.Columns[0].Name = table.Columns[0].ColumnName = "UserId"; + table.Fix(); + Assert.True(scope.Dal.Db.CreateMetaData().CreateTable(table)); + var columns = table.Columns.ToArray(); + var session = scope.Dal.Session; + Assert.Equal(1, session.Insert(table, columns, new[] { new KeyRow(7, "before") })); + Assert.Equal(1, session.Upsert(table, columns, new[] { "Name" }, null, new[] { new KeyRow(7, "after") })); + var formatted = scope.Dal.Db.FormatName(table); + Assert.Equal("after", session.ExecuteScalar($"SELECT name FROM {formatted} WHERE user_id=7")); + Assert.Equal(1, session.ExecuteScalar($"SELECT COUNT(*) FROM {formatted}")); + } + + [Fact] + public void UnboundedStringsAndViewMetadataGuard() + { + using var scope = new DatabaseScope(); + var table = CreateTable("ViewSource"); + table.Columns.Single(column => column.ColumnName == "Name").Length = 0; + var meta = scope.Dal.Db.CreateMetaData(); + Assert.True(meta.CreateTable(table)); + scope.Dal.Execute("INSERT INTO ViewSource(Name) VALUES('ordinary text')"); + var actual = Assert.Single(meta.GetTables(), item => item.TableName == "ViewSource"); + Assert.Equal("STRING", actual.Columns.Single(column => column.ColumnName == "Name").RawType); + scope.Dal.Execute("CREATE VIEW ViewProjection AS SELECT Id,Name FROM ViewSource"); + Assert.Contains("ViewProjection", meta.GetTableNames()); + var error = Assert.Throws(() => meta.GetTables()); + Assert.Contains("#261", error.Message); + } + + [Fact(Skip = "🛠️ SonnetDB 尚需补齐视图声明元数据并正式发布:https://github.com/IoTSharp/SonnetDB/issues/261")] + public void ViewMetadataRetainsDeclaredTypesWithoutReadingRows() + { + using var scope = new DatabaseScope(); + var meta = scope.Dal.Db.CreateMetaData(); + Assert.True(meta.CreateTable(CreateTable("ViewSource"))); + scope.Dal.Execute("CREATE VIEW ViewProjection AS SELECT Id,Name FROM ViewSource"); + var view = Assert.Single(meta.GetTables(), item => item.TableName == "ViewProjection"); + Assert.True(view.IsView); + Assert.Equal(new[] { "Id", "Name" }, view.Columns.Select(column => column.ColumnName)); + Assert.Equal(typeof(Int64), view.Columns[0].DataType); + Assert.Equal(typeof(String), view.Columns[1].DataType); + } + + [Fact] + public void FactoryAndDAL() + { + using var scope = new DatabaseScope(); + var dal = scope.Dal; + Assert.Equal(DatabaseType.SonnetDB, dal.DbType); + Assert.Equal(scope.ConnName, dal.ConnName); + Assert.NotNull(dal.Db.Factory.CreateCommand()); + Assert.NotNull(dal.Db.Factory.CreateParameter()); + // The provider has no DbDataAdapter; XCode must supply its DataSet query path. + Assert.Null(dal.Db.Factory.CreateDataAdapter()); + + using var connection = dal.Db.OpenConnection(); + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Equal("SonnetDB.Data.SndbConnection", connection.GetType().FullName); + Assert.NotEmpty(dal.Db.ServerVersion); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 123"; + Assert.Equal(123, Convert.ToInt32(command.ExecuteScalar())); + var dataSet = dal.Select("SELECT 123 AS Value"); + Assert.Equal(123, Convert.ToInt32(dataSet.Tables[0].Rows[0][0])); + } + + [Fact] + public void AutomaticSchemaAndReverseMetadata() + { + using var scope = new DatabaseScope(); + var table = CreateTable("MetadataRows"); + var amount = AddColumn(table, "Amount", typeof(Decimal)); + amount.Precision = 18; + amount.Scale = 4; + var enabled = AddColumn(table, "Enabled", typeof(Boolean)); + enabled.DefaultValue = "true"; + var payload = AddColumn(table, "Payload", typeof(Byte[]), true); + var time = AddColumn(table, "Created", typeof(DateTime), true); + AddIndex(table, "IU_MetadataRows_Name_Amount", true, "Name", "Amount"); + var meta = scope.Dal.Db.CreateMetaData(); + meta.SetTables(Migration.Full, table); + // Repeating the model check must leave the table usable and retain its indexes. + meta.SetTables(Migration.Full, table); + + Assert.Contains(table.TableName, meta.GetTableNames()); + var actual = Assert.Single(meta.GetTables(), e => e.TableName == table.TableName); + var id = Assert.Single(actual.Columns, e => e.ColumnName == "Id"); + Assert.True(id.PrimaryKey); + Assert.True(id.Identity); + Assert.False(id.Nullable); + Assert.Equal(typeof(Int64), id.DataType); + Assert.Equal(typeof(Decimal), actual.Columns.Single(e => e.ColumnName == amount.ColumnName).DataType); + Assert.Equal(18, actual.Columns.Single(e => e.ColumnName == amount.ColumnName).Precision); + Assert.Equal(4, actual.Columns.Single(e => e.ColumnName == amount.ColumnName).Scale); + Assert.Equal(typeof(Byte[]), actual.Columns.Single(e => e.ColumnName == payload.ColumnName).DataType); + Assert.Equal(typeof(DateTime), actual.Columns.Single(e => e.ColumnName == time.ColumnName).DataType); + var actualEnabled = actual.Columns.Single(e => e.ColumnName == enabled.ColumnName); + Assert.Equal(typeof(Boolean), actualEnabled.DataType); + Assert.False(String.IsNullOrWhiteSpace(actualEnabled.DefaultValue)); + var unique = Assert.Single(actual.Indexes, e => e.Name == "IU_MetadataRows_Name_Amount"); + Assert.True(unique.Unique); + Assert.Equal(new[] { "Name", "Amount" }, unique.Columns); + + scope.Dal.Execute("INSERT INTO MetadataRows (Name,Amount) VALUES ('default',1)"); + Assert.True(scope.Dal.Session.ExecuteScalar("SELECT Enabled FROM MetadataRows WHERE Name='default'")); + Assert.True(meta.DropTable(table)); + Assert.DoesNotContain(table.TableName, meta.GetTableNames()); + } + + [Fact] + public async Task IdentityDoesNotDependOnColumnOrder() + { + using var scope = new DatabaseScope(); + var table = CreateTable("IdentityRows"); + // A text field precedes the identity field in the physical table. + var idColumn = table.Columns[0]; + table.Columns.RemoveAt(0); + table.Columns.Add(idColumn); + scope.Dal.Db.CreateMetaData().SetTables(Migration.Full, table); + var id = scope.Dal.InsertAndGetIdentity("INSERT INTO IdentityRows (Name) VALUES ('first')"); + Assert.True(id > 0); + Assert.Equal(id, scope.Dal.Session.ExecuteScalar("SELECT Id FROM IdentityRows WHERE Name='first'")); + var next = await scope.Dal.InsertAndGetIdentityAsync("INSERT INTO IdentityRows (Name) VALUES ('second')"); + Assert.True(next > id); + Assert.Equal(next, scope.Dal.Session.ExecuteScalar("SELECT Id FROM IdentityRows WHERE Name='second'")); + } + + [Fact] + public async Task EntityCRUDAndTypedRoundtrip() + { + using var scope = new DatabaseScope(); + using var split = scope.CreateEntitySplit(); + var entity = new SonnetTestRow + { + Name = "中文😀 O'Brien", + Amount = 123456789.1234m, + Payload = new Byte[] { 0, 1, 127, 128, 255 }, + Enabled = true, + Created = new DateTime(2026, 10, 8, 12, 34, 56).AddMilliseconds(123), + Remark = null + }; + Assert.Equal(1, entity.Insert()); + Assert.True(entity.Id > 0); + var loaded = Assert.Single(SonnetTestRow.FindAll()); + Assert.Equal(entity.Id, loaded.Id); + Assert.Equal(entity.Name, loaded.Name); + Assert.Equal(entity.Amount, loaded.Amount); + Assert.Equal(entity.Payload, loaded.Payload); + Assert.Equal(entity.Enabled, loaded.Enabled); + Assert.Equal(entity.Created, loaded.Created); + Assert.Null(loaded.Remark); + + loaded.Amount = -12.3456m; + loaded.Remark = "更新"; + Assert.Equal(1, loaded.Update()); + var updated = Assert.Single(SonnetTestRow.FindAll()); + Assert.Equal(-12.3456m, updated.Amount); + Assert.Equal("更新", updated.Remark); + Assert.Equal(1, updated.Delete()); + Assert.Empty(SonnetTestRow.FindAll()); + + var asyncRow = new SonnetTestRow { Name = "async", Amount = 0.0001m, Created = entity.Created }; + Assert.Equal(1, await asyncRow.InsertAsync()); + Assert.True(asyncRow.Id > entity.Id); + asyncRow.Remark = "异步"; + Assert.Equal(1, await asyncRow.UpdateAsync()); + Assert.Equal("异步", Assert.Single(SonnetTestRow.FindAll()).Remark); + Assert.Equal(1, await asyncRow.DeleteAsync()); + Assert.Empty(SonnetTestRow.FindAll()); + } + + [Fact] + public async Task PagingAggregatesAndParameters() + { + using var scope = new DatabaseScope(); + using var split = scope.CreateEntitySplit(); + var rows = Enumerable.Range(1, 5).Select(i => new SonnetTestRow { Name = $"row{i}", Amount = i, Created = new DateTime(2026, 10, 8) }).ToList(); + Assert.Equal(5, rows.BatchInsert()); + var page = SonnetTestRow.FindAll((String)null, "Id ASC", null, 2, 2); + Assert.Equal(new[] { "row3", "row4" }, page.Select(e => e.Name)); + var remaining = SonnetTestRow.FindAll((String)null, "Id ASC", null, 3, 0); + Assert.Equal(new[] { "row4", "row5" }, remaining.Select(e => e.Name)); + + var result = scope.Dal.Query($"SELECT COUNT(*), SUM(Amount) FROM {scope.TableName}"); + var aggregate = Assert.Single(result.Rows); + Assert.Equal(5, Convert.ToInt32(aggregate[0])); + Assert.Equal(15m, Convert.ToDecimal(aggregate[1])); + + const String value = "中文 ' OR 1=1 -- 😀"; + Assert.Equal(1, scope.Dal.Execute($"UPDATE {scope.TableName} SET Remark=@remark WHERE Name=@name", CommandType.Text, + new Dictionary { ["remark"] = value, ["name"] = "row3" })); + var selected = await scope.Dal.QueryAsync($"SELECT Name,Remark FROM {scope.TableName} WHERE Remark=@remark", + new Dictionary { ["remark"] = value }); + var record = Assert.Single(selected.Rows); + Assert.Equal("row3", record[0]); + Assert.Equal(value, record[1]); + } + + [Fact] + public void BatchInsertIgnoreAndUpsert() + { + using var scope = new DatabaseScope(); + using var split = scope.CreateEntitySplit(); + var rows = new List + { + new() { Name = "first", Amount = 1m, Remark = "original", Created = new DateTime(2026, 10, 8) }, + new() { Name = "second", Amount = 2m, Created = new DateTime(2026, 10, 8) } + }; + Assert.Equal(2, rows.BatchInsert()); + var firstId = SonnetTestRow.FindAll().Single(e => e.Name == "first").Id; + var ignored = new List + { + new() { Name = "first", Amount = 99m }, + new() { Name = "third", Amount = 3m } + }; + Assert.Equal(1, ignored.BatchInsertIgnore()); + Assert.Equal(1m, SonnetTestRow.FindAll().Single(e => e.Name == "first").Amount); + + var upserted = new List + { + new() { Name = "first", Amount = 4m, Remark = "upsert" }, + new() { Name = "fourth", Amount = 4m, Remark = "insert" } + }; + Assert.True(upserted.BatchUpsert(new BatchOption { UpdateColumns = new[] { "Remark" }, AddColumns = new[] { "Amount" } }) > 0); + var all = SonnetTestRow.FindAll(); + Assert.Equal(4, all.Count); + var first = all.Single(e => e.Name == "first"); + Assert.Equal(firstId, first.Id); + Assert.Equal(5m, first.Amount); + Assert.Equal("upsert", first.Remark); + Assert.Contains(all, e => e.Name == "fourth" && e.Amount == 4m && e.Remark == "insert"); + } + + [Fact] + public void UnsupportedReplaceFailsClearly() + { + using var scope = new DatabaseScope(); + using var split = scope.CreateEntitySplit(); + var row = new SonnetTestRow { Name = "retained", Remark = "original" }; + Assert.Equal(1, row.Insert()); + var replaced = new List { new() { Name = "retained", Remark = "replacement" } }; + Assert.Throws(() => replaced.BatchReplace()); + Assert.Equal("original", Assert.Single(SonnetTestRow.FindAll()).Remark); + } + + [Fact(Skip = "🛠️ SonnetDB 尚需实现原子 REPLACE 并正式发布:https://github.com/IoTSharp/SonnetDB/issues/242")] + public void BatchReplaceDeletesThenInserts() + { + using var scope = new DatabaseScope(); + using var split = scope.CreateEntitySplit(); + var original = new SonnetTestRow { Name = "first", Amount = 1m, Remark = "original", Created = new DateTime(2026, 10, 8) }; + Assert.Equal(1, original.Insert()); + var replaced = new List { new() { Name = "first", Amount = 10m, Created = original.Created } }; + Assert.True(replaced.BatchReplace(new BatchOption { FullInsert = true }) > 0); + var replacement = Assert.Single(SonnetTestRow.FindAll()); + Assert.Equal(10m, replacement.Amount); + Assert.Null(replacement.Remark); + Assert.NotEqual(original.Id, replacement.Id); + } + + [Fact] + public void UnsupportedDecimalMigrationFailsClearly() + { + using var scope = new DatabaseScope(); + var table = CreateTable("DecimalGuardRows"); + var amount = AddColumn(table, "Amount", typeof(Decimal), true); + amount.Precision = 18; + amount.Scale = 4; + var meta = scope.Dal.Db.CreateMetaData(); + meta.SetTables(Migration.Full, table); + scope.Dal.Execute("INSERT INTO DecimalGuardRows (Name,Amount) VALUES ('retained',123.1234)"); + var extra = AddColumn(table, "Extra", typeof(Decimal), true); + extra.Precision = 18; + extra.Scale = 4; + Assert.Throws(() => meta.AddColumn(extra)); + var old = amount.Clone(table); + amount.Precision = 20; + amount.Scale = 6; + Assert.Throws(() => meta.AlterColumn(amount, old)); + Assert.Equal(123.1234m, scope.Dal.Session.ExecuteScalar("SELECT Amount FROM DecimalGuardRows")); + Assert.DoesNotContain(meta.GetTables().Single(e => e.TableName == table.TableName).Columns, e => e.ColumnName == "Extra"); + } + + [Fact] + public void ValuesThatWouldLosePrecisionFailClearly() + { + using var scope = new DatabaseScope(); + var table = new XTable("PrecisionGuardRows"); + var number = AddColumn(table, "Amount", typeof(Decimal)); + const Decimal unsafeAmount = 9007199254740993.123456789m; + Assert.Throws(() => scope.Dal.Db.CreateParameter("amount", unsafeAmount)); + Assert.Throws(() => scope.Dal.Db.FormatValue(number, unsafeAmount)); + var timestamp = AddColumn(table, "Created", typeof(DateTime)); + var unsafeTime = new DateTime(2026, 10, 8, 12, 34, 56).AddTicks(1); + Assert.Throws(() => scope.Dal.Db.CreateParameter("time", unsafeTime)); + Assert.Throws(() => scope.Dal.Db.FormatValue(timestamp, unsafeTime)); + var unsafeOffset = new DateTimeOffset(unsafeTime, TimeSpan.FromHours(8)); + Assert.Throws(() => scope.Dal.Db.CreateParameter("offset", unsafeOffset)); + var unsigned = AddColumn(table, "Unsigned", typeof(UInt64)); + Assert.Throws(() => scope.Dal.Db.CreateParameter("unsigned", UInt64.MaxValue)); + Assert.Throws(() => scope.Dal.Db.FormatValue(unsigned, UInt64.MaxValue)); + } + + [Fact(Skip = "🛠️ SonnetDB 尚需修复高精度 decimal 与亚毫秒 DATETIME 保真并正式发布:https://github.com/IoTSharp/SonnetDB/issues/235 https://github.com/IoTSharp/SonnetDB/issues/236")] + public void HighPrecisionValuesRoundtrip() + { + using var scope = new DatabaseScope(); + var table = CreateTable("HighPrecisionRows"); + var amount = AddColumn(table, "Amount", typeof(Decimal)); + amount.Precision = 38; + amount.Scale = 20; + AddColumn(table, "Created", typeof(DateTime)); + scope.Dal.Db.CreateMetaData().SetTables(Migration.Full, table); + const Decimal value = 9007199254740993.123456789m; + var time = new DateTime(2026, 10, 8, 12, 34, 56).AddTicks(1234567); + scope.Dal.Execute("INSERT INTO HighPrecisionRows (Name,Amount,Created) VALUES (@name,@amount,@created)", CommandType.Text, + new Dictionary { ["name"] = "precision", ["amount"] = value, ["created"] = time }); + var record = Assert.Single(scope.Dal.Query("SELECT Amount,Created FROM HighPrecisionRows").Rows); + Assert.Equal(value, Convert.ToDecimal(record[0])); + Assert.Equal(time, Convert.ToDateTime(record[1])); + } + + [Fact(Skip = "🛠️ SonnetDB 尚需修复 DECIMAL ADD/ALTER 精度并正式发布:https://github.com/IoTSharp/SonnetDB/issues/241")] + public void DecimalColumnMigrationRetainsPrecision() + { + using var scope = new DatabaseScope(); + var table = CreateTable("DecimalMigrationRows"); + var meta = scope.Dal.Db.CreateMetaData(); + meta.SetTables(Migration.Full, table); + var amount = AddColumn(table, "Amount", typeof(Decimal), true); + amount.Precision = 18; + amount.Scale = 4; + Assert.True(meta.AddColumn(amount)); + scope.Dal.Execute("INSERT INTO DecimalMigrationRows (Name,Amount) VALUES ('precision',123456.1234)"); + var old = amount.Clone(table); + amount.Precision = 20; + amount.Scale = 6; + Assert.True(meta.AlterColumn(amount, old)); + Assert.Equal(123456.1234m, scope.Dal.Session.ExecuteScalar("SELECT Amount FROM DecimalMigrationRows")); + var actual = meta.GetTables().Single(e => e.TableName == table.TableName).Columns.Single(e => e.ColumnName == "Amount"); + Assert.Equal(20, actual.Precision); + Assert.Equal(6, actual.Scale); + } + + [Fact] + public void TransactionCommitAndRollback() + { + using var scope = new DatabaseScope(); + var table = CreateTable("TransactionRows"); + scope.Dal.Db.CreateMetaData().SetTables(Migration.Full, table); + var session = scope.Dal.Session; + session.BeginTransaction(IsolationLevel.ReadCommitted); + session.Execute("INSERT INTO TransactionRows (Name) VALUES ('committed')"); + session.Commit(); + Assert.Equal(1, session.ExecuteScalar("SELECT COUNT(*) FROM TransactionRows")); + session.BeginTransaction(IsolationLevel.ReadCommitted); + session.Execute("UPDATE TransactionRows SET Name='changed' WHERE Name='committed'"); + session.Execute("INSERT INTO TransactionRows (Name) VALUES ('rolled-back')"); + session.Rollback(false); + Assert.Equal(1, session.ExecuteScalar("SELECT COUNT(*) FROM TransactionRows")); + Assert.Equal("committed", session.ExecuteScalar("SELECT Name FROM TransactionRows")); + } + + [Fact] + public void SchemaEvolutionPreservesData() + { + using var scope = new DatabaseScope(); + var table = CreateTable("MigrationRows"); + var meta = scope.Dal.Db.CreateMetaData(); + meta.SetTables(Migration.Full, table); + scope.Dal.Execute("INSERT INTO MigrationRows (Name) VALUES ('retained')"); + var extra = AddColumn(table, "Extra", typeof(String), true, 30); + Assert.True(meta.AddColumn(extra)); + scope.Dal.Execute("UPDATE MigrationRows SET Extra='column-data' WHERE Name='retained'"); + var before = extra.Clone(table); + extra.Nullable = false; + extra.DefaultValue = "'default-extra'"; + Assert.True(meta.AlterColumn(extra, before)); + var index = AddIndex(table, "IX_MigrationRows_Extra", false, "Extra"); + Assert.True(meta.CreateIndex(index)); + var actual = meta.GetTables().Single(e => e.TableName == table.TableName); + Assert.Contains(actual.Columns, e => e.ColumnName == "Extra"); + Assert.False(actual.Columns.Single(e => e.ColumnName == "Extra").Nullable); + Assert.Contains("default-extra", actual.Columns.Single(e => e.ColumnName == "Extra").DefaultValue); + Assert.Contains(actual.Indexes, e => e.Name == index.Name); + Assert.Equal("column-data", scope.Dal.Session.ExecuteScalar("SELECT Extra FROM MigrationRows")); + scope.Dal.Execute("INSERT INTO MigrationRows (Name) VALUES ('default-check')"); + Assert.Equal("default-extra", scope.Dal.Session.ExecuteScalar("SELECT Extra FROM MigrationRows WHERE Name='default-check'")); + Assert.True(meta.DropIndex(index)); + Assert.True(meta.DropColumn(extra)); + actual = meta.GetTables().Single(e => e.TableName == table.TableName); + Assert.DoesNotContain(actual.Columns, e => e.ColumnName == "Extra"); + Assert.DoesNotContain(actual.Indexes, e => e.Name == index.Name); + Assert.Equal("retained", scope.Dal.Session.ExecuteScalar("SELECT Name FROM MigrationRows WHERE Name='retained'")); + } + + [Fact] + public void UnsupportedLengthAndCommentsFailClearly() + { + using var scope = new DatabaseScope(); + var table = CreateTable("UnsupportedSchemaRows"); + var meta = scope.Dal.Db.CreateMetaData(); + meta.SetTables(Migration.Full, table); + var name = table.Columns.Single(e => e.ColumnName == "Name"); + var old = name.Clone(table); + name.Length = 200; + Assert.Throws(() => meta.AlterColumn(name, old)); + table.Description = "需要保留的表说明"; + name.Description = "需要保留的字段说明"; + Assert.Throws(() => meta.AddTableDescription(table)); + Assert.Throws(() => meta.AddColumnDescription(name)); + } + + [Fact] + public void EntityLinqUsesFilteringOrderingAndPaging() + { + using var scope = new DatabaseScope(); + using var split = scope.CreateEntitySplit(); + var rows = Enumerable.Range(1, 5).Select(i => new SonnetTestRow { Name = $"linq{i}", Amount = i }).ToList(); + Assert.Equal(5, rows.BatchInsert()); + var selected = SonnetTestRow.Query.Where(e => e.Amount >= 2m).OrderByDescending(e => e.Amount).Skip(1).Take(2).ToList(); + Assert.Equal(new[] { "linq4", "linq3" }, selected.Select(e => e.Name)); + Assert.Equal(4, SonnetTestRow.Query.Count(e => e.Amount >= 2m)); + } + + [Fact] + public void JoinAndWindowQueries() + { + using var scope = new DatabaseScope(); + var parents = CreateTable("JoinParents"); + var children = CreateTable("JoinChildren"); + AddColumn(children, "ParentId", typeof(Int64)); + var meta = scope.Dal.Db.CreateMetaData(); + meta.SetTables(Migration.Full, parents, children); + var first = scope.Dal.InsertAndGetIdentity("INSERT INTO JoinParents (Name) VALUES ('first')"); + scope.Dal.InsertAndGetIdentity("INSERT INTO JoinParents (Name) VALUES ('second')"); + scope.Dal.Execute($"INSERT INTO JoinChildren (Name,ParentId) VALUES ('child',{first})"); + var joined = scope.Dal.Query("SELECT p.Name,c.Name FROM JoinParents p LEFT JOIN JoinChildren c ON p.Id=c.ParentId ORDER BY p.Id"); + Assert.Equal(2, joined.Rows.Count); + Assert.Equal("first", joined.Rows[0][0]); + Assert.Equal("child", joined.Rows[0][1]); + Assert.Equal("second", joined.Rows[1][0]); + Assert.True(joined.Rows[1][1] == null || joined.Rows[1][1] == DBNull.Value); + var builder = new SelectBuilder { Table = "JoinParents", Column = "Name", OrderBy = "Id", Window = WindowFunction.RowNumber("Id") }; + var numbered = scope.Dal.Query(builder, 0, 0); + Assert.Equal(2, numbered.Rows.Count); + Assert.Equal(1, Convert.ToInt32(numbered.Rows[0][1])); + Assert.Equal(2, Convert.ToInt32(numbered.Rows[1][1])); + } + + [Fact] + public void TablePrefixWorksForEntitySchemaAndCRUD() + { + using var scope = new DatabaseScope("prefix_"); + using var split = scope.CreateEntitySplit(); + var row = new SonnetTestRow { Name = "prefixed", Amount = 1m }; + Assert.Equal(1, row.Insert()); + Assert.Equal("prefixed", Assert.Single(SonnetTestRow.FindAll()).Name); + var names = scope.Dal.Db.CreateMetaData().GetTableNames(); + Assert.Contains("prefix_" + scope.TableName, names); + Assert.DoesNotContain(scope.TableName, names); + row.Remark = "updated"; + Assert.Equal(1, row.Update()); + Assert.Equal("updated", Assert.Single(SonnetTestRow.FindAll()).Remark); + Assert.Equal(1, row.Delete()); + Assert.Empty(SonnetTestRow.FindAll()); + } + + [Fact] + public void BoundedEntityDeleteRespectsMaximumRows() + { + using var scope = new DatabaseScope(); + using var split = scope.CreateEntitySplit(); + var rows = Enumerable.Range(1, 5).Select(i => new SonnetTestRow { Name = $"delete{i}" }).ToList(); + Assert.Equal(5, rows.BatchInsert()); + Assert.Equal(2, SonnetTestRow.Delete(SonnetTestRow.Meta.Table.FindByName("Id") > 0, 2)); + Assert.Equal(3, SonnetTestRow.FindAll().Count); + } + + [Fact] + public void EntityDeleteEscapesApostrophesAndKeepsOtherRows() + { + using var scope = new DatabaseScope(); + using var split = scope.CreateEntitySplit(); + var rows = new List { new() { Name = "O'Brien" }, new() { Name = "OBrien" } }; + Assert.Equal(2, rows.BatchInsert()); + Assert.Equal(1, SonnetTestRow.Delete(SonnetTestRow.Meta.Table.FindByName("Name") == "O'Brien", 1)); + Assert.Equal("OBrien", Assert.Single(SonnetTestRow.FindAll()).Name); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void UnsupportedBoundedDeleteWithoutSinglePrimaryKeyFailsClearly(Boolean composite) + { + using var scope = new DatabaseScope(); + var table = CreateTable("DeleteGuardRows"); + var id = table.Columns.Single(e => e.ColumnName == "Id"); + id.Identity = false; + id.PrimaryKey = composite; + if (composite) + { + var other = AddColumn(table, "Other", typeof(Int64)); + other.PrimaryKey = true; + } + scope.Dal.Db.CreateMetaData().SetTables(Migration.Full, table); + if (composite) scope.Dal.Execute("INSERT INTO DeleteGuardRows (Id,Name,Other) VALUES (1,'retained',2)"); + Assert.Throws(() => scope.Dal.Db.BuildDeleteSql(table.TableName, "Id>0", 1)); + Assert.Equal(composite ? 1 : 0, scope.Dal.Session.ExecuteScalar("SELECT COUNT(*) FROM DeleteGuardRows")); + } + + [Fact] + public void TruncateRetainsSchemaAndResetsIdentity() + { + using var scope = new DatabaseScope(); + var table = CreateTable("TruncateRows"); + scope.Dal.Db.CreateMetaData().SetTables(Migration.Full, table); + var first = scope.Dal.InsertAndGetIdentity("INSERT INTO TruncateRows (Name) VALUES ('before')"); + scope.Dal.Session.Truncate(table.TableName); + Assert.Equal(0, scope.Dal.Session.ExecuteScalar("SELECT COUNT(*) FROM TruncateRows")); + var next = scope.Dal.InsertAndGetIdentity("INSERT INTO TruncateRows (Name) VALUES ('after')"); + Assert.Equal(first, next); + Assert.Contains(table.TableName, scope.Dal.Db.CreateMetaData().GetTableNames()); + } + + private sealed class KeyRow : NewLife.Data.IModel + { + private readonly Dictionary _values; + public KeyRow(Int64 id, String name) => _values = new() { ["UserId"] = id, ["Name"] = name }; + public Object this[String name] { get => _values[name]; set => _values[name] = value; } + } + + private static IDataTable CreateTable(String name) + { + var table = new XTable(name) { Name = name }; + var id = AddColumn(table, "Id", typeof(Int64)); + id.Identity = true; + id.PrimaryKey = true; + AddColumn(table, "Name", typeof(String), false, 100); + return table; + } + + private static IDataColumn AddColumn(IDataTable table, String name, Type type, Boolean nullable = false, Int32 length = 0) + { + var column = table.CreateColumn(); + column.Name = column.ColumnName = name; + column.DataType = type; + column.Nullable = nullable; + column.Length = length; + table.Columns.Add(column); + return column; + } + + private static IDataIndex AddIndex(IDataTable table, String name, Boolean unique, params String[] columns) + { + var index = table.CreateIndex(); + index.Name = name; + index.Unique = unique; + index.Columns = columns; + table.Indexes.Add(index); + return index; + } + + private sealed class DatabaseScope : IDisposable + { + private readonly String _directory = Path.Combine(Path.GetTempPath(), "xcode-sonnetdb", Guid.NewGuid().ToString("N")); + public String ConnName { get; } = "SonnetDB_" + Guid.NewGuid().ToString("N"); + public String TableName { get; } = "SonnetRows_" + Guid.NewGuid().ToString("N"); + public DAL Dal { get; } + + public DatabaseScope(String prefix = null) + { + Directory.CreateDirectory(_directory); + DAL.AddConnStr(ConnName, $"Data Source={_directory};Migration=Full;TablePrefix={prefix}", null, "SonnetDB"); + Dal = DAL.Create(ConnName); + } + + public IDisposable CreateEntitySplit() + { + var split = SonnetTestRow.Meta.CreateSplit(ConnName, TableName); + var model = (IDataTable)SonnetTestRow.Meta.Table.DataTable.Clone(); + model.TableName = TableName; + Dal.Db.CreateMetaData().SetTables(Migration.Full, model); + return split; + } + + public void Dispose() + { + Dal.Session.Dispose(); + Dal.Db.Dispose(); + if (Directory.Exists(_directory)) Directory.Delete(_directory, true); + } + } +} + +/// 仅用于 SonnetDB 集成测试,避免修改 Membership 等全局实体的连接。 +[BindTable("SonnetTestRow", ConnName = "SonnetDB_Test", DbType = DatabaseType.None)] +[BindIndex("IU_SonnetTestRow_Name", true, "Name")] +public class SonnetTestRow : Entity +{ + private Int64 _id; + [DataObjectField(true, true, false, 0)] + [BindColumn("Id", "", "")] + public Int64 Id { get => _id; set { if (OnPropertyChanging(nameof(Id), value)) { _id = value; OnPropertyChanged(nameof(Id)); } } } + + private String _name; + [DataObjectField(false, false, false, 100)] + [BindColumn("Name", "", "")] + public String Name { get => _name; set { if (OnPropertyChanging(nameof(Name), value)) { _name = value; OnPropertyChanged(nameof(Name)); } } } + + private Decimal _amount; + [DataObjectField(false, false, false, 0)] + [BindColumn("Amount", "", "decimal(18,4)", Precision = 18, Scale = 4)] + public Decimal Amount { get => _amount; set { if (OnPropertyChanging(nameof(Amount), value)) { _amount = value; OnPropertyChanged(nameof(Amount)); } } } + + private Byte[] _payload; + [DataObjectField(false, false, true, 0)] + [BindColumn("Payload", "", "")] + public Byte[] Payload { get => _payload; set { if (OnPropertyChanging(nameof(Payload), value)) { _payload = value; OnPropertyChanged(nameof(Payload)); } } } + + private Boolean _enabled; + [DataObjectField(false, false, false, 0)] + [BindColumn("Enabled", "", "")] + public Boolean Enabled { get => _enabled; set { if (OnPropertyChanging(nameof(Enabled), value)) { _enabled = value; OnPropertyChanged(nameof(Enabled)); } } } + + private DateTime _created; + [DataObjectField(false, false, true, 0)] + [BindColumn("Created", "", "")] + public DateTime Created { get => _created; set { if (OnPropertyChanging(nameof(Created), value)) { _created = value; OnPropertyChanged(nameof(Created)); } } } + + private String _remark; + [DataObjectField(false, false, true, 200)] + [BindColumn("Remark", "", "")] + public String Remark { get => _remark; set { if (OnPropertyChanging(nameof(Remark), value)) { _remark = value; OnPropertyChanged(nameof(Remark)); } } } + + public override Object this[String name] + { + get => name switch + { + nameof(Id) => _id, + nameof(Name) => _name, + nameof(Amount) => _amount, + nameof(Payload) => _payload, + nameof(Enabled) => _enabled, + nameof(Created) => _created, + nameof(Remark) => _remark, + _ => base[name] + }; + set + { + switch (name) + { + case nameof(Id): _id = Convert.ToInt64(value); break; + case nameof(Name): _name = value as String; break; + case nameof(Amount): _amount = Convert.ToDecimal(value); break; + case nameof(Payload): _payload = value as Byte[]; break; + case nameof(Enabled): _enabled = Convert.ToBoolean(value); break; + case nameof(Created): _created = value == null || value == DBNull.Value ? default : Convert.ToDateTime(value); break; + case nameof(Remark): _remark = value as String; break; + default: base[name] = value; break; + } + } + } +} diff --git a/XUnitTest.XCode/XUnitTest.XCode.csproj b/XUnitTest.XCode/XUnitTest.XCode.csproj index 64d9f5e69..523aad620 100644 --- a/XUnitTest.XCode/XUnitTest.XCode.csproj +++ b/XUnitTest.XCode/XUnitTest.XCode.csproj @@ -105,6 +105,7 @@ +