Skip to content

Commit 263b9c2

Browse files
ssreeramaCopilot
andauthored
Feature: Auto-create folders when adding SQL objects to a project (#21842)
* Final code changes * loc + test updates * renaming the settings * loc * Update extensions/sql-database-projects/package.nls.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * addressing comments * loc * fixing the underlying duplicatefoder creation issue * addressing comment by extracting the duplicate code to helper method * updated comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent fe57952 commit 263b9c2

8 files changed

Lines changed: 348 additions & 207 deletions

File tree

extensions/sql-database-projects/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ _The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
88

99
## [1.5.9] - 2026-04-22
1010

11+
- Added automatic folder creation (e.g. `dbo/Tables/`) when adding SQL objects to a project. Can be disabled via the `sqlDatabaseProjects.autoCreateFolders` setting.
12+
- Added quick access to the [SQL Database Projects documentation](https://aka.ms/sqlprojects) from the project context menu and panel toolbar.
1113
- Fixed an issue where SQL object templates (table, view, stored procedure) did not reflect the schema specified in the object name.
1214
- Fixed an issue where SQL projects with a missing `ProjectGuid` were silently modified on load with an invalid all-zeros GUID, causing unexpected git dirty state. The extension now prompts the user and generates a valid unique GUID only upon acceptance.
13-
- Added quick access to the [SQL Database Projects documentation](https://aka.ms/sqlprojects) from the project context menu and panel toolbar.
1415

1516
## [1.5.8] - 2026-03-18
1617

extensions/sql-database-projects/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@
8383
"type": "string",
8484
"default": "2.1.0",
8585
"description": "%sqlDatabaseProjects.microsoftBuildSqlVersion%"
86+
},
87+
"sqlDatabaseProjects.autoCreateFolders": {
88+
"type": "boolean",
89+
"default": true,
90+
"description": "%sqlDatabaseProjects.autoCreateFolders%"
8691
}
8792
}
8893
}

extensions/sql-database-projects/package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,6 @@
5252
"sqlDatabaseProjects.collapseProjectNodes": "Whether project nodes start collapsed",
5353
"sqlDatabaseProjects.microsoftBuildSqlVersion": "Which version of Microsoft.Build.Sql SDK to use for building legacy sql projects. Example: 0.1.7-preview",
5454
"sqlDatabaseProjects.enablePreviewFeatures": "Enable preview SQL Database Projects features",
55+
"sqlDatabaseProjects.autoCreateFolders": "Automatically create folders (e.g., dbo/Tables) when adding SQL objects from any add-item entry point in a project.",
5556
"sqlDatabaseProjects.welcome": "No database projects currently open.\n[New Project](command:sqlDatabaseProjects.new)\n[Open Project](command:sqlDatabaseProjects.open)\n[Create Project From Database](command:sqlDatabaseProjects.importDatabase)"
5657
}

extensions/sql-database-projects/src/common/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,11 +637,19 @@ export const tasksJsonFriendlyName = l10n.t("Tasks.json");
637637
// These follow SSDT conventions for folder structure
638638
export const securityFolderName = "Security";
639639
export const functionsFolderName = "Functions";
640+
export const tablesFolderName = "Tables";
641+
export const viewsFolderName = "Views";
642+
export const storedProceduresFolderName = "StoredProcedures";
643+
export const triggersFolderName = "Triggers";
640644
export const databaseTriggersFolderName = "DatabaseTriggers";
641645
export const sequencesFolderName = "Sequences";
642646
export const defaultSchemaName = "dbo";
643647
//#endregion
644648

649+
//#region Extension settings
650+
export const autoCreateFoldersSetting = "sqlDatabaseProjects.autoCreateFolders";
651+
//#endregion
652+
645653
//#endregion
646654

647655
//#region Build

extensions/sql-database-projects/src/controllers/projectController.ts

Lines changed: 135 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -783,76 +783,151 @@ export class ProjectsController {
783783
}
784784

