diff --git a/extensions/mssql/src/models/contracts/sqlProjects/sqlProjectsContracts.ts b/extensions/mssql/src/models/contracts/sqlProjects/sqlProjectsContracts.ts index b2efcbdd36..f52354e3bd 100644 --- a/extensions/mssql/src/models/contracts/sqlProjects/sqlProjectsContracts.ts +++ b/extensions/mssql/src/models/contracts/sqlProjects/sqlProjectsContracts.ts @@ -245,6 +245,28 @@ export namespace MoveNoneItemRequest { //#endregion +//#region RefactorLog functions + +export namespace GetRefactorLogItemsRequest { + export const type = new RequestType( + "sqlProjects/getRefactorLogItems", + ); +} + +export namespace AddRefactorLogItemRequest { + export const type = new RequestType( + "sqlProjects/addRefactorLogItem", + ); +} + +export namespace DeleteRefactorLogItemRequest { + export const type = new RequestType( + "sqlProjects/deleteRefactorLogItem", + ); +} + +//#endregion + //#endregion //#region SQLCMD variable functions diff --git a/extensions/mssql/src/services/sqlProjectsService.ts b/extensions/mssql/src/services/sqlProjectsService.ts index d0546571a4..0c89a8acca 100644 --- a/extensions/mssql/src/services/sqlProjectsService.ts +++ b/extensions/mssql/src/services/sqlProjectsService.ts @@ -713,4 +713,42 @@ export class SqlProjectsService implements mssql.ISqlProjectsService { }; return this._client.sendRequest(contracts.MoveNoneItemRequest.type, params); } + + /** + * Get all RefactorLog items in a project + * @param projectUri Absolute path of the project, including .sqlproj + */ + public async getRefactorLogItems(projectUri: string): Promise { + const params: mssql.SqlProjectParams = { projectUri: projectUri }; + return this._client.sendRequest(contracts.GetRefactorLogItemsRequest.type, params); + } + + /** + * Add a RefactorLog item to a project + * @param projectUri Absolute path of the project, including .sqlproj + * @param path Path of the .refactorlog file, relative to the .sqlproj + */ + public async addRefactorLogItem(projectUri: string, path: string): Promise { + const params: mssql.SqlProjectScriptParams = { + projectUri: projectUri, + path: path, + }; + return this._client.sendRequest(contracts.AddRefactorLogItemRequest.type, params); + } + + /** + * Delete a RefactorLog item from a project + * @param projectUri Absolute path of the project, including .sqlproj + * @param path Path of the .refactorlog file, relative to the .sqlproj + */ + public async deleteRefactorLogItem( + projectUri: string, + path: string, + ): Promise { + const params: mssql.SqlProjectScriptParams = { + projectUri: projectUri, + path: path, + }; + return this._client.sendRequest(contracts.DeleteRefactorLogItemRequest.type, params); + } } diff --git a/extensions/mssql/test/unit/sqlProjectsService.test.ts b/extensions/mssql/test/unit/sqlProjectsService.test.ts new file mode 100644 index 0000000000..059af2d0bf --- /dev/null +++ b/extensions/mssql/test/unit/sqlProjectsService.test.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as sinon from "sinon"; +import * as chai from "chai"; +import { expect } from "chai"; +import sinonChai from "sinon-chai"; +import SqlToolsServiceClient from "../../src/languageservice/serviceclient"; +import { SqlProjectsService } from "../../src/services/sqlProjectsService"; +import * as contracts from "../../src/models/contracts/sqlProjects/sqlProjectsContracts"; +import { GetScriptsResult, ResultStatus } from "vscode-mssql"; + +chai.use(sinonChai); + +suite("SqlProjectsService - RefactorLog methods", () => { + const PROJECT_URI = "/path/to/TestProject.sqlproj"; + const REFACTORLOG_PATH = "TestProject.refactorlog"; + + let sandbox: sinon.SinonSandbox; + let clientStub: sinon.SinonStubbedInstance; + let service: SqlProjectsService; + + setup(() => { + sandbox = sinon.createSandbox(); + clientStub = sandbox.createStubInstance(SqlToolsServiceClient); + service = new SqlProjectsService(clientStub as unknown as SqlToolsServiceClient); + }); + + teardown(() => { + sandbox.restore(); + }); + + test("getRefactorLogItems sends GetRefactorLogItemsRequest with correct params", async () => { + const expectedResult: GetScriptsResult = { + success: true, + errorMessage: "", + scripts: [REFACTORLOG_PATH], + }; + clientStub.sendRequest.resolves(expectedResult); + + const result = await service.getRefactorLogItems(PROJECT_URI); + + expect(clientStub.sendRequest).to.have.been.calledOnceWith( + contracts.GetRefactorLogItemsRequest.type, + { projectUri: PROJECT_URI }, + ); + expect(result).to.deep.equal(expectedResult); + expect(result.scripts).to.deep.equal([REFACTORLOG_PATH]); + }); + + test("addRefactorLogItem sends AddRefactorLogItemRequest with correct params", async () => { + const expectedResult: ResultStatus = { success: true, errorMessage: "" }; + clientStub.sendRequest.resolves(expectedResult); + + const result = await service.addRefactorLogItem(PROJECT_URI, REFACTORLOG_PATH); + + expect(clientStub.sendRequest).to.have.been.calledOnceWith( + contracts.AddRefactorLogItemRequest.type, + { projectUri: PROJECT_URI, path: REFACTORLOG_PATH }, + ); + expect(result).to.deep.equal(expectedResult); + expect(result.success).to.be.true; + }); + + test("deleteRefactorLogItem sends DeleteRefactorLogItemRequest with correct params", async () => { + const expectedResult: ResultStatus = { success: true, errorMessage: "" }; + clientStub.sendRequest.resolves(expectedResult); + + const result = await service.deleteRefactorLogItem(PROJECT_URI, REFACTORLOG_PATH); + + expect(clientStub.sendRequest).to.have.been.calledOnceWith( + contracts.DeleteRefactorLogItemRequest.type, + { projectUri: PROJECT_URI, path: REFACTORLOG_PATH }, + ); + expect(result).to.deep.equal(expectedResult); + expect(result.success).to.be.true; + }); + + test("getRefactorLogItems returns empty scripts array when project has no RefactorLog items", async () => { + const expectedResult: GetScriptsResult = { + success: true, + errorMessage: "", + scripts: [], + }; + clientStub.sendRequest.resolves(expectedResult); + + const result = await service.getRefactorLogItems(PROJECT_URI); + + expect(result.success).to.be.true; + expect(result.scripts).to.deep.equal([]); + }); + + test("addRefactorLogItem propagates failure from service", async () => { + const expectedResult: ResultStatus = { + success: false, + errorMessage: "File not found: TestProject.refactorlog", + }; + clientStub.sendRequest.resolves(expectedResult); + + const result = await service.addRefactorLogItem(PROJECT_URI, REFACTORLOG_PATH); + + expect(result.success).to.be.false; + expect(result.errorMessage).to.include("File not found"); + }); +}); diff --git a/extensions/mssql/typings/vscode-mssql.d.ts b/extensions/mssql/typings/vscode-mssql.d.ts index b128f3b054..c04c8b5b6b 100644 --- a/extensions/mssql/typings/vscode-mssql.d.ts +++ b/extensions/mssql/typings/vscode-mssql.d.ts @@ -1004,6 +1004,26 @@ declare module "vscode-mssql" { path: string, destinationPath: string, ): Promise; + + /** + * Get all RefactorLog items in a project + * @param projectUri Absolute path of the project, including .sqlproj + */ + getRefactorLogItems(projectUri: string): Promise; + + /** + * Add a RefactorLog item to a project + * @param projectUri Absolute path of the project, including .sqlproj + * @param path Path of the .refactorlog file, relative to the .sqlproj + */ + addRefactorLogItem(projectUri: string, path: string): Promise; + + /** + * Delete a RefactorLog item from a project + * @param projectUri Absolute path of the project, including .sqlproj + * @param path Path of the .refactorlog file, relative to the .sqlproj + */ + deleteRefactorLogItem(projectUri: string, path: string): Promise; } /** diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 0fdea658fd..f819a98530 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -780,6 +780,7 @@ export enum DatabaseProjectItemType { noneFile = "databaseProject.itemType.file.noneFile", sqlObjectScript = "databaseProject.itemType.file.sqlObjectScript", publishProfile = "databaseProject.itemType.file.publishProfile", + refactorLogFile = "databaseProject.itemType.file.refactorLogFile", } //#endregion diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 62992037d6..9a92ce1a4a 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -73,6 +73,7 @@ export class Project implements ISqlProject { private _preDeployScripts: FileProjectEntry[] = []; private _postDeployScripts: FileProjectEntry[] = []; private _noneDeployScripts: FileProjectEntry[] = []; + private _refactorLogItems: FileProjectEntry[] = []; private _sqlProjStyle: ProjectType; private _isCrossPlatformCompatible: boolean = false; private _outputPath: string = ""; @@ -138,6 +139,10 @@ export class Project implements ISqlProject { return this._noneDeployScripts; } + public get refactorLogItems(): FileProjectEntry[] { + return this._refactorLogItems; + } + public get sqlProjStyle(): ProjectType { return this._sqlProjStyle; } @@ -347,6 +352,7 @@ export class Project implements ISqlProject { await this.readPostDeployScripts(true); await this.readNoneItems(); // also populates list of publish profiles, determined by file extension + await this.readRefactorLogItems(); await this.readSqlObjectScripts(); // get SQL object scripts await this.readFolders(); // get folders @@ -556,6 +562,23 @@ export class Project implements ISqlProject { } } + private async readRefactorLogItems(): Promise { + const result: GetScriptsResult = await ( + this.sqlProjService as vscodeMssql.ISqlProjectsService + ).getRefactorLogItems(this.projectFilePath); + utils.throwIfFailed(result); + + this._refactorLogItems = []; + + if (result.scripts?.length > 0) { + for (var scriptPath of result.scripts) { + this._refactorLogItems.push( + this.createFileProjectEntry(scriptPath, EntryType.File), + ); + } + } + } + private async readDatabaseReferences(): Promise { this._databaseReferences = []; const databaseReferencesResult = await this.sqlProjService.getDatabaseReferences( @@ -639,6 +662,7 @@ export class Project implements ISqlProject { this._preDeployScripts = []; this._postDeployScripts = []; this._noneDeployScripts = []; + this._refactorLogItems = []; this._outputPath = ""; this._configuration = Configuration.Debug; this._publishProfiles = []; @@ -740,6 +764,7 @@ export class Project implements ISqlProject { await this.readPreDeployScripts(); await this.readPostDeployScripts(); await this.readNoneItems(); + await this.readRefactorLogItems(); await this.readFolders(); } @@ -754,6 +779,7 @@ export class Project implements ISqlProject { await this.readPreDeployScripts(); await this.readPostDeployScripts(); await this.readNoneItems(); + await this.readRefactorLogItems(); await this.readFolders(); } @@ -772,6 +798,7 @@ export class Project implements ISqlProject { await this.readPreDeployScripts(); await this.readPostDeployScripts(); await this.readNoneItems(); + await this.readRefactorLogItems(); await this.readFolders(); } @@ -845,6 +872,7 @@ export class Project implements ISqlProject { await this.readPreDeployScripts(); await this.readNoneItems(); + await this.readRefactorLogItems(); await this.readFolders(); } @@ -889,6 +917,7 @@ export class Project implements ISqlProject { await this.readPostDeployScripts(); await this.readNoneItems(); + await this.readRefactorLogItems(); await this.readFolders(); } @@ -945,6 +974,30 @@ export class Project implements ISqlProject { await this.readFolders(); } + //#region RefactorLog items + + public async addRefactorLogItem(relativePath: string): Promise { + const result = await ( + this.sqlProjService as vscodeMssql.ISqlProjectsService + ).addRefactorLogItem(this.projectFilePath, relativePath); + utils.throwIfFailed(result); + + await this.readRefactorLogItems(); + await this.readFolders(); + } + + public async deleteRefactorLogItem(relativePath: string): Promise { + const result = await ( + this.sqlProjService as vscodeMssql.ISqlProjectsService + ).deleteRefactorLogItem(this.projectFilePath, relativePath); + utils.throwIfFailed(result); + + await this.readRefactorLogItems(); + await this.readFolders(); + } + + //#endregion + //#endregion //#endregion @@ -1018,6 +1071,7 @@ export class Project implements ISqlProject { normalizedRelativeFilePath, ); await this.readNoneItems(); + await this.readRefactorLogItems(); } utils.throwIfFailed(result); diff --git a/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts b/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts index 7dd77efb2d..eaf0997233 100644 --- a/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts +++ b/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts @@ -144,6 +144,19 @@ export class NoneNode extends FileNode { } } +export class RefactorLogNode extends FileNode { + public override get treeItem(): vscode.TreeItem { + const treeItem = super.treeItem; + treeItem.contextValue = this.type; + + return treeItem; + } + + public get type(): DatabaseProjectItemType { + return DatabaseProjectItemType.refactorLogFile; + } +} + export class PublishProfileNode extends FileNode { public override get treeItem(): vscode.TreeItem { const treeItem = super.treeItem; diff --git a/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts b/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts index 969af09cca..c14db3980a 100644 --- a/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts +++ b/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts @@ -136,6 +136,16 @@ export class ProjectRootTreeItem extends BaseProjectTreeItem { this.addNode(newNode, noneEntry); } + // refactor log items + for (const refactorLogEntry of this.project.refactorLogItems) { + const newNode = new fileTree.RefactorLogNode( + refactorLogEntry.fsUri, + this.projectFileUri, + refactorLogEntry.relativePath, + ); + this.addNode(newNode, refactorLogEntry); + } + // publish profiles for (const publishProfile of this.project.publishProfiles) { const newNode = new fileTree.PublishProfileNode( diff --git a/extensions/sql-database-projects/test/baselines/baselines.ts b/extensions/sql-database-projects/test/baselines/baselines.ts index 1e7d62bc4c..4aa69172eb 100644 --- a/extensions/sql-database-projects/test/baselines/baselines.ts +++ b/extensions/sql-database-projects/test/baselines/baselines.ts @@ -31,6 +31,7 @@ export let openSdkStyleSqlProjectWithFilesSpecifiedBaseline: string; export let openSdkStyleSqlProjectWithGlobsSpecifiedBaseline: string; export let sqlProjPropertyReadBaseline: string; export let databaseReferencesReadBaseline: string; +export let openSqlProjectWithRefactorLogBaseline: string; const baselineFolderPath = path.join(__dirname, "..", "..", "..", "test", "baselines"); @@ -119,6 +120,10 @@ export async function loadBaselines() { baselineFolderPath, "databaseReferencesReadBaseline.xml", ); + openSqlProjectWithRefactorLogBaseline = await loadBaseline( + baselineFolderPath, + "openSqlProjectWithRefactorLogBaseline.xml", + ); } async function loadBaseline(baselineFolderPath: string, fileName: string): Promise { diff --git a/extensions/sql-database-projects/test/baselines/openSqlProjectWithRefactorLogBaseline.xml b/extensions/sql-database-projects/test/baselines/openSqlProjectWithRefactorLogBaseline.xml new file mode 100644 index 0000000000..1a87096cf2 --- /dev/null +++ b/extensions/sql-database-projects/test/baselines/openSqlProjectWithRefactorLogBaseline.xml @@ -0,0 +1,71 @@ + + + + Debug + AnyCPU + TestProjectName + 2.0 + 4.1 + {BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575} + Microsoft.Data.Tools.Schema.Sql.Sql150DatabaseSchemaProvider + Database + + + TestProjectName + TestProjectName + 1033, CI + BySchemaAndSchemaType + True + v4.5 + CS + Properties + False + True + True + + + bin\Release\ + $(MSBuildProjectName).sql + False + pdbonly + true + false + true + prompt + 4 + + + bin\Debug\ + $(MSBuildProjectName).sql + false + true + full + false + true + true + prompt + 4 + + + 11.0 + + True + 11.0 + + + + + + + + + + + + + + + + + + diff --git a/extensions/sql-database-projects/test/project.test.ts b/extensions/sql-database-projects/test/project.test.ts index 3fcbdae83e..bd98a5c44f 100644 --- a/extensions/sql-database-projects/test/project.test.ts +++ b/extensions/sql-database-projects/test/project.test.ts @@ -1670,6 +1670,62 @@ suite("Project: publish profiles", function (): void { }); }); +suite("Project: RefactorLog items", function (): void { + suiteSetup(async function (): Promise { + await baselines.loadBaselines(); + }); + + suiteTeardown(async function (): Promise { + await testUtils.deleteGeneratedTestFolder(); + }); + + test("Should read RefactorLog items from sqlproj", async function (): Promise { + const projFilePath = await testUtils.createTestSqlProjFile( + this.test, + baselines.openSqlProjectWithRefactorLogBaseline, + ); + const project = await Project.openProject(projFilePath); + + expect(project.refactorLogItems.length, "refactorLogItems should be loaded").to.equal(2); + expect( + project.refactorLogItems.find((f) => f.relativePath === "TestProjectName.refactorlog"), + "TestProjectName.refactorlog not read", + ).to.not.equal(undefined); + expect( + project.refactorLogItems.find( + (f) => f.relativePath === "Refactoring\\OldRenames.refactorlog", + ), + "Refactoring\\OldRenames.refactorlog not read", + ).to.not.equal(undefined); + }); + + test("Should add and delete RefactorLog item", async function (): Promise { + const projFilePath = await testUtils.createTestSqlProjFile( + this.test, + baselines.openProjectFileBaseline, + ); + const project = await Project.openProject(projFilePath); + expect(project.refactorLogItems.length, "Baseline number of RefactorLog items").to.equal(0); + + // Create the file on disk so STS can add it + const refactorLogName = "TestProject.refactorlog"; + const refactorLogPath = path.join(project.projectFolderPath, refactorLogName); + await fs.writeFile(refactorLogPath, ""); + + await project.addRefactorLogItem(refactorLogName); + expect(project.refactorLogItems.length, "RefactorLog item count after add").to.equal(1); + expect( + project.refactorLogItems.find((f) => f.relativePath === refactorLogName), + `${refactorLogName} should be in refactorLogItems`, + ).to.not.equal(undefined); + + await project.deleteRefactorLogItem(refactorLogName); + expect(project.refactorLogItems.length, "RefactorLog item count after delete").to.equal(0); + expect(await exists(refactorLogPath), "RefactorLog file should have been deleted from disk") + .to.be.false; + }); +}); + suite("Project: properties", function (): void { let sandbox: sinon.SinonSandbox; diff --git a/extensions/sql-database-projects/test/projectTree.test.ts b/extensions/sql-database-projects/test/projectTree.test.ts index d49300a9f1..b62dc83e99 100644 --- a/extensions/sql-database-projects/test/projectTree.test.ts +++ b/extensions/sql-database-projects/test/projectTree.test.ts @@ -14,6 +14,7 @@ import { FileNode, sortFileFolderNodes, SqlObjectFileNode, + RefactorLogNode, } from "../src/models/tree/fileFolderTreeItem"; import { ProjectRootTreeItem } from "../src/models/tree/projectTreeItem"; import { DatabaseProjectItemType } from "../src/common/constants"; @@ -190,4 +191,51 @@ suite("Project Tree tests", function (): void { "/TestProj/MyFile2.sql", ]); }); + + test("Should render RefactorLog items as RefactorLogNodes with correct context value", function (): void { + const root = os.platform() === "win32" ? "Z:\\" : "/"; + const proj = new Project(vscode.Uri.file(`${root}TestProj.sqlproj`).fsPath); + + proj.refactorLogItems.push( + proj.createFileProjectEntry("TestProj.refactorlog", EntryType.File), + ); + proj.refactorLogItems.push( + proj.createFileProjectEntry( + path.join("Refactoring", "OldRenames.refactorlog"), + EntryType.File, + ), + ); + proj.folders.push(proj.createFileProjectEntry("Refactoring", EntryType.Folder)); + + const tree = new ProjectRootTreeItem(proj); + + expect(tree.children.map((x) => x.relativeProjectUri.path)).to.deep.equal([ + "/TestProj/Database References", + "/TestProj/SQLCMD Variables", + "/TestProj/Refactoring", + "/TestProj/TestProj.refactorlog", + ]); + + const refactorLogNode = tree.children.find( + (x) => x.relativeProjectUri.path === "/TestProj/TestProj.refactorlog", + ); + expect(refactorLogNode, "Root RefactorLog node should exist").to.not.be.undefined; + expect(refactorLogNode).to.be.instanceOf(RefactorLogNode); + expect(refactorLogNode?.treeItem.contextValue).to.equal( + DatabaseProjectItemType.refactorLogFile, + ); + + const refactoringFolder = tree.children.find( + (x) => x.relativeProjectUri.path === "/TestProj/Refactoring", + ) as FolderNode; + expect(refactoringFolder, "Refactoring folder node should exist").to.not.be.undefined; + const nestedRefactorLogNode = refactoringFolder.children.find( + (x) => x.relativeProjectUri.path === "/TestProj/Refactoring/OldRenames.refactorlog", + ); + expect(nestedRefactorLogNode, "Nested RefactorLog node should exist").to.not.be.undefined; + expect(nestedRefactorLogNode).to.be.instanceOf(RefactorLogNode); + expect(nestedRefactorLogNode?.treeItem.contextValue).to.equal( + DatabaseProjectItemType.refactorLogFile, + ); + }); });