Skip to content

Commit 94fc17e

Browse files
committed
feat: implement cascading delete script generation with foreign key handling and add unit tests
1 parent 6e8246f commit 94fc17e

5 files changed

Lines changed: 726 additions & 87 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to the MS SQL Manager extension will be documented in this f
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.19.9] - 2026-05-08
9+
10+
### Fixed
11+
12+
- **Script ROW DELETE — Composite foreign key WHERE clause now generates valid SQL**
13+
- Tables with composite foreign keys (e.g. `(MemberId, ProjectId, Role)`) previously generated invalid T-SQL: `WHERE [MemberId, ProjectId, Role] IN (SELECT [MemberId, ProjectId, Role] ...)` — SQL Server does not support multi-column `IN` expressions.
14+
- Fix: FK columns are now traced through the dependency chain back to the root table's PK. When a column in the dependent table maps to the root PK (e.g. `ProjectMemberToolCapabilities.MemberId``Members.Id`), a simple direct `WHERE [MemberId] = @Target_Id` is generated.
15+
- Level-0 dependencies (tables directly referencing the target) use simple direct `WHERE [col] = @Target_X` comparison.
16+
- Higher-level dependencies where no FK column traces to root PK fall back to `WHERE EXISTS (...)` with correlated subqueries (composite FK) or `WHERE [col] IN (SELECT ...)` (single-column FK).
17+
- Extracted reusable `traceColumnsToRoot`, `buildTargetFilter` and `generateDependentDeletes` utilities into `src/utils/deleteScriptGenerator.ts` for testability.
18+
19+
### Tests
20+
21+
- Added **21** unit tests for `deleteScriptGenerator`: `buildTargetFilter` (7 tests), `generateDependentDeletes` (10 tests), and `traceColumnsToRoot` (4 tests) covering direct column tracing, composite FKs, multi-level nesting, deduplication, ordering, circular reference safety, and the original regression case.
22+
23+
24+
825
## [0.19.8] - 2026-04-02
926

1027
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "ms-sql-manager",
33
"displayName": "MS SQL Manager",
44
"description": "Manage Microsoft SQL Server databases with connection management, schema browsing, and query execution.",
5-
"version": "0.19.8",
5+
"version": "0.19.9",
66
"publisher": "jakubkozera",
77
"icon": "ms-sql-manager.png",
88
"sponsor": {

src/commands/tableCommands.ts

Lines changed: 2 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ConnectionProvider } from '../connectionProvider';
33
import { UnifiedTreeProvider } from '../unifiedTreeProvider';
44
import { openSqlInCustomEditor } from '../utils/sqlDocumentHelper';
55
import { SchemaCache } from '../utils/schemaCache';
6+
import { generateDependentDeletes } from '../utils/deleteScriptGenerator';
67

78
export function registerTableCommands(
89
context: vscode.ExtensionContext,
@@ -740,92 +741,7 @@ GO`;
740741

741742
// Generate DELETE statements for dependent tables (from most dependent to least dependent)
742743
if (fkDepsResult.recordset.length > 0) {
743-
deleteScript += ` -- Delete dependent records (from most dependent to least dependent)\n\n`;
744-
745-
// Group by level and table to avoid duplicates
746-
const processedTables = new Set<string>();
747-
const groupedByLevel = new Map<number, any[]>();
748-
749-
fkDepsResult.recordset.forEach((dep: any) => {
750-
const tableKey = `${dep.level}_${dep.ref_schema}.${dep.ref_table}`;
751-
if (!processedTables.has(tableKey)) {
752-
processedTables.add(tableKey);
753-
if (!groupedByLevel.has(dep.level)) {
754-
groupedByLevel.set(dep.level, []);
755-
}
756-
groupedByLevel.get(dep.level)!.push(dep);
757-
}
758-
});
759-
760-
// Sort levels in descending order
761-
const sortedLevels = Array.from(groupedByLevel.keys()).sort((a, b) => b - a);
762-
763-
sortedLevels.forEach(level => {
764-
const depsAtLevel = groupedByLevel.get(level)!;
765-
766-
depsAtLevel.forEach((dep: any) => {
767-
deleteScript += ` -- Level ${dep.level}: Delete from [${dep.ref_schema}].[${dep.ref_table}]\n`;
768-
deleteScript += ` -- Path: ${dep.path}\n`;
769-
deleteScript += ` DELETE [${dep.ref_schema}].[${dep.ref_table}]\n`;
770-
deleteScript += ` WHERE [${dep.ref_columns}] IN (\n`;
771-
deleteScript += ` SELECT [${dep.target_columns}]\n`;
772-
deleteScript += ` FROM [${dep.target_schema}].[${dep.target_table}]\n`;
773-
774-
// Build WHERE clause that traces back to the original table
775-
if (dep.level === 0) {
776-
// Direct dependency on the target table
777-
if (pkResult.recordset.length > 0) {
778-
deleteScript += ` WHERE `;
779-
deleteScript += pkResult.recordset.map((pk: any, pkIndex: number) => {
780-
const operator = pkIndex === 0 ? '' : 'AND ';
781-
return `${operator}[${pk.COLUMN_NAME}] = @Target_${pk.COLUMN_NAME}`;
782-
}).join(' ');
783-
deleteScript += `\n`;
784-
}
785-
} else {
786-
// For higher levels, check if we can use direct column reference
787-
// Look for a column in ref_table that matches the root table's PK pattern
788-
const rootTableNameSingular = table.endsWith('s') ? table.slice(0, -1) : table;
789-
const potentialColumnNames = [
790-
`${table}Id`, // e.g., ProjectsId
791-
`${rootTableNameSingular}Id`, // e.g., ProjectId
792-
pkResult.recordset.length > 0 ? pkResult.recordset[0].COLUMN_NAME : null
793-
].filter(Boolean);
794-
795-
// Check if any of the ref_columns contains a direct reference to root table
796-
const refColumnsList = dep.ref_columns.split(', ');
797-
const directColumn = refColumnsList.find((col: string) =>
798-
potentialColumnNames.some(pcn => col === pcn)
799-
);
800-
801-
if (directColumn && pkResult.recordset.length > 0) {
802-
// Use direct column comparison
803-
deleteScript += ` WHERE [${directColumn}] = @Target_${pkResult.recordset[0].COLUMN_NAME}\n`;
804-
} else {
805-
// Fall back to subquery (for cases where there's no direct column)
806-
const parentDep = fkDepsResult.recordset.find((d: any) =>
807-
d.ref_schema === dep.target_schema &&
808-
d.ref_table === dep.target_table &&
809-
d.level === dep.level - 1
810-
);
811-
812-
if (parentDep) {
813-
const parentRefColumns = parentDep.ref_columns.split(', ');
814-
const parentDirectColumn = parentRefColumns.find((col: string) =>
815-
potentialColumnNames.some(pcn => col === pcn)
816-
);
817-
818-
if (parentDirectColumn && pkResult.recordset.length > 0) {
819-
deleteScript += ` WHERE [${parentDirectColumn}] = @Target_${pkResult.recordset[0].COLUMN_NAME}\n`;
820-
}
821-
}
822-
}
823-
}
824-
825-
deleteScript += ` );\n`;
826-
deleteScript += ` PRINT 'Deleted ' + CAST(@@ROWCOUNT AS VARCHAR) + ' row(s) from [${dep.ref_schema}].[${dep.ref_table}]';\n\n`;
827-
});
828-
});
744+
deleteScript += generateDependentDeletes(schema, table, pkResult.recordset, fkDepsResult.recordset);
829745
}
830746

831747
// Generate DELETE statement for the main table

0 commit comments

Comments
 (0)