785785
/**
786-
* Gets the default folder path for a given item type when creating at project root.
787-
* For schema-dependent items: Checks Schema/ObjectType/ (e.g., Sales/Functions/)
788-
* For non-schema items (like Database Trigger): Checks root-level ObjectType folder (e.g., DatabaseTriggers/)
786+
* Finds an existing folder in the project by path (case-insensitive). If it does not exist
787+
* and `autoCreate` is true, adds it. Otherwise returns `fallback`.
788+
*/
789+
private async findOrAddFolder(
790+
project: ISqlProject,
791+
targetPath: string,
792+
fallback: string,
793+
autoCreate: boolean,
794+
): Promise<string> {
795+
const existing = project.folders.find(
796+
(f) => f.relativePath.toLowerCase() === targetPath.toLowerCase(),
797+
);
798+
if (existing) {
799+
return existing.relativePath;
800+
}
801+
if (autoCreate) {
802+
await project.addFolder(targetPath);
803+
return targetPath;
804+
}
805+
return fallback;
806+
}
807+
808+
/**
809+
* Returns whether the auto-create folder structure setting is enabled.
810+
* Defaults to true if not set.
811+
*/
812+
private isAutoCreateFoldersEnabled(): boolean {
813+
return vscode.workspace
814+
.getConfiguration()
815+
.get<boolean>(constants.autoCreateFoldersSetting, true);
816+
}
817+
818+
/**
819+
* Resolves the folder path for the item being created.
820+
*
821+
* When called from the project root (basePath is empty):
822+
* - Schema-dependent items build `Schema/ObjectType` (e.g. `dbo/Tables`)
823+
* - Non-schema-dependent items build a root-level `ObjectType` folder (e.g. `DatabaseTriggers`)
824+
*
825+
* When called from an existing folder node (basePath is non-empty, e.g. user right-clicked `dbo`):
826+
* - Checks whether `basePath/ObjectTypeFolder` exists and uses it if so
827+
* - If it doesn't exist and auto-create is ON, creates `basePath/ObjectTypeFolder`
828+
* - If it doesn't exist and auto-create is OFF, returns `basePath` unchanged
829+
* - If the last path component already equals the ObjectTypeFolder name (user is already
830+
* inside e.g. `dbo/Tables`), returns `basePath` unchanged to avoid double-nesting
831+
*
789832
* @param itemType The type of item being created
790-
* @param project The project to check for existing folders
791-
* @param schemaName Optional schema name to look for schema-named folders
792-
* @returns The default folder path if it exists, or empty string otherwise
833+
* @param project The project to check / add folders to
834+
* @param schemaName The schema name parsed from user input (e.g. "dbo", "sales")
835+
* @param basePath The folder the user invoked the command from; empty string means project root
836+
* @returns The relative folder path the item should be placed in
793837
*/
794-
public getDefaultFolderForItemType(
838+
public async resolveItemFolder(
795839
itemType: ItemType,
796840
project: ISqlProject,
797841
schemaName?: string,
798-
): string {
799-
let relativePath = "";
800-
801-
// Get the folder config for this item type (defaults to schema-dependent if not in map)
842+
basePath?: string,
843+
): Promise<string> {
802844
const folderConfig = templates.itemTypeToFolderMap.get(itemType);
803-
const isSchemaDependent = folderConfig?.schemaDependent ?? true;
804-
const folderName = folderConfig?.folderName;
845+
if (!folderConfig) {
846+
return basePath ?? "";
847+
}
805848

806-
// Non-schema-dependent items - check root-level folder only
807-
if (!isSchemaDependent && folderName) {
808-
const rootFolder = project.folders.find(
809-
(f) => f.relativePath.toLowerCase() === folderName.toLowerCase(),
849+
const { folderName, schemaDependent } = folderConfig;
850+
const autoCreate = this.isAutoCreateFoldersEnabled();
851+
852+
// Non-root path: user invoked from an existing folder node
853+
if (basePath) {
854+
// Use /[\\/]/ instead of path.sep: basePath comes from trimUri which always
855+
// joins with '/' on all platforms, so on Windows path.sep ('\') would never split "dbo/Tables"
856+
// correctly. The regex handles both separators defensively.
857+
const segments = basePath.split(/[\\/]/).filter(Boolean);
858+
859+
// Already inside a Schema/ObjectType folder (2+ segments, e.g. "dbo/Functions") —
860+
// place the file directly there; Tables and Functions are siblings, not nested.
861+
if (segments.length >= 2) {
862+
return basePath;
863+
}
864+
865+
// Non-schema-dependent types (e.g. DatabaseTriggers, Security) live at the project
866+
// root and must not be nested under a schema folder or themselves.
867+
if (!schemaDependent) {
868+
return basePath;
869+
}
870+
871+
// Short-circuit if basePath is already any known ObjectType folder
872+
// (e.g. user is in "Tables" and adds a View → don't create "Tables/Views").
873+
// Files must only be nested under schema folders (single-segment, non-ObjectType),
874+
// so if basePath matches any folderName in the map we place the file directly there.
875+
const isObjectTypeFolder = [...templates.itemTypeToFolderMap.values()].some(
876+
(cfg) => cfg.folderName.toLowerCase() === basePath.toLowerCase(),
810877
);
811-
if (rootFolder) {
812-
relativePath = rootFolder.relativePath;
878+
if (isObjectTypeFolder) {
879+
return basePath;
813880
}
814-
return relativePath;
881+
882+
// basePath is a single-segment schema folder (e.g. "dbo").
883+
// Check for / create the ObjectType subfolder under it.
884+
const subfolderPath = utils.convertSlashesForSqlProj(path.join(basePath, folderName));
885+
return this.findOrAddFolder(project, subfolderPath, basePath, autoCreate);
815886
}
816887

817-
// Case for Sequence: Check root-level Sequences folder first
818-
if (itemType === ItemType.sequence && folderName) {
819-
const rootObjectFolder = project.folders.find(
888+
// Non-schema-dependent items (e.g. Security/, DatabaseTriggers/) → root-level folder
889+
if (!schemaDependent) {
890+
return this.findOrAddFolder(project, folderName, "", autoCreate);
891+
}
892+
893+
// Backward compat: if a root-level Sequences folder already exists, prefer it
894+
// over creating schema/Sequences (mirrors behavior on projects created before
895+
// the auto-create-folders feature where sequences lived at the project root).
896+
if (itemType === ItemType.sequence) {
897+
const rootSeqFolder = project.folders.find(
820898
(f) => f.relativePath.toLowerCase() === folderName.toLowerCase(),
821899
);
822-
823-
if (rootObjectFolder) {
824-
return rootObjectFolder.relativePath;
900+
if (rootSeqFolder) {
901+
return rootSeqFolder.relativePath;
825902
}
826903
}
827904

828-
// For schema-dependent items, check schema folders
829-
if (schemaName) {
830-
// Case 1: Check for schema folder (e.g., "Sales", "dbo") - case-insensitive
831-
const schemaFolder = project.folders.find(
832-
(f) => f.relativePath.toLowerCase() === schemaName.toLowerCase(),
833-
);
834-
835-
if (schemaFolder) {
836-
relativePath = schemaFolder.relativePath;
905+
// Schema-dependent items (e.g. dbo/Tables/, sales/StoredProcedures/)
906+
const resolvedSchema = schemaName ?? constants.defaultSchemaName;
907+
const nestedPath = utils.convertSlashesForSqlProj(path.join(resolvedSchema, folderName));
837908

838-
// Case 2: Check for nested object type folder (e.g., "Sales/Functions")
839-
if (folderName) {
840-
const nestedPath = utils.convertSlashesForSqlProj(
841-
path.join(schemaFolder.relativePath, folderName),
842-
);
843-
const nestedFolder = project.folders.find(
844-
(f) => f.relativePath.toLowerCase() === nestedPath.toLowerCase(),
845-
);
909+
// Single scan for nestedPath — avoids a second scan inside findOrAddFolder.
910+
const existingNested = project.folders.find(
911+
(f) => f.relativePath.toLowerCase() === nestedPath.toLowerCase(),
912+
);
913+
if (existingNested) {
914+
return existingNested.relativePath;
915+
}
846916

847-
if (nestedFolder) {
848-
relativePath = nestedFolder.relativePath;
849-
}
850-
}
851-
}
917+
if (!autoCreate) {
918+
return "";
852919
}
853920

854-
// Case 3: If no schema folder found, return empty string (place at root)
855-
return relativePath;
921+
// Auto-create: ensure the parent schema folder exists before adding the nested path.
922+
if (
923+
!project.folders.some(
924+
(f) => f.relativePath.toLowerCase() === resolvedSchema.toLowerCase(),
925+
)
926+
) {
927+
await project.addFolder(resolvedSchema);
928+
}
929+
await project.addFolder(nestedPath);
930+
return nestedPath;
856931
}
857932

858933
public async addItemPromptFromNode(
@@ -931,11 +1006,16 @@ export class ProjectsController {
9311006
// Parse schema and object name from input (e.g., "sales.MyFunction" -> schema="sales", objectName="MyFunction")
9321007
const { schemaName, objectName } = this.parseSchemaAndObjectName(itemObjectName);
9331008

934-
// Determine the folder for this item when creating at project root
935-
// Checks: Schema folder -> Schema/ObjectType folder -> root
936-
if (relativePath === "") {
937-
relativePath = this.getDefaultFolderForItemType(itemType.type, project, schemaName);
938-
}
1009+
// Resolve the best folder for this item type, whether adding from root or from
1010+
// an existing folder node (e.g. user right-clicked the dbo/ schema folder).
1011+
// resolveItemFolder handles both cases: creates missing subfolders when auto-create
1012+
// is ON, or returns the existing folder / falls back to the current path when OFF.
1013+
relativePath = await this.resolveItemFolder(
1014+
itemType.type,
1015+
project,
1016+
schemaName,
1017+
relativePath || undefined,
1018+
);
9391019

9401020
const relativeFilePath = path.join(relativePath, objectName + fileExtension);
9411021

extensions/sql-database-projects/src/templates/templates.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,20 @@ export interface ItemTypeFolderConfig {
2222
schemaDependent: boolean;
2323
}
2424

25-
/**
26-
* Maps item types to their default folder locations and schema dependency.
27-
* Configuration for item type default folder placement.
28-
*/
29-
export interface ItemTypeFolderConfig {
30-
/** The default folder name for this item type */
31-
folderName: string;
32-
/** If true, the folder can be nested under schema folders (e.g., Sales/Functions). If false, only root-level folder is checked. */
33-
schemaDependent: boolean;
34-
}
35-
3625
/**
3726
* Maps item types to their default folder locations and schema dependency.
3827
* Following SSDT conventions for folder structure (ObjectType and SchemaObjectType).
3928
* Add new mappings here when adding item types that should be placed in specific folders.
4029
*/
4130
export const itemTypeToFolderMap: ReadonlyMap<ItemType, ItemTypeFolderConfig> = new Map([
4231
[ItemType.schema, { folderName: constants.securityFolderName, schemaDependent: false }],
32+
[ItemType.table, { folderName: constants.tablesFolderName, schemaDependent: true }],
33+
[ItemType.view, { folderName: constants.viewsFolderName, schemaDependent: true }],
34+
[
35+
ItemType.storedProcedure,
36+
{ folderName: constants.storedProceduresFolderName, schemaDependent: true },
37+
],
38+
[ItemType.trigger, { folderName: constants.triggersFolderName, schemaDependent: true }],
4339
[
4440
ItemType.tableValuedFunction,
4541
{ folderName: constants.functionsFolderName, schemaDependent: true },

0 commit comments

Comments
 (0)