diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f4ad2b4..6869924 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -13,6 +13,7 @@ cross-platform SDK projects, including: - Google.Protobuf 3.34.1 - BSD-3-Clause - Microsoft.Bcl.AsyncInterfaces 10.0.7 - MIT - Microsoft.Bcl.HashCode 6.0.0 - MIT +- Microsoft.SqlServer.TransactSql.ScriptDom 180.117.0 - MIT - Microsoft.Extensions.DependencyInjection.Abstractions 8.0.2 - MIT - Microsoft.Extensions.Logging.Abstractions 8.0.3 - MIT - MongoDB.Bson 3.11.0 - Apache-2.0 @@ -49,6 +50,30 @@ System.Data.SQLite states that the main System.Data.SQLite code and documentation are dedicated to the public domain, with the LINQ SQL generation directory under MS-PL. +## Microsoft SQL ScriptDOM License + +Microsoft.SqlServer.TransactSql.ScriptDom is distributed under the MIT License. + +Copyright (c) Microsoft Corporation. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ## SSH.NET License SSH.NET is distributed under the MIT License. diff --git a/docs/MSSQL_SQL_EXPORT.md b/docs/MSSQL_SQL_EXPORT.md index 9c768f3..6c8fdf3 100644 --- a/docs/MSSQL_SQL_EXPORT.md +++ b/docs/MSSQL_SQL_EXPORT.md @@ -4,7 +4,13 @@ This exporter creates a local UTF-8 SQL script for explicitly selected SQL Serve ## Source and restore requirements -Data export uses one owned connection and one SNAPSHOT transaction. The source database must already have `ALLOW_SNAPSHOT_ISOLATION ON` with state `ON`; the exporter does not enable it or fall back to weaker isolation. Schema-only export does not require a data snapshot. The account needs SELECT plus readable definitions for the selected objects and dependencies. Missing prerequisites and unsupported features block preflight. +Data export defaults to ordinary READ COMMITTED on one owned connection/transaction. It never changes SNAPSHOT or RCSI settings. Ordinary reads can observe different times across tables; a concurrently changed relationship can therefore fail trusted foreign-key validation during restore. Artifact hash verification is not restore verification. Schema-only has no data transaction. + +The optional SNAPSHOT choice requires confirmed database ALTER permission, a writable database and a known stable ON/OFF state. Without ALTER it is disabled even when already ON (the product's management policy). Selected SNAPSHOT uses one consistent transaction: originally ON stays ON; originally OFF is enabled before reading and restored after readers/transaction end. Enable has a cancelable 60-second budget; restoration has an independent 30-second budget. The exporter never forces other sessions closed, changes RCSI, or silently switches a selected SNAPSHOT export to ordinary reads. A cooperative per-database lock prevents simultaneous management by another cooperating exporter; outside clients are not covered. + +The account still needs database VIEW DEFINITION and readable dependency metadata; data modes need SELECT on every selected table. Schema-only does not require table SELECT. Missing prerequisites and unsupported features block preflight; retry/recheck preserve choices and safe diagnostic categories without provider secrets. + +Before enabling OFF, a credential-free journal is durably recorded in `%LOCALAPPDATA%\mySQLPunk\ExportRecovery\snapshot`. A canceled ALTER, disconnect or process crash may leave ON or an uncertain state. File success and setting restoration are shown independently: a valid local export can complete with an **Unconfirmed** restoration warning. Pending local records are read-only notices, including changed/unknown database identity; they never trigger automatic reconnection or repair. Check the identified database with the DBA before manual configuration changes. Restore to a prepared, compatible database using **one sqlcmd process/session for the entire artifact**: @@ -25,11 +31,15 @@ The installed classic sqlcmd 15.0.1300.359 fails when this fixture's input cross Native scalar types retain precision and temporal offsets, raw ANSI/Unicode/binary values retain bytes, and NULL differs from empty or trailing spaces. Supported schema includes identity seed/increment, computed columns, rowversion definitions, defaults, keys, supported indexes, checks, foreign keys, comments and supported dependent views. Computed and rowversion values are regenerated, not copied. Identity source allocation history/gaps are not promised. Empty tables retain their declared identity seed. Named filegroups must already exist on the destination. +Table/view/column extended properties retain native `nvarchar`, `int` and SQL NULL values, including custom names, MS_Description and SSMS diagram properties. Their declared length, collation and raw bytes participate in drift detection and are recreated without duplicate comments. Other property types produce a named preflight failure. Data-only does not create or alter target properties. + +Supported same-source `database.schema.tableOrView` references in view FROM/JOIN queries are resolved by SQL Server identity and rewritten to target-local `schema.tableOrView` using ScriptDom tokens. Comments/literals are preserved; view dependencies are ordered by resolved IDs and source definitions remain fingerprinted. This permits restoration into a differently named database without reading back from the source. The bounded path uses SQL Server 2019 grammar; qualified columns/wildcards, unsupported function/expression/rowset contexts, missing schema, unselected objects, other databases and server-qualified references remain explicit failures. Database-qualified table expressions are not rewritten. See [production compatibility design](design/mssql-export/production-preflight.md) for the exact boundary and [verification](design/mssql-export/verification.md) for actual acceptance. + Large values are streamed in bounded chunks; a row may span multiple GO batches. The importer validates ordered chunks, totals and completion before its one target INSERT. Replaying a consumed row's terminal batch does not insert it twice. A failed row is not replaced with NULL, even if a client continues after errors. **Earlier successfully inserted rows may remain:** restoration of the whole file is not all-or-nothing. Stop and investigate any sqlcmd error. -Legacy `text` rows use typed staging even for small values because direct varbinary assignment to text is rejected by SQL Server. This preserves the source codepage but increases script size and restore work for those rows. `ntext`, `image`, untyped XML and ordinary WKT text are supported within the documented tests; native SQL Server `sys.geometry` now uses `Serialize()` -> bounded binary chunks -> `geometry::Deserialize()`, with small values optionally inline. Native geometry DDL, NULL/empty, serialized bytes, SRID and Z/M are retained without WKT conversion, simplification or MakeValid. Geography, user-defined/alias CLR types, spatial indexes, typed XML, sql_variant, unsupported dependencies/table/column/index features and unreadable definitions are rejected rather than silently omitted. Tables with spatial indexes remain blocked in all modes, including data-only; geometry value support does not imply spatial-index scripting. Refer to the current preflight details for the affected object/column. +Legacy `text` rows use typed staging even for small values because direct varbinary assignment to text is rejected by SQL Server. This preserves the source codepage but increases script size and restore work for those rows. `ntext`, `image`, untyped XML and ordinary WKT text are supported within the documented tests; native SQL Server `sys.geometry` now uses `Serialize()` -> bounded binary chunks -> `geometry::Deserialize()`, with small values optionally inline. Native geometry DDL, NULL/empty, serialized bytes, SRID and Z/M are retained without WKT conversion, simplification or MakeValid. Geography, user-defined/alias CLR types, spatial indexes, typed XML, sql_variant columns, unsupported dependencies/table/column/index features and unreadable definitions are rejected rather than silently omitted. Tables with spatial indexes remain blocked in all modes, including data-only; geometry value support does not imply spatial-index scripting. Refer to the current preflight details for the affected object/column. -The transaction remains open while source rows are exported, retaining snapshot versions. It is released before local hash verification and mirror work. Allow disk space for source version store, SQL hex expansion (often more than twice binary payload), partial output, verified local output and optional mirror copies. Progress reports actual source bytes/rows and separate file verification/mirror work; estimates can remain unknown. More bytes in an export do not mean rows have been successfully restored. +The data transaction remains open while source rows are exported; SNAPSHOT mode retains row versions. Readers and the transaction close before bounded settings restoration, and source resources close before local hash verification and mirror work. Allow disk space for source version store, SQL hex expansion (often more than twice binary payload), partial output, verified local output and optional mirror copies. Progress reports actual source bytes/rows and separate file verification/mirror work; estimates can remain unknown. More bytes in an export do not mean rows have been successfully restored. Cancellation waits for owned reader/command/transaction/file cleanup. The final consent stamp detects destination changes observed before publication. Windows File.Replace does not provide an atomic compare-and-swap against unrelated external writers: a change in the narrow final-check/replace window can still be displaced. Recovery paths are retained and reported; after a partial replacement error, the previous file may be at a recovery path instead of its original location. Use a new filename for externally managed targets. Before replacement starts, failure or cancellation preserves the existing output. If the local file was published but the mirror fails or is canceled, the result is **local completed, mirror incomplete**, not complete success. Any retained recovery paths are reported. @@ -40,7 +50,7 @@ The opt-in runner defaults to instructions only: ```powershell .\tests\Run-MssqlExportIntegration.ps1 .\tests\Run-MssqlExportIntegration.ps1 -Run -Server '(localdb)\mySQLPunkExportTest_20261012' -Profile Core -OutputDirectory '' -# Profiles Geometry, Large and Concurrency use the same explicit arguments. +# Profiles Geometry, Large, Concurrency, Properties and References use the same explicit arguments. # Official portable Go client is opt-in: -Client Go -SqlcmdPath . # Profile ClientProbe verifies client semantics before a large restore. # Large -RestoreArtifact validates prior SHA/token, then restores it in a new fixture. diff --git a/docs/design/mssql-export/design.md b/docs/design/mssql-export/design.md index 5f5f8a0..12ae4a4 100644 --- a/docs/design/mssql-export/design.md +++ b/docs/design/mssql-export/design.md @@ -24,7 +24,7 @@ 1. 既有 WinForms MSSQL 互動式本機 SQL 匯出入口共用專用精靈及 worker;依入口預選結構與資料、僅結構或僅資料。資料庫入口預設全選,單表入口只預選指定資料表。 2. 原生 SQL Server 型別與結構的正確腳本、逐表串流資料、大欄位分塊輸出、可取消查詢、串流檔案完整性驗證。 -3. 持續寫入來源採同一個 SNAPSHOT 交易;不自動變更 DB 設定。 +3. 2026-10-13 已核准的 [可選 SNAPSHOT 設計](snapshot-options-design.md) 取代原規則:預設 READ COMMITTED;選 SNAPSHOT 時依原狀有限管理 ON/OFF。 4. 「建立備份」保留目前選用的備份鏡像與保留份數功能;一般手動 SQL 匯出不因此新增鏡像。 5. 沿用現有 Punky 素材、ThemeManager、UiKit、獨立連線及 SSH transport 的生命週期。 @@ -70,7 +70,7 @@ SQL Server 原生 `.bak`、還原工具重寫、排程/自動備份改造、 列出來源、模式、表與檢視範圍、輸出路徑、估計工作量、一致性模式、鏡像,以及具體不支援項目。檢查失敗須指出物件及原因,保留選取供修正。 -未啟用 SNAPSHOT 時,含資料模式停在此頁;不能自動降級成一般讀取或 NOLOCK。僅結構不需要跨表資料快照,但仍檢查 schema 變動與定義可讀性。 +預設一般 READ COMMITTED 不因 SNAPSHOT OFF 被擋;明確選 SNAPSHOT 時需有效 ALTER 與可管理狀態,不能偷偷降級。僅結構不需要跨表資料快照,但仍檢查 schema 變動與定義可讀性。 開始按鈕捕獲不可變的本次 request;執行期間不能改選取、路徑或模式。原型頂端的任意跳頁工具不進正式產品。 @@ -91,8 +91,8 @@ SQL Server 原生 `.bak`、還原工具重寫、排程/自動備份改造、 1. UI 只捕獲本次選擇及必要的連線設定,worker 經既有安全連線流程取得獨立連線及 SSH lease;不得借用主視窗 provider。 2. 在 `ConnectionOpenService` 邊界提供最小的內部 MSSQL 工作連線存取方式,保持 wrapper 及 raw SqlConnection 同一擁有者。不改動所有 provider 的 `IDatabase` public contract,不以另開連線字串繞過 transport 生命週期。 -3. 前置檢查讀取目標 DB 的 `snapshot_isolation_state`,僅 ON 接受含資料快照;過渡狀態不當作可用。只開 `READ_COMMITTED_SNAPSHOT` 不等於這次跨表交易已使用 SNAPSHOT。 -4. 使用者選擇期間不持有快照。按開始後重做必要檢查,在指定資料庫開始一筆明確的 SNAPSHOT transaction,必要來源查詢均使用同一交易。 +3. 前置檢查讀取目標 DB 的 `snapshot_isolation_state` 與管理能力;選 SNAPSHOT 才要求可管理的 ON/OFF,OFF 由作業有限啟用與恢復,過渡狀態不當作可管理。只開 `READ_COMMITTED_SNAPSHOT` 不等於這次跨表交易已使用 SNAPSHOT。 +4. 使用者選擇期間不持有快照。按開始後重做必要檢查,在指定資料庫依明確選項開始一筆 READ COMMITTED 或 SNAPSHOT transaction,必要來源查詢均使用同一交易。 5. 所有選取表依序用同一交易讀取;不能每表重新開交易,也不能中途重新連線續跑而仍宣稱同一時點。 6. SNAPSHOT 不保證 metadata 可任意變更。預檢後凍結本次物件定義;物件變更、不可讀、schema 變動導致查詢失敗等情況中止本次工作,不跳過失敗表後顯示成功。 7. 讀完最後來源資料及必要來源查核後立即結束交易並釋放 DB/SSH 資源;本機驗證、發布、mirror 不延長快照持有時間。 diff --git a/docs/design/mssql-export/diagnose-preflight.sql b/docs/design/mssql-export/diagnose-preflight.sql new file mode 100644 index 0000000..3b99ee7 --- /dev/null +++ b/docs/design/mssql-export/diagnose-preflight.sql @@ -0,0 +1,96 @@ +-- mySQLPunk MSSQL preflight diagnostics (read-only). +-- Execute with the SAME login and selected database as the export wizard. +-- No application row data, property values, module definitions or credentials are returned. +-- No database settings, users, permissions or objects are changed. +SET NOCOUNT ON; + +-- 1. Environment and effective database permissions. +SELECT DB_NAME() AS database_name, + CONVERT(nvarchar(128), SERVERPROPERTY('ProductVersion')) AS product_version, + CONVERT(nvarchar(128), SERVERPROPERTY('Edition')) AS edition, + HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'CONNECT') AS can_connect, + HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'VIEW DEFINITION') AS can_view_database_definition, + HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'ALTER') AS can_alter_database; +SELECT DB_NAME() AS database_name, d.snapshot_isolation_state, + d.snapshot_isolation_state_desc, d.is_read_committed_snapshot_on +FROM (SELECT DB_ID() AS database_id) AS current_db +LEFT JOIN sys.databases AS d ON d.database_id = current_db.database_id; + +-- A hidden catalog is NOT an empty catalog. Do not infer object absence from it. +IF ISNULL(HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'VIEW DEFINITION'), 0) <> 1 +BEGIN + SELECT N'METADATA_NOT_VISIBLE' AS diagnostic_status, + N'Database VIEW DEFINITION is unavailable. Have a DBA run the remaining metadata queries; an empty result under this login would not prove absence.' AS diagnostic_message; + RETURN; +END; + +-- 2. Actual reasons behind the aggregated table/view feature message. +SELECT s.name AS schema_name, o.name AS object_name, o.type_desc, + HAS_PERMS_BY_NAME(QUOTENAME(s.name) + N'.' + QUOTENAME(o.name), 'OBJECT', 'SELECT') AS can_select, + (SELECT COUNT(*) FROM sys.indexes AS i WHERE i.object_id = o.object_id AND i.index_id > 0) AS index_count, + (SELECT COUNT(*) FROM sys.triggers AS t WHERE t.parent_id = o.object_id) AS trigger_count, + (SELECT COUNT(*) FROM sys.extended_properties AS e WHERE e.class = 1 AND e.major_id = o.object_id) AS property_count, + (SELECT COUNT(*) FROM sys.extended_properties AS e WHERE e.class = 1 AND e.major_id = o.object_id AND e.name <> N'MS_Description') AS non_description_property_count +FROM sys.objects AS o JOIN sys.schemas AS s ON s.schema_id = o.schema_id +WHERE s.name = N'dbo' AND o.name IN + (N'WRA_WATERLEVEL_STATION', N'V_EMIC_DISASTER', N'View_WRA_RESERVOIR', + N'View_WRA_WATERLEVEL', N'V_CWB_RAIN', N'Vw_LatestWaterLevel_WithStation_AndCounty') +ORDER BY o.name; + +-- 3. Property ownership, names and SQL types only; values are deliberately excluded. +SELECT s.name AS schema_name, o.name AS object_name, o.type_desc, + e.minor_id, c.name AS column_name, e.name AS property_name, + CONVERT(nvarchar(128), SQL_VARIANT_PROPERTY(e.value, 'BaseType')) AS property_base_type, + CONVERT(int, SQL_VARIANT_PROPERTY(e.value, 'MaxLength')) AS property_max_length, + CONVERT(int, SQL_VARIANT_PROPERTY(e.value, 'Precision')) AS property_precision, + CONVERT(int, SQL_VARIANT_PROPERTY(e.value, 'Scale')) AS property_scale, + CONVERT(nvarchar(128), SQL_VARIANT_PROPERTY(e.value, 'Collation')) AS property_collation, + DATALENGTH(e.value) AS property_bytes +FROM sys.extended_properties AS e +JOIN sys.objects AS o ON o.object_id = e.major_id +JOIN sys.schemas AS s ON s.schema_id = o.schema_id +LEFT JOIN sys.columns AS c ON c.object_id = o.object_id AND c.column_id = e.minor_id +WHERE e.class = 1 AND s.name = N'dbo' AND o.name IN + (N'WRA_WATERLEVEL_STATION', N'V_EMIC_DISASTER', N'View_WRA_RESERVOIR', + N'View_WRA_WATERLEVEL', N'V_CWB_RAIN', N'Vw_LatestWaterLevel_WithStation_AndCounty') +ORDER BY o.name, e.minor_id, e.name; + +-- 4. Index / trigger identities: a property-only view must not be reported as indexed. +SELECT s.name AS schema_name, o.name AS object_name, N'INDEX' AS feature_kind, + i.name AS feature_name, i.type_desc AS feature_type +FROM sys.views AS o JOIN sys.schemas AS s ON s.schema_id = o.schema_id +JOIN sys.indexes AS i ON i.object_id = o.object_id AND i.index_id > 0 +WHERE s.name = N'dbo' AND o.name IN + (N'V_EMIC_DISASTER', N'View_WRA_RESERVOIR', N'View_WRA_WATERLEVEL', N'V_CWB_RAIN', N'Vw_LatestWaterLevel_WithStation_AndCounty') +UNION ALL +SELECT s.name, o.name, N'TRIGGER', t.name, t.type_desc +FROM sys.views AS o JOIN sys.schemas AS s ON s.schema_id = o.schema_id +JOIN sys.triggers AS t ON t.parent_id = o.object_id +WHERE s.name = N'dbo' AND o.name IN + (N'V_EMIC_DISASTER', N'View_WRA_RESERVOIR', N'View_WRA_WATERLEVEL', N'V_CWB_RAIN', N'Vw_LatestWaterLevel_WithStation_AndCounty'); + +-- 5. Database-qualified dependencies and locally resolvable object identity. +-- VIEW DEFINITION alone does not imply SELECT on this catalog view. +IF ISNULL(HAS_PERMS_BY_NAME(N'sys.sql_expression_dependencies', N'OBJECT', N'SELECT'), 0) <> 1 +BEGIN + SELECT N'DEPENDENCY_CATALOG_NOT_READABLE' AS diagnostic_status, + N'SELECT on sys.sql_expression_dependencies is unavailable. Have a DBA review access; dependency results are unknown, not empty.' AS diagnostic_message; + RETURN; +END; +SELECT s.name AS referencing_schema, o.name AS referencing_object, + d.referenced_server_name, d.referenced_database_name, + d.referenced_schema_name, d.referenced_entity_name, d.referenced_id, + CASE WHEN d.referenced_server_name IS NULL + AND (d.referenced_database_name IS NULL OR DB_ID(d.referenced_database_name) = DB_ID()) + THEN 1 ELSE 0 END AS same_database_reference, + CASE WHEN d.referenced_server_name IS NULL + AND (d.referenced_database_name IS NULL OR DB_ID(d.referenced_database_name) = DB_ID()) + AND d.referenced_schema_name IS NOT NULL + THEN OBJECT_ID(QUOTENAME(d.referenced_schema_name) + N'.' + QUOTENAME(d.referenced_entity_name)) + ELSE d.referenced_id END AS locally_resolved_object_id +FROM sys.sql_expression_dependencies AS d +JOIN sys.objects AS o ON o.object_id = d.referencing_id +JOIN sys.schemas AS s ON s.schema_id = o.schema_id +WHERE d.referenced_class = 1 AND s.name = N'dbo' AND o.name IN + (N'V_EMIC_DISASTER', N'View_WRA_RESERVOIR', N'View_WRA_WATERLEVEL', N'V_CWB_RAIN', N'Vw_LatestWaterLevel_WithStation_AndCounty') +ORDER BY o.name, d.referenced_database_name, d.referenced_schema_name, d.referenced_entity_name; diff --git a/docs/design/mssql-export/production-preflight.md b/docs/design/mssql-export/production-preflight.md new file mode 100644 index 0000000..73054b0 --- /dev/null +++ b/docs/design/mssql-export/production-preflight.md @@ -0,0 +1,64 @@ +# Production preflight compatibility investigation (2026-10-13) + +> 2026-10-13 policy update: historical snapshot-required / no-automatic-ALTER rules below are superseded by [snapshot-options-design.md](snapshot-options-design.md). Current default is explicit READ COMMITTED with mixed-time/FK warnings. Optional SNAPSHOT requires ALTER even if already ON and manages its own OFF-to-ON change with bounded cleanup. Historical SQL2019 diagnostics remain evidence of that earlier observation only; no new production connection, setting change, export or restore has been performed. + +Base: merged master f0d4f56. Active worktree: .worktrees/mssql-export, branch codex/mssql-export-preflight-compat. Original master and unrelated Desktop Properties files remain untouched. + +## Confirmed diagnosis + +The production screenshot is blocked at preflight, before any data export. It does not show corrupted data or an export runtime failure. + +- Data modes require snapshot_isolation_state=1. The old UI collapses all other states to OFF, losing transition/unknown distinctions. The user reaffirmed preserving consistency and having a DBA assess ALLOW_SNAPSHOT_ISOLATION. +- Permission failures lose their useful reason: EnsureVisibility throws for missing VIEW DEFINITION, the service replaces it with a generic exception type, and the wizard replaces that with a generic retry message. User explicitly requires insufficient-permission handling. +- The original reader rejected table non-MS_Description extended properties and all view properties, and aggregated the view error with indexes/triggers. Returned production metadata confirms the affected objects only need nvarchar/int properties; indexes/triggers are zero. +- Any dependency with a database qualifier is rejected, including same-database Transfer.dbo.*. Merely allowing it is unsafe: unchanged view bodies would point back to the source when restored into a differently named database. + +## Immediate bounded correction + +1. Preserve a typed, safe prerequisite diagnostic across metadata -> service -> wizard for missing database VIEW DEFINITION and known SQL permission/login/database-access denials. Use a fixed allowlist of error numbers/categories and fixed user-facing wording; never display raw provider messages/connection strings/inner exceptions. Unknown errors retain generic handling. +2. Clearly separate SELECT, readable definitions, connection access and DBA-only configuration remediation. Do not require sysadmin/db_owner or attempt ALTER/GRANT/login changes. ALTER permission is not an exporter prerequisite when snapshot is already ON. +3. Keep data export blocked unless SNAPSHOT is ON. Distinguish OFF, ON, transitions and unknown states; give DBA/retry/schema-only guidance. Never downgrade to READ COMMITTED/NOLOCK or silently omit rows/objects. +4. Add a native confirmation-page recheck action preserving tables/options/path; remain cancelable and do not enable Start from stale preflight. If a catalog read fails, provide an in-place retry without duplicate loads or stale selection expansion. Reuse existing wizard lifecycle and generation logic. +5. Write meaningful RED/GREEN regressions around safe diagnostic propagation, unknown-error redaction, both wizard failure surfaces, snapshot-state/mode matrix and retry lifecycle. Run affected smoke groups and Debug/Release sequentially. No database/production operations or ReportExportTests executable. + +## Evidence collection before schema compatibility changes + +[diagnose-preflight.sql](diagnose-preflight.sql) is read-only and should run under the same production login/database. It reports version, effective permissions, snapshot state, affected property names/types (not values), view feature counts/names and dependency identities. Without VIEW DEFINITION it explicitly stops instead of treating hidden objects as absent. + +The returned production results below determine the extended-property scope. Supporting same-database qualified definitions requires catalog-correct name resolution plus safe SQL identifier rewriting and an actual differently named target restore; not a predicate-only relaxation. These schema compatibility changes are implemented separately from the bounded diagnostic correction. + +## Verification boundaries + +Local tests and authorized LocalDB metadata-query syntax checks are not production export/restore acceptance. No production DB setting, grant, data, source definitions, merge, push or release is authorized by this diagnostic task. Existing dedicated fixture authorization remains available for a later scoped restore test; do not substitute another instance. + +## Diagnostic collection progress + +- The full metadata batch was syntax/execution checked on the exact previously authorized SQL2025 LocalDB instance, master catalog only. No application rows, source definitions, users, grants or settings were changed. Missing SELECT on sys.sql_expression_dependencies is now explicitly reported; lack of visibility is never an empty-catalog assertion. +- The user's initial `OK | 177 ms` is execution status only. The pasted attachment contained the SQL text, not result rows. QueryForm.cs classifies by first keyword; the full diagnostic starts with SET and therefore routes through ExecuteNonQuery. MySqlPunk's current MSSQL reader also exposes one result table. Use SSMS/sqlcmd for the full batch, or execute SELECT-only diagnostic statements individually in mySQLPunk. This existing query-editor limitation is not part of the exporter fix. +- Provided a desktop copy to avoid markdown escaping the hidden-worktree path. Three standalone SELECTs then produced the environment, properties and dependency results recorded below. + +## Production environment evidence returned by user + +The standalone SELECT result is: database Transfer; ProductVersion 15.0.2000.5; snapshot state OFF; RCSI false; database VIEW DEFINITION=1; database ALTER=1; SELECT on sys.sql_expression_dependencies=1. This confirms actual OFF under that login, not a transition. It does not establish table SELECT permissions or successful export/restore. The user will have a DBA evaluate enabling snapshot; no production setting has been changed by this work. + +The next two SELECT-only diagnostics have now been returned by the user. Every affected object has SELECT=1, zero indexes and zero triggers. The four affected views carry MS_DiagramPane1 (nvarchar, 5,468 to 7,006 bytes) and MS_DiagramPaneCount (int). WRA_WATERLEVEL_STATION carries table/column MS_Description plus a custom nvarchar table property. All reported character properties use Chinese_Taiwan_Stroke_CI_AS. No property values were requested. + +The qualified Transfer.dbo references resolve to database ID 5, equal to the current database ID, with matching existing user-table IDs. The two unqualified dependencies also resolve to existing user tables. This establishes same-source identity and existence; selected-object membership must still be checked during each actual export. + +## Compatibility implementation plan + +1. Preserve immutable class-1 table/view/column extended properties for the evidenced nvarchar, int and SQL NULL values. Include ownership, native type, declared length, collation and raw hex in the schema fingerprint. Preserve unsupported types as named preflight failures; never silently omit them. Keep ordinary comments available to the UI, with only one property output path. +2. Reconstruct nvarchar directly into sql_variant with its original COLLATE expression; preserve int and NULL semantics. Hex payloads prevent embedded quotes, newlines or GO text from changing script structure. Keep DataOnly free of property DDL. Index/trigger restrictions remain explicit. +3. Pin Microsoft.SqlServer.TransactSql.ScriptDom 180.117.0 (net472, MIT, no package dependencies) in the existing packages.config workflow. Use its SQL2019 parser for this bounded view-reference support. Resolve source database/object identity through the source SQL Server catalog, using parameters and the current owned connection/transaction. +4. Rewrite only catalog-confirmed same-source three-part named table/view references into local schema/object references. Use parser token offsets, retain comments/literals, reparse the output, and reject unsupported qualified contexts. Retain original source definitions and dependency identities in fingerprints; use resolved local IDs for ordering/cycle detection. Table expressions, external database/server references, unresolved/unselected objects and unsupported contexts remain blockers. +5. Run test-first regressions for properties and parser behavior. Add guarded Properties and References integration profiles to the existing opt-in runner, using the already authorized dedicated LocalDB only. Compare restored native property metadata/bytes, exercise schema/data modes and restricted database users, and prove views follow the differently named target while the source remains accessible with deliberately different rows. +6. Run affected smoke tests, Debug/Release builds, and independent code review. Record actual SQL engine/client identities and cleanup evidence. SQL2025 LocalDB evidence does not certify SQL2019 production; the user still needs DBA-reviewed SNAPSHOT enablement and a real production retry. + +Package evidence: https://www.nuget.org/packages/Microsoft.SqlServer.TransactSql.ScriptDom/180.117.0 . Native property semantics: https://learn.microsoft.com/en-us/sql/t-sql/functions/sql-variant-property-transact-sql and https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addextendedproperty-transact-sql . + +## Implementation and local acceptance complete + +- Added fixed prerequisite diagnostics, precise SNAPSHOT states and retry without discarding wizard choices. Actual restricted-user tests verify ON without ALTER, missing VIEW DEFINITION, denied table SELECT and denied dependency-catalog SELECT. +- Preserved supported native typed properties; independently verified scopes, declared metadata, collation and raw values after restore. Same-source qualified view table references now use parser/catalog checks and restore to local target objects; the source-online divergence test passed. +- Final 169 smoke groups plus AutoReleasePolicy, Debug/Release builds, Properties/References/Core actual LocalDB export/restore and independent scoped reviews passed. Full evidence, hashes, the DB_ID provider-type correction and cleanup records are in verification.md. +- Changes remain on codex/mssql-export-preflight-compat for local acceptance. Original master is unchanged. Production SQL2019 retry still requires DBA-reviewed SNAPSHOT enablement for data modes and may reveal unsupported view syntax not present in the returned metadata; no production success, merge, push or release is implied. diff --git a/docs/design/mssql-export/snapshot-options-design.md b/docs/design/mssql-export/snapshot-options-design.md new file mode 100644 index 0000000..e7bc4a6 --- /dev/null +++ b/docs/design/mssql-export/snapshot-options-design.md @@ -0,0 +1,105 @@ +# MSSQL 匯出:可選 SNAPSHOT 與自動還原資料庫設定 + +日期:2026-10-13 +狀態:2026-10-13 六項實作與逐項覆核完成;全分支覆核的兩項問題已修正並通過 scoped review,最後 adapter 能力檢查邊界由 controller 補齊並驗證。最終 Release smoke 199組、AutoReleasePolicy、Debug/Release build 通過;正式 SQL2019 未驗證。 +基底:codex/mssql-export-preflight-compat,34024b7。 + +## 使用者需求與取代範圍 + +使用者要求匯出前可決定是否使用 SNAPSHOT,預設「否」;程式先檢查 ON/OFF 能力與權限,權限不足時停用選項,走一般匯出;選「是」時由程式在作業中自動處理開關。 + +此決定取代 design.md、production-preflight.md 中「所有含資料匯出必須預先 ON、不自動變更設定」的規則。原本 SNAPSHOT 模式的同一時間點保證保留;一般模式明確不具有該保證。既有串流、Geometry/LOB 分段、原檔保護、取消、DDL fingerprint、Punky、進度與鏡像架構繼續沿用。本設計定義本次產品行為;設計與實作驗收均未連線或更改正式庫。 + +## 方案比較 + +| 方案 | 效果 | 取捨 | +| --- | --- | --- | +| 僅增加一般讀取,SNAPSHOT 仍由 DBA 事先開啟 | 一般帳號可匯出 | 不滿足自動開關需求 | +| 可選一般/SNAPSHOT,按原狀管理資料庫設定(採用) | 符合預設否、能力檢查及自動開關 | 需要有限等待、併行協調與復原紀錄 | +| 每次按選項強制全庫 ON/OFF | 行為表面簡單 | 會關掉原本由 DBA 或其他程式使用的 ON,不採用 | + +## 1. 精靈與權限規則 + +輸出設定頁加入「使用 SNAPSHOT 一致性讀取」是/否,預設否。顯示目前資料庫設定與可用性原因;每次新開精靈預設仍為否,不延用上次的肯定選項。 + +能力檢查只有讀取:目前資料庫的 snapshot_isolation_state、可更新狀態,以及 HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'ALTER')。不能為了試探能力先執行 ON/OFF。能力資料獨立於物件結構 fingerprint。 + +依使用者的權限政策,選項代表包含自動開關管理的完整功能;沒有有效 ALTER 權限時固定為否並停用,即使資料庫目前已 ON 也是如此。這是產品政策,不代表 SQL Server 在已 ON 時使用 SNAPSHOT 本身需要 ALTER。 + +| 情境 | UI 與執行 | +| --- | --- | +| 僅結構 | 固定否、停用;不開資料交易、不改設定 | +| ON 或 OFF,已確認有效 ALTER 權限且可變更設定 | 選項可用,預設否 | +| 權限為 0/NULL,能力資訊不足,或資料庫不可變更設定 | 固定否、停用,說明原因;仍可一般匯出 | +| ON/OFF 轉換中或狀態未知 | 固定否、停用,可重新檢查;一般讀取不因這個設定單獨被擋 | +| 權限檢查發生取消、斷線或真正連線失敗 | 保留原有錯誤/取消處理;不得偽裝成可正常匯出 | + +固定否只解除 SNAPSHOT/ALTER 的額外要求;資料表 SELECT、VIEW DEFINITION、可讀相依 metadata 等必要條件仍保留。 + +開始前重新檢查。若選是後權限或狀態變動,回確認頁要求重新檢查/選擇;執行中不可偷偷降級。能力檢查停用選項時,畫面清楚顯示目前改為一般讀取,再由使用者按開始。 + +## 2. 選否:一般串流讀取 + +沿用同一個 owned session,以明確的 IsolationLevel.ReadCommitted 資料交易逐表讀取;SchemaOnly 不開資料交易。保留 SequentialAccess、bounded buffer、取消、開始/結束結構 fingerprint 與來源資源釋放順序。 + +不執行 ALLOW_SNAPSHOT_ISOLATION ON/OFF,不改 READ_COMMITTED_SNAPSHOT,不使用 NOLOCK。既有 RCSI 設定照常影響 READ COMMITTED;RCSI OFF 時一般讀取可能與寫入互相等待。單一 READ COMMITTED 交易不提供跨表同一時間點保證。 + +確認頁、輸出 SQL header(包含 DataOnly)及結果均標示: + +> 一般讀取(READ COMMITTED):匯出期間可能讀到不同時間的資料;來源持續寫入時,關聯資料可能不一致,還原可能因外鍵限制失敗。檔案驗證不等同還原成功。 + +維持可信任外鍵的 WITH CHECK,不為了讓混合時間資料還原成功而自動改 NOCHECK/刪除約束/修改資料。 + +## 3. 選是:啟用、讀取、還原原狀 + +1. 檢查必要匯出條件與本次要求後,先在來源資料庫取得固定名稱的 Session-owned application lock。只有 SNAPSHOT 作業取得此鎖;同庫另一個合作中的 mySQLPunk 快照匯出執行時,立即回報忙碌,讓使用者稍後重試。不同庫互不阻擋。 +2. 取得鎖後重新讀取真實狀態、資料庫識別與權限;UI 查到的狀態不能當作本次原狀。 +3. 原本 ON:建立本次 SNAPSHOT 交易;結束時保留 ON,不執行 OFF。 +4. 原本 OFF:先落地本機復原紀錄,再執行 ON。啟用等待最多 60 秒並可取消;只有回讀確認 state=1 才開始 SNAPSHOT 交易。轉換中/未知狀態不當作成功。 +5. 所有表仍由同一筆 SNAPSHOT 交易讀取;完成來源資料與結構複核後,關閉 reader、結束交易。 +6. 本次從 OFF 啟用的工作才嘗試恢復 OFF。成功、取消、匯出失敗共用此清理邊界;清理使用獨立 30 秒期限,不能沿用已被取消的作業 token。 +7. 回讀確認 OFF 才記為還原成功並移除該工作復原紀錄。已經是 OFF 時可確認完成;轉換中/未知/讀不到狀態均不能宣稱恢復。 +8. 釋放 application lock 及 DB/SSH 資源後,再執行本機檔案驗證、發布與鏡像。設定管理只包住來源階段。 + +設定管理是明確的工作階段,顯示「啟用 SNAPSHOT」「恢復資料庫設定」與等待時間;不能把長 ALTER 隱藏在無法回報的 Dispose 裡。不得使用 KILL、ROLLBACK IMMEDIATE、SINGLE_USER 或強制終止其他連線。 + +## 4. 失敗、取消、程序中斷 + +設定切換不是匯出交易的一部分,程式當掉或網路中斷時不能承諾一定關回。啟用前的本機紀錄放在 `%LOCALAPPDATA%\mySQLPunk\ExportRecovery\snapshot\.json`,只含 job ID、來源伺服器/資料庫識別、原狀、操作階段及時間,不含帳密、連線字串或資料列。紀錄寫入失敗時不執行 ON。 + +若啟用指令結果不明,必須嘗試回讀與有限清理;不能因 bool 尚未被設成成功便漏掉可能已生效的變更。斷線後不自動換身分/重連去執行 OFF;保留待人工確認的紀錄。 + +匯出與設定復原結果分開顯示: + +- 資料完整且檔案已驗證,但無法確認恢復 OFF:可完成發布,結果須醒目顯示「檔案匯出完成;資料庫設定尚未確認恢復」,附目前已知狀態與復原紀錄位置。 +- 匯出失敗/取消:原目標檔仍受保護;設定清理失敗另外列出,不能覆蓋原始失敗或取消原因。 +- 程序中斷留下紀錄:下次開啟相關精靈可看到未處理紀錄及人工檢查方式;不得只靠同名資料庫自動執行 OFF。來源識別無法確認時也不得代修。 + +## 5. 併行與共用設定的界線 + +application lock 只協調遵守相同協定的 mySQLPunk 版本。其他工具、舊版程式及 DBA 不受此鎖限制。即使本次原始狀態是 OFF,開啟期間其他程式仍可能開始使用 SNAPSHOT;關回 OFF 可能等待它們,並影響後續新 SNAPSHOT 交易。 + +因此選是旁顯示:「需要 ALTER 權限;必要時會暫時變更整個資料庫的 SNAPSHOT 設定,結束後嘗試恢復原狀。其他程式同時使用時可能互相影響。」恢復有期限、留下確切狀態,不保證任意外部併行下零影響。原本 ON 一律保持 ON。 + +## 6. 程式範圍與驗收 + +局部擴充 MssqlExportModels、MetadataReader、Source、SqlExportService、SchemaScriptWriter 與原生 Wizard,新增小型設定生命週期/復原紀錄元件。公開 provider contract、通用 dump、其他資料庫、原生 .bak、排程不變。不新增套件。 + +必要驗證: + +1. 預設否/無 ALTER/NULL 能力/SchemaOnly 的 UI 與 request;能力 retry、取消與過期結果保護。 +2. 一般模式在 ON、OFF、轉換狀態皆不執行 ALTER;資料/定義權限不足仍被擋。靜態資料實際匯出還原通過。 +3. 持續寫入的受控 Parent/Child fixture:一般模式觀察跨時點資料;合法來源更新造成匯出缺父列時,實際還原應因 FK 失敗並保留正確警語。SNAPSHOT 模式仍維持同時點資料。 +4. OFF→ON→OFF 的成功、取消、讀取例外;初始 ON 保持 ON。回讀實際狀態,不以 command 返回或 finally 執行當作證據。 +5. 啟用等待逾時、設定已生效但回應不明、權限撤銷、斷線、復原紀錄寫入失敗與程序中斷殘留。 +6. 同庫快照作業互斥、不同庫獨立;外部 SNAPSHOT 交易令 OFF 等待時,有限清理與警語正確,不強制中斷他人。 +7. 一般模式的取消/DDL drift/原檔保護;設定清理問題不掩蓋檔案與鏡像的真實結果。 +8. 舊的一致性/Core/Geometry 等測試明確指定 SNAPSHOT,避免預設更改後原來的驗收含義悄悄改變。 +9. 全部 DB 測試僅使用既有授權的 (localdb)\mySQLPunkExportTest_20261012 及 token-owned fixture;原生 UI、Debug/Release、適當 smoke/獨立 review 與正式機驗收分別記錄。 + +## 官方依據 + +- [ALTER DATABASE 權限](https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-database-transact-sql?view=sql-server-ver15#permissions):需要資料庫 ALTER。 +- [ALLOW_SNAPSHOT_ISOLATION 開關](https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-database-transact-sql-set-options?view=sql-server-ver15):資料庫層級設定、過渡狀態、等待及取消行為。 +- [隔離等級](https://learn.microsoft.com/en-us/sql/t-sql/statements/set-transaction-isolation-level-transact-sql?view=sql-server-ver15):READ COMMITTED 與 SNAPSHOT 的一致性範圍。 +- [Application lock](https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-getapplock-transact-sql?view=sql-server-ver15):Session ownership、資料庫範圍與有限等待。 diff --git a/docs/design/mssql-export/snapshot-options-implementation-plan.md b/docs/design/mssql-export/snapshot-options-implementation-plan.md new file mode 100644 index 0000000..45b9d47 --- /dev/null +++ b/docs/design/mssql-export/snapshot-options-implementation-plan.md @@ -0,0 +1,189 @@ +# Optional MSSQL SNAPSHOT Export Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 匯出預設走一般 READ COMMITTED,使用者可選擇有權限的 SNAPSHOT;自動設定管理保留原狀並明確回報復原結果。 +**Architecture:** 保留現有 native wizard、owned DB/SSH session、單一資料交易、逐表串流及安全發布。新增獨立 capability、credential-free recovery journal 與 snapshot lifecycle;來源交易結束後先完成有限設定清理,再釋放連線與執行本機階段。 +**Tech Stack:** .NET Framework 4.7.2、WinForms、System.Data.SqlClient、既有 Newtonsoft.Json、MSBuild、既有 smoke/LocalDB harness;不加套件。 +**Spec:** [snapshot-options-design.md](snapshot-options-design.md),使用者於 2026-10-13 明確核准。 +**Status:** 2026-10-13 Task1–6 實作、逐項覆核與全分支覆核完成;兩項覆核修正通過 scoped review,controller 補齊最後 adapter 能力檢查邊界。七項隔離 profile 的原始證據、最終 199組 Release smoke、AutoReleasePolicy、Debug/Release build 及收尾判定見 verification.md。保留分支/worktree,未 merge/push/release。 +**Execution:** 延續已選定的分工實作與逐項覆核;完成每項後再交給下一位 implementer,最後整體覆核。所有 build/DB/native UI 執行由單一 owner 串行控制。 +**Checkout:** `D:/mytools/mySQLPunk/.worktrees/mssql-export`,`codex/mssql-export-preflight-compat`;產品基底 `34024b7`,設計 checkpoint `4286d57`。 + +## Global Constraints + +- 預設否;缺 ALTER 權限時選項停用,即使資料庫原本 ON 也是如此。此為已核准產品政策。 +- 一般模式明確使用 READ COMMITTED;不執行 ON/OFF、不改 RCSI、不用 NOLOCK;SchemaOnly 不開資料交易。 +- SNAPSHOT 原本 ON 保持 ON;只有本次由 OFF 啟用才嘗試恢復 OFF。啟用限時60秒,可取消;設定清理使用獨立30秒期限。 +- 禁止 KILL、ROLLBACK IMMEDIATE、SINGLE_USER 或終止其他連線作為產品清理手段;不在失敗後換身分/重連去執行 OFF。 +- 設定切換前落地復原紀錄;未知結果不得當作 OFF。來源讀取與設定清理結果各自保留,不掩蓋取消/原始錯誤/鏡像結果。 +- 原檔保護、bounded streaming、Geometry/LOB 原始值、可信任外鍵 WITH CHECK、metadata fingerprint、必要讀取權限保持。 +- SQL 與 UI 不含帳密、連線字串、原始 provider 例外或資料列;local journal 只含 allowlisted 操作 metadata。 +- 僅使用已授權 `(localdb)\mySQLPunkExportTest_20261012`、既有 authorization manifest、C: token-owned fixture;不存取正式庫/已存連線/帳號設定。 +- 保留既有 BOM/CRLF;不修改 unrelated files,不執行 ReportExportTests、不調整 AV、不 push/merge/release。 +- 使用 `build.bat`/既有 smoke runner;不以 `dotnet build` 取代舊式專案工作流。生成的大型 SQL、journal fixture、logs 保持 local-only。 + +## Review Focus + +1. ON 已生效但回應遺失:不能只靠 acknowledged bool 決定是否清理。Task3 fault seam、Task6 實際 ON 後注入例外驗證。 +2. 來源方法提前 Dispose/使用已取消 token:會讓 OFF 無法恢復。Task4 明確 FinishRead,Task6 取消/斷線驗證。 +3. 其他作業/DBA/同名重建庫:不能把共用開關當成 job-local。Task3 身分核對和 app lock;Task6 合作與外部交易情境。 +4. 本機紀錄毀損/錯誤身分/路徑導向:不得自動執行 OFF、刪掉不屬於本 job 的檔案或洩漏秘密。Task2/5/6。 +5. 預設改為否後測試悄悄失去一致性意義,或 UI 把清理算成表完成:Task1 明確舊測試模式、Task4/5 進度回歸、Task6 混合時間 FK 還原反例。 + +## Files and boundaries + +| 責任 | 檔案 | +| --- | --- | +| 新 immutable capability/identity/cleanup models | `mySQLPunk/lib/MssqlExport/MssqlSnapshotModels.cs` | +| 唯讀能力查詢與 SQL settings/app-lock adapter | `mySQLPunk/lib/MssqlExport/MssqlSnapshotBackend.cs` | +| durable 本機紀錄 | `mySQLPunk/lib/MssqlExport/MssqlSnapshotRecoveryStore.cs` | +| 設定生命週期 | `mySQLPunk/lib/MssqlExport/MssqlSnapshotLifecycle.cs` | +| request/catalog/schema/result integration | `MssqlExportModels.cs`, `MssqlExportMetadataReader.cs` | +| 交易、清理、結果與 SQL header | `MssqlExportSource.cs`, `MssqlSqlExportService.cs`, `MssqlSchemaScriptWriter.cs`, `MssqlExportProgressEstimator.cs` | +| UI、狀態與 notice | `mySQLPunk/MssqlExportWizardForm.cs`, `mySQLPunk/Form1.MssqlExport.cs` | +| 新 unit / live cases | `tests/SmokeTests.MssqlExport.Snapshot.cs`, `tests/SmokeTests.MssqlExport.SnapshotIntegration.cs`, `tests/MssqlExportFixtures/snapshot.sql` | +| 測試與編譯入口 | `mySQLPunk/mySQLPunk.csproj`, `tests/SmokeTests.cs`, `tests/Run-SmokeTests.ps1`, `tests/Run-MssqlExportIntegration.ps1` | + +## Shared contracts + +以下型別均 internal;未列修改的現有 public provider contract 不變。新 constructor 參數放最後,以明確預設值維持既有呼叫編譯。 + +- `MssqlExportRequest(..., bool useSnapshot = false)` 與唯讀 `UseSnapshot`;SchemaOnly + true 拒絕。Wizard 不記憶上次肯定選項。 +- `MssqlSnapshotIdentity(string serverName, string databaseName, int databaseId, DateTime databaseCreateDate)`:比對伺服器原生名稱、實際 DB 名/ID/create_date;create_date 按伺服器返回值比對,不猜時區。僅在當前存活 session 使用此身分授權清理,禁止拿歷史紀錄自動重連修復。 +- `MssqlSnapshotCapability(identity, int state, bool? canAlter, bool? isWritable)`,唯讀 `Identity/State/CanAlter/IsWritable`;`CanManage` 僅身分完整、state0或1、兩個能力為true。`UnavailableReason` 使用固定 enum,不能 provider 自由文字。 +- Catalog/Schema constructor 加最後的 `MssqlSnapshotCapability snapshotCapability = null`;保留 `SnapshotState`,有 capability 時須與 State 一致,null 表示能力未知。Preflight exposes `SnapshotCapability`;capability/identity 不加入 schema fingerprint。 +- `MssqlSnapshotCleanupState { NotRequired, PreservedOn, RestoredOff, Unconfirmed }`;`MssqlSnapshotCleanupResult(State, int? observedState, string recoveryRecordPath, string warningCode)`,全唯讀。 +- `MssqlExportResult` 加最後 `bool useSnapshot = false, MssqlSnapshotCleanupResult snapshotCleanup = null`;現有 Stage/PublishedPath/MirrorPath/Warnings 保留。復原問題不增加與每種匯出結果交叉相乘的 terminal enum。 +- 在現有 `MssqlExportStage` 尾端加 `EnablingSnapshot, RestoringSnapshot`,保留既有值。 +- `IMssqlSnapshotBackend`:`ReadCapability(CancellationToken)`、`TryAcquireLock(CancellationToken): bool`、`SetEnabled(bool, CancellationToken)`、`ReleaseLock(CancellationToken)`、`bool IsUsable`;SQL adapter 只借用既有 SqlConnection,沒有開新連線/Dispose owner 的權利。 +- `MssqlSnapshotRecoveryRecord(JobId, Identity, OriginalState, Phase, int? ObservedState, DateTime UpdatedUtc, string NoticeCode)`;Phase/NoticeCode 為固定 enum,版本欄位固定1。 +- `IMssqlSnapshotRecoveryStore`:`Create(record): string`、`Update(record)`、`RemoveConfirmed(MssqlSnapshotRecoveryRecord expected)`、`ReadPending(): IReadOnlyList`。Notice 含 validated record/path 或固定 read-error category;不含原始 exception。 +- `MssqlSnapshotLifecycle(backend, store, jobId)`:`Prepare(Action report, CancellationToken)`;`Restore(Action report): MssqlSnapshotCleanupResult`;`Release()`。Prepare/Restore 各有整體60/30秒 deadline,SQL CommandTimeout 取剩餘時間,使用 async command + token;不是每次查詢重新給滿期限。 +- `IMssqlExportSource.BeginRead(request, Action report, CancellationToken)`;加 `FinishRead(Action report): MssqlSnapshotCleanupResult`。CompleteRead 只做最後 schema 複核與 commit;FinishRead 明確關 reader/剩餘交易→恢復設定→放 lock/session。FinishRead 對一般 SQL/IO 清理錯誤回傳安全結果、冪等;Dispose 為最後資源釋放,不隱藏 ALTER。 +- `MssqlSqlExportService.LoadRecoveryNotices(MssqlSnapshotIdentity identity): IReadOnlyList`:純本機、可覆寫的 UI seam;所有 tests 注入 temp store,不讀真實使用者 journal。 + +下列 Task 的產品檔名縮寫一律位於 `mySQLPunk/lib/MssqlExport/`;Wizard/Routing/Worker/Schema/Metadata/Integration 測試縮寫分別指 `tests/SmokeTests.MssqlExport..cs`(Metadata 實際位於 `tests/SmokeTests.MssqlExport.cs`)。新 partial tests 必須加入兩個明確註冊入口。 + +## Task 1: 明確模式、能力模型與唯讀探測 + +**Files:** 新建 SnapshotModels/Backend;修改 Models/MetadataReader/csproj;新增 Snapshot unit partial 與兩個 test registrations;更新現有 request/schema test factories。 +**Consumes:** 已核准 spec;現有 read-only SqlCommand/cancellation owner。 +**Produces:** Shared contracts 的 request/capability/identity/result/stages;`MssqlSnapshotBackend.ReadCapability`,此時具體 Backend 類別只提供讀取,不宣告實作完整 IMssqlSnapshotBackend;Task3 完成其他 methods 後才實作該 interface,沒有產品 stub。 + +- [x] 新增 RED cases `SnapshotOptions_DefaultAndSchemaOnly`:`Assert(!request.UseSnapshot)`;SchemaOnly + true throws;`SnapshotOptions_CapabilityMatrix`:僅0/1 + ALTER=true + writable=true + identity完整可管理;`SnapshotOptions_Fingerprint`:相同 schema 的 capability 改變,`Assert(a.Fingerprint == b.Fingerprint)`。 +- [x] 用 `MYSQLPUNK_SMOKE_FILTER='MSSQL export snapshot options'` 執行既有 Release smoke runner,確認因缺少契約或行為失敗;記錄實際 RED。 +- [x] 實作 immutable models 與 request 參數。讀取 `sys.databases` 的 state/id/create_date/name、`SERVERPROPERTY('ServerName')`、`HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','ALTER')`、`DATABASEPROPERTYEX(DB_NAME(),'Updateability')`;明確 SQL CONVERT 回傳型別,NULL 保留未知。沿用 caller transaction/token,資料庫不存在/取消/斷線仍是真正錯誤。ReadCatalog/ReadSchema 傳遞 capability。 +- [x] 舊測試需要 SNAPSHOT 時明確傳 true;SchemaOnly 傳 false。先保留既有 engine validation,Task4 才切換 start/read 的狀態規則。不要透過將所有 fake capability 預設有權限來掩蓋新 unknown case。 +- [x] 同 filter GREEN;`build.bat debug`。保存命令與證據,只提交本項確切檔案,獨立覆核模型與權限語意。 + +## Task 2: 可追查的本機復原紀錄 + +**Files:** 新建 SnapshotRecoveryStore;修改 csproj、Snapshot unit partial、test registrations。 +**Consumes:** Task1 identity/record/notice contracts。 +**Produces:** `MssqlSnapshotRecoveryStore(string root = null)` 實作 IMssqlSnapshotRecoveryStore;null 才使用 spec 的 LocalApplicationData 路徑。 + +- [x] RED `SnapshotRecovery_DurableCreateAndUpdate`:Create 成功後能從另一個 store 讀回精確 job/identity/phase;已存在 job 檔拒絕覆寫;`SnapshotRecovery_UntrustedFiles`:壞 JSON、>64KiB、未知版本、檔名/job不符及 reparse path 不被信任;`SnapshotRecovery_NoSecretsOrUnsafeDelete`:沒有 connectionString/password/providerMessage 欄位,RemoveConfirmed 只刪 matching job 與 expected identity/original-state 的紀錄。 +- [x] 執行 filter `MSSQL export snapshot recovery`,取得 RED。 +- [x] 實作每 job `.json`、allowlisted DTO、UTF-8、原子同目錄 temp→move/replace。Create/Update 使用 FileStream.Flush(true) 在執行 ON 前完成落地;root/祖先/檔案 reparse 一律拒絕。只讀頂層,單檔64KiB,最多100筆加 overflow notice;不遞迴、不根據 JSON 中的 path 刪除。讀取失敗回固定安全分類;Create/Update 寫入失敗必須明確失敗,不能取得成功 receipt 或繼續 ON;不呼叫 SQL。 +- [x] 注入 IO fault 驗證 Create 未完成時 lifecycle 不得取得可用 journal receipt;Update/Delete 失敗保留可追查結果。全部用測試 temp root,保留 BOM/newlines 無關檔案。 +- [x] 同 filter GREEN,diff check;僅提交確切檔案,覆核持久性順序、路徑擁有權與機密邊界。 + +## Task 3: 有界的 SNAPSHOT 設定生命週期 + +**Files:** 新建 SnapshotLifecycle;完成 SnapshotBackend;修改 csproj、Snapshot unit partial、test registrations。 +**Consumes:** Task1 backend/capability;Task2 journal。 +**Produces:** Shared contracts 的完整 Prepare/Restore/Release;獨立於 data reader 的 deterministic settings state machine。 + +- [x] RED `SnapshotLifecycle_PreserveOnAndRestoreOwnedOff`:初始ON `Assert(backend.AlterCalls.Count == 0)`;初始OFF操作順序為 lock→fresh capability→journal flush→ON→confirm1→後續 Restore→OFF→confirm0→remove→unlock。`SnapshotLifecycle_UnknownEnableAndIdentity`:真ON後throw也要回讀/清理;身分改變不得ALTER;`SnapshotLifecycle_CancelDeadlineAndResidual`:已取消 token 仍走獨立清理,OFF失敗回Unconfirmed且journal存在。 +- [x] 執行 filter `MSSQL export snapshot lifecycle`,取得 RED。 +- [x] SQL adapter 用 resource `mySQLPunk.MssqlExport.SnapshotSetting.v1`、Exclusive、Session、public、LockTimeout=0;negative return code分類,不能當成功。所有 SNAPSHOT jobs(含原本ON)先 lock 後重新讀狀態,普通 jobs 不用此鎖。以既有 Quote() 處理 DB identifier,切換前再次核對存活 session 身分。 +- [x] 實作 Prepare:僅state0/1且能力符合政策;state0先寫 durable journal,記錄「已嘗試」再送ON,確認1才交出成功。任何 error 都保留 controller 供明確 Restore;不在 catch 提早 Dispose session。原本ON無 journal、無ALTER。 +- [x] 實作 Restore:呼叫者先釋放資料交易;獨立30秒deadline,讀實際狀態,匹配身分且本次原始OFF才可能送OFF。state0確認完成;state1嘗試OFF;transition/unknown/closed不能宣稱成功,有限回讀後保留紀錄。不得在已失去 session lock/連線後 reconnect。日誌更新/刪除與 lock release 失敗也以固定 notice 保留,不能抹去主錯誤。 +- [x] 完成 `SnapshotLifecycle_NoJournalNoAlter`、busy同庫、獨立db、NULL能力、withdrawnpermission、repeatedRestore、observerthrows 等矩陣;fake clock/deadline供 unit 使用,產品60/30常數不改短。GREEN、Debug build、精確提交與 lifecycle 覆核。 + +## Task 4: 串流引擎、明確清理與真實結果 + +**Files:** 修改 Source/Service/MetadataReader/SchemaScriptWriter/ProgressEstimator;更新 Worker/Schema/Metadata/Integration test wrappers 與 Snapshot unit cases。 +**Consumes:** Task1–3 contracts/controller/store。 +**Produces:** 一般與 SNAPSHOT 兩條完整來源流程,typed cleanup outcome 傳到結果;本機驗證開始前已釋放 DB/SSH。 + +- [x] RED `SnapshotWorker_ModeAndCleanupOrder`:ordinary=ReadCommitted、schemaonly無transaction、snapshot=Snapshot;`Assert(events.IndexOf("restore") < events.IndexOf("local-verify"))`;`SnapshotWorker_PrimaryFailureAndRecovery`:失敗/取消 + Unconfirmed 都保留;完整資料 + Unconfirmed 可發布且警告;`SnapshotWorker_ProgressDoesNotCompleteInterruptedTable`:data→restore 不增加 CompletedTables,沒有虛構ETA。 +- [x] 執行 filter `MSSQL export snapshot worker`;同時記錄現有 worker/filter 的受影響斷點。 +- [x] 拆開 validation:`Validate(schema,request)` 僅物件/必要讀取條件;新增 `ValidateStart(schema,request)` 合併前者及選是時的 capability gate(允許state0/1)。Source Inspect 使用 ValidateStart;writer與最終schema複核只用 Validate,不能於寫DDL時因設定管理權限改變而假裝結構不支援。BeginRead 的 Prepare 完成後另外確實檢查 state1,才建 SNAPSHOT transaction。 +- [x] Source 依UseSnapshot建立明確交易;OpenRows維持既有持續reader與bounded LOB。改掉 BeginRead/OpenRows/CompleteRead 中會先丟失session的 Dispose 路徑;Service 在 nested finally 一定呼叫 FinishRead,再 Dispose。清理使用不檢查作業取消的 report callback,不能重用現有會 ThrowIfCancellationRequested 的 phase callback。交易無法確認結束則釋放owner、留下Unconfirmed,不能在活躍/未知交易上送ALTER。FinishRead正常SQL/IO錯誤回安全結果,不用 canceled token,也不覆寫 primary error。 +- [x] SQL header 在 DataOnly early-return之前寫明模式;ordinary result加入 spec 警語,維持可信任FK WITH CHECK。補 typed result設定復原結果與journal殘留路徑;單純警語不改 PublishedPath/Stage 的檔案真實性。 +- [x] 新階段採 unknown ETA/等待顯示。ProgressEstimator 的 table change 不能在 RestoringSnapshot 將中斷表算完成;完成來源階段仍能正確統計最後一表。注入callback failure、file verify/publish/mirror failure覆核清理只做一次。 +- [x] 新filter及既有 `MSSQL export worker`、`schema`、`metadata`、`lob`、`values` GREEN;Debug/Release串行build;精確提交後覆核資源所有權與結果優先序。 + +## Task 5: 精靈選項、恢復提示與主視窗狀態 + +**Files:** 修改 Wizard/Form1.MssqlExport;Service加local-notice seam;更新 Wizard/Routing smoke cases與fixture preview。 +**Consumes:** Catalog/Preflight capability、request.UseSnapshot、result.SnapshotCleanup、Task2 pending notices、Task4 progress。 +**Produces:** 原生可操作是/否選項與完整中文/英文提示;舊三步選擇不被重載清空。 + +- [x] RED `SnapshotWizard_DefaultDisableAndRadioGroups`:初始否、無ALTER/NULL/轉換中/SchemaOnly停用;snapshot radios獨立容器,不改mode radio選取。`SnapshotWizard_RecheckAndGeneration`:能力下降清楚改否並清除prepared,不能沿用Yes確認直接開跑;過期結果不恢復按鈕。`SnapshotWizard_RecoveryAndTerminalStatus`:主窗與結果均顯示Unconfirmed,cancel時仍看得到RestoringSnapshot。 +- [x] 執行新filter `MSSQL export snapshot wizard` 取得RED。 +- [x] BuildOutput加入具名radio panel `mssqlSnapshotOptions`、`mssqlSnapshotYes/No`、`mssqlSnapshotReason`、`mssqlSnapshotWarning`、`mssqlRecoveryNotices`。缺能力不令catalog loaded=false;保留SELECT/definition錯誤。設定頁可重新檢查能力;以現有generation/cancellation保護非同步更新。 +- [x] PreflightAsync捕獲immutable選項,移除一律state1門檻,按選項顯示ordinary警語或temporary-setting說明。確認/SQL/結果的mode一致。新表單預設否,SchemaOnly強制否,切回資料模式也不擅自恢復是。 +- [x] LoadRecoveryNotices純本機讀取,依已讀原生identity關聯;journal無法讀取/過量時有安全提示。展示已知狀態/時間/path與人工查詢方式,不執行OFF、不自動刪歷史紀錄。UI測試inject temp store/fake notices。 +- [x] RenderProgress允許cancelRequested時呈現RestoringSnapshot;titlebar關閉仍等待明確清理。BuildMssqlExportStatus針對Completed+Unconfirmed顯示帶警告完成,Failed/Canceled/MirrorIncomplete保持原始狀態並附復原警告。radio/readout在640×480與既有縮放fixture可達。 +- [x] 新filter及既有wizard/diagnostic/routing GREEN;原生fixture深淺色驗收(不是HTML),保留Punky與mute行為;精確提交與UI/結果覆核。工具不可用時明確記pending,不假造截圖。 + +## Task 6: 隔離實際驗收與交付 + +**Files:** 新建 SnapshotIntegration partial、snapshot.sql;修改 Integration/CompatibilityIntegration、兩個runner與registrations;更新 docs/MSSQL_SQL_EXPORT.md、design.md、production-preflight.md、verification.md,追加 ignored history。 +**Consumes:** 完整產品Task1–5;原有token-owned fixture/authorization/classic sqlcmd。 +**Produces:** OptionalSnapshot/SnapshotLifecycle實際證據及新模式回歸,沒有正式機成功推論。 + +- [x] 為profile加入RED驗收:`OptionalSnapshot`、`SnapshotLifecycle` 必須明確dispatch;fixture建庫初始ON/OFF可選,不能總是先ON。Request helper的既有data案例明確Snapshot=true,SchemaOnly=false;新ordinary案例false。注入的journal root限fixture內。 +- [x] OptionalSnapshot:ON/OFF普通模式+WITHOUT LOGIN無ALTER帳號實際匯出還原;原state不變且command audit無ALTER;必要讀取權限拒絕不退化成成功。明確Parent-before-Child次序,Parent讀完後由第二連線合法新增Parent2、把Child改指向2;ordinary還原必須可信任FK失敗,Snapshot同一操作還原成功。另驗RC取消/schema drift/舊檔保護。 +- [x] SnapshotLifecycle:OFF→ON→OFF完成/取消/讀取失敗;初始ON皆保持ON。持有已授權測試writer造成ON等待,觀察transition後放行/取消/一次真實60秒timeout;外部owned SNAPSHOT實際讀取後持有,觀察OFF等待30秒與Unconfirmed。另驗同庫busy、以已owned Target作第二來源的跨庫獨立、ALTER撤銷、journal IO失敗、真ON後注入ack例外、只關閉exporter自身SqlConnection造成斷線;後兩者分別標記fault injection,不冒稱真實網路故障。 +- [x] 程序中斷案例:由parent fixture以既有SmokeTests.exe窄範圍child模式啟動工作,不另建fixture;child驗證server/token/root/database白名單,durable journal及ON後送ready。child以hidden window/CreateNoWindow啟動;parent僅終止自己記錄的child PID,觀察DBstate/journal與下次read-only notice。測試owner人工回復資料庫屬fixture teardown,與產品自動清理證據分開。 +- [x] 修改已過時斷言:Core的OFF一律拒絕改成新capability/模式;Properties的無ALTER + ON成功案例明確ordinary,另加選Snapshot遭拒;Wizard state gate也更新。不得刪除真正permissions/unsupported gates來湊PASS。 +- [x] 執行以下live profiles,依每次exitcode停止並保留失敗證據;每個generated DB需記錄清理。每個還原保存實際engine/client、artifact bytes/hash、source/target比較、snapshot最終state、journal及實際restoreexit。 + +```powershell +$fixture = Get-Content -LiteralPath '.superpowers/sdd/implementation-plan/fixture-environment.json' -Raw | ConvertFrom-Json +$sqlcmd = 'C:\Program Files\Microsoft SQL Server\Client SDK\ODBC\170\Tools\Binn\SQLCMD.EXE' +foreach ($profile in @('OptionalSnapshot','SnapshotLifecycle','Core','Concurrency','Properties','References','Geometry')) { + $out = Join-Path $fixture.FixtureRoot ($profile + '-' + [guid]::NewGuid().ToString('N')) + powershell.exe -NoProfile -File tests/Run-MssqlExportIntegration.ps1 -Run -Server '(localdb)\mySQLPunkExportTest_20261012' -Profile $profile -Client Classic -SqlcmdPath $sqlcmd -OutputDirectory $out + if ($LASTEXITCODE -ne 0) { throw "$profile failed; preserve evidence" } +} +``` + +Runner保留原本server/token/path/reparse/20GiB gates。需要時依既有授權啟動該專用instance;不更改runner自動啟動policy,不使用正式連線。已授權test teardown的generatedDB強制清理與產品禁止強制OFF分開。 + +- [x] 清除本task測試環境變數後跑完整Release smoke及AutoReleasePolicy,再串行Debug/Release build。指令見下方;AV攔截就報pending,不改名重跑或變更防毒。獨立全分支review聚焦DB狀態殘留、混合時間資料/FK、權限與檔案真實性;無新material finding即停止review循環。 +- [x] 更新使用說明與舊spec的被取代段落,保留歷史證據原日期。SQL2025 LocalDB與正式SQL2019、模擬與真實失敗、nativefixture與真人長作業分開。先前>2GiB未重跑不得稱本版已重驗;此改動不改buffer/codec尺寸,Geometry/Core回歸覆蓋新的來源生命週期。精確提交程式/測試/文件,保留worktree供使用者試跑,不自行merge/push。 + +本機七 profile、完整 Release smoke/AutoReleasePolicy、串行 Debug/Release 與原生 fixture 已通過;上項仍未勾選是因獨立 Task6/全分支 review 待 controller 完成。 + +## Commands and completion gate + +每項RED/GREEN使用現有runner;新的group前綴按各Task命名註冊,filter是字串比對。獨立測試process不承襲live profile。 + +```powershell +$env:MYSQLPUNK_MSSQL_INTEGRATION = $null +$env:MYSQLPUNK_MSSQL_WIZARD_PREVIEW = $null +$env:MYSQLPUNK_SMOKE_FILTER = 'MSSQL export snapshot options' # 依Task改成其明確filter +powershell.exe -NoProfile -File tests/Run-SmokeTests.ps1 -Configuration Release +if ($LASTEXITCODE -ne 0) { throw 'Inspect RED or failed regression evidence' } +# 最終全套(不能沿用上述filter) +$env:MYSQLPUNK_SMOKE_FILTER = $null +powershell.exe -NoProfile -File tests/Run-SmokeTests.ps1 -Configuration Release +if ($LASTEXITCODE -ne 0) { throw 'Full smoke failed' } +.\build.bat debug +if ($LASTEXITCODE -ne 0) { throw 'Debug build failed' } +.\build.bat release +if ($LASTEXITCODE -ne 0) { throw 'Release build failed' } +git diff --check +if ($LASTEXITCODE -ne 0) { throw 'Whitespace check failed' } +``` + +完成需同時有:mode/capability與cleanup單元證據、受控寫入下兩種模式的實際還原差異、有限等待/中斷復原證據、原生UI或明確pending、最終build/smoke、覆核結論與history。所有測試結果在執行後填入verification,不提前填PASS。 + +## Plan self-review + +已核對spec各段→Task1–6覆蓋;新型別與介面有單一擁有者;設定管理與物件驗證分開;cleanup在source dispose之前;journal未知結果、progress與FK反例都有明確測試。shared files/build目錄由Task順序控制,禁止同時改相同檔案或共用bin編譯。此段為計畫簽核時的 self-review;後續實作與隔離 DB 驗證見 verification.md,正式設定未修改。 diff --git a/docs/design/mssql-export/verification.md b/docs/design/mssql-export/verification.md index 74acde2..d39fd0a 100644 --- a/docs/design/mssql-export/verification.md +++ b/docs/design/mssql-export/verification.md @@ -186,3 +186,118 @@ The first live run had already passed artifact restoration, byte hashes and cata Cross-version internal serialization compatibility, physical native UI/audio/DPI, long real-job UI, SSH, production, geography, spatial-index DDL and near-theoretical size limits remain unverified or unsupported as stated. Earlier >2GiB acceptance remains separate and was not rerun for this codec extension. Final product commit `92fda819f72b9307df5c37d7901d966b845dd1cf`: focused metadata6/values5/LOB4/schema6, full existing smoke151 (live integration explicitly SKIP in that run), AutoReleasePolicy, explicit Debug/Release builds and diff check PASS. Existing cached-package/NuGet-executable warning remains; no new dependency. Logs/report are in `.superpowers/sdd/geometry-implementation-plan/`. Independent review of immutable extension diff `5edf69d..e3d4998` is APPROVED with no actionable findings. The reviewer independently traced native metadata identity, same-SNAPSHOT projection, bounded continuation, final Deserialize rollback/poisoning and the actual malformed-payload restore log, without rerunning tests or DB work. Report: `.superpowers/sdd/geometry-implementation-plan/geometry-review.md`. Its unjudged cross-version, theoretical-capacity, server-resource, geometry-scaling and previous UI/SSH/production boundaries remain as stated; no additional acceptance is implied. The affected Core profile also passed after the verifier began resolving native types through user_type_id: `/geometry-core-9fc8f25c34d04967855d22531dfb9f65/Core-1`, artifact178,948,509bytes SHA256 `80afc52efd6cb8a00b4f312428f2bf91d2274f7e7b684e5bcb417005f4ce9159`, ownership token `2bcec8beffef4c2b95f693a047f47cd4`. Existing64MiB binary/>16MiB Unicode, catalog/raw hashes, identity/computed/rowversion, mirror, unsupported/visibility/SNAPSHOT gates, and all five chunk-fault/continuing-importer cases passed. Exact owned DBs were cleaned. This rerun replaces no earlier Large/Concurrency/native UI evidence. + +## Production extended-property and permission acceptance (2026-10-13) + +The current production-compatibility branch adds explicit prerequisite diagnostics/retry and typed extended-property support. Independent focused reviews found no material defects in either scope. Before the view-reference implementation, the full existing smoke suite passed162 groups plus AutoReleasePolicy; this was local test/control evidence, with ordinary live integration explicitly skipped. + +PASS actual `Properties` profile on the already authorized dedicated SQL2025 LocalDB17.0.4025.3, using installed x64 classic sqlcmd15.0.1300.359 with one `-E -b -x -f 65001 -i` session per artifact. Evidence: `/production-properties-5e26735bfbf84520aa7a82bcf9188a4e/Properties-1/evidence.json`; owner token `ee7741b98efe4f6d841029a75ac1dd8f`. Exact owned source/target databases were checked and dropped. The instance had stopped while idle; it was restarted under the existing dedicated-instance authorization, without changing production. + +- SchemaAndData artifact36,011bytes, SHA256 `2c54e7b60914e17cdba6dd3167d771bdf27337da4db2cbf09d8d66050a9fe078`, restored successfully into a differently named target. Native property ownership/type/scope/column name/name/BaseType/MaxLength/Precision/Scale/Collation/raw hex/NULL compared equal. +- Cases include SSMS diagram text7,006bytes, near-limit7,400bytes, int min/max, custom table/view/column properties, descriptions, empty literal/declared empty, short value with long declared type, Unicode/surrogate pairs/quotes/CRLF/GO/sqlcmd-like text and trailing spaces. Source/target defaults are BIN2 while property text uses Chinese_Taiwan_Stroke_CI_AS. A dropped source column creates an ordinal gap; restored properties attach to the correct column by name despite new ordinals. +- SchemaOnly and DataOnly artifacts succeeded; schema-only restored empty tables with exact properties; data-only preserved preexisting target properties and inserted data without duplicate property DDL. Actual value/collation drift changed fingerprints and failed publication while preserving an existing output file. Unsupported varchar/decimal properties and indexed/triggered views remained explicit preflight failures. +- An owned database user WITHOUT LOGIN, with ALTER=0, successfully exported data with SNAPSHOT already ON. Missing VIEW DEFINITION produced the typed diagnostic and no file; denied table SELECT blocked data but permitted schema-only export; denied SELECT on sys.sql_expression_dependencies produced the safe permission category. No server login or production grant/configuration was changed. + +The production user supplied SQL2019 catalog metadata, not an export/restore result. Actual production retry, SQL2019 engine restore, final native retry-button observation, physical DPI/audio and long real production-job behavior remain pending. Same-database reference implementation and its separate acceptance are still in progress at this checkpoint. + +## Same-source view reference acceptance (2026-10-13) + +PASS actual `References` profile on SQL2025 LocalDB17.0.4025.3 with the same classic x64 sqlcmd15.0.1300.359 client. Evidence: `/production-references-5f2a6b157d9f446f9aaba83f2d383f53/References-1/evidence.json`; owner `ce219c380e0e47fc83028d72d38b100a`. Exact owned source/target databases were checked and dropped. Artifact2,132bytes, SHA256 `d4db43b6f632e7ddec9a6a3cef3a3b94d3e53c23512db557c2b4bb2bb62630f2`, restored in one process with exit0. + +- Bracketed/double-quoted qualified references, inter-token comments, database-like literals/comments, CTE/nested joins and a dependent ALTER VIEW restored successfully. Dependency ordering created the base view before its dependent view. +- Source remained online and accessible. After exporting `exported`, source was changed to `source_only`. Both target views still returned `exported`; after a target-only update both returned `target_only`, while the source view remained `source_only`. Target catalog dependencies had local resolved IDs with no server/database qualifiers. +- Actual other-database, omitted-schema, unselected-object, four-part-column and existing-local-server four-part references all failed preflight and preserved existing output files. No linked server was created. Pure parser tests separately cover malformed modules, unsupported function/rowset contexts, quoted identifiers, exact token preservation and incomplete catalog coverage. +- The first actual run failed before publication: SQL Server's DB_ID result was smallint/System.Int16, incompatible with SqlDataReader.GetInt32. A direct provider metadata probe established the cause. Resolver SQL now explicitly converts both DB_ID expressions to int; the full References profile above passed after this correction. The generic dependency reader already used conversion and needed no change. Failed-run evidence is retained separately; both failed-run owned databases were cleaned. + +Independent scoped review found no material reference/parser/identity/fingerprint/writer issues. Unit RED/GREEN evidence covers helper and metadata integration; final reference7, metadata6, schema6 and properties5 focused groups passed before the live run. Production SQL2019 retry and restore remain unverified; DBA-reviewed SNAPSHOT enablement is still required for data export on the reported OFF database. No production settings/data/permissions, original checkout, merge/push/release or account configuration were changed. + +## Final combined compatibility verification (2026-10-13) + +After the actual provider DB_ID type correction, the References profile above passed and the complete Properties and Core profiles were rerun on the final implementation. All used the existing authorized SQL2025 LocalDB17.0.4025.3 and classic x64 sqlcmd15.0.1300.359; all exact token-owned source/target databases were checked and dropped. + +| Profile | Final evidence directory under fixture-root | Main artifact | SHA256 | +| --- | --- | --- | --- | +| Properties | production-final-properties-9a069435643e42e5a3ee5710c6baec83/Properties-1 | 36,011 bytes | 310060f624dc7a88d9c0c481df77ae670a97001a995fda403e4c6f9e6d5624f6 | +| Core | production-final-core-1f38975b8fae448e86965d83241a2bd8/Core-1 | 178,948,915 bytes | ea20fbdf2b752a1e3559ee3493449633f0e7ad1ce387efb15197a369005d31c4 | + +Properties again passed the complete native typed-property comparison, all three modes, value/collation fingerprint drift, unsupported-feature rejection and actual restricted-user cases. Owner token: 4585a1b484bc4809b1e4803debc43f8b. Core passed one-session main-artifact restore (exit0), raw streaming hashes/catalog equality, identity/computed/rowversion, mirror, permission/SNAPSHOT/unsupported-object gates and all five adversarial continuation/replay cases. Owner token: ef4fadf700b842ac9e67bd518a24d0ca. Expected negative-case restore exits remain recorded in evidence.json and are not main-artifact failures. + +Fresh full Release smoke: **169 groups passed**, plus AutoReleasePolicy. Live integration is explicitly skipped by that ordinary suite and is evidenced separately above. Explicit sequential `build.bat debug` and `build.bat release` both passed. ScriptDom18.0.0.0 was verified in both output directories. Original working-file BOM/newline styles were preserved for all18 modified tracked files; direct Git blob comparison was unsuitable because this checkout uses core.autocrlf=true. `git diff --check` passed. Logs: `.superpowers/production-preflight/final-full-smoke.log`, `final-build-debug.log`, `final-build-release.log`, `final-properties-live.log`, `final-core-live.log` and `references-live.log`. Cached-package restore remains the local build path; no new clean-machine installation claim is made. + +Three independent scoped reviews (diagnostics/retry, properties, parser/dependencies) reported no material findings. Final production SQL2019 export/restore, actual production view bodies outside the bounded FROM/JOIN support, native retry-button observation, physical DPI/audio and long real production jobs remain pending. The reported Transfer database still has SNAPSHOT OFF and needs DBA-reviewed enablement for data modes; no production ALTER/GRANT/connection was performed. Prior Geometry, >2GiB and native UI evidence retains its original provenance and was not rerun by this compatibility fix. No merge/push/release has occurred. + + +## Optional SNAPSHOT final isolated acceptance (2026-10-13) + +The approved optional-mode policy supersedes the historical snapshot-required/no-ALTER statements above. Default ordinary READ COMMITTED, strict trusted-FK restore, optional ALTER-gated SNAPSHOT and independent restoration warnings were exercised on SQL2025 LocalDB17.0.4025.3, x64 System.Data4.0.0.0, classic x64 sqlcmd15.0.1300.359 (ODBC170). All seven profiles ran sequentially with explicit Release configuration, using the original authorization manifest and ownership/path/reparse/20GiB gates. No product fix was necessary in Task6; intermediate failures below corrected test orchestration assumptions. + +**PASS:** seven profiles,57 named scenario checks,29 actual one-session restore invocations:17 exit0 and12 expected negative exits. Negative exits cover the deliberate ordinary mixed-time FK547 and adversarial artifacts, not failed successful-export acceptance. Each evidence.json retains export mode/cleanup/state/journal/audit, every restored artifact's bytes/SHA256, actual sqlcmd command/exit and source/target comparison assertions. Client identity is recorded in each parent directory's sqlcmd-identity.json. + +| Profile | Scenario checks | Restore exit0 / expected nonzero | Evidence directory under fixture-root | +| --- | ---: | ---: | --- | +| OptionalSnapshot | 8 | 5 / 1 | `s6-Opt-5e1bb1dd953f42a0a95e6535fdf43206/OptionalSnapshot-1` | +| SnapshotLifecycle | 16 | 3 / 0 | `s6-Sna-35a6c4368d5349a0b81c2380ee8679aa/SnapshotLifecycle-1` | +| Core | 8 | 1 / 6 | `s6-Cor-1aed161256254b93b143dd4f8fc853e3/Core-1` | +| Concurrency | 11 | 1 / 0 | `s6-Con-af9ae29a15a543f5a8ba21e0752bc2ef/Concurrency-1` | +| Properties | 4 | 3 / 0 | `s6-Pro-9cc561c189ff46178211189cffb8d16d/Properties-1` | +| References | 3 | 1 / 0 | `s6-Ref-e7f71045b06049fcb4297edf71889d26/References-1` | +| Geometry | 7 | 3 / 5 | `s6-Geo-325d078a080e4eff9769ff46e7f5f506/Geometry-1` | + +Representative actual artifacts (all restore artifacts, including deliberately corrupted copies, are hashed separately in evidence.json): + +| Profile / artifact | Bytes | SHA256 | +| --- | ---: | --- | +| OptionalSnapshot / mixed-time-True.sql | 2,153 | `7a7ac2742352fe5247f7bb213fe8b4f216762689d35ffc709baae3b287307193` | +| SnapshotLifecycle / blocked-off.sql | 2,153 | `fbf66d1ad65997c143c7853f868f199c5eda73a74ae3d76de83ce38590e2f7a2` | +| Core / export.sql | 178,948,946 | `a8030a7570b2d2bb2c7ce9a9b3a35f0862155ad2ed6ef08a1c9bd48fe2102852` | +| Concurrency / export.sql | 2,125,694 | `d233f3f120973824a93882606bff6dd9aab79cdd47f2f60af72da221a425ae3c` | +| Properties / properties-all.sql | 36,042 | `a6a7f10372e586ea8a233383ff8037c0315ced238b9aaf0fca038c04ec566841` | +| References / references.sql | 2,163 | `12dc5849bf4130f6e0500db55302b3c4ea97e7236ce51d325d7a45f4d1e461e8` | +| Geometry / geometry.sql | 36,193,084 | `23d2843a0b4cbe216610b9fea549b75c408742a152d53aefae37dc8f48d11472` | + +- Ordinary initial OFF is genuine fresh-database OFF. Owner and WITHOUT LOGIN/no-ALTER users exported and restored under both OFF and ON; source state stayed unchanged and backend settings audit remained empty. Required table SELECT denial still failed. Explicit Parent-before-Child consumption allowed a legal second-connection insert Parent2/update Child→2: ordinary artifact restored Parent1/Child2 and failed exactly FK_Snapshot_Child_Parent/547; selected SNAPSHOT restored Parent1/Child1 with enabled, trusted FK. Ordinary cancellation and real DDL drift preserved the old output. +- OFF→ON→OFF and original ON preservation passed completion/cancel/read-error cases. A real owned write transaction produced ON transition3 before release/cancel/timeout. Final true enable timeout was60.1196531s, actual state0/RestoredOff. A separate owned SNAPSHOT transaction actually read a row, produced OFF transition2 and held it through the independent30s cleanup:30.7162529s, artifact Completed and restored exit0, cleanupUnconfirmed, actual finalON, durable journal2bdc14c3827d461cad0c7476e725117b.json. Releasing that reader still leftON after ALTER cancellation; fixture-owner explicit OFF was teardown, not product restoration. +- Same-DB second acquisition returned busy in the backend audit. Already-owned Target acquired independently while the first source lock remained; both reads ended before restoration, and both settings returnedOFF. An earlier diagnostic attempt to restore Target while the older source SNAPSHOT remained observed DISABLE_VERSIONING, Unconfirmed and finalON. This engine version-retention wait is distinct from app-lock independence. [Microsoft's DISABLE_VERSIONING definition](https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-objects/sys-dm-os-wait-stats-transact-sql?view=sql-server-ver17) explains its earliest-active-transaction timestamp wait; the LocalDB observation is retained in task6-wait-observation.md. +- Live ALTER revocation failed safely before ON. A fixture-local journal obstruction sent no ALTER. Real ON followed by an injected acknowledgement exception restoredOFF. Exporter-only SqlConnection.Close retainedON/Unconfirmed/journal; these last two are fault injection, not proof of real network-loss handling. +- Actual hidden same-executable child PID31748 was terminated only after parent-verified durable journal andON readiness (job199c27cb-5f06-4245-a297-09d45a694ed2). State remainedON and the next read-only notice did not repair it. Child revalidated server/manifest/token/root/reparse ancestry/physical DB paths; owner OFF and database DROP were separately recorded fixture teardown. +- Final14 generated source/target databases were dropped. Across every Task6 successful/failed attempt, all28 generated databases have individual drop timestamps and retained evidence; a fresh exact-name whitelist query returned0 remaining. All artifacts/journals remain local-only. No production database was touched. + +Failed evidence is retained under fixture-root: OptionalSnapshot-640700b3d22c4529863e72c224d6947e (wrapper must complete with original frozen-schema identity); SnapshotLifecycle-f192aa29e33940e3a32df104a77b8ddb (canceled OFF revertsON); SnapshotLifecycle-65ef985e996b49ed8da5d675e5689b6d and SnapshotLifecycle-9269500aba7244f9a8a4541062d3cafc (cross-DB version retention); snapshot-final-OptionalSnapshot-c96457625d2948f98647653f49fb67a9 (verbose evidence prefix exceeded legacy .NET path length before publication). Only harness choreography/evidence-path naming changed; ownership, restore and product assertions were preserved. The initial RED runner rejected the new profile before any DB creation. + +Final unfiltered `tests/Run-SmokeTests.ps1 -Configuration Release`: **194 groups PASS**, plus AutoReleasePolicy. The native IdentityUnknown notice assertion passed inside the recovery/status group. Explicit sequential `build.bat debug` and `build.bat release` both exited0; `git diff --check` exited0. Checks completed2026-10-13T08:12:18.3975724Z. Logs/exit manifest: `.superpowers/sdd/snapshot-options-implementation-plan/task6-full-release-smoke.log`, `task6-build-debug.log`, `task6-build-release.log`, `task6-final-checks.json`; live summary and28-name cleanup inventory/readback are beside them. At this checkpoint Task6 and whole-branch review were pending; final disposition is recorded below. + +Boundaries: SQL2019 production export/restore, SSH, actual network/power failure, physical DPI/audio and human long production jobs remain unverified. Native WizardService fixtures cover the real WinForms IdentityUnknown/mismatch notices and mode/cleanup surfaces without reading user journals; they are not human production acceptance. Earlier >2GiB evidence retains its original date and was not rerun; current Core/Geometry restore and streaming hashes exercise the unchanged codecs with the new lifecycle. Cached packages built successfully where noted; nuget.exe remains absent, so no fresh restore/install claim is made. No merge/push/release occurred. + + +## Optional SNAPSHOT final review fixes (2026-10-13) + +The final review identified two user-visible start failures: a selected/confirmed SNAPSHOT capability withdrawal and cooperative same-database lock contention both reached the worker as generic failures. The worker/source now preserve an allowlisted immutable SnapshotUnavailable or SnapshotBusy category and current capability, independent of setting cleanup. The native wizard clears the prepared request and returns to confirmation with Back/Recheck, preserving table/output/mode choices. A capability decline explicitly switches to No and displays the ordinary READ COMMITTED warning; a fresh explicit Recheck and Start are required. Busy explains retry later. No automatic ordinary-mode retry occurs. Cleanup Unconfirmed, actual observed state, warning codes and residual journal paths remain visible on that confirmation, including after subsequent recheck failure. + +New real-worker/lifecycle/source-guard regressions cover Inspect rejection, Prepare capability withdrawal, partial-journal cleanup warning, post-enable source rejection with ON/Unconfirmed, and actual lifecycle lock refusal. The native WizardService fixture first successfully confirms Yes, then consumes those actual worker results after Start; it verifies preserved selection/path, blocked Start, explicit mode review/recheck and retained recovery details. A provider-like exception message cannot impersonate SnapshotBusy or leak its payload. + +The test-only sqlcmd process helper now continuously drains bounded tails (65,536 characters per stream), kills only the Process.Start-owned child on timeout, records actual exit/termination/output-completion truth plus available stdout/stderr before throwing, and still rejects the timeout. A same-executable synthetic child tested success exit0, explicit exit7 and timeout/kill/reap; no live database or long sqlcmd wait was needed. Existing fixture/database ownership gates remain unchanged. + +RED: the new late-start worker test lost SnapshotUnavailable and the actual lifecycle busy test lost SnapshotBusy to generic InvalidOperationException; the native post-confirmation fixture timed out waiting for its missing Recheck action. Logs: `.superpowers/sdd/snapshot-options-implementation-plan/final-fix-red-unavailable.log`, `final-fix-red-busy.log`, `final-fix-red-wizard.log`. GREEN: focused unavailable/busy/native/timeout groups and final snapshot filter29 groups plus policy passed. Final unfiltered explicit Release smoke **198 groups PASS**, plus AutoReleasePolicy; serial `build.bat debug` and `build.bat release` exited0. Final logs/receipts: `final-fix-green-snapshot.log`, `final-fix-green-timeout.log`, `final-fix-full-release-smoke.log`, `final-fix-smoke-check.json`, `final-fix-build-debug.log`, `final-fix-build-release.log`, `final-fix-build-checks.json` in that same local-only plan directory. Builds completed 2026-10-13T08:34:51.5100302Z. All scoped source/test files retain UTF-8 without BOM and CRLF; whitespace checks passed. + +Boundaries: no new live DB run was performed for these typed-result/UI/test-harness changes. The seven previous LocalDB profiles,57 scenario checks,29 restores and28-name cleanup inventory retain their original provenance above; they do not newly verify this final UI flow on a real database. SQL2019 production, SSH, actual network/power failure, physical DPI/audio, human long jobs and fresh dependency restore remain pending. Cached packages were used; no package installation, production/account/AV action, merge/push/release occurred. The scoped independent review addressed both findings with no new breakage; the controller closeout below records its narrower adapter observation and correction. + + +## Optional SNAPSHOT controller closeout (2026-10-13) + +All six tasks passed their independent task reviews. The whole-branch review of f0d4f56..38bef4b found two P2 start-outcome gaps and one minor test-timeout diagnostics gap. Fix bba05e5 addressed all three; the one scoped review found no new breakage and confirmed the original findings addressed. It also identified a narrower unchanged adapter race: capability could change after lifecycle checks but before the adapter's final ON preparation. The controller verified and corrected that exact guard locally, without restarting broad review or delegating another fix wave. That final small correction was self-reviewed and tested, not independently re-reviewed. + +The actual adapter SetEnabled preparation boundary now carries the sealed SnapshotUnavailable exception as the positive NotAttempted exception's inner cause only for ON capability/state refusal. Lifecycle still drops mutation ownership before preserving that cause. OFF cleanup refusal, identity/session failure, unknown provider errors and exceptions after execution begins keep their prior classifications. A new actual adapter/lifecycle/worker regression first failed for loss of the decision, then passed for revoked ALTER and externally changed state1/state2. It confirms exact refreshed capability, no rows/output/ON/OFF, unchanged external state, closed fixture connection and removed own journal. A valid ON capability remains accepted; invalid OFF remains cleanup-only. No live DB connection was opened for this narrow test. + +Final verification after the guard correction: snapshot filter30 groups PASS; unfiltered explicit Release199 groups plus AutoReleasePolicy PASS; serial Debug/Release builds and whitespace check exit0. Evidence: .superpowers/sdd/snapshot-options-implementation-plan/adapter-guard-red.log, adapter-guard-green.log, adapter-guard-full-release.log, adapter-guard-build-debug.log, adapter-guard-build-release.log and adapter-guard-final-checks.json. The final full suite and builds contain all product/test changes; only documentation/status/history is updated afterward. Existing BOM and newline styles were retained. The previously recorded seven live profiles/29 restores/zero remaining owned databases retain their original provenance, not a new live run of final typed-result changes. + +Controller rulings, in order: + +| Ruling | Reason | Cost if wrong | +| --- | --- | --- | +| Task2 supplies recovery models in SnapshotModels and the store interface in RecoveryStore. | Closes the Task1 contract allocation gap without stubs. | Internal type/file rework; product policy unchanged. | +| Restore includes bounded lock release before returning its immutable result; Release remains idempotent fallback. | Otherwise release failure cannot reach the returned result. | Cleanup-order rework while preserving observed state and residual notices. | +| Uncertain owned transaction/lock cleanup evicts the matching connection pool before source disposal. | Pooled Dispose alone does not assure immediate session logout. | Matching pooled connections need reconnect; no active-session termination/ClearAllPools. | +| Recovery notices include matching server/database names with changed or unknown native identity, visibly unconfirmed. | Exact identity matching would hide old records after recreation. | Additional manual-review notices; never automatic repair. | +| Finish the narrow final ON capability guard locally with behavioral RED/GREEN and final suite/builds. | The known decision is part of the approved late-rejection behavior; no new review loop. | Local exception-classification regression risk; matrix covers no ALTER/output and external state preservation, with no new live-DB claim. | + +Review exclusions were adjudicated explicitly: production SQL2019, actual network/power failures, SSH, physical DPI/audio and human long jobs remain pending; >2GiB was not rerun because codecs are unchanged and historical evidence is separate; arbitrary external snapshot users may legitimately leave bounded Unconfirmed; hostile OS filesystem replacement/physical power-loss are outside proven journal guarantees; cached-package builds do not prove fresh dependency restoration. No production/account/AV changes, merge/push/release or changes to the unrelated master checkout. Keep codex/mssql-export-preflight-compat and its existing worktree for user trial; retain local failed/successful evidence and authorization manifest. diff --git a/mySQLPunk/Form1.MssqlExport.cs b/mySQLPunk/Form1.MssqlExport.cs index 2ac428c..c8f61d9 100644 --- a/mySQLPunk/Form1.MssqlExport.cs +++ b/mySQLPunk/Form1.MssqlExport.cs @@ -108,6 +108,8 @@ private static string BuildMssqlExportStatus(MssqlExportResult result) if (!string.IsNullOrWhiteSpace(result.PublishedPath)) message += " · " + result.PublishedPath; if (!string.IsNullOrWhiteSpace(result.MirrorPath)) message += " · " + result.MirrorPath; if (!string.IsNullOrWhiteSpace(result.ErrorMessage)) message += " · " + result.ErrorMessage; + if (result.SnapshotCleanup?.State == MssqlSnapshotCleanupState.Unconfirmed) + message += " · " + ReportText.Get("警告:資料庫設定尚未確認恢復", "Warning: database setting restoration is unconfirmed") + MssqlExportWizardForm.CleanupText(result.SnapshotCleanup); return message; } } diff --git a/mySQLPunk/MssqlExportWizardForm.cs b/mySQLPunk/MssqlExportWizardForm.cs index d7f7418..e845fe3 100644 --- a/mySQLPunk/MssqlExportWizardForm.cs +++ b/mySQLPunk/MssqlExportWizardForm.cs @@ -22,11 +22,16 @@ internal sealed class MssqlExportWizardForm : Form private readonly Panel[] pages = new Panel[4]; private readonly TextBox search = new TextBox(), output = new TextBox(); private readonly DataGridView tables = new DataGridView(), queue = new DataGridView(); + private readonly RadioButton snapshotYes = new RadioButton(), snapshotNo = new RadioButton(); + private readonly Label snapshotReason = new Label(), snapshotWarning = new Label(), recoveryNotices = new Label(); + private MssqlSnapshotCapability snapshotCapability; + private int snapshotState = -1; + private string startRejectionNotice = ""; private readonly CheckBox views = new CheckBox(); private readonly RadioButton schemaData = new RadioButton(), schemaOnly = new RadioButton(), dataOnly = new RadioButton(); private readonly Label steps = new Label(), status = new Label(), selection = new Label(), confirmation = new Label(); private readonly Label stage = new Label(), current = new Label(), percent = new Label(), written = new Label(), tableRows = new Label(), elapsed = new Label(), speed = new Label(), eta = new Label(), resultText = new Label(); - private readonly Button next = new Button(), back = new Button(), cancel = new Button(), openFile = new Button(), openFolder = new Button(), sound = new Button(); + private readonly Button retry = new Button(), next = new Button(), back = new Button(), cancel = new Button(), openFile = new Button(), openFolder = new Button(), sound = new Button(); private readonly ProgressBar overall = new ProgressBar(), tableProgress = new ProgressBar(); private readonly PunkyReportLoadingControl punky = new PunkyReportLoadingControl(); private readonly ListBox log = new ListBox(); @@ -39,7 +44,7 @@ internal sealed class MssqlExportWizardForm : Form private CancellationTokenSource cancellation; private MssqlExportRequest prepared; private MssqlExportProgress last; - private bool busy, closing, rebuilding, loaded, exporting, cancelRequested; + private bool busy, closing, rebuilding, loaded, catalogInitialized, exporting, cancelRequested; private int step, generation, includedViews; internal MssqlExportResult Result { get; private set; } @@ -165,10 +170,26 @@ internal MssqlExportWizardForm(Dictionary connection, string dat step--; ShowPage(); }); + Configure(retry, "mssqlRetry", T("重新檢查", "Recheck"), async (s, e) => + { + if (busy || closing) return; + if (step == 1 || (step == 0 && !loaded)) await LoadAsync(); + else if (step == 2) await PreflightAsync(); + }); Configure(openFile, "mssqlOpenFile", T("開啟檔案", "Open file"), (s, e) => Open(Result?.PublishedPath)); Configure(openFolder, "mssqlOpenFolder", T("開啟資料夾", "Open folder"), (s, e) => Open(Result?.PublishedPath == null ? null : Path.GetDirectoryName(Result.PublishedPath))); - footer.Controls.AddRange(new Control[] { cancel, next, back, openFolder, openFile }); + footer.Controls.AddRange(new Control[] { cancel, next, back, retry, openFolder, openFile }); root.Controls.Add(footer, 0, 3); + ClientSizeChanged += (s, e) => { + if (ClientSize.Width >= 800 && ClientSize.Height >= 600) return; + root.Padding = new Padding(12); + foreach (Control control in header.Controls) control.Margin = new Padding(0, 3, 0, 6); + foreach (var button in new[] { cancel, next, back, retry, openFolder, openFile }) { + button.AutoSizeMode = AutoSizeMode.GrowAndShrink; + button.MinimumSize = new Size(85, 32); + button.Margin = new Padding(3); + } + }; ThemeManager.MarkAsPrimary(next); AcceptButton = next; CancelButton = cancel; @@ -376,6 +397,7 @@ private void BuildOutput() views.Enabled = !dataOnly.Checked; if (dataOnly.Checked) views.Checked = false; + UpdateSnapshotOptions(); InvalidateRequest(); UpdateButtons(); }; @@ -392,6 +414,17 @@ private void BuildOutput() }; content.Controls.Add(views); radios[(int)options.Mode].Checked = true; + content.Controls.Add(Label(T("使用 SNAPSHOT 一致性讀取", "Use SNAPSHOT consistent read"), UiKit.Subtitle)); + var snapshotOptions = new FlowLayoutPanel { Name = "mssqlSnapshotOptions", AutoSize = true, FlowDirection = FlowDirection.LeftToRight }; + snapshotYes.Name = "mssqlSnapshotYes"; snapshotYes.Text = T("是", "Yes"); snapshotYes.AutoSize = true; + snapshotNo.Name = "mssqlSnapshotNo"; snapshotNo.Text = T("否(一般讀取)", "No (ordinary read)"); snapshotNo.AutoSize = true; snapshotNo.Checked = true; + snapshotOptions.Controls.AddRange(new Control[] { snapshotYes, snapshotNo }); content.Controls.Add(snapshotOptions); + snapshotYes.CheckedChanged += (s, e) => { InvalidateRequest(); UpdateSnapshotOptions(); UpdateButtons(); }; + snapshotNo.CheckedChanged += (s, e) => { InvalidateRequest(); UpdateButtons(); }; + foreach (var label in new[] { snapshotReason, snapshotWarning, recoveryNotices }) { label.AutoSize = true; label.MaximumSize = new Size(520, 0); content.Controls.Add(label); } + snapshotReason.Name = "mssqlSnapshotReason"; snapshotWarning.Name = "mssqlSnapshotWarning"; recoveryNotices.Name = "mssqlRecoveryNotices"; + pages[1].SizeChanged += (s, e) => { foreach (var label in new[] { snapshotReason, snapshotWarning, recoveryNotices }) label.MaximumSize = new Size(Math.Max(120, Math.Min(700, pages[1].ClientSize.Width - 32)), 0); }; + UpdateSnapshotOptions(); content.Controls.Add(Label(T("SQL 輸出路徑", "SQL output path"))); var path = new FlowLayoutPanel { @@ -600,11 +633,15 @@ private void UpdateButtons() selection.Text = T("已選取 ", "Selected ") + selected.Count + " / " + catalog.Count; back.Visible = step > 0 && step < 3; back.Enabled = !busy; + retry.Visible = step == 1 || step == 2 || (step == 0 && !loaded); + retry.Enabled = !busy && !closing; + retry.Text = step == 0 ? T("重試讀取目錄", "Retry catalog") : T("重新檢查", "Recheck"); + retry.AccessibleName = retry.Text; next.Visible = step < 3 || (!busy && Result != null); next.Enabled = !busy && loaded && (step == 0 ? selected.Count > 0 : step == 1 ? !string.IsNullOrWhiteSpace(output.Text) : step == 2 ? prepared != null : true); next.Text = step == 2 ? T("開始匯出", "Start export") : step == 3 ? T("重新設定", "Reset") : T("下一步 →", "Next →"); cancel.Enabled = !cancelRequested; - cancel.Text = busy && exporting ? T("取消匯出", "Cancel export") : T("關閉", "Close"); + cancel.Text = busy ? (exporting ? T("取消匯出", "Cancel export") : T("取消檢查", "Cancel check")) : T("關閉", "Close"); next.AccessibleName = next.Text; cancel.AccessibleName = cancel.Text; openFile.Visible = openFolder.Visible = !busy && Result?.PublishedPath != null; @@ -634,16 +671,30 @@ private void EndOperation() private async Task LoadAsync() { + if (busy || closing) return; + InvalidateRequest(); + int version = generation; + var previousSelection = catalog.Where(t => selected.Contains(t.Key.ObjectId)).Select(t => t.Key).ToList(); + loaded = false; BeginOperation(); status.Text = T("讀取原生目錄…", "Loading native catalog…"); try { var value = await Task.Run(() => service.LoadCatalog(database, cancellation.Token)); - if (closing) + cancellation.Token.ThrowIfCancellationRequested(); + if (closing || cancelRequested || cancellation.IsCancellationRequested || version != generation) return; + snapshotCapability = value.SnapshotCapability; snapshotState = value.SnapshotState; + UpdateSnapshotOptions(); + RenderRecoveryNotices(service.LoadRecoveryNotices(snapshotCapability?.Identity)); catalog = value.Tables; selected.Clear(); - if (options.InitialObjectName != null) + if (catalogInitialized) + { + foreach (var table in catalog) + if (previousSelection.Any(k => k.ObjectId == table.Key.ObjectId && k.Schema == table.Key.Schema && k.Name == table.Key.Name)) selected.Add(table.Key.ObjectId); + } + else if (options.InitialObjectName != null) { var matches = catalog.Where(t => (t.Key.Schema == "dbo" ? t.Key.Name : t.Key.Schema + "." + t.Key.Name) == options.InitialObjectName).ToList(); if (matches.Count == 1) @@ -653,6 +704,7 @@ private async Task LoadAsync() foreach (var table in catalog) if (options.InitialTables == null || options.InitialTables.Any(k => k.Schema == table.Key.Schema && k.Name == table.Key.Name)) selected.Add(table.Key.ObjectId); + catalogInitialized = true; loaded = true; status.Text = selected.Count == 0 ? T("請選取資料表;初始物件可能已移除或名稱不唯一。", "Select tables; the initial object may be missing or ambiguous.") : T("估計筆數與大小來自目錄,可能與實際值不同。", "Catalog row and size estimates may differ from actual values."); RefreshTables(); @@ -661,9 +713,13 @@ private async Task LoadAsync() { status.Text = T("已取消", "Canceled"); } + catch (MssqlExportPrerequisiteException ex) + { + if (!closing && version == generation && !cancelRequested) status.Text = PrerequisiteText(ex.Reason); + } catch (Exception) { - status.Text = T("無法讀取目錄,請關閉後重試。", "Cannot load catalog. Close and retry."); + if (!closing && version == generation && !cancelRequested) status.Text = T("無法讀取目錄,請檢查連線設定後重試。", "Cannot load catalog. Review connection settings and retry."); } finally { @@ -673,42 +729,64 @@ private async Task LoadAsync() private async Task PreflightAsync() { + if (busy || closing) return; InvalidateRequest(); int version = generation; var keys = catalog.Where(t => selected.Contains(t.Key.ObjectId)).Select(t => t.Key).ToList().AsReadOnly(); var mode = Mode; var include = views.Checked; + bool useSnapshot = mode != MssqlExportMode.SchemaOnly && snapshotYes.Checked; var path = output.Text; BeginOperation(); - confirmation.Text = T("正在捕獲目的檔案狀態並預檢…", "Capturing destination and checking preflight…"); + confirmation.Text = startRejectionNotice + T("正在捕獲目的檔案狀態並預檢…", "Capturing destination and checking preflight…"); try { var stamp = await Task.Run(() => MssqlExportArtifact.CaptureDestination(path, cancellation.Token)); - if (closing || version != generation) + cancellation.Token.ThrowIfCancellationRequested(); + if (closing || cancelRequested || cancellation.IsCancellationRequested || version != generation) return; if (stamp.Exists && MessageBox.Show(this, T("目的檔案已存在。確認覆寫這個檔案?", "Destination exists. Replace this file?"), Text, MessageBoxButtons.YesNo, MessageBoxIcon.Warning) != DialogResult.Yes) { - confirmation.Text = T("未同意覆寫。", "Replacement not approved."); + confirmation.Text = startRejectionNotice + T("未同意覆寫。", "Replacement not approved."); return; } - var request = new MssqlExportRequest(Guid.NewGuid(), database, keys, mode, include, path, stamp, options.Mirror); + var request = new MssqlExportRequest(Guid.NewGuid(), database, keys, mode, include, path, stamp, options.Mirror, useSnapshot); var check = await Task.Run(() => service.Preflight(request, cancellation.Token)); - if (closing || version != generation) + cancellation.Token.ThrowIfCancellationRequested(); + if (closing || cancelRequested || cancellation.IsCancellationRequested || version != generation) return; includedViews = check.Schema.Views.Count; - confirmation.Text = T("SQL 邏輯匯出;不包含登入帳號、作業、原生備份。", "Logical SQL export; excludes logins, jobs and native backups.") + "\r\n\r\n" + keys.Count + T(" 張資料表", " tables") + " · " + ModeText(mode) + "\r\n" + request.TargetPath + "\r\n" + (options.Mirror == null ? T("鏡像:未啟用", "Mirror: disabled") : T("鏡像:", "Mirror: ") + options.Mirror.Directory) + "\r\n" + (mode == MssqlExportMode.SchemaOnly ? T("僅結構:不開啟資料快照交易。", "Schema only: no data snapshot transaction.") : "SNAPSHOT: " + (check.SnapshotState == 1 ? "ON" : "OFF") + T(";全部資料使用同一交易。", "; all data uses one transaction.")) + "\r\n" + ViewScope(mode, include, check.Schema.Views.Count) + "\r\n" + T("計算欄位與 rowversion 值由目標重新產生。", "Computed and rowversion values are regenerated by the target.") + (mode == MssqlExportMode.SchemaOnly ? "" : "\r\n" + T("長時間 SNAPSHOT 可能增加來源 version store 使用量。", "Long SNAPSHOT exports may increase source version-store usage.")) + "\r\n\r\n" + string.Join("\r\n", check.Issues.Select(FormatIssue)); - if (check.CanStart && (mode == MssqlExportMode.SchemaOnly || check.SnapshotState == 1)) - prepared = request; + snapshotCapability = check.SnapshotCapability; snapshotState = check.SnapshotState; + bool declined = useSnapshot && (snapshotCapability == null || !snapshotCapability.CanManage); + UpdateSnapshotOptions(); + RenderRecoveryNotices(service.LoadRecoveryNotices(snapshotCapability?.Identity)); + confirmation.Text = T("SQL 邏輯匯出;不包含登入帳號、作業、原生備份。", "Logical SQL export; excludes logins, jobs and native backups.") + "\r\n\r\n" + keys.Count + T(" 張資料表", " tables") + " · " + ModeText(mode) + "\r\n" + request.TargetPath + "\r\n" + (options.Mirror == null ? T("鏡像:未啟用", "Mirror: disabled") : T("鏡像:", "Mirror: ") + options.Mirror.Directory) + "\r\n" + ReadModeText(mode, useSnapshot && !declined) + "\r\nSNAPSHOT: " + MssqlSnapshotState.Describe(check.SnapshotState) + "\r\n" + ViewScope(mode, include, check.Schema.Views.Count) + "\r\n" + T("計算欄位與 rowversion 值由目標重新產生。", "Computed and rowversion values are regenerated by the target.") + "\r\n\r\n" + string.Join("\r\n", check.Issues.Select(FormatIssue)); + if (declined) + confirmation.Text += "\r\n" + T("SNAPSHOT 能力已變更,已改為否(一般讀取)。請重新檢查並確認一般讀取警語後開始。", "SNAPSHOT capability changed; switched to No (ordinary read). Recheck and review the ordinary-read warning before starting."); + confirmation.Text = startRejectionNotice + confirmation.Text; + if (check.CanStart && !declined) prepared = request; status.Text = prepared != null ? T("預檢通過;開始時會再次檢查來源。", "Preflight passed; source is checked again at start.") : T("預檢未通過,無法開始。", "Preflight failed; start is blocked."); } catch (OperationCanceledException) { status.Text = T("已取消", "Canceled"); } + catch (MssqlExportPrerequisiteException ex) + { + if (!closing && version == generation && !cancelRequested) + { + confirmation.Text = startRejectionNotice + PrerequisiteText(ex.Reason); + status.Text = T("必要條件未符合,請處理後重新檢查。", "A prerequisite is missing. Resolve it and recheck."); + } + } catch (Exception) { - status.Text = T("目的路徑或預檢失敗,請返回檢查設定。", "Destination or preflight failed. Go back and check settings."); + if (!closing && version == generation && !cancelRequested) + { + confirmation.Text = startRejectionNotice + T("目的路徑或預檢失敗,請檢查設定後重新檢查。", "Destination or preflight failed. Review settings and recheck."); + status.Text = T("預檢未通過,無法開始。", "Preflight failed; start is blocked."); + } } finally { @@ -721,6 +799,7 @@ private async Task ExportAsync() if (prepared == null) return; var request = prepared; + startRejectionNotice = ""; int version = generation; Result = null; last = null; @@ -751,7 +830,7 @@ private async Task ExportAsync() UpdateButtons(); var progress = new Progress(p => { - if (!IsDisposed && !closing && busy && exporting && version == generation && p.JobId == request.JobId) + if (!IsDisposed && busy && exporting && version == generation && p.JobId == request.JobId) RenderProgress(p); }); try @@ -759,15 +838,32 @@ private async Task ExportAsync() var result = await Task.Run(() => service.Export(request, progress, cancellation.Token)); if (result.JobId == request.JobId) { + if (result.StartRejection != MssqlSnapshotStartRejection.None && result.PublishedPath == null) { + prepared = null; + snapshotCapability = result.StartCapability; + snapshotState = snapshotCapability?.State ?? -1; + UpdateSnapshotOptions(); // Capability changes may explicitly switch Yes to No and invalidate the old request. + Result = result; + startRejectionNotice = "[" + result.StartRejection + "] " + (result.StartRejection == MssqlSnapshotStartRejection.SnapshotBusy + ? T("同一資料庫有另一個 SNAPSHOT 匯出執行中,請稍後重新檢查再開始。", "Another SNAPSHOT export is running on this database. Retry later by rechecking before starting.") + : T("開始時 SNAPSHOT 能力已變更。請重新檢查並確認讀取模式後再開始。", "SNAPSHOT capability changed at start. Recheck and review the read mode before starting.")) + + CleanupText(result.SnapshotCleanup) + "\r\n" + string.Join("\r\n", result.Warnings.Concat(result.ResidualPaths)) + "\r\n\r\n"; + confirmation.Text = startRejectionNotice + request.Tables.Count + T(" 張資料表", " tables") + " · " + ModeText(request.Mode) + "\r\n" + request.TargetPath + + "\r\n" + ReadModeText(request.Mode, snapshotYes.Checked) + "\r\n" + snapshotReason.Text + + (request.UseSnapshot && !snapshotYes.Checked ? "\r\n" + T("已改為否(一般讀取);請重新檢查並確認一般讀取警語,之後才可按開始。", "Switched to No (ordinary read); recheck and review its warning before pressing Start.") : ""); + status.Text = T("開始被拒絕;請確認並重新檢查。", "Start rejected; review and recheck."); + step = 2; ShowPage(); + return; + } Result = result; stage.Text = StageText(result.Stage); - status.Text = StageText(result.Stage); + status.Text = StageText(result.Stage) + (result.SnapshotCleanup?.State == MssqlSnapshotCleanupState.Unconfirmed ? T(";警告:資料庫設定尚未確認恢復。", "; warning: database setting restoration is unconfirmed.") : ""); foreach (DataGridViewRow row in queue.Rows) { bool done = processed.Contains((int)row.Tag) || result.RowCounts.Any(r => r.Key.ObjectId == (int)row.Tag) || result.Stage == MssqlExportStage.Completed; row.Cells[2].Value = done ? T("已處理", "Processed") : result.Stage == MssqlExportStage.Canceled ? T("已取消", "Canceled") : T("未完成", "Incomplete"); } - resultText.Text = T("已完成資料表:", "Completed tables: ") + (result.Stage == MssqlExportStage.Completed ? request.Tables.Count : Math.Max(processed.Count, result.RowCounts.Count)) + " / " + request.Tables.Count + T(";匯出列數:", "; exported rows: ") + result.RowCounts.Sum(r => r.Rows).ToString("N0") + "\r\n" + ViewScope(request.Mode, request.IncludeViews, includedViews) + "\r\n" + T("計算欄位與 rowversion 值由目標重新產生。", "Computed and rowversion values are regenerated by the target.") + (request.Mode == MssqlExportMode.SchemaOnly ? "" : "\r\n" + T("長時間 SNAPSHOT 可能增加來源 version store 使用量。", "Long SNAPSHOT exports may increase source version-store usage.")) + "\r\n" + (result.PublishedPath == null ? T("沒有已發布的本機檔案。", "No local file was published.") : T("已發布:", "Published: ") + result.PublishedPath) + "\r\n" + (result.MirrorPath ?? "") + "\r\n" + string.Join("\r\n", result.Warnings.Concat(result.ResidualPaths)) + "\r\n" + (result.ErrorMessage ?? "") + "\r\n" + T("檔案驗證不等同還原驗收。請使用支援此檔案大小的客戶端,以 UTF-8、停用變數替代、遇錯停止,於同一工作階段還原。詳見 docs/MSSQL_SQL_EXPORT.md。", "File verification is not restore acceptance. Use a client supporting this file size: UTF-8, variable substitution disabled, stop on error, and one restore session. See docs/MSSQL_SQL_EXPORT.md."); + resultText.Text = T("已完成資料表:", "Completed tables: ") + (result.Stage == MssqlExportStage.Completed ? request.Tables.Count : Math.Max(processed.Count, result.RowCounts.Count)) + " / " + request.Tables.Count + T(";匯出列數:", "; exported rows: ") + result.RowCounts.Sum(r => r.Rows).ToString("N0") + "\r\n" + ViewScope(request.Mode, request.IncludeViews, includedViews) + "\r\n" + T("計算欄位與 rowversion 值由目標重新產生。", "Computed and rowversion values are regenerated by the target.") + "\r\n" + ReadModeText(request.Mode, request.UseSnapshot) + CleanupText(result.SnapshotCleanup) + "\r\n" + (result.PublishedPath == null ? T("沒有已發布的本機檔案。", "No local file was published.") : T("已發布:", "Published: ") + result.PublishedPath) + "\r\n" + (result.MirrorPath ?? "") + "\r\n" + string.Join("\r\n", result.Warnings.Concat(result.ResidualPaths)) + "\r\n" + (result.ErrorMessage ?? "") + "\r\n" + T("檔案驗證不等同還原驗收。請使用支援此檔案大小的客戶端,以 UTF-8、停用變數替代、遇錯停止,於同一工作階段還原。詳見 docs/MSSQL_SQL_EXPORT.md。", "File verification is not restore acceptance. Use a client supporting this file size: UTF-8, variable substitution disabled, stop on error, and one restore session. See docs/MSSQL_SQL_EXPORT.md."); if (result.Stage == MssqlExportStage.Completed) { overall.Style = ProgressBarStyle.Continuous; @@ -795,8 +891,8 @@ private void RenderProgress(MssqlExportProgress p) if (last?.CurrentTable != null && p.CompletedTables > last.CompletedTables) processed.Add(last.CurrentTable.ObjectId); last = p; - stage.Text = cancelRequested ? StageText(MssqlExportStage.CancelRequested) : StageText(p.Stage); - if (!cancelRequested) + stage.Text = cancelRequested && p.Stage != MssqlExportStage.RestoringSnapshot ? StageText(MssqlExportStage.CancelRequested) : StageText(p.Stage); + if (!cancelRequested || p.Stage == MssqlExportStage.RestoringSnapshot) status.Text = stage.Text; current.Text = p.CurrentTable == null ? "" : p.CurrentTable.Schema + "." + p.CurrentTable.Name; percent.Text = p.Percent.HasValue ? T("估計 ", "Estimated ") + Math.Min(99, p.Percent.Value).ToString("0") + "%" : T("估算中", "Estimating"); @@ -842,6 +938,22 @@ internal static string FormatDuration(TimeSpan duration) return ((long)Math.Floor(duration.TotalHours)).ToString("00") + ":" + duration.Minutes.ToString("00") + ":" + duration.Seconds.ToString("00"); } + private static string PrerequisiteText(MssqlPrerequisite reason) + { + switch (reason) + { + case MssqlPrerequisite.ViewDefinitionRequired: + return "[ViewDefinitionRequired] " + T("需要資料庫 VIEW DEFINITION 權限,才能完整檢查物件定義與相依性。請 DBA 確認定義讀取權限後重試;僅結構模式也需要此權限。", "Database VIEW DEFINITION is required to inspect complete definitions and dependencies, including schema-only export. Ask the DBA to review definition visibility, then retry."); + case MssqlPrerequisite.SqlPermissionDenied: + return "[SqlPermissionDenied] " + T("SQL Server 拒絕必要操作。請 DBA 檢查定義讀取權限;含資料匯出另需資料表 SELECT。確認後重試,這不代表需要 ALTER 或管理員權限。", "SQL Server denied a required operation. Ask the DBA to review definition-read permissions and, for data export, table SELECT permissions, then retry. This does not imply ALTER or administrator permission is required."); + case MssqlPrerequisite.LoginRejected: + return "[LoginRejected] " + T("SQL Server 拒絕登入;請檢查連線帳號與驗證設定後重試。", "SQL Server rejected the login. Review the connection account and authentication configuration, then retry."); + case MssqlPrerequisite.DatabaseUnavailable: + return "[DatabaseUnavailable] " + T("指定或預設資料庫無法使用,或帳號存取遭拒。請檢查資料庫名稱、可用狀態及存取設定後重試。", "The requested or default database is unavailable or access was rejected. Review the database name, availability and account access, then retry."); + default: return T("預檢未通過,請檢查設定後重試。", "Preflight failed. Review settings and retry."); + } + } + private static string FormatIssue(MssqlExportIssue issue) { string scope = issue.ObjectKey == null ? T("資料庫", "Database") : issue.ObjectKey.Schema + "." + issue.ObjectKey.Name; @@ -877,6 +989,10 @@ private static string StageText(MssqlExportStage value) { switch (value) { + case MssqlExportStage.EnablingSnapshot: + return T("啟用 SNAPSHOT(最多等待 60 秒)", "Enabling SNAPSHOT (up to 60 seconds)"); + case MssqlExportStage.RestoringSnapshot: + return T("恢復 SNAPSHOT 資料庫設定(最多等待 30 秒)", "Restoring SNAPSHOT database setting (up to 30 seconds)"); case MssqlExportStage.Preflight: return T("準備與預檢", "Preparing and checking"); case MssqlExportStage.Schema: @@ -902,6 +1018,47 @@ private static string StageText(MssqlExportStage value) } } + private static string OrdinaryWarning() => T(MssqlSqlExportService.OrdinaryReadWarning, "Ordinary read (READ COMMITTED): rows may come from different times. Concurrent source writes can produce inconsistent relationships and foreign-key restore failures. File verification is not restore acceptance."); + private static string ReadModeText(MssqlExportMode mode, bool useSnapshot) => mode == MssqlExportMode.SchemaOnly ? T("僅結構:不開資料交易、不改 SNAPSHOT 設定。", "Schema only: no data transaction or SNAPSHOT setting changes.") : useSnapshot ? T("SNAPSHOT:全部資料使用同一快照交易。需要 ALTER;必要時暫時變更整個資料庫設定,結束後嘗試恢復原狀;原本 ON 保持 ON。其他程式同時使用可能互相影響;長時間匯出可能增加 version store。", "SNAPSHOT: all data uses one snapshot transaction. Requires ALTER; may temporarily change the whole database setting and attempt restoration afterward; originally ON stays ON. Other programs may be affected and long exports may increase version-store usage.") : OrdinaryWarning(); + private void UpdateSnapshotOptions() + { + bool enabled = !schemaOnly.Checked && snapshotCapability != null && snapshotCapability.CanManage; + snapshotYes.Enabled = snapshotNo.Enabled = enabled; + if (!enabled) snapshotNo.Checked = true; + string reason = schemaOnly.Checked ? T("僅結構,不使用資料快照。", "Schema only; no data snapshot.") : snapshotCapability == null ? T("能力未知,固定一般讀取。", "Capability unknown; ordinary read required.") : snapshotCapability.CanManage ? T("可用;預設一般讀取。", "Available; ordinary read is the default.") : T("SNAPSHOT 管理不可用,固定一般讀取:", "SNAPSHOT management unavailable; ordinary read required: ") + CapabilityReason(snapshotCapability.UnavailableReason); + snapshotReason.Text = "SNAPSHOT: " + MssqlSnapshotState.Describe(snapshotState) + " · " + reason; + snapshotWarning.Text = ReadModeText(schemaOnly.Checked ? MssqlExportMode.SchemaOnly : MssqlExportMode.SchemaAndData, snapshotYes.Checked); + } + private static string CapabilityReason(MssqlSnapshotUnavailableReason reason) + { + switch (reason) { + case MssqlSnapshotUnavailableReason.IdentityUnknown: return T("來源資料庫識別未知。", "Source database identity is unknown."); + case MssqlSnapshotUnavailableReason.StateUnavailable: return T("設定正在轉換或狀態未知;可重新檢查。", "Setting is transitioning or unknown; recheck is available."); + case MssqlSnapshotUnavailableReason.AlterUnknown: return T("無法確認 ALTER 權限。", "ALTER permission could not be confirmed."); + case MssqlSnapshotUnavailableReason.AlterDenied: return T("沒有 ALTER 權限(即使原本 ON 也停用此管理選項)。", "ALTER permission is missing (management is disabled even when already ON)."); + case MssqlSnapshotUnavailableReason.UpdateabilityUnknown: return T("無法確認資料庫可變更設定。", "Database updateability could not be confirmed."); + case MssqlSnapshotUnavailableReason.ReadOnly: return T("資料庫無法變更設定。", "Database settings cannot be changed."); + default: return ""; + } + } + private void RenderRecoveryNotices(IReadOnlyList notices) + { + var identity = snapshotCapability?.Identity; + var lines = new List(); + foreach (var notice in notices ?? new MssqlSnapshotRecoveryNotice[0]) { + var record = notice.Record; + if (record == null) { lines.Add(T("本機復原紀錄無法完整讀取:", "Local recovery records could not be fully read: ") + notice.ReadError + " · " + notice.Path); continue; } + string match = identity == null || !identity.IsComplete ? "IdentityUnknown" : identity.DatabaseId != record.Identity.DatabaseId || identity.DatabaseCreateDate != record.Identity.DatabaseCreateDate || identity.ServerName != record.Identity.ServerName || identity.DatabaseName != record.Identity.DatabaseName ? "IdentityChanged" : "IdentityMatched"; + lines.Add("[" + match + "] " + record.Identity.ServerName + " / " + record.Identity.DatabaseName + " · id=" + record.Identity.DatabaseId + " · created=" + record.Identity.DatabaseCreateDate.ToString("O") + " · original=" + MssqlSnapshotState.Describe(record.OriginalState) + " · " + record.Phase + " · " + record.NoticeCode + " · SNAPSHOT=" + (record.ObservedState.HasValue ? MssqlSnapshotState.Describe(record.ObservedState.Value) : "UNKNOWN") + " · " + record.UpdatedUtc.ToString("u") + " · " + notice.Path); + } + recoveryNotices.Text = lines.Count == 0 ? "" : T("待人工確認的本機復原紀錄(歷史識別可能不同或未知;不自動修復或刪除):", "Local recovery records requiring manual review (historical identity may differ or be unknown; no automatic repair or deletion): ") + "\r\n" + string.Join("\r\n", lines) + "\r\n" + T("請 DBA 核對原生伺服器、DB ID 與 create_date,再唯讀查詢:", "Ask the DBA to verify native server, database ID and create_date, then inspect read-only: ") + "SELECT name, database_id, create_date, snapshot_isolation_state_desc FROM sys.databases;"; + } + internal static string CleanupText(MssqlSnapshotCleanupResult cleanup) + { + if (cleanup == null) return ""; + return "\r\n" + T("資料庫設定復原:", "Database setting cleanup: ") + cleanup.State + (cleanup.State == MssqlSnapshotCleanupState.Unconfirmed ? T(";尚未確認恢復,請人工核對。", "; restoration is unconfirmed; manual review required.") : "") + " · SNAPSHOT=" + (cleanup.ObservedState.HasValue ? MssqlSnapshotState.Describe(cleanup.ObservedState.Value) : "UNKNOWN") + " · " + cleanup.WarningCode + " · " + cleanup.RecoveryRecordPath; + } + private void Open(string path) { if (path == null) diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs index 868b78d..5697054 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs @@ -19,7 +19,8 @@ internal MssqlExportCatalog ReadCatalog(SqlConnection connection, string databas EnsureVisibility(connection, null, cancellation); var tables = ReadTables(connection, null, cancellation); var issues = tables.Where(t => !t.Flag("can_select")).Select(t => new MssqlExportIssue("UnreadableTable", t.Key, null, "SELECT permission is required.")); - return new MssqlExportCatalog(tables, Snapshot(connection, null, cancellation), issues); + var capability = new MssqlSnapshotBackend(connection).ReadCapability(cancellation); + return new MssqlExportCatalog(tables, capability.State, issues, capability); } internal MssqlExportSchema ReadSchema(SqlConnection connection, SqlTransaction transaction, MssqlExportRequest request, CancellationToken cancellation) { @@ -75,28 +76,88 @@ internal MssqlExportSchema ReadSchema(SqlConnection connection, SqlTransaction t if (request.IncludeViews) foreach (var r in Query(connection, transaction, @"SELECT v.object_id,SCHEMA_NAME(v.schema_id) schema_name,v.name,m.definition,m.uses_ansi_nulls,m.uses_quoted_identifier,m.is_schema_bound,v.with_check_option, (SELECT COUNT(*) FROM sys.indexes i WHERE i.object_id=v.object_id AND i.index_id>0) index_count, -(SELECT COUNT(*) FROM sys.triggers t WHERE t.parent_id=v.object_id) trigger_count, -(SELECT COUNT(*) FROM sys.extended_properties e WHERE e.class=1 AND e.major_id=v.object_id) unsupported_properties +(SELECT COUNT(*) FROM sys.triggers t WHERE t.parent_id=v.object_id) trigger_count FROM sys.views v LEFT JOIN sys.sql_modules m ON m.object_id=v.object_id WHERE v.is_ms_shipped=0 ORDER BY v.object_id", cancellation)) { var f = new MssqlNativeFacts(r); views.Add(new MssqlViewDefinition(new MssqlObjectKey(f.Number("object_id"), f.Text("schema_name"), f.Text("name")), r)); } var objectIds = new HashSet(selected.Select(x => x.Key.ObjectId).Concat(views.Select(x => x.Key.ObjectId))); var dependencies = new List(); foreach (var r in Query(connection, transaction, @"SELECT CASE WHEN o.parent_object_id<>0 THEN o.parent_object_id ELSE d.referencing_id END object_id, -d.referenced_id,d.referenced_server_name,d.referenced_database_name,d.referenced_schema_name,d.referenced_entity_name -FROM sys.sql_expression_dependencies d JOIN sys.objects o ON o.object_id=d.referencing_id WHERE d.referenced_class=1", cancellation)) - { var f = new MssqlNativeFacts(r); if (objectIds.Contains(f.Number("object_id"))) dependencies.Add(new MssqlDependency(f.Number("object_id"), f.Text("referenced_id") == null ? (int?)null : f.Number("referenced_id"), f.Text("referenced_server_name"), f.Text("referenced_database_name"), f.Text("referenced_schema_name"), f.Text("referenced_entity_name"))); } - return new MssqlExportSchema(selected, views, dependencies, Snapshot(connection, transaction, cancellation), issues); +d.referenced_id,d.referenced_server_name,d.referenced_database_name,d.referenced_schema_name,d.referenced_entity_name, +d.is_ambiguous,d.is_caller_dependent,DB_ID() source_database_id,DB_ID(d.referenced_database_name) referenced_database_id, +local_object.object_id resolved_local_id +FROM sys.sql_expression_dependencies d JOIN sys.objects o ON o.object_id=d.referencing_id +LEFT JOIN sys.objects local_object ON d.referenced_server_name IS NULL AND d.referenced_database_name IS NOT NULL +AND DB_ID(d.referenced_database_name)=DB_ID() AND d.referenced_schema_name IS NOT NULL +AND local_object.object_id=OBJECT_ID(QUOTENAME(d.referenced_schema_name)+N'.'+QUOTENAME(d.referenced_entity_name)) +AND local_object.type IN ('U','V') +WHERE d.referenced_class=1", cancellation)) + { + var f = new MssqlNativeFacts(r); + if (objectIds.Contains(f.Number("object_id"))) dependencies.Add(new MssqlDependency(f.Number("object_id"), NullableNumber(f, "referenced_id"), + f.Text("referenced_server_name"), f.Text("referenced_database_name"), f.Text("referenced_schema_name"), f.Text("referenced_entity_name"), + NullableNumber(f, "source_database_id"), NullableNumber(f, "referenced_database_id"), NullableNumber(f, "resolved_local_id"), + f.Flag("is_ambiguous"), f.Flag("is_caller_dependent"))); + } + var properties = ReadExtendedProperties(connection, transaction, objectIds, cancellation); + var capability = new MssqlSnapshotBackend(connection, transaction).ReadCapability(cancellation); + var schema = new MssqlExportSchema(selected, views, dependencies, capability.State, issues, extendedProperties: properties, snapshotCapability: capability); + return PrepareViewReferences(schema, request, (database, ownerSchema, name) => ResolveViewReference(connection, transaction, database, ownerSchema, name, cancellation)); + } + internal static MssqlExportSchema PrepareViewReferences(MssqlExportSchema schema, MssqlExportRequest request, + Func resolver) + { + if (request.Mode == MssqlExportMode.DataOnly) return schema; + var views = schema.Views.ToList(); + var dependencies = schema.Dependencies.ToList(); + var issues = schema.Issues.ToList(); + var selected = new HashSet(schema.Tables.Select(x => x.Key.ObjectId).Concat(views.Select(x => x.Key.ObjectId))); + bool changed = false; + for (int i = 0; i < views.Count; i++) + { + var view = views[i]; + var owned = dependencies.Where(d => d.ObjectId == view.Key.ObjectId).ToArray(); + var qualified = owned.Where(d => d.Database != null || d.Server != null).ToArray(); + if (qualified.Length == 0) continue; + // Preserve the existing failure boundary for every other dependency and + // never authorize a table expression by preparing an unrelated view. + if (owned.Any(d => d.IsAmbiguous || d.IsCallerDependent || d.Server != null || + (d.Database != null ? !d.HasSameDatabaseIdentity || !selected.Contains(d.ResolvedLocalId.Value) : + !d.ReferencedId.HasValue || !selected.Contains(d.ReferencedId.Value)))) continue; + int? sourceId = qualified[0].SourceDatabaseId; + if (qualified.Any(d => d.SourceDatabaseId != sourceId)) continue; + var result = MssqlViewReferenceRewriter.Rewrite(view, (database, ownerSchema, name) => + { + var resolution = resolver(database, ownerSchema, name); + return resolution != null && resolution.SourceDatabaseId == sourceId ? resolution : null; + }, qualified.Select(d => d.ResolvedLocalId), selected); + changed = true; + if (!result.Success) { issues.Add(result.Issue); continue; } + var facts = view.Facts.ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + facts["prepared_definition"] = result.Sql; + facts["prepared_same_database_references"] = true; + views[i] = new MssqlViewDefinition(view.Key, facts); + for (int j = 0; j < dependencies.Count; j++) + if (dependencies[j].ObjectId == view.Key.ObjectId && dependencies[j].Database != null) + dependencies[j] = dependencies[j].WithPreparedSameDatabaseReference(); + } + return changed ? new MssqlExportSchema(schema.Tables, views, dependencies, schema.SnapshotState, issues, schema.ExtendedProperties, schema.SnapshotCapability) : schema; + } + internal IReadOnlyList ValidateStart(MssqlExportSchema schema, MssqlExportRequest request) + { + var issues = new List(Validate(schema, request)); + if (request.UseSnapshot && (schema.SnapshotCapability == null || !schema.SnapshotCapability.CanManage)) + issues.Add(new MssqlExportIssue("SnapshotUnavailable", null, null, "SNAPSHOT requires known state OFF/ON, database ALTER permission and a writable database. Choose ordinary reading or ask the DBA to review capability.")); + return issues.AsReadOnly(); } internal IReadOnlyList Validate(MssqlExportSchema schema, MssqlExportRequest request) { var issues = new List(schema.Issues); - if (request.Mode != MssqlExportMode.SchemaOnly && schema.SnapshotState != 1) issues.Add(new MssqlExportIssue("SnapshotUnavailable", null, null, "ALLOW_SNAPSHOT_ISOLATION must be ON (state 1); OFF and transitions cannot export data.")); var ids = new HashSet(schema.Tables.Select(x => x.Key.ObjectId).Concat(schema.Views.Select(x => x.Key.ObjectId))); foreach (var t in schema.Tables) { Action add = (code, col, reason) => issues.Add(new MssqlExportIssue(code, t.Key, col, reason)); - if (t.Text("can_select") != null && !t.Flag("can_select")) add("UnreadableTable", null, "SELECT permission is required."); - foreach (var flag in new[] { "temporal_type", "ledger_type", "is_memory_optimized", "is_filetable", "is_node", "is_edge", "is_external", "is_remote_data_archive_enabled", "trigger_count", "fulltext_count", "security_policy_count", "unsupported_properties" }) + if (request.Mode != MssqlExportMode.SchemaOnly && t.Facts.ContainsKey("can_select") && !t.Flag("can_select")) add("UnreadableTable", null, "SELECT permission is required."); + foreach (var flag in new[] { "temporal_type", "ledger_type", "is_memory_optimized", "is_filetable", "is_node", "is_edge", "is_external", "is_remote_data_archive_enabled", "trigger_count", "fulltext_count", "security_policy_count" }) if (t.Number(flag) != 0) add("UnsupportedTableFeature", null, "Unsupported table feature: " + flag); if (request.Mode != MssqlExportMode.DataOnly && t.Text("uses_ansi_nulls") != null && !t.Flag("uses_ansi_nulls")) add("UnsupportedAnsiNulls", null, "Legacy ANSI_NULLS OFF cannot be recreated on modern SQL Server."); foreach (var c in t.Columns) @@ -127,30 +188,116 @@ internal IReadOnlyList Validate(MssqlExportSchema schema, Mssq { if (v.Text("uses_ansi_nulls") != null && !v.AnsiNulls) issues.Add(new MssqlExportIssue("UnsupportedAnsiNulls", v.Key, null, "Legacy view ANSI_NULLS OFF cannot be recreated on modern SQL Server.")); if (string.IsNullOrEmpty(v.Definition)) issues.Add(new MssqlExportIssue("UnreadableDefinition", v.Key, null, "View definition is encrypted or unreadable.")); - if (v.Number("index_count") != 0 || v.Number("trigger_count") != 0 || v.Number("unsupported_properties") != 0) issues.Add(new MssqlExportIssue("UnsupportedViewFeature", v.Key, null, "Indexed views, view triggers and view extended properties are unsupported.")); + if (v.Number("index_count") != 0) issues.Add(new MssqlExportIssue("UnsupportedViewFeature", v.Key, null, "Indexed views are unsupported.")); + if (v.Number("trigger_count") != 0) issues.Add(new MssqlExportIssue("UnsupportedViewFeature", v.Key, null, "View triggers are unsupported.")); } + ValidateExtendedProperties(schema, issues); if (request.Mode != MssqlExportMode.DataOnly) foreach (var d in schema.Dependencies) - if (d.Server != null || d.Database != null || !d.ReferencedId.HasValue || !ids.Contains(d.ReferencedId.Value)) + if (d.IsAmbiguous || d.IsCallerDependent || d.Server != null || !d.EffectiveReferencedId.HasValue || !ids.Contains(d.EffectiveReferencedId.Value) || + (d.Database != null && (!d.HasSameDatabaseIdentity || !d.PreparedSameDatabaseReference || + !schema.Views.Any(v => v.Key.ObjectId == d.ObjectId && v.HasPreparedReferences)))) issues.Add(new MssqlExportIssue("MissingDependency", schema.Tables.Select(x => x.Key).Concat(schema.Views.Select(x => x.Key)).FirstOrDefault(x => x.ObjectId == d.ObjectId), null, "Definition requires unexported object: " + string.Join(".", new[] { d.Server, d.Database, d.Schema, d.Name }.Where(x => x != null)))); if (schema.Tables.GroupBy(x => x.Key.ObjectId).Any(g => g.Count() > 1)) issues.Add(new MssqlExportIssue("DuplicateSelection", null, null, "Each table may be selected only once.")); var pending = new HashSet(schema.Views.Select(x => x.Key.ObjectId)); while (pending.Count != 0) { - var ready = pending.Where(id => !schema.Dependencies.Any(d => d.ObjectId == id && d.ReferencedId.HasValue && pending.Contains(d.ReferencedId.Value))).ToList(); + var ready = pending.Where(id => !schema.Dependencies.Any(d => d.ObjectId == id && d.EffectiveReferencedId.HasValue && pending.Contains(d.EffectiveReferencedId.Value))).ToList(); if (ready.Count == 0) { issues.Add(new MssqlExportIssue("DependencyCycle", null, null, "View dependencies contain a cycle and cannot be scripted in dependency order.")); break; } foreach (int id in ready) pending.Remove(id); } return issues.AsReadOnly(); } + private static int? NullableNumber(MssqlNativeFacts facts, string name) => facts.Text(name) == null ? (int?)null : facts.Number(name); + private static MssqlViewReferenceResolution ResolveViewReference(SqlConnection connection, SqlTransaction transaction, + string database, string schema, string name, CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + // Reject before setting sized parameters: truncation cannot establish identity. + if (new[] { database, schema, name }.Any(x => string.IsNullOrEmpty(x) || x.Length > 128)) + return new MssqlViewReferenceResolution(null, null, null, null); + const string sql = @"SELECT CONVERT(int,DB_ID()) source_database_id,CONVERT(int,DB_ID(@database)) referenced_database_id,o.object_id,RTRIM(o.type) object_type +FROM (VALUES(1)) seed(n) +LEFT JOIN sys.objects o ON DB_ID(@database)=DB_ID() +AND o.object_id=OBJECT_ID(QUOTENAME(@schema)+N'.'+QUOTENAME(@name))"; + var command = new SqlCommand(sql, connection, transaction) { CommandTimeout = 0 }; + command.Parameters.Add("@database", SqlDbType.NVarChar, 128).Value = database; + command.Parameters.Add("@schema", SqlDbType.NVarChar, 128).Value = schema; + command.Parameters.Add("@name", SqlDbType.NVarChar, 128).Value = name; + using (var owner = new MssqlCommandOwner(command, cancellation)) + using (var reader = owner.ExecuteReader(CommandBehavior.SingleRow)) + { + cancellation.ThrowIfCancellationRequested(); + if (!reader.Read()) return new MssqlViewReferenceResolution(null, null, null, null); + var result = new MssqlViewReferenceResolution(reader.IsDBNull(0) ? (int?)null : reader.GetInt32(0), + reader.IsDBNull(1) ? (int?)null : reader.GetInt32(1), reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2), + reader.IsDBNull(3) ? null : reader.GetString(3)); + cancellation.ThrowIfCancellationRequested(); + return result; + } + } + private static IReadOnlyList ReadExtendedProperties(SqlConnection connection, SqlTransaction transaction, IEnumerable objectIds, CancellationToken cancellation) + { + string ids = string.Join(",", objectIds.OrderBy(x => x).Select(x => x.ToString(CultureInfo.InvariantCulture))); + if (ids.Length == 0) return new MssqlExtendedPropertyDefinition[0]; + // Read only the selected owners, and only convert values whose native type we support. + string sql = @"SELECT o.object_id,SCHEMA_NAME(o.schema_id) schema_name,o.name object_name, +CASE WHEN o.type='V' THEN 'VIEW' ELSE 'TABLE' END owner_type, +e.minor_id,c.name column_name,e.name,CONVERT(nvarchar(128),SQL_VARIANT_PROPERTY(e.value,'BaseType')) base_type, +CONVERT(int,SQL_VARIANT_PROPERTY(e.value,'MaxLength')) max_length, +CONVERT(nvarchar(128),SQL_VARIANT_PROPERTY(e.value,'Collation')) collation, +CASE WHEN e.value IS NULL THEN 1 ELSE 0 END is_null, +CASE WHEN SQL_VARIANT_PROPERTY(e.value,'BaseType')='nvarchar' THEN CONVERT(varchar(max),CONVERT(varbinary(8000),CONVERT(nvarchar(4000),e.value)),2) + WHEN SQL_VARIANT_PROPERTY(e.value,'BaseType')='int' THEN CONVERT(varchar(max),CONVERT(varbinary(4),CONVERT(int,e.value)),2) + ELSE NULL END value_hex +FROM sys.extended_properties e JOIN sys.objects o ON o.object_id=e.major_id +LEFT JOIN sys.columns c ON c.object_id=e.major_id AND c.column_id=e.minor_id +WHERE e.class=1 AND o.type IN ('U','V') AND e.major_id IN (" + ids + ") ORDER BY e.major_id,e.minor_id,e.name"; + return Query(connection, transaction, sql, cancellation).Select(row => + { + var f = new MssqlNativeFacts(row); + return new MssqlExtendedPropertyDefinition(new MssqlObjectKey(f.Number("object_id"), f.Text("schema_name"), f.Text("object_name")), f.Text("owner_type"), row); + }).ToList().AsReadOnly(); + } + private static void ValidateExtendedProperties(MssqlExportSchema schema, List issues) + { + var identities = new HashSet(StringComparer.Ordinal); + foreach (var p in schema.ExtendedProperties) + { + Action add = (code, reason) => issues.Add(new MssqlExportIssue(code, p.Owner, p.ColumnName, "Extended property " + Quote(p.Name ?? "") + ": " + reason)); + var table = schema.Tables.FirstOrDefault(t => t.Key.ObjectId == p.Owner.ObjectId && t.Key.Schema == p.Owner.Schema && t.Key.Name == p.Owner.Name); + var view = schema.Views.FirstOrDefault(v => v.Key.ObjectId == p.Owner.ObjectId && v.Key.Schema == p.Owner.Schema && v.Key.Name == p.Owner.Name); + bool ownerValid = (p.OwnerType == "TABLE" && table != null) || (p.OwnerType == "VIEW" && view != null); + if (!ownerValid || p.Name == null || p.MinorId < 0 || (p.MinorId == 0 && p.ColumnName != null) || + (p.MinorId != 0 && (p.ColumnName == null || (table != null && !table.Columns.Any(c => c.Ordinal == p.MinorId && c.Name == p.ColumnName))))) + { add("InvalidExtendedProperty", "Owner or column scope is unavailable."); continue; } + string identity = p.Owner.ObjectId.ToString(CultureInfo.InvariantCulture) + ":" + p.MinorId.ToString(CultureInfo.InvariantCulture) + ":" + p.Name; + if (!identities.Add(identity)) { add("InvalidExtendedProperty", "Duplicate property identity."); continue; } + if (p.IsNull) + { + if (p.BaseType != null || p.MaxLengthBytes.HasValue || p.Collation != null || p.ValueHex != null) add("InvalidExtendedProperty", "NULL variant metadata is inconsistent."); + continue; + } + if (p.BaseType != "nvarchar" && p.BaseType != "int") + { add("UnsupportedExtendedPropertyType", "Unsupported SQL type: " + (p.BaseType ?? "") + ". Supported types are nvarchar, int and NULL."); continue; } + string hex = p.ValueHex; + bool validHex = hex != null && hex.Length % 2 == 0 && hex.All(c => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F')); + int bytes = hex == null ? -1 : hex.Length / 2; + bool valid = validHex && bytes <= 7500; + if (p.BaseType == "int") valid &= p.MaxLengthBytes == 4 && bytes == 4 && p.Collation == null; + else valid &= p.MaxLengthBytes.HasValue && p.MaxLengthBytes >= 2 && p.MaxLengthBytes <= 8000 && p.MaxLengthBytes % 2 == 0 && + bytes % 2 == 0 && bytes <= p.MaxLengthBytes && !string.IsNullOrEmpty(p.Collation) && + p.Collation.All(c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'); + if (!valid) add("InvalidExtendedProperty", "Native value length, encoding or collation metadata is inconsistent for SQL type " + p.BaseType + "."); + } + } private static readonly HashSet SupportedTypes = new HashSet("bigint int smallint tinyint bit decimal numeric money smallmoney float real date time datetime datetime2 smalldatetime datetimeoffset char varchar text nchar nvarchar ntext binary varbinary image uniqueidentifier xml timestamp rowversion geometry".Split(' '), StringComparer.OrdinalIgnoreCase); private static void EnsureVisibility(SqlConnection c, SqlTransaction t, CancellationToken token) { var row = Query(c, t, "SELECT HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','VIEW DEFINITION') allowed", token)[0]; - if (!new MssqlNativeFacts(row).Flag("allowed")) throw new InvalidOperationException("Database VIEW DEFINITION permission is required to detect hidden dependencies and definitions."); + if (!new MssqlNativeFacts(row).Flag("allowed")) throw new MssqlExportPrerequisiteException(MssqlPrerequisite.ViewDefinitionRequired); } - private static int Snapshot(SqlConnection c, SqlTransaction t, CancellationToken token) { return new MssqlNativeFacts(Query(c, t, "SELECT snapshot_isolation_state FROM sys.databases WHERE database_id=DB_ID()", token).Single()).Number("snapshot_isolation_state"); } private static bool HasCatalog(SqlConnection c, SqlTransaction t, string name, CancellationToken token) { return Query(c, t, "SELECT object_id FROM sys.all_objects WHERE name='" + name + "' AND schema_id=SCHEMA_ID('sys')", token).Count != 0; } private static List ReadTables(SqlConnection c, SqlTransaction t, CancellationToken token) @@ -161,8 +308,7 @@ private static List ReadTables(SqlConnection c, SqlTransac HAS_PERMS_BY_NAME(QUOTENAME(SCHEMA_NAME(t.schema_id))+'.'+QUOTENAME(t.name),'OBJECT','SELECT') can_select, (SELECT SUM(p.rows) FROM sys.partitions p WHERE p.object_id=t.object_id AND p.index_id IN(0,1)) estimated_rows, (SELECT COUNT(*) FROM sys.triggers tr WHERE tr.parent_id=t.object_id) trigger_count, -(SELECT COUNT(*) FROM sys.fulltext_indexes f WHERE f.object_id=t.object_id) fulltext_count, -(SELECT COUNT(*) FROM sys.extended_properties e WHERE e.class=1 AND e.major_id=t.object_id AND e.name<>N'MS_Description') unsupported_properties," + +(SELECT COUNT(*) FROM sys.fulltext_indexes f WHERE f.object_id=t.object_id) fulltext_count," + (security ? "(SELECT COUNT(*) FROM sys.security_predicates sp WHERE sp.target_object_id=t.object_id)" : "0") + " security_policy_count," + optional + @" FROM sys.tables t LEFT JOIN sys.extended_properties ep ON ep.class=1 AND ep.major_id=t.object_id AND ep.minor_id=0 AND ep.name=N'MS_Description' WHERE t.is_ms_shipped=0 ORDER BY t.object_id"; diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs index 5847d7d..4127bb4 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportModels.cs @@ -11,6 +11,47 @@ namespace mySQLPunk.lib.MssqlExport { internal enum MssqlExportMode { SchemaAndData, SchemaOnly, DataOnly } + internal enum MssqlPrerequisite { ViewDefinitionRequired, SqlPermissionDenied, LoginRejected, DatabaseUnavailable } + + // Only fixed categories cross the provider boundary; never retain provider text or inner exceptions. + internal sealed class MssqlExportPrerequisiteException : InvalidOperationException + { + public MssqlPrerequisite Reason { get; } + internal MssqlExportPrerequisiteException(MssqlPrerequisite reason) : base(Describe(reason)) { Reason = reason; } + private static string Describe(MssqlPrerequisite reason) + { + switch (reason) { + case MssqlPrerequisite.ViewDefinitionRequired: return "[ViewDefinitionRequired] Database VIEW DEFINITION is required to inspect complete object definitions and dependencies. Ask the DBA to review definition visibility, then retry."; + case MssqlPrerequisite.SqlPermissionDenied: return "[SqlPermissionDenied] SQL Server denied a required operation. Ask the DBA to review definition-read permissions and, for data export, table SELECT permissions, then retry."; + case MssqlPrerequisite.LoginRejected: return "[LoginRejected] SQL Server rejected the login. Review the connection account and authentication configuration, then retry."; + case MssqlPrerequisite.DatabaseUnavailable: return "[DatabaseUnavailable] The requested or default database is unavailable or access was rejected. Review the database name, availability and account access, then retry."; + default: throw new ArgumentOutOfRangeException(nameof(reason)); + } + } + internal static MssqlExportPrerequisiteException From(Exception error) + { + var known = error as MssqlExportPrerequisiteException; + if (known != null) return known; + var sql = error as System.Data.SqlClient.SqlException; + if (sql == null) return null; + // The first SqlError may be a general wrapper; inspect every entry without reading messages. + var numbers = new HashSet(sql.Errors.Cast().Select(x => x.Number)); + if (numbers.Contains(18456) || numbers.Contains(18452)) return new MssqlExportPrerequisiteException(MssqlPrerequisite.LoginRejected); + if (numbers.Contains(916) || numbers.Contains(4060) || numbers.Contains(4064)) return new MssqlExportPrerequisiteException(MssqlPrerequisite.DatabaseUnavailable); + if (numbers.Contains(229) || numbers.Contains(230) || numbers.Contains(297) || numbers.Contains(300)) return new MssqlExportPrerequisiteException(MssqlPrerequisite.SqlPermissionDenied); + return null; + } + } + + internal static class MssqlSnapshotState + { + internal static string Describe(int state) + { + string name = state == 0 ? "OFF" : state == 1 ? "ON" : state == 2 ? "IN_TRANSITION_TO_OFF" : state == 3 ? "IN_TRANSITION_TO_ON" : "UNKNOWN"; + return name + " (" + state.ToString(CultureInfo.InvariantCulture) + ")"; + } + } + internal sealed class MssqlObjectKey { public int ObjectId { get; } @@ -85,7 +126,8 @@ internal sealed class MssqlExportRequest public string TargetPath { get; } public ExportDestinationStamp Destination { get; } public MssqlMirrorOptions Mirror { get; } - public MssqlExportRequest(Guid jobId, string databaseName, IReadOnlyList tables, MssqlExportMode mode, bool includeViews, string targetPath, ExportDestinationStamp destination, MssqlMirrorOptions mirror) + public bool UseSnapshot { get; } + public MssqlExportRequest(Guid jobId, string databaseName, IReadOnlyList tables, MssqlExportMode mode, bool includeViews, string targetPath, ExportDestinationStamp destination, MssqlMirrorOptions mirror, bool useSnapshot = false) { if (jobId == Guid.Empty) throw new ArgumentException("Job identity is required", nameof(jobId)); if (string.IsNullOrWhiteSpace(databaseName)) throw new ArgumentException("Database is required", nameof(databaseName)); @@ -97,7 +139,8 @@ public MssqlExportRequest(Guid jobId, string databaseName, IReadOnlyList facts) : base(facts) { Key = key; } public string Definition => Text("definition"); + public string PreparedDefinition => Text("prepared_definition"); + public bool HasPreparedReferences => Flag("prepared_same_database_references") && !string.IsNullOrEmpty(PreparedDefinition); public bool AnsiNulls => Flag("uses_ansi_nulls"); public bool QuotedIdentifier => Flag("uses_quoted_identifier"); } + // SQL variant metadata is stored as immutable scalar facts and native value hex. + // A byte[] fact would be mutable and would not be represented by Canonical(). + internal sealed class MssqlExtendedPropertyDefinition : MssqlNativeFacts + { + public MssqlObjectKey Owner { get; } + public string OwnerType { get; } + internal MssqlExtendedPropertyDefinition(MssqlObjectKey owner, string ownerType, IDictionary facts) : base(facts) + { Owner = owner ?? throw new ArgumentNullException(nameof(owner)); OwnerType = ownerType; } + public int MinorId => Number("minor_id"); + public string ColumnName => Text("column_name"); + public string Name => Text("name"); + public string BaseType => Text("base_type"); + public int? MaxLengthBytes => Text("max_length") == null ? (int?)null : Number("max_length"); + public string Collation => Text("collation"); + public bool IsNull => Flag("is_null"); + public string ValueHex => Text("value_hex"); + } internal sealed class MssqlDependency { public int ObjectId { get; } @@ -196,8 +258,28 @@ internal sealed class MssqlDependency public string Database { get; } public string Schema { get; } public string Name { get; } - internal MssqlDependency(int objectId, int? referencedId, string server, string database, string schema, string name) - { ObjectId = objectId; ReferencedId = referencedId; Server = server; Database = database; Schema = schema; Name = name; } + public int? SourceDatabaseId { get; } + public int? ReferencedDatabaseId { get; } + public int? ResolvedLocalId { get; } + public bool IsAmbiguous { get; } + public bool IsCallerDependent { get; } + public bool PreparedSameDatabaseReference { get; } + public bool HasSameDatabaseIdentity => Server == null && !string.IsNullOrEmpty(Database) && !string.IsNullOrEmpty(Schema) && + SourceDatabaseId.HasValue && SourceDatabaseId.Value > 0 && SourceDatabaseId == ReferencedDatabaseId && + ResolvedLocalId.HasValue && ResolvedLocalId.Value > 0 && (!ReferencedId.HasValue || ReferencedId == ResolvedLocalId) && + !IsAmbiguous && !IsCallerDependent; + public int? EffectiveReferencedId => Server == null && Database == null && !IsAmbiguous && !IsCallerDependent ? ReferencedId : + PreparedSameDatabaseReference && HasSameDatabaseIdentity ? ResolvedLocalId : null; + internal MssqlDependency(int objectId, int? referencedId, string server, string database, string schema, string name, + int? sourceDatabaseId = null, int? referencedDatabaseId = null, int? resolvedLocalId = null, + bool isAmbiguous = false, bool isCallerDependent = false, bool preparedSameDatabaseReference = false) + { + ObjectId = objectId; ReferencedId = referencedId; Server = server; Database = database; Schema = schema; Name = name; + SourceDatabaseId = sourceDatabaseId; ReferencedDatabaseId = referencedDatabaseId; ResolvedLocalId = resolvedLocalId; + IsAmbiguous = isAmbiguous; IsCallerDependent = isCallerDependent; PreparedSameDatabaseReference = preparedSameDatabaseReference; + } + internal MssqlDependency WithPreparedSameDatabaseReference() => new MssqlDependency(ObjectId, ReferencedId, Server, Database, Schema, Name, + SourceDatabaseId, ReferencedDatabaseId, ResolvedLocalId, IsAmbiguous, IsCallerDependent, true); } internal sealed class MssqlExportIssue { @@ -211,21 +293,27 @@ internal sealed class MssqlExportCatalog { public IReadOnlyList Tables { get; } public int SnapshotState { get; } + public MssqlSnapshotCapability SnapshotCapability { get; } public IReadOnlyList Issues { get; } - internal MssqlExportCatalog(IEnumerable tables, int snapshotState, IEnumerable issues) - { Tables = MssqlNativeFacts.Copy(tables); SnapshotState = snapshotState; Issues = MssqlNativeFacts.Copy(issues); } + internal MssqlExportCatalog(IEnumerable tables, int snapshotState, IEnumerable issues, MssqlSnapshotCapability snapshotCapability = null) + { Tables = MssqlNativeFacts.Copy(tables); SnapshotState = snapshotState; SnapshotCapability = snapshotCapability; + if (snapshotCapability != null && snapshotCapability.State != snapshotState) throw new ArgumentException("Snapshot capability state mismatch", nameof(snapshotCapability)); Issues = MssqlNativeFacts.Copy(issues); } } internal sealed class MssqlExportSchema { public IReadOnlyList Tables { get; } public IReadOnlyList Views { get; } public IReadOnlyList Dependencies { get; } + public IReadOnlyList ExtendedProperties { get; } public IReadOnlyList Issues { get; } public int SnapshotState { get; } + public MssqlSnapshotCapability SnapshotCapability { get; } public string Fingerprint { get; } - internal MssqlExportSchema(IEnumerable tables, IEnumerable views, IEnumerable dependencies, int snapshotState, IEnumerable issues) + internal MssqlExportSchema(IEnumerable tables, IEnumerable views, IEnumerable dependencies, int snapshotState, IEnumerable issues, IEnumerable extendedProperties = null, MssqlSnapshotCapability snapshotCapability = null) { - Tables = MssqlNativeFacts.Copy(tables); Views = MssqlNativeFacts.Copy(views); Dependencies = MssqlNativeFacts.Copy(dependencies); Issues = MssqlNativeFacts.Copy(issues); SnapshotState = snapshotState; + ExtendedProperties = MssqlNativeFacts.Copy(extendedProperties); + Tables = MssqlNativeFacts.Copy(tables); Views = MssqlNativeFacts.Copy(views); Dependencies = MssqlNativeFacts.Copy(dependencies); Issues = MssqlNativeFacts.Copy(issues); SnapshotState = snapshotState; SnapshotCapability = snapshotCapability; + if (snapshotCapability != null && snapshotCapability.State != snapshotState) throw new ArgumentException("Snapshot capability state mismatch", nameof(snapshotCapability)); var text = new StringBuilder(); foreach (var t in Tables.OrderBy(x => x.Key.ObjectId)) { @@ -235,8 +323,19 @@ internal MssqlExportSchema(IEnumerable tables, IEnumerable foreach (var c in t.Constraints.OrderBy(x => x.Name, StringComparer.Ordinal)) { c.Canonical(text); foreach (var column in c.Columns) column.Canonical(text); } } foreach (var v in Views.OrderBy(x => x.Key.ObjectId)) { Key(text, v.Key); v.Canonical(text); } - foreach (var d in Dependencies.OrderBy(x => x.ObjectId).ThenBy(x => x.ReferencedId).ThenBy(x => x.Name, StringComparer.Ordinal)) - { MssqlNativeFacts.Part(text, d.ObjectId.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, d.ReferencedId?.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, d.Server); MssqlNativeFacts.Part(text, d.Database); MssqlNativeFacts.Part(text, d.Schema); MssqlNativeFacts.Part(text, d.Name); } + foreach (var d in Dependencies.OrderBy(x => x.ObjectId).ThenBy(x => x.ReferencedId).ThenBy(x => x.Server, StringComparer.Ordinal) + .ThenBy(x => x.Database, StringComparer.Ordinal).ThenBy(x => x.Schema, StringComparer.Ordinal).ThenBy(x => x.Name, StringComparer.Ordinal) + .ThenBy(x => x.SourceDatabaseId).ThenBy(x => x.ReferencedDatabaseId).ThenBy(x => x.ResolvedLocalId) + .ThenBy(x => x.IsAmbiguous).ThenBy(x => x.IsCallerDependent).ThenBy(x => x.PreparedSameDatabaseReference)) + { + MssqlNativeFacts.Part(text, d.ObjectId.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, d.ReferencedId?.ToString(CultureInfo.InvariantCulture)); + MssqlNativeFacts.Part(text, d.Server); MssqlNativeFacts.Part(text, d.Database); MssqlNativeFacts.Part(text, d.Schema); MssqlNativeFacts.Part(text, d.Name); + MssqlNativeFacts.Part(text, d.SourceDatabaseId?.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, d.ReferencedDatabaseId?.ToString(CultureInfo.InvariantCulture)); + MssqlNativeFacts.Part(text, d.ResolvedLocalId?.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, d.IsAmbiguous.ToString()); + MssqlNativeFacts.Part(text, d.IsCallerDependent.ToString()); MssqlNativeFacts.Part(text, d.PreparedSameDatabaseReference.ToString()); + } + foreach (var p in ExtendedProperties.OrderBy(x => x.Owner.ObjectId).ThenBy(x => x.MinorId).ThenBy(x => x.Name, StringComparer.Ordinal)) + { Key(text, p.Owner); MssqlNativeFacts.Part(text, p.OwnerType); p.Canonical(text); } using (var sha = SHA256.Create()) Fingerprint = BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(text.ToString()))).Replace("-", ""); } private static void Key(StringBuilder text, MssqlObjectKey key) { MssqlNativeFacts.Part(text, key.ObjectId.ToString(CultureInfo.InvariantCulture)); MssqlNativeFacts.Part(text, key.Schema); MssqlNativeFacts.Part(text, key.Name); } @@ -245,6 +344,7 @@ internal sealed class MssqlExportPreflight { public MssqlExportSchema Schema { get; } public int SnapshotState => Schema.SnapshotState; + public MssqlSnapshotCapability SnapshotCapability => Schema.SnapshotCapability; public IReadOnlyList Issues { get; } public bool CanStart => Issues.Count == 0; internal MssqlExportPreflight(MssqlExportSchema schema, IEnumerable issues) { Schema = schema; Issues = MssqlNativeFacts.Copy(issues); } @@ -278,7 +378,7 @@ internal MssqlArtifactReceipt(Guid jobId, string temporaryPath, string published } } - internal enum MssqlExportStage { Preflight, Schema, Data, Verifying, Publishing, Mirroring, CancelRequested, Completed, Canceled, Failed, LocalCompletedMirrorIncomplete } + internal enum MssqlExportStage { Preflight, Schema, Data, Verifying, Publishing, Mirroring, CancelRequested, Completed, Canceled, Failed, LocalCompletedMirrorIncomplete, EnablingSnapshot, RestoringSnapshot } internal enum MssqlExportEstimationState { Estimating, Measuring, Waiting, Unreliable, Complete } internal sealed class MssqlExportProgress { @@ -325,11 +425,18 @@ internal sealed class MssqlExportResult public IReadOnlyList Warnings { get; } public IReadOnlyList ResidualPaths { get; } public string ErrorMessage { get; } + public bool UseSnapshot { get; } + public MssqlSnapshotCleanupResult SnapshotCleanup { get; } + public MssqlSnapshotStartRejection StartRejection { get; } + public MssqlSnapshotCapability StartCapability { get; } internal MssqlExportResult(Guid jobId, MssqlExportStage stage, string publishedPath, string mirrorPath, - IEnumerable rows, TimeSpan elapsed, IEnumerable warnings, IEnumerable residuals, string errorMessage) + IEnumerable rows, TimeSpan elapsed, IEnumerable warnings, IEnumerable residuals, string errorMessage, bool useSnapshot = false, MssqlSnapshotCleanupResult snapshotCleanup = null, + MssqlSnapshotStartRejection startRejection = MssqlSnapshotStartRejection.None, MssqlSnapshotCapability startCapability = null) { JobId = jobId; Stage = stage; PublishedPath = publishedPath; MirrorPath = mirrorPath; RowCounts = MssqlNativeFacts.Copy(rows); - Elapsed = elapsed; Warnings = MssqlNativeFacts.Copy(warnings); ResidualPaths = MssqlNativeFacts.Copy(residuals); ErrorMessage = errorMessage; + Elapsed = elapsed; Warnings = MssqlNativeFacts.Copy(warnings); ResidualPaths = MssqlNativeFacts.Copy(residuals); ErrorMessage = errorMessage; UseSnapshot = useSnapshot; SnapshotCleanup = snapshotCleanup; + if (!Enum.IsDefined(typeof(MssqlSnapshotStartRejection), startRejection)) throw new ArgumentOutOfRangeException(nameof(startRejection)); + StartRejection = startRejection; StartCapability = startCapability; } } diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportProgressEstimator.cs b/mySQLPunk/lib/MssqlExport/MssqlExportProgressEstimator.cs index 1ad0e01..14628ac 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportProgressEstimator.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportProgressEstimator.cs @@ -37,7 +37,7 @@ private MssqlExportProgress Build(MssqlExportStage stage, MssqlObjectKey table, { // A terminal failure does not claim that its interrupted table completed. completedRows += tableRows; completedBytes += tableBytes; - if (!terminal || stage == MssqlExportStage.Completed) completed.Add(current.ObjectId); + if ((!terminal && stage != MssqlExportStage.RestoringSnapshot && stage != MssqlExportStage.EnablingSnapshot) || stage == MssqlExportStage.Completed) completed.Add(current.ObjectId); } current = table; tableRows = 0; tableBytes = 0; } @@ -103,7 +103,8 @@ private MssqlExportProgress Build(MssqlExportStage stage, MssqlObjectKey table, } else state = MssqlExportEstimationState.Unreliable; } - if (terminal || stage == MssqlExportStage.Publishing) { eta = null; bytesPerSecond = null; } + if (stage == MssqlExportStage.EnablingSnapshot || stage == MssqlExportStage.RestoringSnapshot) state = MssqlExportEstimationState.Waiting; + if (terminal || stage == MssqlExportStage.Publishing || stage == MssqlExportStage.EnablingSnapshot || stage == MssqlExportStage.RestoringSnapshot) { eta = null; bytesPerSecond = null; } if (stage == MssqlExportStage.Completed) { lastPercent = 100; state = MssqlExportEstimationState.Complete; } string message = state == MssqlExportEstimationState.Waiting ? "Waiting for progress" : stage.ToString(); return new MssqlExportProgress(jobId, stage, current, tableRows, tableBytes, actualRows, actualBytes, written, diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs b/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs index b4104a6..b94a82c 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportSource.cs @@ -11,7 +11,8 @@ internal interface IMssqlExportSource : IDisposable { MssqlExportCatalog ReadCatalog(string databaseName, CancellationToken cancellation); MssqlExportPreflight Inspect(MssqlExportRequest request, CancellationToken cancellation); - MssqlExportSchema BeginRead(MssqlExportRequest request, CancellationToken cancellation); + MssqlExportSchema BeginRead(MssqlExportRequest request, Action report, CancellationToken cancellation); + MssqlSnapshotCleanupResult FinishRead(Action report); MssqlRowReader OpenRows(MssqlTableDefinition table, CancellationToken cancellation); void CompleteRead(MssqlExportSchema schema, CancellationToken cancellation); } @@ -20,6 +21,7 @@ internal sealed class MssqlRowReader : IDisposable private IDisposable commandOwner; public DbDataReader Reader { get; } internal bool IsDisposed { get; private set; } + internal bool DisposeSucceeded { get; private set; } internal MssqlRowReader(DbDataReader reader, IDisposable commandOwner) { Reader = reader ?? throw new ArgumentNullException(nameof(reader)); this.commandOwner = commandOwner ?? throw new ArgumentNullException(nameof(commandOwner)); } public void Dispose() @@ -27,6 +29,7 @@ public void Dispose() if (IsDisposed) return; IsDisposed = true; try { Reader.Dispose(); } finally { var owner = commandOwner; commandOwner = null; owner.Dispose(); } + DisposeSucceeded = true; } } internal sealed class MssqlExportSource : IMssqlExportSource @@ -37,82 +40,158 @@ internal sealed class MssqlExportSource : IMssqlExportSource private MssqlExportRequest request; private MssqlExportSchema frozen; private MssqlRowReader activeReader; - private bool disposed; - internal MssqlExportSource(MssqlOwnedSession session) { this.session = session ?? throw new ArgumentNullException(nameof(session)); } + private bool disposed, ownerClosed; + private readonly Action evictPool; + private readonly IMssqlSnapshotRecoveryStore recoveryStore; + private readonly Func backendFactory; + private readonly Func deadlineFactory; + private SourceSnapshotBackend snapshotBackend; + private MssqlSnapshotLifecycle lifecycle; + private MssqlSnapshotCleanupResult cleanup; + internal MssqlExportSource(MssqlOwnedSession session, IMssqlSnapshotRecoveryStore recoveryStore = null, + Func backendFactory = null, + Func deadlineFactory = null, Action evictPool = null) + { + this.session = session ?? throw new ArgumentNullException(nameof(session)); + this.recoveryStore = recoveryStore ?? new MssqlSnapshotRecoveryStore(); + this.backendFactory = backendFactory ?? (c => new MssqlSnapshotBackend(c)); + this.deadlineFactory = deadlineFactory; + this.evictPool = evictPool ?? SqlConnection.ClearPool; + } + internal static IsolationLevel? ReadIsolation(MssqlExportRequest value) => value.Mode == MssqlExportMode.SchemaOnly + ? (IsolationLevel?)null : value.UseSnapshot ? IsolationLevel.Snapshot : IsolationLevel.ReadCommitted; public MssqlExportCatalog ReadCatalog(string databaseName, CancellationToken cancellation) - { CheckIdle(); try { return metadata.ReadCatalog(session.Connection, databaseName, cancellation); } catch { Dispose(); throw; } } + { CheckIdle(); return metadata.ReadCatalog(session.Connection, databaseName, cancellation); } public MssqlExportPreflight Inspect(MssqlExportRequest value, CancellationToken cancellation) { CheckIdle(); - try { cancellation.ThrowIfCancellationRequested(); session.Connection.ChangeDatabase(value.DatabaseName); var schema = metadata.ReadSchema(session.Connection, null, value, cancellation); return new MssqlExportPreflight(schema, metadata.Validate(schema, value)); } - catch { Dispose(); throw; } + cancellation.ThrowIfCancellationRequested(); session.Connection.ChangeDatabase(value.DatabaseName); + var schema = metadata.ReadSchema(session.Connection, null, value, cancellation); + return new MssqlExportPreflight(schema, metadata.ValidateStart(schema, value)); } - public MssqlExportSchema BeginRead(MssqlExportRequest value, CancellationToken cancellation) + public MssqlExportSchema BeginRead(MssqlExportRequest value, Action report, CancellationToken cancellation) { CheckIdle(); - try + var preflight = Inspect(value, cancellation); + RequireValid(preflight); + request = value; + cancellation.ThrowIfCancellationRequested(); + if (value.UseSnapshot) { - var preflight = Inspect(value, cancellation); - RequireValid(preflight); - request = value; - cancellation.ThrowIfCancellationRequested(); - if (value.Mode != MssqlExportMode.SchemaOnly) transaction = session.Connection.BeginTransaction(IsolationLevel.Snapshot); - frozen = metadata.ReadSchema(session.Connection, transaction, value, cancellation); - RequireValid(new MssqlExportPreflight(frozen, metadata.Validate(frozen, value))); - return frozen; + snapshotBackend = new SourceSnapshotBackend(backendFactory(session.Connection)); + lifecycle = new MssqlSnapshotLifecycle(snapshotBackend, recoveryStore, value.JobId, deadlineFactory); + lifecycle.Prepare(report, cancellation); + // Prepare confirms ON within its budget; recheck immediately before beginning the data transaction. + var capability = snapshotBackend.ReadCapability(cancellation); + RequireSnapshotReady(capability); } - catch { Dispose(); throw; } + var isolation = ReadIsolation(value); + if (isolation.HasValue) transaction = session.Connection.BeginTransaction(isolation.Value); + frozen = metadata.ReadSchema(session.Connection, transaction, value, cancellation); + RequireValid(new MssqlExportPreflight(frozen, metadata.Validate(frozen, value))); + return frozen; } public MssqlRowReader OpenRows(MssqlTableDefinition table, CancellationToken cancellation) { CheckAlive(); - try - { - if (frozen == null || transaction == null) throw new InvalidOperationException("A data snapshot must be started first."); - if (activeReader != null && !activeReader.IsDisposed) throw new InvalidOperationException("Dispose the current row reader first."); - if (!frozen.Tables.Any(t => ReferenceEquals(t, table))) throw new ArgumentException("Table must belong to this frozen schema.", nameof(table)); - cancellation.ThrowIfCancellationRequested(); - var columns = table.Columns.Where(c => !c.Computed && !c.RowVersion).OrderBy(c => c.Ordinal).ToList(); - // A rowversion/computed-only table still needs a row count for DEFAULT VALUES. - string projection = columns.Count == 0 ? "CAST(1 AS bit) AS [__mysqlpunk_row]" : string.Join(",", columns.Select(MssqlValueCodec.SourceExpression)); - var command = new SqlCommand("SELECT " + projection + " FROM " + MssqlExportMetadataReader.Qualified(table.Key), session.Connection, transaction) { CommandTimeout = 0 }; - var owner = new MssqlCommandOwner(command, cancellation); - try { activeReader = new MssqlRowReader(owner.ExecuteReader(CommandBehavior.SequentialAccess), owner); return activeReader; } - catch { owner.Dispose(); throw; } - } - catch { Dispose(); throw; } + if (frozen == null || transaction == null) throw new InvalidOperationException("A data transaction must be started first."); + if (activeReader != null && !activeReader.IsDisposed) throw new InvalidOperationException("Dispose the current row reader first."); + if (!frozen.Tables.Any(t => ReferenceEquals(t, table))) throw new ArgumentException("Table must belong to this frozen schema.", nameof(table)); + cancellation.ThrowIfCancellationRequested(); + var columns = table.Columns.Where(c => !c.Computed && !c.RowVersion).OrderBy(c => c.Ordinal).ToList(); + // A rowversion/computed-only table still needs a row count for DEFAULT VALUES. + string projection = columns.Count == 0 ? "CAST(1 AS bit) AS [__mysqlpunk_row]" : string.Join(",", columns.Select(MssqlValueCodec.SourceExpression)); + var command = new SqlCommand("SELECT " + projection + " FROM " + MssqlExportMetadataReader.Qualified(table.Key), session.Connection, transaction) { CommandTimeout = 0 }; + var owner = new MssqlCommandOwner(command, cancellation); + try { activeReader = new MssqlRowReader(owner.ExecuteReader(CommandBehavior.SequentialAccess), owner); return activeReader; } + catch { owner.Dispose(); throw; } } public void CompleteRead(MssqlExportSchema schema, CancellationToken cancellation) { CheckAlive(); - try - { - if (frozen == null || !ReferenceEquals(schema, frozen)) throw new InvalidOperationException("Schema does not belong to this read."); - if (activeReader != null && !activeReader.IsDisposed) throw new InvalidOperationException("Close the row reader before completing the read."); - cancellation.ThrowIfCancellationRequested(); - var current = metadata.ReadSchema(session.Connection, transaction, request, cancellation); - RequireValid(new MssqlExportPreflight(current, metadata.Validate(current, request))); - if (current.Fingerprint != frozen.Fingerprint) throw new InvalidOperationException("Source definitions changed during export; discard this export."); - cancellation.ThrowIfCancellationRequested(); - if (transaction != null) { transaction.Commit(); transaction.Dispose(); transaction = null; } - } - finally { Dispose(); } + if (frozen == null || !ReferenceEquals(schema, frozen)) throw new InvalidOperationException("Schema does not belong to this read."); + if (activeReader != null && !activeReader.IsDisposed) throw new InvalidOperationException("Close the row reader before completing the read."); + cancellation.ThrowIfCancellationRequested(); + var current = metadata.ReadSchema(session.Connection, transaction, request, cancellation); + RequireValid(new MssqlExportPreflight(current, metadata.Validate(current, request))); + if (current.Fingerprint != frozen.Fingerprint) throw new InvalidOperationException("Source definitions changed during export; discard this export."); + cancellation.ThrowIfCancellationRequested(); + if (transaction != null) { transaction.Commit(); transaction.Dispose(); transaction = null; } } + internal static void RequireSnapshotReady(MssqlSnapshotCapability capability) + { if (capability == null || capability.State != 1 || !capability.CanManage) throw new MssqlSnapshotStartException(MssqlSnapshotStartRejection.SnapshotUnavailable, capability); } private static void RequireValid(MssqlExportPreflight preflight) - { if (!preflight.CanStart) throw new InvalidOperationException(string.Join(Environment.NewLine, preflight.Issues.Select(x => x.Code + ": " + x.Message))); } + { MssqlSnapshotStartException.RequireValid(preflight); } private void CheckAlive() { if (disposed) throw new ObjectDisposedException(nameof(MssqlExportSource)); } private void CheckIdle() { CheckAlive(); if (request != null) throw new InvalidOperationException("A read is already active."); } - public void Dispose() + public MssqlSnapshotCleanupResult FinishRead(Action report) { - if (disposed) return; disposed = true; - try { if (activeReader != null) activeReader.Dispose(); } - finally + if (cleanup != null) return cleanup; + bool ended = CloseReadResources(); + bool ownerReleased = true; + if (!ended) { - try { if (transaction != null) { try { transaction.Rollback(); } catch (InvalidOperationException) { } finally { transaction.Dispose(); transaction = null; } } } - finally { session.Dispose(); } + // A reader/transaction with uncertain closure must never be followed by ALTER. + if (snapshotBackend != null) snapshotBackend.Available = false; + ownerReleased = CloseOwner(true); } + try { cleanup = lifecycle == null ? new MssqlSnapshotCleanupResult(MssqlSnapshotCleanupState.NotRequired, null, null, ended ? null : "SourceCleanupUnconfirmed") : lifecycle.Restore(report); } + catch (Exception) { cleanup = new MssqlSnapshotCleanupResult(MssqlSnapshotCleanupState.Unconfirmed, null, null, "SourceCleanupUnconfirmed"); } + if (!ended && request != null && request.UseSnapshot) + cleanup = new MssqlSnapshotCleanupResult(MssqlSnapshotCleanupState.Unconfirmed, cleanup.ObservedState, cleanup.RecoveryRecordPath, cleanup.WarningCode ?? "SourceCleanupUnconfirmed"); + if (!CloseOwner(snapshotBackend != null && snapshotBackend.LockUnconfirmed) || !ownerReleased) + cleanup = new MssqlSnapshotCleanupResult(cleanup.State, cleanup.ObservedState, cleanup.RecoveryRecordPath, cleanup.WarningCode ?? "SourceReleaseUnconfirmed"); + disposed = true; + return cleanup; + } + private bool CloseReadResources() + { + bool ended = true; + try { activeReader?.Dispose(); } catch (Exception) { ended = false; } + if (activeReader != null && !activeReader.DisposeSucceeded) ended = false; + var value = transaction; transaction = null; + if (value != null) + { + try { value.Rollback(); } catch (Exception) { ended = false; } + try { value.Dispose(); } catch (Exception) { ended = false; } + } + return ended; + } + private bool CloseOwner(bool evict) + { + if (ownerClosed) return true; + ownerClosed = true; + bool closed = true; + // ClearPool marks active connections for discard on close; it does not terminate other sessions. + if (evict) { try { evictPool(session.Connection); } catch (Exception) { closed = false; } } + try { session.Dispose(); } catch (Exception) { closed = false; } + return closed; + } + public void Dispose() + { + if (disposed) return; + disposed = true; + if (snapshotBackend != null) snapshotBackend.Available = false; + // Last-resort resource release only. Settings restoration belongs exclusively to FinishRead. + bool ended = CloseReadResources(); + CloseOwner(!ended || snapshotBackend != null && snapshotBackend.LockUnconfirmed); + } + // Prevent lifecycle SQL after uncertain transaction closure and preserve the deadline through test adapters. + internal sealed class SourceSnapshotBackend : IMssqlSnapshotBackend, IMssqlSnapshotDeadlineBackend + { + private readonly IMssqlSnapshotBackend inner; + internal bool Available = true; + internal bool LockUnconfirmed; + internal SourceSnapshotBackend(IMssqlSnapshotBackend inner) { this.inner = inner ?? throw new ArgumentNullException(nameof(inner)); } + public bool IsUsable => Available && inner.IsUsable; + public IMssqlSnapshotDeadline Deadline { set { var bounded = inner as IMssqlSnapshotDeadlineBackend; if (bounded != null) bounded.Deadline = value; } } + public MssqlSnapshotCapability ReadCapability(CancellationToken token) { if (!IsUsable) throw new InvalidOperationException("SnapshotSessionUnavailable"); return inner.ReadCapability(token); } + public bool TryAcquireLock(CancellationToken token) { LockUnconfirmed = true; bool acquired = inner.TryAcquireLock(token); LockUnconfirmed = acquired; return acquired; } + public void SetEnabled(bool enabled, CancellationToken token) { if (!IsUsable) throw new InvalidOperationException("SnapshotSessionUnavailable"); inner.SetEnabled(enabled, token); } + public void ReleaseLock(CancellationToken token) { if (!IsUsable) throw new InvalidOperationException("SnapshotSessionUnavailable"); inner.ReleaseLock(token); LockUnconfirmed = false; } } } + internal sealed class MssqlCommandOwner : IDisposable { private DbCommand command; diff --git a/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs b/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs index b53a438..68aad83 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs @@ -15,6 +15,8 @@ internal void WriteBeforeData(MssqlExportSchema schema, MssqlExportRequest reque { Validate(schema, request, output, cancellation); Settings(output); + output.WriteLine("-- Source read mode: " + (request.Mode == MssqlExportMode.SchemaOnly ? "Schema only (no data transaction)" : request.UseSnapshot ? "SNAPSHOT" : "READ COMMITTED")); + if (request.Mode != MssqlExportMode.SchemaOnly && !request.UseSnapshot) output.WriteLine("-- " + MssqlSqlExportService.OrdinaryReadWarning); if (request.Mode == MssqlExportMode.DataOnly) return; output.WriteLine("-- Restore into an empty, compatible target selected by the caller; existing objects fail."); output.WriteLine("-- Named filegroups must already exist. Rowversion values are regenerated."); @@ -65,13 +67,11 @@ internal void WriteAfterData(MssqlExportSchema schema, MssqlExportRequest reques cancellation.ThrowIfCancellationRequested(); WriteConstraint(table, constraint, output); } - Comment(output, table.Key, null, table.Comment); - foreach (var column in table.Columns) { cancellation.ThrowIfCancellationRequested(); Comment(output, table.Key, column.Name, column.Comment); } } var pending = schema.Views.ToDictionary(v => v.Key.ObjectId); while (pending.Count != 0) { - var ready = pending.Values.Where(v => !schema.Dependencies.Any(d => d.ObjectId == v.Key.ObjectId && d.ReferencedId.HasValue && pending.ContainsKey(d.ReferencedId.Value))).OrderBy(v => v.Key.ObjectId).ToArray(); + var ready = pending.Values.Where(v => !schema.Dependencies.Any(d => d.ObjectId == v.Key.ObjectId && d.EffectiveReferencedId.HasValue && pending.ContainsKey(d.EffectiveReferencedId.Value))).OrderBy(v => v.Key.ObjectId).ToArray(); if (ready.Length == 0) throw new InvalidOperationException("View dependency cycle."); foreach (var view in ready) { @@ -84,6 +84,11 @@ internal void WriteAfterData(MssqlExportSchema schema, MssqlExportRequest reques pending.Remove(view.Key.ObjectId); } } + foreach (var property in schema.ExtendedProperties.OrderBy(p => p.Owner.ObjectId).ThenBy(p => p.MinorId).ThenBy(p => p.Name, StringComparer.Ordinal)) + { + cancellation.ThrowIfCancellationRequested(); + ExtendedProperty(output, property); + } // Disabling a clustered index first makes its table inaccessible and can disable FKs. // Delay all requested disabled states until every object is in place. Settings(output); @@ -178,10 +183,17 @@ private static void WriteConstraint(MssqlTableDefinition table, MssqlConstraintD output.WriteLine("ALTER TABLE " + Qualified(table.Key) + (c.Flag("is_disabled") ? " NOCHECK" : " CHECK") + " CONSTRAINT " + Quote(c.Name) + ";"); Batch(output); } - private static void Comment(TextWriter output, MssqlObjectKey key, string column, string value) + private static void ExtendedProperty(TextWriter output, MssqlExtendedPropertyDefinition property) { - if (value == null) return; - output.WriteLine("EXEC sys.sp_addextendedproperty @name=N'MS_Description', @value=" + Literal(value) + ", @level0type=N'SCHEMA', @level0name=" + Literal(key.Schema) + ", @level1type=N'TABLE', @level1name=" + Literal(key.Name) + (column == null ? "" : ", @level2type=N'COLUMN', @level2name=" + Literal(column)) + ";"); + string value = property.IsNull ? "NULL" : property.BaseType == "int" ? "CONVERT(int, 0x" + property.ValueHex + ")" : + "CONVERT(nvarchar(" + (property.MaxLengthBytes.Value / 2).ToString(CultureInfo.InvariantCulture) + "), 0x" + property.ValueHex + ") COLLATE " + property.Collation; + // Assign the collated expression directly to sql_variant. An intermediate + // nvarchar variable would use the target database collation instead. + output.WriteLine("DECLARE @__mp_property sql_variant;"); + output.WriteLine("SET @__mp_property = " + value + ";"); + output.WriteLine("EXEC sys.sp_addextendedproperty @name=" + Literal(property.Name) + ", @value=@__mp_property, @level0type=N'SCHEMA', @level0name=" + Literal(property.Owner.Schema) + + ", @level1type=" + Literal(property.OwnerType) + ", @level1name=" + Literal(property.Owner.Name) + + (property.MinorId == 0 ? "" : ", @level2type=N'COLUMN', @level2name=" + Literal(property.ColumnName)) + ";"); Batch(output); } private static void TableOption(TextWriter output, MssqlTableDefinition table, string fact, string option) @@ -193,6 +205,7 @@ private static void TableOption(TextWriter output, MssqlTableDefinition table, s // The catalog name wins over stale module text left by sp_rename. private static string ViewDefinition(MssqlViewDefinition view) { + if (view.HasPreparedReferences) return view.PreparedDefinition; string text = view.Definition; int position = 0; string token = Token(text, ref position); if (string.Equals(token, "CREATE", StringComparison.OrdinalIgnoreCase)) diff --git a/mySQLPunk/lib/MssqlExport/MssqlSnapshotBackend.cs b/mySQLPunk/lib/MssqlExport/MssqlSnapshotBackend.cs new file mode 100644 index 0000000..90cc53e --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlSnapshotBackend.cs @@ -0,0 +1,134 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Threading; +namespace mySQLPunk.lib.MssqlExport +{ + // A positive statement that the setting command was NOT sent; other errors remain uncertain. + internal sealed class MssqlSnapshotSettingNotAttemptedException : InvalidOperationException + { internal MssqlSnapshotSettingNotAttemptedException(Exception cause = null) : base("SnapshotSettingNotAttempted", cause) { } } + // Borrows the caller's live connection/transaction. Never owns or opens a session. + internal class MssqlSnapshotBackend : IMssqlSnapshotBackend, IMssqlSnapshotDeadlineBackend + { + internal const string LockResource = "mySQLPunk.MssqlExport.SnapshotSetting.v1"; + private readonly SqlConnection connection; + private readonly SqlTransaction transaction; + private bool lockHeld; + private Guid sessionId; + private MssqlSnapshotIdentity lockedIdentity; + private IMssqlSnapshotDeadline deadline; + IMssqlSnapshotDeadline IMssqlSnapshotDeadlineBackend.Deadline { set { deadline = value; } } + public bool IsUsable => connection.State == ConnectionState.Open && (!lockHeld || connection.ClientConnectionId == sessionId); + internal MssqlSnapshotBackend(SqlConnection connection, SqlTransaction transaction = null) + { this.connection = connection ?? throw new ArgumentNullException(nameof(connection)); this.transaction = transaction; } + public MssqlSnapshotCapability ReadCapability(CancellationToken cancellation) + { + const string sql = @"SELECT CONVERT(int,snapshot_isolation_state) snapshot_state, +CONVERT(int,database_id) database_id,CONVERT(datetime,create_date) create_date, +CONVERT(nvarchar(128),name) database_name,CONVERT(nvarchar(128),SERVERPROPERTY('ServerName')) server_name, +CONVERT(int,HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','ALTER')) can_alter, +CONVERT(nvarchar(60),DATABASEPROPERTYEX(DB_NAME(),'Updateability')) updateability, +APPLOCK_MODE('public','mySQLPunk.MssqlExport.SnapshotSetting.v1','Session') lock_mode +FROM sys.databases WHERE database_id=DB_ID()"; + using (var command = Command(sql, cancellation)) + using (var reader = command.ExecuteReaderAsync(cancellation).GetAwaiter().GetResult()) + { + if (!reader.ReadAsync(cancellation).GetAwaiter().GetResult()) throw new MssqlExportPrerequisiteException(MssqlPrerequisite.DatabaseUnavailable); + Func value = name => reader[name] == DBNull.Value ? null : reader[name]; + if (lockHeld && !string.Equals((string)value("lock_mode"), "Exclusive", StringComparison.Ordinal)) throw new InvalidOperationException("SnapshotLockLost"); + var identity = new MssqlSnapshotIdentity((string)value("server_name"), (string)value("database_name"), + value("database_id") == null ? 0 : (int)value("database_id"), value("create_date") == null ? default(DateTime) : (DateTime)value("create_date")); + string updateability = (string)value("updateability"); + bool? writable = updateability == "READ_WRITE" ? true : updateability == "READ_ONLY" ? false : (bool?)null; + var capability = new MssqlSnapshotCapability(identity, value("snapshot_state") == null ? -1 : (int)value("snapshot_state"), + value("can_alter") == null ? (bool?)null : (int)value("can_alter") == 1, writable); + if (lockHeld && lockedIdentity == null) lockedIdentity = identity; + return capability; + } + } + public bool TryAcquireLock(CancellationToken cancellation) + { + if (lockHeld || transaction != null) throw new InvalidOperationException("SnapshotLockInvalidContext"); + using (var command = Command(@"DECLARE @result int; +EXEC @result=sys.sp_getapplock @Resource=@resource,@LockMode='Exclusive',@LockOwner='Session',@DbPrincipal='public',@LockTimeout=0; +SELECT @result;", cancellation)) + { + command.Parameters.Add("@resource", SqlDbType.NVarChar, 255).Value = LockResource; + int code = Convert.ToInt32(command.ExecuteScalarAsync(cancellation).GetAwaiter().GetResult()); + if (code == -1) return false; + if (code == -2) throw new OperationCanceledException("SnapshotLockCanceled", cancellation); + if (code < 0) throw new InvalidOperationException(code == -3 ? "SnapshotLockDeadlock" : "SnapshotLockFailed"); + lockHeld = true; sessionId = connection.ClientConnectionId; return true; + } + } + public void SetEnabled(bool enabled, CancellationToken cancellation) + { + SqlCommand command = null; + try + { + command = PrepareSettingCommand(enabled, cancellation); + cancellation.ThrowIfCancellationRequested(); + } + catch (MssqlSnapshotSettingNotAttemptedException) { command?.Dispose(); throw; } + catch (Exception ex) { command?.Dispose(); throw new MssqlSnapshotSettingNotAttemptedException(ex); } + using (command) + { + try { command.ExecuteNonQueryAsync(cancellation).GetAwaiter().GetResult(); } + catch (SqlException ex) when (ex.Number == 50000 && ex.Message == "SnapshotSessionIdentityChanged") + { throw new MssqlSnapshotSettingNotAttemptedException(); } + } + } + // Keep every operation which cannot send ALTER on this side of the dispatch boundary. + // Virtual only for deterministic adapter fault tests without a live SQL connection. + internal virtual SqlCommand PrepareSettingCommand(bool enabled, CancellationToken cancellation) + { + if (!lockHeld || transaction != null || !IsUsable) throw new MssqlSnapshotSettingNotAttemptedException(); + var fresh = ReadCapability(cancellation); + if (!MssqlSnapshotLifecycle.SameIdentity(lockedIdentity, fresh.Identity)) throw new MssqlSnapshotSettingNotAttemptedException(); + RequireSettingCapability(enabled, fresh); + // Guard in the SAME batch as ALTER: transparent provider reconnect must not authorize an unlocked session. + var command = Command(@"IF ISNULL(APPLOCK_MODE('public',@resource,'Session'),'NoLock') <> 'Exclusive' + OR ISNULL(CONVERT(nvarchar(128),SERVERPROPERTY('ServerName')),'') COLLATE Latin1_General_100_BIN2 <> @server COLLATE Latin1_General_100_BIN2 + OR NOT EXISTS(SELECT 1 FROM sys.databases WHERE database_id=DB_ID() AND database_id=@id + AND name COLLATE Latin1_General_100_BIN2=@name COLLATE Latin1_General_100_BIN2 AND create_date=@created) + THROW 50000,'SnapshotSessionIdentityChanged',1; +ALTER DATABASE " + MssqlExportMetadataReader.Quote(fresh.Identity.DatabaseName) + + " SET ALLOW_SNAPSHOT_ISOLATION " + (enabled ? "ON" : "OFF"), cancellation); + try + { + command.Parameters.Add("@resource", SqlDbType.NVarChar, 255).Value = LockResource; + command.Parameters.Add("@server", SqlDbType.NVarChar, 128).Value = lockedIdentity.ServerName; + command.Parameters.Add("@name", SqlDbType.NVarChar, 128).Value = lockedIdentity.DatabaseName; + command.Parameters.Add("@id", SqlDbType.Int).Value = lockedIdentity.DatabaseId; + command.Parameters.Add("@created", SqlDbType.DateTime).Value = lockedIdentity.DatabaseCreateDate; + return command; + } + catch { command.Dispose(); throw; } + } + // Keep the final known capability decision distinct from unknown adapter/provider failures. + internal static void RequireSettingCapability(bool enabled, MssqlSnapshotCapability fresh) + { + if (!fresh.CanManage || fresh.State != (enabled ? 0 : 1)) + throw new MssqlSnapshotSettingNotAttemptedException(enabled ? new MssqlSnapshotStartException(MssqlSnapshotStartRejection.SnapshotUnavailable, fresh) : null); + } + public void ReleaseLock(CancellationToken cancellation) + { + if (!lockHeld) return; + if (!IsUsable) throw new InvalidOperationException("SnapshotSessionUnavailable"); + using (var command = Command(@"DECLARE @result int; +EXEC @result=sys.sp_releaseapplock @Resource=@resource,@LockOwner='Session',@DbPrincipal='public'; SELECT @result;", cancellation)) + { + command.Parameters.Add("@resource", SqlDbType.NVarChar, 255).Value = LockResource; + if (Convert.ToInt32(command.ExecuteScalarAsync(cancellation).GetAwaiter().GetResult()) < 0) throw new InvalidOperationException("SnapshotLockReleaseFailed"); + lockHeld = false; + } + } + private SqlCommand Command(string sql, CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + if (!IsUsable) throw new InvalidOperationException("SnapshotSessionUnavailable"); + int timeout = deadline == null ? 30 : deadline.RemainingSeconds; + return new SqlCommand(sql, connection, transaction) { CommandTimeout = timeout }; + } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlSnapshotLifecycle.cs b/mySQLPunk/lib/MssqlExport/MssqlSnapshotLifecycle.cs new file mode 100644 index 0000000..8cfee60 --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlSnapshotLifecycle.cs @@ -0,0 +1,211 @@ +using System; +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Threading; +namespace mySQLPunk.lib.MssqlExport +{ + internal interface IMssqlSnapshotDeadline : IDisposable + { + CancellationToken Token { get; } + int RemainingSeconds { get; } + void Check(); + void WaitForPoll(); + } + internal interface IMssqlSnapshotDeadlineBackend + { + IMssqlSnapshotDeadline Deadline { set; } + } + // One monotonic budget and linked token cover the entire operation, including every SQL command. + internal sealed class MssqlSnapshotDeadline : IMssqlSnapshotDeadline + { + private readonly Stopwatch elapsed = Stopwatch.StartNew(); + private readonly CancellationTokenSource cancellation; + private readonly int seconds; + internal MssqlSnapshotDeadline(int seconds, CancellationToken token) + { this.seconds = seconds; cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); cancellation.CancelAfter(TimeSpan.FromSeconds(seconds)); } + public CancellationToken Token => cancellation.Token; + public int RemainingSeconds { get { Check(); return Math.Max(1, (int)Math.Ceiling(seconds - elapsed.Elapsed.TotalSeconds)); } } + public void Check() + { Token.ThrowIfCancellationRequested(); if (elapsed.Elapsed.TotalSeconds >= seconds) throw new TimeoutException("SnapshotDeadlineElapsed"); } + public void WaitForPoll() { Check(); Token.WaitHandle.WaitOne(100); Check(); } + public void Dispose() { cancellation.Dispose(); } + } + internal sealed class MssqlSnapshotLifecycle + { + internal const int EnableSeconds = 60; + internal const int RestoreSeconds = 30; + private readonly IMssqlSnapshotBackend backend; + private readonly IMssqlSnapshotRecoveryStore store; + private readonly Guid jobId; + private readonly Func deadlineFactory; + private MssqlSnapshotIdentity identity; + private MssqlSnapshotRecoveryRecord record; + private string path; + private bool started, locked, releaseAttempted, attemptedEnable; + private int? original, observed; + private MssqlSnapshotRecoveryCode notice; + private MssqlSnapshotCleanupResult result; + internal MssqlSnapshotLifecycle(IMssqlSnapshotBackend backend, IMssqlSnapshotRecoveryStore store, Guid jobId, + Func deadlineFactory = null) + { + this.backend = backend ?? throw new ArgumentNullException(nameof(backend)); + this.store = store ?? throw new ArgumentNullException(nameof(store)); + if (jobId == Guid.Empty) throw new ArgumentException("JobIdRequired", nameof(jobId)); + this.jobId = jobId; this.deadlineFactory = deadlineFactory ?? ((s, t) => new MssqlSnapshotDeadline(s, t)); + } + internal void Prepare(Action report, CancellationToken cancellation) + { + if (started || result != null) throw new InvalidOperationException("SnapshotAlreadyPrepared"); + started = true; + using (var deadline = deadlineFactory(EnableSeconds, cancellation)) + { + SetDeadline(deadline); + try + { + deadline.Check(); + if (!backend.TryAcquireLock(deadline.Token)) throw new MssqlSnapshotStartException(MssqlSnapshotStartRejection.SnapshotBusy); + locked = true; + var capability = Read(deadline); + if (!capability.CanManage) throw new MssqlSnapshotStartException(MssqlSnapshotStartRejection.SnapshotUnavailable, capability); + identity = capability.Identity; original = capability.State; + if (original == 1) return; + Report(report, MssqlExportStage.EnablingSnapshot); + record = Record(MssqlSnapshotRecoveryPhase.Prepared, MssqlSnapshotRecoveryCode.None); + path = store.Create(record); + if (string.IsNullOrWhiteSpace(path)) throw new InvalidOperationException("SnapshotJournalReceiptMissing"); + record = Record(MssqlSnapshotRecoveryPhase.EnableAttempted, MssqlSnapshotRecoveryCode.EnableUnconfirmed); + try { store.Update(record); } // Durable intent is required BEFORE sending ON. + catch (Exception) { Warn(MssqlSnapshotRecoveryCode.JournalUpdateFailed); throw; } + var fresh = Read(deadline); + RequireIdentity(fresh); + if (!fresh.CanManage || fresh.State != 0) throw new MssqlSnapshotStartException(MssqlSnapshotStartRejection.SnapshotUnavailable, fresh); + deadline.Check(); attemptedEnable = true; + try { backend.SetEnabled(true, deadline.Token); } + catch (MssqlSnapshotSettingNotAttemptedException ex) + { + attemptedEnable = false; + // Preserve the caller's cancellation/error semantics after discarding mutation ownership. + if (ex.InnerException != null) ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + Confirm(deadline, 1); + Update(MssqlSnapshotRecoveryPhase.Enabled, MssqlSnapshotRecoveryCode.None); + deadline.Check(); + } + finally { SetDeadline(null); } // Keep the session and lock alive for explicit Restore, even on error. + } + } + internal MssqlSnapshotCleanupResult Restore(Action report) + { + if (result != null) return result; + var state = original == 1 ? MssqlSnapshotCleanupState.PreservedOn : MssqlSnapshotCleanupState.NotRequired; + using (var deadline = deadlineFactory(RestoreSeconds, CancellationToken.None)) + { + SetDeadline(deadline); + try + { + if (attemptedEnable) + { + state = MssqlSnapshotCleanupState.Unconfirmed; + Report(report, MssqlExportStage.RestoringSnapshot); + Update(MssqlSnapshotRecoveryPhase.Restoring, MssqlSnapshotRecoveryCode.None); + var capability = Read(deadline); RequireIdentity(capability); + while (capability.State == 2 || capability.State == 3) + { + deadline.WaitForPoll(); capability = Read(deadline); RequireIdentity(capability); + } + if (capability.State == 1) + { + if (!capability.CanManage) throw new InvalidOperationException("SnapshotCapabilityUnavailable"); + // The SQL adapter checks live identity/lock again immediately before ALTER. + try { backend.SetEnabled(false, deadline.Token); } + catch (Exception) { /* Response can be lost after OFF. Actual readback decides. */ } + Confirm(deadline, 0); + } + else if (capability.State != 0) Confirm(deadline, 0); + state = MssqlSnapshotCleanupState.RestoredOff; + Update(MssqlSnapshotRecoveryPhase.Restoring, MssqlSnapshotRecoveryCode.None); + } + if (path != null) + { + // No ALTER was attempted, or matching live-session readback confirmed OFF. + deadline.Check(); + try { store.RemoveConfirmed(record); path = null; } + catch (Exception) { Warn(MssqlSnapshotRecoveryCode.JournalRemoveFailed); Update(MssqlSnapshotRecoveryPhase.Unconfirmed, MssqlSnapshotRecoveryCode.JournalRemoveFailed); } + } + } + catch (Exception) + { + Warn(MssqlSnapshotRecoveryCode.RestoreUnconfirmed); + Update(MssqlSnapshotRecoveryPhase.Unconfirmed, notice); + } + finally + { + ReleaseCore(deadline); + SetDeadline(null); + } + } + result = new MssqlSnapshotCleanupResult(state, observed, path, + notice == MssqlSnapshotRecoveryCode.None ? null : notice.ToString()); + return result; + } + // Final fallback; Restore normally releases the lock so its outcome includes release failures. + internal void Release() { if (!releaseAttempted && locked) Restore(null); } + private void ReleaseCore(IMssqlSnapshotDeadline deadline) + { + if (!locked || releaseAttempted) return; + releaseAttempted = true; + try { deadline.Check(); backend.ReleaseLock(deadline.Token); locked = false; } + catch (Exception) + { + Warn(MssqlSnapshotRecoveryCode.LockReleaseFailed); + if (record != null && path == null) + { + try { record = Record(MssqlSnapshotRecoveryPhase.Unconfirmed, MssqlSnapshotRecoveryCode.LockReleaseFailed); path = store.Create(record); } + catch (Exception) { /* Fixed notice survives a failed journal recreation. */ } + } + else Update(MssqlSnapshotRecoveryPhase.Unconfirmed, MssqlSnapshotRecoveryCode.LockReleaseFailed); + } + } + private MssqlSnapshotCapability Read(IMssqlSnapshotDeadline deadline) + { + deadline.Check(); + if (!locked || !backend.IsUsable) { Warn(MssqlSnapshotRecoveryCode.SessionUnavailable); throw new InvalidOperationException("SnapshotSessionUnavailable"); } + var capability = backend.ReadCapability(deadline.Token); + deadline.Check(); + if (capability == null) throw new InvalidOperationException("SnapshotCapabilityUnavailable"); + observed = (identity == null || SameIdentity(identity, capability.Identity)) && capability.State >= 0 && capability.State <= 3 ? capability.State : (int?)null; + return capability; + } + private void Confirm(IMssqlSnapshotDeadline deadline, int target) + { + while (true) + { + var capability = Read(deadline); RequireIdentity(capability); + if (capability.State == target) return; + if (capability.State != 2 && capability.State != 3) throw new InvalidOperationException("SnapshotStateUnconfirmed"); + deadline.WaitForPoll(); + } + } + private void RequireIdentity(MssqlSnapshotCapability capability) + { + if (!SameIdentity(identity, capability.Identity)) { Warn(MssqlSnapshotRecoveryCode.IdentityChanged); throw new InvalidOperationException("SnapshotIdentityChanged"); } + } + internal static bool SameIdentity(MssqlSnapshotIdentity a, MssqlSnapshotIdentity b) => a != null && b != null && a.IsComplete && b.IsComplete && + string.Equals(a.ServerName, b.ServerName, StringComparison.Ordinal) && string.Equals(a.DatabaseName, b.DatabaseName, StringComparison.Ordinal) && + a.DatabaseId == b.DatabaseId && a.DatabaseCreateDate == b.DatabaseCreateDate; + private MssqlSnapshotRecoveryRecord Record(MssqlSnapshotRecoveryPhase phase, MssqlSnapshotRecoveryCode code) => + new MssqlSnapshotRecoveryRecord(jobId, identity, original ?? 0, phase, observed, DateTime.UtcNow, code); + private void Update(MssqlSnapshotRecoveryPhase phase, MssqlSnapshotRecoveryCode code) + { + if (path == null) return; + record = Record(phase, code); + try { store.Update(record); } catch (Exception) { Warn(MssqlSnapshotRecoveryCode.JournalUpdateFailed); } + } + private void Warn(MssqlSnapshotRecoveryCode code) { if (notice == MssqlSnapshotRecoveryCode.None) notice = code; } + private void Report(Action report, MssqlExportStage stage) + { try { report?.Invoke(stage); } catch (Exception) { Warn(MssqlSnapshotRecoveryCode.ObserverFailed); } } + private void SetDeadline(IMssqlSnapshotDeadline deadline) + { var bounded = backend as IMssqlSnapshotDeadlineBackend; if (bounded != null) bounded.Deadline = deadline; } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlSnapshotModels.cs b/mySQLPunk/lib/MssqlExport/MssqlSnapshotModels.cs new file mode 100644 index 0000000..3ec83ab --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlSnapshotModels.cs @@ -0,0 +1,93 @@ +using System; +using System.Threading; +using System.Linq; +namespace mySQLPunk.lib.MssqlExport +{ + internal sealed class MssqlSnapshotIdentity + { + public string ServerName { get; } + public string DatabaseName { get; } + public int DatabaseId { get; } + public DateTime DatabaseCreateDate { get; } + public bool IsComplete => !string.IsNullOrWhiteSpace(ServerName) && !string.IsNullOrWhiteSpace(DatabaseName) && DatabaseId > 0 && DatabaseCreateDate != default(DateTime); + internal MssqlSnapshotIdentity(string serverName, string databaseName, int databaseId, DateTime databaseCreateDate) + { ServerName = serverName; DatabaseName = databaseName; DatabaseId = databaseId; DatabaseCreateDate = databaseCreateDate; } + } + internal enum MssqlSnapshotUnavailableReason { None, IdentityUnknown, StateUnavailable, AlterUnknown, AlterDenied, UpdateabilityUnknown, ReadOnly } + internal sealed class MssqlSnapshotCapability + { + public MssqlSnapshotIdentity Identity { get; } + public int State { get; } + public bool? CanAlter { get; } + public bool? IsWritable { get; } + public bool CanManage => UnavailableReason == MssqlSnapshotUnavailableReason.None; + public MssqlSnapshotUnavailableReason UnavailableReason => Identity == null || !Identity.IsComplete ? MssqlSnapshotUnavailableReason.IdentityUnknown : + State != 0 && State != 1 ? MssqlSnapshotUnavailableReason.StateUnavailable : + !CanAlter.HasValue ? MssqlSnapshotUnavailableReason.AlterUnknown : !CanAlter.Value ? MssqlSnapshotUnavailableReason.AlterDenied : + !IsWritable.HasValue ? MssqlSnapshotUnavailableReason.UpdateabilityUnknown : !IsWritable.Value ? MssqlSnapshotUnavailableReason.ReadOnly : MssqlSnapshotUnavailableReason.None; + internal MssqlSnapshotCapability(MssqlSnapshotIdentity identity, int state, bool? canAlter, bool? isWritable) + { Identity = identity; State = state; CanAlter = canAlter; IsWritable = isWritable; } + } + internal enum MssqlSnapshotStartRejection { None, SnapshotUnavailable, SnapshotBusy } + // Only explicit application decisions cross this boundary; provider text is never classified by message. + internal sealed class MssqlSnapshotStartException : InvalidOperationException + { + public MssqlSnapshotStartRejection Reason { get; } + public MssqlSnapshotCapability Capability { get; } + internal MssqlSnapshotStartException(MssqlSnapshotStartRejection reason, MssqlSnapshotCapability capability = null) + : base(reason == MssqlSnapshotStartRejection.SnapshotBusy ? "SnapshotBusy: another snapshot export is running; retry later." : "SnapshotUnavailable: recheck and review the read mode before starting.") + { + if (reason != MssqlSnapshotStartRejection.SnapshotBusy && reason != MssqlSnapshotStartRejection.SnapshotUnavailable) throw new ArgumentOutOfRangeException(nameof(reason)); + Reason = reason; Capability = capability; + } + internal static void RequireValid(MssqlExportPreflight preflight) + { + if (preflight.CanStart) return; + if (preflight.Issues.Any(x => x.Code == "SnapshotUnavailable")) + throw new MssqlSnapshotStartException(MssqlSnapshotStartRejection.SnapshotUnavailable, preflight.SnapshotCapability); + throw new InvalidOperationException("Export preflight did not pass."); + } + } + internal enum MssqlSnapshotCleanupState { NotRequired, PreservedOn, RestoredOff, Unconfirmed } + internal sealed class MssqlSnapshotCleanupResult + { + public MssqlSnapshotCleanupState State { get; } + public int? ObservedState { get; } + public string RecoveryRecordPath { get; } + public string WarningCode { get; } + internal MssqlSnapshotCleanupResult(MssqlSnapshotCleanupState state, int? observedState, string recoveryRecordPath, string warningCode) + { State = state; ObservedState = observedState; RecoveryRecordPath = recoveryRecordPath; WarningCode = warningCode; } + } + internal interface IMssqlSnapshotBackend + { + MssqlSnapshotCapability ReadCapability(CancellationToken cancellation); + bool TryAcquireLock(CancellationToken cancellation); + void SetEnabled(bool enabled, CancellationToken cancellation); + void ReleaseLock(CancellationToken cancellation); + bool IsUsable { get; } + } + internal enum MssqlSnapshotRecoveryPhase { Prepared, Enabled, Restoring, Unconfirmed, EnableAttempted } + internal enum MssqlSnapshotRecoveryCode { None, EnableUnconfirmed, RestoreUnconfirmed, JournalUpdateFailed, JournalRemoveFailed, LockReleaseFailed, IdentityChanged, SessionUnavailable, ObserverFailed } + internal enum MssqlSnapshotRecoveryReadError { None, InvalidRecord, UnsupportedVersion, TooLarge, UnsafePath, ReadFailed, Overflow } + internal sealed class MssqlSnapshotRecoveryRecord + { + public int Version => 1; + public Guid JobId { get; } + public MssqlSnapshotIdentity Identity { get; } + public int OriginalState { get; } + public MssqlSnapshotRecoveryPhase Phase { get; } + public int? ObservedState { get; } + public DateTime UpdatedUtc { get; } + public MssqlSnapshotRecoveryCode NoticeCode { get; } + internal MssqlSnapshotRecoveryRecord(Guid jobId, MssqlSnapshotIdentity identity, int originalState, MssqlSnapshotRecoveryPhase phase, int? observedState, DateTime updatedUtc, MssqlSnapshotRecoveryCode noticeCode) + { JobId = jobId; Identity = identity; OriginalState = originalState; Phase = phase; ObservedState = observedState; UpdatedUtc = updatedUtc; NoticeCode = noticeCode; } + } + internal sealed class MssqlSnapshotRecoveryNotice + { + public MssqlSnapshotRecoveryRecord Record { get; } + public string Path { get; } + public MssqlSnapshotRecoveryReadError ReadError { get; } + internal MssqlSnapshotRecoveryNotice(MssqlSnapshotRecoveryRecord record, string path, MssqlSnapshotRecoveryReadError error) + { Record = record; Path = path; ReadError = error; } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlSnapshotRecoveryStore.cs b/mySQLPunk/lib/MssqlExport/MssqlSnapshotRecoveryStore.cs new file mode 100644 index 0000000..2225738 --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlSnapshotRecoveryStore.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +namespace mySQLPunk.lib.MssqlExport +{ + internal interface IMssqlSnapshotRecoveryStore + { + string Create(MssqlSnapshotRecoveryRecord record); + void Update(MssqlSnapshotRecoveryRecord record); + void RemoveConfirmed(MssqlSnapshotRecoveryRecord expected); + IReadOnlyList ReadPending(); + } + // The hook is a test seam at durable-write / publication / deletion boundaries. + internal sealed class MssqlSnapshotRecoveryStore : IMssqlSnapshotRecoveryStore + { + private sealed class UnsafeRecoveryPathException : IOException { } + private readonly string root; + private readonly Action fault; + internal MssqlSnapshotRecoveryStore(string root = null, Action ioFault = null) + { + this.root = Path.GetFullPath(root ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "mySQLPunk", "ExportRecovery", "snapshot")); + fault = ioFault; + } + private static void CheckPath(string path) + { + for (string current = Path.GetFullPath(path); current != null; current = Path.GetDirectoryName(current)) + if ((File.Exists(current) || Directory.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + throw new UnsafeRecoveryPathException(); + } + private string RecordPath(Guid job) => Path.Combine(root, job.ToString("N") + ".json"); + private static bool Same(MssqlSnapshotRecoveryRecord a, MssqlSnapshotRecoveryRecord b) => a.JobId == b.JobId && a.OriginalState == b.OriginalState && + a.Identity.ServerName == b.Identity.ServerName && a.Identity.DatabaseName == b.Identity.DatabaseName && a.Identity.DatabaseId == b.Identity.DatabaseId && a.Identity.DatabaseCreateDate == b.Identity.DatabaseCreateDate; + private static void Validate(MssqlSnapshotRecoveryRecord r) + { + if (r == null || r.JobId == Guid.Empty || r.Identity == null || !r.Identity.IsComplete || r.Identity.ServerName.Length > 256 || r.Identity.DatabaseName.Length > 128 || + (r.OriginalState != 0 && r.OriginalState != 1) || (r.ObservedState.HasValue && (r.ObservedState < 0 || r.ObservedState > 3)) || + !Enum.IsDefined(typeof(MssqlSnapshotRecoveryPhase), r.Phase) || !Enum.IsDefined(typeof(MssqlSnapshotRecoveryCode), r.NoticeCode) || r.UpdatedUtc.Kind != DateTimeKind.Utc) + throw new ArgumentException("Invalid recovery record."); + } + private static JObject Encode(MssqlSnapshotRecoveryRecord r) => new JObject { + { "version", 1 }, { "jobId", r.JobId.ToString("N") }, { "serverName", r.Identity.ServerName }, { "databaseName", r.Identity.DatabaseName }, + { "databaseId", r.Identity.DatabaseId }, { "databaseCreateDate", r.Identity.DatabaseCreateDate.ToString("O") }, { "originalState", r.OriginalState }, + { "phase", (int)r.Phase }, { "observedState", r.ObservedState.HasValue ? new JValue(r.ObservedState.Value) : JValue.CreateNull() }, + { "updatedUtc", r.UpdatedUtc.ToString("O") }, { "noticeCode", (int)r.NoticeCode } }; + public string Create(MssqlSnapshotRecoveryRecord record) { Write(record, false); return RecordPath(record.JobId); } + public void Update(MssqlSnapshotRecoveryRecord record) { Write(record, true); } + private void Write(MssqlSnapshotRecoveryRecord record, bool update) + { + Validate(record); CheckPath(root); Directory.CreateDirectory(root); CheckPath(root); + string path = RecordPath(record.JobId); + CheckPath(path); + if (update) + { + var existing = ReadOne(path); + if (existing.Record == null || !Same(existing.Record, record)) throw new IOException("Recovery record ownership mismatch."); + } + string temp = Path.Combine(root, Guid.NewGuid().ToString("N") + ".tmp"); + try + { + byte[] bytes = new UTF8Encoding(false, true).GetBytes(Encode(record).ToString(Formatting.None)); + using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { stream.Write(bytes, 0, bytes.Length); fault?.Invoke("BeforeFlush"); stream.Flush(true); } + fault?.Invoke("BeforePublish"); CheckPath(root); CheckPath(path); CheckPath(temp); + if (update) File.Replace(temp, path, null); else File.Move(temp, path); + } + finally { if (File.Exists(temp)) { CheckPath(temp); File.Delete(temp); } } + } + public void RemoveConfirmed(MssqlSnapshotRecoveryRecord expected) + { + Validate(expected); CheckPath(root); string path = RecordPath(expected.JobId); CheckPath(path); + if (!File.Exists(path)) return; + var current = ReadOne(path); + if (current.Record == null || !Same(current.Record, expected)) throw new IOException("Recovery record ownership mismatch."); + fault?.Invoke("BeforeDelete"); CheckPath(root); CheckPath(path); File.Delete(path); + } + private MssqlSnapshotRecoveryNotice ReadOne(string path) + { + try + { + CheckPath(path); + byte[] bytes; + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + if (stream.Length > 65536) return Error(MssqlSnapshotRecoveryReadError.TooLarge); + bytes = new byte[(int)stream.Length]; int offset = 0; + while (offset < bytes.Length) { int count = stream.Read(bytes, offset, bytes.Length - offset); if (count == 0) throw new IOException(); offset += count; } + } + JObject dto; + using (var reader = new JsonTextReader(new StringReader(new UTF8Encoding(false, true).GetString(bytes))) { DateParseHandling = DateParseHandling.None, MaxDepth = 8 }) + { dto = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error }); if (reader.Read()) throw new JsonException(); } + if (dto["version"] == null || dto["version"].Type != JTokenType.Integer) throw new JsonException(); + if ((int?)dto["version"] != 1) return Error(MssqlSnapshotRecoveryReadError.UnsupportedVersion); + string[] names = { "version", "jobId", "serverName", "databaseName", "databaseId", "databaseCreateDate", "originalState", "phase", "observedState", "updatedUtc", "noticeCode" }; + if (dto.Count != names.Length) throw new JsonException(); + foreach (string name in names) if (dto[name] == null) throw new JsonException(); + foreach (string name in new[] { "jobId", "serverName", "databaseName", "databaseCreateDate", "updatedUtc" }) if (dto[name].Type != JTokenType.String) throw new JsonException(); + foreach (string name in new[] { "databaseId", "originalState", "phase", "noticeCode" }) if (dto[name].Type != JTokenType.Integer) throw new JsonException(); + if (dto["observedState"].Type != JTokenType.Integer && dto["observedState"].Type != JTokenType.Null) throw new JsonException(); + var record = new MssqlSnapshotRecoveryRecord(Guid.ParseExact((string)dto["jobId"], "N"), + new MssqlSnapshotIdentity((string)dto["serverName"], (string)dto["databaseName"], (int)dto["databaseId"], DateTime.ParseExact((string)dto["databaseCreateDate"], "O", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind)), + (int)dto["originalState"], (MssqlSnapshotRecoveryPhase)(int)dto["phase"], (int?)dto["observedState"], DateTime.ParseExact((string)dto["updatedUtc"], "O", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.RoundtripKind), (MssqlSnapshotRecoveryCode)(int)dto["noticeCode"]); + Validate(record); + if (!string.Equals(Path.GetFileName(path), record.JobId.ToString("N") + ".json", StringComparison.Ordinal)) throw new JsonException(); + return new MssqlSnapshotRecoveryNotice(record, path, MssqlSnapshotRecoveryReadError.None); + } + catch (UnsafeRecoveryPathException) { return Error(MssqlSnapshotRecoveryReadError.UnsafePath); } + catch (IOException) { return Error(MssqlSnapshotRecoveryReadError.ReadFailed); } + catch (UnauthorizedAccessException) { return Error(MssqlSnapshotRecoveryReadError.ReadFailed); } + catch (Exception ex) when (ex is JsonException || ex is ArgumentException || ex is FormatException || ex is InvalidCastException || ex is OverflowException) + { return Error(MssqlSnapshotRecoveryReadError.InvalidRecord); } + } + private static MssqlSnapshotRecoveryNotice Error(MssqlSnapshotRecoveryReadError error) => new MssqlSnapshotRecoveryNotice(null, null, error); + public IReadOnlyList ReadPending() + { + var results = new List(); + try + { + CheckPath(root); if (!Directory.Exists(root)) return results.AsReadOnly(); + foreach (string path in Directory.EnumerateFiles(root, "*.json", SearchOption.TopDirectoryOnly)) + { + if (results.Count == 100) { results.Add(Error(MssqlSnapshotRecoveryReadError.Overflow)); break; } + results.Add(ReadOne(path)); + } + } + catch (UnsafeRecoveryPathException) { results.Add(Error(MssqlSnapshotRecoveryReadError.UnsafePath)); } + catch (IOException) { results.Add(Error(MssqlSnapshotRecoveryReadError.ReadFailed)); } + catch (UnauthorizedAccessException) { results.Add(Error(MssqlSnapshotRecoveryReadError.ReadFailed)); } + return results.AsReadOnly(); + } + } +} diff --git a/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs b/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs index f58a35f..aa378b7 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; +using System.Data.SqlClient; using System.Text; using System.Threading; @@ -9,19 +11,32 @@ namespace mySQLPunk.lib.MssqlExport { internal class MssqlSqlExportService { + internal const string OrdinaryReadWarning = "一般讀取(READ COMMITTED):匯出期間可能讀到不同時間的資料;來源持續寫入時,關聯資料可能不一致,還原可能因外鍵限制失敗。檔案驗證不等同還原成功。"; + private readonly IMssqlSnapshotRecoveryStore recoveryStore; private readonly Func sourceFactory; private readonly MssqlArtifactFiles files; private readonly Action localPhase; internal MssqlSqlExportService(Func sourceFactory) : this(sourceFactory, new MssqlArtifactFiles(), null) { } - internal MssqlSqlExportService(Dictionary connection) : this(OwnedFactory(connection)) { } + internal MssqlSqlExportService(Dictionary connection) : this(connection, new MssqlSnapshotRecoveryStore()) { } + internal MssqlSqlExportService(Dictionary connection, IMssqlSnapshotRecoveryStore recoveryStore, + Func backendFactory = null) + : this(OwnedFactory(connection, recoveryStore, backendFactory), new MssqlArtifactFiles(), null, recoveryStore) { } // Existing file operations plus a local phase boundary are sufficient for deterministic faults. - internal MssqlSqlExportService(Func sourceFactory, MssqlArtifactFiles files, Action localPhase) - { this.sourceFactory = sourceFactory ?? throw new ArgumentNullException(nameof(sourceFactory)); this.files = files ?? throw new ArgumentNullException(nameof(files)); this.localPhase = localPhase; } - private static Func OwnedFactory(Dictionary connection) + internal MssqlSqlExportService(Func sourceFactory, MssqlArtifactFiles files, Action localPhase, IMssqlSnapshotRecoveryStore recoveryStore = null) + { this.sourceFactory = sourceFactory ?? throw new ArgumentNullException(nameof(sourceFactory)); this.files = files ?? throw new ArgumentNullException(nameof(files)); this.localPhase = localPhase; this.recoveryStore = recoveryStore ?? new MssqlSnapshotRecoveryStore(); } + private static Func OwnedFactory(Dictionary connection, IMssqlSnapshotRecoveryStore recoveryStore, Func backendFactory) { if (connection == null) throw new ArgumentNullException(nameof(connection)); var settings = new Dictionary(connection); - return () => new MssqlExportSource(ConnectionOpenService.OpenMssqlSession(new Dictionary(settings))); + return () => new MssqlExportSource(ConnectionOpenService.OpenMssqlSession(new Dictionary(settings)), recoveryStore, backendFactory); + } + internal virtual IReadOnlyList LoadRecoveryNotices(MssqlSnapshotIdentity identity) + { + // Display candidates are broader than live-session cleanup authorization: recreated databases + // and unknown current identity still need an honest historical notice, never automatic repair. + return recoveryStore.ReadPending().Where(n => n.Record == null || identity == null || !identity.IsComplete || + string.Equals(identity.ServerName, n.Record.Identity.ServerName, StringComparison.Ordinal) && + string.Equals(identity.DatabaseName, n.Record.Identity.DatabaseName, StringComparison.Ordinal)).ToList().AsReadOnly(); } internal virtual MssqlExportCatalog LoadCatalog(string databaseName, CancellationToken cancellation) { @@ -36,6 +51,8 @@ internal virtual MssqlExportPreflight Preflight(MssqlExportRequest request, Canc private static Exception SafeInspectionFailure(string operation, Exception error, CancellationToken cancellation) { if (cancellation.IsCancellationRequested) return new OperationCanceledException(cancellation); + var prerequisite = MssqlExportPrerequisiteException.From(error); + if (prerequisite != null) return prerequisite; // No original exception as InnerException: even ToString must remain safe for a UI log. return new InvalidOperationException("Export " + operation + " failed (" + error.GetType().Name + ")."); } @@ -47,21 +64,33 @@ internal virtual MssqlExportResult Export(MssqlExportRequest request, IProgress< MssqlExportArtifact artifact = null; MssqlExportMirror mirror = null; var estimator = new MssqlExportProgressEstimator(request.JobId, new MssqlExportSchema(null, null, null, 0, null), request.Mirror != null); var stage = MssqlExportStage.Preflight; bool succeeded = false; bool canceled = false; string error = null; + MssqlSnapshotCleanupResult cleanup = null; + var startRejection = MssqlSnapshotStartRejection.None; MssqlSnapshotCapability startCapability = null; + Exception sourceError = null; bool sourceCanceled = false; + Action captureSourceFailure = ex => { + if (sourceError != null) return; + sourceError = ex; sourceCanceled = cancellation.IsCancellationRequested; + }; + if (request.Mode != MssqlExportMode.SchemaOnly && !request.UseSnapshot) warnings.Add(OrdinaryReadWarning); long written = 0; Action report = p => { if (p != null && progress != null) { try { progress.Report(p); } catch { /* Observer failure must not change publication truth. */ } } }; Action phase = s => { stage = s; report(estimator.Sample(s, null, 0, 0, written, clock.Elapsed, true)); localPhase?.Invoke(s); cancellation.ThrowIfCancellationRequested(); }; + // Cleanup reports are deliberately independent of job cancellation and fallible local phase hooks. + Action cleanupPhase = s => report(estimator.Sample(s, null, 0, 0, written, clock.Elapsed, true)); try { report(estimator.Sample(stage, null, 0, 0, 0, clock.Elapsed, true)); cancellation.ThrowIfCancellationRequested(); - using (var source = sourceFactory()) + var source = sourceFactory(); + try { var preflight = source.Inspect(request, cancellation); - if (!preflight.CanStart) throw new InvalidOperationException("Export preflight did not pass."); - var schema = source.BeginRead(request, cancellation); + startCapability = preflight.SnapshotCapability; + MssqlSnapshotStartException.RequireValid(preflight); + var schema = source.BeginRead(request, s => { stage = s; report(estimator.Sample(s, null, 0, 0, written, clock.Elapsed, true)); }, cancellation); estimator = new MssqlExportProgressEstimator(request.JobId, schema, request.Mirror != null); cancellation.ThrowIfCancellationRequested(); artifact = MssqlExportArtifact.Create(request, files); @@ -80,13 +109,21 @@ internal virtual MssqlExportResult Export(MssqlExportRequest request, IProgress< report(estimator.Sample(stage, table.Key, 0, 0, artifact.Output.Position, clock.Elapsed, true)); if (request.Mode != MssqlExportMode.SchemaOnly) { - using (var reader = source.OpenRows(table, cancellation)) + var reader = source.OpenRows(table, cancellation); + bool readFailed = false; + try { rows = data.WriteTable(reader.Reader, table, writer, (r, b) => { rows = r; sourceBytes = b; written = artifact.Output.Position; report(estimator.Sample(stage, table.Key, r, b, written, clock.Elapsed, false)); }, cancellation); } + catch (Exception ex) { readFailed = true; captureSourceFailure(ex); throw; } + finally + { + try { reader.Dispose(); } + catch (Exception) when (readFailed) { /* FinishRead still sees DisposeSucceeded=false; keep the read failure. */ } + } } counts.Add(new MssqlTableRowCount(table.Key, rows)); written = artifact.Output.Position; @@ -99,6 +136,13 @@ internal virtual MssqlExportResult Export(MssqlExportRequest request, IProgress< } source.CompleteRead(schema, cancellation); } + catch (Exception ex) { captureSourceFailure(ex); throw; } + finally + { + try { cleanup = source.FinishRead(cleanupPhase); } + catch (Exception) { cleanup = new MssqlSnapshotCleanupResult(MssqlSnapshotCleanupState.Unconfirmed, null, null, "SourceCleanupUnconfirmed"); } + finally { try { source.Dispose(); } catch (Exception) { warnings.Add("SourceReleaseUnconfirmed"); } } + } // No snapshot/reader/command/connection is alive during these local phases. var receipt = artifact.Complete(counts.AsReadOnly(), cancellation); written = receipt.Bytes; phase(MssqlExportStage.Verifying); @@ -121,9 +165,15 @@ internal virtual MssqlExportResult Export(MssqlExportRequest request, IProgress< } catch (Exception ex) { - canceled = cancellation.IsCancellationRequested; + // Cleanup can observe a later cancellation or throw another exception. The original source + // outcome is immutable; cancellation after successful reading still stops local publication. + var primary = sourceError ?? ex; + canceled = sourceError != null ? sourceCanceled : cancellation.IsCancellationRequested; // SQL/provider messages can contain credentials or row values. Return only a safe category. - error = canceled ? "Export canceled." : "Export failed during " + stage + " (" + ex.GetType().Name + ")."; + var rejected = canceled ? null : primary as MssqlSnapshotStartException; + if (rejected != null) { startRejection = rejected.Reason; startCapability = rejected.Reason == MssqlSnapshotStartRejection.SnapshotBusy ? rejected.Capability ?? startCapability : rejected.Capability; } + var prerequisite = canceled ? null : MssqlExportPrerequisiteException.From(primary); + error = canceled ? "Export canceled." : rejected != null ? rejected.Message : prerequisite != null ? "Export failed during " + stage + ". " + prerequisite.Message : "Export failed during " + stage + " (" + primary.GetType().Name + ")."; } finally { @@ -136,12 +186,18 @@ internal virtual MssqlExportResult Export(MssqlExportRequest request, IProgress< // Mirror owns its temporary lifetime inside CopyVerified's finally. if (mirror != null) { warnings.AddRange(mirror.Warnings); residuals.AddRange(mirror.ResidualPaths); } } + if (cleanup != null) + { + if (cleanup.WarningCode != null) warnings.Add(cleanup.WarningCode); + else if (cleanup.State == MssqlSnapshotCleanupState.Unconfirmed) warnings.Add("SnapshotRestoreUnconfirmed"); + if (cleanup.RecoveryRecordPath != null) residuals.Add(cleanup.RecoveryRecordPath); + } string published = artifact?.PublishedPath, mirrorPath = mirror?.MirrorPublishedPath; if (succeeded) stage = MssqlExportStage.Completed; else if (published != null) stage = MssqlExportStage.LocalCompletedMirrorIncomplete; else stage = canceled ? MssqlExportStage.Canceled : MssqlExportStage.Failed; report(estimator.Sample(stage, null, 0, 0, written, clock.Elapsed, true)); - return new MssqlExportResult(request.JobId, stage, published, mirrorPath, counts, clock.Elapsed, warnings, residuals, error); + return new MssqlExportResult(request.JobId, stage, published, mirrorPath, counts, clock.Elapsed, warnings, residuals, error, request.UseSnapshot, cleanup, startRejection, startRejection == MssqlSnapshotStartRejection.None ? null : startCapability); } } } diff --git a/mySQLPunk/lib/MssqlExport/MssqlViewReferenceRewriter.cs b/mySQLPunk/lib/MssqlExport/MssqlViewReferenceRewriter.cs new file mode 100644 index 0000000..a6e0d62 --- /dev/null +++ b/mySQLPunk/lib/MssqlExport/MssqlViewReferenceRewriter.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace mySQLPunk.lib.MssqlExport +{ + internal sealed class MssqlViewReferenceResolution + { + public int? SourceDatabaseId { get; } + public int? ReferencedDatabaseId { get; } + public int? ObjectId { get; } + public string ObjectType { get; } + internal MssqlViewReferenceResolution(int? sourceDatabaseId, int? referencedDatabaseId, int? objectId, string objectType) + { SourceDatabaseId = sourceDatabaseId; ReferencedDatabaseId = referencedDatabaseId; ObjectId = objectId; ObjectType = objectType; } + } + + internal sealed class MssqlViewRewriteResult + { + public string Sql { get; } + public IReadOnlyList ReferencedIds { get; } + public MssqlExportIssue Issue { get; } + public bool Success => Issue == null; + internal MssqlViewRewriteResult(string sql, IEnumerable referencedIds, MssqlExportIssue issue) + { Sql = sql; ReferencedIds = (referencedIds ?? Enumerable.Empty()).Distinct().OrderBy(x => x).ToList().AsReadOnly(); Issue = issue; } + } + + internal static class MssqlViewReferenceRewriter + { + internal static MssqlViewRewriteResult Rewrite(MssqlViewDefinition view, + Func resolver, + IEnumerable qualifiedDependencyIds, IEnumerable selectedObjectIds) + { + if (view == null) throw new ArgumentNullException(nameof(view)); + if (resolver == null) throw new ArgumentNullException(nameof(resolver)); + var expected = (qualifiedDependencyIds ?? Enumerable.Empty()).ToArray(); + if (expected.Any(x => !x.HasValue || x.Value <= 0)) return Failure(view, "A qualified dependency has no verified local object identity."); + var selected = new HashSet(selectedObjectIds ?? Enumerable.Empty()); + ViewStatementBody statement; + if (!ParseView(view.Definition, view.QuotedIdentifier, out statement)) + return Failure(view, "The view definition is not a single supported SQL Server 2019 view statement."); + var audit = new ReferenceAudit(); + statement.SelectStatement.Accept(audit); + if (audit.Error != null) return Failure(view, audit.Error); + var patches = new List(); + var ids = new HashSet(); + int? sourceDatabaseId = null; + foreach (var name in audit.QualifiedNames) + { + string database = name.DatabaseIdentifier.Value, schema = name.SchemaIdentifier.Value, entity = name.BaseIdentifier.Value; + if (!ValidIdentifier(database) || !ValidIdentifier(schema) || !ValidIdentifier(entity)) + return Failure(view, "Qualified identifiers must contain between 1 and 128 UTF-16 characters."); + // SQL Server, not a client comparer, decides database and object identity. + var resolution = resolver(database, schema, entity); + if (resolution == null || !resolution.SourceDatabaseId.HasValue || resolution.SourceDatabaseId.Value <= 0 || + resolution.SourceDatabaseId != resolution.ReferencedDatabaseId || !resolution.ObjectId.HasValue || resolution.ObjectId.Value <= 0 || + (resolution.ObjectType != "U" && resolution.ObjectType != "V") || !selected.Contains(resolution.ObjectId.Value) || + (sourceDatabaseId.HasValue && sourceDatabaseId != resolution.SourceDatabaseId)) + return Failure(view, "A qualified reference is not a verified selected table or view in the source database."); + sourceDatabaseId = resolution.SourceDatabaseId; + ids.Add(resolution.ObjectId.Value); + if (!RemoveDatabaseQualifier(view.Definition, name, patches)) + return Failure(view, "Qualified identifier token boundaries could not be verified."); + } + if (!ids.SetEquals(expected.Select(x => x.Value))) + return Failure(view, "The parsed qualified references do not match the catalog dependency identities."); + if (!NormalizeDeclaration(view, statement, patches)) + return Failure(view, "The view declaration token boundaries could not be verified."); + string sql; + if (!ApplyPatches(view.Definition, patches, out sql)) return Failure(view, "View definition edits overlap or have invalid token boundaries."); + ViewStatementBody rewritten; + if (!ParseView(sql, view.QuotedIdentifier, out rewritten) || !(rewritten is CreateViewStatement) || + rewritten.SchemaObjectName.Count != 2 || rewritten.SchemaObjectName.SchemaIdentifier.Value != view.Key.Schema || + rewritten.SchemaObjectName.BaseIdentifier.Value != view.Key.Name) + return Failure(view, "The rewritten view declaration could not be verified."); + var finalAudit = new ReferenceAudit(); + rewritten.SelectStatement.Accept(finalAudit); + if (finalAudit.Error != null || finalAudit.QualifiedNames.Count != 0) + return Failure(view, "A qualified reference remains after rewriting the view."); + return new MssqlViewRewriteResult(sql, ids, null); + } + + private static bool ParseView(string text, bool quotedIdentifier, out ViewStatementBody statement) + { + statement = null; + if (string.IsNullOrEmpty(text)) return false; + IList errors; + var script = new TSql150Parser(quotedIdentifier).Parse(new StringReader(text), out errors) as TSqlScript; + if (errors.Count != 0 || script == null || script.Batches.Count != 1 || script.Batches[0].Statements.Count != 1 || + script.ScriptTokenStream.Any(x => x.TokenType == TSqlTokenType.Go)) return false; + var candidate = script.Batches[0].Statements[0]; + if (!(candidate is CreateViewStatement) && !(candidate is AlterViewStatement) && !(candidate is CreateOrAlterViewStatement)) return false; + statement = (ViewStatementBody)candidate; + return statement.SchemaObjectName != null && statement.SelectStatement != null && !statement.IsMaterialized; + } + + private static bool RemoveDatabaseQualifier(string text, SchemaObjectName name, List patches) + { + var database = name.DatabaseIdentifier; + var schema = name.SchemaIdentifier; + var tokens = name.ScriptTokenStream; + if (tokens == null || database.FirstTokenIndex != database.LastTokenIndex || + database.FirstTokenIndex < 0 || schema.FirstTokenIndex <= database.LastTokenIndex || schema.FirstTokenIndex >= tokens.Count) return false; + var separator = Enumerable.Range(database.LastTokenIndex + 1, schema.FirstTokenIndex - database.LastTokenIndex - 1) + .Select(i => tokens[i]).Where(t => !Trivia(t)).ToArray(); + if (separator.Length != 1 || separator[0].TokenType != TSqlTokenType.Dot) return false; + return AddTokenPatch(text, tokens[database.FirstTokenIndex], "", patches) && AddTokenPatch(text, separator[0], "", patches); + } + + private static bool NormalizeDeclaration(MssqlViewDefinition view, ViewStatementBody statement, List patches) + { + var name = statement.SchemaObjectName; + var tokens = statement.ScriptTokenStream; + if (name.FirstTokenIndex <= statement.FirstTokenIndex || name.StartOffset < 0 || name.FragmentLength <= 0 || + name.StartOffset > view.Definition.Length - name.FragmentLength) return false; + var header = Enumerable.Range(statement.FirstTokenIndex, name.FirstTokenIndex - statement.FirstTokenIndex) + .Select(i => tokens[i]).Where(t => !Trivia(t)).ToArray(); + if (header.Length == 2 && header[1].TokenType == TSqlTokenType.View && + (header[0].TokenType == TSqlTokenType.Create || header[0].TokenType == TSqlTokenType.Alter)) + { + if (header[0].TokenType == TSqlTokenType.Alter && !AddTokenPatch(view.Definition, header[0], "CREATE", patches)) return false; + } + else if (header.Length == 4 && header[0].TokenType == TSqlTokenType.Create && header[1].TokenType == TSqlTokenType.Or && + header[2].TokenType == TSqlTokenType.Alter && header[3].TokenType == TSqlTokenType.View) + { + if (!AddTokenPatch(view.Definition, header[1], "", patches) || !AddTokenPatch(view.Definition, header[2], "", patches)) return false; + } + else return false; + patches.Add(new TextPatch(name.StartOffset, name.FragmentLength, MssqlExportMetadataReader.Qualified(view.Key))); + return true; + } + + private static bool AddTokenPatch(string text, TSqlParserToken token, string replacement, List patches) + { + if (token.Offset < 0 || string.IsNullOrEmpty(token.Text) || token.Offset > text.Length - token.Text.Length || + string.CompareOrdinal(text, token.Offset, token.Text, 0, token.Text.Length) != 0) return false; + patches.Add(new TextPatch(token.Offset, token.Text.Length, replacement)); + return true; + } + + private static bool ApplyPatches(string text, List patches, out string result) + { + result = null; + var sql = new StringBuilder(text); + int end = text.Length; + foreach (var patch in patches.OrderByDescending(x => x.Offset)) + { + if (patch.Offset < 0 || patch.Length <= 0 || patch.Offset > end - patch.Length) return false; + sql.Remove(patch.Offset, patch.Length).Insert(patch.Offset, patch.Replacement); + end = patch.Offset; + } + result = sql.ToString(); + return true; + } + + private static bool Trivia(TSqlParserToken token) => token.TokenType == TSqlTokenType.WhiteSpace || + token.TokenType == TSqlTokenType.SingleLineComment || token.TokenType == TSqlTokenType.MultilineComment; + private static bool ValidIdentifier(string name) => !string.IsNullOrEmpty(name) && name.Length <= 128; + private static MssqlViewRewriteResult Failure(MssqlViewDefinition view, string message) => + new MssqlViewRewriteResult(null, null, new MssqlExportIssue("UnsupportedViewReference", view.Key, null, message)); + + private sealed class TextPatch + { + internal readonly int Offset, Length; + internal readonly string Replacement; + internal TextPatch(int offset, int length, string replacement) { Offset = offset; Length = length; Replacement = replacement; } + } + + private sealed class ReferenceAudit : TSqlFragmentVisitor + { + internal readonly List QualifiedNames = new List(); + internal string Error; + private void Reject(string reason) { if (Error == null) Error = reason; } + public override void Visit(NamedTableReference node) + { + var name = node.SchemaObject; + if (name.ServerIdentifier != null) Reject("Server-qualified references are not supported."); + if (name.DatabaseIdentifier != null) + { + if (name.Count != 3 || name.SchemaIdentifier == null || !ValidIdentifier(name.SchemaIdentifier.Value) || + name.BaseIdentifier == null || !ValidIdentifier(name.BaseIdentifier.Value) || !ValidIdentifier(name.DatabaseIdentifier.Value)) + Reject("A qualified table or view reference requires explicit database, schema and object identifiers."); + else QualifiedNames.Add(name); + } + base.Visit(node); + } + public override void Visit(SchemaObjectName node) + { + if ((node.ServerIdentifier != null || node.DatabaseIdentifier != null) && !QualifiedNames.Contains(node)) + Reject("A qualified reference occurs outside a supported named table or view reference."); + base.Visit(node); + } + public override void Visit(ColumnReferenceExpression node) + { + if (node.MultiPartIdentifier != null && node.MultiPartIdentifier.Count >= 4) + Reject("Database-qualified column references require a separate supported rewrite and are not exported."); + base.Visit(node); + } + public override void Visit(SelectStarExpression node) + { + if (node.Qualifier != null && node.Qualifier.Count >= 3) Reject("Database-qualified wildcard references are not supported."); + base.Visit(node); + } + public override void Visit(MultiPartIdentifierCallTarget node) + { + if (node.MultiPartIdentifier != null && node.MultiPartIdentifier.Count >= 2) + Reject("Multipart function or method targets cannot be safely normalized in this view."); + base.Visit(node); + } + public override void Visit(NextValueForExpression node) { Reject("Sequence references are not supported."); } + public override void Visit(PartitionFunctionCall node) { Reject("Partition function references are not supported."); } + public override void Visit(SchemaObjectFunctionTableReference node) { Reject("Table-valued function references are not supported."); } + public override void Visit(OpenQueryTableReference node) { Reject("External rowset references are not supported."); } + public override void Visit(OpenRowsetTableReference node) { Reject("External rowset references are not supported."); } + public override void Visit(AdHocTableReference node) { Reject("External rowset references are not supported."); } + public override void Visit(BulkOpenRowset node) { Reject("External rowset references are not supported."); } + public override void Visit(TSqlFragment node) + { + // Bound the accepted rowset forms for this new normalization path. Concrete + // external/function overrides above do not rely on base-type visitation. + if (node is TableReference && !(node is NamedTableReference) && !(node is QueryDerivedTable) && !(node is InlineDerivedTable) && + !(node is QualifiedJoin) && !(node is UnqualifiedJoin) && !(node is JoinParenthesisTableReference) && + !(node is OdbcQualifiedJoinTableReference) && !(node is PivotedTableReference) && !(node is UnpivotedTableReference) && !(node is OpenJsonTableReference)) + Reject("This view contains an unsupported rowset reference context."); + } + } + } +} diff --git a/mySQLPunk/mySQLPunk.csproj b/mySQLPunk/mySQLPunk.csproj index 4d64dca..1c539b1 100644 --- a/mySQLPunk/mySQLPunk.csproj +++ b/mySQLPunk/mySQLPunk.csproj @@ -63,6 +63,9 @@ ..\packages\Microsoft.Extensions.Logging.Abstractions.8.0.3\lib\net462\Microsoft.Extensions.Logging.Abstractions.dll + + ..\packages\Microsoft.SqlServer.TransactSql.ScriptDom.180.117.0\lib\net472\Microsoft.SqlServer.TransactSql.ScriptDom.dll + ..\packages\MySqlConnector.2.3.7\lib\net471\MySqlConnector.dll @@ -362,6 +365,10 @@ + + + + @@ -370,6 +377,7 @@ + diff --git a/mySQLPunk/packages.config b/mySQLPunk/packages.config index 214cfcc..f54f794 100644 --- a/mySQLPunk/packages.config +++ b/mySQLPunk/packages.config @@ -5,6 +5,7 @@ + diff --git a/tests/MssqlExportFixtures/compatibility.sql b/tests/MssqlExportFixtures/compatibility.sql new file mode 100644 index 0000000..14f2aae --- /dev/null +++ b/tests/MssqlExportFixtures/compatibility.sql @@ -0,0 +1,38 @@ +-- Synthetic metadata only; caller owns the generated source database and its token. +-- Source/target defaults are BIN2. Property collation intentionally differs. +SET NOCOUNT ON; +SET XACT_ABORT ON; +GO +CREATE SCHEMA [報表] AUTHORIZATION dbo; +GO +CREATE TABLE [報表].[Property] ([id] int NOT NULL PRIMARY KEY, [dropped] int NULL, [note欄] nvarchar(40) NULL); +ALTER TABLE [報表].[Property] DROP COLUMN [dropped]; +INSERT [報表].[Property] VALUES (1,N'exported'); +GO +CREATE VIEW dbo.PropertyView AS SELECT [id],[note欄] FROM [報表].[Property]; +GO +CREATE VIEW dbo.LongView AS SELECT [id] FROM [報表].[Property]; +GO +DECLARE @value sql_variant, @short nvarchar(3000)=N'short', @empty nvarchar(40)=N'', @minimum int=-2147483648, @maximum int=2147483647; +SET @value=CONVERT(nvarchar(3503),REPLICATE(N'文',3503)) COLLATE Chinese_Taiwan_Stroke_CI_AS; +EXEC sys.sp_addextendedproperty @name=N'MS_DiagramPane1',@value=@value,@level0type=N'SCHEMA',@level0name=N'dbo',@level1type=N'VIEW',@level1name=N'PropertyView'; +SET @value=CONVERT(nvarchar(3700),REPLICATE(N'界',3700)) COLLATE Chinese_Taiwan_Stroke_CI_AS; +EXEC sys.sp_addextendedproperty @name=N'MS_DiagramPane1',@value=@value,@level0type=N'SCHEMA',@level0name=N'dbo',@level1type=N'VIEW',@level1name=N'LongView'; +EXEC sys.sp_addextendedproperty @name=N'MS_DiagramPaneCount',@value=1,@level0type=N'SCHEMA',@level0name=N'dbo',@level1type=N'VIEW',@level1name=N'PropertyView'; +EXEC sys.sp_addextendedproperty @name=N'int-min',@value=@minimum,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property'; +EXEC sys.sp_addextendedproperty @name=N'int-max',@value=@maximum,@level0type=N'SCHEMA',@level0name=N'dbo',@level1type=N'VIEW',@level1name=N'LongView'; +EXEC sys.sp_addextendedproperty @name=N'null',@value=NULL,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property'; +SET @value=@short COLLATE Chinese_Taiwan_Stroke_CI_AS; +EXEC sys.sp_addextendedproperty @name=N'short-declared-long',@value=@value,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property'; +SET @value=@empty COLLATE Chinese_Taiwan_Stroke_CI_AS; +EXEC sys.sp_addextendedproperty @name=N'empty-declared',@value=@value,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property'; +SET @value=N'' COLLATE Chinese_Taiwan_Stroke_CI_AS; +EXEC sys.sp_addextendedproperty @name=N'empty-literal',@value=@value,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property'; +SET @value=CONVERT(nvarchar(300),N'中文 '' ] '+CONVERT(nvarchar(2),0x3DD800DE)+NCHAR(13)+NCHAR(10)+N'GO'+NCHAR(13)+NCHAR(10)+N':r ignored $(name) tail ') COLLATE Chinese_Taiwan_Stroke_CI_AS; +EXEC sys.sp_addextendedproperty @name=N'MS_Description',@value=@value,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property'; +EXEC sys.sp_addextendedproperty @name=N'Property',@value=@value,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property'; +EXEC sys.sp_addextendedproperty @name=N'MS_Description',@value=@value,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property',@level2type=N'COLUMN',@level2name=N'note欄'; +EXEC sys.sp_addextendedproperty @name=N'column '' ] custom',@value=@value,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property',@level2type=N'COLUMN',@level2name=N'note欄'; +EXEC sys.sp_addextendedproperty @name=N'view-column',@value=@value,@level0type=N'SCHEMA',@level0name=N'dbo',@level1type=N'VIEW',@level1name=N'PropertyView',@level2type=N'COLUMN',@level2name=N'note欄'; +SET @value=CONVERT(nvarchar(30),N'drift-original') COLLATE Chinese_Taiwan_Stroke_CI_AS; +EXEC sys.sp_addextendedproperty @name=N'drift',@value=@value,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property'; diff --git a/tests/MssqlExportFixtures/snapshot.sql b/tests/MssqlExportFixtures/snapshot.sql new file mode 100644 index 0000000..6cfafb3 --- /dev/null +++ b/tests/MssqlExportFixtures/snapshot.sql @@ -0,0 +1,4 @@ +CREATE TABLE dbo.Parent(id int NOT NULL PRIMARY KEY); +CREATE TABLE dbo.Child(id int NOT NULL PRIMARY KEY,parent int NOT NULL,CONSTRAINT FK_Snapshot_Child_Parent FOREIGN KEY(parent) REFERENCES dbo.Parent(id)); +INSERT dbo.Parent VALUES(1); +INSERT dbo.Child VALUES(1,1); diff --git a/tests/Run-MssqlExportIntegration.ps1 b/tests/Run-MssqlExportIntegration.ps1 index e6ddc55..04923c7 100644 --- a/tests/Run-MssqlExportIntegration.ps1 +++ b/tests/Run-MssqlExportIntegration.ps1 @@ -1,7 +1,7 @@ param( [switch]$Run, [string]$Server, - [ValidateSet('Core','Large','Concurrency','ClientProbe','FinalFix','Geometry','Ssh')][string]$Profile = 'Core', + [ValidateSet('OptionalSnapshot','SnapshotLifecycle','Core','Large','Concurrency','ClientProbe','FinalFix','Geometry','Properties','References','Ssh')][string]$Profile = 'Core', [string]$OutputDirectory, [ValidateSet('Classic','Go')][string]$Client = 'Classic', [string]$SqlcmdPath, @@ -9,7 +9,7 @@ param( ) $ErrorActionPreference = 'Stop' if (!$Run) { - Write-Host 'Opt-in isolated MSSQL acceptance. Supply -Run -Server "(localdb)\mySQLPunkExportTest_20261012" -Profile Core|Large|Concurrency|Geometry -OutputDirectory .' + Write-Host 'Opt-in isolated MSSQL acceptance. Supply -Run -Server "(localdb)\mySQLPunkExportTest_20261012" -Profile OptionalSnapshot|SnapshotLifecycle|Core|Large|Concurrency|Geometry|Properties|References -OutputDirectory .' Write-Host 'Uses Integrated Security; creates only token-owned GUID source/target databases; requires an already running authorized fixture and sqlcmd. Never reads saved connections or installs/starts a server.' return } @@ -59,12 +59,13 @@ try { $architecture = switch ($machine) { 0x8664 { 'x64' } 0x014c { 'x86' } 0xAA64 { 'ARM64' } default { "PE-machine-$machine" } } @{ path=$sqlcmd; architecture=$architecture; fileVersion=(Get-Item -LiteralPath $sqlcmd).VersionInfo.FileVersion } | ConvertTo-Json | Out-File (Join-Path $output 'sqlcmd-identity.json') -Encoding utf8 } finally { $peReader.Dispose() } -$envNames = @('MYSQLPUNK_SMOKE_FILTER','MYSQLPUNK_MSSQL_INTEGRATION','MYSQLPUNK_MSSQL_INTEGRATION_SERVER','MYSQLPUNK_MSSQL_INTEGRATION_OUTPUT','MYSQLPUNK_MSSQL_INTEGRATION_FIXTURES','MYSQLPUNK_MSSQL_INTEGRATION_FACTOR','MYSQLPUNK_MSSQL_INTEGRATION_SQLCMD','MYSQLPUNK_MSSQL_INTEGRATION_CLIENT','MYSQLPUNK_MSSQL_INTEGRATION_RESTORE','MYSQLPUNK_MSSQL_INTEGRATION_PIPE') +$envNames = @('MYSQLPUNK_MSSQL_INTEGRATION_AUTH','MYSQLPUNK_SMOKE_FILTER','MYSQLPUNK_MSSQL_INTEGRATION','MYSQLPUNK_MSSQL_INTEGRATION_SERVER','MYSQLPUNK_MSSQL_INTEGRATION_OUTPUT','MYSQLPUNK_MSSQL_INTEGRATION_FIXTURES','MYSQLPUNK_MSSQL_INTEGRATION_FACTOR','MYSQLPUNK_MSSQL_INTEGRATION_SQLCMD','MYSQLPUNK_MSSQL_INTEGRATION_CLIENT','MYSQLPUNK_MSSQL_INTEGRATION_RESTORE','MYSQLPUNK_MSSQL_INTEGRATION_PIPE') $saved = @{} foreach ($name in $envNames) { $saved[$name] = [Environment]::GetEnvironmentVariable($name) } try { $env:MYSQLPUNK_SMOKE_FILTER = 'MSSQL export integration opt-in' $env:MYSQLPUNK_MSSQL_INTEGRATION = $Profile + $env:MYSQLPUNK_MSSQL_INTEGRATION_AUTH = [IO.Path]::GetFullPath("$PSScriptRoot/../.superpowers/sdd/implementation-plan/fixture-environment.json") $env:MYSQLPUNK_MSSQL_INTEGRATION_SERVER = $Server $env:MYSQLPUNK_MSSQL_INTEGRATION_FIXTURES = Join-Path $PSScriptRoot 'MssqlExportFixtures' $env:MYSQLPUNK_MSSQL_INTEGRATION_SQLCMD = $sqlcmd diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index 7a4d4a3..5eea199 100644 --- a/tests/Run-SmokeTests.ps1 +++ b/tests/Run-SmokeTests.ps1 @@ -143,7 +143,7 @@ if (!(Test-Path -LiteralPath $mysqlConnector)) { /r:System.Core.dll ` /r:System.IO.Compression.dll ` /r:System.IO.Compression.FileSystem.dll ` - $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Lob.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Worker.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Wizard.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Routing.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Integration.cs") + $source $preferencesSource $schemaSyncSource $erModelSource (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Snapshot.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Schema.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.ViewReferences.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Properties.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Artifact.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Values.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Lob.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Worker.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Wizard.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Routing.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.Integration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.SnapshotIntegration.cs") (Join-Path $PSScriptRoot "SmokeTests.MssqlExport.CompatibilityIntegration.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.Artifact.cs b/tests/SmokeTests.MssqlExport.Artifact.cs index de8296d..297e79a 100644 --- a/tests/SmokeTests.MssqlExport.Artifact.cs +++ b/tests/SmokeTests.MssqlExport.Artifact.cs @@ -84,7 +84,7 @@ private static void Artifact_Pipeline() } private static MssqlExportRequest ArtifactRequest(string path, ExportDestinationStamp stamp) { - return new MssqlExportRequest(Guid.NewGuid(), "fixture", new[] {new MssqlObjectKey(1,"dbo","T")}, MssqlExportMode.SchemaAndData, false, path, stamp, null); + return new MssqlExportRequest(Guid.NewGuid(), "fixture", new[] {new MssqlObjectKey(1,"dbo","T")}, MssqlExportMode.SchemaAndData, false, path, stamp, null, true); } private static MssqlArtifactReceipt FinishArtifact(MssqlExportArtifact artifact) { diff --git a/tests/SmokeTests.MssqlExport.CompatibilityIntegration.cs b/tests/SmokeTests.MssqlExport.CompatibilityIntegration.cs new file mode 100644 index 0000000..ca80160 --- /dev/null +++ b/tests/SmokeTests.MssqlExport.CompatibilityIntegration.cs @@ -0,0 +1,265 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + private const string CompatibilityPropertiesSql = @"SELECT SCHEMA_NAME(o.schema_id) owner_schema,o.name owner_name,o.type owner_type, +CASE WHEN e.minor_id=0 THEN 'OBJECT' ELSE 'COLUMN' END property_scope,c.name column_name,e.name property_name, +CONVERT(nvarchar(128),SQL_VARIANT_PROPERTY(e.value,'BaseType')) base_type, +CONVERT(int,SQL_VARIANT_PROPERTY(e.value,'MaxLength')) max_length, +CONVERT(int,SQL_VARIANT_PROPERTY(e.value,'Precision')) precision_value, +CONVERT(int,SQL_VARIANT_PROPERTY(e.value,'Scale')) scale_value, +CONVERT(nvarchar(128),SQL_VARIANT_PROPERTY(e.value,'Collation')) collation_name, +CASE WHEN e.value IS NULL THEN 1 ELSE 0 END is_null, +CONVERT(varchar(max),CONVERT(varbinary(8000),e.value),2) native_hex +FROM sys.extended_properties e JOIN sys.objects o ON o.object_id=e.major_id +LEFT JOIN sys.columns c ON c.object_id=e.major_id AND c.column_id=e.minor_id +WHERE e.class=1 AND o.is_ms_shipped=0"; + + private static void IntegrationProperties(IntegrationFixture f) + { + Assert(Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_CLIENT") == "Classic", "Properties requires documented classic sqlcmd"); + f.Setup("compatibility.sql"); + using (var c = f.Open(f.Source)) + { + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT DATALENGTH(value) FROM sys.extended_properties WHERE class=1 AND major_id=OBJECT_ID(N'dbo.PropertyView') AND name=N'MS_DiagramPane1'")) == 7006, "Production-sized 7006-byte SSMS diagram fixture"); + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT DATALENGTH(value) FROM sys.extended_properties WHERE class=1 AND major_id=OBJECT_ID(N'dbo.LongView') AND name=N'MS_DiagramPane1'")) == 7400, "Near-limit 7400-byte diagram fixture"); + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT column_id FROM sys.columns WHERE object_id=OBJECT_ID(N'[報表].[Property]') AND name=N'note欄'")) == 3, "Source column property follows a dropped-column gap"); + f.Evidence["property-source-minor-ids"] = IntegrationCatalog(c, "SELECT major_id,minor_id,name FROM sys.extended_properties WHERE class=1"); + } + var all = f.Request("properties-all.sql"); + var check = f.Service().Preflight(all, CancellationToken.None); + Assert(check.CanStart, "Supported actual metadata passes: " + string.Join(";", check.Issues.Select(x => x.Code + ":" + x.Message))); + var result = f.Export(all); + Assert(result.Stage == MssqlExportStage.Completed, "Properties schema/data service artifact: " + result.ErrorMessage); + f.RecordArtifact(result.PublishedPath); f.Restore(result.PublishedPath); + CompatibilityCompareProperties(f, "schema-data"); + using (var c = f.Open(f.Target)) + { + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT COUNT(*) FROM [報表].[Property] WHERE [note欄]=N'exported'")) == 1, "Properties data restored"); + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT column_id FROM sys.columns WHERE object_id=OBJECT_ID(N'[報表].[Property]') AND name=N'note欄'")) == 2, "Restored properties resolve by column name after renumbering"); + f.Evidence["property-target-minor-ids"] = IntegrationCatalog(c, "SELECT major_id,minor_id,name FROM sys.extended_properties WHERE class=1"); + IntegrationFixture.Exec(c, "DROP VIEW dbo.LongView; DROP VIEW dbo.PropertyView; DROP TABLE [報表].[Property];"); + } + var schema = f.Request("properties-schema.sql", true, MssqlExportMode.SchemaOnly); + result = f.Export(schema); + Assert(result.Stage == MssqlExportStage.Completed, "Property schema-only export: " + result.ErrorMessage); + f.RecordArtifact(result.PublishedPath); f.Restore(result.PublishedPath); + CompatibilityCompareProperties(f, "schema-only"); + string beforeData; + using (var c = f.Open(f.Target)) + { + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT COUNT(*) FROM [報表].[Property]")) == 0, "Schema-only does not insert rows"); + IntegrationFixture.Exec(c, "EXEC sys.sp_addextendedproperty @name=N'target-only',@value=N'keep',@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property';"); + beforeData = IntegrationCatalog(c, CompatibilityPropertiesSql); + } + var data = f.Request("properties-data.sql", false, MssqlExportMode.DataOnly); + result = f.Export(data); + Assert(result.Stage == MssqlExportStage.Completed, "Property data-only export: " + result.ErrorMessage); + AssertNotContains(File.ReadAllText(result.PublishedPath), "sp_addextendedproperty", "DataOnly emits no duplicate properties"); + f.RecordArtifact(result.PublishedPath); f.Restore(result.PublishedPath); + using (var c = f.Open(f.Target)) + { + Assert(IntegrationCatalog(c, CompatibilityPropertiesSql) == beforeData, "DataOnly preserves every preexisting target property"); + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT COUNT(*) FROM [報表].[Property]")) == 1, "DataOnly restored one row after schema-only"); + } + f.Pass("Typed object/column/view properties preserve scope type length precision scale collation hex and NULL through all three modes"); + CompatibilityPropertyDrift(f); + CompatibilityPropertyGates(f); + CompatibilityPermissions(f); + } + + private static void CompatibilityCompareProperties(IntegrationFixture f, string phase) + { + using (var a = f.Open(f.Source)) using (var b = f.Open(f.Target)) + { + string source = IntegrationCatalog(a, CompatibilityPropertiesSql), target = IntegrationCatalog(b, CompatibilityPropertiesSql); + File.WriteAllText(Path.Combine(f.Root, "properties-" + phase + "-source.jsonl"), source); + File.WriteAllText(Path.Combine(f.Root, "properties-" + phase + "-target.jsonl"), target); + Assert(source == target, "Native property catalog equality " + phase + "; compare semantic column scope, not recreated column IDs"); + } + } + + private static string CompatibilityUpdateProperty(string text, string collation) + { + return "DECLARE @v sql_variant; SET @v=CONVERT(nvarchar(30)," + IntegrationFixture.Lit(text) + ") COLLATE " + collation + "; EXEC sys.sp_updateextendedproperty @name=N'drift',@value=@v,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property';"; + } + + private static void CompatibilityPropertyDrift(IntegrationFixture f) + { + var request = f.Request("property-fingerprint.sql", true, MssqlExportMode.SchemaOnly); + string original = f.Service().Preflight(request, CancellationToken.None).Schema.Fingerprint; + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, CompatibilityUpdateProperty("drift-changed", "Chinese_Taiwan_Stroke_CI_AS")); + string valueChanged = f.Service().Preflight(request, CancellationToken.None).Schema.Fingerprint; + Assert(original != valueChanged, "Actual property value participates in source fingerprint"); + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, CompatibilityUpdateProperty("drift-changed", "Latin1_General_100_BIN2")); + string collationChanged = f.Service().Preflight(request, CancellationToken.None).Schema.Fingerprint; + Assert(valueChanged != collationChanged, "Actual property collation participates in source fingerprint"); + foreach (string kind in new[] { "value", "collation" }) + { + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, CompatibilityUpdateProperty("drift-original", "Chinese_Taiwan_Stroke_CI_AS")); + string path = Path.Combine(f.Root, "property-drift-" + kind + ".sql"); + File.WriteAllText(path, "original"); + request = f.Request(Path.GetFileName(path), true, MssqlExportMode.SchemaOnly); + bool changed = false; + f.AfterBegin = () => { using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, CompatibilityUpdateProperty(kind == "value" ? "drift-changed" : "drift-original", kind == "value" ? "Chinese_Taiwan_Stroke_CI_AS" : "Latin1_General_100_BIN2")); changed = true; }; + try + { + var result = f.Export(request); + Assert(changed && result.Stage == MssqlExportStage.Failed && result.PublishedPath == null && File.ReadAllText(path) == "original", "Live " + kind + " drift rejects publication and preserves original"); + Assert(!Directory.GetFiles(f.Root, "*.partial", SearchOption.AllDirectories).Any(), "Drift partial artifacts cleaned"); + } + finally { f.AfterBegin = null; } + } + f.Pass("Actual value/collation drift changes fingerprint and rejects frozen-schema publication"); + } + + private static void CompatibilityPropertyGates(IntegrationFixture f) + { + foreach (string type in new[] { "varchar(20)", "decimal(8,2)" }) + { + string value = type.StartsWith("varchar", StringComparison.Ordinal) ? "'unsupported'" : "12.34"; + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "DECLARE @v " + type + "=" + value + "; EXEC sys.sp_addextendedproperty @name=N'unsupported',@value=@v,@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property';"); + try { CompatibilityExpectBlocked(f, "property-unsupported-" + type.Split('(')[0], x => x.Code.IndexOf("Property", StringComparison.OrdinalIgnoreCase) >= 0); } + finally { using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "EXEC sys.sp_dropextendedproperty @name=N'unsupported',@level0type=N'SCHEMA',@level0name=N'報表',@level1type=N'TABLE',@level1name=N'Property';"); } + } + using (var c = f.Open(f.Source)) + { + IntegrationFixture.Exec(c, "SET ANSI_NULLS ON; SET QUOTED_IDENTIFIER ON; SET ANSI_PADDING ON; SET ANSI_WARNINGS ON; SET CONCAT_NULL_YIELDS_NULL ON; SET ARITHABORT ON; SET NUMERIC_ROUNDABORT OFF;"); + IntegrationFixture.Exec(c, "CREATE VIEW dbo.IndexedProperty WITH SCHEMABINDING AS SELECT id FROM [報表].[Property];"); + IntegrationFixture.Exec(c, "CREATE UNIQUE CLUSTERED INDEX IX_IndexedProperty ON dbo.IndexedProperty(id);"); + } + try { CompatibilityExpectBlocked(f, "indexed-view", x => x.Code == "UnsupportedViewFeature"); } + finally { using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "DROP VIEW dbo.IndexedProperty;"); } + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "CREATE TRIGGER dbo.PropertyTrigger ON dbo.PropertyView INSTEAD OF INSERT AS BEGIN SET NOCOUNT ON; END;"); + try { CompatibilityExpectBlocked(f, "view-trigger", x => x.Code == "UnsupportedViewFeature"); } + finally { using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "DROP TRIGGER dbo.PropertyTrigger;"); } + f.Pass("Unsupported varchar/decimal properties indexed views and view triggers still fail before artifact publication"); + } + + private static void CompatibilityExpectBlocked(IntegrationFixture f, string name, Func expected, Func select = null) + { + string path = Path.Combine(f.Root, name + ".sql"); + File.WriteAllText(path, "original"); + var request = f.Request(Path.GetFileName(path), true, MssqlExportMode.SchemaOnly, select); + var check = f.Service().Preflight(request, CancellationToken.None); + f.Evidence["blocked-" + name] = check.Issues.Select(x => x.Code + ":" + x.Message).ToArray(); + Assert(!check.CanStart && check.Issues.Any(expected), "Expected preflight refusal " + name); + var result = f.Export(request); + Assert(result.Stage == MssqlExportStage.Failed && result.PublishedPath == null && File.ReadAllText(path) == "original", "Rejected " + name + " preserves original and publishes nothing"); + Assert(!Directory.GetFiles(f.Root, "*.partial", SearchOption.AllDirectories).Any(), "Rejected " + name + " leaves no partial"); + } + + private static void CompatibilityPermissions(IntegrationFixture f) + { + var data = f.Request("permission-data.sql", false, useSnapshot: false); + var schema = f.Request("permission-schema.sql", false, MssqlExportMode.SchemaOnly); + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "CREATE USER fixture_reader WITHOUT LOGIN; GRANT CONNECT TO fixture_reader; GRANT SELECT TO fixture_reader; DENY VIEW DEFINITION TO fixture_reader;"); + f.RestrictedReader = true; + try + { + try { f.Service().LoadCatalog(f.Source, CancellationToken.None); Assert(false, "Missing VIEW DEFINITION must fail"); } + catch (MssqlExportPrerequisiteException ex) { Assert(ex.Reason == MssqlPrerequisite.ViewDefinitionRequired, "Live missing VIEW DEFINITION keeps typed diagnostic"); } + var failed = f.Export(schema); + Assert(failed.Stage == MssqlExportStage.Failed && failed.PublishedPath == null && !File.Exists(schema.TargetPath), "Live missing definitions cannot publish schema-only artifact"); + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "REVOKE VIEW DEFINITION FROM fixture_reader; GRANT VIEW DEFINITION TO fixture_reader; GRANT SELECT ON OBJECT::sys.sql_expression_dependencies TO fixture_reader;"); + using (var c = f.Open(f.Source)) + { + IntegrationFixture.Exec(c, "EXECUTE AS USER=N'fixture_reader';"); + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT HAS_PERMS_BY_NAME(DB_NAME(),'DATABASE','ALTER')")) == 0, "Fixture reader has no database ALTER permission"); + } + Assert(f.Service().Preflight(data, CancellationToken.None).CanStart, "Live ordinary reader with definitions and SELECT can export"); + var selected = f.Request("permission-snapshot.sql", false, useSnapshot: true); + Assert(!f.Service().Preflight(selected, CancellationToken.None).CanStart, "Selected Snapshot without ALTER is refused even ON"); + var dataResult = f.Export(data); + Assert(dataResult.Stage == MssqlExportStage.Completed, "Actual ordinary data export does not require ALTER: " + dataResult.ErrorMessage); + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "DENY SELECT ON OBJECT::[報表].[Property] TO fixture_reader;"); + Assert(f.Service().Preflight(data, CancellationToken.None).Issues.Any(x => x.Code == "UnreadableTable"), "Live SELECT denial blocks data mode"); + Assert(f.Service().Preflight(schema, CancellationToken.None).CanStart, "Definitions without table SELECT permit schema-only"); + var schemaResult = f.Export(schema); + Assert(schemaResult.Stage == MssqlExportStage.Completed, "Actual low-privilege schema-only artifact: " + schemaResult.ErrorMessage); + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "DENY SELECT ON OBJECT::sys.sql_expression_dependencies TO fixture_reader;"); + try { f.Service().Preflight(schema, CancellationToken.None); Assert(false, "Denied dependency catalog must fail"); } + catch (MssqlExportPrerequisiteException ex) { Assert(ex.Reason == MssqlPrerequisite.SqlPermissionDenied, "Live dependency catalog denial keeps safe permission category"); } + } + finally { f.RestrictedReader = false; } + f.Pass("Owned WITHOUT LOGIN user distinguishes definition SELECT and dependency-catalog permissions without production or server grants"); + } + + private static void IntegrationReferences(IntegrationFixture f) + { + Assert(Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_CLIENT") == "Classic", "References requires documented classic sqlcmd"); + string db = IntegrationFixture.Q(f.Source), literal = f.Source + ".dbo.RefTable stays literal", comment = "/* " + f.Source + ".dbo.RefTable stays comment */"; + using (var c = f.Open(f.Source)) + { + IntegrationFixture.Exec(c, "SET ANSI_NULLS ON; SET QUOTED_IDENTIFIER ON;"); + IntegrationFixture.Exec(c, "CREATE TABLE dbo.RefTable(id int NOT NULL PRIMARY KEY,payload nvarchar(40) NOT NULL); INSERT dbo.RefTable VALUES(1,N'exported'); CREATE TABLE dbo.Unselected(id int);"); + // Give the dependent view the lower object_id so restore must honor dependencies. + IntegrationFixture.Exec(c, "CREATE VIEW [dbo].[Ref Nested] AS SELECT 1 AS id,CONVERT(nvarchar(40),N'placeholder') AS payload;"); + IntegrationFixture.Exec(c, "CREATE VIEW dbo.RefBase AS SELECT t.id,t.payload," + IntegrationFixture.Lit(literal) + " AS marker FROM " + db + " /* qualifier retained */ . [dbo] . [RefTable] AS t " + comment + ";"); + IntegrationFixture.Exec(c, "ALTER VIEW [dbo].[Ref Nested] AS WITH cte AS (SELECT a.id,a.payload FROM " + db + ".[dbo].[RefBase] AS a) SELECT cte.id,cte.payload FROM cte JOIN (SELECT z.id FROM \"" + f.Source + "\".\"dbo\".\"RefTable\" AS z) AS b ON b.id=cte.id;"); + } + var request = f.Request("references.sql", true, MssqlExportMode.SchemaAndData, t => t.Key.Name == "RefTable"); + var check = f.Service().Preflight(request, CancellationToken.None); + Assert(check.CanStart, "Same-database references pass: " + string.Join(";", check.Issues.Select(x => x.Code + ":" + x.Message))); + var result = f.Export(request); + Assert(result.Stage == MssqlExportStage.Completed, "Reference service artifact: " + result.ErrorMessage); + string script = File.ReadAllText(result.PublishedPath); + AssertContains(script, literal, "Database-like string unchanged"); + AssertContains(script, comment, "Database-like comment unchanged"); + AssertContains(script, "/* qualifier retained */", "Inter-token comment unchanged"); + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "UPDATE dbo.RefTable SET payload=N'source_only';"); + f.RecordArtifact(result.PublishedPath); f.Restore(result.PublishedPath); + using (var c = f.Open(f.Target)) + { + foreach (string view in new[] { "dbo.RefBase", "dbo.[Ref Nested]" }) Assert(Convert.ToString(IntegrationFixture.Scalar(c, "SELECT payload FROM " + view)) == "exported", "Restored view reads target exported row: " + view); + Assert(Convert.ToString(IntegrationFixture.Scalar(c, "SELECT marker FROM dbo.RefBase")) == literal, "Restored view literal unchanged"); + Assert(Convert.ToInt32(IntegrationFixture.Scalar(c, "SELECT COUNT(*) FROM sys.sql_expression_dependencies d JOIN sys.views v ON v.object_id=d.referencing_id WHERE d.referenced_server_name IS NOT NULL OR d.referenced_database_name IS NOT NULL OR d.referenced_id IS NULL")) == 0, "All target view dependencies are local resolved IDs"); + IntegrationFixture.Exec(c, "UPDATE dbo.RefTable SET payload=N'target_only';"); + foreach (string view in new[] { "dbo.RefBase", "dbo.[Ref Nested]" }) Assert(Convert.ToString(IntegrationFixture.Scalar(c, "SELECT payload FROM " + view)) == "target_only", "Target updates flow through both view levels: " + view); + } + using (var c = f.Open(f.Source)) Assert(Convert.ToString(IntegrationFixture.Scalar(c, "SELECT payload FROM dbo.RefBase")) == "source_only", "Retained source remains independently changed"); + f.Pass("Same-database quoted CTE nested/join/view references restore into a different database with target-local semantics"); + CompatibilityReferenceGates(f); + } + + private static void CompatibilityReferenceGates(IntegrationFixture f) + { + string db = IntegrationFixture.Q(f.Source), target = IntegrationFixture.Q(f.Target); + var definitions = new[] { + new { Name="cross-database", Sql="SELECT t.id FROM " + target + ".dbo.RefTable t" }, + new { Name="missing-schema", Sql="SELECT t.id FROM " + db + "..RefTable t" }, + new { Name="unselected", Sql="SELECT t.id FROM " + db + ".dbo.Unselected t" }, + new { Name="qualified-column", Sql="SELECT " + db + ".dbo.RefTable.id FROM " + db + ".dbo.RefTable" } + }; + foreach (var item in definitions) + { + using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "CREATE VIEW dbo.RejectReference AS " + item.Sql + ";"); + try { CompatibilityExpectBlocked(f, "reference-" + item.Name, x => x.Code == "MissingDependency" || x.Code == "UnsupportedViewReference", t => t.Key.Name == "RefTable"); } + finally { using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "DROP VIEW dbo.RejectReference;"); } + } + // Four-part named reference to this existing local server: no linked server is created. + string server; + using (var c = f.Open(f.Source)) server = Convert.ToString(IntegrationFixture.Scalar(c, "SELECT CONVERT(nvarchar(128),SERVERPROPERTY('ServerName'))"), CultureInfo.InvariantCulture); + bool created = false; + try + { + using (var c = f.Open(f.Source)) + { + try { IntegrationFixture.Exec(c, "CREATE VIEW dbo.RejectServer AS SELECT t.id FROM " + IntegrationFixture.Q(server) + "." + db + ".dbo.RefTable t;"); created = true; } + catch (SqlException ex) { f.Evidence["server-reference-fixture-not-creatable"] = ex.Number; } + } + if (created) CompatibilityExpectBlocked(f, "reference-server", x => x.Code == "MissingDependency" || x.Code == "UnsupportedViewReference", t => t.Key.Name == "RefTable"); + } + finally { if (created) using (var c = f.Open(f.Source)) IntegrationFixture.Exec(c, "DROP VIEW dbo.RejectServer;"); } + f.Pass("Cross-database omitted-schema unselected-object and four-part-column references remain blocked"); + if (created) f.Pass("Existing local server four-part reference remains blocked without configuring linked servers"); + else { Console.WriteLine("SKIP server-reference live fixture: local provider rejected its creation; parser fixture remains separate evidence"); f.Save(); } + } +} diff --git a/tests/SmokeTests.MssqlExport.Integration.cs b/tests/SmokeTests.MssqlExport.Integration.cs index ce8d1e5..86f9d14 100644 --- a/tests/SmokeTests.MssqlExport.Integration.cs +++ b/tests/SmokeTests.MssqlExport.Integration.cs @@ -21,18 +21,103 @@ private static void Integration_Run() { string profile = Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION"); if (string.IsNullOrEmpty(profile)) { Console.WriteLine("SKIP live integration (explicit runner -Run required)"); return; } - Assert(new[] { "Core", "Large", "Concurrency", "ClientProbe", "FinalFix", "Geometry" }.Contains(profile), "Known integration profile"); - using (var fixture = new IntegrationFixture(profile)) + Assert(new[] { "OptionalSnapshot", "SnapshotLifecycle", "Core", "Large", "Concurrency", "ClientProbe", "FinalFix", "Geometry", "Properties", "References" }.Contains(profile), "Known integration profile"); + using (var fixture = new IntegrationFixture(profile, profile != "OptionalSnapshot" && profile != "SnapshotLifecycle")) { - if (profile == "ClientProbe") IntegrationClientProbe(fixture); + if (profile == "OptionalSnapshot") IntegrationOptionalSnapshot(fixture); + else if (profile == "SnapshotLifecycle") IntegrationSnapshotLifecycle(fixture); + else if (profile == "ClientProbe") IntegrationClientProbe(fixture); else if (profile == "FinalFix") IntegrationFinalFix(fixture); else if (profile == "Geometry") IntegrationGeometry(fixture); + else if (profile == "Properties") IntegrationProperties(fixture); + else if (profile == "References") IntegrationReferences(fixture); else if (profile == "Core") IntegrationCore(fixture); else if (profile == "Large") IntegrationLarge(fixture); else IntegrationConcurrency(fixture); } } + private sealed class RestoreProcessReceipt + { + public bool TimedOut, TerminationConfirmed, OutputComplete, TerminationFailed; + public int? ExitCode; + public string Stdout, Stderr; + public long StdoutCharacters, StderrCharacters; + } + private sealed class RestoreOutput + { + internal const int Limit = 65536; + private readonly StringBuilder text = new StringBuilder(); + internal long Characters; + internal async System.Threading.Tasks.Task Drain(StreamReader reader) + { + var buffer = new char[4096]; int count; + while ((count = await reader.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) != 0) { + lock (text) { Characters += count; text.Append(buffer, 0, count); if (text.Length > Limit) text.Remove(0, text.Length - Limit); } + } + } + internal string Capture() { lock (text) return text.ToString(); } + } + // This helper owns only the Process.Start return value. It cannot enumerate or kill other processes. + private static int RunOwnedRestore(ProcessStartInfo start, int timeoutMilliseconds, Action save) + { + using (var process = Process.Start(start)) { + var output = new RestoreOutput(); var errors = new RestoreOutput(); + var stdout = output.Drain(process.StandardOutput); var stderr = errors.Drain(process.StandardError); + bool timedOut = !process.WaitForExit(timeoutMilliseconds), terminationFailed = false; + if (timedOut) { + try { process.Kill(); } + catch (InvalidOperationException) { /* The owned child may have exited at the deadline. */ } + catch (System.ComponentModel.Win32Exception) { terminationFailed = true; } + } + bool reaped = process.WaitForExit(10000); + bool complete = false; + try { complete = System.Threading.Tasks.Task.WaitAll(new[] { stdout, stderr }, 10000); } + catch (AggregateException) { /* Available bounded output and incomplete receipt still survive. */ } + var receipt = new RestoreProcessReceipt { + TimedOut = timedOut, TerminationConfirmed = reaped, TerminationFailed = terminationFailed, + ExitCode = reaped ? (int?)process.ExitCode : null, OutputComplete = complete, + Stdout = output.Capture(), Stderr = errors.Capture(), StdoutCharacters = output.Characters, StderrCharacters = errors.Characters + }; + save(receipt); // Persist output and actual exit/termination truth BEFORE throwing. + if (timedOut) throw new TimeoutException("Owned sqlcmd restore exceeded profile deadline"); + if (!reaped || !complete) throw new IOException("Owned sqlcmd output capture incomplete"); + return receipt.ExitCode.Value; + } + } + private static int RestoreProcessChild(string mode) + { + Console.WriteLine("owned stdout begin"); Console.Error.WriteLine("owned stderr begin"); + if (mode == "--owned-restore-fixture-timeout") { + Console.Write(new string('o', RestoreOutput.Limit + 4096)); Console.WriteLine("owned stdout tail"); + Console.Error.Write(new string('e', RestoreOutput.Limit + 4096)); Console.Error.WriteLine("owned stderr tail"); + Console.Out.Flush(); Console.Error.Flush(); Thread.Sleep(Timeout.Infinite); + } + return mode == "--owned-restore-fixture-failure" ? 7 : 0; + } + private static void RestoreProcess_TimeoutEvidence() + { + string root = Path.Combine(Path.GetTempPath(), "owned-restore-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); + try { + foreach (string mode in new[] { "success", "failure", "timeout" }) { + RestoreProcessReceipt receipt = null; bool timeout = false; + var start = new ProcessStartInfo(System.Windows.Forms.Application.ExecutablePath, "--owned-restore-fixture-" + mode) { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; + try { + int exit = RunOwnedRestore(start, mode == "timeout" ? 2000 : 10000, r => { + receipt = r; File.WriteAllText(Path.Combine(root, mode + ".json"), JsonConvert.SerializeObject(r)); + }); + Assert(exit == (mode == "failure" ? 7 : 0), "Non-timeout child preserves exact exit truth"); + } catch (TimeoutException) { timeout = true; } + Assert(receipt != null && receipt.TerminationConfirmed && receipt.OutputComplete && File.Exists(Path.Combine(root, mode + ".json")), "Receipt persists after owned child reap"); + Assert(timeout == (mode == "timeout") && receipt.TimedOut == timeout, "Timeout still fails after evidence is saved"); + Assert(receipt.Stdout.Length <= RestoreOutput.Limit && receipt.Stderr.Length <= RestoreOutput.Limit, "Available output is bounded independently"); + if (timeout) { + Assert(receipt.ExitCode.HasValue && receipt.ExitCode.Value != 0 && !receipt.TerminationFailed, "Owned terminated process cannot be reported as successful restore"); + Assert(receipt.StdoutCharacters > RestoreOutput.Limit && receipt.StderrCharacters > RestoreOutput.Limit && receipt.Stdout.Contains("owned stdout tail") && receipt.Stderr.Contains("owned stderr tail"), "Bounded tail and original character counts survive timeout"); + } + } + } finally { Directory.Delete(root, true); } + } private sealed class IntegrationFixture : IDisposable { internal readonly string Server, Root, Token, Source, Target, Profile; @@ -42,8 +127,13 @@ private sealed class IntegrationFixture : IDisposable internal Action AfterBegin; internal Action BeforeRows; internal bool RestrictedReader; + internal bool ParentFirst; + internal string SourceOverride, JournalOverride; + internal Action BeforeSetting, AfterSetting; + internal readonly List SettingAudit = new List(); + internal readonly List LockAudit = new List(); private int restoreSequence; - internal IntegrationFixture(string profile) + internal IntegrationFixture(string profile, bool initialSnapshot = true) { Profile = profile; Server = Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_SERVER"); @@ -69,7 +159,9 @@ internal IntegrationFixture(string profile) Exec(c,"USE " + Q(db) + "; EXEC sys.sp_addextendedproperty @name=N'mysqlpunk_export_owner',@value=" + Lit(Token) + ";"); Exec(c,"USE master; ALTER DATABASE " + Q(db) + " SET RECOVERY SIMPLE;"); } - Exec(c,"ALTER DATABASE " + Q(Source) + " SET ALLOW_SNAPSHOT_ISOLATION ON;"); + if (initialSnapshot) Exec(c,"ALTER DATABASE " + Q(Source) + " SET ALLOW_SNAPSHOT_ISOLATION ON;"); + Evidence["initialSnapshotRequested"] = initialSnapshot; + Evidence["initialSnapshotObserved"] = Scalar(c,"SELECT snapshot_isolation_state FROM sys.databases WHERE name=" + Lit(Source)); } Evidence["sourceCollation"] = "Latin1_General_100_BIN2"; Save(); @@ -97,11 +189,13 @@ internal void Setup(string name, int factor = 1) } internal IMssqlExportSource NewSource() { + Assert(SourceOverride == null || SourceOverride == Source || SourceOverride == Target, "Source factory uses exact owned database whitelist"); + Assert(JournalOverride == null || Path.GetFullPath(JournalOverride).StartsWith(Root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase), "Injected journal remains inside owned fixture root"); var provider = new my_mssql(); - provider.SetConn(new SqlConnectionStringBuilder { DataSource = Server, InitialCatalog = Source, IntegratedSecurity = true, Pooling = false, TrustServerCertificate = true }.ConnectionString); + provider.SetConn(new SqlConnectionStringBuilder { DataSource = Server, InitialCatalog = SourceOverride ?? Source, IntegratedSecurity = true, Pooling = false, TrustServerCertificate = true }.ConnectionString); provider.Open(); WorkerConnections.Add(provider.MCT); if (RestrictedReader) Exec(provider.MCT,"EXECUTE AS USER = N'fixture_reader';"); - return new IntegrationSource(new MssqlExportSource(new MssqlOwnedSession(provider,provider.MCT)),this); + return new IntegrationSource(new MssqlExportSource(new MssqlOwnedSession(provider,provider.MCT), new MssqlSnapshotRecoveryStore(JournalOverride ?? Path.Combine(Root,"journal")), c => new IntegrationSnapshotBackend(c,this)),this); } internal MssqlSqlExportService Service(MssqlArtifactFiles files = null, Action phase = null) { @@ -109,40 +203,42 @@ internal MssqlSqlExportService Service(MssqlArtifactFiles files = null, Action c.State == ConnectionState.Closed), "Real source connections released before file " + stage); Evidence["sourceReleasedBeforeLocalPhases"] = true; if (phase != null) phase(stage); - }); + }, new MssqlSnapshotRecoveryStore(Path.Combine(Root,"journal"))); } - internal MssqlExportRequest Request(string filename = "export.sql", bool views = true, MssqlExportMode mode = MssqlExportMode.SchemaAndData, Func select = null, bool mirror = false) + internal MssqlExportRequest Request(string filename = "export.sql", bool views = true, MssqlExportMode mode = MssqlExportMode.SchemaAndData, Func select = null, bool mirror = false, bool? useSnapshot = null) { var tables = Service().LoadCatalog(Source,CancellationToken.None).Tables.Where(t => select == null || select(t)).Select(t => t.Key).ToArray(); string path = Path.Combine(Root,filename); - return new MssqlExportRequest(Guid.NewGuid(),Source,tables,mode,views,path,MssqlExportArtifact.CaptureDestination(path,CancellationToken.None),mirror ? new MssqlMirrorOptions(Path.Combine(Root,"mirror"),2) : null); + return new MssqlExportRequest(Guid.NewGuid(),Source,tables,mode,views,path,MssqlExportArtifact.CaptureDestination(path,CancellationToken.None),mirror ? new MssqlMirrorOptions(Path.Combine(Root,"mirror"),2) : null, useSnapshot ?? (mode != MssqlExportMode.SchemaOnly)); } internal MssqlExportResult Export(MssqlExportRequest request, CancellationToken cancellation = default(CancellationToken), MssqlArtifactFiles files = null, Action phase = null, Action progress = null) { using (var memory = new IntegrationMemory()) { var result = Service(files,phase).Export(request,new WorkerProgress(p => { memory.Stage = p.Stage.ToString(); if (progress != null) progress(p); }),cancellation); - Evidence["export-" + Path.GetFileName(request.TargetPath)] = new { stage=result.Stage.ToString(), result.Elapsed, result.ErrorMessage, result.PublishedPath, result.MirrorPath, rows = result.RowCounts.Sum(x => x.Rows), memory = memory.Snapshot() }; + Evidence["export-" + Path.GetFileName(request.TargetPath)] = new { stage=result.Stage.ToString(), result.Elapsed, result.ErrorMessage, result.PublishedPath, result.MirrorPath, request.UseSnapshot, cleanup = result.SnapshotCleanup, snapshotState = SnapshotState(this), journal = Directory.Exists(Path.Combine(Root,"journal")) ? Directory.GetFiles(Path.Combine(Root,"journal")) : new string[0], settingAudit = SettingAudit.ToArray(), lockAudit = LockAudit.ToArray(), rows = result.RowCounts.Sum(x => x.Rows), memory = memory.Snapshot() }; Save(); return result; } } internal void Restore(string file, bool expectSuccess = true) { Assert(File.Exists(file),"Restore artifact exists"); + RecordArtifact(file); bool go = Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_CLIENT") == "Go"; string endpoint = go ? "np:"+Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_PIPE") : Server; string args = "-S \"" + endpoint + "\" -d " + Target + " -E -b -x " + (go ? "" : "-f 65001 ") + "-i \"" + file + "\""; var start = new ProcessStartInfo(Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_SQLCMD"),args) { UseShellExecute=false,CreateNoWindow=true,RedirectStandardOutput=true,RedirectStandardError=true }; var elapsed = Stopwatch.StartNew(); - using (var p = Process.Start(start)) - { - var stdout = p.StandardOutput.ReadToEndAsync(); var stderr = p.StandardError.ReadToEndAsync(); p.WaitForExit(); - int attempt = ++restoreSequence; - File.WriteAllText(file + ".restore-" + attempt + ".log",stdout.Result + stderr.Result); + int attempt = ++restoreSequence; + int exitCode = RunOwnedRestore(start, Profile == "Large" ? 3600000 : 300000, receipt => { + File.WriteAllText(file + ".restore-" + attempt + ".log", receipt.Stdout + receipt.Stderr); elapsed.Stop(); - Evidence["restore-" + Path.GetFileName(file) + "-" + attempt] = new { command = "sqlcmd " + args, exitCode = p.ExitCode, elapsed=elapsed.Elapsed }; - Save(); Assert(expectSuccess ? p.ExitCode == 0 : p.ExitCode != 0,"sqlcmd single-session expected exit for " + file + ": " + p.ExitCode); - } + Evidence["restore-" + Path.GetFileName(file) + "-" + attempt] = new { command = "sqlcmd " + args, exitCode = receipt.ExitCode, elapsed=elapsed.Elapsed, + timedOut = receipt.TimedOut, terminationConfirmed = receipt.TerminationConfirmed, terminationFailed = receipt.TerminationFailed, outputComplete = receipt.OutputComplete, + stdoutCharacters = receipt.StdoutCharacters, stderrCharacters = receipt.StderrCharacters, outputLimitPerStream = RestoreOutput.Limit }; + Save(); + }); + Assert(expectSuccess ? exitCode == 0 : exitCode != 0,"sqlcmd single-session expected exit for " + file + ": " + exitCode); } internal void RecordArtifact(string file) { @@ -165,6 +261,7 @@ public void Dispose() while (r.Read()) Assert(Path.GetDirectoryName(Path.GetFullPath(r.GetString(0))) == Root,"Owned database file path before DROP"); } using (var c = Open("master")) Exec(c,"ALTER DATABASE " + Q(db) + " SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE " + Q(db)); + Evidence["cleaned-" + db] = DateTime.UtcNow; Save(); Created.Remove(db); Console.WriteLine("CLEANED " + db); } Evidence["cleanup"] = "All exact token-owned databases dropped; artifacts retained"; Save(); @@ -173,13 +270,14 @@ public void Dispose() private sealed class IntegrationSource : IMssqlExportSource { - private readonly IMssqlExportSource inner; private readonly IntegrationFixture fixture; + private readonly IMssqlExportSource inner; private readonly IntegrationFixture fixture; private MssqlExportSchema originalReadSchema; internal IntegrationSource(IMssqlExportSource inner, IntegrationFixture fixture) { this.inner=inner; this.fixture=fixture; } public MssqlExportCatalog ReadCatalog(string d,CancellationToken c) { return inner.ReadCatalog(d,c); } public MssqlExportPreflight Inspect(MssqlExportRequest r,CancellationToken c) { return inner.Inspect(r,c); } - public MssqlExportSchema BeginRead(MssqlExportRequest r,CancellationToken c) { var schema=inner.BeginRead(r,c); if(fixture.AfterBegin!=null)fixture.AfterBegin(); return schema; } + public MssqlExportSchema BeginRead(MssqlExportRequest r,Action report,CancellationToken c) { var schema=inner.BeginRead(r,report,c); originalReadSchema=schema; if(fixture.AfterBegin!=null)fixture.AfterBegin(); if(fixture.ParentFirst) schema = new MssqlExportSchema(schema.Tables.OrderBy(t=>t.Key.Name=="Parent"?0:1),schema.Views,schema.Dependencies,schema.SnapshotState,schema.Issues,schema.ExtendedProperties,schema.SnapshotCapability); return schema; } public MssqlRowReader OpenRows(MssqlTableDefinition t,CancellationToken c) { if(fixture.BeforeRows!=null)fixture.BeforeRows(t); return inner.OpenRows(t,c); } - public void CompleteRead(MssqlExportSchema s,CancellationToken c) { inner.CompleteRead(s,c); } + public MssqlSnapshotCleanupResult FinishRead(Action report) { return inner.FinishRead(report); } + public void CompleteRead(MssqlExportSchema s,CancellationToken c) { inner.CompleteRead(originalReadSchema ?? s,c); } public void Dispose() { inner.Dispose(); } } @@ -490,9 +588,9 @@ private static void IntegrationUnsupported(IntegrationFixture f) f.Evidence["encryptedDefinitionIssues"]=encryptedCheck.Issues.Select(x=>x.Code+":"+x.ObjectKey?.Name).ToArray(); using(var c=f.Open("master"))IntegrationFixture.Exec(c,"ALTER DATABASE "+IntegrationFixture.Q(f.Source)+" SET ALLOW_SNAPSHOT_ISOLATION OFF;"); var off=f.Request("snapshot-off.sql",false,select:t=>t.Key.Name=="IdentityEmpty"); - Assert(f.Service().Preflight(off,CancellationToken.None).Issues.Any(x=>x.Code=="SnapshotUnavailable"),"Snapshot OFF rejected"); + Assert(f.Service().Preflight(off,CancellationToken.None).CanStart,"Snapshot OFF with confirmed ALTER capability may start"); using(var c=f.Open("master"))IntegrationFixture.Exec(c,"ALTER DATABASE "+IntegrationFixture.Q(f.Source)+" SET ALLOW_SNAPSHOT_ISOLATION ON;"); - f.Pass("Live unsupported spatial variant alias typed XML sequence encrypted definition and SNAPSHOT OFF preflight"); + f.Pass("Live unsupported spatial variant alias typed XML sequence encrypted definition and manageable SNAPSHOT OFF preflight"); using(var c=f.Open(f.Source))IntegrationFixture.Exec(c,"CREATE USER fixture_reader WITHOUT LOGIN; GRANT SELECT TO fixture_reader; DENY VIEW DEFINITION TO fixture_reader;"); var hidden=f.Request("hidden.sql",false,select:t=>t.Key.Schema=="一"); f.RestrictedReader=true; diff --git a/tests/SmokeTests.MssqlExport.Properties.cs b/tests/SmokeTests.MssqlExport.Properties.cs new file mode 100644 index 0000000..c44fec1 --- /dev/null +++ b/tests/SmokeTests.MssqlExport.Properties.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + private static string PropertiesHex(string text) + { + var hex = new StringBuilder(); + foreach (char c in text) { hex.Append(((int)c & 255).ToString("X2", CultureInfo.InvariantCulture)); hex.Append(((int)c >> 8).ToString("X2", CultureInfo.InvariantCulture)); } + return hex.ToString(); + } + private static MssqlExtendedPropertyDefinition PropertiesText(MssqlObjectKey owner, string ownerType, int minorId, string columnName, string name, string value, int? declaredBytes = null, string collation = "Chinese_Taiwan_Stroke_CI_AS") + { + // A separately allocated key represents a native catalog row, not object reference equality. + return new MssqlExtendedPropertyDefinition(new MssqlObjectKey(owner.ObjectId, owner.Schema, owner.Name), ownerType, + SchemaFacts("minor_id", minorId, "column_name", columnName, "name", name, "base_type", value == null ? null : "nvarchar", + "max_length", value == null ? null : (object)(declaredBytes ?? Math.Max(2, value.Length * 2)), "collation", value == null ? null : collation, + "is_null", value == null, "value_hex", value == null ? null : PropertiesHex(value))); + } + private static MssqlExportSchema PropertiesFixture(IEnumerable properties = null) + { + var tableKey = new MssqlObjectKey(901, "dbo", "station"); + var viewKey = new MssqlObjectKey(902, "dbo", "station_view"); + var column = new MssqlColumnDefinition(SchemaFacts("column_id", 1, "name", "id", "type_name", "int", "comment", "old display comment")); + var table = new MssqlTableDefinition(tableKey, SchemaFacts("comment", "old table display comment"), new[] { column }, null, null); + var view = new MssqlViewDefinition(viewKey, SchemaFacts("definition", "CREATE VIEW dbo.station_view AS SELECT id FROM dbo.station", "uses_ansi_nulls", true, "uses_quoted_identifier", true)); + return new MssqlExportSchema(new[] { table }, new[] { view }, null, 1, null, extendedProperties: properties); + } + private static void Properties_AllowsOrdinaryNamedProperties() + { + var original = PropertiesFixture(); var table = original.Tables[0].Key; var view = original.Views[0].Key; + var props = new[] { + PropertiesText(table, "TABLE", 0, null, "WRA_WATERLEVEL_STATION", "station"), + PropertiesText(view, "VIEW", 0, null, "MS_DiagramPane1", new string('\u4e2d', 3503)), + new MssqlExtendedPropertyDefinition(view, "VIEW", SchemaFacts("minor_id", 0, "name", "MS_DiagramPaneCount", "base_type", "int", "max_length", 4, "is_null", false, "value_hex", "00000001")) }; + var schema = PropertiesFixture(props); + var issues = new MssqlExportMetadataReader().Validate(schema, SchemaRequest(schema)); + Assert(issues.Count == 0, "Ordinary named properties on a table and nonindexed, trigger-free view must pass: " + string.Join("; ", issues.Select(x => x.Message))); + } + private static void Properties_WritesVariantValuesAndScopes() + { + var original = PropertiesFixture(); var table = original.Tables[0].Key; var view = original.Views[0].Key; + string pane = "\u4e2d\u6587 '\r\nGO\r\n$(name)\ud83d\ude80"; + pane += new string('\u4e2d', 3503 - pane.Length); + var props = new[] { + PropertiesText(table, "TABLE", 0, null, "MS_Description", "owner's table", 100), + PropertiesText(table, "TABLE", 1, "id", "MS_Description", "column\r\ncomment "), + PropertiesText(table, "TABLE", 0, null, "empty", "", 60), + PropertiesText(view, "VIEW", 0, null, "MS_DiagramPane1", pane), + PropertiesText(view, "VIEW", 1, "id", "caption'\u03a9", null), + PropertiesText(view, "VIEW", 0, null, "near_limit", new string('\u4e2d', 3700)), + new MssqlExtendedPropertyDefinition(view, "VIEW", SchemaFacts("minor_id", 0, "name", "MS_DiagramPaneCount", "base_type", "int", "max_length", 4, "is_null", false, "value_hex", "80000000")) }; + var schema = PropertiesFixture(props); var output = new StringWriter(); var request = SchemaRequest(schema); + new MssqlSchemaScriptWriter().WriteAfterData(schema, request, output, CancellationToken.None); + string sql = output.ToString(); + AssertContains(sql, "CONVERT(nvarchar(3503), 0x" + PropertiesHex(pane) + ") COLLATE Chinese_Taiwan_Stroke_CI_AS", "Keep 7006 bytes, declared length and source collation"); + AssertContains(sql, "CONVERT(nvarchar(3700), 0x", "Near-limit property stays bounded and native"); + AssertContains(sql, "CONVERT(nvarchar(50), 0x" + PropertiesHex("owner's table") + ")", "Declared length must not shrink to actual string length"); + AssertContains(sql, "CONVERT(nvarchar(30), 0x) COLLATE", "Empty typed property retains declared length"); + AssertContains(sql, "CONVERT(int, 0x80000000)", "Keep negative int range without widening to numeric"); + AssertContains(sql, "SET @__mp_property = NULL;", "Existing NULL property must not disappear"); + AssertContains(sql, "@name=N'caption''\u03a9'", "Quote property names independently"); + AssertContains(sql, "@level1type=N'VIEW', @level1name=N'station_view', @level2type=N'COLUMN', @level2name=N'id'", "View column scope"); + AssertContains(sql, "@level1type=N'TABLE', @level1name=N'station', @level2type=N'COLUMN', @level2name=N'id'", "Table column scope"); + Assert(sql.Split(new[] { "EXEC sys.sp_addextendedproperty" }, StringSplitOptions.None).Length - 1 == props.Length, "Exactly one write per source property, including descriptions"); + Assert(sql.IndexOf("EXEC sys.sp_addextendedproperty", StringComparison.Ordinal) > sql.IndexOf("CREATE VIEW", StringComparison.Ordinal), "All owner DDL precedes property output"); + AssertNotContains(sql, "$(name)", "Value text cannot be parsed by sqlcmd"); + AssertNotContains(sql, "old display comment", "UI comment projection cannot add duplicate properties"); + var noProps = new StringWriter(); + new MssqlSchemaScriptWriter().WriteAfterData(original, SchemaRequest(original), noProps, CancellationToken.None); + AssertNotContains(noProps.ToString(), "sp_addextendedproperty", "Only the canonical property list is scripted"); + } + private static void Properties_RejectsUnsupportedAndMalformedMetadata() + { + var original = PropertiesFixture(); var key = original.Tables[0].Key; + foreach (string type in new[] { "varchar", "decimal", "varbinary", "bit" }) + { + var property = new MssqlExtendedPropertyDefinition(key, "TABLE", SchemaFacts("minor_id", 0, "name", "unsupported_" + type, "base_type", type, "max_length", 4, "is_null", false)); + var schema = PropertiesFixture(new[] { property }); + var issue = new MssqlExportMetadataReader().Validate(schema, SchemaRequest(schema)).Single(); + Assert(issue.Code == "UnsupportedExtendedPropertyType" && issue.Message.Contains(property.Name) && issue.Message.Contains(type), "Unsupported property reports exact owner/name/type"); + AssertThrows(() => new MssqlSchemaScriptWriter().WriteBeforeData(schema, SchemaRequest(schema), new StringWriter(), CancellationToken.None), "Unsupported properties fail before any output"); + } + var good = PropertiesText(key, "TABLE", 1, "id", "test", "x"); + foreach (var change in new[] { SchemaFacts("value_hex", "GG00"), SchemaFacts("value_hex", "780"), SchemaFacts("value_hex", "7800FFFF"), SchemaFacts("max_length", 0), SchemaFacts("max_length", 3), SchemaFacts("collation", "bad; DROP TABLE t"), SchemaFacts("column_name", "missing"), SchemaFacts("is_null", true) }) + { + var facts = good.Facts.ToDictionary(x => x.Key, x => x.Value); foreach (var pair in change) facts[pair.Key] = pair.Value; + var bad = new MssqlExtendedPropertyDefinition(key, "TABLE", facts); var schema = PropertiesFixture(new[] { bad }); + Assert(new MssqlExportMetadataReader().Validate(schema, SchemaRequest(schema)).Any(x => x.Code == "InvalidExtendedProperty"), "Malformed native property cannot silently convert or lose scope"); + } + var duplicate = PropertiesFixture(new[] { good, good }); + Assert(new MssqlExportMetadataReader().Validate(duplicate, SchemaRequest(duplicate)).Any(x => x.Code == "InvalidExtendedProperty"), "Repeated property identity fails before restore"); + foreach (string feature in new[] { "index_count", "trigger_count" }) + { + var viewFacts = original.Views[0].Facts.ToDictionary(x => x.Key, x => x.Value); viewFacts[feature] = 1; + var schema = new MssqlExportSchema(original.Tables, new[] { new MssqlViewDefinition(original.Views[0].Key, viewFacts) }, null, 1, null); + Assert(new MssqlExportMetadataReader().Validate(schema, SchemaRequest(schema)).Any(x => x.Code == "UnsupportedViewFeature"), "Actual view index and trigger remain unsupported"); + } + } + private static void Properties_FingerprintsImmutableNativeFacts() + { + var original = PropertiesFixture(); var key = original.Tables[0].Key; + var first = PropertiesText(key, "TABLE", 0, null, "same_length", "ab"); + var second = PropertiesText(key, "TABLE", 1, "id", "column", null); + var baseline = PropertiesFixture(new[] { first, second }); + Assert(baseline.Fingerprint == PropertiesFixture(new[] { second, first }).Fingerprint, "Catalog row order cannot change property fingerprint"); + foreach (var change in new[] { SchemaFacts("value_hex", PropertiesHex("ac")), SchemaFacts("max_length", 8), SchemaFacts("collation", "Latin1_General_100_BIN2"), SchemaFacts("name", "renamed"), SchemaFacts("base_type", "int", "max_length", 4, "collation", null, "value_hex", "00000001"), SchemaFacts("minor_id", 1, "column_name", "id") }) + { + var facts = first.Facts.ToDictionary(x => x.Key, x => x.Value); foreach (var pair in change) facts[pair.Key] = pair.Value; + var changed = new MssqlExtendedPropertyDefinition(key, "TABLE", facts); + Assert(baseline.Fingerprint != PropertiesFixture(new[] { changed, second }).Fingerprint, "Native property values, types, length, collation and identity participate in drift detection"); + } + var mutableFacts = first.Facts.ToDictionary(x => x.Key, x => x.Value); var copy = new MssqlExtendedPropertyDefinition(key, "TABLE", mutableFacts); + mutableFacts["value_hex"] = PropertiesHex("zz"); + Assert(copy.ValueHex == PropertiesHex("ab"), "Property snapshot cannot follow later dictionary edits"); + } + private static void Properties_RespectsModesAndCancellation() + { + var original = PropertiesFixture(); var schema = PropertiesFixture(new[] { PropertiesText(original.Tables[0].Key, "TABLE", 0, null, "MS_Description", "comment") }); + var writer = new MssqlSchemaScriptWriter(); var output = new StringWriter(); + writer.WriteAfterData(schema, SchemaRequest(schema, MssqlExportMode.SchemaOnly), output, CancellationToken.None); + AssertContains(output.ToString(), "sp_addextendedproperty", "SchemaOnly preserves properties"); + output = new StringWriter(); writer.WriteAfterData(schema, SchemaRequest(schema, MssqlExportMode.DataOnly), output, CancellationToken.None); + AssertNotContains(output.ToString(), "sp_addextendedproperty", "DataOnly leaves target metadata untouched"); + AssertThrows(() => writer.WriteAfterData(schema, SchemaRequest(schema), new StringWriter(), new CancellationToken(true)), "Property output obeys cancellation"); + } +} diff --git a/tests/SmokeTests.MssqlExport.Routing.cs b/tests/SmokeTests.MssqlExport.Routing.cs index 0139e15..2657590 100644 --- a/tests/SmokeTests.MssqlExport.Routing.cs +++ b/tests/SmokeTests.MssqlExport.Routing.cs @@ -185,10 +185,12 @@ private static void Routing_ResultStatus() foreach (var stage in new[] { MssqlExportStage.Completed, MssqlExportStage.Canceled, MssqlExportStage.Failed, MssqlExportStage.LocalCompletedMirrorIncomplete }) using (var fixture = new RoutingFixture("mssql")) { - fixture.Result = new MssqlExportResult(Guid.NewGuid(), stage, stage == MssqlExportStage.Completed || stage == MssqlExportStage.LocalCompletedMirrorIncomplete ? "local.sql" : null, null, null, TimeSpan.Zero, null, null, "fixture reason"); + fixture.Result = new MssqlExportResult(Guid.NewGuid(), stage, stage == MssqlExportStage.Completed || stage == MssqlExportStage.LocalCompletedMirrorIncomplete ? "local.sql" : null, null, null, TimeSpan.Zero, null, null, "fixture reason", true, new MssqlSnapshotCleanupResult(MssqlSnapshotCleanupState.Unconfirmed, 3, "fixture-only.json", "RestoreUnconfirmed")); fixture.Invoke("BackupSelectedDatabaseWithDialog"); Assert(fixture.Status.Count == 1, "Terminal wizard result updates main status once"); AssertContains(fixture.Status.Single(), stage == MssqlExportStage.Completed ? "completed" : stage == MssqlExportStage.Canceled ? "canceled" : stage == MssqlExportStage.Failed ? "failed" : "mirror incomplete", "Status follows authoritative terminal stage"); + AssertContains(fixture.Status.Single(), "restoration is unconfirmed", "Restore warning is independent of terminal stage"); + AssertContains(fixture.Status.Single(), "fixture-only.json", "Main status retains recovery record path"); if (stage == MssqlExportStage.LocalCompletedMirrorIncomplete) AssertContains(fixture.Status.Single(), "local.sql", "Incomplete mirror retains published local path"); } } diff --git a/tests/SmokeTests.MssqlExport.Schema.cs b/tests/SmokeTests.MssqlExport.Schema.cs index 88b9ecd..2189b85 100644 --- a/tests/SmokeTests.MssqlExport.Schema.cs +++ b/tests/SmokeTests.MssqlExport.Schema.cs @@ -14,7 +14,7 @@ private static Dictionary SchemaFacts(params object[] pairs) return result; } private static MssqlExportRequest SchemaRequest(MssqlExportSchema schema, MssqlExportMode mode = MssqlExportMode.SchemaAndData) - { return new MssqlExportRequest(Guid.NewGuid(), "source_db", schema.Tables.Select(t => t.Key).ToArray(), mode, mode != MssqlExportMode.DataOnly && schema.Views.Count != 0, Path.Combine(Path.GetTempPath(), "schema.sql"), null, null); } + { return new MssqlExportRequest(Guid.NewGuid(), "source_db", schema.Tables.Select(t => t.Key).ToArray(), mode, mode != MssqlExportMode.DataOnly && schema.Views.Count != 0, Path.Combine(Path.GetTempPath(), "schema.sql"), null, null, mode != MssqlExportMode.SchemaOnly); } private static void Schema_RequiresWriter() { Assert(typeof(MssqlObjectKey).Assembly.GetType("mySQLPunk.lib.MssqlExport.MssqlSchemaScriptWriter") != null, "Native two-stage schema writer must exist"); } private static MssqlExportSchema SchemaFixture() @@ -37,7 +37,7 @@ private static MssqlExportSchema SchemaFixture() var table = new MssqlTableDefinition(key,SchemaFacts("comment","owner's table","lock_escalation_desc","TABLE","text_in_row_limit",0,"large_value_types_out_of_row",false),columns,indexes,constraints); var v1 = new MssqlViewDefinition(new MssqlObjectKey(2,"dbo","v1"),SchemaFacts("definition","CREATE VIEW [dbo].[v1] AS SELECT [id] FROM [sa'les]]].[ord]]ers]","uses_ansi_nulls",true,"uses_quoted_identifier",true)); var v2 = new MssqlViewDefinition(new MssqlObjectKey(3,"dbo","v2"),SchemaFacts("definition","ALTER VIEW [dbo].[v2] AS SELECT * FROM [dbo].[v1]","uses_ansi_nulls",true,"uses_quoted_identifier",false)); - return new MssqlExportSchema(new[]{table},new[]{v2,v1},new[]{new MssqlDependency(3,2,null,null,"dbo","v1"),new MssqlDependency(2,1,null,null,key.Schema,key.Name)},1,null); + return new MssqlExportSchema(new[]{table},new[]{v2,v1},new[]{new MssqlDependency(3,2,null,null,"dbo","v1"),new MssqlDependency(2,1,null,null,key.Schema,key.Name)},1,null, extendedProperties: new[] { PropertiesText(key,"TABLE",0,null,"MS_Description","owner's table"), PropertiesText(key,"TABLE",2,"name","MS_Description","owner's column") }); } private static void Schema_WritesNativeBeforeAndAfter() { @@ -63,7 +63,7 @@ private static void Schema_WritesNativeBeforeAndAfter() AssertContains(last,"FILLFACTOR = 80","Preserve index option"); AssertContains(last,"WITH NOCHECK ADD CONSTRAINT [ck] CHECK","Preserve untrusted check"); AssertContains(last,"NOCHECK CONSTRAINT [fk]","Preserve disabled FK"); - AssertContains(last,"owner''s column","Escape extended property values"); + AssertContains(last,PropertiesHex("owner's column"),"Keep extended property native bytes"); AssertContains(last,"SET QUOTED_IDENTIFIER OFF;","Preserve view SET option"); Assert(last.IndexOf("CREATE VIEW [dbo].[v1]",StringComparison.Ordinal)(); var backend = new SnapshotFakeBackend(events) { State = state }; + var store = new SnapshotFakeStore(events); var lifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); + lifecycle.Prepare(null, CancellationToken.None); + var result = lifecycle.Restore(null); lifecycle.Release(); + Assert(result.State == (state == 0 ? MssqlSnapshotCleanupState.RestoredOff : MssqlSnapshotCleanupState.PreservedOn), "Original state determines cleanup"); + Assert(backend.AlterCalls.Count == (state == 0 ? 2 : 0), "Originally ON never altered"); + Assert(!store.Exists && backend.ReleaseCalls == 1, "Journal removed and lock released once"); + if (state == 0) Assert(string.Join(",", events).Contains("lock,read,create,update,read,ON,read"), "Durable intent precedes fresh capability and ON"); + Assert(object.ReferenceEquals(result, lifecycle.Restore(null)), "Restore is idempotent"); + } + } + private static void SnapshotLifecycle_UnknownEnableAndIdentity() + { + var events = new List(); var backend = new SnapshotFakeBackend(events) { ThrowAfterOn = true }; + var store = new SnapshotFakeStore(events); var lifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); + bool failed = false; try { lifecycle.Prepare(null, CancellationToken.None); } catch (InvalidOperationException) { failed = true; } + Assert(failed && backend.State == 1 && store.Exists, "Lost ON response retains recovery controller"); + Assert(lifecycle.Restore(null).State == MssqlSnapshotCleanupState.RestoredOff && backend.State == 0, "Unknown enable is read back and restored"); + backend = new SnapshotFakeBackend(new List()); store = new SnapshotFakeStore(backend.Events); + lifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); lifecycle.Prepare(null, CancellationToken.None); + backend.Identity = new MssqlSnapshotIdentity("server", "fixture", 3, new DateTime(2026, 2, 1)); + Assert(lifecycle.Restore(null).State == MssqlSnapshotCleanupState.Unconfirmed && backend.AlterCalls.Count == 1 && store.Exists, "Recreated database never altered"); + backend = new SnapshotFakeBackend(new List()) { NotAttemptedOn = true }; store = new SnapshotFakeStore(backend.Events); + lifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); + failed = false; try { lifecycle.Prepare(null, CancellationToken.None); } catch (MssqlSnapshotSettingNotAttemptedException) { failed = true; } + Assert(failed && lifecycle.Restore(null).State == MssqlSnapshotCleanupState.NotRequired && backend.State == 1 && backend.AlterCalls.Count == 0, "Definite pre-send rejection never owns external ON"); + backend = new SnapshotFakeBackend(new List()) { ThrowAfterOff = true }; store = new SnapshotFakeStore(backend.Events); + lifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); lifecycle.Prepare(null, CancellationToken.None); + Assert(lifecycle.Restore(null).State == MssqlSnapshotCleanupState.RestoredOff && !store.Exists, "Lost OFF response resolved by actual readback"); + } + private static void SnapshotWorker_AdapterStartUnavailable() + { + foreach (int fault in new[] { 0, 1, 2 }) + using (var connection = new SqlConnection()) + { + var calls = new List(); + var backend = new SnapshotFakeBackend(calls); + MssqlSnapshotCapability observed = null; + backend.SettingAdapter = new SnapshotCapabilityGuardAdapter(connection, () => { + backend.CanAlter = fault != 0; backend.State = fault == 0 ? 0 : fault; + return observed = backend.ReadCapability(CancellationToken.None); + }); + var store = new SnapshotFakeStore(calls); + var source = new WorkerSource(WorkerSchema(), calls) { + SnapshotLifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()) + }; + string target = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".sql"); + var request = WorkerRequest(target, WorkerSchema(), MssqlExportMode.DataOnly, null); + var result = new MssqlSqlExportService(() => source).Export(request, null, CancellationToken.None); + Assert(result.StartRejection == MssqlSnapshotStartRejection.SnapshotUnavailable && ReferenceEquals(result.StartCapability, observed), + "Final adapter capability refusal retains exact safe decision after lifecycle checks passed"); + Assert(result.Stage == MssqlExportStage.Failed && result.PublishedPath == null && !File.Exists(target) && !calls.Contains("source.rows"), + "Final adapter refusal cannot publish or silently fall back"); + Assert(backend.AlterCalls.Count == 0 && backend.State == observed.State && !store.Exists, + "Positive pre-dispatch refusal must not own external state or issue cleanup OFF"); + Assert(calls.Contains("source.dispose") && connection.State == System.Data.ConnectionState.Closed, + "Worker releases source and adapter fixture never opens a database"); + } + var valid = new MssqlSnapshotCapability(WIdentity(), 0, true, true); + MssqlSnapshotBackend.RequireSettingCapability(true, valid); + Exception off = null; + try { MssqlSnapshotBackend.RequireSettingCapability(false, valid); } catch (Exception ex) { off = ex; } + Assert(off is MssqlSnapshotSettingNotAttemptedException && off.InnerException == null, + "OFF cleanup refusal remains cleanup-only, never a new start rejection"); + } + private sealed class SnapshotCapabilityGuardAdapter : MssqlSnapshotBackend + { + private readonly Func capability; + internal SnapshotCapabilityGuardAdapter(SqlConnection connection, Func capability) : base(connection) + { this.capability = capability; } + internal override SqlCommand PrepareSettingCommand(bool enabled, CancellationToken cancellation) + { + RequireSettingCapability(enabled, capability()); + return new SqlCommand(); + } + } + private static void SnapshotLifecycle_AdapterPreDispatchFailures() + { + foreach (int fault in new[] { 0, 1, 2 }) + using (var connection = new SqlConnection()) + using (var cancellation = new CancellationTokenSource()) + { + Exception expected = fault == 0 ? (Exception)new TimeoutException("PreliminaryReadTimedOut") : + fault == 1 ? new OperationCanceledException(cancellation.Token) : new InvalidOperationException("CommandPreparationFailed"); + var adapter = new SnapshotPreparationFaultAdapter(connection, expected); + var backend = new SnapshotFakeBackend(new List()) { SettingAdapter = adapter }; + var store = new SnapshotFakeStore(backend.Events); + var job = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); + Exception caught = null; + try { job.Prepare(null, cancellation.Token); } catch (Exception ex) { caught = ex; } + Assert(object.ReferenceEquals(expected, caught), "Original pre-dispatch cause and cancellation type preserved"); + backend.State = 1; // External actor enables after the adapter's preliminary phase failed. + var cleanup = job.Restore(null); + Assert(cleanup.State == MssqlSnapshotCleanupState.NotRequired && backend.State == 1 && backend.AlterCalls.Count == 0, + "Adapter pre-dispatch failure never claims external ON or sends OFF"); + Assert(adapter.PreparationCalls == 1 && !store.Exists && connection.State == System.Data.ConnectionState.Closed, + "Actual SetEnabled preparation boundary exercised without opening SQL connection"); + } + using (var connection = new SqlConnection()) + { + // Reaching the execution call is a different boundary: its failure must stay uncertain. + var adapter = new SnapshotPreparationFaultAdapter(connection, null); + Exception caught = null; try { adapter.SetEnabled(true, CancellationToken.None); } catch (Exception ex) { caught = ex; } + Assert(caught is InvalidOperationException && !(caught is MssqlSnapshotSettingNotAttemptedException), + "Exceptions from the actual SqlCommand execution call are not relabeled as pre-dispatch"); + Assert(connection.State == System.Data.ConnectionState.Closed, "Execution boundary test never opens a database connection"); + } + } + private sealed class SnapshotPreparationFaultAdapter : MssqlSnapshotBackend + { + private readonly Exception failure; + internal int PreparationCalls; + internal SnapshotPreparationFaultAdapter(SqlConnection connection, Exception failure) : base(connection) { this.failure = failure; } + internal override SqlCommand PrepareSettingCommand(bool enabled, CancellationToken cancellation) + { PreparationCalls++; if (failure != null) throw failure; return new SqlCommand(); } + } + private static void SnapshotLifecycle_CancelDeadlineAndResidual() + { + var backend = new SnapshotFakeBackend(new List()); var store = new SnapshotFakeStore(backend.Events); + var canceled = new CancellationTokenSource(); var lifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); + lifecycle.Prepare(null, canceled.Token); canceled.Cancel(); + Assert(lifecycle.Restore(null).State == MssqlSnapshotCleanupState.RestoredOff && !backend.OffTokenCanceled, "Cleanup does not inherit job cancellation"); + backend = new SnapshotFakeBackend(new List()) { ThrowOff = true }; store = new SnapshotFakeStore(backend.Events); + lifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); lifecycle.Prepare(null, CancellationToken.None); + Assert(lifecycle.Restore(null).State == MssqlSnapshotCleanupState.Unconfirmed && store.Exists, "Failed OFF retains journal"); + } + private static void SnapshotLifecycle_NoJournalNoAlter() + { + foreach (int failure in new[] { 0, 1, 2 }) + { + var backend = new SnapshotFakeBackend(new List()); + var store = new SnapshotFakeStore(backend.Events) { FailCreate = failure == 0, FailUpdate = failure == 1, MissingReceipt = failure == 2 }; + var lifecycle = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); + bool failed = false; try { lifecycle.Prepare(null, CancellationToken.None); } catch (Exception) { failed = true; } + var result = lifecycle.Restore(null); + Assert(failed && backend.AlterCalls.Count == 0, "No durable receipt/intent means no ALTER"); + if (failure == 1) Assert(result.WarningCode == "JournalUpdateFailed", "Journal failure retained as fixed notice"); + } + } + private static void SnapshotLifecycle_CapabilityAndCooperation() + { + foreach (bool? permission in new bool?[] { null, false }) + foreach (int state in new[] { 0, 1 }) + { + var backend = new SnapshotFakeBackend(new List()) { CanAlter = permission, State = state }; + var lifecycle = new MssqlSnapshotLifecycle(backend, new SnapshotFakeStore(backend.Events), Guid.NewGuid()); + bool failed = false; try { lifecycle.Prepare(null, CancellationToken.None); } catch (InvalidOperationException) { failed = true; } + lifecycle.Restore(null); Assert(failed && backend.AlterCalls.Count == 0, "Unknown or denied capability blocks even originally ON"); + } + var locks = new HashSet(); var a = new SnapshotFakeBackend(new List()) { Locks = locks }; + var b = new SnapshotFakeBackend(new List()) { Locks = locks }; + var c = new SnapshotFakeBackend(new List()) { Locks = locks, Database = "other" }; + var first = new MssqlSnapshotLifecycle(a, new SnapshotFakeStore(a.Events), Guid.NewGuid()); first.Prepare(null, CancellationToken.None); + var second = new MssqlSnapshotLifecycle(b, new SnapshotFakeStore(b.Events), Guid.NewGuid()); + bool busy = false; try { second.Prepare(null, CancellationToken.None); } catch (InvalidOperationException) { busy = true; } + Assert(busy && b.Events.Count == 1 && b.AlterCalls.Count == 0, "Busy same database stops before read/ALTER"); + var third = new MssqlSnapshotLifecycle(c, new SnapshotFakeStore(c.Events), Guid.NewGuid()); third.Prepare(null, CancellationToken.None); + third.Restore(null); first.Restore(null); second.Restore(null); + var withdrawn = new SnapshotFakeBackend(new List()); var store = new SnapshotFakeStore(withdrawn.Events); + int reads = 0; withdrawn.OnRead = () => { if (++reads == 2) withdrawn.CanAlter = false; }; + var job = new MssqlSnapshotLifecycle(withdrawn, store, Guid.NewGuid()); + bool rejected = false; try { job.Prepare(null, CancellationToken.None); } catch (InvalidOperationException) { rejected = true; } + job.Restore(null); Assert(rejected && withdrawn.AlterCalls.Count == 0, "Withdrawn permission rechecked before ON"); + } + private static void SnapshotLifecycle_ResidualAndObservers() + { + foreach (int fault in new[] { 0, 1, 2, 3, 4, 5 }) + { + var backend = new SnapshotFakeBackend(new List()); var store = new SnapshotFakeStore(backend.Events); + var job = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid()); + Action report = stage => { if (fault == 4) throw new InvalidOperationException("observer-secret"); }; + job.Prepare(report, CancellationToken.None); + backend.Closed = fault == 0; backend.CanAlter = fault == 1 ? false : true; + store.FailRemove = fault == 2; backend.FailRelease = fault == 3; store.FailUpdate = fault == 5; + var result = job.Restore(report); job.Release(); job.Restore(report); + Assert(result.WarningCode != null && !result.WarningCode.Contains("secret"), "Cleanup/observer faults expose fixed notice"); + Assert(result.State == (fault <= 1 ? MssqlSnapshotCleanupState.Unconfirmed : MssqlSnapshotCleanupState.RestoredOff), "Known OFF retained despite local warning"); + Assert(store.Exists == (fault <= 3), "Residual recovery kept for incomplete cleanup"); + if (fault == 3) Assert(result.ObservedState == 0 && backend.ReleaseCalls == 1, "Failed release retains known OFF and does not repeat"); + } + } + private static void SnapshotLifecycle_WholeDeadline() + { + var deadlines = new List(); + Func factory = (seconds, token) => + { var d = new SnapshotFakeDeadline(seconds, token); deadlines.Add(d); return d; }; + var backend = new SnapshotFakeBackend(new List()) { TransitionAfterOn = true }; + var store = new SnapshotFakeStore(backend.Events); var job = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid(), factory); + bool failed = false; try { job.Prepare(null, CancellationToken.None); } catch (TimeoutException) { failed = true; } + Assert(failed && deadlines[0].Seconds == 60 && deadlines[0].Polls == 60, "Enable whole deadline never resets between polls"); + Assert(job.Restore(null).State == MssqlSnapshotCleanupState.Unconfirmed && deadlines[1].Seconds == 30 && deadlines[1].Polls == 30 && store.Exists, "Independent cleanup deadline bounds transition wait"); + Assert(object.ReferenceEquals(backend.SeenDeadlines[0], backend.SeenDeadlines[1]), "Backend receives same deadline across operation commands"); + backend = new SnapshotFakeBackend(new List()); store = new SnapshotFakeStore(backend.Events); deadlines.Clear(); + job = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid(), factory); job.Prepare(null, CancellationToken.None); + backend.State = 3; int reads = 0; backend.OnRead = () => { if (++reads == 2) backend.State = 1; }; + Assert(job.Restore(null).State == MssqlSnapshotCleanupState.RestoredOff, "Transition settling ON still attempts owned OFF"); + var cancel = new CancellationTokenSource(); backend = new SnapshotFakeBackend(new List()) { OnEnabled = () => cancel.Cancel(), ThrowAfterOn = true }; + store = new SnapshotFakeStore(backend.Events); job = new MssqlSnapshotLifecycle(backend, store, Guid.NewGuid(), factory); + try { job.Prepare(null, cancel.Token); } catch (InvalidOperationException) { } + Assert(job.Restore(null).State == MssqlSnapshotCleanupState.RestoredOff && !backend.OffTokenCanceled, "Canceled uncertain ON still restored using fresh token"); + } + private sealed class SnapshotFakeDeadline : IMssqlSnapshotDeadline + { + internal readonly int Seconds; internal int Polls; private readonly CancellationToken token; + internal SnapshotFakeDeadline(int seconds, CancellationToken token) { Seconds = seconds; this.token = token; } + public CancellationToken Token => token; + public int RemainingSeconds { get { Check(); return Seconds - Polls; } } + public void Check() { token.ThrowIfCancellationRequested(); if (Polls >= Seconds) throw new TimeoutException(); } + public void WaitForPoll() { Polls++; Check(); } + public void Dispose() { } + } + private sealed class SnapshotFakeBackend : IMssqlSnapshotBackend, IMssqlSnapshotDeadlineBackend + { + internal readonly List Events; internal readonly List AlterCalls = new List(); + internal int State, ReleaseCalls; internal bool ThrowAfterOn, ThrowOff, ThrowAfterOff, NotAttemptedOn, OffTokenCanceled, Closed, FailRelease, TransitionAfterOn; + internal HashSet Locks; internal string Database = "fixture"; + internal Action OnEnabled; + internal MssqlSnapshotBackend SettingAdapter; + private IMssqlSnapshotDeadline deadline; + internal readonly List SeenDeadlines = new List(); + public IMssqlSnapshotDeadline Deadline { set { deadline = value; } } + internal bool? CanAlter = true, Writable = true; + internal Action OnRead; + internal MssqlSnapshotIdentity Identity = new MssqlSnapshotIdentity("server", "fixture", 3, new DateTime(2026, 1, 1)); + internal SnapshotFakeBackend(List events) { Events = events; } + public bool IsUsable => !Closed; + public bool TryAcquireLock(CancellationToken token) { token.ThrowIfCancellationRequested(); Events.Add("lock"); SeenDeadlines.Add(deadline); return Locks == null || Locks.Add(Database); } + public MssqlSnapshotCapability ReadCapability(CancellationToken token) { token.ThrowIfCancellationRequested(); Events.Add("read"); SeenDeadlines.Add(deadline); OnRead?.Invoke(); return new MssqlSnapshotCapability(Identity, State, CanAlter, Writable); } + public void SetEnabled(bool enabled, CancellationToken token) + { + if (enabled && SettingAdapter != null) { SettingAdapter.SetEnabled(enabled, token); return; } + if (enabled && NotAttemptedOn) { State = 1; throw new MssqlSnapshotSettingNotAttemptedException(); } + if (!enabled) OffTokenCanceled = token.IsCancellationRequested; + token.ThrowIfCancellationRequested(); Events.Add(enabled ? "ON" : "OFF"); AlterCalls.Add(enabled); + if (!enabled && ThrowOff) throw new InvalidOperationException("unsafe-provider-message"); + State = enabled ? (TransitionAfterOn ? 3 : 1) : 0; + if (enabled) OnEnabled?.Invoke(); + if (enabled && ThrowAfterOn) throw new InvalidOperationException("unsafe-provider-message"); + if (!enabled && ThrowAfterOff) throw new InvalidOperationException("unsafe-provider-message"); + } + public void ReleaseLock(CancellationToken token) { token.ThrowIfCancellationRequested(); Events.Add("unlock"); ReleaseCalls++; if (FailRelease) throw new InvalidOperationException(); Locks?.Remove(Database); } + } + private sealed class SnapshotFakeStore : IMssqlSnapshotRecoveryStore + { + private readonly List events; internal bool Exists, FailCreate, FailUpdate, FailRemove, MissingReceipt; + internal SnapshotFakeStore(List events) { this.events = events; } + public string Create(MssqlSnapshotRecoveryRecord record) { events.Add("create"); if (FailCreate) throw new IOException(); Exists = true; return MissingReceipt ? null : "fixture-journal"; } + public void Update(MssqlSnapshotRecoveryRecord record) { events.Add("update"); if (FailUpdate) throw new IOException(); } + public void RemoveConfirmed(MssqlSnapshotRecoveryRecord record) { events.Add("remove"); if (FailRemove) throw new IOException(); Exists = false; } + public IReadOnlyList ReadPending() => new MssqlSnapshotRecoveryNotice[0]; + } + private static MssqlExportRequest SnapshotRequest(MssqlExportMode mode, bool snapshot = false) => new MssqlExportRequest(Guid.NewGuid(), "fixture", new[] { new MssqlObjectKey(1, "dbo", "t") }, mode, false, Path.Combine(Path.GetTempPath(), "snapshot.sql"), null, null, snapshot); + private static void SnapshotOptions_DefaultAndSchemaOnly() + { + Assert(!SnapshotRequest(MssqlExportMode.SchemaAndData).UseSnapshot, "Snapshot defaults off"); + bool rejected = false; + try { SnapshotRequest(MssqlExportMode.SchemaOnly, true); } catch (ArgumentException) { rejected = true; } + Assert(rejected, "Schema only rejects snapshot"); + Assert(!SnapshotRequest(MssqlExportMode.SchemaOnly).UseSnapshot && SnapshotRequest(MssqlExportMode.DataOnly, true).UseSnapshot, "Modes retain explicit selection"); + var result = new MssqlExportResult(Guid.NewGuid(), MssqlExportStage.Completed, null, null, null, TimeSpan.Zero, null, null, null); + Assert(!result.UseSnapshot && result.SnapshotCleanup == null, "Existing results retain unknown cleanup default"); + } + private static void SnapshotOptions_CapabilityMatrix() + { + var identity = new MssqlSnapshotIdentity("server", "fixture", 1, new DateTime(2026, 1, 1)); + foreach (int state in new[] { -1, 0, 1, 2, 3, 9 }) + foreach (bool? alter in new bool?[] { null, false, true }) + foreach (bool? writable in new bool?[] { null, false, true }) + foreach (var id in new[] { null, identity, new MssqlSnapshotIdentity(null, "fixture", 1, identity.DatabaseCreateDate) }) + { + var capability = new MssqlSnapshotCapability(id, state, alter, writable); + Assert(capability.CanManage == (id == identity && (state == 0 || state == 1) && alter == true && writable == true), "Capability requires complete identity, stable state and explicit permissions"); + } + } + private static void SnapshotOptions_Fingerprint() + { + var a = new MssqlExportSchema(null, null, null, 0, null, snapshotCapability: new MssqlSnapshotCapability(null, 0, null, null)); + var b = new MssqlExportSchema(null, null, null, 1, null, snapshotCapability: new MssqlSnapshotCapability(new MssqlSnapshotIdentity("server", "fixture", 1, DateTime.Today), 1, true, true)); + bool rejected = false; + try { new MssqlExportCatalog(null, 0, null, b.SnapshotCapability); } catch (ArgumentException) { rejected = true; } + Assert(rejected, "Catalog rejects inconsistent capability state"); + rejected = false; + try { new MssqlExportSchema(null, null, null, 0, null, snapshotCapability: b.SnapshotCapability); } catch (ArgumentException) { rejected = true; } + Assert(rejected, "Schema rejects inconsistent capability state"); + Assert(new MssqlExportCatalog(null, 0, null).SnapshotCapability == null, "Missing capability stays unknown"); + Assert(a.Fingerprint == b.Fingerprint, "Capability is not schema drift"); + Assert(new MssqlExportPreflight(b, null).SnapshotCapability == b.SnapshotCapability, "Preflight exposes capability"); + } + private static void SnapshotRecovery_DurableCreateAndUpdate() + { + string root = Path.Combine(Path.GetTempPath(), "snapshot-recovery-" + Guid.NewGuid().ToString("N")); + try + { + var store = new MssqlSnapshotRecoveryStore(root); + var record = new MssqlSnapshotRecoveryRecord(Guid.NewGuid(), new MssqlSnapshotIdentity("server", "fixture", 3, new DateTime(2026, 1, 1)), 0, MssqlSnapshotRecoveryPhase.Prepared, null, DateTime.UtcNow, MssqlSnapshotRecoveryCode.None); + string path = store.Create(record); + var pending = new MssqlSnapshotRecoveryStore(root).ReadPending(); + Assert(pending.Count == 1 && pending[0].Record.JobId == record.JobId && pending[0].Record.Identity.DatabaseCreateDate == record.Identity.DatabaseCreateDate, "Durable identity roundtrip"); + bool rejected = false; + try { store.Create(record); } catch (IOException) { rejected = true; } + Assert(rejected, "Create never overwrites existing job"); + var update = new MssqlSnapshotRecoveryRecord(record.JobId, record.Identity, 0, MssqlSnapshotRecoveryPhase.Enabled, 1, DateTime.UtcNow, MssqlSnapshotRecoveryCode.None); + store.Update(update); + Assert(store.ReadPending()[0].Record.Phase == MssqlSnapshotRecoveryPhase.Enabled, "Atomic update persists phase"); + store.RemoveConfirmed(update); + Assert(!File.Exists(path), "Matching record removed"); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } + private static void SnapshotRecovery_UntrustedFiles() + { + string root = Path.Combine(Path.GetTempPath(), "snapshot-untrusted-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var store = new MssqlSnapshotRecoveryStore(root); + string path = Path.Combine(root, Guid.NewGuid().ToString("N") + ".json"); + File.WriteAllText(path, "{"); + Assert(store.ReadPending()[0].ReadError == MssqlSnapshotRecoveryReadError.InvalidRecord, "Bad JSON safe category"); + File.WriteAllText(path, new string('x', 65537)); + Assert(store.ReadPending()[0].ReadError == MssqlSnapshotRecoveryReadError.TooLarge, "Bounded read"); + File.WriteAllText(path, "{\"version\":2}"); + Assert(store.ReadPending()[0].ReadError == MssqlSnapshotRecoveryReadError.UnsupportedVersion, "Unknown version"); + File.Delete(path); + var record = RecoveryFixture(); string owned = store.Create(record); + using (var locked = new FileStream(owned, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + Assert(store.ReadPending()[0].ReadError == MssqlSnapshotRecoveryReadError.ReadFailed, "Locked journal yields safe notice"); + File.Move(owned, path); + Assert(store.ReadPending()[0].Record == null, "Filename must match job"); + File.Delete(path); + Directory.CreateDirectory(Path.Combine(root, "nested")); + File.WriteAllText(Path.Combine(root, "nested", "ignored.json"), "{}"); + for (int n = 0; n < 101; n++) File.WriteAllText(Path.Combine(root, Guid.NewGuid().ToString("N") + ".json"), "{}"); + var notices = store.ReadPending(); + Assert(notices.Count == 101 && notices[100].ReadError == MssqlSnapshotRecoveryReadError.Overflow, "Top-level cap plus overflow"); + // A junction is a real reparse path, not a mocked attribute. + string link = root + "-link"; + var start = new System.Diagnostics.ProcessStartInfo("cmd.exe", "/c mklink /J \"" + link + "\" \"" + root + "\"") { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; + using (var process = System.Diagnostics.Process.Start(start)) { process.WaitForExit(); Assert(process.ExitCode == 0, "Create test junction"); } + try + { + Assert(new MssqlSnapshotRecoveryStore(link).ReadPending()[0].ReadError == MssqlSnapshotRecoveryReadError.UnsafePath, "Reparse root rejected"); + bool unsafeWrite = false; + try { new MssqlSnapshotRecoveryStore(Path.Combine(link, "child")).Create(RecoveryFixture()); } catch (IOException) { unsafeWrite = true; } + Assert(unsafeWrite && !Directory.Exists(Path.Combine(root, "child")), "Reparse ancestor prevents writes"); + } + finally { Directory.Delete(link); } + } + finally { Directory.Delete(root, true); } + } + private static MssqlSnapshotRecoveryRecord RecoveryFixture() => new MssqlSnapshotRecoveryRecord(Guid.NewGuid(), new MssqlSnapshotIdentity("server", "fixture", 3, new DateTime(2026, 1, 1)), 0, MssqlSnapshotRecoveryPhase.Prepared, null, DateTime.UtcNow, MssqlSnapshotRecoveryCode.None); + private static void SnapshotRecovery_NoSecretsOrUnsafeDelete() + { + string root = Path.Combine(Path.GetTempPath(), "snapshot-faults-" + Guid.NewGuid().ToString("N")); + try + { + var record = RecoveryFixture(); var store = new MssqlSnapshotRecoveryStore(root); string path = store.Create(record); + string json = File.ReadAllText(path); + Assert(!json.Contains("connectionString") && !json.Contains("password") && !json.Contains("providerMessage"), "Allowlisted metadata only"); + var wrong = new MssqlSnapshotRecoveryRecord(record.JobId, new MssqlSnapshotIdentity("other", "fixture", 3, record.Identity.DatabaseCreateDate), 0, record.Phase, null, record.UpdatedUtc, record.NoticeCode); + bool rejected = false; try { store.RemoveConfirmed(wrong); } catch (IOException) { rejected = true; } + Assert(rejected && File.Exists(path), "Wrong identity cannot delete"); + wrong = new MssqlSnapshotRecoveryRecord(record.JobId, record.Identity, 1, record.Phase, null, record.UpdatedUtc, record.NoticeCode); + rejected = false; try { store.RemoveConfirmed(wrong); } catch (IOException) { rejected = true; } + Assert(rejected && File.Exists(path), "Wrong original state cannot delete"); + foreach (string boundary in new[] { "BeforeFlush", "BeforePublish" }) + { + var failing = new MssqlSnapshotRecoveryStore(root, point => { if (point == boundary) throw new IOException("secret provider text"); }); + string receipt = null; var fresh = RecoveryFixture(); + try { receipt = failing.Create(fresh); } catch (IOException) { } + Assert(receipt == null && !File.Exists(Path.Combine(root, fresh.JobId.ToString("N") + ".json")), "Failed create grants no receipt"); + rejected = false; try { failing.Update(new MssqlSnapshotRecoveryRecord(record.JobId, record.Identity, 0, MssqlSnapshotRecoveryPhase.Enabled, 1, DateTime.UtcNow, record.NoticeCode)); } catch (IOException) { rejected = true; } + Assert(rejected && File.ReadAllText(path) == json, "Failed update retains prior durable journal"); + } + rejected = false; + try { new MssqlSnapshotRecoveryStore(root, point => { throw new IOException(); }).RemoveConfirmed(record); } catch (IOException) { rejected = true; } + Assert(rejected && File.Exists(path), "Delete fault retains journal"); + Assert(Directory.GetFiles(root, "*.tmp").Length == 0, "No temporary residue"); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, true); } + } +} diff --git a/tests/SmokeTests.MssqlExport.SnapshotIntegration.cs b/tests/SmokeTests.MssqlExport.SnapshotIntegration.cs new file mode 100644 index 0000000..5f9b805 --- /dev/null +++ b/tests/SmokeTests.MssqlExport.SnapshotIntegration.cs @@ -0,0 +1,248 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + private sealed class IntegrationSnapshotBackend : IMssqlSnapshotBackend, IMssqlSnapshotDeadlineBackend + { + private readonly MssqlSnapshotBackend inner; + private readonly IntegrationFixture fixture; + internal IntegrationSnapshotBackend(SqlConnection connection, IntegrationFixture fixture) { inner=new MssqlSnapshotBackend(connection); this.fixture=fixture; } + public IMssqlSnapshotDeadline Deadline { set { ((IMssqlSnapshotDeadlineBackend)inner).Deadline=value; } } + public bool IsUsable { get { return inner.IsUsable; } } + public MssqlSnapshotCapability ReadCapability(CancellationToken token) { return inner.ReadCapability(token); } + public bool TryAcquireLock(CancellationToken token) { bool acquired=inner.TryAcquireLock(token); fixture.LockAudit.Add(acquired?"acquired":"busy");return acquired; } + public void SetEnabled(bool enabled,CancellationToken token) { + fixture.BeforeSetting?.Invoke(enabled); + fixture.SettingAudit.Add(enabled?"ON":"OFF"); + inner.SetEnabled(enabled,token); + fixture.AfterSetting?.Invoke(enabled); + } + public void ReleaseLock(CancellationToken token) { inner.ReleaseLock(token); } + } + private static int SnapshotState(IntegrationFixture f,string db=null) + { + using(var c=f.Open("master")) using(var cmd=new SqlCommand("SELECT CONVERT(int,snapshot_isolation_state) FROM sys.databases WHERE name=@db",c) { CommandTimeout=5 }) { + cmd.Parameters.AddWithValue("@db",db??f.Source); return (int)cmd.ExecuteScalar(); + } + } + private static void SnapshotOwnerState(IntegrationFixture f,bool enabled,string db=null) + { + using(var c=f.Open("master")) using(var cmd=new SqlCommand("ALTER DATABASE "+IntegrationFixture.Q(db??f.Source)+" SET ALLOW_SNAPSHOT_ISOLATION "+(enabled?"ON":"OFF"),c) { CommandTimeout=15 }) cmd.ExecuteNonQuery(); + Assert(SnapshotState(f,db)==(enabled?1:0),"Fixture owner state confirmed"); + } + private static void SnapshotPoll(Func predicate,int seconds,string message) + { + var elapsed=Stopwatch.StartNew(); while(!predicate()) { Assert(elapsed.Elapsed.TotalSeconds { if(t.Key.Name=="Parent")parentOpened=true; if(t.Key.Name=="Child") { Assert(parentOpened,"Explicit Parent before Child consumption"); using(var c=f.Open(f.Source))IntegrationFixture.Exec(c,"BEGIN TRAN; INSERT dbo.Parent VALUES(2); UPDATE dbo.Child SET parent=2; COMMIT;"); changed=true; } }; + var request=f.Request("mixed-time-"+snapshot+".sql",false,useSnapshot:snapshot); var result=f.Export(request); f.BeforeRows=null; f.ParentFirst=false; + Assert(changed && result.Stage==MssqlExportStage.Completed,"Controlled legal source write and export complete"); f.RecordArtifact(request.TargetPath); f.Restore(request.TargetPath,snapshot); + if(snapshot) SnapshotExpectedRows(f,1); else { + string log=File.ReadAllText(Directory.GetFiles(f.Root,Path.GetFileName(request.TargetPath)+".restore-*.log").Single()); + Assert(log.Contains("FK_Snapshot_Child_Parent") && log.Contains("547"),"Ordinary actual restore specifically fails trusted FK validation"); + using(var c=f.Open(f.Target))Assert(Convert.ToInt32(IntegrationFixture.Scalar(c,"SELECT COUNT(*) FROM dbo.Child c LEFT JOIN dbo.Parent p ON p.id=c.parent WHERE p.id IS NULL"))==1,"Actual restored mixed-time missing parent"); + } + f.Evidence["comparison-"+Path.GetFileName(request.TargetPath)]=snapshot?"Target Parent1/Child1 vs changed source Parent1,2/Child2; trusted FK":"Target Parent1/Child2; sqlcmd FK547 expected"; f.Pass("Controlled mixed-time counterexample Snapshot="+snapshot); + } + foreach(string fault in new[]{"cancel","ddl"}) using(var cancel=new CancellationTokenSource()) { + string path=Path.Combine(f.Root,"ordinary-"+fault+".sql"); File.WriteAllText(path,"original");var request=f.Request(Path.GetFileName(path),false,useSnapshot:false); + f.AfterBegin=()=> { if(fault=="cancel")cancel.Cancel(); else using(var c=f.Open(f.Source))IntegrationFixture.Exec(c,"ALTER TABLE dbo.Child ADD drift int NULL;"); }; + var result=f.Export(request,cancel.Token);f.AfterBegin=null; + Assert(result.Stage==(fault=="cancel"?MssqlExportStage.Canceled:MssqlExportStage.Failed)&&File.ReadAllText(path)=="original","Ordinary cancellation/drift protects old file");f.Pass("Ordinary "+fault+" old-file protection"); + } + } + private static void IntegrationSnapshotLifecycle(IntegrationFixture f) + { + Assert(SnapshotState(f)==0,"Lifecycle genuine initial OFF"); f.Setup("snapshot.sql"); + SnapshotCooperation(f); SnapshotCrash(f); SnapshotRevoke(f); + foreach(bool initial in new[]{false,true}) foreach(string outcome in new[]{"complete","cancel","read-failure"}) using(var cancel=new CancellationTokenSource()) { + SnapshotOwnerState(f,initial); var request=f.Request("lifecycle-"+initial+"-"+outcome+".sql",false); + f.AfterBegin=()=> { Assert(SnapshotState(f)==1,"Actual ON before reads"); if(outcome=="cancel")cancel.Cancel(); }; + f.BeforeRows=outcome=="read-failure"?(Action)(_=> {throw new IOException("Injected read failure");}):null; + var result=f.Export(request,cancel.Token);f.AfterBegin=null;f.BeforeRows=null; + Assert(result.Stage==(outcome=="complete"?MssqlExportStage.Completed:outcome=="cancel"?MssqlExportStage.Canceled:MssqlExportStage.Failed),"Lifecycle primary outcome"); + Assert(SnapshotState(f)==(initial?1:0) && result.SnapshotCleanup.State==(initial?MssqlSnapshotCleanupState.PreservedOn:MssqlSnapshotCleanupState.RestoredOff),"Actual restored/preserved setting"); + if(outcome=="complete") { SnapshotResetTarget(f); f.RecordArtifact(request.TargetPath);f.Restore(request.TargetPath);SnapshotExpectedRows(f,1); } + f.Pass("Lifecycle "+initial+" "+outcome); + } + SnapshotOwnerState(f,false); + foreach(string action in new[]{"release","cancel","timeout"}) SnapshotBlockedOn(f,action); + SnapshotBlockedOff(f); + SnapshotOwnerState(f,false); + foreach(string fault in new[]{"ack","disconnect","journal"}) { + var request=f.Request("fault-"+fault+".sql",false); int count=f.SettingAudit.Count; + if(fault=="ack")f.AfterSetting=enabled=> {if(enabled) {Assert(SnapshotState(f)==1,"Real ON before injected lost acknowledgement");throw new IOException("Injected acknowledgement failure");}}; + if(fault=="disconnect")f.AfterBegin=()=>f.WorkerConnections.Last().Close(); + if(fault=="journal") { f.JournalOverride=Path.Combine(f.Root,"journal-obstruction");File.WriteAllText(f.JournalOverride,"owned obstruction"); } + var result=f.Export(request);f.AfterSetting=null;f.AfterBegin=null;f.JournalOverride=null; + Assert(result.Stage==MssqlExportStage.Failed,"Injected failure retained "+fault); + if(fault=="disconnect") {Assert(result.SnapshotCleanup.State==MssqlSnapshotCleanupState.Unconfirmed && SnapshotState(f)==1 && File.Exists(result.SnapshotCleanup.RecoveryRecordPath),"Own exporter SqlConnection.Close retains unconfirmed ON and journal");SnapshotOwnerState(f,false);} + else Assert(SnapshotState(f)==0,"Failed acknowledgement/journal final OFF"); + if(fault=="journal")Assert(f.SettingAudit.Count==count,"Journal IO failure sends no ALTER"); + f.Pass("Fault injection "+fault); + } + + } + private static void SnapshotBlockedOn(IntegrationFixture f,string action) + { + SnapshotOwnerState(f,false); var request=f.Request("blocked-on-"+action+".sql",false); + using(var blocker=f.Open(f.Source))using(var transaction=blocker.BeginTransaction())using(var cancel=new CancellationTokenSource()) { + using(var cmd=new SqlCommand("UPDATE dbo.Child SET parent=parent",blocker,transaction){CommandTimeout=5})cmd.ExecuteNonQuery(); + var clock=Stopwatch.StartNew(); var work=Task.Run(()=>f.Export(request,cancel.Token));bool released=false; + try { + SnapshotPoll(()=>SnapshotState(f)==3,15,"Actual ON transition observed"); f.Evidence["on-transition-"+action]=3; + if(action=="release") {transaction.Rollback();released=true;} else if(action=="cancel")cancel.Cancel(); + Assert(work.Wait(TimeSpan.FromSeconds(action=="timeout"?105:45)),"Bounded exporter ON wait");var result=work.Result; + if(action=="release") Assert(result.Stage==MssqlExportStage.Completed&&SnapshotState(f)==0,"Writer release completes ON/read/OFF"); + else Assert(result.Stage==(action=="cancel"?MssqlExportStage.Canceled:MssqlExportStage.Failed),"Wait primary failure/cancel"); + if(action=="timeout")Assert(clock.Elapsed.TotalSeconds>=59 && clock.Elapsed.TotalSeconds<105,"Real sixty-second enable deadline"); + f.Evidence["on-wait-"+action]=new { seconds=clock.Elapsed.TotalSeconds,state=SnapshotState(f),cleanup=result.SnapshotCleanup };f.Save(); + } finally { if(!released)transaction.Rollback();cancel.Cancel();Assert(work.Wait(TimeSpan.FromSeconds(40)),"Wait ends after own blocker release"); } + } + SnapshotPoll(()=>SnapshotState(f)!=3 && SnapshotState(f)!=2,15,"Transition settles after owner writer release"); + if(SnapshotState(f)==1)SnapshotOwnerState(f,false);f.Pass("Real blocked ON "+action); + } + private static void SnapshotBlockedOff(IntegrationFixture f) + { + SnapshotOwnerState(f,false); var request=f.Request("blocked-off.sql",false);SqlConnection external=null;SqlTransaction transaction=null; + f.AfterBegin=()=> { external=f.Open(f.Source);transaction=external.BeginTransaction(IsolationLevel.Snapshot);using(var cmd=new SqlCommand("SELECT COUNT(*) FROM dbo.Parent",external,transaction){CommandTimeout=5})cmd.ExecuteScalar(); }; + var clock=Stopwatch.StartNew();var work=Task.Run(()=>f.Export(request)); + try { + SnapshotPoll(()=>SnapshotState(f)==2,20,"Actual OFF transition observed with external snapshot reader"); + Assert(work.Wait(TimeSpan.FromSeconds(45)),"Independent thirty-second cleanup deadline");var result=work.Result; + Assert(clock.Elapsed.TotalSeconds>=29 && result.Stage==MssqlExportStage.Completed && result.SnapshotCleanup.State==MssqlSnapshotCleanupState.Unconfirmed && File.Exists(result.SnapshotCleanup.RecoveryRecordPath),"Published file plus bounded unconfirmed cleanup and durable notice"); + f.Evidence["off-wait"]=new { seconds=clock.Elapsed.TotalSeconds,state=SnapshotState(f),cleanup=result.SnapshotCleanup }; f.RecordArtifact(request.TargetPath); + SnapshotResetTarget(f);f.Restore(request.TargetPath);SnapshotExpectedRows(f,1); + } finally { transaction?.Dispose();external?.Dispose();f.AfterBegin=null;Assert(work.Wait(TimeSpan.FromSeconds(40)),"Own external reader released"); } + SnapshotPoll(()=>SnapshotState(f)==0 || SnapshotState(f)==1,15,"State settles after external reader release"); f.Evidence["after-external-reader-release"]=SnapshotState(f); f.Save(); + if(SnapshotState(f)==1)SnapshotOwnerState(f,false); f.Evidence["off-wait-owner-teardown"]="Owner readback and explicit OFF after canceled ALTER; not product recovery"; f.Pass("Real thirty-second OFF wait; external reader released only by fixture owner"); + } + private static void SnapshotCooperation(IntegrationFixture f) + { + SnapshotOwnerState(f,false); SnapshotResetTarget(f);using(var c=f.Open(f.Target))IntegrationFixture.Exec(c,"CREATE TABLE dbo.Parent(id int PRIMARY KEY);INSERT dbo.Parent VALUES(1);"); + var request=f.Request("lock-first.sql",false);using(var first=f.NewSource()) { + first.BeginRead(request,null,CancellationToken.None); + var busy=f.Export(f.Request("lock-busy.sql",false));Assert(busy.Stage==MssqlExportStage.Failed && f.LockAudit.Last()=="busy" && SnapshotState(f)==1,"Same-database cooperative lock refuses competing exporter"); + f.SourceOverride=f.Target; + try { + using(var other=f.NewSource()) { + var catalog=other.ReadCatalog(f.Target,CancellationToken.None);string path=Path.Combine(f.Root,"cross-db.sql"); + var independent=new MssqlExportRequest(Guid.NewGuid(),f.Target,catalog.Tables.Select(t=>t.Key).ToArray(),MssqlExportMode.SchemaAndData,false,path,MssqlExportArtifact.CaptureDestination(path,CancellationToken.None),null,true); + f.Evidence["cross-db-initial"]=catalog.SnapshotState; + var schema=other.BeginRead(independent,null,CancellationToken.None); + Assert(SnapshotState(f)==1 && SnapshotState(f,f.Target)==1,"Both database lifecycles acquired independently while first lock held"); + other.CompleteRead(schema,CancellationToken.None); + // SQL version retention can span databases. End both read transactions before OFF cleanup. + Assert(first.FinishRead(null).State==MssqlSnapshotCleanupState.RestoredOff,"First read ends before independent target OFF"); + var cleanup=other.FinishRead(null);f.Evidence["cross-db-cleanup"]=cleanup;f.Evidence["cross-db-final"]=SnapshotState(f,f.Target);f.Save(); Assert(cleanup.State==MssqlSnapshotCleanupState.RestoredOff,"Already-owned Target independently manages snapshot: "+cleanup.State+"/"+cleanup.WarningCode); + } + } finally {f.SourceOverride=null;} + Assert(first.FinishRead(null).State==MssqlSnapshotCleanupState.RestoredOff,"First cooperation owner restores source"); + } + f.Pass("Same database busy and already-owned Target cross-database independence"); + } + private static void SnapshotRevoke(IntegrationFixture f) + { + using(var c=f.Open(f.Source))IntegrationFixture.Exec(c,"CREATE USER fixture_reader WITHOUT LOGIN;GRANT CONNECT,SELECT,VIEW DEFINITION,ALTER TO fixture_reader;GRANT SELECT ON OBJECT::sys.sql_expression_dependencies TO fixture_reader;"); + f.RestrictedReader=true;var request=f.Request("revoke.sql",false); + f.BeforeSetting=enabled=> {if(enabled)using(var c=f.Open(f.Source))IntegrationFixture.Exec(c,"REVOKE ALTER FROM fixture_reader;");}; + try {var result=f.Export(request);Assert(result.Stage==MssqlExportStage.Failed && SnapshotState(f)==0,"Real ALTER revocation after capability capture blocks enable");} + finally {f.BeforeSetting=null;f.RestrictedReader=false;} + f.Pass("Real ALTER revoke between capability capture and dispatch"); + } + // A narrow mode in this same test executable; no fixture creation or teardown in the child. + private static int SnapshotCrashChild(string[] args) + { + try { + Assert(args.Length==3 && args[0]=="--snapshot-crash-child","Exact child arguments"); + Guid owner,nonce; Assert(Guid.TryParseExact(args[1],"N",out owner)&&Guid.TryParseExact(args[2],"N",out nonce),"Child token and nonce format"); + string server=Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_SERVER"); + Assert(server==@"(localdb)\mySQLPunkExportTest_20261012","Child authorized instance only"); + var auth=Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_AUTH"))); + Assert((bool)auth["CreatedByTask"] && (string)auth["Instance"]=="mySQLPunkExportTest_20261012","Child authorization manifest"); + string root=Path.GetFullPath(Environment.GetEnvironmentVariable("MYSQLPUNK_MSSQL_INTEGRATION_OUTPUT")); + string authorized=Path.GetFullPath((string)auth["FixtureRoot"]).TrimEnd('\\')+"\\"; + Assert(root.StartsWith(@"C:\",StringComparison.OrdinalIgnoreCase)&&root.StartsWith(authorized,StringComparison.OrdinalIgnoreCase),"Child root belongs to authorized C fixture"); + for(var path=new DirectoryInfo(root);path!=null;path=path.Parent)Assert((path.Attributes&FileAttributes.ReparsePoint)==0,"Child has no reparse ancestor"); + Assert(File.ReadAllText(Path.Combine(root,"owner.token"))==args[1],"Child owner token exact"); + string db="mysqlpunk_export_"+args[1]+"_src"; + var provider=new mySQLPunk.lib.my_mssql();provider.SetConn(new SqlConnectionStringBuilder {DataSource=server,InitialCatalog=db,IntegratedSecurity=true,Pooling=false,TrustServerCertificate=true,ApplicationName="mySQLPunk-owned-crash-child"}.ConnectionString);provider.Open(); + var store=new MssqlSnapshotRecoveryStore(Path.Combine(root,"journal")); + using(var source=new MssqlExportSource(new MssqlOwnedSession(provider,provider.MCT),store)) { + Assert(Convert.ToString(IntegrationFixture.Scalar(provider.MCT,"SELECT value FROM sys.extended_properties WHERE class=0 AND name=N'mysqlpunk_export_owner'"))==args[1],"Child database token exact"); + using(var cmd=new SqlCommand("SELECT physical_name FROM sys.database_files",provider.MCT){CommandTimeout=5})using(var reader=cmd.ExecuteReader())while(reader.Read())Assert(Path.GetDirectoryName(Path.GetFullPath(reader.GetString(0)))==root,"Child database physical files inside owned root"); + var catalog=source.ReadCatalog(db,CancellationToken.None);Assert(catalog.SnapshotState==0,"Crash starts OFF"); + string target=Path.Combine(root,"crash-child.sql");Guid job=Guid.NewGuid(); + var request=new MssqlExportRequest(job,db,catalog.Tables.Select(t=>t.Key).ToArray(),MssqlExportMode.SchemaAndData,false,target,MssqlExportArtifact.CaptureDestination(target,CancellationToken.None),null,true); + try { + source.BeginRead(request,null,CancellationToken.None); + Assert(store.ReadPending().Any(n=>n.Record!=null&&n.Record.JobId==job),"Durable journal precedes ready"); + // Read state through a distinct owned observer because source owns a transaction. + using(var observer=new SqlConnection(new SqlConnectionStringBuilder{DataSource=server,InitialCatalog=db,IntegratedSecurity=true,Pooling=false,TrustServerCertificate=true}.ConnectionString)) {observer.Open();Assert(Convert.ToInt32(IntegrationFixture.Scalar(observer,"SELECT snapshot_isolation_state FROM sys.databases WHERE database_id=DB_ID()"))==1,"Actual ON precedes ready");} + string ready=Path.Combine(root,"crash-ready-"+args[2]+".json");File.WriteAllText(ready+".tmp",Newtonsoft.Json.JsonConvert.SerializeObject(new {nonce=args[2],job=job.ToString(),pid=Process.GetCurrentProcess().Id}));File.Move(ready+".tmp",ready); + Thread.Sleep(TimeSpan.FromSeconds(120)); + } finally {source.FinishRead(null);} + } + return 2; // Normal completion is a failed crash test, even though its finally safely restores. + } catch(Exception ex) {Console.Error.WriteLine("Crash child validation/work failed: "+ex.GetType().Name);return 3;} + } + private static void SnapshotCrash(IntegrationFixture f) + { + SnapshotOwnerState(f,false);string nonce=Guid.NewGuid().ToString("N"),ready=Path.Combine(f.Root,"crash-ready-"+nonce+".json"); + var start=new ProcessStartInfo(System.Reflection.Assembly.GetExecutingAssembly().Location,"--snapshot-crash-child "+f.Token+" "+nonce) {UseShellExecute=false,CreateNoWindow=true,WindowStyle=ProcessWindowStyle.Hidden,RedirectStandardOutput=true,RedirectStandardError=true}; + using(var child=Process.Start(start)) { + int ownPid=child.Id;var stdout=child.StandardOutput.ReadToEndAsync();var stderr=child.StandardError.ReadToEndAsync(); + try { + SnapshotPoll(()=>File.Exists(ready)||child.HasExited,30,"Crash child ready bounded");Assert(File.Exists(ready)&&!child.HasExited,"Child reached durable ready"); + var receipt=Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(ready));Assert((string)receipt["nonce"]==nonce&&(int)receipt["pid"]==ownPid,"Ready belongs to this exact process"); + Guid job=Guid.Parse((string)receipt["job"]);Assert(SnapshotState(f)==1,"Parent sees actual ON before terminating own child"); + child.Kill();Assert(child.WaitForExit(10000),"Own child reaped"); + var store=new MssqlSnapshotRecoveryStore(Path.Combine(f.Root,"journal"));Assert(store.ReadPending().Any(n=>n.Record!=null&&n.Record.JobId==job),"Crash journal remains"); + var identity=f.Service().LoadCatalog(f.Source,CancellationToken.None).SnapshotCapability.Identity; + Assert(f.Service().LoadRecoveryNotices(identity).Any(n=>n.Record!=null&&n.Record.JobId==job)&&SnapshotState(f)==1,"Next read-only notice does not repair ON"); + f.Evidence["crash"]=new { ownPid,job,stateAfterKill=SnapshotState(f),journal="retained",notice="read-only",repair="fixture owner teardown only" };f.Save(); + } finally {if(!child.HasExited){child.Kill();child.WaitForExit(10000);}File.WriteAllText(Path.Combine(f.Root,"crash-child.log"),stdout.Result+stderr.Result);} + } + SnapshotOwnerState(f,false);f.Pass("Real own-child termination leaves ON and durable read-only notice; fixture owner restores"); + } +} diff --git a/tests/SmokeTests.MssqlExport.ViewReferences.cs b/tests/SmokeTests.MssqlExport.ViewReferences.cs new file mode 100644 index 0000000..4e39bc7 --- /dev/null +++ b/tests/SmokeTests.MssqlExport.ViewReferences.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using mySQLPunk.lib.MssqlExport; + +public static partial class SmokeTests +{ + private static MssqlViewDefinition Reference_View(string definition, bool quotedIdentifier = true) + { + return new MssqlViewDefinition(new MssqlObjectKey(90, "dbo", "v"), SchemaFacts("definition", definition, + "uses_ansi_nulls", true, "uses_quoted_identifier", quotedIdentifier)); + } + + private static MssqlViewReferenceResolution Reference_Local(string database, string schema, string name) + { + return new MssqlViewReferenceResolution(7, 7, name == "U" ? 12 : 11, "U"); + } + + private static void Reference_RequireFailure(MssqlViewRewriteResult result, string reason) + { + Assert(!result.Success && result.Sql == null && result.ReferencedIds.Count == 0, reason + ": no partially prepared SQL or IDs"); + Assert(result.Issue != null && result.Issue.Code == "UnsupportedViewReference" && result.Issue.ObjectKey.ObjectId == 90, + reason + ": named safe view issue"); + } + + private static void Reference_RewritesTokens() + { + const string sql = "/* prefix \ud83d\ude80 */ CREATE VIEW dbo.old AS WITH c AS (SELECT t.id, N'Transfer.dbo.T' AS txt FROM [Transfer]/* keep . */.[dbo].[T] AS t)\r\nSELECT c.id FROM c JOIN Transfer-- keep line\r\n.dbo.U AS u ON c.id=u.id WHERE EXISTS (SELECT 1 FROM Transfer.dbo.T AS t WHERE t.id=c.id); -- Transfer.dbo.T"; + const string expected = "/* prefix \ud83d\ude80 */ CREATE VIEW [dbo].[v] AS WITH c AS (SELECT t.id, N'Transfer.dbo.T' AS txt FROM /* keep . */[dbo].[T] AS t)\r\nSELECT c.id FROM c JOIN -- keep line\r\ndbo.U AS u ON c.id=u.id WHERE EXISTS (SELECT 1 FROM dbo.T AS t WHERE t.id=c.id); -- Transfer.dbo.T"; + var result = MssqlViewReferenceRewriter.Rewrite(Reference_View(sql), Reference_Local, new int?[] { 12, 11, 11 }, new[] { 11, 12 }); + Assert(result.Success, "Qualified named table references must become target-local: " + result.Issue?.Message); + Assert(result.Sql == expected, "Only approved qualifier/declaration spans may change; CTEs, aliases, strings, nested references and comments survive"); + Assert(result.ReferencedIds.SequenceEqual(new[] { 11, 12 }), "Catalog normalized IDs cover repeated and nested references without duplicates"); + } + + private static void Reference_QuotedNamesAndHeaders() + { + var calls = new List(); + Func resolve = (database, schema, name) => + { + calls.Add(database + "|" + schema + "|" + name); + return new MssqlViewReferenceResolution(8, 8, 11, "V"); + }; + const string quoted = "-- CREATE VIEW untouched\r\nALTER/* header */ VIEW [old].[name] AS SELECT t.id FROM [Tran]]s.fer].[d]]bo].[T.name] t JOIN \"Tran\"\"s.fer\" . \"dbo\" . \"T\"\"name\" x ON t.id=x.id"; + const string expected = "-- CREATE VIEW untouched\r\nCREATE/* header */ VIEW [dbo].[v] AS SELECT t.id FROM [d]]bo].[T.name] t JOIN \"dbo\" . \"T\"\"name\" x ON t.id=x.id"; + var result = MssqlViewReferenceRewriter.Rewrite(Reference_View(quoted), resolve, new int?[] { 11 }, new[] { 11 }); + Assert(result.Success && result.Sql == expected, "Delimited identifiers are decoded for resolution and otherwise preserve original quoting"); + Assert(calls.SequenceEqual(new[] { "Tran]s.fer|d]bo|T.name", "Tran\"s.fer|dbo|T\"name" }), "Pass decoded exact identifier parts to SQL Server, without case normalization or splitting embedded dots"); + const string alternate = "/*ALTER VIEW*/ CREATE/*a*/ OR/*b*/ ALTER/*c*/ VIEW dbo.old AS SELECT t.id FROM tRaNsFeR.dbo.T t"; + var changed = MssqlViewReferenceRewriter.Rewrite(Reference_View(alternate), Reference_Local, new int?[] { 11 }, new[] { 11 }); + Assert(changed.Success && changed.Sql == "/*ALTER VIEW*/ CREATE/*a*/ /*b*/ /*c*/ VIEW [dbo].[v] AS SELECT t.id FROM dbo.T t", "Normalize executable CREATE OR ALTER tokens, preserving interleaved comments and server-approved spelling"); + var off = MssqlViewReferenceRewriter.Rewrite(Reference_View("CREATE VIEW dbo.v AS SELECT \"Transfer.dbo.T\" AS txt FROM Transfer.dbo.T", false), Reference_Local, new int?[] { 11 }, new[] { 11 }); + Assert(off.Success && off.Sql == "CREATE VIEW [dbo].[v] AS SELECT \"Transfer.dbo.T\" AS txt FROM dbo.T", "QUOTED_IDENTIFIER OFF double-quoted literal stays literal"); + } + + private static void Reference_RejectsUnsupportedContexts() + { + foreach (string body in new[] { + "SELECT * FROM Transfer..T", + "SELECT * FROM server.Transfer.dbo.T", + "SELECT Transfer.dbo.T.id FROM Transfer.dbo.T", + "SELECT Transfer.dbo.T.* FROM Transfer.dbo.T", + "SELECT Transfer.dbo.f(t.id) FROM Transfer.dbo.T t", + "SELECT NEXT VALUE FOR Transfer.dbo.seq FROM Transfer.dbo.T", + "SELECT * FROM Transfer.dbo.T t CROSS APPLY Transfer.dbo.f(t.id) x", + "SELECT * FROM Transfer.dbo.T t CROSS JOIN OPENQUERY(remote, 'SELECT id FROM dbo.T') x", + "SELECT * FROM Transfer.dbo.T t CROSS JOIN OPENROWSET('SQLNCLI', 'Server=remote;Trusted_Connection=yes;', 'SELECT id FROM dbo.T') x", + "SELECT * FROM Transfer.dbo.T t CROSS JOIN OPENDATASOURCE('SQLNCLI', 'Data Source=remote;Integrated Security=SSPI').db.dbo.T x", + "SELECT t.shape.STArea() FROM Transfer.dbo.T t" }) + { + var result = MssqlViewReferenceRewriter.Rewrite(Reference_View("CREATE VIEW dbo.v AS " + body), Reference_Local, new int?[] { 11 }, new[] { 11 }); + Reference_RequireFailure(result, "Unsupported multipart or external reference must fail safely: " + body); + } + var localColumn = MssqlViewReferenceRewriter.Rewrite(Reference_View("CREATE VIEW dbo.v AS SELECT dbo.T.id FROM Transfer.dbo.T"), Reference_Local, new int?[] { 11 }, new[] { 11 }); + Assert(localColumn.Success && localColumn.Sql == "CREATE VIEW [dbo].[v] AS SELECT dbo.T.id FROM dbo.T", "Three-part column name is schema.table.column and must not be rewritten as database.schema.table"); + } + + private static void Reference_RequiresCatalogIdentity() + { + var view = Reference_View("CREATE VIEW dbo.v AS SELECT * FROM Transfer.dbo.T"); + foreach (var identity in new[] { + new MssqlViewReferenceResolution(7, 8, 11, "U"), + new MssqlViewReferenceResolution(7, null, 11, "U"), + new MssqlViewReferenceResolution(null, null, 11, "U"), + new MssqlViewReferenceResolution(0, 0, 11, "U"), + new MssqlViewReferenceResolution(7, 7, null, "U"), + new MssqlViewReferenceResolution(7, 7, 0, "U"), + new MssqlViewReferenceResolution(7, 7, 11, "SN"), + new MssqlViewReferenceResolution(7, 7, 11, "FN"), + new MssqlViewReferenceResolution(7, 7, 12, "U"), + null }) + Reference_RequireFailure(MssqlViewReferenceRewriter.Rewrite(view, (d, s, n) => identity, new int?[] { 11 }, new[] { 11 }), "Unknown, external, missing, unsupported or unselected identity"); + foreach (var ids in new[] { new int?[] { }, new int?[] { null }, new int?[] { 12 }, new int?[] { 11, 12 } }) + Reference_RequireFailure(MssqlViewReferenceRewriter.Rewrite(view, Reference_Local, ids, new[] { 11, 12 }), "Catalog-to-AST coverage must agree in both directions"); + var secondSource = Reference_View("CREATE VIEW dbo.v AS SELECT t.id FROM Transfer.dbo.T t JOIN DifferentSpelling.dbo.U u ON t.id=u.id"); + Reference_RequireFailure(MssqlViewReferenceRewriter.Rewrite(secondSource, (d, s, n) => new MssqlViewReferenceResolution(n == "T" ? 7 : 8, n == "T" ? 7 : 8, n == "T" ? 11 : 12, "U"), new int?[] { 11, 12 }, new[] { 11, 12 }), "Every occurrence must resolve against the same source database"); + bool called = false; + var longName = Reference_View("CREATE VIEW dbo.v AS SELECT * FROM [" + new string('a', 129) + "].dbo.T"); + Reference_RequireFailure(MssqlViewReferenceRewriter.Rewrite(longName, (d, s, n) => { called = true; return Reference_Local(d, s, n); }, new int?[] { 11 }, new[] { 11 }), "Oversized identifiers cannot be truncated by SQL parameters"); + Assert(!called, "Reject oversized identifiers before invoking the catalog resolver"); + var error = new OperationCanceledException("private diagnostic sentinel"); + try { MssqlViewReferenceRewriter.Rewrite(view, (d, s, n) => { throw error; }, new int?[] { 11 }, new[] { 11 }); throw new Exception("Expected resolver cancellation"); } + catch (OperationCanceledException actual) { Assert(object.ReferenceEquals(actual, error), "Catalog cancellation retains ownership and is not converted into an unsafe partial success"); } + } + + private static void Reference_RejectsInvalidModules() + { + foreach (string sql in new[] { + "", "CREATE VIEW dbo.v AS SELECT FROM Transfer.dbo.T", + "CREATE VIEW dbo.v AS SELECT * FROM Transfer.dbo.T;\r\nGO\r\nSELECT 1", + "CREATE VIEW dbo.v AS SELECT * FROM Transfer.dbo.T;\r\nGO", + "SELECT * FROM Transfer.dbo.T", "/* private sentinel */ CREATE VIEW dbo.v AS SELECT 'unterminated FROM Transfer.dbo.T" }) + { + var result = MssqlViewReferenceRewriter.Rewrite(Reference_View(sql), Reference_Local, new int?[] { 11 }, new[] { 11 }); + Reference_RequireFailure(result, "Invalid or multi-batch module is not a view rewrite candidate"); + AssertNotContains(result.Issue.Message, "private sentinel", "Parser diagnostics must not copy source text"); + } + } + + private static MssqlExportSchema Reference_Schema(IEnumerable dependencies = null, string viewText = null) + { + var table = new MssqlTableDefinition(new MssqlObjectKey(11, "dbo", "T"), SchemaFacts(), + new[] { new MssqlColumnDefinition(SchemaFacts("column_id", 1, "name", "id", "type_name", "int")) }, null, null); + var first = new MssqlViewDefinition(new MssqlObjectKey(91, "dbo", "first"), SchemaFacts("definition", viewText ?? "CREATE VIEW dbo.first AS SELECT * FROM Transfer.dbo.T", "uses_ansi_nulls", true, "uses_quoted_identifier", true)); + var last = new MssqlViewDefinition(new MssqlObjectKey(90, "dbo", "last"), SchemaFacts("definition", "ALTER VIEW dbo.last AS SELECT * FROM Transfer.dbo.first", "uses_ansi_nulls", true, "uses_quoted_identifier", true)); + return new MssqlExportSchema(new[] { table }, new[] { last, first }, dependencies ?? new[] { + new MssqlDependency(91, null, null, "Transfer", "dbo", "T", 7, 7, 11), + new MssqlDependency(90, null, null, "Transfer", "dbo", "first", 7, 7, 91) }, 1, null); + } + + private static MssqlViewReferenceResolution Reference_ResolveSchema(string database, string schema, string name) + { return new MssqlViewReferenceResolution(7, 7, name == "first" ? 91 : name == "last" ? 90 : 11, name == "T" ? "U" : "V"); } + + private static void Reference_IntegratesPreparationAndOrdering() + { + var raw = Reference_Schema(); + var request = SchemaRequest(raw); + var prepared = MssqlExportMetadataReader.PrepareViewReferences(raw, request, Reference_ResolveSchema); + var issues = new MssqlExportMetadataReader().Validate(prepared, request); + Assert(issues.Count == 0, "Catalog-proven same-source view dependencies must pass after preparation: " + string.Join("; ", issues.Select(x => x.Code))); + Assert(prepared.Views.All(v => v.HasPreparedReferences) && prepared.Views.Single(v => v.Key.ObjectId == 91).Definition == raw.Views.Single(v => v.Key.ObjectId == 91).Definition, + "Preparation retains original source definitions separately from target-local SQL"); + Assert(prepared.Dependencies.All(d => d.ReferencedId == null && d.EffectiveReferencedId.HasValue), "Keep raw nullable IDs while authorizing effective local IDs"); + var output = new StringWriter(); + new MssqlSchemaScriptWriter().WriteAfterData(prepared, request, output, CancellationToken.None); + string sql = output.ToString(); + AssertNotContains(sql, "Transfer.dbo", "Writer must consume prepared view bodies"); + Assert(sql.IndexOf("CREATE VIEW [dbo].[first]", StringComparison.Ordinal) < sql.IndexOf("CREATE VIEW [dbo].[last]", StringComparison.Ordinal), "Normalized IDs order views even when object IDs suggest the reverse"); + Assert(raw.Dependencies.All(d => !d.EffectiveReferencedId.HasValue), "Preparation does not mutate original dependency objects"); + var cycleRaw = Reference_Schema(new[] { + new MssqlDependency(91, null, null, "Transfer", "dbo", "last", 7, 7, 90), + new MssqlDependency(90, null, null, "Transfer", "dbo", "first", 7, 7, 91) }, "CREATE VIEW dbo.first AS SELECT * FROM Transfer.dbo.last"); + var cycle = MssqlExportMetadataReader.PrepareViewReferences(cycleRaw, SchemaRequest(cycleRaw), Reference_ResolveSchema); + Assert(new MssqlExportMetadataReader().Validate(cycle, SchemaRequest(cycle)).Any(x => x.Code == "DependencyCycle"), "Catalog-normalized view cycles remain rejected"); + } + + private static void Reference_IntegrationGatesAndFingerprint() + { + var original = Reference_Schema(); + var request = SchemaRequest(original); + foreach (var bad in new[] { + new MssqlDependency(91, null, null, "Other", "dbo", "T", 7, 8, 11), + new MssqlDependency(91, null, "local", "Transfer", "dbo", "T", 7, 7, 11), + new MssqlDependency(91, null, null, "Transfer", null, "T", 7, 7, 11), + new MssqlDependency(91, null, null, "Transfer", "dbo", "T", 7, 7, 999), + new MssqlDependency(91, 999, null, "Transfer", "dbo", "T", 7, 7, 11), + new MssqlDependency(91, null, null, "Transfer", "dbo", "T", 7, 7, 11, isAmbiguous: true), + new MssqlDependency(91, null, null, "Transfer", "dbo", "T", 7, 7, 11, isCallerDependent: true), + new MssqlDependency(11, null, null, "Transfer", "dbo", "T", 7, 7, 11, preparedSameDatabaseReference: true) }) + { + var candidate = Reference_Schema(new[] { bad }); + var result = MssqlExportMetadataReader.PrepareViewReferences(candidate, SchemaRequest(candidate), Reference_ResolveSchema); + Assert(new MssqlExportMetadataReader().Validate(result, SchemaRequest(result)).Any(x => x.Code == "MissingDependency"), "Invalid identity, flags, selected scope and table expressions must remain blocked"); + AssertThrows(() => new MssqlSchemaScriptWriter().WriteBeforeData(result, SchemaRequest(result), new StringWriter(), CancellationToken.None), "No DDL can precede qualified-reference failure"); + } + Assert(new MssqlExportMetadataReader().Validate(original, request).Any(x => x.Code == "MissingDependency"), "Identity resolution alone never authorizes unrevised source-qualified definitions"); + var prepared = MssqlExportMetadataReader.PrepareViewReferences(original, request, Reference_ResolveSchema); + var reversed = new MssqlExportSchema(prepared.Tables, prepared.Views, prepared.Dependencies.Reverse(), 1, null); + Assert(prepared.Fingerprint == reversed.Fingerprint, "Dependency catalog order must not create schema drift"); + var changedText = Reference_Schema(viewText: "CREATE VIEW dbo.first AS SELECT * FROM Transfer.dbo.T -- changed source"); + Assert(prepared.Fingerprint != MssqlExportMetadataReader.PrepareViewReferences(changedText, SchemaRequest(changedText), Reference_ResolveSchema).Fingerprint, "Original source text remains part of schema drift detection"); + var changedIdentity = Reference_Schema(new[] { new MssqlDependency(91, null, null, "Transfer", "dbo", "T", 7, 8, 11), original.Dependencies[1] }); + Assert(original.Fingerprint != changedIdentity.Fingerprint, "Resolved database identity participates in schema fingerprint"); + var dataOnly = SchemaRequest(original, MssqlExportMode.DataOnly); + var untouched = MssqlExportMetadataReader.PrepareViewReferences(original, dataOnly, (d, s, n) => { throw new Exception("Data-only must not resolve view references"); }); + Assert(object.ReferenceEquals(original, untouched), "Data-only retains the existing schema behavior without view rewriting"); + } +} diff --git a/tests/SmokeTests.MssqlExport.Wizard.cs b/tests/SmokeTests.MssqlExport.Wizard.cs index 524ca97..020f86b 100644 --- a/tests/SmokeTests.MssqlExport.Wizard.cs +++ b/tests/SmokeTests.MssqlExport.Wizard.cs @@ -10,6 +10,212 @@ public static partial class SmokeTests { + private static MssqlSnapshotIdentity WIdentity() => new MssqlSnapshotIdentity("fixture-server", "fixture_db", 7, new DateTime(2020, 1, 1)); + private static void SnapshotWizard_DefaultDisableAndRadioGroups() + { + foreach (var capability in new[] { new MssqlSnapshotCapability(WIdentity(), 0, true, true), new MssqlSnapshotCapability(WIdentity(), 1, false, true), new MssqlSnapshotCapability(WIdentity(), 1, null, true), new MssqlSnapshotCapability(WIdentity(), 3, true, true), null }) + using (var form = WForm(new WizardService { CapabilityOverride = capability }, Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".sql"))) { + form.Show(); WPump(() => WControl