diff --git a/extensions/ql-vscode/src/compare/compare-interface.ts b/extensions/ql-vscode/src/compare/compare-interface.ts index 81a0963f3..1da1fc429 100644 --- a/extensions/ql-vscode/src/compare/compare-interface.ts +++ b/extensions/ql-vscode/src/compare/compare-interface.ts @@ -20,11 +20,11 @@ import { DatabaseManager } from '../databases'; import { getHtmlForWebview, jumpToLocation } from '../interface-utils'; import { transformBqrsResultSet, RawResultSet, BQRSInfo } from '../pure/bqrs-cli-types'; import resultsDiff from './resultsDiff'; -import { FullCompletedQueryInfo } from '../query-results'; +import { CompletedLocalQueryInfo } from '../query-results'; interface ComparePair { - from: FullCompletedQueryInfo; - to: FullCompletedQueryInfo; + from: CompletedLocalQueryInfo; + to: CompletedLocalQueryInfo; } export class CompareInterfaceManager extends DisposableObject { @@ -39,15 +39,15 @@ export class CompareInterfaceManager extends DisposableObject { private cliServer: CodeQLCliServer, private logger: Logger, private showQueryResultsCallback: ( - item: FullCompletedQueryInfo + item: CompletedLocalQueryInfo ) => Promise ) { super(); } async showResults( - from: FullCompletedQueryInfo, - to: FullCompletedQueryInfo, + from: CompletedLocalQueryInfo, + to: CompletedLocalQueryInfo, selectedResultSetName?: string ) { this.comparePair = { from, to }; @@ -188,8 +188,8 @@ export class CompareInterfaceManager extends DisposableObject { } private async findCommonResultSetNames( - from: FullCompletedQueryInfo, - to: FullCompletedQueryInfo, + from: CompletedLocalQueryInfo, + to: CompletedLocalQueryInfo, selectedResultSetName: string | undefined ): Promise<[string[], string, RawResultSet, RawResultSet]> { const fromSchemas = await this.cliServer.bqrsInfo( diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index 5e56ea056..345d1e304 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -70,7 +70,7 @@ import { InterfaceManager } from './interface'; import { WebviewReveal } from './interface-utils'; import { ideServerLogger, logger, queryServerLogger } from './logging'; import { QueryHistoryManager } from './query-history'; -import { FullCompletedQueryInfo, FullQueryInfo } from './query-results'; +import { CompletedLocalQueryInfo, LocalQueryInfo } from './query-results'; import * as qsClient from './queryserver-client'; import { displayQuickQuery } from './quick-query'; import { compileAndRunQueryAgainstDatabase, createInitialQueryInfo } from './run-queries'; @@ -443,7 +443,7 @@ async function activateWithInstalledDistribution( void logger.log('Initializing query history manager.'); const queryHistoryConfigurationListener = new QueryHistoryConfigListener(); ctx.subscriptions.push(queryHistoryConfigurationListener); - const showResults = async (item: FullCompletedQueryInfo) => + const showResults = async (item: CompletedLocalQueryInfo) => showResultsForCompletedQuery(item, WebviewReveal.Forced); const queryStorageDir = path.join(ctx.globalStorageUri.fsPath, 'queries'); await fs.ensureDir(queryStorageDir); @@ -456,7 +456,7 @@ async function activateWithInstalledDistribution( ctx, queryHistoryConfigurationListener, showResults, - async (from: FullCompletedQueryInfo, to: FullCompletedQueryInfo) => + async (from: CompletedLocalQueryInfo, to: CompletedLocalQueryInfo) => showResultsForComparison(from, to), ); await qhm.readQueryHistory(); @@ -480,8 +480,8 @@ async function activateWithInstalledDistribution( archiveFilesystemProvider.activate(ctx); async function showResultsForComparison( - from: FullCompletedQueryInfo, - to: FullCompletedQueryInfo + from: CompletedLocalQueryInfo, + to: CompletedLocalQueryInfo ): Promise { try { await cmpm.showResults(from, to); @@ -491,7 +491,7 @@ async function activateWithInstalledDistribution( } async function showResultsForCompletedQuery( - query: FullCompletedQueryInfo, + query: CompletedLocalQueryInfo, forceReveal: WebviewReveal ): Promise { await intm.showResults(query, forceReveal, false); @@ -521,7 +521,7 @@ async function activateWithInstalledDistribution( token.onCancellationRequested(() => source.cancel()); const initialInfo = await createInitialQueryInfo(selectedQuery, databaseInfo, quickEval, range); - const item = new FullQueryInfo(initialInfo, queryHistoryConfigurationListener, source); + const item = new LocalQueryInfo(initialInfo, queryHistoryConfigurationListener, source); qhm.addQuery(item); try { const completedQueryInfo = await compileAndRunQueryAgainstDatabase( @@ -535,7 +535,7 @@ async function activateWithInstalledDistribution( ); item.completeThisQuery(completedQueryInfo); await qhm.writeQueryHistory(); - await showResultsForCompletedQuery(item as FullCompletedQueryInfo, WebviewReveal.NotForced); + await showResultsForCompletedQuery(item as CompletedLocalQueryInfo, WebviewReveal.NotForced); // Note we must update the query history view after showing results as the // display and sorting might depend on the number of results } catch (e) { diff --git a/extensions/ql-vscode/src/interface.ts b/extensions/ql-vscode/src/interface.ts index 4e3ff782a..12e007014 100644 --- a/extensions/ql-vscode/src/interface.ts +++ b/extensions/ql-vscode/src/interface.ts @@ -47,7 +47,7 @@ import { import { getDefaultResultSetName, ParsedResultSets } from './pure/interface-types'; import { RawResultSet, transformBqrsResultSet, ResultSetSchema } from './pure/bqrs-cli-types'; import { PAGE_SIZE } from './config'; -import { FullCompletedQueryInfo } from './query-results'; +import { CompletedLocalQueryInfo } from './query-results'; /** * interface.ts @@ -97,7 +97,7 @@ function numInterpretedPages(interpretation: Interpretation | undefined): number } export class InterfaceManager extends DisposableObject { - private _displayedQuery?: FullCompletedQueryInfo; + private _displayedQuery?: CompletedLocalQueryInfo; private _interpretation?: Interpretation; private _panel: vscode.WebviewPanel | undefined; private _panelLoaded = false; @@ -357,7 +357,7 @@ export class InterfaceManager extends DisposableObject { * history entry. */ public async showResults( - fullQuery: FullCompletedQueryInfo, + fullQuery: CompletedLocalQueryInfo, forceReveal: WebviewReveal, shouldKeepOldResultsWhileRendering = false ): Promise { diff --git a/extensions/ql-vscode/src/query-history.ts b/extensions/ql-vscode/src/query-history.ts index 539ab9351..78376a180 100644 --- a/extensions/ql-vscode/src/query-history.ts +++ b/extensions/ql-vscode/src/query-history.ts @@ -29,9 +29,11 @@ import { QueryServerClient } from './queryserver-client'; import { DisposableObject } from './pure/disposable-object'; import { commandRunner } from './commandRunner'; import { assertNever, ONE_HOUR_IN_MS, TWO_HOURS_IN_MS } from './pure/helpers-pure'; -import { FullCompletedQueryInfo, FullQueryInfo, QueryStatus } from './query-results'; +import { CompletedLocalQueryInfo, LocalQueryInfo as LocalQueryInfo, QueryHistoryInfo } from './query-results'; import { DatabaseManager } from './databases'; import { registerQueryHistoryScubber } from './query-history-scrubber'; +import { QueryStatus } from './query-status'; +import { slurpQueryHistory, splatQueryHistory } from './query-serialization'; /** * query-history.ts @@ -99,18 +101,18 @@ const WORKSPACE_QUERY_HISTORY_FILE = 'workspace-query-history.json'; export class HistoryTreeDataProvider extends DisposableObject { private _sortOrder = SortOrder.DateAsc; - private _onDidChangeTreeData = super.push(new EventEmitter()); + private _onDidChangeTreeData = super.push(new EventEmitter()); - readonly onDidChangeTreeData: Event = this + readonly onDidChangeTreeData: Event = this ._onDidChangeTreeData.event; - private history: FullQueryInfo[] = []; + private history: QueryHistoryInfo[] = []; private failedIconPath: string; private localSuccessIconPath: string; - private current: FullQueryInfo | undefined; + private current: QueryHistoryInfo | undefined; constructor(extensionPath: string) { super(); @@ -124,7 +126,7 @@ export class HistoryTreeDataProvider extends DisposableObject { ); } - async getTreeItem(element: FullQueryInfo): Promise { + async getTreeItem(element: QueryHistoryInfo): Promise { const treeItem = new TreeItem(element.label); treeItem.command = { @@ -134,36 +136,52 @@ export class HistoryTreeDataProvider extends DisposableObject { tooltip: element.failureReason || element.label }; - // Populate the icon and the context value. We use the context value to - // control which commands are visible in the context menu. - let hasResults; - switch (element.status) { - case QueryStatus.InProgress: - treeItem.iconPath = new ThemeIcon('sync~spin'); - treeItem.contextValue = 'inProgressResultsItem'; - break; - case QueryStatus.Completed: - hasResults = await element.completedQuery?.query.hasInterpretedResults(); - treeItem.iconPath = this.localSuccessIconPath; - treeItem.contextValue = hasResults - ? 'interpretedResultsItem' - : 'rawResultsItem'; - break; - case QueryStatus.Failed: - treeItem.iconPath = this.failedIconPath; - treeItem.contextValue = 'cancelledResultsItem'; - break; - default: - assertNever(element.status); + if (element.t === 'local') { + // Populate the icon and the context value. We use the context value to + // control which commands are visible in the context menu. + let hasResults; + switch (element.status) { + case QueryStatus.InProgress: + treeItem.iconPath = new ThemeIcon('sync~spin'); + treeItem.contextValue = 'inProgressResultsItem'; + break; + case QueryStatus.Completed: + hasResults = await element.completedQuery?.query.hasInterpretedResults(); + treeItem.iconPath = this.localSuccessIconPath; + treeItem.contextValue = hasResults + ? 'interpretedResultsItem' + : 'rawResultsItem'; + break; + case QueryStatus.Failed: + treeItem.iconPath = this.failedIconPath; + treeItem.contextValue = 'cancelledResultsItem'; + break; + default: + assertNever(element.status); + } + } else { + // TODO remote queries are not implemented yet. } return treeItem; } getChildren( - element?: FullQueryInfo - ): ProviderResult { + element?: QueryHistoryInfo + ): ProviderResult { return element ? [] : this.history.sort((h1, h2) => { + + // TODO remote queries are not implemented yet. + if (h1.t !== 'local' && h2.t !== 'local') { + return 0; + } + if (h1.t !== 'local') { + return -1; + } + if (h2.t !== 'local') { + return 1; + } + const resultCount1 = h1.completedQuery?.resultCount ?? -1; const resultCount2 = h2.completedQuery?.resultCount ?? -1; @@ -192,25 +210,25 @@ export class HistoryTreeDataProvider extends DisposableObject { }); } - getParent(_element: FullQueryInfo): ProviderResult { + getParent(_element: QueryHistoryInfo): ProviderResult { return null; } - getCurrent(): FullQueryInfo | undefined { + getCurrent(): QueryHistoryInfo | undefined { return this.current; } - pushQuery(item: FullQueryInfo): void { + pushQuery(item: QueryHistoryInfo): void { this.history.push(item); this.setCurrentItem(item); this.refresh(); } - setCurrentItem(item?: FullQueryInfo) { + setCurrentItem(item?: QueryHistoryInfo) { this.current = item; } - remove(item: FullQueryInfo) { + remove(item: QueryHistoryInfo) { const isCurrent = this.current === item; if (isCurrent) { this.setCurrentItem(); @@ -227,11 +245,11 @@ export class HistoryTreeDataProvider extends DisposableObject { } } - get allHistory(): FullQueryInfo[] { + get allHistory(): QueryHistoryInfo[] { return this.history; } - set allHistory(history: FullQueryInfo[]) { + set allHistory(history: QueryHistoryInfo[]) { this.history = history; this.current = history[0]; this.refresh(); @@ -254,9 +272,9 @@ export class HistoryTreeDataProvider extends DisposableObject { export class QueryHistoryManager extends DisposableObject { treeDataProvider: HistoryTreeDataProvider; - treeView: TreeView; - lastItemClick: { time: Date; item: FullQueryInfo } | undefined; - compareWithItem: FullQueryInfo | undefined; + treeView: TreeView; + lastItemClick: { time: Date; item: QueryHistoryInfo } | undefined; + compareWithItem: LocalQueryInfo | undefined; queryHistoryScrubber: Disposable | undefined; private queryMetadataStorageLocation; @@ -266,10 +284,10 @@ export class QueryHistoryManager extends DisposableObject { private queryStorageDir: string, ctx: ExtensionContext, private queryHistoryConfigListener: QueryHistoryConfig, - private selectedCallback: (item: FullCompletedQueryInfo) => Promise, + private selectedCallback: (item: CompletedLocalQueryInfo) => Promise, private doCompareCallback: ( - from: FullCompletedQueryInfo, - to: FullCompletedQueryInfo + from: CompletedLocalQueryInfo, + to: CompletedLocalQueryInfo ) => Promise ) { super(); @@ -303,7 +321,12 @@ export class QueryHistoryManager extends DisposableObject { } else { this.treeDataProvider.setCurrentItem(ev.selection[0]); } - this.updateCompareWith([...ev.selection]); + if (ev.selection.some(item => item.t !== 'local')) { + // Don't allow comparison of non-local items + this.updateCompareWith([]); + } else { + this.updateCompareWith([...ev.selection] as LocalQueryInfo[]); + } }) ); @@ -395,7 +418,7 @@ export class QueryHistoryManager extends DisposableObject { this.push( commandRunner( 'codeQLQueryHistory.itemClicked', - async (item: FullQueryInfo) => { + async (item: LocalQueryInfo) => { return this.handleItemClicked(item, [item]); } ) @@ -449,28 +472,29 @@ export class QueryHistoryManager extends DisposableObject { async readQueryHistory(): Promise { void logger.log(`Reading cached query history from '${this.queryMetadataStorageLocation}'.`); - const history = await FullQueryInfo.slurp(this.queryMetadataStorageLocation, this.queryHistoryConfigListener); + const history = await slurpQueryHistory(this.queryMetadataStorageLocation, this.queryHistoryConfigListener); this.treeDataProvider.allHistory = history; } async writeQueryHistory(): Promise { const toSave = this.treeDataProvider.allHistory.filter(q => q.isCompleted()); - await FullQueryInfo.splat(toSave, this.queryMetadataStorageLocation); + await splatQueryHistory(toSave, this.queryMetadataStorageLocation); } - async invokeCallbackOn(queryHistoryItem: FullQueryInfo) { + async invokeCallbackOn(queryHistoryItem: QueryHistoryInfo) { if (this.selectedCallback && queryHistoryItem.isCompleted()) { const sc = this.selectedCallback; - await sc(queryHistoryItem as FullCompletedQueryInfo); + await sc(queryHistoryItem as CompletedLocalQueryInfo); } } async handleOpenQuery( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ): Promise { + // TODO will support remote queries const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); - if (!this.assertSingleQuery(finalMultiSelect)) { + if (!this.assertSingleQuery(finalMultiSelect) || (finalSingleItem && finalSingleItem.t !== 'local')) { return; } @@ -499,12 +523,17 @@ export class QueryHistoryManager extends DisposableObject { } async handleRemoveHistoryItem( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); const toDelete = (finalMultiSelect || [finalSingleItem]); await Promise.all(toDelete.map(async (item) => { + // TODO Remote queries are not implemented yet + if (item.t !== 'local') { + return; + } + // Removing in progress queries is not supported. They must be cancelled first. if (item.status !== QueryStatus.InProgress) { this.treeDataProvider.remove(item); @@ -548,12 +577,13 @@ export class QueryHistoryManager extends DisposableObject { } async handleSetLabel( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ): Promise { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); - if (!this.assertSingleQuery(finalMultiSelect)) { + // TODO will support remote queries + if (!this.assertSingleQuery(finalMultiSelect) || finalSingleItem?.t !== 'local') { return; } @@ -571,21 +601,26 @@ export class QueryHistoryManager extends DisposableObject { } async handleCompareWith( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); try { + // local queries only + if (finalSingleItem?.t !== 'local') { + throw new Error('Please select a local query.'); + } + if (!finalSingleItem.completedQuery?.didRunSuccessfully) { - throw new Error('Please select a successful query.'); + throw new Error('Please select a query that has completed successfully.'); } const from = this.compareWithItem || singleItem; const to = await this.findOtherQueryToCompare(from, finalMultiSelect); if (from.isCompleted() && to?.isCompleted()) { - await this.doCompareCallback(from as FullCompletedQueryInfo, to as FullCompletedQueryInfo); + await this.doCompareCallback(from as CompletedLocalQueryInfo, to as CompletedLocalQueryInfo); } } catch (e) { void showAndLogErrorMessage(e.message); @@ -593,11 +628,12 @@ export class QueryHistoryManager extends DisposableObject { } async handleItemClicked( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ) { + // TODO will support remote queries const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); - if (!this.assertSingleQuery(finalMultiSelect)) { + if (!this.assertSingleQuery(finalMultiSelect) || (finalSingleItem && finalSingleItem?.t !== 'local')) { return; } @@ -625,9 +661,10 @@ export class QueryHistoryManager extends DisposableObject { } async handleShowQueryLog( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: LocalQueryInfo, + multiSelect: LocalQueryInfo[] ) { + // Local queries only if (!this.assertSingleQuery(multiSelect)) { return; } @@ -644,25 +681,28 @@ export class QueryHistoryManager extends DisposableObject { } async handleCancel( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ) { + // Local queries only + // In the future, we may support cancelling remote queries, but this is not a short term plan. const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); (finalMultiSelect || [finalSingleItem]).forEach((item) => { - if (item.status === QueryStatus.InProgress) { + if (item.status === QueryStatus.InProgress && item.t === 'local') { item.cancel(); } }); } async handleShowQueryText( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); - if (!this.assertSingleQuery(finalMultiSelect)) { + // TODO will support remote queries + if (!this.assertSingleQuery(finalMultiSelect) || (finalSingleItem && finalSingleItem?.t !== 'local')) { return; } @@ -682,12 +722,13 @@ export class QueryHistoryManager extends DisposableObject { } async handleViewSarifAlerts( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); - if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem.completedQuery) { + // Local queries only + if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local' || !finalSingleItem.completedQuery) { return; } @@ -706,15 +747,13 @@ export class QueryHistoryManager extends DisposableObject { } async handleViewCsvResults( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); - if (!this.assertSingleQuery(finalMultiSelect)) { - return; - } - if (!finalSingleItem.completedQuery) { + // Local queries only + if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local' || !finalSingleItem.completedQuery) { return; } const query = finalSingleItem.completedQuery.query; @@ -730,12 +769,13 @@ export class QueryHistoryManager extends DisposableObject { } async handleViewCsvAlerts( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); - if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem.completedQuery) { + // Local queries only + if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local' || !finalSingleItem.completedQuery) { return; } @@ -745,15 +785,13 @@ export class QueryHistoryManager extends DisposableObject { } async handleViewDil( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[], + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[], ) { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); - if (!this.assertSingleQuery(finalMultiSelect)) { - return; - } - if (!finalSingleItem.completedQuery) { + // Local queries only + if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'local' || !finalSingleItem.completedQuery) { return; } @@ -762,11 +800,11 @@ export class QueryHistoryManager extends DisposableObject { ); } - async getQueryText(queryHistoryItem: FullQueryInfo): Promise { + async getQueryText(queryHistoryItem: LocalQueryInfo): Promise { return queryHistoryItem.initialInfo.queryText; } - addQuery(item: FullQueryInfo) { + addQuery(item: LocalQueryInfo) { this.treeDataProvider.pushQuery(item); this.updateTreeViewSelectionIfVisible(); } @@ -825,10 +863,12 @@ the file in the file explorer and dragging it into the workspace.` } private async findOtherQueryToCompare( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] - ): Promise { - if (!singleItem.completedQuery) { + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] + ): Promise { + + // Remote queries cannot be compared + if (singleItem.t !== 'local' || multiSelect.some(s => s.t !== 'local') || !singleItem.completedQuery) { return undefined; } const dbName = singleItem.initialInfo.databaseInfo.name; @@ -837,7 +877,7 @@ the file in the file explorer and dragging it into the workspace.` if (multiSelect?.length === 2) { // return the query that is not the first selected one const otherQuery = - singleItem === multiSelect[0] ? multiSelect[1] : multiSelect[0]; + (singleItem === multiSelect[0] ? multiSelect[1] : multiSelect[0]) as LocalQueryInfo; if (!otherQuery.completedQuery) { throw new Error('Please select a completed query.'); } @@ -847,10 +887,10 @@ the file in the file explorer and dragging it into the workspace.` if (otherQuery.initialInfo.databaseInfo.name !== dbName) { throw new Error('Query databases must be the same.'); } - return otherQuery; + return otherQuery as CompletedLocalQueryInfo; } - if (multiSelect?.length > 1) { + if (multiSelect?.length > 2) { throw new Error('Please select no more than 2 queries.'); } @@ -859,15 +899,16 @@ the file in the file explorer and dragging it into the workspace.` .filter( (otherQuery) => otherQuery !== singleItem && + otherQuery.t === 'local' && otherQuery.completedQuery && otherQuery.completedQuery.didRunSuccessfully && otherQuery.initialInfo.databaseInfo.name === dbName ) .map((item) => ({ label: item.label, - description: item.initialInfo.databaseInfo.name, - detail: item.completedQuery!.statusString, - query: item, + description: (item as CompletedLocalQueryInfo).initialInfo.databaseInfo.name, + detail: (item as CompletedLocalQueryInfo).completedQuery.statusString, + query: item as CompletedLocalQueryInfo, })); if (comparableQueryLabels.length < 1) { throw new Error('No other queries available to compare with.'); @@ -876,7 +917,7 @@ the file in the file explorer and dragging it into the workspace.` return choice?.query; } - private assertSingleQuery(multiSelect: FullQueryInfo[] = [], message = 'Please select a single query.') { + private assertSingleQuery(multiSelect: QueryHistoryInfo[] = [], message = 'Please select a single query.') { if (multiSelect.length > 1) { void showAndLogErrorMessage( message @@ -903,7 +944,7 @@ the file in the file explorer and dragging it into the workspace.` * * @param newSelection the new selection after the most recent selection change */ - private updateCompareWith(newSelection: FullQueryInfo[]) { + private updateCompareWith(newSelection: LocalQueryInfo[]) { if (newSelection.length === 1) { this.compareWithItem = newSelection[0]; } else if ( @@ -927,11 +968,11 @@ the file in the file explorer and dragging it into the workspace.` * @param multiSelect a multi-select or undefined if no items are selected */ private determineSelection( - singleItem: FullQueryInfo, - multiSelect: FullQueryInfo[] + singleItem: QueryHistoryInfo, + multiSelect: QueryHistoryInfo[] ): { - finalSingleItem: FullQueryInfo; - finalMultiSelect: FullQueryInfo[] + finalSingleItem: QueryHistoryInfo; + finalMultiSelect: QueryHistoryInfo[] } { if (!singleItem && !multiSelect?.[0]) { const selection = this.treeView.selection; diff --git a/extensions/ql-vscode/src/query-results.ts b/extensions/ql-vscode/src/query-results.ts index eca6c4de8..784c6fe1c 100644 --- a/extensions/ql-vscode/src/query-results.ts +++ b/extensions/ql-vscode/src/query-results.ts @@ -15,8 +15,8 @@ import { } from './pure/interface-types'; import { QueryHistoryConfig } from './config'; import { DatabaseInfo } from './pure/interface-types'; -import { showAndLogErrorMessage } from './helpers'; -import { asyncFilter } from './pure/helpers-pure'; +import { QueryStatus } from './query-status'; +import { RemoteQueryHistoryItem } from './remote-queries/remote-query-history-item'; /** * query-results.ts @@ -42,12 +42,6 @@ export interface InitialQueryInfo { readonly id: string; // unique id for this query. } -export enum QueryStatus { - InProgress = 'InProgress', - Completed = 'Completed', - Failed = 'Failed', -} - export class CompletedQueryInfo implements QueryWithResults { readonly query: QueryEvaluationInfo; readonly result: messages.EvaluationResult; @@ -191,88 +185,21 @@ export function ensureMetadataIsComplete(metadata: QueryMetadata | undefined) { /** * Used in Interface and Compare-Interface for queries that we know have been complated. */ -export type FullCompletedQueryInfo = FullQueryInfo & { +export type CompletedLocalQueryInfo = LocalQueryInfo & { completedQuery: CompletedQueryInfo }; -export class FullQueryInfo { +export type QueryHistoryInfo = LocalQueryInfo | RemoteQueryHistoryItem; - static async slurp(fsPath: string, config: QueryHistoryConfig): Promise { - try { - if (!(await fs.pathExists(fsPath))) { - return []; - } - - const data = await fs.readFile(fsPath, 'utf8'); - const queries = JSON.parse(data); - const parsedQueries = queries.map((q: FullQueryInfo) => { - - // Need to explicitly set prototype since reading in from JSON will not - // do this automatically. Note that we can't call the constructor here since - // the constructor invokes extra logic that we don't want to do. - Object.setPrototypeOf(q, FullQueryInfo.prototype); - - // The config object is a global, se we need to set it explicitly - // and ensure it is not serialized to JSON. - q.setConfig(config); - - // Date instances are serialized as strings. Need to - // convert them back to Date instances. - (q.initialInfo as any).start = new Date(q.initialInfo.start); - if (q.completedQuery) { - // Again, need to explicitly set prototypes. - Object.setPrototypeOf(q.completedQuery, CompletedQueryInfo.prototype); - Object.setPrototypeOf(q.completedQuery.query, QueryEvaluationInfo.prototype); - // slurped queries do not need to be disposed - q.completedQuery.dispose = () => { /**/ }; - } - return q; - }); - - // filter out queries that have been deleted on disk - // most likely another workspace has deleted them because the - // queries aged out. - return asyncFilter(parsedQueries, async (q) => { - const resultsPath = q.completedQuery?.query.resultsPaths.resultsPath; - return !!resultsPath && await fs.pathExists(resultsPath); - }); - } catch (e) { - void showAndLogErrorMessage('Error loading query history.', { - fullMessage: ['Error loading query history.', e.stack].join('\n'), - }); - return []; - } - } - - /** - * Save the query history to disk. It is not necessary that the parent directory - * exists, but if it does, it must be writable. An existing file will be overwritten. - * - * Any errors will be rethrown. - * - * @param queries the list of queries to save. - * @param fsPath the path to save the queries to. - */ - static async splat(queries: FullQueryInfo[], fsPath: string): Promise { - try { - if (!(await fs.pathExists(fsPath))) { - await fs.mkdir(path.dirname(fsPath), { recursive: true }); - } - // remove incomplete queries since they cannot be recreated on restart - const filteredQueries = queries.filter(q => q.completedQuery !== undefined); - const data = JSON.stringify(filteredQueries, null, 2); - await fs.writeFile(fsPath, data); - } catch (e) { - throw new Error(`Error saving query history to ${fsPath}: ${e.message}`); - } - } +export class LocalQueryInfo { + readonly t = 'local'; public failureReason: string | undefined; public completedQuery: CompletedQueryInfo | undefined; private config: QueryHistoryConfig | undefined; /** - * Note that in the {@link FullQueryInfo.slurp} method, we create a FullQueryInfo instance + * Note that in the {@link slurpQueryHistory} method, we create a FullQueryInfo instance * by explicitly setting the prototype in order to avoid calling this constructor. */ constructor( @@ -401,7 +328,7 @@ export class FullQueryInfo { * * @param config the global query history config object */ - private setConfig(config: QueryHistoryConfig) { + setConfig(config: QueryHistoryConfig) { // avoid serializing config property Object.defineProperty(this, 'config', { enumerable: false, diff --git a/extensions/ql-vscode/src/query-serialization.ts b/extensions/ql-vscode/src/query-serialization.ts new file mode 100644 index 000000000..b94ec335c --- /dev/null +++ b/extensions/ql-vscode/src/query-serialization.ts @@ -0,0 +1,85 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; + +import { QueryHistoryConfig } from './config'; +import { showAndLogErrorMessage } from './helpers'; +import { asyncFilter } from './pure/helpers-pure'; +import { CompletedQueryInfo, LocalQueryInfo, QueryHistoryInfo } from './query-results'; +import { QueryEvaluationInfo } from './run-queries'; + +export async function slurpQueryHistory(fsPath: string, config: QueryHistoryConfig): Promise { + try { + if (!(await fs.pathExists(fsPath))) { + return []; + } + + const data = await fs.readFile(fsPath, 'utf8'); + const queries = JSON.parse(data); + const parsedQueries = queries.map((q: QueryHistoryInfo) => { + + // Need to explicitly set prototype since reading in from JSON will not + // do this automatically. Note that we can't call the constructor here since + // the constructor invokes extra logic that we don't want to do. + if (q.t === 'local') { + Object.setPrototypeOf(q, LocalQueryInfo.prototype); + + // The config object is a global, se we need to set it explicitly + // and ensure it is not serialized to JSON. + q.setConfig(config); + + // Date instances are serialized as strings. Need to + // convert them back to Date instances. + (q.initialInfo as any).start = new Date(q.initialInfo.start); + if (q.completedQuery) { + // Again, need to explicitly set prototypes. + Object.setPrototypeOf(q.completedQuery, CompletedQueryInfo.prototype); + Object.setPrototypeOf(q.completedQuery.query, QueryEvaluationInfo.prototype); + // slurped queries do not need to be disposed + q.completedQuery.dispose = () => { /**/ }; + } + } else if (q.t === 'remote') { + // TODO Remote queries are not implemented yet. + } + return q; + }); + + // filter out queries that have been deleted on disk + // most likely another workspace has deleted them because the + // queries aged out. + return asyncFilter(parsedQueries, async (q) => { + if (q.t !== 'local') { + return false; + } + const resultsPath = q.completedQuery?.query.resultsPaths.resultsPath; + return !!resultsPath && await fs.pathExists(resultsPath); + }); + } catch (e) { + void showAndLogErrorMessage('Error loading query history.', { + fullMessage: ['Error loading query history.', e.stack].join('\n'), + }); + return []; + } +} + +/** + * Save the query history to disk. It is not necessary that the parent directory + * exists, but if it does, it must be writable. An existing file will be overwritten. + * + * Any errors will be rethrown. + * + * @param queries the list of queries to save. + * @param fsPath the path to save the queries to. + */ +export async function splatQueryHistory(queries: QueryHistoryInfo[], fsPath: string): Promise { + try { + if (!(await fs.pathExists(fsPath))) { + await fs.mkdir(path.dirname(fsPath), { recursive: true }); + } + // remove incomplete queries since they cannot be recreated on restart + const filteredQueries = queries.filter(q => q.t === 'local' && q.completedQuery !== undefined); + const data = JSON.stringify(filteredQueries, null, 2); + await fs.writeFile(fsPath, data); + } catch (e) { + throw new Error(`Error saving query history to ${fsPath}: ${e.message}`); + } +} diff --git a/extensions/ql-vscode/src/query-status.ts b/extensions/ql-vscode/src/query-status.ts new file mode 100644 index 000000000..040454ce6 --- /dev/null +++ b/extensions/ql-vscode/src/query-status.ts @@ -0,0 +1,5 @@ +export enum QueryStatus { + InProgress = 'InProgress', + Completed = 'Completed', + Failed = 'Failed', +} diff --git a/extensions/ql-vscode/src/remote-queries/remote-query-history-item.ts b/extensions/ql-vscode/src/remote-queries/remote-query-history-item.ts new file mode 100644 index 000000000..6267b928a --- /dev/null +++ b/extensions/ql-vscode/src/remote-queries/remote-query-history-item.ts @@ -0,0 +1,15 @@ + +// TODO This is a stub and will be filled implemented in later PRs. + +import { QueryStatus } from '../query-status'; + +/** + * Information about a remote query. + */ +export interface RemoteQueryHistoryItem { + readonly t: 'remote'; + label: string; + failureReason: string | undefined; + status: QueryStatus; + isCompleted(): boolean; +} diff --git a/extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts b/extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts index 443ddba91..4ac06b2a5 100644 --- a/extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts @@ -14,7 +14,7 @@ import { QueryEvaluationInfo, QueryWithResults } from '../../run-queries'; import { QueryHistoryConfigListener } from '../../config'; import * as messages from '../../pure/messages'; import { QueryServerClient } from '../../queryserver-client'; -import { FullQueryInfo, InitialQueryInfo } from '../../query-results'; +import { LocalQueryInfo, InitialQueryInfo } from '../../query-results'; import { DatabaseManager } from '../../databases'; import * as tmp from 'tmp-promise'; import { ONE_DAY_IN_MS, ONE_HOUR_IN_MS, TWO_HOURS_IN_MS, THREE_HOURS_IN_MS } from '../../pure/helpers-pure'; @@ -107,7 +107,7 @@ describe('query-history', () => { }); }); - let allHistory: FullQueryInfo[]; + let allHistory: LocalQueryInfo[]; beforeEach(() => { allHistory = [ @@ -520,13 +520,14 @@ describe('query-history', () => { }, completedQuery: { resultCount, - } + }, + t: 'local' }; } }); - function createMockFullQueryInfo(dbName = 'a', queryWitbResults?: QueryWithResults, isFail = false): FullQueryInfo { - const fqi = new FullQueryInfo( + function createMockFullQueryInfo(dbName = 'a', queryWitbResults?: QueryWithResults, isFail = false): LocalQueryInfo { + const fqi = new LocalQueryInfo( { databaseInfo: { name: dbName }, start: new Date(), @@ -736,7 +737,7 @@ describe('query-history', () => { }; } - async function createMockQueryHistory(allHistory: FullQueryInfo[]) { + async function createMockQueryHistory(allHistory: LocalQueryInfo[]) { const qhm = new QueryHistoryManager( {} as QueryServerClient, {} as DatabaseManager, diff --git a/extensions/ql-vscode/src/vscode-tests/no-workspace/query-results.test.ts b/extensions/ql-vscode/src/vscode-tests/no-workspace/query-results.test.ts index a6f185f6e..05e3742d3 100644 --- a/extensions/ql-vscode/src/vscode-tests/no-workspace/query-results.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/no-workspace/query-results.test.ts @@ -5,7 +5,7 @@ import 'mocha'; import 'sinon-chai'; import * as sinon from 'sinon'; import * as chaiAsPromised from 'chai-as-promised'; -import { FullQueryInfo, InitialQueryInfo, interpretResults } from '../../query-results'; +import { LocalQueryInfo, InitialQueryInfo, interpretResults } from '../../query-results'; import { QueryEvaluationInfo, QueryWithResults } from '../../run-queries'; import { QueryHistoryConfig } from '../../config'; import { EvaluationResult, QueryResultType } from '../../pure/messages'; @@ -13,6 +13,7 @@ import { DatabaseInfo, SortDirection, SortedResultSetInfo } from '../../pure/int import { CodeQLCliServer, SourceInfo } from '../../cli'; import { CancellationTokenSource, Uri, env } from 'vscode'; import { tmpDir } from '../../helpers'; +import { slurpQueryHistory, splatQueryHistory } from '../../query-serialization'; chai.use(chaiAsPromised); const expect = chai.expect; @@ -277,12 +278,12 @@ describe('query-results', () => { const allHistoryPath = path.join(tmpDir.name, 'workspace-query-history.json'); // splat and slurp - await FullQueryInfo.splat(allHistory, allHistoryPath); - const allHistoryActual = await FullQueryInfo.slurp(allHistoryPath, mockConfig); + await splatQueryHistory(allHistory, allHistoryPath); + const allHistoryActual = await slurpQueryHistory(allHistoryPath, mockConfig); // the dispose methods will be different. Ignore them. allHistoryActual.forEach(info => { - if (info.completedQuery) { + if (info.t === 'local' && info.completedQuery) { const completedQuery = info.completedQuery; (completedQuery as any).dispose = undefined; @@ -355,8 +356,8 @@ describe('query-results', () => { return result; } - function createMockFullQueryInfo(dbName = 'a', queryWitbResults?: QueryWithResults, isFail = false): FullQueryInfo { - const fqi = new FullQueryInfo( + function createMockFullQueryInfo(dbName = 'a', queryWitbResults?: QueryWithResults, isFail = false): LocalQueryInfo { + const fqi = new LocalQueryInfo( { databaseInfo: { name: dbName,