From 34024b7996ec8f7bd19b6b1201f3221f287007bd Mon Sep 17 00:00:00 2001 From: John Date: Tue, 13 Oct 2026 14:03:13 +0800 Subject: [PATCH 01/14] fix: handle MSSQL export metadata and prerequisite compatibility --- THIRD_PARTY_NOTICES.md | 25 ++ docs/MSSQL_SQL_EXPORT.md | 10 +- .../mssql-export/diagnose-preflight.sql | 96 +++++++ .../mssql-export/production-preflight.md | 62 +++++ docs/design/mssql-export/verification.md | 39 +++ mySQLPunk/MssqlExportWizardForm.cs | 81 +++++- .../MssqlExport/MssqlExportMetadataReader.cs | 169 ++++++++++- .../lib/MssqlExport/MssqlExportModels.cs | 103 ++++++- .../MssqlExport/MssqlSchemaScriptWriter.cs | 23 +- .../lib/MssqlExport/MssqlSqlExportService.cs | 5 +- .../MssqlExport/MssqlViewReferenceRewriter.cs | 230 +++++++++++++++ mySQLPunk/mySQLPunk.csproj | 4 + mySQLPunk/packages.config | 1 + tests/MssqlExportFixtures/compatibility.sql | 38 +++ tests/Run-MssqlExportIntegration.ps1 | 4 +- tests/Run-SmokeTests.ps1 | 2 +- ...ts.MssqlExport.CompatibilityIntegration.cs | 263 ++++++++++++++++++ tests/SmokeTests.MssqlExport.Integration.cs | 4 +- tests/SmokeTests.MssqlExport.Properties.cs | 133 +++++++++ tests/SmokeTests.MssqlExport.Schema.cs | 4 +- .../SmokeTests.MssqlExport.ViewReferences.cs | 190 +++++++++++++ tests/SmokeTests.MssqlExport.Wizard.cs | 106 ++++++- tests/SmokeTests.MssqlExport.Worker.cs | 53 ++++ tests/SmokeTests.MssqlExport.cs | 14 + tests/SmokeTests.cs | 18 ++ 25 files changed, 1626 insertions(+), 51 deletions(-) create mode 100644 docs/design/mssql-export/diagnose-preflight.sql create mode 100644 docs/design/mssql-export/production-preflight.md create mode 100644 mySQLPunk/lib/MssqlExport/MssqlViewReferenceRewriter.cs create mode 100644 tests/MssqlExportFixtures/compatibility.sql create mode 100644 tests/SmokeTests.MssqlExport.CompatibilityIntegration.cs create mode 100644 tests/SmokeTests.MssqlExport.Properties.cs create mode 100644 tests/SmokeTests.MssqlExport.ViewReferences.cs 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..215f131 100644 --- a/docs/MSSQL_SQL_EXPORT.md +++ b/docs/MSSQL_SQL_EXPORT.md @@ -4,7 +4,7 @@ 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 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 database VIEW DEFINITION and readable dependency metadata; data modes additionally need SELECT on each selected table. Schema-only does not require table SELECT. When SNAPSHOT is already ON, the exporter does not require ALTER or administrator rights. Missing prerequisites and unsupported features block preflight. The wizard distinguishes snapshot transitions, missing definition/data permissions and login/database-access failures, with in-place catalog retry or confirmation recheck; provider messages and connection details are not displayed. Restore to a prepared, compatible database using **one sqlcmd process/session for the entire artifact**: @@ -25,9 +25,13 @@ 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. @@ -40,7 +44,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/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..7c29c6a --- /dev/null +++ b/docs/design/mssql-export/production-preflight.md @@ -0,0 +1,62 @@ +# Production preflight compatibility investigation (2026-10-13) + +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/verification.md b/docs/design/mssql-export/verification.md index 74acde2..b040d29 100644 --- a/docs/design/mssql-export/verification.md +++ b/docs/design/mssql-export/verification.md @@ -186,3 +186,42 @@ 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. diff --git a/mySQLPunk/MssqlExportWizardForm.cs b/mySQLPunk/MssqlExportWizardForm.cs index d7f7418..76a8be4 100644 --- a/mySQLPunk/MssqlExportWizardForm.cs +++ b/mySQLPunk/MssqlExportWizardForm.cs @@ -26,7 +26,7 @@ internal sealed class MssqlExportWizardForm : Form 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 +39,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,9 +165,15 @@ internal MssqlExportWizardForm(Dictionary connection, string dat step--; ShowPage(); }); + Configure(retry, "mssqlRetry", T("重新檢查", "Recheck"), async (s, e) => + { + if (busy || closing) return; + if (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); ThemeManager.MarkAsPrimary(next); AcceptButton = next; @@ -600,11 +606,15 @@ private void UpdateButtons() selection.Text = T("已選取 ", "Selected ") + selected.Count + " / " + catalog.Count; back.Visible = step > 0 && step < 3; back.Enabled = !busy; + retry.Visible = 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 +644,27 @@ 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; 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 +674,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 +683,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,6 +699,7 @@ 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(); @@ -684,7 +711,8 @@ private async Task PreflightAsync() 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) { @@ -694,10 +722,13 @@ private async Task PreflightAsync() var request = new MssqlExportRequest(Guid.NewGuid(), database, keys, mode, include, path, stamp, options.Mirror); 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)); + 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: " + MssqlSnapshotState.Describe(check.SnapshotState) + 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 (mode != MssqlExportMode.SchemaOnly && check.SnapshotState != 1) + confirmation.Text += "\r\n" + T("含資料匯出需要 ALLOW_SNAPSHOT_ISOLATION ON;請 DBA 評估設定,若正在轉換則完成後重新檢查。也可返回改選僅結構。程式不變更資料庫設定;已為 ON 時不需要 ALTER 權限。", "Data export requires ALLOW_SNAPSHOT_ISOLATION ON. Ask the DBA to review the setting; if transitioning, recheck after it completes. You may go back and choose schema only. The exporter does not change database settings; ALTER permission is not required when already ON."); if (check.CanStart && (mode == MssqlExportMode.SchemaOnly || check.SnapshotState == 1)) prepared = request; status.Text = prepared != null ? T("預檢通過;開始時會再次檢查來源。", "Preflight passed; source is checked again at start.") : T("預檢未通過,無法開始。", "Preflight failed; start is blocked."); @@ -706,9 +737,21 @@ private async Task PreflightAsync() { status.Text = T("已取消", "Canceled"); } + catch (MssqlExportPrerequisiteException ex) + { + if (!closing && version == generation && !cancelRequested) + { + confirmation.Text = 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 = T("目的路徑或預檢失敗,請檢查設定後重新檢查。", "Destination or preflight failed. Review settings and recheck."); + status.Text = T("預檢未通過,無法開始。", "Preflight failed; start is blocked."); + } } finally { @@ -842,6 +885,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; diff --git a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs index 868b78d..5d1f3bd 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlExportMetadataReader.cs @@ -75,28 +75,81 @@ 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 schema = new MssqlExportSchema(selected, views, dependencies, Snapshot(connection, transaction, cancellation), issues, extendedProperties: properties); + 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; } 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.")); + if (request.Mode != MssqlExportMode.SchemaOnly && schema.SnapshotState != 1) issues.Add(new MssqlExportIssue("SnapshotUnavailable", null, null, "ALLOW_SNAPSHOT_ISOLATION must be ON (state 1). Current state: " + MssqlSnapshotState.Describe(schema.SnapshotState) + ". Ask the DBA to review this database setting; retry after state ON, or choose schema only. The exporter does not change settings or require ALTER permission when already ON.")); 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,28 +180,115 @@ 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) @@ -161,8 +301,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..5c9f1e2 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; } @@ -185,9 +226,28 @@ internal sealed class MssqlViewDefinition : MssqlNativeFacts public MssqlObjectKey Key { get; } internal MssqlViewDefinition(MssqlObjectKey key, IDictionary 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 +256,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 { @@ -220,11 +300,13 @@ 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 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) { + ExtendedProperties = MssqlNativeFacts.Copy(extendedProperties); Tables = MssqlNativeFacts.Copy(tables); Views = MssqlNativeFacts.Copy(views); Dependencies = MssqlNativeFacts.Copy(dependencies); Issues = MssqlNativeFacts.Copy(issues); SnapshotState = snapshotState; var text = new StringBuilder(); foreach (var t in Tables.OrderBy(x => x.Key.ObjectId)) @@ -235,8 +317,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); } diff --git a/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs b/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs index b53a438..6cc51c2 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlSchemaScriptWriter.cs @@ -65,13 +65,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 +82,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 +181,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 +203,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/MssqlSqlExportService.cs b/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs index f58a35f..0a6028f 100644 --- a/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs +++ b/mySQLPunk/lib/MssqlExport/MssqlSqlExportService.cs @@ -36,6 +36,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 + ")."); } @@ -123,7 +125,8 @@ internal virtual MssqlExportResult Export(MssqlExportRequest request, IProgress< { canceled = 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 prerequisite = canceled ? null : MssqlExportPrerequisiteException.From(ex); + error = canceled ? "Export canceled." : prerequisite != null ? "Export failed during " + stage + ". " + prerequisite.Message : "Export failed during " + stage + " (" + ex.GetType().Name + ")."; } finally { 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..3a32ce2 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 @@ -370,6 +373,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/Run-MssqlExportIntegration.ps1 b/tests/Run-MssqlExportIntegration.ps1 index e6ddc55..f4441eb 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('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 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 } diff --git a/tests/Run-SmokeTests.ps1 b/tests/Run-SmokeTests.ps1 index 7a4d4a3..3e10d20 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.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.CompatibilityIntegration.cs") if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/tests/SmokeTests.MssqlExport.CompatibilityIntegration.cs b/tests/SmokeTests.MssqlExport.CompatibilityIntegration.cs new file mode 100644 index 0000000..7a6ae78 --- /dev/null +++ b/tests/SmokeTests.MssqlExport.CompatibilityIntegration.cs @@ -0,0 +1,263 @@ +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); + 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 reader with definitions and SELECT can export when snapshot is already ON"); + var dataResult = f.Export(data); + Assert(dataResult.Stage == MssqlExportStage.Completed, "Actual data export does not require ALTER when snapshot is ON: " + 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..37ef334 100644 --- a/tests/SmokeTests.MssqlExport.Integration.cs +++ b/tests/SmokeTests.MssqlExport.Integration.cs @@ -21,12 +21,14 @@ 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"); + Assert(new[] { "Core", "Large", "Concurrency", "ClientProbe", "FinalFix", "Geometry", "Properties", "References" }.Contains(profile), "Known integration profile"); using (var fixture = new IntegrationFixture(profile)) { 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); 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.Schema.cs b/tests/SmokeTests.MssqlExport.Schema.cs index 88b9ecd..f1dd856 100644 --- a/tests/SmokeTests.MssqlExport.Schema.cs +++ b/tests/SmokeTests.MssqlExport.Schema.cs @@ -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)(); + 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..f0ae92c 100644 --- a/tests/SmokeTests.MssqlExport.Wizard.cs +++ b/tests/SmokeTests.MssqlExport.Wizard.cs @@ -10,6 +10,96 @@ public static partial class SmokeTests { + private static void Diagnostic_WizardSnapshot() + { + foreach (int state in new[] { 0, 1, 2, 3, 9 }) + using (var form = WForm(new WizardService { SnapshotOverride = state }, Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".sql"))) { + form.Show(); WPump(() => WControl