diff --git a/package.json b/package.json index 0ef11bd05..0bf385979 100644 --- a/package.json +++ b/package.json @@ -283,6 +283,20 @@ "title": "Update to Cloud", "icon": "$(cloud-upload)" }, + { + "//": "Databases FileSystem", + "category": "Azure Databases", + "command": "azureDatabases.fs.save", + "title": "Upload to Cloud", + "icon": "$(cloud-upload)" + }, + { + "//": "Databases FileSystem", + "category": "Azure Databases", + "command": "azureDatabases.fs.revert", + "title": "Revert", + "icon": "$(refresh)" + }, { "category": "Cosmos DB", "command": "cosmosDB.newConnection", @@ -694,17 +708,32 @@ "group": "navigation" }, { - "command": "azureDatabases.update", + "command": "azureDatabases.fs.save", "when": "resourceFilename=~/(.*cosmos-document[.]json)(?![a-z])/i", "group": "navigation" }, { - "command": "azureDatabases.update", + "command": "azureDatabases.fs.save", "when": "resourceFilename=~/(.*cosmos-collection[.]json)(?![a-z])/i", "group": "navigation" }, { - "command": "azureDatabases.update", + "command": "azureDatabases.fs.save", + "when": "resourceFilename=~/(.*cosmos-stored-procedure[.]js)(?![a-z])/i", + "group": "navigation" + }, + { + "command": "azureDatabases.fs.revert", + "when": "resourceFilename=~/(.*cosmos-document[.]json)(?![a-z])/i", + "group": "navigation" + }, + { + "command": "azureDatabases.fs.revert", + "when": "resourceFilename=~/(.*cosmos-collection[.]json)(?![a-z])/i", + "group": "navigation" + }, + { + "command": "azureDatabases.fs.revert", "when": "resourceFilename=~/(.*cosmos-stored-procedure[.]js)(?![a-z])/i", "group": "navigation" } diff --git a/src/AzureDBFileSystemProvider.ts b/src/AzureDBFileSystemProvider.ts new file mode 100644 index 000000000..28e358826 --- /dev/null +++ b/src/AzureDBFileSystemProvider.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + type AzExtItemChangeEvent, + type AzExtItemQuery, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import { + parse as parseQuery, + stringify as stringifyQuery, + type ParsedUrlQuery, + type ParsedUrlQueryInput, +} from 'querystring'; +import vscode, { + Disposable, + EventEmitter, + FileSystemError, + FileType, + l10n, + Uri, + window, + type Event, + type FileChangeEvent, + type FileStat, + type FileSystemProvider, + type TextDocumentShowOptions, +} from 'vscode'; +import { nonNullProp } from './utils/nonNull'; + +const unsupportedError: Error = new Error(l10n.t('This operation is not supported.')); + +export interface TreeFileSystemItem { + /** + * Warning: the identifier cannot contain plus sign '+'. No matter if it's exactly '+' or if it's URL encoded "%2B". + */ + id: string; + refresh?(context: IActionContext): Promise; +} + +export abstract class AzureDBFileSystemProvider + implements FileSystemProvider, Disposable +{ + private readonly itemCache: Map = new Map(); + + public abstract scheme: string; + + private readonly disposables: Disposable[] = []; + private readonly _emitter: EventEmitter = new EventEmitter(); + private readonly _bufferedEvents: FileChangeEvent[] = []; + private _fireSoonHandle?: NodeJS.Timeout; + + constructor() { + const closeSub = vscode.workspace.onDidCloseTextDocument((e) => { + if (e.uri.scheme === this.scheme) { + const query = this.getQueryFromUri(e.uri); + const item = this.findItem(query); + if (item) { + this.itemCache.delete(query.id); + } + } + }); + + this.disposables.push(closeSub); + } + + public dispose(): void { + this._emitter.dispose(); + this.disposables.forEach((d) => void d.dispose()); + this.itemCache.clear(); + } + + public get onDidChangeFile(): Event { + return this._emitter.event; + } + + protected abstract validateImpl(item: TItem, content: string): Promise; + protected abstract statImpl(item: TItem, originalUri: Uri): Promise; + protected abstract readFileImpl(item: TItem, originalUri: Uri): Promise; + protected abstract writeFileImpl(item: TItem, content: string, originalUri: Uri): Promise; + protected abstract deleteFileImpl(item: TItem, originalUri: Uri): Promise; + protected abstract getFilePath(item: TItem): string; + protected abstract getFileQuery(item: TItem): ParsedUrlQueryInput; + + public async showTextDocument(item: TItem, options?: TextDocumentShowOptions): Promise { + const document = await this.openTextDocument(item); + await window.showTextDocument(document, options); + //await vscode.commands.executeCommand('vscode.open', uri); + } + + public openTextDocument(item: TItem): Thenable { + const uri = this.getUriFromItem(item); + const query = this.getQueryFromUri(uri); + this.itemCache.set(query.id, item); + + return vscode.workspace.openTextDocument(uri); + } + + public watch(): Disposable { + return new Disposable((): void => { + // Since we're not actually watching "in Azure" (i.e. polling for changes), + // there's no need to selectively watch based on the Uri passed in here. Thus, there's nothing to dispose + }); + } + + public async stat(uri: Uri): Promise { + return ( + (await callWithTelemetryAndErrorHandling('stat', async (context) => { + context.telemetry.suppressIfSuccessful = true; + context.telemetry.eventVersion = 2; + context.errorHandling.rethrow = true; + + const item = this.findItem(this.getQueryFromUri(uri)); + if (item) { + return await this.statImpl(item, uri); + } + + return { type: FileType.Unknown, ctime: 0, mtime: 0, size: 0 }; + })) || { type: FileType.Unknown, ctime: 0, mtime: 0, size: 0 } + ); + } + + public async readFile(uri: Uri): Promise { + return ( + (await callWithTelemetryAndErrorHandling('readFile', async (context) => { + context.telemetry.suppressIfSuccessful = true; + context.telemetry.eventVersion = 2; + context.errorHandling.rethrow = true; + context.errorHandling.suppressDisplay = true; + + const item = await this.lookup(uri); + const content = await this.readFileImpl(item, uri); + return Buffer.from(content); + })) || Buffer.from('') + ); + } + + public async writeFile(uri: Uri, content: Uint8Array): Promise { + await callWithTelemetryAndErrorHandling('writeFile', async (context) => { + context.telemetry.suppressIfSuccessful = true; + context.telemetry.eventVersion = 2; + context.errorHandling.rethrow = true; + + const item = await this.lookup(uri); + await this.writeFileImpl(item, content.toString(), uri); + await item.refresh?.(context); + }); + } + + public async readDirectory(_uri: Uri): Promise<[string, FileType][]> { + throw unsupportedError; + } + + public async createDirectory(_uri: Uri): Promise { + throw unsupportedError; + } + + public async delete(uri: Uri): Promise { + await callWithTelemetryAndErrorHandling('deleteFile', async (context) => { + context.telemetry.suppressIfSuccessful = true; + context.telemetry.eventVersion = 2; + context.errorHandling.rethrow = true; + + const item = await this.lookup(uri); + await this.deleteFileImpl(item, uri); + this.itemCache.delete(this.getQueryFromUri(uri).id); + }); + } + + public async rename(oldUri: Uri, newUri: Uri, _options: { readonly overwrite: boolean }): Promise { + await callWithTelemetryAndErrorHandling('renameFile', async (context) => { + context.telemetry.suppressIfSuccessful = true; + context.telemetry.eventVersion = 2; + context.errorHandling.rethrow = true; + + const oldItem = this.itemCache.get(this.getQueryFromUri(oldUri).id); + const newItem = this.itemCache.get(this.getQueryFromUri(newUri).id); + + if (!oldItem) { + throw FileSystemError.FileNotFound(oldUri); + } + + if (newItem) { + // Ignore overwrite option and throw error if newItem already exists + throw FileSystemError.FileExists(newUri); + } + + this.itemCache.delete(this.getQueryFromUri(oldUri).id); + this.itemCache.set(this.getQueryFromUri(newUri).id, oldItem); + }); + } + + /** + * Uses a simple buffer to group events that occur within a few milliseconds of each other + * Adapted from https://github.com/microsoft/vscode-extension-samples/blob/master/fsprovider-sample/src/fileSystemProvider.ts + */ + protected fireSoon(...events: AzExtItemChangeEvent[]): void { + this._bufferedEvents.push( + ...events.map((e) => { + return { + type: e.type, + uri: this.getUriFromItem(e.item), + }; + }), + ); + + if (this._fireSoonHandle) { + clearTimeout(this._fireSoonHandle); + } + + this._fireSoonHandle = setTimeout(() => { + this._emitter.fire(this._bufferedEvents); + this._bufferedEvents.length = 0; // clear buffer + }, 5); + } + + protected findItem(query: AzExtItemQuery): TItem | undefined { + return this.itemCache.get(query.id); + } + + protected getUriFromItem(item: TItem): Uri { + const query: string = stringifyQuery(this.getFileQuery(item)); + const filePath: string = encodeURIComponent(this.getFilePath(item)); + return Uri.parse(`${this.scheme}:///${filePath}/?${query}`); + } + + protected async lookup(uri: Uri): Promise { + const item = this.findItem(this.getQueryFromUri(uri)); + if (!item) { + throw FileSystemError.FileNotFound(); + } else { + return item; + } + } + + protected getQueryFromUri(uri: Uri): AzExtItemQuery { + const query: ParsedUrlQuery = parseQuery(uri.query); + const id: string | string[] = nonNullProp(query, 'id'); + if (typeof id === 'string') { + return Object.assign(query, { id }); // Not technically necessary to use `Object.assign`, but it's better than casting which would lose type validation + } else { + throw new Error('Internal Error: Expected "id" to be type string.'); + } + } +} diff --git a/src/DatabasesFileSystem.ts b/src/DatabasesFileSystem.ts deleted file mode 100644 index 4043df658..000000000 --- a/src/DatabasesFileSystem.ts +++ /dev/null @@ -1,117 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { - AzExtTreeFileSystem, - AzExtTreeItem, - DialogResponses, - UserCancelledError, - type AzExtTreeFileSystemItem, - type IActionContext, -} from '@microsoft/vscode-azext-utils'; -import vscode, { FileType, workspace, type FileStat, type MessageItem, type Uri } from 'vscode'; -import { FileChangeType } from 'vscode-languageclient'; -import { ext } from './extensionVariables'; -import { SettingsService } from './services/SettingsService'; -import { localize } from './utils/localize'; -import { getNodeEditorLabel } from './utils/vscodeUtils'; - -export interface IEditableTreeItem extends AzExtTreeItem { - id: string; - filePath: string; - cTime: number; - mTime: number; - getFileContent(context: IActionContext): Promise; - writeFileContent(context: IActionContext, data: string): Promise; -} - -export interface EditableFileSystemItem extends AzExtTreeFileSystemItem { - id: string; - filePath: string; - cTime: number; - mTime: number; - getFileContent(context: IActionContext): Promise; - writeFileContent(context: IActionContext, data: string): Promise; -} - -export class DatabasesFileSystem extends AzExtTreeFileSystem { - public static scheme: string = 'azureDatabases'; - public scheme: string = DatabasesFileSystem.scheme; - private _showSaveConfirmation: boolean = true; - - public async statImpl( - context: IActionContext, - node: IEditableTreeItem | EditableFileSystemItem, - ): Promise { - const size: number = Buffer.byteLength(await node.getFileContent(context)); - return { type: FileType.File, ctime: node.cTime, mtime: node.mTime, size }; - } - - public async readFileImpl(context: IActionContext, node: IEditableTreeItem): Promise { - return Buffer.from(await node.getFileContent(context)); - } - - public async writeFileImpl( - context: IActionContext, - node: IEditableTreeItem | EditableFileSystemItem, - content: Uint8Array, - _originalUri: Uri, - ): Promise { - const showSavePromptKey: string = 'showSavePrompt'; - // NOTE: Using "cosmosDB" instead of "azureDatabases" here for the sake of backwards compatibility. If/when this file system adds support for non-cosmosdb items, we should consider changing this to "azureDatabases" - const prefix: string = 'cosmosDB'; - const nodeEditorLabel: string = getNodeEditorLabel(node); - if (this._showSaveConfirmation && SettingsService.getSetting(showSavePromptKey, prefix)) { - const message: string = localize( - 'saveConfirmation', - 'Saving "{0}" will update the entity "{1}" to the cloud.', - node.filePath, - nodeEditorLabel, - ); - const result: MessageItem | undefined = await context.ui.showWarningMessage( - message, - { stepName: 'writeFile' }, - DialogResponses.upload, - DialogResponses.alwaysUpload, - DialogResponses.dontUpload, - ); - if (result === DialogResponses.alwaysUpload) { - await SettingsService.updateGlobalSetting(showSavePromptKey, false, prefix); - } else if (result === DialogResponses.dontUpload) { - throw new UserCancelledError('dontUpload'); - } - } - - await node.writeFileContent(context, content.toString()); - if (node instanceof AzExtTreeItem) { - await node.refresh(context); - } else { - this.fireChangedEvent(node); - await vscode.commands.executeCommand('azureDatabases.refresh', node); - } - - const updatedMessage: string = localize('updatedEntity', 'Updated entity "{0}".', nodeEditorLabel); - ext.outputChannel.appendLog(updatedMessage); - } - - public getFilePath(node: IEditableTreeItem | EditableFileSystemItem): string { - return node.filePath; - } - - public async updateWithoutPrompt(uri: Uri): Promise { - const textDoc = await workspace.openTextDocument(uri); - this._showSaveConfirmation = false; - try { - await textDoc.save(); - } finally { - this._showSaveConfirmation = true; - } - } - - public fireChangedEvent(node: IEditableTreeItem | EditableFileSystemItem): void { - node.mTime = Date.now(); - this.fireSoon({ type: FileChangeType.Changed, item: node }); - } -} diff --git a/src/commands/createDocument/createDocument.ts b/src/commands/createDocument/createDocument.ts index b4ca4f44c..8dd3fe03a 100644 --- a/src/commands/createDocument/createDocument.ts +++ b/src/commands/createDocument/createDocument.ts @@ -5,10 +5,11 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; -import vscode, { ViewColumn } from 'vscode'; +import vscode from 'vscode'; +import { DocumentFileDescriptor } from '../../docdb/fs/DocumentFileDescriptor'; import { createNoSqlQueryConnection } from '../../docdb/utils/NoSqlQueryConnection'; +import { ext } from '../../extensionVariables'; import { type CollectionItem } from '../../mongoClusters/tree/CollectionItem'; -import { DocumentTab } from '../../panels/DocumentTab'; import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; import { type DocumentDBItemsResourceItem } from '../../tree/docdb/DocumentDBItemsResourceItem'; import { pickAppResource } from '../../utils/pickItem/pickAppResource'; @@ -28,7 +29,17 @@ export async function createDocumentDBDocument( return; } - DocumentTab.render(createNoSqlQueryConnection(node), 'add', undefined, ViewColumn.Active); + context.telemetry.properties.experience = node.experience.api; + + const fsNode = new DocumentFileDescriptor( + node.id, + node.experience, + createNoSqlQueryConnection(node.model), + node.model.container.partitionKey, + ); + await ext.fileSystem.showTextDocument(fsNode); + + // DocumentTab.render(createNoSqlQueryConnection(node.model), 'add', undefined, ViewColumn.Active); } export async function createMongoDocument(context: IActionContext, node?: CollectionItem): Promise { diff --git a/src/commands/deleteItems/deleteItems.ts b/src/commands/deleteItems/deleteItems.ts index 86efc3c38..402849d41 100644 --- a/src/commands/deleteItems/deleteItems.ts +++ b/src/commands/deleteItems/deleteItems.ts @@ -6,12 +6,12 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; import vscode from 'vscode'; -import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { DocumentFileDescriptor } from '../../docdb/fs/DocumentFileDescriptor'; +import { createNoSqlQueryConnection } from '../../docdb/utils/NoSqlQueryConnection'; import { ext } from '../../extensionVariables'; import { type DocumentDBItemResourceItem } from '../../tree/docdb/DocumentDBItemResourceItem'; import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; -import { extractPartitionKey } from '../../utils/document'; import { localize } from '../../utils/localize'; import { pickAppResource } from '../../utils/pickItem/pickAppResource'; @@ -29,9 +29,9 @@ export async function deleteDocumentDBItem(context: IActionContext, node: Docume return undefined; } - const databaseId = node.model.database.id; - const containerId = node.model.container.id; - const partitionKeyDefinition = node.model.container.partitionKey; + // const databaseId = node.model.database.id; + // const containerId = node.model.container.id; + // const partitionKeyDefinition = node.model.container.partitionKey; const item = node.model.item; if (item.id === undefined) { @@ -49,18 +49,19 @@ export async function deleteDocumentDBItem(context: IActionContext, node: Docume return; } - const accountInfo = node.model.accountInfo; - const client = getCosmosClient(accountInfo.endpoint, accountInfo.credentials, accountInfo.isEmulator); - try { let success = false; await ext.state.showDeleting(node.id, async () => { - const response = await client - .database(databaseId) - .container(containerId) - .item(item.id!, partitionKeyDefinition ? extractPartitionKey(item, partitionKeyDefinition) : undefined) - .delete(); - success = response.statusCode === 204; + const fsNode = new DocumentFileDescriptor( + node.id, + node.experience, + createNoSqlQueryConnection(node.model), + node.model.container.partitionKey, + node.model.item, + ); + const document = await ext.fileSystem.openTextDocument(fsNode); + await vscode.workspace.fs.delete(document.uri); + success = true; }); if (success) { diff --git a/src/commands/openDocument/openDocument.ts b/src/commands/openDocument/openDocument.ts index da96462ab..23f519671 100644 --- a/src/commands/openDocument/openDocument.ts +++ b/src/commands/openDocument/openDocument.ts @@ -7,6 +7,7 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; import { ViewColumn } from 'vscode'; import { DocumentFileDescriptor } from '../../docdb/fs/DocumentFileDescriptor'; +import { createNoSqlQueryConnection } from '../../docdb/utils/NoSqlQueryConnection'; import { ext } from '../../extensionVariables'; import { type DocumentDBItemResourceItem } from '../../tree/docdb/DocumentDBItemResourceItem'; import { pickAppResource } from '../../utils/pickItem/pickAppResource'; @@ -26,7 +27,13 @@ export async function openDocumentDBItem(context: IActionContext, node?: Documen context.telemetry.properties.experience = node.experience.api; - const fsNode = new DocumentFileDescriptor(node.id, node.model, node.experience); + const fsNode = new DocumentFileDescriptor( + node.id, + node.experience, + createNoSqlQueryConnection(node.model), + node.model.container.partitionKey, + node.model.item, + ); // Clear un-uploaded local changes to the document before opening https://github.com/microsoft/vscode-cosmosdb/issues/1619 ext.fileSystem.fireChangedEvent(fsNode); await ext.fileSystem.showTextDocument(fsNode); diff --git a/src/commands/openNoSqlQueryEditor/openNoSqlQueryEditor.ts b/src/commands/openNoSqlQueryEditor/openNoSqlQueryEditor.ts index 898f60101..25416acac 100644 --- a/src/commands/openNoSqlQueryEditor/openNoSqlQueryEditor.ts +++ b/src/commands/openNoSqlQueryEditor/openNoSqlQueryEditor.ts @@ -5,8 +5,7 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; -import { getCosmosAuthCredential, getCosmosKeyCredential } from '../../docdb/getCosmosClient'; -import { type NoSqlQueryConnection } from '../../docdb/NoSqlCodeLensProvider'; +import { createNoSqlQueryConnection } from '../../docdb/utils/NoSqlQueryConnection'; import { QueryEditorTab } from '../../panels/QueryEditorTab'; import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; import { type DocumentDBItemsResourceItem } from '../../tree/docdb/DocumentDBItemsResourceItem'; @@ -29,17 +28,5 @@ export async function openNoSqlQueryEditor( context.telemetry.properties.experience = node.experience.api; - const accountInfo = node.model.accountInfo; - const keyCred = getCosmosKeyCredential(accountInfo.credentials); - const tenantId = getCosmosAuthCredential(accountInfo.credentials)?.tenantId; - const connection: NoSqlQueryConnection = { - databaseId: node.model.database.id, - containerId: node.model.container.id, - endpoint: accountInfo.endpoint, - masterKey: keyCred?.key, - isEmulator: accountInfo.isEmulator, - tenantId: tenantId, - }; - - QueryEditorTab.render(connection); + QueryEditorTab.render(createNoSqlQueryConnection(node.model)); } diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts index e229499c9..9c1ea5133 100644 --- a/src/commands/registerCommands.ts +++ b/src/commands/registerCommands.ts @@ -52,6 +52,7 @@ import { viewDocumentDBContainerOffer, viewDocumentDBDatabaseOffer } from './vie export function registerCommands(): void { registerCommandWithTreeNodeUnwrapping('azureDatabases.createServer', createServer); + registerCommandWithTreeNodeUnwrapping('azureDatabases.refresh', refreshTreeElement); registerAccountCommands(); registerDatabaseCommands(); @@ -65,13 +66,8 @@ export function registerCommands(): void { registerMongoCommands(); registerPostgresCommands(); - registerCommandWithTreeNodeUnwrapping('azureDatabases.refresh', refreshTreeElement); - // For DocumentDB FileSystem (Scrapbook) - registerCommandWithTreeNodeUnwrapping( - 'azureDatabases.update', - async (_actionContext: IActionContext, uri: vscode.Uri) => await ext.fileSystem.updateWithoutPrompt(uri), - ); + registerFsCommands(); } export function registerAccountCommands() { @@ -122,3 +118,18 @@ export function registerTriggerCommands() { registerCommandWithTreeNodeUnwrapping('cosmosDB.openTrigger', openDocumentDBTrigger, doubleClickDebounceDelay); registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBTrigger', deleteDocumentDBTrigger); } + +export function registerFsCommands() { + registerCommandWithTreeNodeUnwrapping( + 'azureDatabases.fs.save', + async (_actionContext: IActionContext, uri: vscode.Uri) => await ext.fileSystem.save(uri), + ); + registerCommandWithTreeNodeUnwrapping( + 'azureDatabases.fs.revert', + async (_actionContext: IActionContext, uri: vscode.Uri) => await ext.fileSystem.revert(uri), + ); + registerCommandWithTreeNodeUnwrapping( + 'azureDatabases.update', + async (_actionContext: IActionContext, uri: vscode.Uri) => await ext.fileSystem.updateWithoutPrompt(uri), + ); +} diff --git a/src/docdb/NoSqlCodeLensProvider.ts b/src/docdb/NoSqlCodeLensProvider.ts index eb5162046..50225f7d9 100644 --- a/src/docdb/NoSqlCodeLensProvider.ts +++ b/src/docdb/NoSqlCodeLensProvider.ts @@ -21,11 +21,28 @@ export type NoSqlQueryConnection = { databaseId: string; containerId: string; endpoint: string; - masterKey?: string; isEmulator: boolean; + masterKey: string | undefined; tenantId: string | undefined; }; +export const isNoSqlQueryConnection = (obj: unknown): obj is NoSqlQueryConnection => { + return ( + (obj && + typeof obj === 'object' && + 'databaseId' in obj && + typeof obj.databaseId === 'string' && + 'containerId' in obj && + typeof obj.containerId === 'string' && + 'endpoint' in obj && + typeof obj.endpoint === 'string' && + 'isEmulator' in obj && + typeof obj.isEmulator === 'boolean' && + ('masterKey' in obj ? typeof obj.masterKey === 'string' : true) && + ('tenantId' in obj ? typeof obj.tenantId === 'string' : true)) + ); +}; + export const noSqlQueryConnectionKey = 'NO_SQL_QUERY_CONNECTION_KEY.v1'; export class NoSqlCodeLensProvider implements CodeLensProvider { diff --git a/src/docdb/commands/connectNoSqlContainer.ts b/src/docdb/commands/connectNoSqlContainer.ts index 670d6afb3..990b8cfff 100644 --- a/src/docdb/commands/connectNoSqlContainer.ts +++ b/src/docdb/commands/connectNoSqlContainer.ts @@ -13,7 +13,7 @@ import { noSqlQueryConnectionKey } from '../NoSqlCodeLensProvider'; import { createNoSqlQueryConnection } from '../utils/NoSqlQueryConnection'; export function setConnectedNoSqlContainer(node: DocumentDBContainerResourceItem): void { - const noSqlQueryConnection = createNoSqlQueryConnection(node); + const noSqlQueryConnection = createNoSqlQueryConnection(node.model); KeyValueStore.instance.set(noSqlQueryConnectionKey, noSqlQueryConnection); ext.noSqlCodeLensProvider.updateCodeLens(); } diff --git a/src/docdb/fs/CosmosFileSystem.ts b/src/docdb/fs/CosmosFileSystem.ts new file mode 100644 index 000000000..307d56e47 --- /dev/null +++ b/src/docdb/fs/CosmosFileSystem.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, DialogResponses } from '@microsoft/vscode-azext-utils'; +import { type ParsedUrlQueryInput } from 'querystring'; +import vscode, { + FilePermission, + type FileStat, + FileSystemError, + FileType, + type MessageItem, + type Uri, + window, + workspace, +} from 'vscode'; +import { FileChangeType } from 'vscode-languageclient'; +import { AzureDBFileSystemProvider, type TreeFileSystemItem } from '../../AzureDBFileSystemProvider'; +import { ext } from '../../extensionVariables'; +import { SettingsService } from '../../services/SettingsService'; +import { localize } from '../../utils/localize'; +import { getNodeEditorLabel, writeToEditor } from '../../utils/vscodeUtils'; +import { DocumentFileDescriptor } from './DocumentFileDescriptor'; + +export type EditableFileSystemItemType = 'Document' | 'StoredProcedure' | 'Trigger'; + +export interface EditableFileSystemItem extends TreeFileSystemItem { + id: string; + filePath: string; + cTime: number; + mTime: number; + isReadOnly: boolean; + size: number; + type: EditableFileSystemItemType; + create(data: string): Promise; + read(): Promise; + update(data: string): Promise; + delete(): Promise; + validate(data: string): Promise; + getFileQuery(): ParsedUrlQueryInput; +} + +export class CosmosFileSystem extends AzureDBFileSystemProvider { + public static newFileName: string = ''; + public static scheme: string = 'cosmosdb'; + public scheme: string = CosmosFileSystem.scheme; + + private _showSaveConfirmation: boolean = true; + + protected async statImpl(item: EditableFileSystemItem): Promise { + const permissions = item.isReadOnly ? FilePermission.Readonly : undefined; + return { type: FileType.File, ctime: item.cTime, mtime: item.mTime, size: item.size, permissions }; + } + + protected async readFileImpl(item: EditableFileSystemItem): Promise { + return item.read(); + } + + protected async writeFileImpl(item: EditableFileSystemItem, content: string, originalUri: Uri): Promise { + const showSavePromptKey: string = 'showSavePrompt'; + // NOTE: Using "cosmosDB" instead of "azureDatabases" here for the sake of backwards compatibility. + const prefix: string = 'cosmosDB'; + const nodeEditorLabel: string = getNodeEditorLabel(item); + if (this._showSaveConfirmation && SettingsService.getSetting(showSavePromptKey, prefix)) { + const message: string = localize( + 'saveConfirmation', + 'Saving "{0}" will update the entity "{1}" to the cloud.', + item.filePath, + nodeEditorLabel, + ); + const result: MessageItem | undefined = await window.showWarningMessage( + message, + DialogResponses.upload, + DialogResponses.alwaysUpload, + DialogResponses.dontUpload, + ); + if (result === undefined || result === DialogResponses.cancel || result === DialogResponses.dontUpload) { + throw FileSystemError.NoPermissions(localize('userCancelledError', 'Operation cancelled.')); + } else if (result === DialogResponses.alwaysUpload) { + await SettingsService.updateGlobalSetting(showSavePromptKey, false, prefix); + } + } + + const query = this.getFileQuery(item); + if (query.id === CosmosFileSystem.newFileName) { + await item.create(content); + this.fireSoon({ type: FileChangeType.Created, item: item }); + + const updatedMessage: string = localize('createdEntity', 'Created entity "{0}".', nodeEditorLabel); + ext.outputChannel.appendLog(updatedMessage); + setTimeout(() => { + // Rename the file to remove the "" suffix. Need gap between creation and renaming + void vscode.workspace.fs.rename(originalUri, this.getUriFromItem(item), { overwrite: true }); + }, 0); + } else { + await item.update(content); + this.fireSoon({ type: FileChangeType.Changed, item: item }); + + const updatedMessage: string = localize('updatedEntity', 'Updated entity "{0}".', nodeEditorLabel); + ext.outputChannel.appendLog(updatedMessage); + } + + await vscode.commands.executeCommand('azureDatabases.refresh', item); + } + + protected async deleteFileImpl(item: EditableFileSystemItem): Promise { + const query = this.getFileQuery(item); + if (query.id === CosmosFileSystem.newFileName) { + // Do nothing if renaming a new file + return; + } + + await item.delete(); + + this.fireSoon({ type: FileChangeType.Deleted, item }); + } + + protected async validateImpl(item: EditableFileSystemItem, content: string): Promise { + return item.validate(content); + } + + protected getFilePath(item: EditableFileSystemItem): string { + return item.filePath; + } + + protected getFileQuery(item: EditableFileSystemItem): ParsedUrlQueryInput { + return item.getFileQuery(); + } + + public async save(uri: Uri): Promise { + if (uri.scheme === CosmosFileSystem.scheme) { + const textDoc = await workspace.openTextDocument(uri); + return textDoc.save(); + } + + return false; + } + + public async revert(uri: Uri): Promise { + await callWithTelemetryAndErrorHandling('deleteFile', async (context) => { + context.telemetry.suppressIfSuccessful = true; + + const item = await this.lookup(uri); + const activeTextEditor = vscode.window.activeTextEditor; + if (activeTextEditor?.document.uri === uri) { + const text = await item.read(); + await writeToEditor(activeTextEditor, text); + } + }); + } + + public async updateWithoutPrompt(uri: Uri): Promise { + const textDoc = await workspace.openTextDocument(uri); + this._showSaveConfirmation = false; + try { + await textDoc.save(); + } finally { + this._showSaveConfirmation = true; + } + } + + public fireChangedEvent(item: EditableFileSystemItem): void { + item.mTime = Date.now(); + this.fireSoon({ type: FileChangeType.Changed, item: item }); + } + + protected async lookup(uri: Uri): Promise { + const item = this.findItem(this.getQueryFromUri(uri)); + if (!item) { + const parsedQuery = this.getQueryFromUri(uri); + const type = parsedQuery.type as EditableFileSystemItemType; + switch (type) { + case 'Document': { + const newItem = await DocumentFileDescriptor.fromURI(uri); + this.openTextDocument(newItem); + return newItem; + } + case 'StoredProcedure': + case 'Trigger': + default: + throw FileSystemError.FileNotFound(); + } + } else { + return item; + } + } +} diff --git a/src/docdb/fs/DocumentFileDescriptor.ts b/src/docdb/fs/DocumentFileDescriptor.ts index da9a1627b..81d0ea620 100644 --- a/src/docdb/fs/DocumentFileDescriptor.ts +++ b/src/docdb/fs/DocumentFileDescriptor.ts @@ -3,79 +3,250 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type ItemDefinition, type JSONValue, type RequestOptions } from '@azure/cosmos'; -import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { type Experience } from '../../AzureDBExperiences'; -import { DocumentDBHiddenFields } from '../../constants'; -import { type EditableFileSystemItem } from '../../DatabasesFileSystem'; -import { type DocumentDBItemModel } from '../../tree/docdb/models/DocumentDBItemModel'; -import { extractPartitionKey } from '../../utils/document'; +import { type ItemDefinition, type PartitionKeyDefinition, type Resource } from '@azure/cosmos'; +import { parse as parseQuery, type ParsedUrlQuery, type ParsedUrlQueryInput } from 'querystring'; +import { FileSystemError, type Uri } from 'vscode'; +import { getExperienceFromApi, type API, type Experience } from '../../AzureDBExperiences'; +import { ext } from '../../extensionVariables'; +import { extractPartitionKey, generateUniqueId } from '../../utils/document'; import { getDocumentTreeItemLabel } from '../../utils/vscodeUtils'; -import { getCosmosClient } from '../getCosmosClient'; +import { getCosmosClientByConnection } from '../getCosmosClient'; +import { type NoSqlQueryConnection } from '../NoSqlCodeLensProvider'; +import { ItemService } from '../services/ItemService'; +import { validateDocument } from '../utils/validateDocument'; +import { CosmosFileSystem, type EditableFileSystemItem } from './CosmosFileSystem'; export class DocumentFileDescriptor implements EditableFileSystemItem { public readonly cTime: number = Date.now(); + public readonly type = 'Document'; + public mTime: number = Date.now(); + public isReadOnly: boolean = false; + public size: number = 0; + + private readonly itemService: ItemService; constructor( public readonly id: string, - public readonly model: DocumentDBItemModel, public readonly experience: Experience, - ) {} + private readonly connection: NoSqlQueryConnection, + private readonly partitionKey?: PartitionKeyDefinition | undefined, + private item?: ItemDefinition & Resource, + ) { + this.itemService = new ItemService(this.connection); + if (item) { + this.size = Buffer.byteLength(JSON.stringify(item)); + } + } + + public static async fromURI(uri: Uri): Promise { + const query: ParsedUrlQuery = parseQuery(uri.query); + const id = query['id']; + const type = query['type']; + const api = query['api']; + const databaseId = query['databaseId']; + const containerId = query['containerId']; + const endpoint = query['endpoint']; + const isEmulator = query['isEmulator']; + let masterKey = query['masterKey']; + let tenantId = query['tenantId']; + let itemId = query['itemId']; + let partitionKeyValue = query['partitionKeyValue']; + + if (id === undefined || Array.isArray(id) || id === '') { + throw new Error('The query parameter "id" is required'); + } + + if (api === undefined || Array.isArray(api) || id === '') { + throw new Error('The query parameter "api" is required'); + } + + if (type === undefined || Array.isArray(type) || type === '') { + throw new Error('The query parameter "api" is required'); + } + + if (databaseId === undefined || Array.isArray(databaseId) || id === '') { + throw new Error('The query parameter "databaseId" is required'); + } + + if (containerId === undefined || Array.isArray(containerId) || id === '') { + throw new Error('The query parameter "containerId" is required'); + } + + if (endpoint === undefined || Array.isArray(endpoint) || id === '') { + throw new Error('The query parameter "endpoint" is required'); + } + + if (isEmulator === undefined || Array.isArray(isEmulator) || id === '') { + throw new Error('The query parameter "isEmulator" is required'); + } + + if (Array.isArray(masterKey)) { + throw new Error('The query parameter "masterKey" should not be an array'); + } else if (masterKey === '') { + masterKey = undefined; + } + + if (Array.isArray(tenantId)) { + throw new Error('The query parameter "tenantId" should not be an array'); + } else if (tenantId === '') { + tenantId = undefined; + } + + if (Array.isArray(itemId)) { + throw new Error('The query parameter "itemId" should not be an array'); + } else if (itemId === '') { + itemId = undefined; + } + + if (partitionKeyValue === '') { + partitionKeyValue = undefined; + } + + if (type !== 'Document') { + throw new Error(`Document file descriptor expected type "Document" but received "${type}"`); + } + + const connection: NoSqlQueryConnection = { + databaseId, + containerId, + endpoint, + masterKey, + isEmulator: isEmulator.toLowerCase() === 'true', + tenantId, + }; + + const cosmosClient = getCosmosClientByConnection(connection); + const container = await cosmosClient.database(databaseId).container(containerId).read(); + + if (!container.resource) { + throw new Error(`Container with id "${containerId}" not found`); + } + + const partitionKey = container.resource.partitionKey; + + let item: (ItemDefinition & Resource) | undefined; + if (itemId) { + const itemResponse = await cosmosClient + .database(databaseId) + .container(containerId) + .item(itemId, partitionKeyValue) + .read(); + + if (!itemResponse.resource) { + throw new Error( + `Item with id "${itemId}" ${partitionKeyValue ? `and partition key ${partitionKeyValue}` : ''} not found`, + ); + } + + item = itemResponse.resource; + } + + return new DocumentFileDescriptor(id, getExperienceFromApi(api as API), connection, partitionKey, item); + } public get filePath(): string { - return getDocumentTreeItemLabel(this.model.item) + '-cosmos-document.json'; + if (this.item) { + return getDocumentTreeItemLabel(this.item) + '.cosmos-document.json'; + } + + return 'New item.cosmos-document.json'; } - public getFileContent(): Promise { - const clonedDoc: ItemDefinition = { ...this.model.item }; + public getFileQuery(): ParsedUrlQueryInput { + return { + id: this.item + ? generateUniqueId(this.item, this.partitionKey).replaceAll('+', '').replaceAll('%2B', '') + : CosmosFileSystem.newFileName, + api: this.experience.api, + type: this.type, + databaseId: this.connection.databaseId, + containerId: this.connection.containerId, + endpoint: this.connection.endpoint, + masterKey: this.connection.masterKey, + isEmulator: this.connection.isEmulator.toString(), + tenantId: this.connection.tenantId, + itemId: this.item?.id ?? CosmosFileSystem.newFileName, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + partitionKeyValue: + this.partitionKey && this.item ? extractPartitionKey(this.item, this.partitionKey) : undefined, + _rid: (this.item?._rid as string) || undefined, + }; + } - // TODO: Why user can't change/see them? - for (const field of DocumentDBHiddenFields) { - delete clonedDoc[field]; + public async create(data: string): Promise { + if (this.item) { + return Promise.reject(FileSystemError.FileExists()); } - return Promise.resolve(JSON.stringify(clonedDoc, null, 2)); + await this.validate(data); + + this.item = await this.itemService.create(JSON.parse(data) as ItemDefinition); + if (this.item) { + this.mTime = Date.now(); + this.size = Buffer.byteLength(JSON.stringify(this.item)); + } else { + throw new Error('Failed to create the item'); + } } - public async writeFileContent(_context: IActionContext, content: string): Promise { - const newData: JSONValue = JSON.parse(content) as JSONValue; + public async read(readFromServer?: boolean): Promise { + if (readFromServer) { + if (!this.item) { + throw FileSystemError.FileNotFound(); + } + + const itemResponse = await this.itemService.read(this.item); + if (itemResponse) { + this.item = itemResponse; + this.mTime = Date.now(); + this.size = Buffer.byteLength(JSON.stringify(this.item)); + } else { + throw new Error('Failed to read the item'); + } + } + + return JSON.stringify(this.item ?? (await this.itemService.generateNewItemTemplate()), null, 2); + } - if (typeof newData !== 'object' || newData === null) { - throw new Error('The document content is not a valid JSON object'); + public async update(data: string): Promise { + if (!this.item) { + throw FileSystemError.FileNotFound(); } - if (!newData['id'] || typeof newData['id'] !== 'string') { - throw new Error('The "id" field is required to update a document'); + await this.validate(data); + + this.item = await this.itemService.update(JSON.parse(data) as ItemDefinition & Resource); + if (this.item) { + this.mTime = Date.now(); + this.size = Buffer.byteLength(JSON.stringify(this.item)); + } else { + throw new Error('Failed to update the item'); } + } - // TODO: Does it matter to keep the same fields in the document? Why user can't change them? - for (const field of DocumentDBHiddenFields) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - newData[field] = this.model.item[field]; + public async delete(): Promise { + if (!this.item) { + throw FileSystemError.FileNotFound(); } - // TODO: Does it make sense now? This check was created 4 years ago - if (!newData['_etag'] || typeof newData['_etag'] !== 'string') { - throw new Error(`The "_etag" field is required to update a document`); + if (!this.item['id'] || typeof this.item['id'] !== 'string') { + throw new Error('The "id" field is required to delete the item'); } - const { endpoint, credentials, isEmulator } = this.model.accountInfo; - const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); - const options: RequestOptions = { accessCondition: { type: 'IfMatch', condition: newData['_etag'] } }; - const partitionKeyValues = this.model.container.partitionKey - ? extractPartitionKey(this.model.item, this.model.container.partitionKey) - : undefined; - const response = await cosmosClient - .database(this.model.database.id) - .container(this.model.container.id) - .item(newData['id'], partitionKeyValues) - .replace(newData, options); + await this.itemService.delete(this.item); + } - if (response.resource) { - this.model.item = response.resource; - } else { - throw new Error('Failed to update the document'); + public async validate(data: string): Promise { + const errors = validateDocument(data, this.partitionKey); + + if (errors.length > 0) { + ext.outputChannel.appendLog(`Item validation failed`); + ext.outputChannel.appendLog(errors.join('\n')); + ext.outputChannel.show(); + + throw new Error(errors.join('\n')); } } } diff --git a/src/docdb/fs/StoredProcedureFileDescriptor.ts b/src/docdb/fs/StoredProcedureFileDescriptor.ts index 33f2eb646..96f7d2c2f 100644 --- a/src/docdb/fs/StoredProcedureFileDescriptor.ts +++ b/src/docdb/fs/StoredProcedureFileDescriptor.ts @@ -5,14 +5,15 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { type Experience } from '../../AzureDBExperiences'; -import { type EditableFileSystemItem } from '../../DatabasesFileSystem'; import { type DocumentDBStoredProcedureModel } from '../../tree/docdb/models/DocumentDBStoredProcedureModel'; import { nonNullProp } from '../../utils/nonNull'; import { getCosmosClient } from '../getCosmosClient'; +import { type EditableFileSystemItem } from './CosmosFileSystem'; export class StoredProcedureFileDescriptor implements EditableFileSystemItem { public readonly cTime: number = Date.now(); public mTime: number = Date.now(); + public isReadOnly: boolean = false; constructor( public readonly id: string, @@ -24,11 +25,11 @@ export class StoredProcedureFileDescriptor implements EditableFileSystemItem { return this.model.procedure.id + '-cosmos-stored-procedure.js'; } - public getFileContent(): Promise { + public read(): Promise { return Promise.resolve(typeof this.model.procedure.body === 'string' ? this.model.procedure.body : ''); } - public async writeFileContent(_context: IActionContext, content: string): Promise { + public async update(_context: IActionContext, content: string): Promise { const { endpoint, credentials, isEmulator } = this.model.accountInfo; const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); const replace = await cosmosClient diff --git a/src/docdb/fs/TriggerFileDescriptor.ts b/src/docdb/fs/TriggerFileDescriptor.ts index 9517723d7..0d6d46d83 100644 --- a/src/docdb/fs/TriggerFileDescriptor.ts +++ b/src/docdb/fs/TriggerFileDescriptor.ts @@ -7,11 +7,11 @@ import { TriggerOperation, TriggerType } from '@azure/cosmos'; import { type IActionContext } from '@microsoft/vscode-azext-utils'; import type vscode from 'vscode'; import { type Experience } from '../../AzureDBExperiences'; -import { type EditableFileSystemItem } from '../../DatabasesFileSystem'; import { type DocumentDBTriggerModel } from '../../tree/docdb/models/DocumentDBTriggerModel'; import { localize } from '../../utils/localize'; import { nonNullProp } from '../../utils/nonNull'; import { getCosmosClient } from '../getCosmosClient'; +import { type EditableFileSystemItem } from './CosmosFileSystem'; export async function getTriggerType(context: IActionContext): Promise { const options = Object.keys(TriggerType).map((type) => ({ label: type })); @@ -32,6 +32,7 @@ export async function getTriggerOperation(context: IActionContext): Promise { + public read(): Promise { return Promise.resolve(typeof this.model.trigger.body === 'string' ? this.model.trigger.body : ''); } - public async writeFileContent(context: IActionContext, content: string): Promise { + public async update(context: IActionContext, content: string): Promise { const { endpoint, credentials, isEmulator } = this.model.accountInfo; const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); const readResponse = await cosmosClient diff --git a/src/docdb/services/ItemService.ts b/src/docdb/services/ItemService.ts new file mode 100644 index 000000000..05b352a7f --- /dev/null +++ b/src/docdb/services/ItemService.ts @@ -0,0 +1,330 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + AbortError, + ErrorResponse, + TimeoutError, + type CosmosClient, + type ItemDefinition, + type JSONObject, + type PartitionKey, + type PartitionKeyDefinition, + type Resource, +} from '@azure/cosmos'; +import { parseError } from '@microsoft/vscode-azext-utils'; +import vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { extractPartitionKey } from '../../utils/document'; +import { localize } from '../../utils/localize'; +import { type NoSqlQueryConnection } from '../NoSqlCodeLensProvider'; +import { getCosmosClientByConnection } from '../getCosmosClient'; + +export class ItemService implements vscode.Disposable { + private readonly client: CosmosClient; + private readonly databaseId: string; + private readonly containerId: string; + + private abortControllers: WeakSet = new WeakSet(); + private partitionKey: PartitionKeyDefinition | undefined; + private isDisposed = false; + + constructor(public readonly connection: NoSqlQueryConnection) { + const { databaseId, containerId } = connection; + + this.client = getCosmosClientByConnection(connection); + this.databaseId = databaseId; + this.containerId = containerId; + } + + public async create( + item: ItemDefinition, + abortController?: AbortController, + ): Promise<(ItemDefinition & Resource) | undefined> { + if (this.isDisposed) { + throw new Error('Session is disposed'); + } + + abortController ??= new AbortController(); + + this.abortControllers.add(abortController); + + try { + const result = await this.client + .database(this.databaseId) + .container(this.containerId) + .items.create(item, { + abortSignal: abortController.signal, + }); + + return result.resource; + } catch (error) { + await this.errorHandling(error); + } finally { + this.abortControllers.delete(abortController); + } + + return undefined; + } + + public async read( + item: ItemDefinition & Resource, + abortController?: AbortController, + ): Promise<(ItemDefinition & Resource) | undefined>; + public async read( + itemId: string, + partitionKeyValue?: PartitionKey, + resourceId?: string, // _rid + abortController?: AbortController, + ): Promise<(ItemDefinition & Resource) | undefined>; + public async read( + arg1: (ItemDefinition & Resource) | string, + arg2?: PartitionKey | AbortController, + arg3?: string, + arg4?: AbortController, + ): Promise<(ItemDefinition & Resource) | undefined> { + if (this.isDisposed) { + throw new Error('Session is disposed'); + } + + let itemId: string | undefined; + let resourceId: string | undefined; + let partitionKeyValue: PartitionKey | undefined; + let abortController: AbortController | undefined; + + if (typeof arg1 === 'object') { + itemId = arg1.id; + resourceId = arg1._rid; + const partitionKey = await this.getPartitionKey(); + partitionKeyValue = partitionKey ? extractPartitionKey(arg1, partitionKey) : undefined; + abortController = arg2 as AbortController; + } else { + itemId = arg1 as string; + partitionKeyValue = arg2 as PartitionKey; + resourceId = arg3; + abortController = arg4; + } + + abortController ??= new AbortController(); + + this.abortControllers.add(abortController); + + try { + const response = await this.client + .database(this.databaseId) + .container(this.containerId) + .item(itemId, partitionKeyValue) + .read({ + abortSignal: abortController.signal, + }); + + if (response?.resource) { + return response.resource; + } + + // TODO: Should we try to read the document by _rid if the above fails? + if (resourceId) { + const queryResult = await this.client + .database(this.databaseId) + .container(this.containerId) + .items.query(`SELECT * FROM c WHERE c._rid = "${resourceId}"`, { + abortSignal: abortController.signal, + bufferItems: true, + }) + .fetchAll(); + + if (queryResult.resources?.length === 1) { + return queryResult.resources[0]; + } + } + } catch (error) { + await this.errorHandling(error); + } finally { + this.abortControllers.delete(abortController); + } + + return undefined; + } + + public async update( + item: ItemDefinition & Resource, + partitionKeyValue?: PartitionKey, + abortController?: AbortController, + ): Promise<(ItemDefinition & Resource) | undefined> { + if (this.isDisposed) { + throw new Error('Session is disposed'); + } + + if (!partitionKeyValue) { + const partitionKey = await this.getPartitionKey(); + partitionKeyValue = partitionKey ? extractPartitionKey(item, partitionKey) : undefined; + } + + abortController ??= new AbortController(); + + this.abortControllers.add(abortController); + + try { + const response = await this.client + .database(this.databaseId) + .container(this.containerId) + .item(item.id, partitionKeyValue) + .replace(item, { + abortSignal: abortController.signal, + }); + + return response.resource; + } catch (error) { + await this.errorHandling(error); + } finally { + this.abortControllers.delete(abortController); + } + + return undefined; + } + + public async delete(item: ItemDefinition & Resource, abortController?: AbortController): Promise; + public async delete( + itemId: string, + partitionKeyValue?: PartitionKey, + abortController?: AbortController, + ): Promise; + public async delete( + arg1: (ItemDefinition & Resource) | string, + arg2?: PartitionKey | AbortController, + arg3?: AbortController, + ): Promise { + if (this.isDisposed) { + throw new Error('Session is disposed'); + } + + let itemId: string | undefined; + let partitionKeyValue: PartitionKey | undefined; + let abortController: AbortController | undefined; + + if (typeof arg1 === 'string') { + itemId = arg1; + partitionKeyValue = arg2 as PartitionKey; + abortController = arg3; + } else { + const partitionKey = await this.getPartitionKey(); + itemId = arg1.id; + partitionKeyValue = partitionKey ? extractPartitionKey(arg1, partitionKey) : undefined; + abortController = arg2 as AbortController; + } + + abortController ??= new AbortController(); + + this.abortControllers.add(abortController); + + try { + const result = await this.client + .database(this.databaseId) + .container(this.containerId) + .item(itemId, partitionKeyValue) + .delete({ + abortSignal: abortController.signal, + }); + + if (result?.statusCode === 204) { + return true; + } + } catch (error) { + await this.errorHandling(error); + } finally { + this.abortControllers.delete(abortController); + } + + return false; + } + + public async generateNewItemTemplate(): Promise { + if (this.isDisposed) { + throw new Error('Session is disposed'); + } + + const partitionKey = await this.getPartitionKey(); + const newItem: JSONObject = { + id: 'replace_with_new_item_id', + }; + + partitionKey?.paths.forEach((partitionKeyProperty) => { + let target = newItem; + const keySegments = partitionKeyProperty.split('/').filter((segment) => segment.length > 0); + const finalSegment = keySegments.pop(); + + if (!finalSegment) { + return; + } + + // Initialize nested objects as needed + keySegments.forEach((segment) => { + target[segment] ??= {}; + target = target[segment] as JSONObject; + }); + + target[finalSegment] = 'replace_with_new_partition_key_value'; + }); + + return newItem; + } + + public async getPartitionKey(): Promise { + if (this.partitionKey) { + return this.partitionKey; + } + + const container = await this.client.database(this.databaseId).container(this.containerId).read(); + + this.partitionKey = container.resource?.partitionKey; + + return this.partitionKey; + } + + public dispose(): void { + this.isDisposed = true; + Set.prototype.forEach.call(this.abortControllers, (abortController: AbortController) => { + if (abortController.signal.aborted) { + return; + } + abortController.abort(); + }); + } + + private async errorHandling(error: unknown): Promise { + const isObject = error && typeof error === 'object'; + if (error instanceof ErrorResponse) { + await this.logAndThrowError('Query failed', error); + } else if (error instanceof TimeoutError) { + await this.logAndThrowError('Query timed out', error); + } else if (error instanceof AbortError || (isObject && 'name' in error && error.name === 'AbortError')) { + await this.logAndThrowError('Query was aborted', error); + } else { + await this.logAndThrowError('Unknown error', error); + } + } + + // Should always throw an error + private async logAndThrowError(message: string, error: unknown): Promise | never { + // TODO: parseError does not handle "Message : {JSON}" format coming from Cosmos DB SDK + // we need to parse the error message and show it in a better way in the UI + const parsedError = parseError(error); + ext.outputChannel.error(`${message}: ${parsedError.message}`); + + if (parsedError.message) { + message = `${message}\n${parsedError.message}`; + } + + if (error instanceof ErrorResponse && error.message.indexOf('ActivityId:') === 0) { + message = `${message}\nActivityId: ${error.ActivityId}`; + } + + const showLogButton = localize('goToOutput', 'Go to output'); + if (await vscode.window.showErrorMessage(message, showLogButton)) { + ext.outputChannel.show(); + } + throw new Error(`${message}, ${parsedError.message}`); + } +} diff --git a/src/docdb/session/DocumentSession.ts b/src/docdb/session/DocumentSession.ts index 6a0d65439..4eea231ac 100644 --- a/src/docdb/session/DocumentSession.ts +++ b/src/docdb/session/DocumentSession.ts @@ -3,58 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - AbortError, - ErrorResponse, - TimeoutError, - type CosmosClient, - type ItemDefinition, - type JSONObject, - type PartitionKeyDefinition, -} from '@azure/cosmos'; +import { AbortError, ErrorResponse, TimeoutError, type ItemDefinition, type Resource } from '@azure/cosmos'; import { callWithTelemetryAndErrorHandling, parseError, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as crypto from 'crypto'; import { v4 as uuid } from 'uuid'; -import vscode from 'vscode'; -import { ext } from '../../extensionVariables'; import { type Channel } from '../../panels/Communication/Channel/Channel'; -import { getErrorMessage } from '../../panels/Communication/Channel/CommonChannel'; import { extractPartitionKey } from '../../utils/document'; -import { localize } from '../../utils/localize'; import { type NoSqlQueryConnection } from '../NoSqlCodeLensProvider'; -import { getCosmosClient, type CosmosDBCredential } from '../getCosmosClient'; -import { type CosmosDbRecord, type CosmosDbRecordIdentifier } from '../types/queryResult'; +import { ItemService } from '../services/ItemService'; +import { type CosmosDbRecordIdentifier } from '../types/queryResult'; export class DocumentSession { public readonly id: string; - private readonly channel: Channel; - private readonly client: CosmosClient; - private readonly databaseId: string; - private readonly containerId: string; - // For telemetry - private readonly endpoint: string; - private readonly masterKey: string; - - private partitionKey: PartitionKeyDefinition | undefined; - private abortController: AbortController; + + private readonly itemService: ItemService; + private isDisposed = false; - constructor(connection: NoSqlQueryConnection, channel: Channel) { - const { databaseId, containerId, endpoint, masterKey, isEmulator, tenantId } = connection; - const credentials: CosmosDBCredential[] = []; - if (masterKey !== undefined) { - credentials.push({ type: 'key', key: masterKey }); - } - credentials.push({ type: 'auth', tenantId: tenantId }); + constructor( + private readonly connection: NoSqlQueryConnection, + private readonly channel: Channel, + ) { + this.itemService = new ItemService(connection); this.id = uuid(); this.channel = channel; - this.client = getCosmosClient(endpoint, credentials, isEmulator); - this.databaseId = databaseId; - this.containerId = containerId; - this.endpoint = endpoint; - this.masterKey = masterKey ?? ''; - this.abortController = new AbortController(); } public async create(document: ItemDefinition): Promise { @@ -66,28 +39,22 @@ export class DocumentSession { } try { - const partitionKey = await this.getPartitionKey(); - - const response = await this.client - .database(this.databaseId) - .container(this.containerId) - .items.create(document, { - abortSignal: this.abortController.signal, - }); - - if (response?.resource) { - const record = response.resource as CosmosDbRecord; + const record = await this.itemService.create(document); + const partitionKeyDefinition = await this.itemService.getPartitionKey(); + if (record) { await this.channel.postMessage({ type: 'event', name: 'setDocument', - params: [this.id, record, partitionKey], + params: [this.id, record, partitionKeyDefinition], }); return { id: record.id, _rid: record._rid, - partitionKey: partitionKey ? extractPartitionKey(record, partitionKey) : undefined, + partitionKey: partitionKeyDefinition + ? extractPartitionKey(record, partitionKeyDefinition) + : undefined, }; } else { await this.channel.postMessage({ @@ -117,42 +84,15 @@ export class DocumentSession { } try { - let result: CosmosDbRecord | null = null; - const response = await this.client - .database(this.databaseId) - .container(this.containerId) - .item(documentId.id, documentId.partitionKey) - .read({ - abortSignal: this.abortController.signal, - }); - - if (response?.resource) { - result = response.resource; - } + const record = await this.itemService.read(documentId.id, documentId.partitionKey, documentId._rid); - // TODO: Should we try to read the document by _rid if the above fails? - if (!result && documentId._rid) { - const queryResult = await this.client - .database(this.databaseId) - .container(this.containerId) - .items.query(`SELECT * FROM c WHERE c._rid = "${documentId._rid}"`, { - abortSignal: this.abortController.signal, - bufferItems: true, - }) - .fetchAll(); - - if (queryResult.resources?.length === 1) { - result = queryResult.resources[0]; - } - } - - if (result) { - const partitionKey = await this.getPartitionKey(); + if (record) { + const partitionKey = await this.itemService.getPartitionKey(); await this.channel.postMessage({ type: 'event', name: 'setDocument', - params: [this.id, result, partitionKey], + params: [this.id, record, partitionKey], }); } else { await this.channel.postMessage({ @@ -178,33 +118,30 @@ export class DocumentSession { throw new Error('Session is disposed'); } - if (documentId.id === undefined) { + if (documentId.id === undefined || document.id === undefined) { throw new Error('Document id is required'); } try { - const response = await this.client - .database(this.databaseId) - .container(this.containerId) - .item(documentId.id, documentId.partitionKey) - .replace(document, { - abortSignal: this.abortController.signal, - }); - - if (response?.resource) { - const record = response.resource as CosmosDbRecord; - const partitionKey = await this.getPartitionKey(); + const record = await this.itemService.update( + document as ItemDefinition & Resource, + documentId.partitionKey, + ); + if (record) { + const partitionKeyDefinition = await this.itemService.getPartitionKey(); await this.channel.postMessage({ type: 'event', name: 'setDocument', - params: [this.id, record, partitionKey], + params: [this.id, record, documentId.partitionKey], }); return { id: record.id, _rid: record._rid, - partitionKey: partitionKey ? extractPartitionKey(record, partitionKey) : undefined, + partitionKey: partitionKeyDefinition + ? extractPartitionKey(record, partitionKeyDefinition) + : undefined, }; } else { await this.channel.postMessage({ @@ -234,15 +171,9 @@ export class DocumentSession { } try { - const result = await this.client - .database(this.databaseId) - .container(this.containerId) - .item(documentId.id, documentId.partitionKey) - .delete({ - abortSignal: this.abortController.signal, - }); + const result = await this.itemService.delete(documentId.id, documentId.partitionKey); - if (result?.statusCode === 204) { + if (result) { await this.channel.postMessage({ type: 'event', name: 'documentDeleted', @@ -271,33 +202,13 @@ export class DocumentSession { throw new Error('Session is disposed'); } - const partitionKey = await this.getPartitionKey(); - - const newDocument: JSONObject = { - id: 'replace_with_new_document_id', - }; - partitionKey?.paths.forEach((partitionKeyProperty) => { - let target = newDocument; - const keySegments = partitionKeyProperty.split('/').filter((segment) => segment.length > 0); - const finalSegment = keySegments.pop(); - - if (!finalSegment) { - return; - } - - // Initialize nested objects as needed - keySegments.forEach((segment) => { - target[segment] ??= {}; - target = target[segment] as JSONObject; - }); - - target[finalSegment] = 'replace_with_new_partition_key_value'; - }); + const newDocument = this.itemService.generateNewItemTemplate(); + const partitionKeyDefinition = await this.itemService.getPartitionKey(); await this.channel.postMessage({ type: 'event', name: 'setDocument', - params: [this.id, newDocument, partitionKey], + params: [this.id, newDocument, partitionKeyDefinition], }); }, ); @@ -305,7 +216,7 @@ export class DocumentSession { public dispose(): void { this.isDisposed = true; - this.abortController?.abort(); + this.itemService.dispose(); } private async errorHandling(error: unknown, context: IActionContext): Promise { @@ -318,87 +229,41 @@ export class DocumentSession { name: 'queryError', params: [this.id, message], }); - await this.logAndThrowError('Query failed', error); } else if (error instanceof TimeoutError) { await this.channel.postMessage({ type: 'event', name: 'queryError', params: [this.id, 'Query timed out'], }); - await this.logAndThrowError('Query timed out', error); } else if (error instanceof AbortError || (isObject && 'name' in error && error.name === 'AbortError')) { await this.channel.postMessage({ type: 'event', name: 'queryError', params: [this.id, 'Query was aborted'], }); - await this.logAndThrowError('Query was aborted', error); } else { // always force unexpected query errors to be included in report issue command context.errorHandling.forceIncludeInReportIssueCommand = true; await this.channel.postMessage({ type: 'event', name: 'queryError', - params: [this.id, getErrorMessage(error)], + params: [this.id, parseError(error)], }); - await this.logAndThrowError('Query failed', error); } throw error; } private setTelemetryProperties(context: IActionContext): void { - context.valuesToMask.push(this.masterKey, this.endpoint, this.databaseId, this.containerId); + const { masterKey, endpoint, databaseId, containerId } = this.connection; + + context.valuesToMask.push(masterKey ?? '', endpoint, databaseId, containerId); context.errorHandling.suppressDisplay = true; context.errorHandling.suppressReportIssue = true; context.telemetry.properties.sessionId = this.id; - context.telemetry.properties.databaseId = crypto.createHash('sha256').update(this.databaseId).digest('hex'); - context.telemetry.properties.containerId = crypto.createHash('sha256').update(this.containerId).digest('hex'); - } - - private async getPartitionKey(): Promise { - if (this.partitionKey) { - return this.partitionKey; - } - - return callWithTelemetryAndErrorHandling('cosmosDB.nosql.document.session.getPartitionKey', async () => { - const container = await this.client.database(this.databaseId).container(this.containerId).read(); - - if (container.resource === undefined) { - // Should be impossible since here we have a connection from the extension - throw new Error(`Container ${this.containerId} not found`); - } - - this.partitionKey = container.resource.partitionKey; - return this.partitionKey; - }); - } - - private async logAndThrowError(message: string, error: unknown = undefined): Promise { - if (error) { - //TODO: parseError does not handle "Message : {JSON}" format coming from Cosmos DB SDK - // we need to parse the error message and show it in a better way in the UI - const parsedError = parseError(error); - ext.outputChannel.error(`${message}: ${parsedError.message}`); - - if (parsedError.message) { - message = `${message}\n${parsedError.message}`; - } - - if (error instanceof ErrorResponse && error.message.indexOf('ActivityId:') === 0) { - message = `${message}\nActivityId: ${error.ActivityId}`; - } - - const showLogButton = localize('goToOutput', 'Go to output'); - if (await vscode.window.showErrorMessage(message, showLogButton)) { - ext.outputChannel.show(); - } - throw new Error(`${message}, ${parsedError.message}`); - } else { - await vscode.window.showErrorMessage(message); - throw new Error(message); - } + context.telemetry.properties.databaseId = crypto.createHash('sha256').update(databaseId).digest('hex'); + context.telemetry.properties.containerId = crypto.createHash('sha256').update(containerId).digest('hex'); } } diff --git a/src/docdb/utils/NoSqlQueryConnection.ts b/src/docdb/utils/NoSqlQueryConnection.ts index 3b3fbe526..1880fbbff 100644 --- a/src/docdb/utils/NoSqlQueryConnection.ts +++ b/src/docdb/utils/NoSqlQueryConnection.ts @@ -6,17 +6,19 @@ import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; -import { type DocumentDBItemsResourceItem } from '../../tree/docdb/DocumentDBItemsResourceItem'; +import { type DocumentDBContainerModel } from '../../tree/docdb/models/DocumentDBContainerModel'; +import { type DocumentDBItemModel } from '../../tree/docdb/models/DocumentDBItemModel'; +import { type DocumentDBItemsModel } from '../../tree/docdb/models/DocumentDBItemsModel'; import { pickAppResource } from '../../utils/pickItem/pickAppResource'; import { getCosmosAuthCredential, getCosmosKeyCredential } from '../getCosmosClient'; import { type NoSqlQueryConnection } from '../NoSqlCodeLensProvider'; export function createNoSqlQueryConnection( - node: DocumentDBContainerResourceItem | DocumentDBItemsResourceItem, + model: DocumentDBContainerModel | DocumentDBItemsModel | DocumentDBItemModel, ): NoSqlQueryConnection { - const accountInfo = node.model.accountInfo; - const databaseId = node.model.database.id; - const containerId = node.model.container.id; + const accountInfo = model.accountInfo; + const databaseId = model.database.id; + const containerId = model.container.id; const keyCred = getCosmosKeyCredential(accountInfo.credentials); const tenantId = getCosmosAuthCredential(accountInfo.credentials)?.tenantId; @@ -36,6 +38,6 @@ export async function getNoSqlQueryConnection(): Promise'}|${partitionKeyValues || ''}|${rid || ''}`; - } - - /** - * Warning: This method is used to generate a partition key value for the document tree item. - * It is not used to generate the actual partition key value. - */ - protected generatePartitionKeyValue(model: DocumentDBItemModel): string { - if (!model.container.partitionKey || model.container.partitionKey.paths.length === 0) { - return ''; - } - - let partitionKeyValues = extractPartitionKey(model.item, model.container.partitionKey); - partitionKeyValues = Array.isArray(partitionKeyValues) ? partitionKeyValues : [partitionKeyValues]; - partitionKeyValues = partitionKeyValues - .map((v) => { - if (v === null) { - return '\\'; - } - if (v === undefined) { - return '\\'; - } - if (typeof v === 'object') { - return JSON.stringify(v); - } - return v; - }) - .join(', '); - - return partitionKeyValues; - } } diff --git a/src/tree/docdb/DocumentDBItemsResourceItem.ts b/src/tree/docdb/DocumentDBItemsResourceItem.ts index 584f8f53b..9675ca6d2 100644 --- a/src/tree/docdb/DocumentDBItemsResourceItem.ts +++ b/src/tree/docdb/DocumentDBItemsResourceItem.ts @@ -3,7 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type CosmosClient, type FeedOptions, type ItemDefinition, type QueryIterator } from '@azure/cosmos'; +import { + type CosmosClient, + type FeedOptions, + type ItemDefinition, + type QueryIterator, + type Resource, +} from '@azure/cosmos'; import { createContextValue, createGenericElement, type IActionContext } from '@microsoft/vscode-azext-utils'; import vscode, { type TreeItem } from 'vscode'; import { type Experience } from '../../AzureDBExperiences'; @@ -74,14 +80,19 @@ export abstract class DocumentDBItemsResourceItem }; } - protected getIterator(cosmosClient: CosmosClient, feedOptions: FeedOptions): QueryIterator { + protected getIterator( + cosmosClient: CosmosClient, + feedOptions: FeedOptions, + ): QueryIterator { return cosmosClient .database(this.model.database.id) .container(this.model.container.id) - .items.readAll(feedOptions); + .items.readAll(feedOptions); } - protected async getItems(iterator: QueryIterator): Promise { + protected async getItems( + iterator: QueryIterator, + ): Promise<(ItemDefinition & Resource)[]> { const result = await iterator.fetchNext(); const items = result.resources; this.hasMoreChildren = result.hasMoreResults; @@ -89,5 +100,5 @@ export abstract class DocumentDBItemsResourceItem return items; } - protected abstract getChildrenImpl(items: ItemDefinition[]): Promise; + protected abstract getChildrenImpl(items: (ItemDefinition & Resource)[]): Promise; } diff --git a/src/tree/docdb/models/DocumentDBItemModel.ts b/src/tree/docdb/models/DocumentDBItemModel.ts index cd7c4fb8e..864152d5b 100644 --- a/src/tree/docdb/models/DocumentDBItemModel.ts +++ b/src/tree/docdb/models/DocumentDBItemModel.ts @@ -10,5 +10,5 @@ export type DocumentDBItemModel = { accountInfo: AccountInfo; database: DatabaseDefinition & Resource; container: ContainerDefinition & Resource; - item: ItemDefinition; + item: ItemDefinition & Resource; }; diff --git a/src/tree/nosql/NoSqlItemsResourceItem.ts b/src/tree/nosql/NoSqlItemsResourceItem.ts index 42da5ab92..f1e56c27a 100644 --- a/src/tree/nosql/NoSqlItemsResourceItem.ts +++ b/src/tree/nosql/NoSqlItemsResourceItem.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type ItemDefinition } from '@azure/cosmos'; +import { type ItemDefinition, type Resource } from '@azure/cosmos'; import { type Experience } from '../../AzureDBExperiences'; import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; import { DocumentDBItemsResourceItem } from '../docdb/DocumentDBItemsResourceItem'; @@ -15,7 +15,7 @@ export class NoSqlItemsResourceItem extends DocumentDBItemsResourceItem { super(model, experience); } - protected getChildrenImpl(items: ItemDefinition[]): Promise { + protected getChildrenImpl(items: (ItemDefinition & Resource)[]): Promise { return Promise.resolve( items.map((item) => new NoSqlItemResourceItem({ ...this.model, item }, this.experience)), ); diff --git a/src/utils/document.ts b/src/utils/document.ts index 3b75b8994..72b3cc721 100644 --- a/src/utils/document.ts +++ b/src/utils/document.ts @@ -75,3 +75,47 @@ export const getDocumentId = ( return undefined; }; + +/** + * Warning: This method is used to generate a partition key value for the document tree item. + * It is not used to generate the actual partition key value. + */ +export const generatePartitionKeyValue = ( + item: ItemDefinition, + partitionKeyDefinition?: PartitionKeyDefinition, +): string => { + if (!partitionKeyDefinition || partitionKeyDefinition.paths.length === 0) { + return ''; + } + + let partitionKeyValues = extractPartitionKey(item, partitionKeyDefinition); + partitionKeyValues = Array.isArray(partitionKeyValues) ? partitionKeyValues : [partitionKeyValues]; + partitionKeyValues = partitionKeyValues + .map((v) => { + if (v === null) { + return '\\'; + } + if (v === undefined) { + return '\\'; + } + if (typeof v === 'object') { + return JSON.stringify(v); + } + return v; + }) + .join(', '); + + return partitionKeyValues; +}; + +/** + * Warning: This method is used to generate a unique ID for the document tree item. + */ +export const generateUniqueId = (item: ItemDefinition, partitionKeyDefinition?: PartitionKeyDefinition): string => { + const documentId = getDocumentId(item, partitionKeyDefinition); + const id = documentId?.id; + const rid = documentId?._rid; + const partitionKeyValues = generatePartitionKeyValue(item, partitionKeyDefinition); + + return `${id || ''}|${partitionKeyValues || ''}|${rid || ''}`; +}; diff --git a/src/utils/vscodeUtils.ts b/src/utils/vscodeUtils.ts index 390d3ef21..ac383d8f0 100644 --- a/src/utils/vscodeUtils.ts +++ b/src/utils/vscodeUtils.ts @@ -8,7 +8,7 @@ import { AzExtTreeItem } from '@microsoft/vscode-azext-utils'; import * as fse from 'fs-extra'; import * as path from 'path'; import * as vscode from 'vscode'; -import { type EditableFileSystemItem } from '../DatabasesFileSystem'; +import { type EditableFileSystemItem } from '../docdb/fs/CosmosFileSystem'; import { ext } from '../extensionVariables'; import { type CosmosDBTreeElement } from '../tree/CosmosDBTreeElement'; import { getRootPath } from './workspacUtils'; diff --git a/src/webviews/QueryEditor/ResultPanel/ResultTabToolbar.tsx b/src/webviews/QueryEditor/ResultPanel/ResultTabToolbar.tsx index f80a35306..9d6d3bfab 100644 --- a/src/webviews/QueryEditor/ResultPanel/ResultTabToolbar.tsx +++ b/src/webviews/QueryEditor/ResultPanel/ResultTabToolbar.tsx @@ -7,7 +7,7 @@ import { type OptionOnSelectData } from '@fluentui/react-combobox'; import { Dropdown, Option, Toolbar, ToolbarButton, Tooltip, useRestoreFocusTarget } from '@fluentui/react-components'; import { AddFilled, DeleteRegular, EditRegular, EyeRegular } from '@fluentui/react-icons'; import { useMemo } from 'react'; -import { type CosmosDbRecordIdentifier } from 'src/docdb/types/queryResult'; +import { type CosmosDbRecordIdentifier } from '../../../docdb/types/queryResult'; import { getDocumentId, isSelectStar } from '../../utils'; import { useQueryEditorDispatcher, useQueryEditorState } from '../state/QueryEditorContext'; import { type TableViewMode } from '../state/QueryEditorState';