From 7bd6b6cdb82147007e72224f8d365c44625a7160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 13:36:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20SQL=20Server=20?= =?UTF-8?q?=E6=90=AC=E7=A7=BB=E5=8F=83=E6=95=B8=E5=9E=8B=E5=88=A5=E8=88=87?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=E9=A1=8D=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit time 不再套用會轉成 DateTime 的 DbType,NULL 使用 SqlDbType.Time;binary 依欄位型別保留 NULL 與空值。資料參數上限改為 2098,替 RPC 保留額度,避免七欄批次撞上引擎限制。新增連結正式來源的 2019 與 2022 隔離雙向回歸,可保存來源雜湊與容器清理證據,不存取真實 profile。 --- CHANGELOG.md | 1 + mySQLPunk.CrossPlatform.SmokeTests/Program.cs | 1 + mySQLPunk/lib/SqlServerBatchInsert.cs | 22 ++- tests/Run-SqlServerVersionMigration.py | 69 +++++++++ tests/SqlServerBatchInsertTests.cs | 27 +++- .../Program.cs | 139 ++++++++++++++++++ .../README.md | 15 ++ .../Review.csproj | 16 ++ 8 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 tests/Run-SqlServerVersionMigration.py create mode 100644 tests/SqlServerVersionMigrationHarness/Program.cs create mode 100644 tests/SqlServerVersionMigrationHarness/README.md create mode 100644 tests/SqlServerVersionMigrationHarness/Review.csproj diff --git a/CHANGELOG.md b/CHANGELOG.md index acd2d60..350ad14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ - **Linux/macOS SSH Tunnel**:跨平台連線設定可勾選「透過 SSH Tunnel 連線」,填入 SSH 主機、port、使用者、密碼或 OpenSSH/PEM 私鑰(含密語)與必填的主機金鑰 SHA256 指紋;連線時以 SSH.NET 建立只綁定 127.0.0.1 動態 port 的本機轉送,MySQL/MariaDB、PostgreSQL、SQL Server 的所有查詢與資料表操作都改走轉送端點,session 中斷或程式關閉時一併關閉;SSH 連線被跳板機中斷(閒置逾時、重啟、網路中斷)後,下一次資料庫操作會以同樣的指紋規則自動重建轉送,而不是把死掉的端點交給驅動程式。主機金鑰與指紋完全比對、不符即中止並回報伺服器實際指紋;MD5 或格式錯誤的指紋、缺主機/使用者/指紋、非絕對路徑私鑰、私鑰權限過寬與 MySQL/PostgreSQL 的 VerifyFull(127.0.0.1 主機名稱必定不符)一律 fail closed,SQL Server 則改以原始主機名稱驗證憑證。SSH 密碼與私鑰密語不寫入 `connections.json`(設定檔含這兩個欄位會拒絕載入),勾選系統密碼庫時與資料庫密碼一樣以獨立項目存入 Linux Secret Service/macOS Keychain,否則只保留到本次程式關閉;smoke test 會以本機臨時 sshd 實機驗證金鑰登入、加密私鑰、錯誤密語、指紋不符、權限過寬與 provider session 整合,實機矩陣另會經 tunnel 對 MySQL 8/MariaDB 11.4(Required)、PostgreSQL 16(VerifyCA+CA)與 SQL Server 2022(Mandatory+伺服器憑證,以原始主機名稱驗證)實際查詢與列出資料庫。 ### 🛠️ 問題修正與優化 +- **SQL Server 搬移參數**:TimeSpan 不再被 DbType.Time 轉成 DateTime,NULL time 明確保留 SQL time;binary 欄位的 NULL 不再推導成 nvarchar。每批資料參數改為 2098,保留 RPC 額度。新增正式來源 SQL Server 2019 ↔ 2022 隔離雙向回歸與末批失敗/安全重試測試,不宣稱 PK/FK/index/identity 搬移或所有版本已驗證。 - 邏輯搬移先取得 byte length 與 SQL 限額投影再讀取 BLOB/文字,避免超過 1 MiB 才拒絕時已配置整個大陣列;單欄與累計預算在 materialize 前檢查,超限保持空目標。 - SQLite SQL 與執行計畫改在背景執行,長查詢透過原生 progress handler 中斷,不再凍結 UI;取消後等實際語句停止才釋放操作鎖。取消訊息明示先前已提交語句不會自動回滾,避免盲目重試。 diff --git a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs index 6ae711e..6d2f01a 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/Program.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/Program.cs @@ -51,6 +51,7 @@ static Task BatchInsertAsync() using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=:memory:"); connection.Open(); SqlServerBatchInsertTests.Run(connection); + SqlServerBatchInsertTests.RunParameterTypes(() => new Microsoft.Data.SqlClient.SqlParameter()); return Task.CompletedTask; } diff --git a/mySQLPunk/lib/SqlServerBatchInsert.cs b/mySQLPunk/lib/SqlServerBatchInsert.cs index f089cf7..c826d41 100644 --- a/mySQLPunk/lib/SqlServerBatchInsert.cs +++ b/mySQLPunk/lib/SqlServerBatchInsert.cs @@ -9,9 +9,23 @@ public static class SqlServerBatchInsert { public static int RowsPerCommand(int columnCount) { - if (columnCount < 1 || columnCount > 2100) + if (columnCount < 1 || columnCount > 2098) throw new ArgumentOutOfRangeException("columnCount"); - return Math.Min(1000, 2100 / columnCount); + return Math.Min(1000, 2098 / columnCount); + } + + internal static void SetParameterValue(DbParameter parameter, Type columnType, object value) + { + parameter.Value = value; + if (columnType == typeof(DateTime)) parameter.DbType = DbType.DateTime2; + if (columnType == typeof(byte[])) parameter.DbType = DbType.Binary; + if (columnType != typeof(TimeSpan)) return; + var sqlType = parameter.GetType().GetProperty("SqlDbType"); + if (sqlType != null && sqlType.CanWrite && sqlType.PropertyType == typeof(SqlDbType)) + { + if (value == DBNull.Value) sqlType.SetValue(parameter, SqlDbType.Time, null); + } + else parameter.DbType = DbType.Time; } public static void Execute(DbConnection connection, string qualifiedTable, DataTable rows) @@ -50,9 +64,7 @@ public static void Execute(DbConnection connection, string qualifiedTable, DataT values.Add(name); DbParameter parameter = command.CreateParameter(); parameter.ParameterName = name; - if (rows.Columns[columnIndex].DataType == typeof(DateTime)) parameter.DbType = DbType.DateTime2; - if (rows.Columns[columnIndex].DataType == typeof(TimeSpan)) parameter.DbType = DbType.Time; - parameter.Value = rows.Rows[offset + rowIndex][columnIndex]; + SetParameterValue(parameter, rows.Columns[columnIndex].DataType, rows.Rows[offset + rowIndex][columnIndex]); command.Parameters.Add(parameter); } groups.Add("(" + string.Join(",", values) + ")"); diff --git a/tests/Run-SqlServerVersionMigration.py b/tests/Run-SqlServerVersionMigration.py new file mode 100644 index 0000000..076acd9 --- /dev/null +++ b/tests/Run-SqlServerVersionMigration.py @@ -0,0 +1,69 @@ +import hashlib +import json +import os +from pathlib import Path +import secrets +import subprocess +import sys +import time +import uuid + +root = Path(__file__).resolve().parents[1] +output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path('/tmp') / ('mysqlpunk-sql-version-' + uuid.uuid4().hex) +output.mkdir(parents=True, exist_ok=False) +project = root / 'tests/SqlServerVersionMigrationHarness/Review.csproj' +subprocess.run(['dotnet', 'build', str(project), '-c', 'Release'], check=True) +key = 'mysqlpunk-version-' + uuid.uuid4().hex +environment = os.environ.copy() +environment['MSSQL_SA_PASSWORD'] = 'Aa!' + secrets.token_hex(18) +environment['MYSQLPUNK_TEST_PASSWORD'] = environment['MSSQL_SA_PASSWORD'] +containers = [] +metadata = {'sources': {}, 'images': []} +for path in [*project.parent.glob('*.cs'), root / 'tests/SqlServerBatchInsertTests.cs', project, Path(__file__).resolve(), *[root / 'mySQLPunk/lib' / name for name in + ('SqlServerBatchInsert.cs', 'SqlServerCompatibilityMigration.cs', 'SqlServerMigrationSession.cs', + 'SqlServerMigrationCatalogReader.cs', 'MssqlExport/MssqlOwnedSession.cs')]]: + metadata['sources'][str(path.relative_to(root))] = hashlib.sha256(path.read_bytes()).hexdigest() + +def docker(*arguments): + result = subprocess.run(['docker', *arguments], env=environment, text=True, capture_output=True) + if result.returncode: + raise RuntimeError('Docker operation failed: ' + arguments[0]) + return result.stdout.strip() + +try: + ports = [] + for year in (2019, 2022): + image = json.loads(docker('image', 'inspect', 'mcr.microsoft.com/mssql/server:' + str(year) + '-latest', '--format', '{{json .}}')) + container = docker('run', '-d', '--name', key + '-' + str(year), '--label', 'mysqlpunk.test.owner=' + key, + '--cpus=2', '--memory=2g', '--pids-limit=512', '--tmpfs', '/var/opt/mssql:uid=10001,gid=0', + '-e', 'ACCEPT_EULA=Y', '-e', 'MSSQL_PID=Developer', '-e', 'MSSQL_SA_PASSWORD', + '-p', '127.0.0.1::1433', image['Id']) + containers.append(container) + ports.append(docker('port', container, '1433/tcp').split(':')[-1]) + metadata['images'].append({'year': year, 'id': image['Id'], 'digests': image.get('RepoDigests', [])}) + command = ['dotnet', str(project.parent / 'bin/Release/net8.0/Review.dll'), *ports] + for attempt in range(60): + probe = subprocess.run(command + ['probe'], env=environment, text=True, capture_output=True, timeout=15) + if probe.returncode == 0: + metadata['versions'] = probe.stdout.strip().splitlines() + print('Actual versions: ' + ' / '.join(metadata['versions']), flush=True) + break + time.sleep(2) + else: + raise RuntimeError('Isolated SQL engines did not become ready') + result = subprocess.run(command, env=environment, text=True, capture_output=True, timeout=180) + text = (result.stdout + '\n' + result.stderr).replace(environment['MSSQL_SA_PASSWORD'], '[redacted]') + (output / 'migration.log').write_text(text) + print(text, flush=True) + if result.returncode: + raise RuntimeError('Formal-source migration regression failed') +finally: + metadata['cleanup'] = [] + for container in containers: + if docker('inspect', container, '--format', '{{index .Config.Labels "mysqlpunk.test.owner"}}') != key: + raise RuntimeError('Refusing cleanup: ownership mismatch') + docker('rm', '-f', container) + metadata['cleanup'].append(container) + print('Removed owned isolated container', flush=True) + (output / 'evidence.json').write_text(json.dumps(metadata, indent=2) + '\n') + print('Evidence: ' + str(output), flush=True) diff --git a/tests/SqlServerBatchInsertTests.cs b/tests/SqlServerBatchInsertTests.cs index b7585f9..f1b654f 100644 --- a/tests/SqlServerBatchInsertTests.cs +++ b/tests/SqlServerBatchInsertTests.cs @@ -5,12 +5,35 @@ public static class SqlServerBatchInsertTests { + public static void RunParameterTypes(Func factory) + { + foreach (object value in new object[] { TimeSpan.FromTicks(1234567), DBNull.Value }) + { + DbParameter parameter = factory(); + SqlServerBatchInsert.SetParameterValue(parameter, typeof(TimeSpan), value); + var sqlType = parameter.GetType().GetProperty("SqlDbType"); + if (sqlType != null) + { + if (!Equals(sqlType.GetValue(parameter, null), SqlDbType.Time)) throw new Exception("SQL time parameter type"); + } + else if (parameter.DbType != DbType.Time) throw new Exception("Generic time parameter type"); + if (!Equals(parameter.Value, value)) throw new Exception("Time value changed"); + } + foreach (object value in new object[] { new byte[] { 0, 255 }, new byte[0], DBNull.Value }) + { + DbParameter parameter = factory(); + SqlServerBatchInsert.SetParameterValue(parameter, typeof(byte[]), value); + if (parameter.DbType != DbType.Binary || !Equals(parameter.Value, value)) throw new Exception("Binary parameter type/value"); + } + } + public static void Run(DbConnection connection) { + using (DbCommand command = connection.CreateCommand()) RunParameterTypes(command.CreateParameter); if (SqlServerBatchInsert.RowsPerCommand(1) != 1000 || SqlServerBatchInsert.RowsPerCommand(2) != 1000 || - SqlServerBatchInsert.RowsPerCommand(3) != 700 || SqlServerBatchInsert.RowsPerCommand(2100) != 1) + SqlServerBatchInsert.RowsPerCommand(3) != 699 || SqlServerBatchInsert.RowsPerCommand(7) != 299 || SqlServerBatchInsert.RowsPerCommand(2098) != 1) throw new Exception("SQL Server batch boundary"); - foreach (int columns in new[] { 0, 2101 }) + foreach (int columns in new[] { 0, 2099, 2100, 2101 }) { try { SqlServerBatchInsert.RowsPerCommand(columns); throw new Exception("Invalid column count accepted"); } catch (ArgumentOutOfRangeException) { } diff --git a/tests/SqlServerVersionMigrationHarness/Program.cs b/tests/SqlServerVersionMigrationHarness/Program.cs new file mode 100644 index 0000000..895e4b8 --- /dev/null +++ b/tests/SqlServerVersionMigrationHarness/Program.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Threading; +using mySQLPunk.lib; +using mySQLPunk.lib.MssqlExport; + +namespace mySQLPunk.lib +{ + public interface IDatabase : IDisposable { } + internal static class ConnectionOpenService + { + internal static MssqlOwnedSession OpenMssqlImportSession(Dictionary settings) + { + throw new InvalidOperationException("No real profile access"); + } + } +} + +internal sealed class Owner : IDatabase +{ + private readonly SqlConnection connection; + public Owner(SqlConnection connection) { this.connection = connection; } + public void Dispose() { connection.Dispose(); } +} + +internal static class Program +{ + private static SqlConnection Open(int port, string database) + { + var settings = new SqlConnectionStringBuilder + { + DataSource = "127.0.0.1," + port, InitialCatalog = database, UserID = "sa", + Password = Environment.GetEnvironmentVariable("MYSQLPUNK_TEST_PASSWORD"), + Encrypt = false, TrustServerCertificate = true, ConnectTimeout = 3, Pooling = false + }; + var connection = new SqlConnection(settings.ConnectionString); + try { connection.Open(); return connection; } + catch { connection.Dispose(); throw; } + } + + private static object Query(SqlConnection connection, string sql) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; command.CommandTimeout = 60; + return command.ExecuteScalar(); + } + + private static SqlServerMigrationSession Session(int port, string database) + { + return new SqlServerMigrationSession(new Dictionary(), database, settings => + { + var connection = Open(port, (string)settings["initial_database"]); + return new MssqlOwnedSession(new Owner(connection), connection); + }); + } + + private static void Check(bool condition, string message) + { + if (!condition) throw new InvalidOperationException(message); + Console.WriteLine("PASS " + message); + } + + private static void BatchRollback(int port, string database) + { + using var connection = Open(port, database); + Query(connection, "CREATE TABLE dbo.batch_failure(id int PRIMARY KEY, clock time(7), payload varbinary(64), label nvarchar(100), amount decimal(20,6), at datetime2(7), flag bit);"); + var rows = new DataTable(); + rows.Columns.Add("id", typeof(int)); rows.Columns.Add("clock", typeof(TimeSpan)); + rows.Columns.Add("payload", typeof(byte[])); rows.Columns.Add("label", typeof(string)); + rows.Columns.Add("amount", typeof(decimal)); rows.Columns.Add("at", typeof(DateTime)); rows.Columns.Add("flag", typeof(bool)); + for (int index = 0; index < 1000; index++) rows.Rows.Add(index, DBNull.Value, DBNull.Value, "中文😀", DBNull.Value, DBNull.Value, DBNull.Value); + rows.Rows[999]["id"] = 0; + bool rejected = false; + try { SqlServerBatchInsert.Execute(connection, "dbo.batch_failure", rows); } + catch (SqlException error) when (error.Number == 2627) { rejected = true; } + Check(rejected && Convert.ToInt32(Query(connection, "SELECT count(*) FROM dbo.batch_failure;")) == 0, "Real SQL second/later batch failure rolls back all rows"); + rows.Rows[999]["id"] = 999; + SqlServerBatchInsert.Execute(connection, "dbo.batch_failure", rows); + Check(Convert.ToInt32(Query(connection, "SELECT count(*) FROM dbo.batch_failure;")) == 1000, "Real SQL seven-column 2098 budget, NULL time/binary and safe retry"); + Query(connection, "DROP TABLE dbo.batch_failure;"); + } + + private static void Main(string[] args) + { + SqlServerBatchInsertTests.RunParameterTypes(() => new SqlParameter()); + int port2019 = int.Parse(args[0]), port2022 = int.Parse(args[1]); + if (args.Length > 2 && args[2] == "probe") + { + using var oldEngine = Open(port2019, "master"); + using var newEngine = Open(port2022, "master"); + Console.WriteLine(Query(oldEngine, "SELECT SERVERPROPERTY('ProductVersion')")); + Console.WriteLine(Query(newEngine, "SELECT SERVERPROPERTY('ProductVersion')")); + return; + } + string identity = "mysqlpunk_test_" + Guid.NewGuid().ToString("N"); + var names = new[] { identity + "_old", identity + "_new", identity + "_back" }; + using (var connection = Open(port2019, "master")) + { + Check(Convert.ToString(Query(connection, "SELECT SERVERPROPERTY('ProductMajorVersion')")) == "15", "2019 actual engine major15"); + Query(connection, "CREATE DATABASE [" + names[0] + "]; CREATE DATABASE [" + names[2] + "];"); + } + using (var connection = Open(port2022, "master")) + { + Check(Convert.ToString(Query(connection, "SELECT SERVERPROPERTY('ProductMajorVersion')")) == "16", "2022 actual engine major16"); + Query(connection, "CREATE DATABASE [" + names[1] + "];"); + } + BatchRollback(port2019, names[0]); BatchRollback(port2022, names[1]); + using (var connection = Open(port2019, names[0])) + { + Query(connection, @"CREATE TABLE dbo.fixture (id bigint NOT NULL,label nvarchar(100) NULL,amount decimal(20,6) NULL,at datetime2(7) NULL,clock time(7) NULL,payload varbinary(64) NULL,flag bit NULL); +WITH n AS (SELECT 1 n UNION ALL SELECT n+1 FROM n WHERE n<1005) INSERT dbo.fixture SELECT n,N'繁體中文 😺 ' + CONVERT(nvarchar(12),n),CONVERT(decimal(20,6),n/1000.0),CONVERT(datetime2(7),'2020-02-29T12:34:56.1234567'),CONVERT(time(7),'23:59:59.1234567'),0x00FF0100,1 FROM n OPTION(MAXRECURSION 2000); +INSERT dbo.fixture VALUES(-9223372036854775808,NULL,NULL,NULL,NULL,NULL,NULL),(9223372036854775807,N'',-1234567890.123456,'2000-01-01T00:00:00.0000001','00:00:00.0000001',0x,0); +CREATE TABLE dbo.empty_table (value int NULL);"); + } + Run(port2019, names[0], port2022, names[1], 16, "2019->2022"); + Run(port2022, names[1], port2019, names[2], 15, "2022->2019"); + using (var origin = Open(port2019, names[0])) + using (var returned = Open(port2019, names[2])) + { + string check = "SELECT CONVERT(nvarchar(30),COUNT_BIG(*))+':'+CONVERT(nvarchar(30),SUM(CONVERT(decimal(30,0),id)))+':'+CONVERT(nvarchar(30),SUM(CASE WHEN label IS NULL THEN 1 ELSE 0 END)) FROM dbo.fixture"; + Check(Convert.ToString(Query(origin, check)) == Convert.ToString(Query(returned, check)), "Round-trip row totals/extreme integer sum/NULL count match"); + } + Console.WriteLine("PASS actual 2019->2022->2019 migration, 1007 rows plus empty table, UTF16/decimal/datetime2(7)/time(7)/binary/NULL and engine-owned SHA256 content verification; no PK/FK/index/identity migration coverage"); + } + + private static void Run(int sourcePort, string sourceDatabase, int targetPort, string targetDatabase, int major, string label) + { + using var source = Session(sourcePort, sourceDatabase); + using var target = Session(targetPort, targetDatabase); + var preview = SqlServerCompatibilityMigration.Preview(source.ReadCatalog(), target.ReadCatalog(), major); + Console.WriteLine(label + " preview source=" + preview.Source.MajorVersion + " target=" + preview.Target.MajorVersion + " blockers=" + string.Join(";", preview.Blockers)); + Check(preview.CanStart, label + " actual catalog preflight"); + var result = SqlServerCompatibilityMigration.Run(preview, targetDatabase, source, target, CancellationToken.None); + Console.WriteLine(result.Report); + Check(result.Succeeded && result.Rows == 1007, label + " production session/transaction/content verification"); + } +} diff --git a/tests/SqlServerVersionMigrationHarness/README.md b/tests/SqlServerVersionMigrationHarness/README.md new file mode 100644 index 0000000..442fef0 --- /dev/null +++ b/tests/SqlServerVersionMigrationHarness/README.md @@ -0,0 +1,15 @@ +# SQL Server 正式來源雙向回歸 + +在已快取 `mcr.microsoft.com/mssql/server:2019-latest` 與 `2022-latest` 的 Docker x64 主機執行: + +```sh +python3 tests/Run-SqlServerVersionMigration.py /tmp/mysqlpunk-sql-evidence-unique +``` + +輸出目錄必須尚不存在。腳本先編譯直接連結的正式 service/catalog/session/batch/owner,再以隨機名稱、localhost 動態 port、ownership label、tmpfs 建立自己的兩個容器;不使用既有 profile。密碼只存在子程序環境且輸出遮蔽;finally 核對 label 才清除自己的容器。失敗仍保留日誌、來源 SHA256、image digest、實際版本與清除紀錄。不下載 image、不讀使用者資料或憑證。 + +每方向 1007 列、7 欄及空表,包含 bigint 極值、繁中 emoji、decimal(20,6)、datetime2(7)、time(7)、varbinary bytes/empty/NULL、bit。NULL time 使用 SqlDbType.Time,非 NULL TimeSpan 交由 SqlClient 推導,不能設會轉成 DateTime 的 DbType.Time;泛用 provider 仍使用 DbType.Time。二進位 NULL 依 DataColumn 型別設定 Binary。每批最多 2098 個資料參數,保留兩個 RPC 額度,7 欄最多 299 列。 + +兩個引擎另各測七欄 1000 列:末批 PK 重複時全部回滾、修正後重試不重複,NULL time/binary 同時涵蓋。這裡的 PK 只用來注入批次失敗,不代表已支援 PK 的跨版本搬移。 + +這不是所有 SQL Server 版本矩陣,也不涵蓋 PK/FK/index/identity 搬移、Windows UI 或 SSH 真機。跨平台 smoke 另測 Microsoft.Data.SqlClient 與泛用 SQLite parameter;真引擎 harness 使用 Windows 程式同族的 System.Data.SqlClient 4.9.1。 diff --git a/tests/SqlServerVersionMigrationHarness/Review.csproj b/tests/SqlServerVersionMigrationHarness/Review.csproj new file mode 100644 index 0000000..bcac9e1 --- /dev/null +++ b/tests/SqlServerVersionMigrationHarness/Review.csproj @@ -0,0 +1,16 @@ + + + Exe + net8.0 + CS0618 + + + + + + + + + + + From 5e5103acd6b5c9e0ae3f8292946f66b9563ccdb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=94=E6=AF=94?= Date: Wed, 14 Oct 2026 13:46:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E5=9C=A8=20PostgreSQL=20=E6=90=AC?= =?UTF-8?q?=E7=A7=BB=E9=A0=90=E6=AA=A2=E9=99=90=E5=88=B6=E7=B4=A2=E5=BC=95?= =?UTF-8?q?=E9=8D=B5=E4=BD=8D=E5=85=83=E7=B5=84=E9=A0=90=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8000-byte 文字主鍵原先會放行到 INSERT 才遇到 btree 上限。現在讀取實際 page 大小,逐列計算每個主鍵與普通索引的 UTF-8、複合欄位及對齊成本,以保守 tuple 預算拒絕超限;未知 page 不放行。新增 PG16 真引擎的單鍵、複合鍵、普通索引、多位元文字、邊界及未索引文字回歸,並確認來源鍵變更會失效原確認,拒絕時目標保持空白。 --- CHANGELOG.md | 3 +- docs/LOGICAL_MIGRATION_MATRIX.md | 3 +- .../Services/LogicalMigrationKeys.cs | 20 ++++++ .../Services/LogicalMigrationService.cs | 24 ++++++- .../LogicalMigrationTests.cs | 68 +++++++++++++++++++ 5 files changed, 113 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 350ad14..1899968 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - **邏輯搬移的文字、主鍵與索引子集**:SQLite STRICT ↔ PostgreSQL 保留有效 UTF-8 TEXT(BINARY/C)、明確非自增單欄/複合主鍵、普通升冪索引與欄位順序。預覽包含索引 DDL,來源變更失效、取消、缺索引或內容不符仍整批回滾;FK、其他定序、default、identity、特殊索引依舊預檢拒絕,不宣稱所有跨引擎組合完成。 - **可驗證邏輯搬移的第一階段**:SQL Server 專用相容工具加入受限雙向跨版本;Avalonia 新增 SQLite STRICT ↔ PostgreSQL 的整數/二進位搬移,預覽來源/目標版本、schema/data 轉換與阻擋原因,確認後才寫入另建空目標。單一交易、分批、內容校驗與不確定狀態禁止重試,不將一般資料傳輸或原生備份宣稱為無限制跨版本/跨引擎還原。 -- **SQL Server 降版相容搬移(Windows,有限範圍預覽版)**:選取實際目標版本,預覽結構與列數並輸入目標名稱確認後,只寫入另建的空資料庫。僅 dbo 一般 heap 與白名單型別;identity、constraints、indexes、triggers、procs、schema 相依與進階功能明確阻擋,定序必須相同。分批寫入單一交易,內容與結構驗證通過才提交,不自動重試;2016–2025 尚僅離線/mock 驗證,不宣稱真引擎矩陣通過。 +- **SQL Server 降版相容搬移(Windows,有限範圍預覽版)**:選取實際目標版本,預覽結構與列數並輸入目標名稱確認後,只寫入另建的空資料庫。僅 dbo 一般 heap 與白名單型別;identity、constraints、indexes、triggers、procs、schema 相依與進階功能明確阻擋,定序必須相同。分批寫入單一交易,內容與結構驗證通過才提交,不自動重試;2016–2025 完整矩陣仍僅離線/mock,2019 ↔ 2022 另有受限隔離真引擎回歸,不宣稱全部版本組合通過。 - **系統資料庫規格書樣版(Windows,待最終匯出驗收)**:DOCX 新增第三種樣版,提供 A4 直式資料表清單、主鍵與索引摘要、淡藍色可編輯欄位表,以及 PK/NN 和長度/精度欄;中文名稱沿用資料庫註解。 - **Windows 建置與啟動腳本**:根目錄新增 `build.bat` 與 `run.bat`,支援 `debug` / `release` 且預設 Debug;自動尋找 MSBuild 並還原套件,`run.bat` 只在編譯成功後啟動對應版本。 @@ -54,6 +54,7 @@ - **Linux/macOS SSH Tunnel**:跨平台連線設定可勾選「透過 SSH Tunnel 連線」,填入 SSH 主機、port、使用者、密碼或 OpenSSH/PEM 私鑰(含密語)與必填的主機金鑰 SHA256 指紋;連線時以 SSH.NET 建立只綁定 127.0.0.1 動態 port 的本機轉送,MySQL/MariaDB、PostgreSQL、SQL Server 的所有查詢與資料表操作都改走轉送端點,session 中斷或程式關閉時一併關閉;SSH 連線被跳板機中斷(閒置逾時、重啟、網路中斷)後,下一次資料庫操作會以同樣的指紋規則自動重建轉送,而不是把死掉的端點交給驅動程式。主機金鑰與指紋完全比對、不符即中止並回報伺服器實際指紋;MD5 或格式錯誤的指紋、缺主機/使用者/指紋、非絕對路徑私鑰、私鑰權限過寬與 MySQL/PostgreSQL 的 VerifyFull(127.0.0.1 主機名稱必定不符)一律 fail closed,SQL Server 則改以原始主機名稱驗證憑證。SSH 密碼與私鑰密語不寫入 `connections.json`(設定檔含這兩個欄位會拒絕載入),勾選系統密碼庫時與資料庫密碼一樣以獨立項目存入 Linux Secret Service/macOS Keychain,否則只保留到本次程式關閉;smoke test 會以本機臨時 sshd 實機驗證金鑰登入、加密私鑰、錯誤密語、指紋不符、權限過寬與 provider session 整合,實機矩陣另會經 tunnel 對 MySQL 8/MariaDB 11.4(Required)、PostgreSQL 16(VerifyCA+CA)與 SQL Server 2022(Mandatory+伺服器憑證,以原始主機名稱驗證)實際查詢與列出資料庫。 ### 🛠️ 問題修正與優化 +- **PostgreSQL 索引鍵預檢**:讀取實際 block_size,標準 8 KiB page 下逐列檢查 PK 與普通索引的保守 2048-byte tuple 預算,包含 UTF-8、複合鍵與對齊成本。超限在確認前拒絕,不依賴壓縮或等 INSERT 才失敗;未索引文字仍保留原欄位限額。 - **SQL Server 搬移參數**:TimeSpan 不再被 DbType.Time 轉成 DateTime,NULL time 明確保留 SQL time;binary 欄位的 NULL 不再推導成 nvarchar。每批資料參數改為 2098,保留 RPC 額度。新增正式來源 SQL Server 2019 ↔ 2022 隔離雙向回歸與末批失敗/安全重試測試,不宣稱 PK/FK/index/identity 搬移或所有版本已驗證。 - 邏輯搬移先取得 byte length 與 SQL 限額投影再讀取 BLOB/文字,避免超過 1 MiB 才拒絕時已配置整個大陣列;單欄與累計預算在 materialize 前檢查,超限保持空目標。 - SQLite SQL 與執行計畫改在背景執行,長查詢透過原生 progress handler 中斷,不再凍結 UI;取消後等實際語句停止才釋放操作鎖。取消訊息明示先前已提交語句不會自動回滾,避免盲目重試。 diff --git a/docs/LOGICAL_MIGRATION_MATRIX.md b/docs/LOGICAL_MIGRATION_MATRIX.md index 9735445..ef4a4eb 100644 --- a/docs/LOGICAL_MIGRATION_MATRIX.md +++ b/docs/LOGICAL_MIGRATION_MATRIX.md @@ -6,7 +6,7 @@ | 來源 → 目標 | 程式允許的版本 | 可執行範圍 | 驗證區分 | | --- | --- | --- | --- | -| SQL Server → SQL Server | 2016/2017/2019/2022/2025 任意雙向及同版本,共 25 組 | Windows 既有專用工具;dbo heap、既有型別白名單、同定序、無 advanced features | 25 組預檢及交易 mock;不是 25 組真引擎驗證 | +| SQL Server → SQL Server | 2016/2017/2019/2022/2025 任意雙向及同版本,共 25 組 | Windows 既有專用工具;dbo heap、既有型別白名單、同定序、無 advanced features | 25 組預檢及交易 mock;另有正式來源 2019 ↔ 2022 隔離回歸,1007 列/7 欄+空表,不是 25 組真引擎驗證 | | SQLite → SQLite | 3.37–3.53 | STRICT、INTEGER/BLOB/TEXT、NULL/NOT NULL、非自增 PK、普通索引;詳細子集見下文 | 本機實際 SQLite 3.53.3;歷史 minor 版本未逐一跑 | | SQLite → PostgreSQL | SQLite 3.37–3.53 → PostgreSQL 14–17 | INTEGER → bigint、BLOB → bytea、TEXT BINARY → text C;非自增 PK、普通索引 | 隔離 CI PostgreSQL 16 fixture 加入文字/鍵/索引雙向 round-trip;只以這次新 head 結果為證據 | | PostgreSQL → SQLite | PostgreSQL 14–17 → SQLite 3.37–3.53 | 上述型別反向映射;PK → WITHOUT ROWID、普通索引 | 同上;14/15/17 未實機跑 | @@ -32,6 +32,7 @@ SQLite/PostgreSQL 工具入口在 Avalonia 工作台「邏輯搬移」。來 - 預覽顯示實際版本、方向、目標、表、列數、轉換 SQL 與阻擋原因,不顯示資料值。來源內容也納入確認 fingerprint;輸入完整目標名稱才可執行,確認後來源內容(包括相同列數下的變更)/結構/實際引擎識別/目標變動會拒絕。 - SQLite/PostgreSQL 全部 DDL+資料在單一目標交易。SQLite 用 immediate 交易;PostgreSQL 持有工具專用 advisory lock,來源全部表 SHARE lock 後讀取並在提交前再檢查。這不是在線無鎖搬移;請用隔離副本/維護時段,外部工具不會自動遵守 advisory lock,也不能宣稱能禁止其他管理者在最後檢查之後建立額外物件。 - 每批至多 100 列/200 參數,最多 100,000 列;每欄至多 1 MiB,每輪快照資料至多 64 MiB。驗證 schema、PK/索引、列數、Int64/text/binary/NULL 的 SHA-256 多重集合(保留重複列)。不保證隱含 rowid、實體排序或儲存配置跨引擎相同。 +- PostgreSQL 目標先讀 `SHOW block_size` 並納入確認 fingerprint;只接受標準 8192-byte page。PK 與每個普通索引各自逐列限制保守 tuple 預算 2048 bytes,32 欄以內:保留 40 bytes 給 tuple header/TID/NULL bitmap/對齊,每欄 bigint 8 bytes,text/bytea 加 4-byte varlena header 後向上對齊到 8 bytes,文字使用 UTF-8 byte 數,複合鍵加總。這是刻意低於標準 btree 頁面上限的應用白名單,不是宣稱精確最大可用大小;不依賴壓縮。超限在預覽阻擋且列出表/鍵名稱;未索引文字仍可達原本 1 MiB 欄位限額。非標準 page/未知能力 fail closed。 - 取消等實際工作停止,SQLite 用原生 progress handler,PostgreSQL 使用 provider cancellation;工作台 gate 持續到對話框關閉,不能同時 SQL/edit/delete/connect。變更目標/版本/認證即失效原預覽。 ## 重試與恢復 diff --git a/mySQLPunk.Core/Services/LogicalMigrationKeys.cs b/mySQLPunk.Core/Services/LogicalMigrationKeys.cs index 3ed10ea..c3a1c41 100644 --- a/mySQLPunk.Core/Services/LogicalMigrationKeys.cs +++ b/mySQLPunk.Core/Services/LogicalMigrationKeys.cs @@ -6,6 +6,26 @@ namespace MySqlPunk.Core.Services; public static partial class LogicalMigrationService { + internal static bool PostgreSqlIndexKeyFits(int blockSize, IReadOnlyList values) + { + if (blockSize != 8192 || values.Count is < 1 or > 32) return false; + long bytes = 40; + foreach (var value in values) + { + long width = value switch + { + null => 0, + long => 8, + string text => 4L + System.Text.Encoding.UTF8.GetByteCount(text), + byte[] binary => 4L + binary.Length, + _ => 2049 + }; + bytes += (width + 7) / 8 * 8; + if (bytes > 2048) return false; + } + return true; + } + private sealed record Keys(IReadOnlyList PrimaryKey, IReadOnlyList Indexes); private static async Task ReadKeysAsync(DbConnection connection, DbTransaction? transaction, diff --git a/mySQLPunk.Core/Services/LogicalMigrationService.cs b/mySQLPunk.Core/Services/LogicalMigrationService.cs index bc88754..2ecb1a5 100644 --- a/mySQLPunk.Core/Services/LogicalMigrationService.cs +++ b/mySQLPunk.Core/Services/LogicalMigrationService.cs @@ -18,7 +18,7 @@ public static partial class LogicalMigrationService private const long MaximumBytes = 64 * 1024 * 1024; private static readonly Regex Identifier = new("\\A[A-Za-z_][A-Za-z0-9_]{0,62}\\z", RegexOptions.CultureInvariant); private sealed record Snapshot(string Version, string Identity, List Tables, - Dictionary> Data, List Blockers); + Dictionary> Data, List Blockers, int BlockSize); public static Task PreviewAsync(ConnectionProfile source, ConnectionProfile target, int selectedTargetMajor, CancellationToken cancellationToken = default) => @@ -66,6 +66,22 @@ private static LogicalMigrationPreview BuildPreview(ConnectionProfile source, Co { var blockers = origin.Blockers.Select(reason => "來源:" + reason) .Concat(destination.Blockers.Select(reason => "目標:" + reason)).ToList(); + if (target.Provider == DatabaseProviderKind.PostgreSql) + { + if (destination.BlockSize != 8192) blockers.Add("PostgreSQL 索引預算只驗證過 8192-byte block_size;未知/其他 page 大小不能放行。"); + foreach (var table in origin.Tables) + { + var keys = table.Indexes.Select(index => (index.Name, index.Columns)) + .Concat(table.PrimaryKey.Count == 0 ? Array.Empty<(string, IReadOnlyList)>() : new[] { ("PRIMARY KEY", table.PrimaryKey) }); + foreach (var key in keys) + { + var ordinals = key.Item2.Select(name => table.Columns.ToList().FindIndex(column => column.Name == name)).ToArray(); + if (ordinals.Length is < 1 or > 32 || ordinals.Any(ordinal => ordinal < 0) || + origin.Data[table.Name].Any(row => !PostgreSqlIndexKeyFits(destination.BlockSize, ordinals.Select(ordinal => row[ordinal]).ToArray()))) + blockers.Add(table.Name + "/" + key.Item1 + ":索引鍵超出保守 2048-byte tuple 預算(含 UTF-8/複合鍵/NULL 與對齊成本),不能依賴壓縮或執行時才失敗。"); + } + } + } if (destination.Tables.Count != 0) blockers.Add("目標必須是另建的空資料庫,不能覆蓋或續寫既有表。"); if (origin.Tables.Count == 0) blockers.Add("來源沒有可搬移資料表。"); if (source.Provider == target.Provider && origin.Identity == destination.Identity) blockers.Add("實際引擎識別為同一資料庫,不允許搬移至自身。"); @@ -86,7 +102,7 @@ private static LogicalMigrationPreview BuildPreview(ConnectionProfile source, Co { Source = new { source.Provider, source.Host, source.Port, source.Database }, Target = new { target.Provider, target.Host, target.Port, target.Database }, - origin.Version, origin.Identity, TargetIdentity = destination.Identity, TargetVersion = destination.Version, origin.Tables, Statements = statements, Blockers = blockers + origin.Version, origin.Identity, TargetIdentity = destination.Identity, TargetVersion = destination.Version, destination.BlockSize, origin.Tables, Statements = statements, Blockers = blockers })); return new(origin.Version, destination.Version, target.Database, origin.Tables.AsReadOnly(), statements, blockers.AsReadOnly(), fingerprint); @@ -196,6 +212,8 @@ private static async Task ReadSnapshotAsync(DbConnection connection, D DatabaseProviderKind provider, bool readData, CancellationToken token) { var blockers = new List(); + var blockSize = provider == DatabaseProviderKind.PostgreSql + ? Convert.ToInt32(await ScalarAsync(connection, transaction, "SHOW block_size;", token), CultureInfo.InvariantCulture) : 0; var version = Convert.ToString(await ScalarAsync(connection, transaction, provider == DatabaseProviderKind.Sqlite ? "SELECT sqlite_version();" : "SHOW server_version;", token), CultureInfo.InvariantCulture) ?? ""; var identity = connection is SqliteConnection sqlite ? Path.GetFullPath(sqlite.DataSource) : @@ -291,7 +309,7 @@ private static async Task ReadSnapshotAsync(DbConnection connection, D tables.Add(new(definition.Name, columns.AsReadOnly(), rows.Count, ContentHash(rows)) { PrimaryKey = keys.PrimaryKey, Indexes = keys.Indexes }); data.Add(definition.Name, rows); } - return new(version, identity, tables, data, blockers); + return new(version, identity, tables, data, blockers, blockSize); } internal static byte[] ReadBoundedBytes(DbDataReader reader, int ordinal, long length, CancellationToken token) diff --git a/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs b/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs index f729fdc..5477b31 100644 --- a/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs +++ b/mySQLPunk.CrossPlatform.SmokeTests/LogicalMigrationTests.cs @@ -8,6 +8,12 @@ internal static class LogicalMigrationTests { public static async Task RunAsync() { + Check(LogicalMigrationService.PostgreSqlIndexKeyFits(8192, new object?[] { new string('a', 1996), 1L }), "PG conservative tuple boundary fits"); + Check(!LogicalMigrationService.PostgreSqlIndexKeyFits(8192, new object?[] { new string('a', 1997), 1L }), "PG tuple alignment boundary rejects"); + Check(!LogicalMigrationService.PostgreSqlIndexKeyFits(8192, new object?[] { new string('中', 700) }), "PG UTF8 byte budget not character count"); + Check(!LogicalMigrationService.PostgreSqlIndexKeyFits(8192, new object?[] { new string('a', 1000), new string('a', 1000) }), "PG composite budget aggregates columns"); + Check(!LogicalMigrationService.PostgreSqlIndexKeyFits(4096, new object?[] { 1L }) && !LogicalMigrationService.PostgreSqlIndexKeyFits(0, new object?[] { 1L }), "PG unknown/custom page fails closed"); + Check(LogicalMigrationService.PostgreSqlIndexKeyFits(8192, new object?[] { null, Array.Empty(), "😀", 1L }), "PG NULL/empty/multibyte key costs"); var root = Path.Combine(Path.GetTempPath(), "mysqlpunk-logical-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); try @@ -120,6 +126,7 @@ public static async Task PostgreSqlLiveAsync() try { await admin.ExecuteAsync("postgres", "CREATE DATABASE \"" + postgres.Database + "\";"); created = true; + await PostgreSqlKeyBudgetAsync(root, postgres, admin); var sqlite = Profile(Path.Combine(root, "source.db")); using var origin = DatabaseProviderFactory.Create(sqlite); await origin.ExecuteAsync(sqlite.Database, "CREATE TABLE sample (number INTEGER, payload BLOB NOT NULL) STRICT; INSERT INTO sample VALUES (-9223372036854775808,X'00FF'),(9223372036854775807,X''),(NULL,X'01'),(NULL,X'01');" + BusinessSql); @@ -178,6 +185,67 @@ public static async Task PostgreSqlLiveAsync() } } + private static async Task PostgreSqlKeyBudgetAsync(string root, ConnectionProfile postgres, IDatabaseSession admin) + { + var version = await admin.ExecuteAsync(postgres.Database, "SHOW server_version;"); + var page = await admin.ExecuteAsync(postgres.Database, "SHOW block_size;"); + Console.WriteLine("Isolated PostgreSQL version=" + version.Rows.Single()[0] + "; block_size=" + page.Rows.Single()[0]); + foreach (var scenario in new[] { "pk", "composite", "index", "utf8", "boundary", "unindexed" }) + { + var source = Profile(Path.Combine(root, "budget-" + scenario + ".db")); + using (var connection = new SqliteConnection("Data Source=" + source.Database)) + { + connection.Open(); + using var command = connection.CreateCommand(); + var constraint = scenario is "pk" or "utf8" ? ", PRIMARY KEY(label)" : scenario is "composite" ? ", PRIMARY KEY(label,other)" : scenario == "boundary" ? ", PRIMARY KEY(id,label)" : ""; + command.CommandText = "CREATE TABLE budget(id INTEGER NOT NULL,label TEXT NOT NULL,other TEXT NOT NULL" + constraint + ") STRICT" + (constraint.Length == 0 ? ";" : ", WITHOUT ROWID;") + + (scenario == "index" ? "CREATE INDEX budget_label ON budget(label);" : ""); + command.ExecuteNonQuery(); + var random = new Random(20261014); + var buffer = new byte[6000]; random.NextBytes(buffer); + var text = Convert.ToBase64String(buffer); + command.CommandText = "INSERT INTO budget VALUES(1,@label,@other);"; + command.Parameters.AddWithValue("@label", scenario == "utf8" ? new string('中', 700) : scenario == "composite" ? text[..1000] : scenario == "boundary" ? text[..1996] : text); + command.Parameters.AddWithValue("@other", scenario == "composite" ? text[1000..2000] : ""); + command.ExecuteNonQuery(); + } + var preview = await LogicalMigrationService.PreviewAsync(source, postgres, 16); + var allowed = scenario is "boundary" or "unindexed"; + Check(preview.CanStart == allowed, "Real PG key preflight " + scenario + ": " + string.Join(";", preview.Blockers)); + Console.WriteLine("PASS real PG index key preflight: " + scenario + "; CanStart=" + preview.CanStart + "; " + string.Join(";", preview.Blockers)); + if (scenario == "boundary") + { + using var connection = new SqliteConnection("Data Source=" + source.Database); connection.Open(); + using var update = connection.CreateCommand(); + update.CommandText = "UPDATE budget SET label=@label;"; + update.Parameters.AddWithValue("@label", new string('x', 8000)); update.ExecuteNonQuery(); + var stale = await LogicalMigrationService.RunAsync(source, postgres, 16, preview, postgres.Database); + Check(stale.Outcome == LogicalMigrationOutcome.RolledBack, "Changed indexed key invalidates approved preview before writing"); + var empty = await admin.ExecuteAsync(postgres.Database, "SELECT count(*) FROM pg_tables WHERE schemaname='public';"); + Check(Convert.ToInt64(empty.Rows.Single()[0]) == 0, "Stale oversized key leaves real PG target empty"); + update.Parameters[0].Value = new string('x', 1996); update.ExecuteNonQuery(); + preview = await LogicalMigrationService.PreviewAsync(source, postgres, 16); + Check(preview.CanStart, "Fresh boundary preview after restoring indexed key"); + Console.WriteLine("PASS real PG stale indexed key refused before DDL; fresh preview required"); + } + var result = await LogicalMigrationService.RunAsync(source, postgres, 16, preview, postgres.Database); + Check(result.Outcome == (allowed ? LogicalMigrationOutcome.Verified : LogicalMigrationOutcome.Refused), "Real PG key execution " + scenario); + if (allowed) + { + if (scenario == "boundary") + { + try { await admin.ExecuteAsync(postgres.Database, "INSERT INTO budget SELECT * FROM budget;"); throw new Exception("Boundary key must be enforced"); } + catch (Npgsql.PostgresException error) { Check(error.SqlState == "23505", "Real PG 2048-byte conservative tuple boundary PK enforced"); } + } + await admin.ExecuteAsync(postgres.Database, "DROP TABLE budget;"); + } + else Check(preview.Blockers.Any(reason => reason.Contains("2048-byte")), "Real PG rejected key has explicit byte budget reason"); + var count = await admin.ExecuteAsync(postgres.Database, "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public';"); + Check(Convert.ToInt64(count.Rows.Single()[0]) == 0, "Real PG target empty after key scenario " + scenario); + Console.WriteLine("PASS real PG key outcome: " + scenario + "; " + result.Outcome + "; target public objects=0 after owned fixture cleanup"); + } + } + private const string BusinessSql = "CREATE TABLE customers (id INTEGER NOT NULL, label TEXT COLLATE BINARY NOT NULL, note TEXT, payload BLOB, PRIMARY KEY(id)) STRICT, WITHOUT ROWID; " + "CREATE INDEX customer_label ON customers(label); CREATE INDEX customer_note ON customers(note,id); " + "CREATE TABLE lines (customer INTEGER NOT NULL, code TEXT NOT NULL, PRIMARY KEY(customer,code)) STRICT, WITHOUT ROWID; " +