From 456163aba57df1ad00c5319aa1d0c4793843f92d Mon Sep 17 00:00:00 2001 From: Koen Vlaswinkel Date: Wed, 1 Nov 2023 11:03:20 +0100 Subject: [PATCH] Prevent duplicate query packs when creating a query This prevents the creation of duplicate query pack names when creating a query in the following ways: - When you have selected a folder, the query pack name will include the name of the folder. This should prevent duplicate query pack names when creating queries in different folders. - When the folder name includes `codeql` or `queries`, we will not add `codeql-extra-queries-` since that would be redundant. - After generating the query pack name, we will resolve all qlpacks and check if one with this name already exists. If it does, we will start adding an index to the name until we find a unique name. --- .../src/local-queries/qlpack-generator.ts | 41 ++++- .../local-queries/skeleton-query-wizard.ts | 51 +++--- .../qlpack-generator.test.ts | 145 +++++++++++++++++- 3 files changed, 207 insertions(+), 30 deletions(-) diff --git a/extensions/ql-vscode/src/local-queries/qlpack-generator.ts b/extensions/ql-vscode/src/local-queries/qlpack-generator.ts index 7b5476a7e..1ed61243b 100644 --- a/extensions/ql-vscode/src/local-queries/qlpack-generator.ts +++ b/extensions/ql-vscode/src/local-queries/qlpack-generator.ts @@ -1,12 +1,14 @@ import { mkdir, writeFile } from "fs-extra"; import { dump } from "js-yaml"; -import { join } from "path"; +import { dirname, join } from "path"; import { Uri } from "vscode"; import { CodeQLCliServer } from "../codeql-cli/cli"; import { QueryLanguage } from "../common/query-language"; +import { getOnDiskWorkspaceFolders } from "../common/vscode/workspace-folders"; +import { basename } from "../common/path"; export class QlPackGenerator { - private readonly qlpackName: string; + private qlpackName: string | undefined; private readonly qlpackVersion: string; private readonly header: string; private readonly qlpackFileName: string; @@ -16,8 +18,8 @@ export class QlPackGenerator { private readonly queryLanguage: QueryLanguage, private readonly cliServer: CodeQLCliServer, private readonly storagePath: string, + private readonly includeFolderNameInQlpackName: boolean = false, ) { - this.qlpackName = `getting-started/codeql-extra-queries-${this.queryLanguage}`; this.qlpackVersion = "1.0.0"; this.header = "# This is an automatically generated file.\n\n"; @@ -26,6 +28,8 @@ export class QlPackGenerator { } public async generate() { + this.qlpackName = await this.determineQlpackName(); + // create QL pack folder and add to workspace await this.createWorkspaceFolder(); @@ -39,6 +43,37 @@ export class QlPackGenerator { await this.createCodeqlPackLockYaml(); } + private async determineQlpackName(): Promise { + let qlpackBaseName = `getting-started/codeql-extra-queries-${this.queryLanguage}`; + if (this.includeFolderNameInQlpackName) { + const folderBasename = basename(dirname(this.folderUri.fsPath)); + if ( + folderBasename.includes("codeql") || + folderBasename.includes("queries") + ) { + // If the user has already included "codeql" or "queries" in the folder name, don't include it twice + qlpackBaseName = `getting-started/${folderBasename}-${this.queryLanguage}`; + } else { + qlpackBaseName = `getting-started/codeql-extra-queries-${folderBasename}-${this.queryLanguage}`; + } + } + + const existingQlPacks = await this.cliServer.resolveQlpacks( + getOnDiskWorkspaceFolders(), + ); + const existingQlPackNames = Object.keys(existingQlPacks); + + let qlpackName = qlpackBaseName; + let i = 0; + while (existingQlPackNames.includes(qlpackName)) { + i++; + + qlpackName = `${qlpackBaseName}-${i}`; + } + + return qlpackName; + } + private async createWorkspaceFolder() { await mkdir(this.folderUri.fsPath); } diff --git a/extensions/ql-vscode/src/local-queries/skeleton-query-wizard.ts b/extensions/ql-vscode/src/local-queries/skeleton-query-wizard.ts index a05b69a44..899ccf779 100644 --- a/extensions/ql-vscode/src/local-queries/skeleton-query-wizard.ts +++ b/extensions/ql-vscode/src/local-queries/skeleton-query-wizard.ts @@ -34,7 +34,7 @@ import { showInformationMessageWithAction } from "../common/vscode/dialog"; import { redactableError } from "../common/errors"; import { App } from "../common/app"; import { QueryTreeViewItem } from "../queries-panel/query-tree-view-item"; -import { containsPath } from "../common/files"; +import { containsPath, pathsEqual } from "../common/files"; import { getQlPackPath } from "../common/ql"; import { load } from "js-yaml"; import { QlPackFile } from "../packaging/qlpack-file"; @@ -284,13 +284,6 @@ export class SkeletonQueryWizard { } private async createQlPack() { - if (this.qlPackStoragePath === undefined) { - throw new Error("Query pack storage path is undefined"); - } - if (this.language === undefined) { - throw new Error("Language is undefined"); - } - this.progress({ message: "Creating skeleton QL pack around query", step: 2, @@ -298,11 +291,7 @@ export class SkeletonQueryWizard { }); try { - const qlPackGenerator = new QlPackGenerator( - this.language, - this.cliServer, - this.qlPackStoragePath, - ); + const qlPackGenerator = this.createQlPackGenerator(); await qlPackGenerator.generate(); } catch (e: unknown) { @@ -313,13 +302,6 @@ export class SkeletonQueryWizard { } private async createExampleFile() { - if (this.qlPackStoragePath === undefined) { - throw new Error("Folder name is undefined"); - } - if (this.language === undefined) { - throw new Error("Language is undefined"); - } - this.progress({ message: "Skeleton query pack already exists. Creating additional query example file.", @@ -328,11 +310,7 @@ export class SkeletonQueryWizard { }); try { - const qlPackGenerator = new QlPackGenerator( - this.language, - this.cliServer, - this.qlPackStoragePath, - ); + const qlPackGenerator = this.createQlPackGenerator(); this.fileName = await this.determineNextFileName(); await qlPackGenerator.createExampleQlFile(this.fileName); @@ -475,6 +453,29 @@ export class SkeletonQueryWizard { return `[${this.fileName}](command:vscode.open?${queryString})`; } + private createQlPackGenerator() { + if (this.qlPackStoragePath === undefined) { + throw new Error("Query pack storage path is undefined"); + } + if (this.language === undefined) { + throw new Error("Language is undefined"); + } + + const parentFolder = dirname(this.qlPackStoragePath); + + // Only include the folder name in the qlpack name if the qlpack is not in the root of the workspace. + const includeFolderNameInQlpackName = !getOnDiskWorkspaceFolders().some( + (workspaceFolder) => pathsEqual(workspaceFolder, parentFolder), + ); + + return new QlPackGenerator( + this.language, + this.cliServer, + this.qlPackStoragePath, + includeFolderNameInQlpackName, + ); + } + public static async findDatabaseItemByNwo( language: string, databaseNwo: string, diff --git a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/qlpack-generator.test.ts b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/qlpack-generator.test.ts index e8b4d5baa..b8e3abb1f 100644 --- a/extensions/ql-vscode/test/vscode-tests/minimal-workspace/qlpack-generator.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/minimal-workspace/qlpack-generator.test.ts @@ -7,6 +7,9 @@ import { Uri, workspace } from "vscode"; import { getErrorMessage } from "../../../src/common/helpers-pure"; import * as tmp from "tmp"; import { mockedObject } from "../utils/mocking.helpers"; +import { ensureDir, readFile } from "fs-extra"; +import { load } from "js-yaml"; +import { QlPackFile } from "../../../src/packaging/qlpack-file"; describe("QlPackGenerator", () => { let packFolderPath: string; @@ -14,7 +17,11 @@ describe("QlPackGenerator", () => { let exampleQlFilePath: string; let language: string; let generator: QlPackGenerator; - let packAddSpy: jest.Mock; + let packAddSpy: jest.MockedFunction; + let resolveQlpacksSpy: jest.MockedFunction< + typeof CodeQLCliServer.prototype.resolveQlpacks + >; + let mockCli: CodeQLCliServer; let dir: tmp.DirResult; beforeEach(async () => { @@ -29,8 +36,10 @@ describe("QlPackGenerator", () => { exampleQlFilePath = join(packFolderPath, "example.ql"); packAddSpy = jest.fn(); - const mockCli = mockedObject({ + resolveQlpacksSpy = jest.fn().mockResolvedValue({}); + mockCli = mockedObject({ packAdd: packAddSpy, + resolveQlpacks: resolveQlpacksSpy, }); generator = new QlPackGenerator( @@ -71,5 +80,137 @@ describe("QlPackGenerator", () => { expect(existsSync(exampleQlFilePath)).toBe(true); expect(packAddSpy).toHaveBeenCalledWith(packFolderPath, language); + + const qlpack = load( + await readFile(qlPackYamlFilePath, "utf8"), + ) as QlPackFile; + expect(qlpack).toEqual( + expect.objectContaining({ + name: "getting-started/codeql-extra-queries-ruby", + }), + ); + }); + + describe("when a pack with the same name already exists", () => { + beforeEach(() => { + resolveQlpacksSpy.mockResolvedValue({ + "getting-started/codeql-extra-queries-ruby": ["/path/to/pack"], + }); + }); + + it("should change the name of the pack", async () => { + await generator.generate(); + + const qlpack = load( + await readFile(qlPackYamlFilePath, "utf8"), + ) as QlPackFile; + expect(qlpack).toEqual( + expect.objectContaining({ + name: "getting-started/codeql-extra-queries-ruby-1", + }), + ); + }); + }); + + describe("when the folder name is included in the pack name", () => { + beforeEach(async () => { + const parentFolderPath = join(dir.name, "my-folder"); + + packFolderPath = Uri.file( + join(parentFolderPath, `test-ql-pack-${language}`), + ).fsPath; + await ensureDir(parentFolderPath); + + qlPackYamlFilePath = join(packFolderPath, "codeql-pack.yml"); + exampleQlFilePath = join(packFolderPath, "example.ql"); + + generator = new QlPackGenerator( + language as QueryLanguage, + mockCli, + packFolderPath, + true, + ); + }); + + it("should set the name of the pack", async () => { + await generator.generate(); + + const qlpack = load( + await readFile(qlPackYamlFilePath, "utf8"), + ) as QlPackFile; + expect(qlpack).toEqual( + expect.objectContaining({ + name: "getting-started/codeql-extra-queries-my-folder-ruby", + }), + ); + }); + + describe("when the folder name includes codeql", () => { + beforeEach(async () => { + const parentFolderPath = join(dir.name, "my-codeql"); + + packFolderPath = Uri.file( + join(parentFolderPath, `test-ql-pack-${language}`), + ).fsPath; + await ensureDir(parentFolderPath); + + qlPackYamlFilePath = join(packFolderPath, "codeql-pack.yml"); + exampleQlFilePath = join(packFolderPath, "example.ql"); + + generator = new QlPackGenerator( + language as QueryLanguage, + mockCli, + packFolderPath, + true, + ); + }); + + it("should set the name of the pack", async () => { + await generator.generate(); + + const qlpack = load( + await readFile(qlPackYamlFilePath, "utf8"), + ) as QlPackFile; + expect(qlpack).toEqual( + expect.objectContaining({ + name: "getting-started/my-codeql-ruby", + }), + ); + }); + }); + + describe("when the folder name includes queries", () => { + beforeEach(async () => { + const parentFolderPath = join(dir.name, "my-queries"); + + packFolderPath = Uri.file( + join(parentFolderPath, `test-ql-pack-${language}`), + ).fsPath; + await ensureDir(parentFolderPath); + + qlPackYamlFilePath = join(packFolderPath, "codeql-pack.yml"); + exampleQlFilePath = join(packFolderPath, "example.ql"); + + generator = new QlPackGenerator( + language as QueryLanguage, + mockCli, + packFolderPath, + true, + ); + }); + + it("should set the name of the pack", async () => { + await generator.generate(); + + const qlpack = load( + await readFile(qlPackYamlFilePath, "utf8"), + ) as QlPackFile; + expect(qlpack).toEqual( + expect.objectContaining({ + name: "getting-started/my-queries-ruby", + }), + ); + }); + }); }); });