diff --git a/.azure-pipelines/compliance/CredScanSuppressions.json b/.azure-pipelines/compliance/CredScanSuppressions.json index a3567c7ec..5a91a03df 100644 --- a/.azure-pipelines/compliance/CredScanSuppressions.json +++ b/.azure-pipelines/compliance/CredScanSuppressions.json @@ -5,30 +5,6 @@ "folder": "node_modules\\", "_justification": "No need to scan external node modules." }, - { - "file": "out\\test\\docDBConnectionStrings.test.js", - "_justification": "Fake credentials used for unit tests." - }, - { - "file": "out\\test\\mongoConnectionStrings.test.js", - "_justification": "Fake credentials used for unit tests." - }, - { - "file": "test\\docDBConnectionStrings.test.ts", - "_justification": "Fake credentials used for unit tests." - }, - { - "file": "test\\mongoConnectionStrings.test.ts", - "_justification": "Fake credentials used for unit tests." - }, - { - "file": "dist\\test\\docDBConnectionStrings.test.js", - "_justification": "Fake credentials used for unit tests." - }, - { - "file": "dist\\test\\mongoConnectionStrings.test.js", - "_justification": "Fake credentials used for unit tests." - }, { "file": "dist\\extension.bundle.js.map", "_justification": "Should be covered by scanning the pre-bundled files and it's unclear why the 'map' file in particular is getting flagged." diff --git a/extension.bundle.ts b/extension.bundle.ts index fdb1a8a29..2d5ede37d 100644 --- a/extension.bundle.ts +++ b/extension.bundle.ts @@ -18,30 +18,29 @@ export { ObjectId } from 'bson'; // // The tests should import '../extension.bundle.ts'. At design-time they live in tests/ and so will pick up this file (extension.bundle.ts). // At runtime the tests live in dist/tests and will therefore pick up the main webpack bundle at dist/extension.bundle.js. -export { AzureAccountTreeItemBase, createAzureClient } from '@microsoft/vscode-azext-azureutils'; -export * from '@microsoft/vscode-azext-utils'; export { emulatorPassword, isWindows } from './src/constants'; export { ParsedDocDBConnectionString, parseDocDBConnectionString } from './src/docdb/docDBConnectionStrings'; export { getCosmosClient } from './src/docdb/getCosmosClient'; export * from './src/docdb/registerDocDBCommands'; -export { activateInternal, cosmosDBCopyConnectionString, createServer, deactivateInternal, deleteAccount } from './src/extension'; +export { activateInternal, deactivateInternal } from './src/extension'; export { ext } from './src/extensionVariables'; -export * from './src/graph/registerGraphCommands'; -export { MongoCommand } from './src/mongo/MongoCommand'; -export { findCommandAtPosition, getAllCommandsFromText } from './src/mongo/MongoScrapbook'; -export { MongoShell } from './src/mongo/MongoShell'; export { connectToMongoClient, isCosmosEmulatorConnectionString } from './src/mongo/connectToMongoClient'; -export { addDatabaseToAccountConnectionString, encodeMongoConnectionString, getDatabaseNameFromConnectionString } from './src/mongo/mongoConnectionStrings'; +export { MongoCommand } from './src/mongo/MongoCommand'; +export { + addDatabaseToAccountConnectionString, + encodeMongoConnectionString, + getDatabaseNameFromConnectionString, +} from './src/mongo/mongoConnectionStrings'; +export { findCommandAtPosition, getAllCommandsFromText } from './src/mongo/MongoScrapbookHelpers'; +export { MongoShellScriptRunner as MongoShell } from './src/mongo/MongoShellScriptRunner'; export * from './src/mongo/registerMongoCommands'; -export { IDatabaseInfo } from './src/mongo/tree/MongoAccountTreeItem'; export { addDatabaseToConnectionString } from './src/postgres/postgresConnectionStrings'; -export { AttachedAccountsTreeItem, MONGO_CONNECTION_EXPECTED } from './src/tree/AttachedAccountsTreeItem'; -export { AzureAccountTreeItemWithAttached } from './src/tree/AzureAccountTreeItemWithAttached'; +export { SettingUtils } from './src/services/SettingsService'; +export { AttachedAccountsTreeItem } from './src/tree/AttachedAccountsTreeItem'; export * from './src/utils/azureClients'; export { getPublicIpv4, isIpInRanges } from './src/utils/getIp'; export { improveError } from './src/utils/improveError'; export { randomUtils } from './src/utils/randomUtils'; -export { getGlobalSetting, updateGlobalSetting } from './src/utils/settingUtils'; export { rejectOnTimeout, valueOnTimeout } from './src/utils/timeout'; export { IDisposable, getDocumentTreeItemLabel } from './src/utils/vscodeUtils'; export { wrapError } from './src/utils/wrapError'; diff --git a/package-lock.json b/package-lock.json index 93e4ba24a..1dfea7fb5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@azure/arm-cosmosdb": "16.0.0-beta.7", "@azure/arm-postgresql": "^6.1.0", "@azure/arm-postgresql-flexible": "^7.1.0", + "@azure/arm-resources": "^5.2.0", "@azure/cosmos": "^4.1.1", "@fluentui/react-components": "^9.56.2", "@fluentui/react-icons": "^2.0.265", @@ -269,6 +270,23 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, + "node_modules/@azure/arm-resources": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@azure/arm-resources/-/arm-resources-5.2.0.tgz", + "integrity": "sha512-wQyuhL8WQsLkW/KMdik8bLJIJCz3Z6mg/+AKm0KedgK73SKhicSqYP+ed3t+43tLlRFltcrmGKMcHLQ+Jhv/6A==", + "dependencies": { + "@azure/abort-controller": "^1.0.0", + "@azure/core-auth": "^1.3.0", + "@azure/core-client": "^1.7.0", + "@azure/core-lro": "^2.5.0", + "@azure/core-paging": "^1.2.0", + "@azure/core-rest-pipeline": "^1.8.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@azure/arm-resources-profile-2020-09-01-hybrid": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@azure/arm-resources-profile-2020-09-01-hybrid/-/arm-resources-profile-2020-09-01-hybrid-2.0.0.tgz", @@ -311,6 +329,11 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" }, + "node_modules/@azure/arm-resources/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/@azure/arm-storage": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/@azure/arm-storage/-/arm-storage-18.2.0.tgz", @@ -4286,28 +4309,6 @@ "@azure/ms-rest-azure-env": "^2.0.0" } }, - "node_modules/@microsoft/vscode-azext-azureutils/node_modules/@azure/arm-resources": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@azure/arm-resources/-/arm-resources-5.2.0.tgz", - "integrity": "sha512-wQyuhL8WQsLkW/KMdik8bLJIJCz3Z6mg/+AKm0KedgK73SKhicSqYP+ed3t+43tLlRFltcrmGKMcHLQ+Jhv/6A==", - "dependencies": { - "@azure/abort-controller": "^1.0.0", - "@azure/core-auth": "^1.3.0", - "@azure/core-client": "^1.7.0", - "@azure/core-lro": "^2.5.0", - "@azure/core-paging": "^1.2.0", - "@azure/core-rest-pipeline": "^1.8.0", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@microsoft/vscode-azext-azureutils/node_modules/tslib": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==" - }, "node_modules/@microsoft/vscode-azext-azureutils/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/package.json b/package.json index ef951e2ce..dd4aca76e 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ }, "dependencies": { "@azure/arm-cosmosdb": "16.0.0-beta.7", + "@azure/arm-resources": "^5.2.0", "@azure/arm-postgresql": "^6.1.0", "@azure/arm-postgresql-flexible": "^7.1.0", "@azure/cosmos": "^4.1.1", @@ -265,11 +266,6 @@ "title": "Create Server...", "icon": "$(add)" }, - { - "category": "Azure Databases", - "command": "azureDatabases.loadMore", - "title": "Load More" - }, { "category": "Azure Databases", "command": "azureDatabases.refresh", @@ -295,7 +291,7 @@ }, { "category": "Cosmos DB", - "command": "azureDatabases.detachDatabaseAccount", + "command": "cosmosDB.detachDatabaseAccount", "title": "Detach Database Account..." }, { @@ -303,47 +299,21 @@ "command": "cosmosDB.attachEmulator", "title": "Attach Emulator..." }, - { - "category": "MongoDB", - "command": "cosmosDB.connectMongoDB", - "title": "Connect to Database..." - }, - { - "category": "Cosmos DB", - "command": "cosmosDB.connectNoSqlContainer", - "title": "Connect to NoSQL container", - "when": "false" - }, { "category": "Cosmos DB", "command": "cosmosDB.copyConnectionString", "title": "Copy Connection String" }, { - "category": "NoSQL", - "command": "cosmosDB.createDocDBCollection", - "title": "Create Container..." + "category": "Cosmos DB", + "command": "cosmosDB.deleteAccount", + "title": "Delete Account..." }, { - "category": "NoSQL", - "command": "cosmosDB.createDocDBDatabase", + "category": "Cosmos DB", + "command": "cosmosDB.createDatabase", "title": "Create Database..." }, - { - "category": "NoSQL", - "command": "cosmosDB.createDocDBDocument", - "title": "Create Document..." - }, - { - "category": "NoSQL", - "command": "cosmosDB.createDocDBStoredProcedure", - "title": "Create Stored Procedure..." - }, - { - "category": "NoSQL", - "command": "cosmosDB.createDocDBTrigger", - "title": "Create Trigger..." - }, { "category": "Graph (Gremlin)", "command": "cosmosDB.createGraph", @@ -351,38 +321,38 @@ }, { "category": "Graph (Gremlin)", - "command": "cosmosDB.createGraphDatabase", - "title": "Create Database..." + "command": "cosmosDB.deleteGraph", + "title": "Delete Graph..." }, { - "category": "MongoDB", - "command": "cosmosDB.createMongoCollection", - "title": "Create Collection..." + "category": "Graph (Gremlin)", + "command": "cosmosDB.openGraphExplorer", + "title": "Open Graph Explorer" }, { - "category": "MongoDB", - "command": "cosmosDB.createMongoDatabase", - "title": "Create Database..." + "category": "NoSQL", + "command": "cosmosDB.createDocDBContainer", + "title": "Create Container..." }, { - "category": "MongoDB", - "command": "cosmosDB.createMongoDocument", - "title": "Create Document" + "category": "NoSQL", + "command": "cosmosDB.deleteDocDBContainer", + "title": "Delete Container..." }, { - "category": "Cosmos DB", - "command": "cosmosDB.deleteAccount", - "title": "Delete Account..." + "category": "NoSQL", + "command": "cosmosDB.deleteDatabase", + "title": "Delete Database..." }, { "category": "NoSQL", - "command": "cosmosDB.deleteDocDBCollection", - "title": "Delete Container..." + "command": "cosmosDB.createDocDBDocument", + "title": "Create Document..." }, { "category": "NoSQL", - "command": "cosmosDB.deleteDocDBDatabase", - "title": "Delete Database..." + "command": "cosmosDB.openDocument", + "title": "Open Document" }, { "category": "NoSQL", @@ -391,126 +361,85 @@ }, { "category": "NoSQL", - "command": "cosmosDB.deleteDocDBStoredProcedure", - "title": "Delete Stored Procedure..." + "command": "cosmosDB.createDocDBStoredProcedure", + "title": "Create Stored Procedure..." }, { "category": "NoSQL", - "command": "cosmosDB.deleteDocDBTrigger", - "title": "Delete Trigger..." + "command": "cosmosDB.openStoredProcedure", + "title": "Open Stored Procedure" }, { - "category": "Graph (Gremlin)", - "command": "cosmosDB.deleteGraph", - "title": "Delete Graph..." + "category": "NoSQL", + "command": "cosmosDB.executeDocDBStoredProcedure", + "title": "Execute Stored Procedure..." }, { - "category": "Graph (Gremlin)", - "command": "cosmosDB.deleteGraphDatabase", - "title": "Delete Database..." + "category": "NoSQL", + "command": "cosmosDB.deleteDocDBStoredProcedure", + "title": "Delete Stored Procedure..." }, { - "category": "MongoDB", - "command": "cosmosDB.deleteMongoCollection", - "title": "Delete Collection..." + "category": "NoSQL", + "command": "cosmosDB.createDocDBTrigger", + "title": "Create Trigger..." }, { - "category": "MongoDB", - "command": "cosmosDB.deleteMongoDB", - "title": "Delete Database..." + "category": "NoSQL", + "command": "cosmosDB.openTrigger", + "title": "Open Trigger" }, { - "category": "MongoDB", - "command": "cosmosDB.deleteMongoDocument", - "title": "Delete Document..." + "category": "NoSQL", + "command": "cosmosDB.deleteDocDBTrigger", + "title": "Delete Trigger..." }, { - "category": "MongoDB", - "command": "cosmosDB.executeAllMongoCommands", - "title": "Execute All MongoDB Commands" + "category": "NoSQL", + "command": "cosmosDB.viewDocDBContainerOffer", + "title": "View Container Offer" }, { "category": "NoSQL", - "command": "cosmosDB.executeDocDBStoredProcedure", - "title": "Execute Stored Procedure..." + "command": "cosmosDB.viewDocDBDatabaseOffer", + "title": "View Database Offer" }, { - "category": "MongoDB", - "command": "cosmosDB.executeMongoCommand", - "title": "Execute MongoDB Command" + "//": "NoSQL Scrapbook", + "category": "NoSQL", + "command": "cosmosDB.writeNoSqlQuery", + "title": "Open Query Scrapbook" }, { - "category": "Cosmos DB", + "//": "NoSQL Scrapbook", + "category": "NoSQL", + "command": "cosmosDB.connectNoSqlContainer", + "title": "Connect to NoSQL container", + "when": "false" + }, + { + "//": "NoSQL Scrapbook", + "category": "NoSQL", "command": "cosmosDB.executeNoSqlQuery", "title": "Execute NoSQL Query", "when": "false" }, { - "category": "Cosmos DB", + "//": "NoSQL Scrapbook", + "category": "NoSQL", "command": "cosmosDB.getNoSqlQueryPlan", "title": "Get NoSQL Query Plan", "when": "false" }, - { - "category": "Cosmos DB", - "command": "cosmosDB.importDocument", - "title": "Import Document into a Container..." - }, - { - "category": "MongoDB", - "command": "cosmosDB.launchMongoShell", - "title": "Launch Shell" - }, - { - "category": "MongoDB", - "command": "cosmosDB.newMongoScrapbook", - "title": "New Mongo Scrapbook", - "icon": "$(new-file)" - }, - { - "category": "MongoDB", - "command": "cosmosDB.openCollection", - "title": "Open Collection" - }, - { - "category": "Cosmos DB", - "command": "cosmosDB.openDocument", - "title": "Open Document" - }, - { - "category": "Graph (Gremlin)", - "command": "cosmosDB.openGraphExplorer", - "title": "Open Graph Explorer" - }, - { - "category": "Cosmos DB", - "command": "cosmosDB.openNoSqlQueryEditor", - "title": "Open Query Editor (preview)" - }, - { - "category": "Cosmos DB", - "command": "cosmosDB.openStoredProcedure", - "title": "Open Stored Procedure" - }, - { - "category": "Cosmos DB", - "command": "cosmosDB.openTrigger", - "title": "Open Trigger" - }, - { - "category": "NoSQL", - "command": "cosmosDB.viewDocDBCollectionOffer", - "title": "View Container Offer" - }, { "category": "NoSQL", - "command": "cosmosDB.viewDocDBDatabaseOffer", - "title": "View Database Offer" + "command": "cosmosDB.openNoSqlQueryEditor", + "title": "Open Query Editor" }, { "category": "NoSQL", - "command": "cosmosDB.writeNoSqlQuery", - "title": "Open Query Scrapbook" + "command": "cosmosDB.importDocument", + "title": "Import Document into a Container..." }, { "category": "PostgreSQL", @@ -557,6 +486,11 @@ "command": "postgreSQL.deleteServer", "title": "Delete Server..." }, + { + "category": "PostgreSQL", + "command": "postgreSQL.detachServer", + "title": "Detach Server..." + }, { "category": "PostgreSQL", "command": "postgreSQL.deleteStoredProcedure", @@ -593,26 +527,52 @@ "title": "Learn more about authenticating with Azure Active Directory", "icon": "$(warning)" }, + { + "//": "Mongo DB|Cluster Scrapbook Connect Database", + "category": "MongoDB", + "command": "cosmosDB.connectMongoDB", + "title": "Connect to this database" + }, + { + "//": "Mongo DB|Cluster Scrapbook Execute All Commands", + "category": "MongoDB", + "command": "cosmosDB.executeAllMongoCommands", + "title": "Execute All MongoDB Commands" + }, + { + "//": "Mongo DB|Cluster Scrapbook Execute Command", + "category": "MongoDB", + "command": "cosmosDB.executeMongoCommand", + "title": "Execute MongoDB Command" + }, + { + "//": "Mongo DB|Cluster Launch Shell", + "category": "MongoDB", + "command": "cosmosDB.launchMongoShell", + "title": "Launch Shell" + }, + { + "//": "Mongo DB|Cluster Scrapbook New Scrapbook", + "category": "MongoDB", + "command": "cosmosDB.newMongoScrapbook", + "title": "New Mongo Scrapbook", + "icon": "$(new-file)" + }, { "category": "MongoDB Clusters", "command": "command.mongoClusters.dropCollection", - "title": "Drop Collection..." + "title": "Delete Collection..." }, { "category": "MongoDB Clusters", "command": "command.mongoClusters.dropDatabase", - "title": "Drop Database..." + "title": "Delete Database..." }, { "category": "MongoDB Clusters", "command": "command.mongoClusters.createCollection", "title": "Create Collection..." }, - { - "category": "MongoDB Clusters", - "command": "command.mongoClusters.createDatabase", - "title": "Create Database..." - }, { "category": "MongoDB Clusters", "command": "command.mongoClusters.importDocuments", @@ -623,6 +583,11 @@ "command": "command.mongoClusters.exportDocuments", "title": "Export Documents from Collection..." }, + { + "category": "MongoDB Clusters", + "command": "command.mongoClusters.createDocument", + "title": "Create Document..." + }, { "category": "MongoDB Clusters", "command": "command.mongoClusters.launchShell", @@ -630,8 +595,8 @@ }, { "category": "MongoDB Clusters", - "command": "command.mongoClusters.removeWorkspaceConnection", - "title": "Remove Connection..." + "command": "command.mongoClusters.containerView.open", + "title": "Open Collection" } ], "submenus": [ @@ -642,6 +607,14 @@ "dark": "resources/databases.png", "light": "resources/databases.png" } + }, + { + "id": "azureDatabases.submenus.mongo.database.scrapbook", + "label": "Mongo Scrapbook" + }, + { + "id": "azureDatabases.submenus.mongo.collection.scrapbook", + "label": "Mongo Scrapbook" } ], "menus": { @@ -651,6 +624,34 @@ "group": "1_attach@1" } ], + "azureDatabases.submenus.mongo.database.scrapbook": [ + { + "//": "[Database] Mongo DB|Cluster Scrapbook New", + "command": "cosmosDB.newMongoScrapbook", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@1" + }, + { + "//": "[Database] Mongo DB|Cluster Scrapbook Connect", + "command": "cosmosDB.connectMongoDB", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@2" + } + ], + "azureDatabases.submenus.mongo.collection.scrapbook": [ + { + "//": "[Collection] Mongo DB|Cluster Scrapbook New", + "command": "cosmosDB.newMongoScrapbook", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@1" + }, + { + "//": "[Collection] Mongo DB|Cluster Scrapbook Connect", + "command": "cosmosDB.connectMongoDB", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@2" + } + ], "view/title": [ { "submenu": "azureDatabases.submenus.workspaceActions", @@ -721,460 +722,364 @@ "group": "1@1" }, { - "command": "cosmosDB.deleteAccount", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBMongoServer(?![a-z])/i", - "group": "1@2" - }, - { - "command": "cosmosDB.deleteAccount", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBDocumentServer(?![a-z])/i", - "group": "1@2" + "//": "[Account] (Postgres) Create Database", + "command": "postgreSQL.createDatabase", + "when": "view =~ /(azureResourceGroups|Workspace|azureFocusView)/ && viewItem =~ /postgresServer(?=[a-z])/i", + "group": "1@1" }, { - "command": "cosmosDB.deleteAccount", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBGraphAccount(?![a-z])/i", + "//": "[Account] (Postgres) Delete Account", + "command": "postgreSQL.deleteServer", + "when": "view =~ /azure(ResourceGroups|FocusView)/ && viewItem =~ /postgresServer(?![a-z])/i", "group": "1@2" }, { - "command": "cosmosDB.deleteAccount", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBTableAccount(?![a-z])/i", + "//": "[Account] (Postgres) Detached Account", + "command": "postgreSQL.detachServer", + "when": "view =~ /azureWorkspace/ && viewItem == postgresServerAttached", "group": "1@2" }, { - "command": "postgreSQL.deleteServer", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /postgresServer(?![a-z])/i", - "group": "1@2" + "//": "[Account] (Postgres) Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azureWorkspace/ && viewItem == postgresServerAttached", + "group": "2@1" }, { - "command": "cosmosDB.createMongoDatabase", - "when": "view == azureWorkspace && viewItem == cosmosDBMongoServerAttached", + "//": "[Database] (Postgres) Connect to Database", + "command": "postgreSQL.connectDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", "group": "1@1" }, { - "command": "cosmosDB.createMongoDatabase", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBMongoServer(?![a-z])/i", - "group": "1@1" + "//": "[Database] (Postgres) Password warning", + "command": "postgreSQL.showPasswordlessWiki", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i && viewItem =~ /usesPassword/i", + "group": "inline" }, { - "command": "cosmosDB.createMongoDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", - "group": "1@1" - }, - { - "command": "cosmosDB.createMongoCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == mongoDb", - "group": "1@1" - }, - { - "command": "cosmosDB.createDocDBDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentsGroup", - "group": "1@2" - }, - { - "command": "cosmosDB.openNoSqlQueryEditor", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection && config.cosmosDB.preview.queryEditor", - "group": "1@1" - }, - { - "command": "cosmosDB.openNoSqlQueryEditor", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentsGroup && config.cosmosDB.preview.queryEditor", - "group": "1@1" - }, - { - "command": "cosmosDB.writeNoSqlQuery", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", + "//": "[Database] (Postgres) Delete Database", + "command": "postgreSQL.deleteDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", "group": "1@2" }, { - "command": "cosmosDB.importDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", - "group": "1@3" - }, - { - "command": "cosmosDB.createDocDBStoredProcedure", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBStoredProceduresGroup", - "group": "1@1" - }, - { - "command": "cosmosDB.createDocDBTrigger", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBTriggersGroup", - "group": "1@1" - }, - { - "command": "cosmosDB.createDocDBCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentDatabase", - "group": "1@1" - }, - { - "command": "cosmosDB.createDocDBDatabase", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBDocumentServer(?![a-z])/i", - "group": "1@1" + "//": "[Database] (Postgres) Copy connection string", + "command": "postgreSQL.copyConnectionString", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", + "group": "2@1" }, { - "command": "cosmosDB.createDocDBDatabase", - "when": "view == azureWorkspace && viewItem == cosmosDBDocumentServerAttached", - "group": "1@1" + "//": "[Database] (Postgres) Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", + "group": "3@1" }, { - "command": "cosmosDB.createGraphDatabase", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBGraphAccount(?![a-z])/i", + "//": "[Table] (Postgres) Refresh (?)", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresTables", "group": "1@1" }, { - "command": "cosmosDB.createGraphDatabase", - "when": "view == azureWorkspace && viewItem == cosmosDBGraphAccountAttached", - "group": "1@1" + "//": "[Table] (Postgres) Delete Table", + "command": "postgreSQL.deleteTable", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresTable", + "group": "1@2" }, { - "command": "cosmosDB.createGraph", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBGraphDatabase", + "//": "[Functions] (Postgres) Create Function", + "command": "postgreSQL.createFunctionQuery", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunctions", "group": "1@1" }, { - "command": "postgreSQL.showPasswordlessWiki", - "when": "view =~ /azure(ResourceGroups|azureFocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i && viewItem =~ /usesPassword/i", - "group": "inline" + "//": "[Functions] (Postgres) Delete Function", + "command": "postgreSQL.deleteFunction", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunction", + "group": "1@2" }, { - "command": "postgreSQL.createDatabase", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /postgresServer(?![a-z])/i", - "group": "1@1" + "//": "[Functions] (Postgres) Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunctions", + "group": "2@1" }, { - "command": "postgreSQL.createDatabase", - "when": "view == azureWorkspace && viewItem == postgresServerAttached", + "//": "[Stored Procedure] (Postgres) Create Stored Procedure", + "command": "postgreSQL.createStoredProcedureQuery", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedures", "group": "1@1" }, { - "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == cosmosDBMongoServerAttached", + "//": "[Stored Procedure] (Postgres) Delete Stored Procedure", + "command": "postgreSQL.deleteStoredProcedure", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedure", "group": "1@2" }, { - "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == cosmosDBGraphAccountAttached", - "group": "1@2" + "//": "[Stored Procedure] (Postgres) Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedures", + "group": "2@1" }, { - "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == cosmosDBDocumentServerAttached", - "group": "1@2" + "//": "[Account] Create account database", + "command": "cosmosDB.createDatabase", + "when": "view =~ /azure(ResourceGroups|FocusView|Workspace)/ && viewItem =~ /treeitem[.](mongoCluster|account)(?![a-z.\\/])/i", + "group": "1@1" }, { - "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == cosmosDBTableAccountAttached", + "//": "[Account] Delete account", + "command": "cosmosDB.deleteAccount", + "when": "view =~ /azure(ResourceGroups|FocusView)/ && viewItem =~ /treeitem[.](mongoCluster|account)(?![a-z.\\/])/i", "group": "1@2" }, { - "command": "azureDatabases.detachDatabaseAccount", - "when": "view == azureWorkspace && viewItem == postgresServerAttached", + "//": "[Account] Detach account (workspace only)", + "command": "cosmosDB.detachDatabaseAccount", + "when": "view =~ /azureWorkspace/ && viewItem =~ /treeitem[.](mongoCluster|account)(?![a-z.\\/])/i", "group": "1@2" }, { - "command": "cosmosDB.connectMongoDB", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == mongoDb", + "//": "[Account] CosmosDB | MongoDB | Cluster Copy connection string", + "command": "cosmosDB.copyConnectionString", + "when": "view =~ /azure(ResourceGroups|FocusView|Workspace)/ && viewItem =~ /treeitem[.](mongoCluster|account)(?![a-z.\\/])/i", "group": "2@1" }, { - "command": "cosmosDB.deleteMongoDB", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == mongoDb", - "group": "1@2" - }, - { - "command": "cosmosDB.deleteMongoCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", - "group": "1@4" - }, - { - "command": "cosmosDB.deleteMongoDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoDocument", - "group": "1@2" - }, - { - "command": "cosmosDB.deleteDocDBCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", - "group": "1@4" - }, - { - "command": "cosmosDB.viewDocDBCollectionOffer", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", - "group": "1@5" - }, - { - "command": "cosmosDB.viewDocDBDatabaseOffer", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentDatabase", - "group": "1@5" - }, - { - "command": "cosmosDB.deleteDocDBDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocument", - "group": "1@2" + "//": "[Account] Mongo DB|Cluster Launch Shell", + "command": "command.mongoClusters.launchShell", + "when": "view =~ /azure(ResourceGroups|FocusView|Workspace)/ && viewItem =~ /treeitem[.]mongoCluster(?![a-z.\\/])/i", + "group": "2@2" }, { - "command": "cosmosDB.deleteDocDBStoredProcedure", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBStoredProcedure", - "group": "1@2" + "//": "[Account] Refresh (workspace only)", + "command": "azureDatabases.refresh", + "when": "view =~ /azureWorkspace/ && viewItem =~ /treeitem[.](mongoCluster|account)(?![a-z.\\/])/i", + "group": "3@1" }, { - "command": "cosmosDB.executeDocDBStoredProcedure", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBStoredProcedure", + "//": "[Database] Create Graph container", + "command": "cosmosDB.createGraph", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](graph)/i", "group": "1@1" }, { - "command": "cosmosDB.deleteDocDBTrigger", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBTrigger", - "group": "1@2" + "//": "[Database] Create NoSql container", + "command": "cosmosDB.createDocDBContainer", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@1" }, { - "command": "cosmosDB.deleteDocDBDatabase", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentDatabase", - "group": "1@2" + "//": "[Database] Mongo DB|Cluster Create collection", + "command": "command.mongoClusters.createCollection", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@1" }, { - "command": "cosmosDB.deleteGraphDatabase", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBGraphDatabase", + "//": "[Database] Delete database", + "command": "cosmosDB.deleteDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core|graph)/i", "group": "1@2" }, { - "command": "postgreSQL.deleteDatabase", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", + "//": "[Database] Mongo DB|Cluster Delete database", + "command": "command.mongoClusters.dropDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@2" }, { - "command": "postgreSQL.deleteTable", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresTable", - "group": "1@2" + "//": "[Database] View NoSql database offer", + "command": "cosmosDB.viewDocDBDatabaseOffer", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@3" }, { - "command": "postgreSQL.deleteFunction", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunction", - "group": "1@2" + "//": "[Database] Mongo DB|Cluster Launch Shell", + "command": "command.mongoClusters.launchShell", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "2@1" }, { - "command": "postgreSQL.deleteStoredProcedure", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedure", - "group": "1@2" + "//": "[Database] Mongo DB|Cluster Scrapbook Submenu", + "submenu": "azureDatabases.submenus.mongo.database.scrapbook", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "2@2" }, { - "command": "cosmosDB.deleteGraph", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBGraph", - "group": "1@2" + "//": "[Database] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]database(?![a-z.\\/])/i", + "group": "3@1" }, { - "command": "cosmosDB.attachDatabaseAccount", - "when": "view == azureWorkspace && viewItem =~ /cosmosDBAttachedAccounts(?![a-z])/gi", + "//": "[Container] Open NoSql query editor", + "command": "cosmosDB.openNoSqlQueryEditor", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", "group": "1@1" }, { - "command": "cosmosDB.attachEmulator", - "when": "view == azureWorkspace && viewItem == cosmosDBAttachedAccountsWithEmulator", + "//": "[Container] Import NoSql documents", + "command": "cosmosDB.importDocument", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", "group": "1@2" }, { - "command": "cosmosDB.openCollection", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", + "//": "[Container] Delete Graph container", + "command": "cosmosDB.deleteGraph", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](graph)/i", "group": "1@2" }, { - "command": "cosmosDB.copyConnectionString", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBMongoServer(?![a-z])/i", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBGraphAccount(?![a-z])/i", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBDocumentServer(?![a-z])/i", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view =~ /(azureResourceGroups|azureFocusView)/ && viewItem =~ /cosmosDBTableAccount(?![a-z])/i", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view == azureWorkspace && viewItem == cosmosDBMongoServerAttached", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view == azureWorkspace && viewItem == cosmosDBGraphAccountAttached", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view == azureWorkspace && viewItem == cosmosDBDocumentServerAttached", - "group": "2@1" - }, - { - "command": "cosmosDB.copyConnectionString", - "when": "view == azureWorkspace && viewItem == cosmosDBTableAccountAttached", - "group": "2@1" - }, - { - "command": "postgreSQL.copyConnectionString", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", - "group": "2@1" + "//": "[Container] Delete NoSql container", + "command": "cosmosDB.deleteDocDBContainer", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@3" }, { - "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem == cosmosDBMongoServerAttached", - "group": "3@2" + "//": "[Container] View NoSql container offer", + "command": "cosmosDB.viewDocDBContainerOffer", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i && viewItem =~ /experience[.](table|cassandra|core)/i", + "group": "1@4" }, { + "//": "[Container] Refresh", "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentCollection", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]container(?![a-z.\\/])/i", "group": "2@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentDatabase", - "group": "4@1" - }, - { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBDocumentsGroup", - "group": "2@1" + "//": "[Collection] Mongo DB|Cluster Open collection", + "command": "command.mongoClusters.containerView.open", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBStoredProceduresGroup", - "group": "2@1" + "//": "[Collection] Mongo DB|Cluster Create document", + "command": "command.mongoClusters.createDocument", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "1@2" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBTriggersGroup", + "//": "[Collection] Import Mongo DB|Cluster documents", + "command": "command.mongoClusters.importDocuments", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "2@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azureWorkspace/ && viewItem =~ /cosmosDBDocumentServer(?![a-z])/i", - "group": "3@2" - }, - { - "command": "azureDatabases.refresh", - "when": "view =~ /azureWorkspace/ && viewItem =~ /postgresServer(?![a-z])/i", + "//": "[Collection] Mongo DB|Cluster Export documents", + "command": "command.mongoClusters.exportDocuments", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "2@2" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", + "//": "[Collection] Mongo DB|Cluster Drop collection", + "command": "command.mongoClusters.dropCollection", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "3@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresTables", - "group": "1@1" - }, - { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunctions", - "group": "2@1" - }, - { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedures", - "group": "2@1" + "//": "[Collection] Mongo DB|Cluster Launch shell", + "command": "command.mongoClusters.launchShell", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "4@1" }, { - "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem == cosmosDBDocumentServerAttached", - "group": "3@1" + "//": "[Collection] Mongo DB|Cluster Scrapbook Submenu", + "submenu": "azureDatabases.submenus.mongo.collection.scrapbook", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", + "group": "4@2" }, { + "//": "[Collection] Refresh", "command": "azureDatabases.refresh", - "when": "view =~ /azureWorkspace/ && viewItem =~ /cosmosDBGraphAccount(?![a-z])/i", - "group": "3@2" + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]collection(?![a-z.\\/])/i", + "group": "5@1" }, { - "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem == cosmosDBGraphAccountAttached", - "group": "3@1" + "//": "[Stored Procedures] Create Stored Procedure", + "command": "cosmosDB.createDocDBStoredProcedure", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]storedProcedures(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@1" }, { + "//": "[Stored Procedures] Refresh", "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == cosmosDBGraphDatabase", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]storedProcedures(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", "group": "2@1" }, { - "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem == postgresServerAttached", - "group": "2@1" + "//": "[Stored Procedure] Execute", + "command": "cosmosDB.executeDocDBStoredProcedure", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]storedProcedure(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@1" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == mongoDb", - "group": "3@1" + "//": "[Stored Procedure] Delete", + "command": "cosmosDB.deleteDocDBStoredProcedure", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]storedProcedure(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@2" }, { - "command": "azureDatabases.refresh", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", - "group": "4@1" + "//": "[Triggers] Create Trigger", + "command": "cosmosDB.createDocDBTrigger", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]triggers(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@1" }, { + "//": "[Triggers] Refresh", "command": "azureDatabases.refresh", - "when": "view == azureWorkspace && viewItem =~ /^cosmosDBAttachedAccounts(?![a-z])/gi", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]triggers(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", "group": "2@1" }, { - "command": "cosmosDB.importDocument", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == MongoCollection", - "group": "1@3" - }, - { - "command": "postgreSQL.connectDatabase", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /postgresDatabase(?![a-z])/i", - "group": "1@1" - }, - { - "command": "postgreSQL.createFunctionQuery", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresFunctions", + "//": "[Trigger] Delete", + "command": "cosmosDB.deleteDocDBTrigger", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]trigger(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", "group": "1@1" }, { - "command": "postgreSQL.createStoredProcedureQuery", - "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem == postgresStoredProcedures", + "//": "[Documents] Open NoSql query editor", + "command": "cosmosDB.openNoSqlQueryEditor", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]documents(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", "group": "1@1" }, { - "command": "command.mongoClusters.dropCollection", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.collection" + "//": "[Documents] Create NoSql Document", + "command": "cosmosDB.createDocDBDocument", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]documents(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@2" }, { - "command": "command.mongoClusters.dropDatabase", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.database" + "//": "[Documents] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]documents(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "2@1" }, { - "command": "command.mongoClusters.removeWorkspaceConnection", - "when": "vscodeDatabases.mongoClustersSupportEnabled && view == azureWorkspace && viewItem == mongoClusters.item.mongoCluster" + "//": "[Document] Delete", + "command": "cosmosDB.deleteDocDBDocument", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]document(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "1@1" }, { - "command": "command.mongoClusters.createCollection", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.database" + "//": "[Document] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]document(?![a-z.\\/])/i && viewItem =~ /experience[.](graph|table|cassandra|core)/i", + "group": "2@1" }, { - "command": "command.mongoClusters.createDatabase", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem =~ /mongoClusters.item.mongoCluster/i", + "//": "[Indexes] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]indexes(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@1" }, { - "command": "command.mongoClusters.importDocuments", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.collection", + "//": "[Index] Refresh", + "command": "azureDatabases.refresh", + "when": "view =~ /azure(ResourceGroups|Workspace|FocusView)/ && viewItem =~ /treeitem[.]index(?![a-z.\\/])/i && viewItem =~ /experience[.](mongocluster|mongodb)/i", "group": "1@1" - }, - { - "command": "command.mongoClusters.exportDocuments", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem == mongoClusters.item.collection", - "group": "1@2" - }, - { - "command": "command.mongoClusters.launchShell", - "when": "vscodeDatabases.mongoClustersSupportEnabled && viewItem =~ /mongoClusters.item.(mongoCluster|database|collection)/i", - "group": "2@1" } ], "explorer/context": [ @@ -1188,10 +1093,6 @@ } ], "commandPalette": [ - { - "command": "azureDatabases.loadMore", - "when": "never" - }, { "command": "azureDatabases.refresh", "when": "never" @@ -1331,11 +1232,6 @@ "type": "boolean", "default": true, "description": "Show warning dialog when uploading a document to the cloud." - }, - "cosmosDB.preview.queryEditor": { - "type": "boolean", - "default": true, - "description": "Enable the NoSQL Query Editor." } } } diff --git a/src/AzureDBExperiences.ts b/src/AzureDBExperiences.ts index eed43a810..c981467c7 100644 --- a/src/AzureDBExperiences.ts +++ b/src/AzureDBExperiences.ts @@ -5,13 +5,14 @@ import { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb/src/models'; import { type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import { nonNullProp } from './utils/nonNull'; +import { type CosmosDBResource } from './tree/CosmosAccountModel'; export enum API { MongoDB = 'MongoDB', MongoClusters = 'MongoClusters', Graph = 'Graph', Table = 'Table', + Cassandra = 'Cassandra', Core = 'Core', // Now called NoSQL PostgresSingle = 'PostgresSingle', PostgresFlexible = 'PostgresFlexible', @@ -23,7 +24,21 @@ export enum DBAccountKind { GlobalDocumentDB = 'GlobalDocumentDB', } -export type CapabilityName = 'EnableGremlin' | 'EnableTable'; +enum Capability { + EnableGremlin = 'EnableGremlin', + EnableTable = 'EnableTable', + EnableCassandra = 'EnableCassandra', +} + +enum Tag { + Core = 'Core (SQL)', + Mongo = 'Azure Cosmos DB for MongoDB API', + Table = 'Azure Table', + Gremlin = 'Gremlin (graph)', + Cassandra = 'Cassandra', +} + +export type CapabilityName = 'EnableGremlin' | 'EnableTable' | 'EnableCassandra'; export function getExperienceFromApi(api: API): Experience { let info = experiencesMap.get(api); @@ -33,30 +48,32 @@ export function getExperienceFromApi(api: API): Experience { return info; } -export function getExperienceLabel(databaseAccount: DatabaseAccountGetResults): string { - const experience: Experience | undefined = tryGetExperience(databaseAccount); - if (experience) { - return experience.shortName; - } - // Must be some new kind of resource that we aren't aware of. Try to get a decent label - const defaultExperience: string = ( - (databaseAccount && databaseAccount.tags && databaseAccount.tags.defaultExperience) - ); - const firstCapability = databaseAccount.capabilities && databaseAccount.capabilities[0]; - const firstCapabilityName = firstCapability?.name?.replace(/^Enable/, ''); - return defaultExperience || firstCapabilityName || nonNullProp(databaseAccount, 'kind'); -} - -export function tryGetExperience(resource: DatabaseAccountGetResults): Experience | undefined { - // defaultExperience in the resource doesn't really mean anything, we can't depend on its value for determining resource type +export function tryGetExperience(resource: CosmosDBResource | DatabaseAccountGetResults): Experience | undefined { if (resource.kind === DBAccountKind.MongoDB) { return MongoExperience; - } else if (resource.capabilities?.find((cap) => cap.name === 'EnableGremlin')) { - return GremlinExperience; - } else if (resource.capabilities?.find((cap) => cap.name === 'EnableTable')) { - return TableExperience; - } else if (resource.capabilities?.length === 0) { - return CoreExperience; + } + + if ('capabilities' in resource) { + // defaultExperience in the resource doesn't really mean anything, we can't depend on its value for determining resource type + if (resource.capabilities?.find((cap) => cap.name === Capability.EnableGremlin)) { + return GremlinExperience; + } else if (resource.capabilities?.find((cap) => cap.name === Capability.EnableTable)) { + return TableExperience; + } else if (resource.capabilities?.find((cap) => cap.name === Capability.EnableCassandra)) { + return CassandraExperience; + } else if (resource.capabilities?.length === 0) { + return CoreExperience; + } + } else if ('tags' in resource) { + if (resource.tags?.defaultExperience === Tag.Gremlin) { + return GremlinExperience; + } else if (resource.tags?.defaultExperience === Tag.Table) { + return TableExperience; + } else if (resource.tags?.defaultExperience === Tag.Cassandra) { + return CassandraExperience; + } else if (resource.tags?.defaultExperience === Tag.Core) { + return CoreExperience; + } } return undefined; @@ -72,6 +89,9 @@ export interface Experience { shortName: string; description?: string; + // the string used as a telemetry key for a given experience + telemetryName?: string; + // These properties are what the portal actually looks at to determine the difference between APIs kind?: DBAccountKind; capability?: CapabilityName; @@ -80,28 +100,23 @@ export interface Experience { tag?: string; } -export function getExperienceQuickPicks(attached?: boolean): IAzureQuickPickItem[] { - if (attached) { - return experiencesArray.map((exp) => getExperienceQuickPickForAttached(exp.api)); - } else { - return experiencesArray.map((exp) => getExperienceQuickPick(exp.api)); - } +export function getExperienceQuickPicks(): IAzureQuickPickItem[] { + return experiencesArray.map((exp) => getExperienceQuickPick(exp.api)); } -export function getCosmosExperienceQuickPicks(attached?: boolean): IAzureQuickPickItem[] { - if (attached) { - return cosmosExperiencesArray.map((exp) => getExperienceQuickPickForAttached(exp.api)); - } else { - return cosmosExperiencesArray.map((exp) => getExperienceQuickPick(exp.api)); - } +export function getCosmosExperienceQuickPicks(): IAzureQuickPickItem[] { + return cosmosExperiencesArray.map((exp) => getExperienceQuickPick(exp.api)); } -export function getExperienceQuickPick(api: API): IAzureQuickPickItem { - const exp = getExperienceFromApi(api); - return { label: exp.longName, description: exp.description, data: exp }; +export function getPostgresExperienceQuickPicks(): IAzureQuickPickItem[] { + return postgresExperiencesArray.map((exp) => getExperienceQuickPick(exp.api)); +} + +export function getMongoCoreExperienceQuickPicks(): IAzureQuickPickItem[] { + return mongoCoreExperienceArray.map((exp) => getExperienceQuickPick(exp.api)); } -export function getExperienceQuickPickForAttached(api: API): IAzureQuickPickItem { +export function getExperienceQuickPick(api: API): IAzureQuickPickItem { const exp = getExperienceFromApi(api); return { label: exp.longName, description: exp.description, data: exp }; } @@ -120,9 +135,16 @@ export const MongoExperience: Experience = { api: API.MongoDB, longName: 'Cosmos DB for MongoDB', shortName: 'MongoDB', + telemetryName: 'mongo', kind: DBAccountKind.MongoDB, tag: 'Azure Cosmos DB for MongoDB API', } as const; +export const MongoClustersExperience: Experience = { + api: API.MongoClusters, + longName: 'Cosmos DB for MongoDB (vCore)', + shortName: 'MongoDB (vCore)', + telemetryName: 'mongoClusters', +} as const; export const TableExperience: Experience = { api: API.Table, longName: 'Cosmos DB for Table', @@ -140,22 +162,32 @@ export const GremlinExperience: Experience = { capability: 'EnableGremlin', tag: 'Gremlin (graph)', } as const; -const PostgresSingleExperience: Experience = { +export const CassandraExperience: Experience = { + api: API.Cassandra, + longName: 'Cosmos DB for Cassandra', + shortName: 'Cassandra', + kind: DBAccountKind.GlobalDocumentDB, + capability: 'EnableCassandra', + tag: 'Cassandra', +}; +export const PostgresSingleExperience: Experience = { api: API.PostgresSingle, longName: 'PostgreSQL Single Server', shortName: 'PostgreSQLSingle', }; -const PostgresFlexibleExperience: Experience = { +export const PostgresFlexibleExperience: Experience = { api: API.PostgresFlexible, longName: 'PostgreSQL Flexible Server', shortName: 'PostgreSQLFlexible', }; -const cosmosExperiencesArray: Experience[] = [CoreExperience, MongoExperience, TableExperience, GremlinExperience]; +const cosmosExperiencesArray: Experience[] = [CoreExperience, TableExperience, GremlinExperience]; +const postgresExperiencesArray: Experience[] = [PostgresSingleExperience, PostgresFlexibleExperience]; +const mongoCoreExperienceArray: Experience[] = [MongoExperience, MongoClustersExperience]; const experiencesArray: Experience[] = [ ...cosmosExperiencesArray, - PostgresSingleExperience, - PostgresFlexibleExperience, + ...postgresExperiencesArray, + ...mongoCoreExperienceArray, ]; const experiencesMap = new Map( experiencesArray.map((info: Experience): [API, Experience] => [info.api, info]), diff --git a/src/DatabasesFileSystem.ts b/src/DatabasesFileSystem.ts index e58aa0359..4043df658 100644 --- a/src/DatabasesFileSystem.ts +++ b/src/DatabasesFileSystem.ts @@ -5,16 +5,17 @@ import { AzExtTreeFileSystem, + AzExtTreeItem, DialogResponses, UserCancelledError, - type AzExtTreeItem, + type AzExtTreeFileSystemItem, type IActionContext, } from '@microsoft/vscode-azext-utils'; -import { FileType, workspace, type FileStat, type MessageItem, type Uri } from 'vscode'; +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 { getWorkspaceSetting, updateGlobalSetting } from './utils/settingUtils'; import { getNodeEditorLabel } from './utils/vscodeUtils'; export interface IEditableTreeItem extends AzExtTreeItem { @@ -26,12 +27,24 @@ export interface IEditableTreeItem extends AzExtTreeItem { writeFileContent(context: IActionContext, data: string): Promise; } -export class DatabasesFileSystem extends AzExtTreeFileSystem { +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): Promise { + 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 }; } @@ -42,7 +55,7 @@ export class DatabasesFileSystem extends AzExtTreeFileSystem public async writeFileImpl( context: IActionContext, - node: IEditableTreeItem, + node: IEditableTreeItem | EditableFileSystemItem, content: Uint8Array, _originalUri: Uri, ): Promise { @@ -50,7 +63,7 @@ export class DatabasesFileSystem extends AzExtTreeFileSystem // 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 && getWorkspaceSetting(showSavePromptKey, undefined, prefix)) { + if (this._showSaveConfirmation && SettingsService.getSetting(showSavePromptKey, prefix)) { const message: string = localize( 'saveConfirmation', 'Saving "{0}" will update the entity "{1}" to the cloud.', @@ -65,20 +78,25 @@ export class DatabasesFileSystem extends AzExtTreeFileSystem DialogResponses.dontUpload, ); if (result === DialogResponses.alwaysUpload) { - await updateGlobalSetting(showSavePromptKey, false, prefix); + await SettingsService.updateGlobalSetting(showSavePromptKey, false, prefix); } else if (result === DialogResponses.dontUpload) { throw new UserCancelledError('dontUpload'); } } await node.writeFileContent(context, content.toString()); - await node.refresh(context); + 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): string { + public getFilePath(node: IEditableTreeItem | EditableFileSystemItem): string { return node.filePath; } @@ -92,7 +110,7 @@ export class DatabasesFileSystem extends AzExtTreeFileSystem } } - public fireChangedEvent(node: IEditableTreeItem): void { + public fireChangedEvent(node: IEditableTreeItem | EditableFileSystemItem): void { node.mTime = Date.now(); this.fireSoon({ type: FileChangeType.Changed, item: node }); } diff --git a/src/commands/api/DatabaseAccountTreeItemInternal.ts b/src/commands/api/DatabaseAccountTreeItemInternal.ts index b37825244..814559cda 100644 --- a/src/commands/api/DatabaseAccountTreeItemInternal.ts +++ b/src/commands/api/DatabaseAccountTreeItemInternal.ts @@ -5,25 +5,17 @@ import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import { API } from '../../AzureDBExperiences'; -import { getCosmosKeyCredential } from '../../docdb/getCosmosClient'; -import { DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTreeItemBase'; import { ext } from '../../extensionVariables'; -import { ParsedMongoConnectionString } from '../../mongo/mongoConnectionStrings'; -import { MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; import { type ParsedConnectionString } from '../../ParsedConnectionString'; import { ParsedPostgresConnectionString } from '../../postgres/postgresConnectionStrings'; import { PostgresServerTreeItem } from '../../postgres/tree/PostgresServerTreeItem'; -import { nonNullProp } from '../../utils/nonNull'; import { type DatabaseAccountTreeItem } from '../../vscode-cosmosdb.api'; export class DatabaseAccountTreeItemInternal implements DatabaseAccountTreeItem { protected _parsedCS: ParsedConnectionString; - private _accountNode: MongoAccountTreeItem | DocDBAccountTreeItemBase | PostgresServerTreeItem | undefined; + private _accountNode: PostgresServerTreeItem | undefined; - constructor( - parsedCS: ParsedConnectionString, - accountNode?: MongoAccountTreeItem | DocDBAccountTreeItemBase | PostgresServerTreeItem, - ) { + constructor(parsedCS: ParsedConnectionString, accountNode?: PostgresServerTreeItem) { this._parsedCS = parsedCS; this._accountNode = accountNode; } @@ -41,17 +33,7 @@ export class DatabaseAccountTreeItemInternal implements DatabaseAccountTreeItem } public get azureData(): { accountName: string; accountId: string } | undefined { - if ( - this._accountNode instanceof MongoAccountTreeItem || - this._accountNode instanceof DocDBAccountTreeItemBase - ) { - if (this._accountNode?.databaseAccount) { - return { - accountName: nonNullProp(this._accountNode.databaseAccount, 'name'), - accountId: this._accountNode.fullId, - }; - } - } else if (this._accountNode instanceof PostgresServerTreeItem) { + if (this._accountNode instanceof PostgresServerTreeItem) { if (this._accountNode.azureName) { return { accountName: this._accountNode.azureName, @@ -63,19 +45,7 @@ export class DatabaseAccountTreeItemInternal implements DatabaseAccountTreeItem } public get docDBData(): { masterKey: string; documentEndpoint: string } | undefined { - if (this._accountNode instanceof DocDBAccountTreeItemBase) { - const keyCred = getCosmosKeyCredential(this._accountNode.root.credentials); - if (keyCred) { - return { - documentEndpoint: this._accountNode.root.endpoint, - masterKey: keyCred.key, - }; - } else { - return undefined; - } - } else { - return undefined; - } + return undefined; } public get postgresData(): { username: string | undefined; password: string | undefined } | undefined { @@ -98,24 +68,20 @@ export class DatabaseAccountTreeItemInternal implements DatabaseAccountTreeItem }); } - protected async getAccountNode( - context: IActionContext, - ): Promise { + protected async getAccountNode(context: IActionContext): Promise { // If this._accountNode is undefined, attach a new node based on connection string if (!this._accountNode) { let apiType: API; - if (this._parsedCS instanceof ParsedMongoConnectionString) { - apiType = API.MongoDB; - } else if (this._parsedCS instanceof ParsedPostgresConnectionString) { + if (this._parsedCS instanceof ParsedPostgresConnectionString) { apiType = API.PostgresSingle; + this._accountNode = await ext.attachedAccountsNode.attachConnectionString( + context, + this.connectionString, + apiType, + ); } else { - apiType = API.Core; + throw new Error('Unsupported connection string.'); } - this._accountNode = await ext.attachedAccountsNode.attachConnectionString( - context, - this.connectionString, - apiType, - ); } return this._accountNode; diff --git a/src/commands/api/DatabaseTreeItemInternal.ts b/src/commands/api/DatabaseTreeItemInternal.ts index d104bdf45..d8ff50c3c 100644 --- a/src/commands/api/DatabaseTreeItemInternal.ts +++ b/src/commands/api/DatabaseTreeItemInternal.ts @@ -8,11 +8,7 @@ import { type AzExtTreeItem, type IActionContext, } from '@microsoft/vscode-azext-utils'; -import { type DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTreeItemBase'; -import { type DocDBDatabaseTreeItemBase } from '../../docdb/tree/DocDBDatabaseTreeItemBase'; import { ext } from '../../extensionVariables'; -import { type MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; -import { type MongoDatabaseTreeItem } from '../../mongo/tree/MongoDatabaseTreeItem'; import { type ParsedConnectionString } from '../../ParsedConnectionString'; import { type PostgresDatabaseTreeItem } from '../../postgres/tree/PostgresDatabaseTreeItem'; import { type PostgresServerTreeItem } from '../../postgres/tree/PostgresServerTreeItem'; @@ -26,8 +22,8 @@ export class DatabaseTreeItemInternal extends DatabaseAccountTreeItemInternal im constructor( parsedCS: ParsedConnectionString, databaseName: string, - accountNode?: MongoAccountTreeItem | DocDBAccountTreeItemBase | PostgresServerTreeItem, - dbNode?: MongoDatabaseTreeItem | DocDBDatabaseTreeItemBase | PostgresDatabaseTreeItem, + accountNode?: PostgresServerTreeItem, + dbNode?: PostgresDatabaseTreeItem, ) { super(parsedCS, accountNode); this.databaseName = databaseName; @@ -39,8 +35,7 @@ export class DatabaseTreeItemInternal extends DatabaseAccountTreeItemInternal im context.errorHandling.suppressDisplay = true; context.errorHandling.rethrow = true; - const accountNode: MongoAccountTreeItem | DocDBAccountTreeItemBase | PostgresServerTreeItem = - await this.getAccountNode(context); + const accountNode: PostgresServerTreeItem = await this.getAccountNode(context); if (!this._dbNode) { const databaseId = `${accountNode.fullId}/${this.databaseName}`; this._dbNode = await ext.rgApi.workspaceResourceTree.findTreeItem(databaseId, context); diff --git a/src/commands/api/findTreeItem.ts b/src/commands/api/findTreeItem.ts index ec2624d15..ea0b55dac 100644 --- a/src/commands/api/findTreeItem.ts +++ b/src/commands/api/findTreeItem.ts @@ -9,12 +9,8 @@ import { type IActionContext, } from '@microsoft/vscode-azext-utils'; import { parseDocDBConnectionString } from '../../docdb/docDBConnectionStrings'; -import { DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTreeItemBase'; -import { DocDBDatabaseTreeItemBase } from '../../docdb/tree/DocDBDatabaseTreeItemBase'; import { ext } from '../../extensionVariables'; import { parseMongoConnectionString } from '../../mongo/mongoConnectionStrings'; -import { MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; -import { MongoDatabaseTreeItem } from '../../mongo/tree/MongoDatabaseTreeItem'; import { type ParsedConnectionString } from '../../ParsedConnectionString'; import { createPostgresConnectionString, @@ -22,13 +18,16 @@ import { } from '../../postgres/postgresConnectionStrings'; import { PostgresDatabaseTreeItem } from '../../postgres/tree/PostgresDatabaseTreeItem'; import { PostgresServerTreeItem } from '../../postgres/tree/PostgresServerTreeItem'; -import { SubscriptionTreeItem } from '../../tree/SubscriptionTreeItem'; import { nonNullProp } from '../../utils/nonNull'; import { type DatabaseAccountTreeItem, type DatabaseTreeItem, type TreeItemQuery } from '../../vscode-cosmosdb.api'; import { cacheTreeItem, tryGetTreeItemFromCache } from './apiCache'; import { DatabaseAccountTreeItemInternal } from './DatabaseAccountTreeItemInternal'; import { DatabaseTreeItemInternal } from './DatabaseTreeItemInternal'; +/** + * TODO: This needs a rewrite to match v2 + */ + export async function findTreeItem( query: TreeItemQuery, ): Promise { @@ -70,22 +69,22 @@ export async function findTreeItem( } // 3. Search subscriptions - if (!result) { - const rootNodes = await ext.rgApi.appResourceTree.getChildren(); - for (const rootNode of rootNodes) { - if (Date.now() > maxTime) { - break; - } - - if (rootNode instanceof SubscriptionTreeItem) { - const dbAccounts = await rootNode.getCachedChildren(context); - result = await searchDbAccounts(dbAccounts, parsedCS, context, maxTime); - if (result) { - break; - } - } - } - } + // if (!result) { + // const rootNodes = await ext.rgApi.appResourceTree.getChildren(); + // for (const rootNode of rootNodes) { + // if (Date.now() > maxTime) { + // break; + // } + // + // if (rootNode instanceof SubscriptionTreeItem) { + // const dbAccounts = await rootNode.getCachedChildren(context); + // result = await searchDbAccounts(dbAccounts, parsedCS, context, maxTime); + // if (result) { + // break; + // } + // } + // } + // } // 4. If all else fails, just attach a new node if (!result) { @@ -115,11 +114,7 @@ async function searchDbAccounts( } let actual: ParsedConnectionString; - if (dbAccount instanceof MongoAccountTreeItem) { - actual = await parseMongoConnectionString(dbAccount.connectionString); - } else if (dbAccount instanceof DocDBAccountTreeItemBase) { - actual = parseDocDBConnectionString(dbAccount.connectionString); - } else if (dbAccount instanceof PostgresServerTreeItem) { + if (dbAccount instanceof PostgresServerTreeItem) { actual = dbAccount.partialConnectionString; } else { return undefined; @@ -129,12 +124,6 @@ async function searchDbAccounts( if (expected.databaseName) { const dbs = await dbAccount.getCachedChildren(context); for (const db of dbs) { - if ( - (db instanceof MongoDatabaseTreeItem || db instanceof DocDBDatabaseTreeItemBase) && - expected.databaseName === db.databaseName - ) { - return new DatabaseTreeItemInternal(expected, expected.databaseName, dbAccount, db); - } if ( db instanceof PostgresDatabaseTreeItem && dbAccount instanceof PostgresServerTreeItem && diff --git a/src/commands/api/pickTreeItem.ts b/src/commands/api/pickTreeItem.ts index 3abf8d7b4..e6c498fe5 100644 --- a/src/commands/api/pickTreeItem.ts +++ b/src/commands/api/pickTreeItem.ts @@ -6,15 +6,7 @@ import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import { type PickAppResourceOptions } from '@microsoft/vscode-azext-utils/hostapi'; import { databaseAccountType } from '../../constants'; -import { parseDocDBConnectionString } from '../../docdb/docDBConnectionStrings'; -import { DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTreeItemBase'; -import { DocDBDatabaseTreeItem } from '../../docdb/tree/DocDBDatabaseTreeItem'; -import { DocDBDatabaseTreeItemBase } from '../../docdb/tree/DocDBDatabaseTreeItemBase'; import { ext } from '../../extensionVariables'; -import { GraphDatabaseTreeItem } from '../../graph/tree/GraphDatabaseTreeItem'; -import { parseMongoConnectionString } from '../../mongo/mongoConnectionStrings'; -import { MongoAccountTreeItem } from '../../mongo/tree/MongoAccountTreeItem'; -import { MongoDatabaseTreeItem } from '../../mongo/tree/MongoDatabaseTreeItem'; import { type ParsedConnectionString } from '../../ParsedConnectionString'; import { PostgresDatabaseTreeItem } from '../../postgres/tree/PostgresDatabaseTreeItem'; import { PostgresServerTreeItem } from '../../postgres/tree/PostgresServerTreeItem'; @@ -29,20 +21,13 @@ import { cacheTreeItem } from './apiCache'; import { DatabaseAccountTreeItemInternal } from './DatabaseAccountTreeItemInternal'; import { DatabaseTreeItemInternal } from './DatabaseTreeItemInternal'; -const databaseContextValues = [ - MongoDatabaseTreeItem.contextValue, - DocDBDatabaseTreeItem.contextValue, - GraphDatabaseTreeItem.contextValue, - PostgresDatabaseTreeItem.contextValue, -]; +/** + * TODO: This needs a rewrite to match v2 + */ + +const databaseContextValues = [PostgresDatabaseTreeItem.contextValue]; function getDatabaseContextValue(apiType: AzureDatabasesApiType): string { switch (apiType) { - case 'Mongo': - return MongoDatabaseTreeItem.contextValue; - case 'SQL': - return DocDBDatabaseTreeItem.contextValue; - case 'Graph': - return GraphDatabaseTreeItem.contextValue; case 'Postgres': return PostgresDatabaseTreeItem.contextValue; default: @@ -75,25 +60,11 @@ export async function pickTreeItem( const pickedItem = await ext.rgApi.pickAppResource(context, options); let parsedCS: ParsedConnectionString; - let accountNode: MongoAccountTreeItem | DocDBAccountTreeItemBase | PostgresServerTreeItem; - let databaseNode: MongoDatabaseTreeItem | DocDBDatabaseTreeItemBase | PostgresDatabaseTreeItem | undefined; - if (pickedItem instanceof MongoAccountTreeItem) { - parsedCS = await parseMongoConnectionString(pickedItem.connectionString); - accountNode = pickedItem; - } else if (pickedItem instanceof DocDBAccountTreeItemBase) { - parsedCS = parseDocDBConnectionString(pickedItem.connectionString); - accountNode = pickedItem; - } else if (pickedItem instanceof PostgresServerTreeItem) { + let accountNode: PostgresServerTreeItem; + let databaseNode: PostgresDatabaseTreeItem | undefined; + if (pickedItem instanceof PostgresServerTreeItem) { parsedCS = await pickedItem.getFullConnectionString(); accountNode = pickedItem; - } else if (pickedItem instanceof MongoDatabaseTreeItem) { - parsedCS = await parseMongoConnectionString(pickedItem.connectionString); - accountNode = pickedItem.parent; - databaseNode = pickedItem; - } else if (pickedItem instanceof DocDBDatabaseTreeItemBase) { - parsedCS = parseDocDBConnectionString(pickedItem.connectionString); - accountNode = pickedItem.parent; - databaseNode = pickedItem; } else if (pickedItem instanceof PostgresDatabaseTreeItem) { parsedCS = await pickedItem.parent.getFullConnectionString(); accountNode = pickedItem.parent; diff --git a/src/commands/attachAccount/AttachAccountWizardContext.ts b/src/commands/attachAccount/AttachAccountWizardContext.ts new file mode 100644 index 000000000..95b5208eb --- /dev/null +++ b/src/commands/attachAccount/AttachAccountWizardContext.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import type ConnectionString from 'mongodb-connection-string-url'; +import { type Experience } from '../../AzureDBExperiences'; +import { type ParsedDocDBConnectionString } from '../../docdb/docDBConnectionStrings'; +import { type QuickPickType } from '../../utils/pickItem/pickExperience'; + +export interface AttachAccountWizardContext extends IActionContext { + quickPickType: QuickPickType; + parentId: string; + + experience?: Experience; + connectionString?: string; + parsedConnectionString?: URL | ConnectionString | ParsedDocDBConnectionString; + + username?: string; + password?: string; +} diff --git a/src/commands/attachAccount/DocumentDBConnectionStringStep.ts b/src/commands/attachAccount/DocumentDBConnectionStringStep.ts new file mode 100644 index 000000000..ee5f0824e --- /dev/null +++ b/src/commands/attachAccount/DocumentDBConnectionStringStep.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { parseDocDBConnectionString } from '../../docdb/docDBConnectionStrings'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; + +export class DocumentDBConnectionStringStep extends AzureWizardPromptStep { + public async prompt(context: AttachAccountWizardContext): Promise { + context.connectionString = ( + await context.ui.showInputBox({ + placeHolder: `AccountEndpoint=...;AccountKey=...`, + prompt: 'Enter the connection string for your database account', + validateInput: (connectionString?: string) => this.validateInput(connectionString), + asyncValidationTask: (connectionString: string) => this.validateConnectionString(connectionString), + }) + ).trim(); + + context.valuesToMask.push(context.connectionString); + } + + public shouldPrompt(context: AttachAccountWizardContext): boolean { + return !context.connectionString; + } + + public validateInput(connectionString: string | undefined): string | undefined { + connectionString = connectionString ? connectionString.trim() : ''; + + if (connectionString.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + return undefined; + } + + //eslint-disable-next-line @typescript-eslint/require-await + private async validateConnectionString(connectionString: string): Promise { + try { + parseDocDBConnectionString(connectionString); + } catch (error) { + if (error instanceof Error) { + return error.message; + } else { + return 'Connection string must be of the form "AccountEndpoint=...;AccountKey=..."'; + } + } + + return undefined; + } +} diff --git a/src/commands/attachAccount/DocumentDBExecuteStep.ts b/src/commands/attachAccount/DocumentDBExecuteStep.ts new file mode 100644 index 000000000..9681f3ec9 --- /dev/null +++ b/src/commands/attachAccount/DocumentDBExecuteStep.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { API, getExperienceFromApi } from '../../AzureDBExperiences'; +import { parseDocDBConnectionString } from '../../docdb/docDBConnectionStrings'; +import { ext } from '../../extensionVariables'; +import { WorkspaceResourceType } from '../../tree/workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage, type SharedWorkspaceStorageItem } from '../../tree/workspace/SharedWorkspaceStorage'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; + +export class DocumentDBExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: AttachAccountWizardContext): Promise { + const api = context.experience?.api ?? API.Common; + const connectionString = context.connectionString!; + const parentId = context.parentId; + + if (api === API.Core || api === API.Table || api === API.Graph || api === API.Cassandra) { + const parsedCS = parseDocDBConnectionString(connectionString); + const label = `${parsedCS.accountId} (${getExperienceFromApi(api).shortName})`; + + return ext.state.showCreatingChild(parentId, `Creating "${label}"...`, async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + + const storageItem: SharedWorkspaceStorageItem = { + id: parsedCS.accountId, + name: label, + properties: { isEmulator: false, api }, + secrets: [connectionString], + }; + + await SharedWorkspaceStorage.push(WorkspaceResourceType.AttachedAccounts, storageItem, true); + }); + } + } + + public shouldExecute(context: AttachAccountWizardContext): boolean { + return !!context.connectionString; + } +} diff --git a/src/commands/attachAccount/ExperienceStep.ts b/src/commands/attachAccount/ExperienceStep.ts new file mode 100644 index 000000000..c02f79ba6 --- /dev/null +++ b/src/commands/attachAccount/ExperienceStep.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzureWizardExecuteStep, AzureWizardPromptStep, type IWizardOptions } from '@microsoft/vscode-azext-utils'; +import { API } from '../../AzureDBExperiences'; +import { pickExperience } from '../../utils/pickItem/pickExperience'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; +import { DocumentDBConnectionStringStep } from './DocumentDBConnectionStringStep'; +import { DocumentDBExecuteStep } from './DocumentDBExecuteStep'; +import { MongoConnectionStringStep } from './MongoConnectionStringStep'; +import { MongoExecuteStep } from './MongoExecuteStep'; +import { MongoPasswordStep } from './MongoPasswordStep'; +import { MongoUsernameStep } from './MongoUsernameStep'; +import { PostgresConnectionStringStep } from './PostgresConnectionStringStep'; +import { PostgresExecuteStep } from './PostgresExecuteStep'; +import { PostgresPasswordStep } from './PostgresPasswordStep'; +import { PostgresUsernameStep } from './PostgresUsernameStep'; + +export class ExperienceStep extends AzureWizardPromptStep { + public async prompt(context: AttachAccountWizardContext): Promise { + context.experience = await pickExperience(context, context.quickPickType); + } + + public async getSubWizard( + context: AttachAccountWizardContext, + ): Promise> { + const promptSteps: AzureWizardPromptStep[] = []; + const executeSteps: AzureWizardExecuteStep[] = []; + const api = context.experience?.api; + + if (api === API.PostgresSingle || api === API.PostgresFlexible) { + promptSteps.push( + new PostgresConnectionStringStep(), + new PostgresUsernameStep(), + new PostgresPasswordStep(), + ); + executeSteps.push(new PostgresExecuteStep()); + } else if (api === API.MongoDB || api === API.MongoClusters) { + promptSteps.push(new MongoConnectionStringStep(), new MongoUsernameStep(), new MongoPasswordStep()); + executeSteps.push(new MongoExecuteStep()); + } else if (api === API.Core || api === API.Table || api === API.Graph || api === API.Cassandra) { + promptSteps.push(new DocumentDBConnectionStringStep()); + executeSteps.push(new DocumentDBExecuteStep()); + } + return { promptSteps, executeSteps }; + } + + public shouldPrompt(context: AttachAccountWizardContext): boolean { + return !context.experience; + } +} diff --git a/src/mongoClusters/wizards/addWorkspaceConnection/ConnectionStringStep.ts b/src/commands/attachAccount/MongoConnectionStringStep.ts similarity index 74% rename from src/mongoClusters/wizards/addWorkspaceConnection/ConnectionStringStep.ts rename to src/commands/attachAccount/MongoConnectionStringStep.ts index 1fb5ab138..47f37e2e2 100644 --- a/src/mongoClusters/wizards/addWorkspaceConnection/ConnectionStringStep.ts +++ b/src/commands/attachAccount/MongoConnectionStringStep.ts @@ -5,13 +5,13 @@ import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; import ConnectionString from 'mongodb-connection-string-url'; -import { localize } from '../../../utils/localize'; -import { type AddWorkspaceConnectionContext } from './AddWorkspaceConnectionContext'; +import { localize } from '../../utils/localize'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; -export class ConnectionStringStep extends AzureWizardPromptStep { +export class MongoConnectionStringStep extends AzureWizardPromptStep { public hideStepCount: boolean = true; - public async prompt(context: AddWorkspaceConnectionContext): Promise { + public async prompt(context: AttachAccountWizardContext): Promise { const prompt: string = localize( 'mongoClusters.addWorkspaceConnection.connectionString.prompt', 'Enter the connection string of your MongoDB cluster.', @@ -24,15 +24,19 @@ export class ConnectionStringStep extends AzureWizardPromptStep this.validateInput(connectionString), asyncValidationTask: (connectionString: string) => this.validateConnectionString(connectionString), }) ).trim(); + const parsedConnectionString = new ConnectionString(context.connectionString); + context.username = parsedConnectionString.username; + context.password = parsedConnectionString.password; + context.valuesToMask.push(context.connectionString); } - // eslint-disable-next-line @typescript-eslint/require-await + //eslint-disable-next-line @typescript-eslint/require-await private async validateConnectionString(connectionString: string): Promise { try { new ConnectionString(connectionString); @@ -51,11 +55,11 @@ export class ConnectionStringStep extends AzureWizardPromptStep { + public priority: number = 100; + + public async execute(context: AttachAccountWizardContext): Promise { + const api = context.experience?.api ?? API.Common; + const connectionString = context.connectionString!; + const parentId = context.parentId; + + if (api === API.MongoDB || api === API.MongoClusters) { + const parsedCS = new ConnectionString(connectionString); + const label = parsedCS.username + '@' + parsedCS.hosts.join(','); + + return ext.state.showCreatingChild(parentId, `Creating "${label}"...`, async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + + const storageItem: SharedWorkspaceStorageItem = { + id: parsedCS.username + '@' + parsedCS.redact().toString(), + name: label, + properties: { isEmulator: false, api }, + secrets: [connectionString], + }; + + await SharedWorkspaceStorage.push(WorkspaceResourceType.MongoClusters, storageItem, true); + }); + } + } + + public shouldExecute(context: AttachAccountWizardContext): boolean { + return !!context.connectionString; + } +} diff --git a/src/commands/attachAccount/MongoPasswordStep.ts b/src/commands/attachAccount/MongoPasswordStep.ts new file mode 100644 index 000000000..3832a3f13 --- /dev/null +++ b/src/commands/attachAccount/MongoPasswordStep.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import ConnectionString from 'mongodb-connection-string-url'; +import { localize } from '../../utils/localize'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; + +export class MongoPasswordStep extends AzureWizardPromptStep { + public async prompt(context: AttachAccountWizardContext): Promise { + const prompt: string = `Enter the password for ${context.experience!.shortName}`; + + const password = await context.ui.showInputBox({ + prompt: prompt, + ignoreFocusOut: true, + password: true, + value: context.password, + validateInput: (password?: string) => this.validateInput(context, password), + }); + + const parsedConnectionString = new ConnectionString(context.connectionString!); + parsedConnectionString.password = password; + + context.connectionString = parsedConnectionString.toString(); + context.password = password; + + context.valuesToMask.push(password); + } + + public shouldPrompt(): boolean { + return true; + } + + public validateInput(context: AttachAccountWizardContext, password: string | undefined): string | undefined { + password = password ? password.trim() : ''; + + if (password.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + try { + const parsedConnectionString = new ConnectionString(context.connectionString!); + parsedConnectionString.password = password; + + const connectionString = parsedConnectionString.toString(); + + new ConnectionString(connectionString); + } catch (error) { + if (error instanceof Error && error.name === 'MongoParseError') { + return error.message; + } else { + return localize( + 'mongoClusters.addWorkspaceConnection.connectionString.invalid', + 'Invalid Connection String: {0}', + `${error}`, + ); + } + } + + return undefined; + } +} diff --git a/src/commands/attachAccount/MongoUsernameStep.ts b/src/commands/attachAccount/MongoUsernameStep.ts new file mode 100644 index 000000000..15a55aab7 --- /dev/null +++ b/src/commands/attachAccount/MongoUsernameStep.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import ConnectionString from 'mongodb-connection-string-url'; +import { localize } from '../../utils/localize'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; + +export class MongoUsernameStep extends AzureWizardPromptStep { + public async prompt(context: AttachAccountWizardContext): Promise { + const prompt: string = `Enter the username for ${context.experience!.shortName}`; + + const username = await context.ui.showInputBox({ + prompt: prompt, + ignoreFocusOut: true, + value: context.username, + validateInput: (username?: string) => this.validateInput(context, username), + }); + + const parsedConnectionString = new ConnectionString(context.connectionString!); + parsedConnectionString.username = username; + + context.connectionString = parsedConnectionString.toString(); + context.username = username; + + context.valuesToMask.push(username); + } + + public shouldPrompt(): boolean { + return true; + } + + public validateInput(context: AttachAccountWizardContext, username: string | undefined): string | undefined { + username = username ? username.trim() : ''; + + if (username.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + try { + const parsedConnectionString = new ConnectionString(context.connectionString!); + parsedConnectionString.username = username; + + const connectionString = parsedConnectionString.toString(); + + new ConnectionString(connectionString); + } catch (error) { + if (error instanceof Error && error.name === 'MongoParseError') { + return error.message; + } else { + return localize( + 'mongoClusters.addWorkspaceConnection.connectionString.invalid', + 'Invalid Connection String: {0}', + `${error}`, + ); + } + } + + return undefined; + } +} diff --git a/src/commands/attachAccount/PostgresConnectionStringStep.ts b/src/commands/attachAccount/PostgresConnectionStringStep.ts new file mode 100644 index 000000000..634f6895c --- /dev/null +++ b/src/commands/attachAccount/PostgresConnectionStringStep.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { parsePostgresConnectionString } from '../../postgres/postgresConnectionStrings'; +import { localize } from '../../utils/localize'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; + +export class PostgresConnectionStringStep extends AzureWizardPromptStep { + public async prompt(context: AttachAccountWizardContext): Promise { + context.connectionString = ( + await context.ui.showInputBox({ + placeHolder: localize( + 'attachedPostgresPlaceholder', + '"postgres://username:password@host" or "postgres://username:password@host/database"', + ), + prompt: 'Enter the connection string for your database account', + validateInput: (connectionString?: string) => this.validateInput(connectionString), + asyncValidationTask: (connectionString: string) => this.validateConnectionString(connectionString), + }) + ).trim(); + + const parsedConnectionString = parsePostgresConnectionString(context.connectionString); + context.username = parsedConnectionString.username; + context.password = parsedConnectionString.password; + + context.valuesToMask.push(context.connectionString); + } + + public shouldPrompt(context: AttachAccountWizardContext): boolean { + return !context.connectionString; + } + + public validateInput(connectionString: string | undefined): string | undefined { + connectionString = connectionString ? connectionString.trim() : ''; + + if (connectionString.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + if (!connectionString.startsWith(`postgres://`)) { + return localize('invalidPostgresConnectionString', 'Connection string must start with "postgres://"'); + } + + return undefined; + } + + //eslint-disable-next-line @typescript-eslint/require-await + private async validateConnectionString(connectionString: string): Promise { + if (connectionString.length === 0) { + return 'Connection string is required.'; + } + + try { + parsePostgresConnectionString(connectionString); + } catch (error) { + if (error instanceof Error) { + return error.message; + } else { + return localize('invalidPostgresConnectionString', 'Invalid connection string: {0}', `${error}`); + } + } + + return undefined; + } +} diff --git a/src/commands/attachAccount/PostgresExecuteStep.ts b/src/commands/attachAccount/PostgresExecuteStep.ts new file mode 100644 index 000000000..33f36188e --- /dev/null +++ b/src/commands/attachAccount/PostgresExecuteStep.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { API } from '../../AzureDBExperiences'; +import { ext } from '../../extensionVariables'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; + +export class PostgresExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: AttachAccountWizardContext): Promise { + const api = context.experience?.api ?? API.Common; + const connectionString = context.connectionString!; + + if (api === API.PostgresFlexible || api === API.PostgresSingle) { + await ext.attachedAccountsNode.attachConnectionString(context, connectionString, api); + } + } + + public shouldExecute(context: AttachAccountWizardContext): boolean { + return !!context.connectionString; + } +} diff --git a/src/commands/attachAccount/PostgresPasswordStep.ts b/src/commands/attachAccount/PostgresPasswordStep.ts new file mode 100644 index 000000000..f7ca7dc2e --- /dev/null +++ b/src/commands/attachAccount/PostgresPasswordStep.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { buildPostgresConnectionString, parsePostgresConnectionString } from '../../postgres/postgresConnectionStrings'; +import { localize } from '../../utils/localize'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; + +export class PostgresPasswordStep extends AzureWizardPromptStep { + public async prompt(context: AttachAccountWizardContext): Promise { + const prompt: string = `Enter the password for ${context.experience!.shortName}`; + + context.password = await context.ui.showInputBox({ + prompt: prompt, + ignoreFocusOut: true, + password: true, + validateInput: (password?: string) => this.validateInput(context, password), + }); + + const pCS = parsePostgresConnectionString(context.connectionString!); + context.connectionString = buildPostgresConnectionString( + pCS.hostName, + pCS.port, + context.username, + context.password, + pCS.databaseName, + ); + + context.valuesToMask.push(context.password); + } + + public shouldPrompt(): boolean { + return true; + } + + public validateInput(context: AttachAccountWizardContext, password: string | undefined): string | undefined { + password = password ? password.trim() : ''; + + if (password.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + try { + const pCS = parsePostgresConnectionString(context.connectionString!); + const connectionString = buildPostgresConnectionString( + pCS.hostName, + pCS.port, + pCS.username, + password, + pCS.databaseName, + ); + + parsePostgresConnectionString(connectionString); + } catch (error) { + if (error instanceof Error) { + return error.message; + } else { + return localize('invalidPostgresConnectionString', 'Invalid connection string: {0}', `${error}`); + } + } + + return undefined; + } +} diff --git a/src/commands/attachAccount/PostgresUsernameStep.ts b/src/commands/attachAccount/PostgresUsernameStep.ts new file mode 100644 index 000000000..5ac401bac --- /dev/null +++ b/src/commands/attachAccount/PostgresUsernameStep.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { buildPostgresConnectionString, parsePostgresConnectionString } from '../../postgres/postgresConnectionStrings'; +import { localize } from '../../utils/localize'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; + +export class PostgresUsernameStep extends AzureWizardPromptStep { + public async prompt(context: AttachAccountWizardContext): Promise { + const prompt: string = `Enter the username for ${context.experience!.shortName}`; + + context.username = await context.ui.showInputBox({ + prompt: prompt, + ignoreFocusOut: true, + value: context.username, + validateInput: (username?: string) => this.validateInput(context, username), + }); + + const pCS = parsePostgresConnectionString(context.connectionString!); + context.connectionString = buildPostgresConnectionString( + pCS.hostName, + pCS.port, + context.username, + context.password, + pCS.databaseName, + ); + + context.valuesToMask.push(context.username); + } + + public shouldPrompt(): boolean { + return true; + } + + public validateInput(context: AttachAccountWizardContext, username: string | undefined): string | undefined { + username = username ? username.trim() : ''; + + if (username.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + try { + const pCS = parsePostgresConnectionString(context.connectionString!); + const connectionString = buildPostgresConnectionString( + pCS.hostName, + pCS.port, + username, + pCS.password, + pCS.databaseName, + ); + + parsePostgresConnectionString(connectionString); + } catch (error) { + if (error instanceof Error) { + return error.message; + } else { + return localize('invalidPostgresConnectionString', 'Invalid connection string: {0}', `${error}`); + } + } + + return undefined; + } +} diff --git a/src/commands/attachAccount/attachAccount.ts b/src/commands/attachAccount/attachAccount.ts new file mode 100644 index 000000000..afa7895b4 --- /dev/null +++ b/src/commands/attachAccount/attachAccount.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzExtTreeItem, AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { ext } from '../../extensionVariables'; +import { MongoDBAttachAccountResourceItem } from '../../mongoClusters/tree/workspace/MongoDBAttachAccountResourceItem'; +import { CosmosDBAttachAccountResourceItem } from '../../tree/attached/CosmosDBAttachAccountResourceItem'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { localize } from '../../utils/localize'; +import { QuickPickType } from '../../utils/pickItem/pickExperience'; +import { type AttachAccountWizardContext } from './AttachAccountWizardContext'; +import { ExperienceStep } from './ExperienceStep'; + +export async function attachAccount( + context: IActionContext, + node?: AzExtTreeItem | CosmosDBAttachAccountResourceItem | MongoDBAttachAccountResourceItem, +): Promise { + let type: QuickPickType = QuickPickType.ALL; + let parentId: string = ''; + + if (node instanceof AzExtTreeItem) { + type = QuickPickType.Postgres; + parentId = node.parent?.id ?? ''; + } + + if (node instanceof CosmosDBAttachAccountResourceItem) { + type = QuickPickType.Cosmos; + parentId = node.parentId ?? ext.cosmosDBWorkspaceBranchDataResource.id; + } + + if (node instanceof MongoDBAttachAccountResourceItem) { + type = QuickPickType.Mongo; + parentId = node.parentId ?? ext.mongoClusterWorkspaceBranchDataResource.id; + } + + const wizardContext: AttachAccountWizardContext = { ...context, quickPickType: type, parentId }; + + const wizard = new AzureWizard(wizardContext, { + title: localize('attachAccountTitle', 'Attach Account'), + promptSteps: [new ExperienceStep()], + executeSteps: [], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + showConfirmationAsInSettings( + localize('showConfirmation.addedWorkspaceConnection', 'New connection has been added to your workspace.'), + ); +} diff --git a/src/mongoClusters/wizards/addWorkspaceConnection/AddWorkspaceConnectionContext.ts b/src/commands/attachEmulator/AttachEmulatorWizardContext.ts similarity index 68% rename from src/mongoClusters/wizards/addWorkspaceConnection/AddWorkspaceConnectionContext.ts rename to src/commands/attachEmulator/AttachEmulatorWizardContext.ts index fd899f3f2..3618e8237 100644 --- a/src/mongoClusters/wizards/addWorkspaceConnection/AddWorkspaceConnectionContext.ts +++ b/src/commands/attachEmulator/AttachEmulatorWizardContext.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type Experience } from '../../AzureDBExperiences'; -export interface AddWorkspaceConnectionContext extends IActionContext { - /** These values will be populated by the wizard. */ - connectionString?: string; - username?: string; - password?: string; +export interface AttachEmulatorWizardContext extends IActionContext { + parentTreeElementId: string; - aborted?: boolean; + experience?: Experience; + connectionString?: string; + port?: number; } diff --git a/src/commands/attachEmulator/ExecuteStep.ts b/src/commands/attachEmulator/ExecuteStep.ts new file mode 100644 index 000000000..70d35bd2a --- /dev/null +++ b/src/commands/attachEmulator/ExecuteStep.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { ext } from '../../extensionVariables'; +import { WorkspaceResourceType } from '../../tree/workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage, type SharedWorkspaceStorageItem } from '../../tree/workspace/SharedWorkspaceStorage'; +import { type AttachEmulatorWizardContext } from './AttachEmulatorWizardContext'; + +export class ExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: AttachEmulatorWizardContext): Promise { + const parentId = context.parentTreeElementId; + const connectionString = context.connectionString; + const port = context.port; + const experience = context.experience; + + if (connectionString === undefined || port === undefined || experience === undefined) { + throw new Error('Internal error: connectionString, port, and api must be defined.'); + } + + const label = `${experience.shortName} Emulator (${port})`; + + return ext.state.showCreatingChild(parentId, `Creating "${label}"...`, async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + + const storageItem: SharedWorkspaceStorageItem = { + id: connectionString, + name: label, + properties: { isEmulator: true, api: experience.api }, + secrets: [connectionString], + }; + + await SharedWorkspaceStorage.push(WorkspaceResourceType.AttachedAccounts, storageItem, true); + }); + } + + public shouldExecute(context: AttachEmulatorWizardContext): boolean { + return !!context.connectionString; + } +} diff --git a/src/commands/attachEmulator/PromptExperienceStep.ts b/src/commands/attachEmulator/PromptExperienceStep.ts new file mode 100644 index 000000000..bff3fe2bc --- /dev/null +++ b/src/commands/attachEmulator/PromptExperienceStep.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { API, getExperienceQuickPick } from '../../AzureDBExperiences'; +import { SettingsService } from '../../services/SettingsService'; +import { type AttachEmulatorWizardContext } from './AttachEmulatorWizardContext'; + +export class PromptExperienceStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: AttachEmulatorWizardContext): Promise { + const defaultExperiencePick = await context.ui.showQuickPick( + [getExperienceQuickPick(API.MongoDB), getExperienceQuickPick(API.Core)], + { + placeHolder: 'Select a Database Account API...', + stepName: 'attachEmulator', + }, + ); + const experience = defaultExperiencePick.data; + const settingName = experience.api === API.MongoDB ? 'cosmosDB.emulator.mongoPort' : 'cosmosDB.emulator.port'; + const port = + SettingsService.getWorkspaceSetting(settingName) ?? + SettingsService.getGlobalSetting(settingName); + + context.telemetry.properties.experience = experience.api; + context.experience = experience; + context.port = port; + } + + public shouldPrompt(context: AttachEmulatorWizardContext): boolean { + return !context.experience; + } +} diff --git a/src/commands/attachEmulator/PromptPortStep.ts b/src/commands/attachEmulator/PromptPortStep.ts new file mode 100644 index 000000000..f0adf7cd7 --- /dev/null +++ b/src/commands/attachEmulator/PromptPortStep.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, parseError } from '@microsoft/vscode-azext-utils'; +import { API } from '../../AzureDBExperiences'; +import { emulatorPassword } from '../../constants'; +import { ext } from '../../extensionVariables'; +import { WorkspaceResourceType } from '../../tree/workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage } from '../../tree/workspace/SharedWorkspaceStorage'; +import { type AttachEmulatorWizardContext } from './AttachEmulatorWizardContext'; + +export class PromptPortStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: AttachEmulatorWizardContext): Promise { + const port = await context.ui.showInputBox({ + value: context.port ? context.port.toString() : '', + prompt: 'Enter the port number for the Azure Cosmos DB Emulator', + validateInput: (port: string) => this.validateInput(port), + asyncValidationTask: (port: string) => this.validateNameAvailable(context, port), + }); + + if (port && context.experience) { + context.port = Number(port); + context.connectionString = this.buildConnectionString(Number(port), context.experience.api); + } + } + + public shouldPrompt(_context: AttachEmulatorWizardContext): boolean { + return true; + } + + public validateInput(port: string | undefined): string | undefined { + port = port ? port.trim() : ''; + + try { + const portNumber = parseInt(port, 10); + + if (portNumber <= 0 || portNumber > 65535) { + return 'Port number must be between 1 and 65535'; + } + } catch { + return 'Input must be a number'; + } + + return undefined; + } + + private async validateNameAvailable( + context: AttachEmulatorWizardContext, + port: string, + ): Promise { + if (port.length === 0) { + return 'Port is required.'; + } + + if (context.experience === undefined) { + return 'API is required.'; + } + + try { + const items = await SharedWorkspaceStorage.getItems(WorkspaceResourceType.AttachedAccounts); + const api = context.experience.api; + const connectionString = this.buildConnectionString(Number(port), api); + + if ( + items.some((item) => { + const { properties, secrets } = item; + const itemApi: API = (properties?.api as API) ?? API.Common; + const isEmulator: boolean = !!properties?.isEmulator; + const itemConnectionString: string = secrets?.[0] ?? ''; + + return isEmulator && itemApi === api && itemConnectionString === connectionString; + }) + ) { + // Need to set the port to undefined so that the user is prompted again + context.port = undefined; + return `The port "${port}" is already in use by another emulator.`; + } + } catch (error) { + ext.outputChannel.appendLine(`Failed to validate port: ${parseError(error).message}`); + return undefined; // we don't want to block the user from continuing if we can't validate the name + } + + return undefined; + } + + private buildConnectionString(port: string | number, experience: API): string { + return experience === API.MongoDB + ? `mongodb://localhost:${encodeURIComponent(emulatorPassword)}@localhost:${port}/?ssl=true` + : `AccountEndpoint=https://localhost:${port}/;AccountKey=${emulatorPassword};`; + } +} diff --git a/src/commands/attachEmulator/attachEmulator.ts b/src/commands/attachEmulator/attachEmulator.ts new file mode 100644 index 000000000..23749a204 --- /dev/null +++ b/src/commands/attachEmulator/attachEmulator.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { isEmulatorSupported } from '../../constants'; +import { type CosmosDBAttachEmulatorResourceItem } from '../../tree/attached/CosmosDBAttachEmulatorResourceItem'; +import { localize } from '../../utils/localize'; +import { type AttachEmulatorWizardContext } from './AttachEmulatorWizardContext'; +import { ExecuteStep } from './ExecuteStep'; +import { PromptExperienceStep } from './PromptExperienceStep'; +import { PromptPortStep } from './PromptPortStep'; + +export async function attachEmulator(context: IActionContext, node: CosmosDBAttachEmulatorResourceItem) { + if (!isEmulatorSupported) { + context.errorHandling.suppressReportIssue = true; + throw new Error( + localize( + 'emulatorNotSupported', + 'The Cosmos DB emulator is only supported on Windows, Linux and MacOS (Intel).', + ), + ); + } + + const wizardContext: AttachEmulatorWizardContext = { ...context, parentTreeElementId: node.parentId }; + + const wizard = new AzureWizard(wizardContext, { + title: 'Attach Emulator', + promptSteps: [new PromptExperienceStep(), new PromptPortStep()], + executeSteps: [new ExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); +} diff --git a/src/commands/copyConnectionString/copyConnectionString.ts b/src/commands/copyConnectionString/copyConnectionString.ts new file mode 100644 index 000000000..e38f9f7e4 --- /dev/null +++ b/src/commands/copyConnectionString/copyConnectionString.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzExtTreeItem, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import * as vscode from 'vscode'; +import { postgresFlexibleFilter, postgresSingleFilter } from '../../constants'; +import { ext } from '../../extensionVariables'; +import { MongoClusterItemBase } from '../../mongoClusters/tree/MongoClusterItemBase'; +import { checkAuthentication } from '../../postgres/commands/checkAuthentication'; +import { addDatabaseToConnectionString, buildPostgresConnectionString } from '../../postgres/postgresConnectionStrings'; +import { PostgresDatabaseTreeItem } from '../../postgres/tree/PostgresDatabaseTreeItem'; +import { CosmosDBAccountResourceItemBase } from '../../tree/CosmosDBAccountResourceItemBase'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function copyPostgresConnectionString( + context: IActionContext, + node?: PostgresDatabaseTreeItem, +): Promise { + if (!node) { + node = await ext.rgApi.pickAppResource(context, { + filter: [postgresSingleFilter, postgresFlexibleFilter], + expectedChildContextValue: PostgresDatabaseTreeItem.contextValue, + }); + } + + if (!node) { + return; + } + + await copyConnectionString(context, node); +} + +export async function copyAzureConnectionString( + context: IActionContext, + node?: CosmosDBAccountResourceItemBase | MongoClusterItemBase, +) { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb, AzExtResourceType.MongoClusters], + }); + } + + if (!node) { + return undefined; + } + + await copyConnectionString(context, node); +} + +export async function copyConnectionString( + context: IActionContext, + node: AzExtTreeItem | CosmosDBAccountResourceItemBase | MongoClusterItemBase, // Mongo Cluster (vCore), in both, the resource and in the workspace area +): Promise { + let connectionString: string | undefined; + + if (node instanceof PostgresDatabaseTreeItem) { + await checkAuthentication(context, node); + const parsedConnectionString = await node.parent.getFullConnectionString(); + if (node.parent.azureName) { + const parsedCS = await node.parent.getFullConnectionString(); + connectionString = buildPostgresConnectionString( + parsedCS.hostName, + parsedCS.port, + parsedCS.username, + parsedCS.password, + node.databaseName, + ); + } else { + connectionString = addDatabaseToConnectionString( + parsedConnectionString.connectionString, + node.databaseName, + ); + } + } else if (node instanceof CosmosDBAccountResourceItemBase || node instanceof MongoClusterItemBase) { + connectionString = await ext.state.runWithTemporaryDescription( + node.id, + localize('copyConnectionString.working', 'Working...'), + async () => { + if (node instanceof CosmosDBAccountResourceItemBase) { + context.telemetry.properties.experience = node.experience.api; + return await node.getConnectionString(); + } + + if (node instanceof MongoClusterItemBase) { + context.telemetry.properties.experience = node.mongoCluster.dbExperience?.api; + return node.getConnectionString(); + } + + return undefined; + }, + ); + } + + if (!connectionString) { + void vscode.window.showErrorMessage( + localize( + 'copyConnectionString.noConnectionString', + 'Failed to extract the connection string from the selected account.', + ), + ); + } else { + await vscode.env.clipboard.writeText(connectionString); + void vscode.window.showInformationMessage( + localize('copyConnectionString.success', 'The connection string has been copied to the clipboard'), + ); + } +} diff --git a/src/docdb/tree/DocDBUtils.ts b/src/commands/createContainer/CreateCollectionWizardContext.ts similarity index 57% rename from src/docdb/tree/DocDBUtils.ts rename to src/commands/createContainer/CreateCollectionWizardContext.ts index 3d45cf950..c06318f23 100644 --- a/src/docdb/tree/DocDBUtils.ts +++ b/src/commands/createContainer/CreateCollectionWizardContext.ts @@ -3,10 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/** - * Sanitize an the id of a DocDB tree item so it can be safely used in a query string. - * Learn more at: https://github.com/ljharb/qs#rfc-3986-and-rfc-1738-space-encoding - */ -export function sanitizeId(id: string): string { - return id.replace(/\+/g, ' '); +import { type IActionContext } from '@microsoft/vscode-azext-utils'; + +export interface CreateCollectionWizardContext extends IActionContext { + credentialsId: string; + databaseId: string; + nodeId: string; + + newCollectionName?: string; } diff --git a/src/docdb/commands/createDocDBCollection.ts b/src/commands/createContainer/CreateContainerWizardContext.ts similarity index 50% rename from src/docdb/commands/createDocDBCollection.ts rename to src/commands/createContainer/CreateContainerWizardContext.ts index ce3a0dd10..62faf2641 100644 --- a/src/docdb/commands/createDocDBCollection.ts +++ b/src/commands/createContainer/CreateContainerWizardContext.ts @@ -3,13 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type PartitionKeyDefinition } from '@azure/cosmos'; import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { DocDBDatabaseTreeItem } from '../tree/DocDBDatabaseTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; +import { type AccountInfo } from '../../tree/docdb/AccountInfo'; -export async function createDocDBCollection(context: IActionContext, node?: DocDBDatabaseTreeItem): Promise { - if (!node) { - node = await pickDocDBAccount(context, DocDBDatabaseTreeItem.contextValue); - } - await node.createChild(context); +export interface CreateContainerWizardContext extends IActionContext { + containerName?: string; + partitionKey?: PartitionKeyDefinition; + throughput?: number; + + accountInfo: AccountInfo; + databaseId: string; + nodeId: string; + containerTypeName: string; } diff --git a/src/commands/createContainer/DocumentDBContainerNameStep.ts b/src/commands/createContainer/DocumentDBContainerNameStep.ts new file mode 100644 index 000000000..75d9b0dd1 --- /dev/null +++ b/src/commands/createContainer/DocumentDBContainerNameStep.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, parseError } from '@microsoft/vscode-azext-utils'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type CreateContainerWizardContext } from './CreateContainerWizardContext'; + +export class DocumentDBContainerNameStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: CreateContainerWizardContext): Promise { + context.containerName = ( + await context.ui.showInputBox({ + prompt: `Enter a ${context.containerTypeName} name for ${context.databaseId}`, + validateInput: (name: string) => this.validateInput(name), + asyncValidationTask: (name: string) => this.validateNameAvailable(context, name), + }) + ).trim(); + + context.valuesToMask.push(context.containerName); + } + + public shouldPrompt(context: CreateContainerWizardContext): boolean { + return !context.containerName; + } + + public validateInput(name: string | undefined): string | undefined { + name = name ? name.trim() : ''; + + if (name.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + if (/[/\\?#]/.test(name)) { + return `Container name cannot contain the characters '\\', '/', '#', '?'`; + } + + if (name.length > 255) { + return 'Container name cannot be longer than 255 characters'; + } + + return undefined; + } + + private async validateNameAvailable( + context: CreateContainerWizardContext, + name: string, + ): Promise { + if (name.length === 0) { + return 'Container name is required.'; + } + + try { + const { endpoint, credentials, isEmulator } = context.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + const result = await cosmosClient.database(context.databaseId).containers.readAll().fetchAll(); + + if (result.resources && result.resources.filter((c) => c.id === name).length > 0) { + return `The collection "${name}" already exists in the database "${context.databaseId}".`; + } + } catch (error) { + ext.outputChannel.appendLine(`Failed to validate container name: ${parseError(error).message}`); + return undefined; // we don't want to block the user from continuing if we can't validate the name + } + + return undefined; + } +} diff --git a/src/commands/createContainer/DocumentDBExecuteStep.ts b/src/commands/createContainer/DocumentDBExecuteStep.ts new file mode 100644 index 000000000..e92c3f6ae --- /dev/null +++ b/src/commands/createContainer/DocumentDBExecuteStep.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PartitionKeyDefinitionVersion, PartitionKeyKind, type RequestOptions } from '@azure/cosmos'; +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type CreateContainerWizardContext } from './CreateContainerWizardContext'; + +export class DocumentDBExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: CreateContainerWizardContext): Promise { + const options: RequestOptions = {}; + const { endpoint, credentials, isEmulator } = context.accountInfo; + const { containerName, partitionKey, throughput, databaseId, nodeId } = context; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + if (throughput !== 0) { + options.offerThroughput = throughput; + } + + return ext.state.showCreatingChild(nodeId, `Creating "${containerName}"...`, async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + + const partitionKeyDefinition = { + paths: partitionKey?.paths ?? [], + kind: + (partitionKey?.kind ?? (partitionKey?.paths?.length ?? 0) > 1) + ? PartitionKeyKind.MultiHash // Multi-hash partition key if there are sub-partitions + : PartitionKeyKind.Hash, // Hash partition key if there is only one partition + version: PartitionKeyDefinitionVersion.V2, + }; + + const containerDefinition = { + id: containerName, + partitionKey: partitionKeyDefinition, + }; + + await cosmosClient.database(databaseId).containers.create(containerDefinition, options); + }); + } + + public shouldExecute(context: CreateContainerWizardContext): boolean { + return !!context.containerName; + } +} diff --git a/src/commands/createContainer/DocumentDBPartitionKeyStep.ts b/src/commands/createContainer/DocumentDBPartitionKeyStep.ts new file mode 100644 index 000000000..1e0f51f61 --- /dev/null +++ b/src/commands/createContainer/DocumentDBPartitionKeyStep.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { type CreateContainerWizardContext } from './CreateContainerWizardContext'; + +export enum HierarchyStep { + First = 1, + Second = 2, + Third = 3, +} + +export class DocumentDBPartitionKeyStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + constructor(public readonly hierarchyStep: HierarchyStep) { + super(); + } + + public async prompt(context: CreateContainerWizardContext): Promise { + const placeHolder = + this.hierarchyStep === HierarchyStep.First + ? 'first partition key e.g., /TenantId' + : this.hierarchyStep === HierarchyStep.Second + ? 'second partition key e.g., /UserId' + : this.hierarchyStep === HierarchyStep.Third + ? 'third partition key e.g., /address/zipCode' + : 'partition key'; + const prompt = + `Enter the partition key for the container` + + (this.hierarchyStep === HierarchyStep.First ? '' : ` (leave blank to skip)`); + + let partitionKey = ( + await context.ui.showInputBox({ + prompt, + placeHolder, + value: '', + validateInput: (name: string) => this.validateInput(name), + asyncValidationTask: (name: string) => this.validateNameAvailable(context, name), + }) + ).trim(); + + if (partitionKey.length === 0) { + return; + } + + if (partitionKey && partitionKey.length && partitionKey[0] !== '/') { + partitionKey = '/' + partitionKey; + } + + context.valuesToMask.push(partitionKey, partitionKey.slice(1)); + + context.partitionKey ??= { paths: [] }; + context.partitionKey.paths.push(partitionKey); + } + + public shouldPrompt(context: CreateContainerWizardContext): boolean { + if (this.hierarchyStep === HierarchyStep.First || this.hierarchyStep === HierarchyStep.Second) { + return true; + } + + if (this.hierarchyStep === HierarchyStep.Third) { + return (context.partitionKey?.paths?.length ?? 0) >= 2; + } + + return false; + } + + public validateInput(partitionKey: string | undefined): string | undefined { + partitionKey = partitionKey ? partitionKey.trim() : ''; + + if (partitionKey.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + if (/[^a-zA-Z0-9_/]/.test(partitionKey)) { + return `Partition key cannot contain the wildcard characters`; + } + + if (!/^\/?[^/]*$/.test(partitionKey)) { + return 'Partition key can only start with a forward slash (/)'; + } + + if (partitionKey.length > 255) { + return 'Partition key cannot be longer than 255 characters'; + } + + return undefined; + } + + private async validateNameAvailable( + context: CreateContainerWizardContext, + partitionKey: string, + ): Promise { + if (this.hierarchyStep === HierarchyStep.First && partitionKey.length === 0) { + return 'Partition key is required.'; + } + + if (partitionKey && partitionKey.length && partitionKey[0] !== '/') { + partitionKey = '/' + partitionKey; + } + + if (context.partitionKey?.paths?.includes(partitionKey)) { + return 'Partition key must be unique.'; + } + + return undefined; + } +} diff --git a/src/commands/createContainer/DocumentDBThroughputStep.ts b/src/commands/createContainer/DocumentDBThroughputStep.ts new file mode 100644 index 000000000..7a285add1 --- /dev/null +++ b/src/commands/createContainer/DocumentDBThroughputStep.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { type CreateContainerWizardContext } from './CreateContainerWizardContext'; + +const minThroughput: number = 400; +const maxThroughput: number = 100000; +const throughputStepSize = 100; + +export class DocumentDBThroughputStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: CreateContainerWizardContext): Promise { + const prompt = `Initial throughput capacity, between ${ + minThroughput + } and ${maxThroughput} inclusive in increments of ${ + throughputStepSize + }. Enter 0 if the account doesn't support throughput.`; + + context.throughput = Number( + await context.ui.showInputBox({ + value: minThroughput.toString(), + prompt, + validateInput: (name: string) => this.validateInput(name), + }), + ); + + context.valuesToMask.push(context.throughput.toString()); + } + + public shouldPrompt(context: CreateContainerWizardContext): boolean { + return !context.accountInfo.isServerless; + } + + public validateInput(throughput: string | undefined): string | undefined { + throughput = throughput ? throughput.trim() : ''; + + if (throughput === '0') { + return undefined; + } + + try { + const value = Number(throughput); + if (value < minThroughput || value > maxThroughput || (value - minThroughput) % throughputStepSize !== 0) { + return `Value must be between ${minThroughput} and ${maxThroughput} in increments of ${throughputStepSize}`; + } + } catch { + return 'Input must be a number'; + } + return undefined; + } +} diff --git a/src/mongoClusters/wizards/create/PromptCollectionNameStep.ts b/src/commands/createContainer/MongoCollectionNameStep.ts similarity index 89% rename from src/mongoClusters/wizards/create/PromptCollectionNameStep.ts rename to src/commands/createContainer/MongoCollectionNameStep.ts index 75101a2f3..40bc4f729 100644 --- a/src/mongoClusters/wizards/create/PromptCollectionNameStep.ts +++ b/src/commands/createContainer/MongoCollectionNameStep.ts @@ -4,9 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../../utils/localize'; -import { MongoClustersClient } from '../../MongoClustersClient'; -import { type CreateCollectionWizardContext } from './createWizardContexts'; +import { MongoClustersClient } from '../../mongoClusters/MongoClustersClient'; +import { localize } from '../../utils/localize'; +import { type CreateCollectionWizardContext } from './CreateCollectionWizardContext'; export class CollectionNameStep extends AzureWizardPromptStep { public hideStepCount: boolean = true; @@ -16,7 +16,7 @@ export class CollectionNameStep extends AzureWizardPromptStep this.validateInput(name), asyncValidationTask: (name: string) => this.validateNameAvailable(context, name), }) ).trim(); @@ -28,7 +28,7 @@ export class CollectionNameStep extends AzureWizardPromptStep c.name === name).length > 0) { return localize( 'mongoClusters.collectionExists', 'The collection "{0}" already exists in the database "{1}".', name, - context.databaseItem.databaseInfo.name, + context.databaseId, ); } } catch (_error) { diff --git a/src/commands/createContainer/MongoExecuteStep.ts b/src/commands/createContainer/MongoExecuteStep.ts new file mode 100644 index 000000000..1b7486f70 --- /dev/null +++ b/src/commands/createContainer/MongoExecuteStep.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { ext } from '../../extensionVariables'; +import { MongoClustersClient } from '../../mongoClusters/MongoClustersClient'; +import { localize } from '../../utils/localize'; +import { type CreateCollectionWizardContext } from './CreateCollectionWizardContext'; + +export class MongoExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: CreateCollectionWizardContext): Promise { + const collectionName = context.newCollectionName!; + const databaseName = context.databaseId; + const client = await MongoClustersClient.getClient(context.credentialsId); + + return ext.state.showCreatingChild( + context.nodeId, + localize('mongoClusters.tree.creating', 'Creating "{0}"...', collectionName), + async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + await client.createCollection(databaseName, collectionName); + }, + ); + } + + public shouldExecute(context: CreateCollectionWizardContext): boolean { + return !!context.newCollectionName; + } +} diff --git a/src/commands/createContainer/createContainer.ts b/src/commands/createContainer/createContainer.ts new file mode 100644 index 000000000..63dfab85f --- /dev/null +++ b/src/commands/createContainer/createContainer.ts @@ -0,0 +1,120 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizard, nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { API } from '../../AzureDBExperiences'; +import { type DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; +import { type DocumentDBDatabaseResourceItem } from '../../tree/docdb/DocumentDBDatabaseResourceItem'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import { type CreateCollectionWizardContext } from './CreateCollectionWizardContext'; +import { type CreateContainerWizardContext } from './CreateContainerWizardContext'; +import { DocumentDBContainerNameStep } from './DocumentDBContainerNameStep'; +import { DocumentDBExecuteStep } from './DocumentDBExecuteStep'; +import { DocumentDBPartitionKeyStep, HierarchyStep } from './DocumentDBPartitionKeyStep'; +import { DocumentDBThroughputStep } from './DocumentDBThroughputStep'; +import { CollectionNameStep } from './MongoCollectionNameStep'; +import { MongoExecuteStep } from './MongoExecuteStep'; + +export async function createGraph(context: IActionContext, node?: DocumentDBDatabaseResourceItem): Promise { + if (!node) { + node = await pickAppResource(context, { + type: AzExtResourceType.AzureCosmosDb, + expectedChildContextValue: ['treeItem.database'], + unexpectedContextValue: [/experience[.](table|cassandra|core)/i], + }); + } + + if (!node) { + return undefined; + } + + return createDocumentDBContainer(context, node); +} + +export async function createDocumentDBContainer( + context: IActionContext, + node?: DocumentDBDatabaseResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: AzExtResourceType.AzureCosmosDb, + expectedChildContextValue: 'treeItem.database', + }); + } + + if (!node) { + return undefined; + } + + context.telemetry.properties.experience = node.experience.api; + + const isCore = node.experience.api === API.Core; + const containerTypeName = isCore ? 'container' : 'graph'; + + const wizardContext: CreateContainerWizardContext = { + ...context, + accountInfo: node.model.accountInfo, + databaseId: node.model.database.id, + nodeId: node.id, + containerTypeName, + }; + + const wizard = new AzureWizard(wizardContext, { + title: `Create ${containerTypeName}`, + promptSteps: [ + new DocumentDBContainerNameStep(), + new DocumentDBPartitionKeyStep(HierarchyStep.First), + isCore ? new DocumentDBPartitionKeyStep(HierarchyStep.Second) : undefined, + isCore ? new DocumentDBPartitionKeyStep(HierarchyStep.Third) : undefined, + new DocumentDBThroughputStep(), + ].filter((s) => !!s), + executeSteps: [new DocumentDBExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + const newContainerName = nonNullValue(wizardContext.containerName); + showConfirmationAsInSettings(`The "${newContainerName}" container has been created.`); +} + +export async function createMongoCollection(context: IActionContext, node?: DatabaseItem): Promise { + if (!node) { + node = await pickAppResource(context, { + type: AzExtResourceType.MongoClusters, + expectedChildContextValue: 'treeItem.database', + }); + } + + if (!node) { + return undefined; + } + + context.telemetry.properties.experience = node.experience.api; + + const wizardContext: CreateCollectionWizardContext = { + ...context, + credentialsId: node.mongoCluster.id, + databaseId: node.databaseInfo.name, + nodeId: node.id, + }; + + const wizard: AzureWizard = new AzureWizard(wizardContext, { + title: localize('mongoClusters.createCollection.title', 'Create collection'), + promptSteps: [new CollectionNameStep()], + executeSteps: [new MongoExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + const newCollectionName = nonNullValue(wizardContext.newCollectionName); + showConfirmationAsInSettings(`The "${newCollectionName}" collection has been created.`); +} diff --git a/src/docdb/tree/IDocDBTreeRoot.ts b/src/commands/createDatabase/CreateDatabaseWizardContext.ts similarity index 55% rename from src/docdb/tree/IDocDBTreeRoot.ts rename to src/commands/createDatabase/CreateDatabaseWizardContext.ts index b6e6e1ab0..a01f7532d 100644 --- a/src/docdb/tree/IDocDBTreeRoot.ts +++ b/src/commands/createDatabase/CreateDatabaseWizardContext.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type CosmosClient } from '@azure/cosmos'; -import { type CosmosDBCredential } from '../getCosmosClient'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AccountInfo } from '../../tree/docdb/AccountInfo'; -export interface IDocDBTreeRoot { - endpoint: string; - credentials: CosmosDBCredential[]; - isEmulator: boolean | undefined; - getCosmosClient(): CosmosClient; +export interface CreateDatabaseWizardContext extends IActionContext { + accountInfo: AccountInfo; + nodeId: string; + + databaseName?: string; } diff --git a/src/commands/createDatabase/CreateMongoDatabaseWizardContext.ts b/src/commands/createDatabase/CreateMongoDatabaseWizardContext.ts new file mode 100644 index 000000000..0f0bab911 --- /dev/null +++ b/src/commands/createDatabase/CreateMongoDatabaseWizardContext.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; + +export interface CreateMongoDatabaseWizardContext extends IActionContext { + credentialsId: string; + clusterName: string; + nodeId: string; + + databaseName?: string; +} diff --git a/src/commands/createDatabase/DocumentDBDatabaseNameStep.ts b/src/commands/createDatabase/DocumentDBDatabaseNameStep.ts new file mode 100644 index 000000000..f2535addf --- /dev/null +++ b/src/commands/createDatabase/DocumentDBDatabaseNameStep.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, parseError } from '@microsoft/vscode-azext-utils'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type CreateDatabaseWizardContext } from './CreateDatabaseWizardContext'; + +export class DocumentDBDatabaseNameStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: CreateDatabaseWizardContext): Promise { + context.databaseName = ( + await context.ui.showInputBox({ + prompt: `Enter a database name`, + validateInput: (name: string) => this.validateInput(name), + asyncValidationTask: (name: string) => this.validateNameAvailable(context, name), + }) + ).trim(); + + context.valuesToMask.push(context.databaseName); + } + + public shouldPrompt(context: CreateDatabaseWizardContext): boolean { + return !context.databaseName; + } + + public validateInput(name: string | undefined): string | undefined { + name = name ? name.trim() : ''; + + if (name.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + if (/[/\\?#=]/.test(name)) { + return `Database name cannot contain the characters '\\', '/', '#', '?', '='`; + } + + if (name.length > 255) { + return 'Database name cannot be longer than 255 characters'; + } + + return undefined; + } + + private async validateNameAvailable( + context: CreateDatabaseWizardContext, + name: string, + ): Promise { + if (name.length === 0) { + return 'Database name is required.'; + } + + try { + const { endpoint, credentials, isEmulator } = context.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + const result = await cosmosClient.databases.readAll().fetchAll(); + + if (result.resources && result.resources.filter((c) => c.id === name).length > 0) { + return `The database "${name}" already exists in the account.`; + } + } catch (error) { + ext.outputChannel.appendLine(`Failed to validate database name: ${parseError(error).message}`); + return undefined; // we don't want to block the user from continuing if we can't validate the name + } + + return undefined; + } +} diff --git a/src/commands/createDatabase/DocumentDBExecuteStep.ts b/src/commands/createDatabase/DocumentDBExecuteStep.ts new file mode 100644 index 000000000..b7efeeeba --- /dev/null +++ b/src/commands/createDatabase/DocumentDBExecuteStep.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type CreateDatabaseWizardContext } from './CreateDatabaseWizardContext'; + +export class DocumentDBExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: CreateDatabaseWizardContext): Promise { + const { endpoint, credentials, isEmulator } = context.accountInfo; + const { databaseName, nodeId } = context; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + return ext.state.showCreatingChild(nodeId, `Creating "${databaseName}"...`, async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + await cosmosClient.databases.create({ id: databaseName }); + }); + } + + public shouldExecute(context: CreateDatabaseWizardContext): boolean { + return !!context.databaseName; + } +} diff --git a/src/mongoClusters/wizards/create/PromptDatabaseNameStep.ts b/src/commands/createDatabase/MongoDatabaseNameStep.ts similarity index 78% rename from src/mongoClusters/wizards/create/PromptDatabaseNameStep.ts rename to src/commands/createDatabase/MongoDatabaseNameStep.ts index 2b04523ac..b939df700 100644 --- a/src/mongoClusters/wizards/create/PromptDatabaseNameStep.ts +++ b/src/commands/createDatabase/MongoDatabaseNameStep.ts @@ -4,31 +4,31 @@ *--------------------------------------------------------------------------------------------*/ import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../../utils/localize'; -import { MongoClustersClient } from '../../MongoClustersClient'; -import { type CreateDatabaseWizardContext } from './createWizardContexts'; +import { MongoClustersClient } from '../../mongoClusters/MongoClustersClient'; +import { localize } from '../../utils/localize'; +import { type CreateMongoDatabaseWizardContext } from './CreateMongoDatabaseWizardContext'; -export class DatabaseNameStep extends AzureWizardPromptStep { +export class MongoDatabaseNameStep extends AzureWizardPromptStep { public hideStepCount: boolean = true; - public async prompt(context: CreateDatabaseWizardContext): Promise { + public async prompt(context: CreateMongoDatabaseWizardContext): Promise { const prompt: string = localize('mongoClusters.databaseNamePrompt', 'Enter a database name.'); - context.newDatabaseName = ( + context.databaseName = ( await context.ui.showInputBox({ prompt, - validateInput: DatabaseNameStep.validateInput, + validateInput: (name?: string) => this.validateInput(name), asyncValidationTask: (name: string) => this.validateNameAvailable(context, name), }) ).trim(); - context.valuesToMask.push(context.newDatabaseName); + context.valuesToMask.push(context.databaseName); } - public shouldPrompt(context: CreateDatabaseWizardContext): boolean { - return !context.newDatabaseName; + public shouldPrompt(context: CreateMongoDatabaseWizardContext): boolean { + return !context.databaseName; } - public static validateInput(this: void, databaseName: string | undefined): string | undefined { + public validateInput(databaseName: string | undefined): string | undefined { // https://www.mongodb.com/docs/manual/reference/limits/#naming-restrictions databaseName = databaseName ? databaseName.trim() : ''; @@ -62,7 +62,7 @@ export class DatabaseNameStep extends AzureWizardPromptStep { if (name.length === 0) { @@ -82,7 +82,7 @@ export class DatabaseNameStep extends AzureWizardPromptStep { + public priority: number = 100; + + public async execute(context: CreateMongoDatabaseWizardContext): Promise { + const credentialsId = context.credentialsId; + const databaseName = context.databaseName!; + const nodeId = context.nodeId; + const client = await MongoClustersClient.getClient(credentialsId); + + return ext.state.showCreatingChild( + nodeId, + localize('mongoClusters.tree.creating', 'Creating "{0}"...', databaseName), + async () => { + // Adding a delay to ensure the "creating child" animation is visible. + // The `showCreatingChild` function refreshes the parent to show the + // "creating child" animation and label. Refreshing the parent triggers its + // `getChildren` method. If the database creation completes too quickly, + // the dummy node with the animation might be shown alongside the actual + // database entry, as it will already be available in the database. + // Note to future maintainers: Do not remove this delay. + await new Promise((resolve) => setTimeout(resolve, 250)); + await client.createDatabase(databaseName); + }, + ); + } + + public shouldExecute(context: CreateMongoDatabaseWizardContext): boolean { + return !!context.databaseName; + } +} diff --git a/src/commands/createDatabase/createDatabase.ts b/src/commands/createDatabase/createDatabase.ts new file mode 100644 index 000000000..6225a4f27 --- /dev/null +++ b/src/commands/createDatabase/createDatabase.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizard, type IActionContext, nonNullValue } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { CredentialCache } from '../../mongoClusters/CredentialCache'; +import { MongoClusterItemBase } from '../../mongoClusters/tree/MongoClusterItemBase'; +import { type MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { type CosmosDBAccountResourceItemBase } from '../../tree/CosmosDBAccountResourceItemBase'; +import { getAccountInfo } from '../../tree/docdb/AccountInfo'; +import { DocumentDBAccountAttachedResourceItem } from '../../tree/docdb/DocumentDBAccountAttachedResourceItem'; +import { DocumentDBAccountResourceItem } from '../../tree/docdb/DocumentDBAccountResourceItem'; +import { MongoAccountResourceItem } from '../../tree/mongo/MongoAccountResourceItem'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import { type CreateDatabaseWizardContext } from './CreateDatabaseWizardContext'; +import { type CreateMongoDatabaseWizardContext } from './CreateMongoDatabaseWizardContext'; +import { DocumentDBDatabaseNameStep } from './DocumentDBDatabaseNameStep'; +import { DocumentDBExecuteStep } from './DocumentDBExecuteStep'; +import { MongoDatabaseNameStep } from './MongoDatabaseNameStep'; +import { MongoExecuteStep } from './MongoExecuteStep'; + +export async function createAzureDatabase( + context: IActionContext, + node?: CosmosDBAccountResourceItemBase | MongoClusterResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb, AzExtResourceType.MongoClusters], + }); + } + + if (!node) { + return undefined; + } + + return createDatabase(context, node); +} + +export async function createDatabase( + context: IActionContext, + node: CosmosDBAccountResourceItemBase | MongoClusterResourceItem, +): Promise { + if (node instanceof DocumentDBAccountResourceItem || node instanceof DocumentDBAccountAttachedResourceItem) { + await createDocDBDatabase(context, node); + } + + if (node instanceof MongoAccountResourceItem || node instanceof MongoClusterItemBase) { + await createMongoDatabase(context, node); + } +} + +async function createDocDBDatabase( + context: IActionContext, + node: DocumentDBAccountResourceItem | DocumentDBAccountAttachedResourceItem, +): Promise { + context.telemetry.properties.experience = node.experience.api; + + const wizardContext: CreateDatabaseWizardContext = { + ...context, + accountInfo: await getAccountInfo(node.account), + nodeId: node.id, + }; + + const wizard = new AzureWizard(wizardContext, { + title: 'Create database', + promptSteps: [new DocumentDBDatabaseNameStep()], + executeSteps: [new DocumentDBExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + const newDatabaseName = nonNullValue(wizardContext.databaseName); + showConfirmationAsInSettings( + localize('showConfirmation.createdDatabase', 'The "{0}" database has been created.', newDatabaseName), + ); +} + +async function createMongoDatabase( + context: IActionContext, + node: MongoAccountResourceItem | MongoClusterItemBase, +): Promise { + context.telemetry.properties.experience = node.experience.api; + + const credentialsId = node instanceof MongoAccountResourceItem ? node.id : node.mongoCluster.id; + const clusterName = node instanceof MongoAccountResourceItem ? node.account.name : node.mongoCluster.name; + + if (!CredentialCache.hasCredentials(credentialsId)) { + throw new Error( + localize( + 'mongoClusters.notSignedIn', + 'You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node "{0}") and try again.', + clusterName, + ), + ); + } + + const wizardContext: CreateMongoDatabaseWizardContext = { + ...context, + credentialsId, + clusterName, + nodeId: node.id, + }; + + const wizard = new AzureWizard(wizardContext, { + title: localize('mongoClusters.createDatabase.title', 'Create database'), + promptSteps: [new MongoDatabaseNameStep()], + executeSteps: [new MongoExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + const newDatabaseName = nonNullValue(wizardContext.databaseName); + showConfirmationAsInSettings( + localize('showConfirmation.createdDatabase', 'The "{0}" database has been created.', newDatabaseName), + ); +} diff --git a/src/commands/createDocument/createDocument.ts b/src/commands/createDocument/createDocument.ts new file mode 100644 index 000000000..b4ca4f44c --- /dev/null +++ b/src/commands/createDocument/createDocument.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import vscode, { ViewColumn } from 'vscode'; +import { createNoSqlQueryConnection } from '../../docdb/utils/NoSqlQueryConnection'; +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'; + +export async function createDocumentDBDocument( + context: IActionContext, + node?: DocumentDBContainerResourceItem | DocumentDBItemsResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.container'], + }); + } + + if (!node) { + return; + } + + DocumentTab.render(createNoSqlQueryConnection(node), 'add', undefined, ViewColumn.Active); +} + +export async function createMongoDocument(context: IActionContext, node?: CollectionItem): Promise { + context.telemetry.properties.experience = node?.mongoCluster.dbExperience.api; + + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.MongoClusters], + expectedChildContextValue: ['treeItem.collection'], + }); + } + + if (!node) { + return; + } + + await vscode.commands.executeCommand('command.internal.mongoClusters.documentView.open', { + clusterId: node.mongoCluster.id, + databaseName: node.databaseInfo.name, + collectionName: node.collectionInfo.name, + mode: 'add', + }); +} diff --git a/src/commands/createServer/createServer.ts b/src/commands/createServer/createServer.ts new file mode 100644 index 000000000..3dfde0a33 --- /dev/null +++ b/src/commands/createServer/createServer.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { API } from '../../AzureDBExperiences'; +import { openUrl } from '../../utils/openUrl'; +import { pickExperience, QuickPickType } from '../../utils/pickItem/pickExperience'; + +export async function createServer(context: IActionContext): Promise { + const experience = await pickExperience(context, QuickPickType.ALL); + const api = experience.api; + + context.telemetry.properties.experience = api; + + if (api === API.PostgresSingle) { + await openUrl(`https://portal.azure.com/#create/Microsoft.PostgreSQLServerGroup`); + } + + if (api === API.PostgresFlexible) { + await openUrl(`https://portal.azure.com/#create/Microsoft.PostgreSQLFlexibleServer`); + } + + if (experience.api === API.MongoClusters || experience.api === API.MongoDB) { + await openUrl(`https://portal.azure.com/#view/Microsoft_Azure_DocumentDB/MongoDB_Type_Selection.ReactView`); + } + + if (api === API.Core || api === API.Table || api === API.Graph || api === API.Cassandra) { + await openUrl(`https://portal.azure.com/#create/Microsoft.DocumentDB`); + } +} diff --git a/src/commands/createStoredProcedure/CreateStoredProcedureWizardContext.ts b/src/commands/createStoredProcedure/CreateStoredProcedureWizardContext.ts new file mode 100644 index 000000000..2b5bec1aa --- /dev/null +++ b/src/commands/createStoredProcedure/CreateStoredProcedureWizardContext.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Resource, type StoredProcedureDefinition } from '@azure/cosmos'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AccountInfo } from '../../tree/docdb/AccountInfo'; + +export interface CreateStoredProcedureWizardContext extends IActionContext { + accountInfo: AccountInfo; + databaseId: string; + containerId: string; + nodeId: string; + + storedProcedureName?: string; + storedProcedureBody?: string; + + response?: StoredProcedureDefinition & Resource; +} diff --git a/src/commands/createStoredProcedure/DocumentDBExecuteStep.ts b/src/commands/createStoredProcedure/DocumentDBExecuteStep.ts new file mode 100644 index 000000000..70fbd32a2 --- /dev/null +++ b/src/commands/createStoredProcedure/DocumentDBExecuteStep.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type StoredProcedureDefinition } from '@azure/cosmos'; +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type CreateStoredProcedureWizardContext } from './CreateStoredProcedureWizardContext'; + +export class DocumentDBExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: CreateStoredProcedureWizardContext): Promise { + const { endpoint, credentials, isEmulator } = context.accountInfo; + const { containerId, databaseId, storedProcedureBody, storedProcedureName, nodeId } = context; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + return ext.state.showCreatingChild(nodeId, `Creating "${storedProcedureName}"...`, async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + + const body: StoredProcedureDefinition = { + id: storedProcedureName, + body: storedProcedureBody!, + }; + + const response = await cosmosClient + .database(databaseId) + .container(containerId) + .scripts.storedProcedures.create(body); + + context.response = response.resource; + }); + } + + public shouldExecute(context: CreateStoredProcedureWizardContext): boolean { + return !!context.storedProcedureName && !!context.storedProcedureBody; + } +} diff --git a/src/commands/createStoredProcedure/DocumentDBStoredProcedureNameStep.ts b/src/commands/createStoredProcedure/DocumentDBStoredProcedureNameStep.ts new file mode 100644 index 000000000..022ebb174 --- /dev/null +++ b/src/commands/createStoredProcedure/DocumentDBStoredProcedureNameStep.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, parseError } from '@microsoft/vscode-azext-utils'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type CreateStoredProcedureWizardContext } from './CreateStoredProcedureWizardContext'; + +export class DocumentDBStoredProcedureNameStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: CreateStoredProcedureWizardContext): Promise { + context.storedProcedureName = ( + await context.ui.showInputBox({ + prompt: `Enter a stored procedure name for ${context.containerId}`, + validateInput: (name: string) => this.validateInput(name), + asyncValidationTask: (name: string) => this.validateNameAvailable(context, name), + }) + ).trim(); + + context.valuesToMask.push(context.storedProcedureName); + } + + public shouldPrompt(context: CreateStoredProcedureWizardContext): boolean { + return !context.storedProcedureName; + } + + public validateInput(name: string | undefined): string | undefined { + name = name ? name.trim() : ''; + + if (name.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + if (/[/\\?#&]/.test(name)) { + return `Container name cannot contain the characters '\\', '/', '#', '?', '&'`; + } + + if (name.length > 255) { + return 'Trigger name cannot be longer than 255 characters'; + } + + return undefined; + } + + private async validateNameAvailable( + context: CreateStoredProcedureWizardContext, + name: string, + ): Promise { + if (name.length === 0) { + return 'Stored procedure name is required.'; + } + + try { + const { endpoint, credentials, isEmulator } = context.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + const result = await cosmosClient + .database(context.databaseId) + .container(context.containerId) + .scripts.storedProcedures.readAll() + .fetchAll(); + + if (result.resources && result.resources.filter((t) => t.id === name).length > 0) { + return `The stored procedure "${name}" already exists in the container "${context.databaseId}".`; + } + } catch (error) { + ext.outputChannel.appendLine(`Failed to validate stored procedure name: ${parseError(error).message}`); + return undefined; // we don't want to block the user from continuing if we can't validate the name + } + + return undefined; + } +} diff --git a/src/commands/createStoredProcedure/createStoredProcedure.ts b/src/commands/createStoredProcedure/createStoredProcedure.ts new file mode 100644 index 000000000..697d6fd89 --- /dev/null +++ b/src/commands/createStoredProcedure/createStoredProcedure.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { defaultStoredProcedure } from '../../constants'; +import { StoredProcedureFileDescriptor } from '../../docdb/fs/StoredProcedureFileDescriptor'; +import { ext } from '../../extensionVariables'; +import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; +import { DocumentDBStoredProceduresResourceItem } from '../../tree/docdb/DocumentDBStoredProceduresResourceItem'; +import { type DocumentDBStoredProcedureModel } from '../../tree/docdb/models/DocumentDBStoredProcedureModel'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import { type CreateStoredProcedureWizardContext } from './CreateStoredProcedureWizardContext'; +import { DocumentDBExecuteStep } from './DocumentDBExecuteStep'; +import { DocumentDBStoredProcedureNameStep } from './DocumentDBStoredProcedureNameStep'; + +export async function createDocumentDBStoredProcedure( + context: IActionContext, + node?: DocumentDBContainerResourceItem | DocumentDBStoredProceduresResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: 'treeItem.container', + unexpectedContextValue: [/experience[.](table|cassandra|graph)/i], // Only Core supports triggers + }); + + if (!node) { + return; + } + } + + context.telemetry.properties.experience = node.experience.api; + + const nodeId = node instanceof DocumentDBStoredProceduresResourceItem ? node.id : `${node.id}/storedProcedures`; + const wizardContext: CreateStoredProcedureWizardContext = { + ...context, + accountInfo: node.model.accountInfo, + databaseId: node.model.database.id, + containerId: node.model.container.id, + storedProcedureBody: defaultStoredProcedure, + nodeId, + }; + + const wizard: AzureWizard = new AzureWizard(wizardContext, { + title: localize('cosmosDB.createTrigger.title', 'Create trigger'), + promptSteps: [new DocumentDBStoredProcedureNameStep()], + executeSteps: [new DocumentDBExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + if (wizardContext.response) { + const model: DocumentDBStoredProcedureModel = { ...node.model, procedure: wizardContext.response }; + const procedureId = model.procedure.id; + const fsNode = new StoredProcedureFileDescriptor(`${nodeId}/${procedureId}`, model, node.experience); + await ext.fileSystem.showTextDocument(fsNode); + } +} diff --git a/src/commands/createTrigger/CreateTriggerWizardContext.ts b/src/commands/createTrigger/CreateTriggerWizardContext.ts new file mode 100644 index 000000000..89d653c44 --- /dev/null +++ b/src/commands/createTrigger/CreateTriggerWizardContext.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Resource, type TriggerDefinition, type TriggerOperation, type TriggerType } from '@azure/cosmos'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type AccountInfo } from '../../tree/docdb/AccountInfo'; + +export interface CreateTriggerWizardContext extends IActionContext { + accountInfo: AccountInfo; + databaseId: string; + containerId: string; + nodeId: string; + + triggerName?: string; + triggerType?: TriggerType; + triggerOperation?: TriggerOperation; + triggerBody?: string; + + response?: TriggerDefinition & Resource; +} diff --git a/src/commands/createTrigger/DocumentDBExecuteStep.ts b/src/commands/createTrigger/DocumentDBExecuteStep.ts new file mode 100644 index 000000000..ee6fd5c28 --- /dev/null +++ b/src/commands/createTrigger/DocumentDBExecuteStep.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type TriggerDefinition } from '@azure/cosmos'; +import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type CreateTriggerWizardContext } from './CreateTriggerWizardContext'; + +export class DocumentDBExecuteStep extends AzureWizardExecuteStep { + public priority: number = 100; + + public async execute(context: CreateTriggerWizardContext): Promise { + const { endpoint, credentials, isEmulator } = context.accountInfo; + const { containerId, databaseId, triggerBody, triggerName, triggerOperation, triggerType, nodeId } = context; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + return ext.state.showCreatingChild(nodeId, `Creating "${triggerName}"...`, async () => { + await new Promise((resolve) => setTimeout(resolve, 250)); + + const body: TriggerDefinition = { + id: triggerName, + body: triggerBody!, + triggerType: triggerType!, + triggerOperation: triggerOperation!, + }; + + const response = await cosmosClient + .database(databaseId) + .container(containerId) + .scripts.triggers.create(body); + + context.response = response.resource; + }); + } + + public shouldExecute(context: CreateTriggerWizardContext): boolean { + return !!context.triggerName && !!context.triggerType && !!context.triggerOperation && !!context.triggerBody; + } +} diff --git a/src/commands/createTrigger/DocumentDBTriggerNameStep.ts b/src/commands/createTrigger/DocumentDBTriggerNameStep.ts new file mode 100644 index 000000000..9c667713c --- /dev/null +++ b/src/commands/createTrigger/DocumentDBTriggerNameStep.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep, parseError } from '@microsoft/vscode-azext-utils'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type CreateTriggerWizardContext } from './CreateTriggerWizardContext'; + +export class DocumentDBTriggerNameStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: CreateTriggerWizardContext): Promise { + context.triggerName = ( + await context.ui.showInputBox({ + prompt: `Enter a trigger name for ${context.containerId}`, + validateInput: (name: string) => this.validateInput(name), + asyncValidationTask: (name: string) => this.validateNameAvailable(context, name), + }) + ).trim(); + + context.valuesToMask.push(context.triggerName); + } + + public shouldPrompt(context: CreateTriggerWizardContext): boolean { + return !context.triggerName; + } + + public validateInput(name: string | undefined): string | undefined { + name = name ? name.trim() : ''; + + if (name.length === 0) { + // skip this for now, asyncValidationTask takes care of this case, otherwise it's only warnings the user sees.. + return undefined; + } + + if (/[/\\?#&]/.test(name)) { + return `Container name cannot contain the characters '\\', '/', '#', '?', '&'`; + } + + if (name.length > 255) { + return 'Trigger name cannot be longer than 255 characters'; + } + + return undefined; + } + + private async validateNameAvailable( + context: CreateTriggerWizardContext, + name: string, + ): Promise { + if (name.length === 0) { + return 'Trigger name is required.'; + } + + try { + const { endpoint, credentials, isEmulator } = context.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + const result = await cosmosClient + .database(context.databaseId) + .container(context.containerId) + .scripts.triggers.readAll() + .fetchAll(); + + if (result.resources && result.resources.filter((t) => t.id === name).length > 0) { + return `The trigger "${name}" already exists in the container "${context.databaseId}".`; + } + } catch (error) { + ext.outputChannel.appendLine(`Failed to validate trigger name: ${parseError(error).message}`); + return undefined; // we don't want to block the user from continuing if we can't validate the name + } + + return undefined; + } +} diff --git a/src/commands/createTrigger/DocumentDBTriggerOperationStep.ts b/src/commands/createTrigger/DocumentDBTriggerOperationStep.ts new file mode 100644 index 000000000..e2e56f551 --- /dev/null +++ b/src/commands/createTrigger/DocumentDBTriggerOperationStep.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; +import { getTriggerOperation } from '../../docdb/fs/TriggerFileDescriptor'; +import { type CreateTriggerWizardContext } from './CreateTriggerWizardContext'; + +export class DocumentDBTriggerOperationStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: CreateTriggerWizardContext): Promise { + context.triggerOperation = await getTriggerOperation(context); + } + + public shouldPrompt(context: CreateTriggerWizardContext): boolean { + return !!context.triggerName; + } +} diff --git a/src/commands/createTrigger/DocumentDBTriggerTypeStep.ts b/src/commands/createTrigger/DocumentDBTriggerTypeStep.ts new file mode 100644 index 000000000..6dbb55f21 --- /dev/null +++ b/src/commands/createTrigger/DocumentDBTriggerTypeStep.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; + +import { getTriggerType } from '../../docdb/fs/TriggerFileDescriptor'; +import { type CreateTriggerWizardContext } from './CreateTriggerWizardContext'; + +export class DocumentDBTriggerTypeStep extends AzureWizardPromptStep { + public hideStepCount: boolean = false; + + public async prompt(context: CreateTriggerWizardContext): Promise { + context.triggerType = await getTriggerType(context); + } + + public shouldPrompt(context: CreateTriggerWizardContext): boolean { + return !!context.triggerName; + } +} diff --git a/src/commands/createTrigger/createTrigger.ts b/src/commands/createTrigger/createTrigger.ts new file mode 100644 index 000000000..da56dae52 --- /dev/null +++ b/src/commands/createTrigger/createTrigger.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { defaultTrigger } from '../../constants'; +import { TriggerFileDescriptor } from '../../docdb/fs/TriggerFileDescriptor'; +import { ext } from '../../extensionVariables'; +import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; +import { DocumentDBTriggersResourceItem } from '../../tree/docdb/DocumentDBTriggersResourceItem'; +import { type DocumentDBTriggerModel } from '../../tree/docdb/models/DocumentDBTriggerModel'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import { type CreateTriggerWizardContext } from './CreateTriggerWizardContext'; +import { DocumentDBExecuteStep } from './DocumentDBExecuteStep'; +import { DocumentDBTriggerNameStep } from './DocumentDBTriggerNameStep'; +import { DocumentDBTriggerOperationStep } from './DocumentDBTriggerOperationStep'; +import { DocumentDBTriggerTypeStep } from './DocumentDBTriggerTypeStep'; + +export async function createDocumentDBTrigger( + context: IActionContext, + node?: DocumentDBContainerResourceItem | DocumentDBTriggersResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: 'treeItem.container', + unexpectedContextValue: [/experience[.](table|cassandra|graph)/i], // Only Core supports triggers + }); + + if (!node) { + return; + } + } + + context.telemetry.properties.experience = node.experience.api; + + const nodeId = node instanceof DocumentDBTriggersResourceItem ? node.id : `${node.id}/triggers`; + const wizardContext: CreateTriggerWizardContext = { + ...context, + accountInfo: node.model.accountInfo, + databaseId: node.model.database.id, + containerId: node.model.container.id, + triggerBody: defaultTrigger, + nodeId, + }; + + const wizard: AzureWizard = new AzureWizard(wizardContext, { + title: localize('cosmosDB.createTrigger.title', 'Create trigger'), + promptSteps: [ + new DocumentDBTriggerNameStep(), + new DocumentDBTriggerTypeStep(), + new DocumentDBTriggerOperationStep(), + ], + executeSteps: [new DocumentDBExecuteStep()], + showLoadingPrompt: true, + }); + + await wizard.prompt(); + await wizard.execute(); + + if (wizardContext.response) { + const model: DocumentDBTriggerModel = { ...node.model, trigger: wizardContext.response }; + const triggerId = model.trigger.id; + const fsNode = new TriggerFileDescriptor(`${nodeId}/${triggerId}`, model, node.experience); + await ext.fileSystem.showTextDocument(fsNode); + } +} diff --git a/src/commands/deleteContainer/deleteContainer.ts b/src/commands/deleteContainer/deleteContainer.ts new file mode 100644 index 000000000..df38178ca --- /dev/null +++ b/src/commands/deleteContainer/deleteContainer.ts @@ -0,0 +1,118 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { API } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { MongoClustersClient } from '../../mongoClusters/MongoClustersClient'; +import { CollectionItem } from '../../mongoClusters/tree/CollectionItem'; +import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function deleteGraph(context: IActionContext, node?: DocumentDBContainerResourceItem): Promise { + if (!node) { + node = await pickAppResource(context, { + type: AzExtResourceType.AzureCosmosDb, + expectedChildContextValue: ['treeItem.container'], + unexpectedContextValue: [/experience[.](table|cassandra|core)/i], + }); + } + + if (!node) { + return undefined; + } + + return deleteContainer(context, node); +} + +export async function deleteAzureContainer( + context: IActionContext, + node?: DocumentDBContainerResourceItem | CollectionItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb, AzExtResourceType.MongoClusters], + expectedChildContextValue: ['treeItem.container', 'treeItem.collection'], + }); + } + + if (!node) { + return undefined; + } + + await deleteContainer(context, node); +} + +export async function deleteContainer( + context: IActionContext, + node: DocumentDBContainerResourceItem | CollectionItem, +): Promise { + context.telemetry.properties.experience = node.experience.api; + + const containerId = node instanceof CollectionItem ? node.collectionInfo.name : node.model.container.id; + const containerTypeName = + node instanceof CollectionItem ? 'collection' : node.experience.api === API.Graph ? 'graph' : 'container'; + + const confirmed = await getConfirmationAsInSettings( + `Delete "${containerId}"?`, + `Delete ${containerTypeName} "${containerId}" and its contents?\nThis can't be undone.`, + containerId, + ); + + if (!confirmed) { + return; + } + + try { + const success = + node instanceof CollectionItem ? await deleteMongoCollection(node) : await deleteDocumentDBContainer(node); + + if (success) { + showConfirmationAsInSettings( + localize( + 'showConfirmation.droppedDatabase', + `The "{0}" ${containerTypeName} has been deleted.`, + containerId, + ), + ); + } + } finally { + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} + +async function deleteMongoCollection(node: CollectionItem): Promise { + const client = await MongoClustersClient.getClient(node.mongoCluster.id); + + let success = false; + await ext.state.showDeleting(node.id, async () => { + success = await client.dropCollection(node.databaseInfo.name, node.collectionInfo.name); + }); + + return success; +} + +async function deleteDocumentDBContainer(node: DocumentDBContainerResourceItem): Promise { + const accountInfo = node.model.accountInfo; + const client = getCosmosClient(accountInfo.endpoint, accountInfo.credentials, accountInfo.isEmulator); + + let success = false; + await ext.state.showDeleting(node.id, async () => { + const response = await client.database(node.model.database.id).container(node.model.container.id).delete(); + success = response.statusCode === 204; + }); + + return success; +} diff --git a/src/commands/deleteDatabase/deleteDatabase.ts b/src/commands/deleteDatabase/deleteDatabase.ts new file mode 100644 index 000000000..25a3bfa39 --- /dev/null +++ b/src/commands/deleteDatabase/deleteDatabase.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { MongoClustersClient } from '../../mongoClusters/MongoClustersClient'; +import { DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; +import { type DocumentDBDatabaseResourceItem } from '../../tree/docdb/DocumentDBDatabaseResourceItem'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function deleteAzureDatabase( + context: IActionContext, + node?: DocumentDBDatabaseResourceItem | DatabaseItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb, AzExtResourceType.MongoClusters], + expectedChildContextValue: ['treeItem.database'], + }); + } + + if (!node) { + return undefined; + } + + return deleteDatabase(context, node); +} + +export async function deleteDatabase( + context: IActionContext, + node: DocumentDBDatabaseResourceItem | DatabaseItem, +): Promise { + context.telemetry.properties.experience = node.experience.api; + + const databaseId = node instanceof DatabaseItem ? node.databaseInfo.name : node.model.database.id; + const confirmed = await getConfirmationAsInSettings( + `Delete "${databaseId}"?`, + `Delete database "${databaseId}" and its contents?\nThis can't be undone.`, + databaseId, + ); + + if (!confirmed) { + return; + } + + try { + const success = await (node instanceof DatabaseItem + ? deleteMongoDatabase(node) + : deleteDocumentDBDatabase(node)); + + if (success) { + showConfirmationAsInSettings( + localize('showConfirmation.droppedDatabase', 'The "{0}" database has been deleted.', databaseId), + ); + } + } finally { + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} + +async function deleteDocumentDBDatabase(node: DocumentDBDatabaseResourceItem): Promise { + const accountInfo = node.model.accountInfo; + const client = getCosmosClient(accountInfo.endpoint, accountInfo.credentials, accountInfo.isEmulator); + + let success = false; + await ext.state.showDeleting(node.id, async () => { + const response = await client.database(node.model.database.id).delete(); + success = response.statusCode === 204; + }); + + return success; +} + +async function deleteMongoDatabase(node: DatabaseItem): Promise { + const client = await MongoClustersClient.getClient(node.mongoCluster.id); + + let success = false; + await ext.state.showDeleting(node.id, async () => { + success = await client.dropDatabase(node.databaseInfo.name); + }); + + return success; +} diff --git a/src/commands/deleteDatabaseAccount/DatabaseAccountDeleteStep.ts b/src/commands/deleteDatabaseAccount/DatabaseAccountDeleteStep.ts index 9f4940dd6..fe6ddecd0 100644 --- a/src/commands/deleteDatabaseAccount/DatabaseAccountDeleteStep.ts +++ b/src/commands/deleteDatabaseAccount/DatabaseAccountDeleteStep.ts @@ -3,17 +3,36 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; -import { type IDeleteWizardContext } from './IDeleteWizardContext'; +import { AzExtTreeItem, AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; +import { ext } from '../../extensionVariables'; +import { MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { CosmosDBAccountResourceItemBase } from '../../tree/CosmosDBAccountResourceItemBase'; +import { type DeleteWizardContext } from './DeleteWizardContext'; +import { deleteCosmosDBAccount } from './deleteCosmosDBAccount'; +import { deleteMongoClustersAccount } from './deleteMongoClustersAccount'; -export class DatabaseAccountDeleteStep extends AzureWizardExecuteStep { +export class DatabaseAccountDeleteStep extends AzureWizardExecuteStep { public priority: number = 100; - public async execute(context: IDeleteWizardContext): Promise { - await context.node.deleteTreeItem(context); + public async execute(context: DeleteWizardContext): Promise { + if (context.node instanceof AzExtTreeItem) { + await context.node.deleteTreeItem(context); + } else if (context.node instanceof CosmosDBAccountResourceItemBase) { + await ext.state.showDeleting(context.node.id, () => + deleteCosmosDBAccount(context, context.node as CosmosDBAccountResourceItemBase), + ); + ext.cosmosDBBranchDataProvider.refresh(); + } else if (context.node instanceof MongoClusterResourceItem) { + await ext.state.showDeleting(context.node.id, () => + deleteMongoClustersAccount(context, context.node as MongoClusterResourceItem), + ); + ext.mongoClustersBranchDataProvider.refresh(); + } else { + throw new Error('Unexpected node type'); + } } - public shouldExecute(_wizardContext: IDeleteWizardContext): boolean { + public shouldExecute(_wizardContext: DeleteWizardContext): boolean { return true; } } diff --git a/src/commands/deleteDatabaseAccount/IDeleteWizardContext.ts b/src/commands/deleteDatabaseAccount/DeleteWizardContext.ts similarity index 60% rename from src/commands/deleteDatabaseAccount/IDeleteWizardContext.ts rename to src/commands/deleteDatabaseAccount/DeleteWizardContext.ts index a394ab799..11be04aab 100644 --- a/src/commands/deleteDatabaseAccount/IDeleteWizardContext.ts +++ b/src/commands/deleteDatabaseAccount/DeleteWizardContext.ts @@ -9,10 +9,10 @@ import { type IActionContext, type ISubscriptionContext, } from '@microsoft/vscode-azext-utils'; +import { type MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { type CosmosDBAccountResourceItemBase } from '../../tree/CosmosDBAccountResourceItemBase'; -export interface IDeleteWizardContext extends IActionContext, ExecuteActivityContext { - node: AzExtTreeItem; - deletePostgres: boolean; - resourceGroupToDelete?: string; +export interface DeleteWizardContext extends IActionContext, ExecuteActivityContext { + node: AzExtTreeItem | CosmosDBAccountResourceItemBase | MongoClusterResourceItem; subscription: ISubscriptionContext; } diff --git a/src/commands/deleteDatabaseAccount/deleteCosmosDBAccount.ts b/src/commands/deleteDatabaseAccount/deleteCosmosDBAccount.ts index bf6f69c41..899e69c37 100644 --- a/src/commands/deleteDatabaseAccount/deleteCosmosDBAccount.ts +++ b/src/commands/deleteDatabaseAccount/deleteCosmosDBAccount.ts @@ -5,18 +5,43 @@ import { type CosmosDBManagementClient } from '@azure/arm-cosmosdb'; import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils'; -import { type AzExtTreeItem } from '@microsoft/vscode-azext-utils'; +import { AzExtTreeItem, createSubscriptionContext } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; import { ext } from '../../extensionVariables'; +import { CosmosDBAccountResourceItemBase } from '../../tree/CosmosDBAccountResourceItemBase'; import { createCosmosDBClient } from '../../utils/azureClients'; import { getDatabaseAccountNameFromId } from '../../utils/azureUtils'; import { localize } from '../../utils/localize'; -import { type IDeleteWizardContext } from './IDeleteWizardContext'; +import { type DeleteWizardContext } from './DeleteWizardContext'; + +export async function deleteCosmosDBAccount( + context: DeleteWizardContext, + node: AzExtTreeItem | CosmosDBAccountResourceItemBase, +): Promise { + let client: CosmosDBManagementClient; + let resourceGroup: string; + let accountName: string; + + if (node instanceof AzExtTreeItem) { + client = await createCosmosDBClient([context, node.subscription]); + resourceGroup = getResourceGroupFromId(node.fullId); + accountName = getDatabaseAccountNameFromId(node.fullId); + } else if (node instanceof CosmosDBAccountResourceItemBase) { + // Not all CosmosAccountResourceItemBase instances have a subscription property (attached account does not), + // so we need to create a subscription context + if (!('subscription' in node.account)) { + throw new Error('Subscription is required to delete an account.'); + } + + const subscriptionContext = createSubscriptionContext(node.account.subscription as AzureSubscription); + client = await createCosmosDBClient([context, subscriptionContext]); + resourceGroup = getResourceGroupFromId(node.account.id); + accountName = node.account.name; + } else { + throw new Error('Unexpected node type'); + } -export async function deleteCosmosDBAccount(context: IDeleteWizardContext, node: AzExtTreeItem): Promise { - const client: CosmosDBManagementClient = await createCosmosDBClient([context, node.subscription]); - const resourceGroup: string = getResourceGroupFromId(node.fullId); - const accountName: string = getDatabaseAccountNameFromId(node.fullId); const deletePromise = client.databaseAccounts.beginDeleteAndWait(resourceGroup, accountName); if (!context.suppressNotification) { const deletingMessage: string = `Deleting account "${accountName}"...`; diff --git a/src/commands/deleteDatabaseAccount/deleteDatabaseAccount.ts b/src/commands/deleteDatabaseAccount/deleteDatabaseAccount.ts index bf518aafb..4b7613831 100644 --- a/src/commands/deleteDatabaseAccount/deleteDatabaseAccount.ts +++ b/src/commands/deleteDatabaseAccount/deleteDatabaseAccount.ts @@ -4,38 +4,124 @@ *--------------------------------------------------------------------------------------------*/ import { + AzExtTreeItem, AzureWizard, + createSubscriptionContext, DeleteConfirmationStep, - type AzExtTreeItem, type IActionContext, + type ISubscriptionContext, + type ITreeItemPickerContext, } from '@microsoft/vscode-azext-utils'; -import { createActivityContext } from '../../utils/activityUtils'; +import { AzExtResourceType, type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import { + cosmosGremlinFilter, + cosmosMongoFilter, + cosmosTableFilter, + postgresFlexibleFilter, + postgresSingleFilter, + sqlFilter, +} from '../../constants'; +import { ext } from '../../extensionVariables'; +import { type MongoClusterItemBase } from '../../mongoClusters/tree/MongoClusterItemBase'; +import { MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { PostgresServerTreeItem } from '../../postgres/tree/PostgresServerTreeItem'; +import { CosmosDBAccountResourceItemBase } from '../../tree/CosmosDBAccountResourceItemBase'; +import { createActivityContextV2 } from '../../utils/activityUtils'; import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; import { DatabaseAccountDeleteStep } from './DatabaseAccountDeleteStep'; -import { type IDeleteWizardContext } from './IDeleteWizardContext'; +import { type DeleteWizardContext } from './DeleteWizardContext'; + +export async function deletePostgresServer(context: IActionContext, node?: PostgresServerTreeItem): Promise { + const suppressCreateContext: ITreeItemPickerContext = context; + suppressCreateContext.suppressCreatePick = true; + if (!node) { + node = await ext.rgApi.pickAppResource(context, { + filter: [postgresSingleFilter, postgresFlexibleFilter], + }); + } + + if (!node) { + return undefined; + } + + await deleteDatabaseAccount(context, node); +} + +export async function deleteAccount(context: IActionContext, node?: AzExtTreeItem): Promise { + const suppressCreateContext: ITreeItemPickerContext = context; + suppressCreateContext.suppressCreatePick = true; + if (!node) { + node = await ext.rgApi.pickAppResource(context, { + filter: [cosmosMongoFilter, cosmosTableFilter, cosmosGremlinFilter, sqlFilter], + }); + } + + await deleteDatabaseAccount(context, node); +} + +export async function deleteAzureDatabaseAccount( + context: IActionContext, + node?: CosmosDBAccountResourceItemBase | MongoClusterItemBase, +) { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb, AzExtResourceType.MongoClusters], + }); + } + + if (!node) { + return undefined; + } + + await deleteDatabaseAccount(context, node); +} export async function deleteDatabaseAccount( context: IActionContext, - node: AzExtTreeItem, - isPostgres: boolean = false, + node: AzExtTreeItem | CosmosDBAccountResourceItemBase | MongoClusterItemBase, ): Promise { - const wizardContext: IDeleteWizardContext = Object.assign(context, { + let subscription: ISubscriptionContext; + let accountName: string; + let isPostgres = false; + + if (node instanceof AzExtTreeItem) { + subscription = node.subscription; + accountName = node.label; + isPostgres = node instanceof PostgresServerTreeItem; + } else if (node instanceof CosmosDBAccountResourceItemBase && 'subscription' in node.account) { + subscription = createSubscriptionContext(node.account.subscription as AzureSubscription); + accountName = node.account.name; + } else if (node instanceof MongoClusterResourceItem) { + subscription = createSubscriptionContext(node.subscription); + accountName = node.mongoCluster.name; + } else { + // Not all CosmosAccountResourceItemBase instances have a subscription property (attached account does not), + // so we need to create a subscription context + throw new Error('Subscription is required to delete an account.'); + } + + const activityContext = await createActivityContextV2(); + const wizardContext: DeleteWizardContext = Object.assign(context, { node, - deletePostgres: isPostgres, - subscription: node.subscription, - ...(await createActivityContext()), + subscription: subscription, + ...activityContext, }); - const title = wizardContext.deletePostgres - ? localize('deletePoSer', 'Delete Postgres Server "{0}"', node.label) - : localize('deleteDbAcc', 'Delete Database Account "{0}"', node.label); + const title = isPostgres + ? localize('deletePoSer', 'Delete Postgres Server "{0}"', accountName) + : localize('deleteDbAcc', 'Delete Database Account "{0}"', accountName); - const confirmationMessage = wizardContext.deletePostgres - ? localize('deleteAccountConfirm', 'Are you sure you want to delete server "{0}" and its contents?', node.label) + const confirmationMessage = isPostgres + ? localize( + 'deleteAccountConfirm', + 'Are you sure you want to delete server "{0}" and its contents?', + accountName, + ) : localize( 'deleteAccountConfirm', 'Are you sure you want to delete account "{0}" and its contents?', - node.label, + accountName, ); const wizard = new AzureWizard(wizardContext, { diff --git a/src/commands/deleteDatabaseAccount/deleteMongoClustersAccount.ts b/src/commands/deleteDatabaseAccount/deleteMongoClustersAccount.ts new file mode 100644 index 000000000..bdf4dfedc --- /dev/null +++ b/src/commands/deleteDatabaseAccount/deleteMongoClustersAccount.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { type MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { createMongoClustersManagementClient } from '../../utils/azureClients'; +import { localize } from '../../utils/localize'; +import { type DeleteWizardContext } from './DeleteWizardContext'; + +export async function deleteMongoClustersAccount( + context: DeleteWizardContext, + node: MongoClusterResourceItem, +): Promise { + const client = createMongoClustersManagementClient(context, node.subscription); + const resourceGroup = node.mongoCluster.resourceGroup as string; + const accountName = node.mongoCluster.name; + + const deletePromise = (await client).mongoClusters.beginDeleteAndWait(resourceGroup, accountName); + if (!context.suppressNotification) { + const deletingMessage: string = `Deleting account "${accountName}"...`; + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: deletingMessage }, + async () => { + await deletePromise; + const deleteMessage: string = localize( + 'deleteAccountMsg', + `Successfully deleted account "{0}".`, + accountName, + ); + void vscode.window.showInformationMessage(deleteMessage); + ext.outputChannel.appendLog(deleteMessage); + }, + ); + } else { + await deletePromise; + } +} diff --git a/src/commands/deleteItems/deleteItems.ts b/src/commands/deleteItems/deleteItems.ts new file mode 100644 index 000000000..86efc3c38 --- /dev/null +++ b/src/commands/deleteItems/deleteItems.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { 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'; + +export async function deleteDocumentDBItem(context: IActionContext, node: DocumentDBItemResourceItem): Promise { + context.telemetry.properties.experience = node.experience.api; + + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.item'], + }); + } + + if (!node) { + return undefined; + } + + 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) { + vscode.window.showErrorMessage('Document id is required'); + return undefined; + } + + const confirmed = await getConfirmationAsInSettings( + `Delete ${item.id ? `"${item.id}"` : 'document'}?`, + `Delete document ${item.id ? `"${item.id}"` : ''} and its contents?\nThis can't be undone.`, + item.id, + ); + + if (!confirmed) { + 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; + }); + + if (success) { + showConfirmationAsInSettings( + localize( + 'showConfirmation.droppedItem', + 'The document {0} has been deleted.', + item.id ? `"${item.id}"` : '', + ), + ); + } + } finally { + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} diff --git a/src/commands/deleteStoredProcedure/deleteStoredProcedure.ts b/src/commands/deleteStoredProcedure/deleteStoredProcedure.ts new file mode 100644 index 000000000..9fb836521 --- /dev/null +++ b/src/commands/deleteStoredProcedure/deleteStoredProcedure.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type DocumentDBStoredProcedureResourceItem } from '../../tree/docdb/DocumentDBStoredProcedureResourceItem'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function deleteDocumentDBStoredProcedure( + context: IActionContext, + node: DocumentDBStoredProcedureResourceItem, +): Promise { + context.telemetry.properties.experience = node.experience.api; + + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.storedProcedure'], + }); + } + + if (!node) { + return undefined; + } + + const databaseId = node.model.database.id; + const containerId = node.model.container.id; + const procedureId = node.model.procedure.id; + + const confirmed = await getConfirmationAsInSettings( + `Delete "${procedureId}"?`, + `Delete stored procedure "${procedureId}" and its contents?\nThis can't be undone.`, + procedureId, + ); + + if (!confirmed) { + 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) + .scripts.storedProcedure(procedureId) + .delete(); + success = response.statusCode === 204; + }); + + if (success) { + showConfirmationAsInSettings( + localize( + 'showConfirmation.droppedStoredProcedure', + 'The stored procedure {0} has been deleted.', + procedureId, + ), + ); + } + } finally { + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} diff --git a/src/commands/deleteTrigger/deleteTrigger.ts b/src/commands/deleteTrigger/deleteTrigger.ts new file mode 100644 index 000000000..a919bebd7 --- /dev/null +++ b/src/commands/deleteTrigger/deleteTrigger.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { ext } from '../../extensionVariables'; +import { type DocumentDBTriggerResourceItem } from '../../tree/docdb/DocumentDBTriggerResourceItem'; +import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function deleteDocumentDBTrigger( + context: IActionContext, + node: DocumentDBTriggerResourceItem, +): Promise { + context.telemetry.properties.experience = node.experience.api; + + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.item'], + }); + } + + if (!node) { + return undefined; + } + + const databaseId = node.model.database.id; + const containerId = node.model.container.id; + const triggerId = node.model.trigger.id; + + const confirmed = await getConfirmationAsInSettings( + `Delete "${triggerId}"?`, + `Delete trigger "${triggerId}" and its contents?\nThis can't be undone.`, + triggerId, + ); + + if (!confirmed) { + 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) + .scripts.trigger(triggerId) + .delete(); + success = response.statusCode === 204; + }); + + if (success) { + showConfirmationAsInSettings( + localize('showConfirmation.droppedTrigger', 'The trigger {0} has been deleted.', triggerId), + ); + } + } finally { + const lastSlashIndex = node.id.lastIndexOf('/'); + let parentId = node.id; + if (lastSlashIndex !== -1) { + parentId = parentId.substring(0, lastSlashIndex); + } + ext.state.notifyChildrenChanged(parentId); + } +} diff --git a/src/commands/detachDatabaseAccount/detachDatabaseAccount.ts b/src/commands/detachDatabaseAccount/detachDatabaseAccount.ts new file mode 100644 index 000000000..dc38f1e64 --- /dev/null +++ b/src/commands/detachDatabaseAccount/detachDatabaseAccount.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type AzExtTreeItem, type IActionContext } from '@microsoft/vscode-azext-utils'; +import vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { MongoClusterItemBase } from '../../mongoClusters/tree/MongoClusterItemBase'; +import { PostgresServerTreeItem } from '../../postgres/tree/PostgresServerTreeItem'; +import { AttachedAccountSuffix } from '../../tree/AttachedAccountsTreeItem'; +import { CosmosDBAccountResourceItemBase } from '../../tree/CosmosDBAccountResourceItemBase'; +import { WorkspaceResourceType } from '../../tree/workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage } from '../../tree/workspace/SharedWorkspaceStorage'; +import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; +import { localize } from '../../utils/localize'; +import { pickWorkspaceResource } from '../../utils/pickItem/pickAppResource'; + +export async function detachDatabaseAccountV1(context: IActionContext, node?: AzExtTreeItem): Promise { + const cosmosDBTopLevelContextValues: string[] = [PostgresServerTreeItem.contextValue]; + + const children = await ext.attachedAccountsNode.loadAllChildren(context); + if (children.length < 2) { + const message = localize('noAttachedAccounts', 'There are no Attached Accounts.'); + void vscode.window.showInformationMessage(message); + } else { + if (!node) { + node = await ext.rgApi.workspaceResourceTree.showTreeItemPicker( + cosmosDBTopLevelContextValues.map((val: string) => (val += AttachedAccountSuffix)), + context, + ); + } + + if (!node) { + return undefined; + } + + await ext.attachedAccountsNode.detach(node); + await ext.rgApi.workspaceResourceTree.refresh(context, ext.attachedAccountsNode); + } +} + +export async function detachAzureDatabaseAccount( + context: IActionContext, + node?: CosmosDBAccountResourceItemBase | MongoClusterItemBase, +): Promise { + if (!node) { + node = await pickWorkspaceResource(context, { + type: [WorkspaceResourceType.AttachedAccounts, WorkspaceResourceType.MongoClusters], + expectedChildContextValue: ['treeItem.account', 'treeItem.mongoCluster'], + }); + } + + if (!node) { + return; + } + + await detachDatabaseAccount(context, node); +} + +export async function detachDatabaseAccount( + context: IActionContext, + node: CosmosDBAccountResourceItemBase | MongoClusterItemBase, +): Promise { + context.telemetry.properties.experience = node.experience.api; + + if (node instanceof MongoClusterItemBase) { + await ext.state.showDeleting(node.id, async () => { + await SharedWorkspaceStorage.delete(WorkspaceResourceType.MongoClusters, node.id); + }); + + ext.mongoClustersWorkspaceBranchDataProvider.refresh(); + } + + if (node instanceof CosmosDBAccountResourceItemBase) { + await ext.state.showDeleting(node.id, async () => { + await SharedWorkspaceStorage.delete(WorkspaceResourceType.AttachedAccounts, node.id); + }); + + ext.cosmosDBWorkspaceBranchDataProvider.refresh(); + } + + showConfirmationAsInSettings( + localize( + 'showConfirmation.removedWorkspaceConnection', + 'The selected connection has been removed from your workspace.', + ), + ); +} diff --git a/src/commands/executeStoredProcedure/executeStoredProcedure.ts b/src/commands/executeStoredProcedure/executeStoredProcedure.ts new file mode 100644 index 000000000..c96f9d483 --- /dev/null +++ b/src/commands/executeStoredProcedure/executeStoredProcedure.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext, openReadOnlyJson, randomUtils } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { type DocumentDBStoredProcedureResourceItem } from '../../tree/docdb/DocumentDBStoredProcedureResourceItem'; +import { localize } from '../../utils/localize'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function executeDocumentDBStoredProcedure( + context: IActionContext, + node?: DocumentDBStoredProcedureResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.storedProcedure'], + }); + } + + const partitionKey = await context.ui.showInputBox({ + title: 'Partition Key', + // @todo: add a learnMoreLink + }); + + const paramString = await context.ui.showInputBox({ + title: 'Parameters', + placeHolder: localize( + 'executeCosmosStoredProcedureParameters', + 'empty or array of values e.g. [1, {key: value}]', + ), + // @todo: add a learnMoreLink + }); + + let parameters: (string | number | object)[] | undefined = undefined; + if (paramString !== '') { + try { + parameters = JSON.parse(paramString) as (string | number | object)[]; + } catch { + // Ignore parameters if they are invalid + } + } + + const { endpoint, credentials, isEmulator } = node.model.accountInfo; + const databaseId = node.model.database.id; + const containerId = node.model.container.id; + const procedureId = node.model.procedure.id; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const result = await cosmosClient + .database(databaseId) + .container(containerId) + .scripts.storedProcedure(procedureId) + .execute(partitionKey, parameters); + + try { + const resultFileName = `${procedureId}-result`; + await openReadOnlyJson({ label: resultFileName, fullId: randomUtils.getRandomHexString() }, result); + } catch { + await context.ui.showWarningMessage(`Unable to parse execution result`); + } +} diff --git a/src/commands/importDocuments.ts b/src/commands/importDocuments.ts deleted file mode 100644 index 9ee006e94..000000000 --- a/src/commands/importDocuments.ts +++ /dev/null @@ -1,238 +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 { type ItemDefinition } from '@azure/cosmos'; -import { callWithTelemetryAndErrorHandling, parseError, type IActionContext } from '@microsoft/vscode-azext-utils'; -import { EJSON } from 'bson'; -import * as fse from 'fs-extra'; -import * as vscode from 'vscode'; -import { cosmosMongoFilter, sqlFilter } from '../constants'; -import { DocDBCollectionTreeItem } from '../docdb/tree/DocDBCollectionTreeItem'; -import { ext } from '../extensionVariables'; -import { MongoCollectionTreeItem } from '../mongo/tree/MongoCollectionTreeItem'; -import { CollectionItem } from '../mongoClusters/tree/CollectionItem'; -import { nonNullProp, nonNullValue } from '../utils/nonNull'; -import { getRootPath } from '../utils/workspacUtils'; - -export async function importDocuments( - context: IActionContext, - uris: vscode.Uri[] | undefined, - collectionNode: MongoCollectionTreeItem | DocDBCollectionTreeItem | CollectionItem | undefined, -): Promise { - if (!uris) { - uris = await askForDocuments(context); - } - const ignoredUris: vscode.Uri[] = []; //account for https://github.com/Microsoft/vscode/issues/59782 - uris = uris.filter((uri) => { - if (uri.fsPath.toLocaleLowerCase().endsWith('.json')) { - return true; - } else { - ignoredUris.push(uri); - return false; - } - }); - if (ignoredUris.length) { - ext.outputChannel.appendLog(`Ignoring the following files that do not match the "*.json" file name pattern:`); - ignoredUris.forEach((uri) => ext.outputChannel.appendLog(`${uri.fsPath}`)); - ext.outputChannel.show(); - } - if (!collectionNode) { - collectionNode = await ext.rgApi.pickAppResource(context, { - filter: [cosmosMongoFilter, sqlFilter], - expectedChildContextValue: [MongoCollectionTreeItem.contextValue, DocDBCollectionTreeItem.contextValue], - }); - } - - // adding a precaution for the mongoClusters path - if (!collectionNode) { - throw new Error('No collection selected.'); - } - - let result: string; - result = await vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'Importing documents...', - }, - async (progress) => { - uris = nonNullValue(uris, 'uris'); - collectionNode = nonNullValue(collectionNode, 'collectionNode'); - - progress.report({ increment: 20, message: 'Loading documents...' }); - - const supportEJSON: boolean = collectionNode instanceof CollectionItem; // added this line for better readability - const documents: unknown[] = await parseDocuments(uris, supportEJSON); - - progress.report({ increment: 30, message: `Loaded ${documents.length} document(s). Importing...` }); - if (collectionNode instanceof MongoCollectionTreeItem) { - result = await insertDocumentsIntoMongo(collectionNode, documents); - } else if (collectionNode instanceof CollectionItem) { - result = await insertDocumentsIntoMongoCluster(context, collectionNode, documents); - } else { - result = await insertDocumentsIntoDocdb(collectionNode, documents, uris); - } - progress.report({ increment: 50, message: 'Finished importing' }); - return result; - }, - ); - - if (collectionNode instanceof CollectionItem === false) { - await collectionNode.refresh(context); - } - - await vscode.window.showInformationMessage(result); -} - -async function askForDocuments(context: IActionContext): Promise { - const openDialogOptions: vscode.OpenDialogOptions = { - canSelectMany: true, - openLabel: 'Import', - filters: { - JSON: ['json'], - }, - }; - const rootPath: string | undefined = getRootPath(); - if (rootPath) { - openDialogOptions.defaultUri = vscode.Uri.file(rootPath); - } - return await context.ui.showOpenDialog(openDialogOptions); -} - -/** - * Parses an array of URIs to read JSON documents and returns them as an array of unknown objects. - * If any errors are encountered while reading the documents, they are logged to the output channel. - * - * @param uris - An array of `vscode.Uri` objects representing the file paths to the JSON documents. - * @param supportEJSON - An optional boolean parameter that indicates whether to support extended JSON (EJSON). - * EJSON is used to read documents that are supposed to be converted into BSON. - * EJSON supports more datatypes and is specific to MongoDB. This is currently used for MongoDB clusters/vcore. - * @returns A promise that resolves to an array of parsed documents as unknown objects. - * @throws An error if any documents contain errors, prompting the user to fix them and try again. - */ -async function parseDocuments(uris: vscode.Uri[], supportEJSON: boolean = false): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let documents: any[] = []; - let errorFoundFlag: boolean = false; - for (const uri of uris) { - let parsed; - try { - if (supportEJSON) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - parsed = EJSON.parse(await fse.readFile(uri.fsPath, 'utf8')); - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - parsed = await fse.readJSON(uri.fsPath); - } - } catch (e) { - if (!errorFoundFlag) { - errorFoundFlag = true; - ext.outputChannel.appendLog('Errors found in documents listed below. Please fix these.'); - ext.outputChannel.show(); - } - const err = parseError(e); - ext.outputChannel.appendLog(`${uri.path}:\n${err.message}`); - } - if (parsed) { - if (Array.isArray(parsed)) { - documents = documents.concat(parsed); - } else { - documents.push(parsed); - } - } - } - if (errorFoundFlag) { - throw new Error(`Errors found in some documents. Please see the output, fix these and try again.`); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return documents; -} - -async function insertDocumentsIntoDocdb( - collectionNode: DocDBCollectionTreeItem, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - documents: any[], - uris: vscode.Uri[], -): Promise { - const ids: string[] = []; - let i = 0; - const erroneousFiles: vscode.Uri[] = []; - for (i = 0; i < documents.length; i++) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const document: ItemDefinition = documents[i]; - if (!collectionNode.documentsTreeItem.documentHasPartitionKey(document)) { - erroneousFiles.push(uris[i]); - } - } - if (erroneousFiles.length) { - ext.outputChannel.appendLog(`The following documents do not contain the required partition key:`); - erroneousFiles.forEach((file) => ext.outputChannel.appendLog(file.path)); - ext.outputChannel.show(); - throw new Error( - `See output for list of documents that do not contain the partition key '${nonNullProp(collectionNode, 'partitionKey').paths[0]}' required by collection '${collectionNode.label}'`, - ); - } - for (const document of documents) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const retrieved: ItemDefinition = await collectionNode.documentsTreeItem.createDocument(document); - if (retrieved.id) { - ids.push(retrieved.id); - } - } - const result: string = `Import into NoSQL successful. Inserted ${ids.length} document(s). See output for more details.`; - for (const id of ids) { - ext.outputChannel.appendLog(`Inserted document: ${id}`); - } - return result; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function insertDocumentsIntoMongo(node: MongoCollectionTreeItem, documents: any[]): Promise { - let output = ''; - - const parsed = await callWithTelemetryAndErrorHandling('cosmosDB.mongo.importDocumets', async (actionContext) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const parsed = await node.collection.insertMany(documents); - - actionContext.telemetry.measurements.documentCount = parsed?.insertedCount; - - return parsed; - }); - - if (parsed?.acknowledged) { - output = `Import into mongo successful. Inserted ${parsed.insertedCount} document(s). See output for more details.`; - for (const inserted of Object.values(parsed.insertedIds)) { - ext.outputChannel.appendLog(`Inserted document: ${inserted}`); - } - } - return output; -} - -async function insertDocumentsIntoMongoCluster( - context: IActionContext, - node: CollectionItem, - documents: unknown[], -): Promise { - const result = await callWithTelemetryAndErrorHandling( - 'cosmosDB.mongoClusters.importDocumets', - async (actionContext) => { - const result = await node.insertDocuments(context, documents as Document[]); - - actionContext.telemetry.measurements.documentCount = result?.insertedCount; - - return result; - }, - ); - - let message: string; - if (result?.acknowledged) { - message = `Import successful. Inserted ${result.insertedCount} document(s).`; - } else { - message = `Import failed. The operation was not acknowledged by the database.`; - } - - ext.outputChannel.appendLog('MongoDB Clusters ' + message); - return message; -} diff --git a/src/commands/importDocuments/importDocuments.ts b/src/commands/importDocuments/importDocuments.ts new file mode 100644 index 000000000..a45da6df0 --- /dev/null +++ b/src/commands/importDocuments/importDocuments.ts @@ -0,0 +1,318 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ItemDefinition, type JSONObject, type JSONValue, type PartitionKeyDefinition } from '@azure/cosmos'; +import { parseError, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { parse as parseJson } from '@prantlf/jsonlint'; +import { EJSON, type Document } from 'bson'; +import * as fse from 'fs-extra'; +import * as vscode from 'vscode'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { validateDocumentId, validatePartitionKey } from '../../docdb/utils/validateDocument'; +import { ext } from '../../extensionVariables'; +import { MongoClustersClient } from '../../mongoClusters/MongoClustersClient'; +import { CollectionItem } from '../../mongoClusters/tree/CollectionItem'; +import { DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import { getRootPath } from '../../utils/workspacUtils'; + +export async function importDocuments( + context: IActionContext, + selectedItem: vscode.Uri | DocumentDBContainerResourceItem | CollectionItem | undefined, + uris: vscode.Uri[] | undefined, +): Promise { + if (selectedItem instanceof vscode.Uri) { + uris ||= [selectedItem]; + selectedItem = undefined; + } else { + uris ||= []; + } + + if (!uris || uris.length === 0) { + uris = await askForDocuments(context); + } + + const ignoredUris: vscode.Uri[] = []; //account for https://github.com/Microsoft/vscode/issues/59782 + uris = uris.filter((uri) => { + if (uri.fsPath.toLocaleLowerCase().endsWith('.json')) { + return true; + } else { + ignoredUris.push(uri); + return false; + } + }); + + if (ignoredUris.length) { + ext.outputChannel.appendLog(`Ignoring the following files that do not match the "*.json" file name pattern:`); + ignoredUris.forEach((uri) => ext.outputChannel.appendLog(`${uri.fsPath}`)); + ext.outputChannel.show(); + } + + if (!selectedItem) { + selectedItem = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb, AzExtResourceType.MongoClusters], + expectedChildContextValue: ['treeItem.container', 'treeItem.collection'], + }); + } + + if (!selectedItem) { + return undefined; + } + + context.telemetry.properties.experience = selectedItem.experience.api; + + await ext.state.runWithTemporaryDescription(selectedItem.id, 'Importing...', async () => { + await importDocumentsWithProgress(selectedItem, uris); + }); + + ext.state.notifyChildrenChanged(selectedItem.id); +} + +export async function importDocumentsWithProgress( + selectedItem: DocumentDBContainerResourceItem | CollectionItem, + uris: vscode.Uri[], +): Promise { + const result = await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'Importing documents...', + }, + async (progress) => { + progress.report({ increment: 0, message: 'Loading documents...' }); + + const countUri = uris.length; + const incrementUri = 50 / (countUri || 1); + const documents: unknown[] = []; + let hasErrors = false; + + for (let i = 0, percent = 0; i < countUri; i++, percent += incrementUri) { + progress.report({ + increment: Math.floor(percent), + message: `Loading document ${i + 1} of ${countUri}`, + }); + + const result = await parseAndValidateFile(selectedItem, uris[i]); + + if (result.errors && result.errors.length) { + ext.outputChannel.appendLog(`Errors found in document ${uris[i].path}. Please fix these.`); + ext.outputChannel.appendLog(result.errors.join('\n')); + ext.outputChannel.show(); + hasErrors = true; + } + + if (result.documents && result.documents.length) { + documents.push(...result.documents); + } + } + + const countDocuments = documents.length; + const incrementDocuments = 50 / (countDocuments || 1); + let count = 0; + + for (let i = 0, percent = 0; i < countDocuments; i++, percent += incrementDocuments) { + progress.report({ + increment: Math.floor(percent), + message: `Importing document ${i + 1} of ${countDocuments}`, + }); + + const result = await insertDocument(selectedItem, documents[i]); + + if (result.error) { + ext.outputChannel.appendLog( + `The insertion of document ${i + 1} failed with error: ${result.error}`, + ); + ext.outputChannel.show(); + hasErrors = true; + } else { + count++; + } + } + + progress.report({ increment: 50, message: 'Finished importing' }); + + return `${hasErrors ? 'Import has accomplished with errors' : 'Import successful'}. Inserted ${count} document(s). See output for more details.`; + }, + ); + + // We should not use await here, otherwise the node status will not be updated until the message is closed + vscode.window.showInformationMessage(result); +} + +async function askForDocuments(context: IActionContext): Promise { + const openDialogOptions: vscode.OpenDialogOptions = { + canSelectMany: true, + openLabel: 'Import', + filters: { + JSON: ['json'], + }, + }; + const rootPath: string | undefined = getRootPath(); + if (rootPath) { + openDialogOptions.defaultUri = vscode.Uri.file(rootPath); + } + return await context.ui.showOpenDialog(openDialogOptions); +} + +async function parseAndValidateFile( + node: DocumentDBContainerResourceItem | CollectionItem, + uri: vscode.Uri, +): Promise<{ documents: unknown[]; errors: string[] }> { + try { + if (node instanceof CollectionItem) { + // await needs to catch the error here, otherwise it will be thrown to the caller + return await parseAndValidateFileForMongo(uri); + } + + if (node instanceof DocumentDBContainerResourceItem) { + // await needs to catch the error here, otherwise it will be thrown to the caller + return await parseAndValidateFileForDocumentDB(uri, node.model.container.partitionKey); + } + } catch (e) { + return { documents: [], errors: [parseError(e).message] }; + } + + return { documents: [], errors: ['Unknown error'] }; +} + +/** + * @param uri - An array of `vscode.Uri` objects representing the file paths to the JSON documents. + * EJSON is used to read documents that are supposed to be converted into BSON. + * EJSON supports more datatypes and is specific to MongoDB. This is currently used for MongoDB clusters/vcore. + * @returns A promise that resolves to an array of parsed documents as unknown objects. + */ +async function parseAndValidateFileForMongo(uri: vscode.Uri): Promise<{ documents: unknown[]; errors: string[] }> { + const fileContent = await fse.readFile(uri.fsPath, 'utf8'); + const parsed = EJSON.parse(fileContent) as unknown; + const errors: string[] = []; + const documents: unknown[] = []; + + if (!parsed || typeof parsed !== 'object') { + errors.push('Document must be an object.'); + } else if (Array.isArray(parsed)) { + documents.push( + ...parsed + .map((document: unknown) => { + // Only top-level array is supported + if (!document || typeof document !== 'object' || Array.isArray(document)) { + errors.push(`Document must be an object. Skipping...\n${EJSON.stringify(document)}`); + return undefined; + } + + return document; + }) + .filter((e) => e), + ); + } else if (typeof parsed === 'object') { + documents.push(parsed); + } + + return { documents, errors }; +} + +async function parseAndValidateFileForDocumentDB( + uri: vscode.Uri, + partitionKey?: PartitionKeyDefinition, +): Promise<{ documents: unknown[]; errors: string[] }> { + const errors: string[] = []; + const documents: unknown[] = []; + + const validateOneDocument = (document: JSONObject): boolean => { + let hasErrors = false; + const partitionKeyError = validatePartitionKey(document, partitionKey); + if (partitionKeyError) { + errors.push(...partitionKeyError); + hasErrors = true; + } + + const idError = validateDocumentId(document); + if (idError) { + errors.push(...idError); + hasErrors = true; + } + + return !hasErrors; + }; + + const fileContent = await fse.readFile(uri.fsPath, 'utf8'); + const parsed = parseJson(fileContent) as JSONValue; + + if (!parsed || typeof parsed !== 'object') { + errors.push('Document must be an object.'); + } else if (Array.isArray(parsed)) { + documents.push( + ...parsed + .map((document: unknown) => { + // Only top-level array is supported + if (!document || typeof document !== 'object' || Array.isArray(document)) { + errors.push(`Document must be an object. Skipping...\n${EJSON.stringify(document)}`); + return undefined; + } + + return validateOneDocument(document as JSONObject) ? document : undefined; + }) + .filter((e) => e), + ); + } else if (typeof parsed === 'object') { + if (validateOneDocument(parsed as JSONObject)) { + documents.push(parsed); + } + } + + return { documents, errors }; +} + +async function insertDocument( + node: DocumentDBContainerResourceItem | CollectionItem, + document: unknown, +): Promise<{ document: unknown; error: string }> { + try { + if (node instanceof CollectionItem) { + // await needs to catch the error here, otherwise it will be thrown to the caller + return await insertDocumentIntoMongoCluster(node, document as Document); + } + + if (node instanceof DocumentDBContainerResourceItem) { + // await needs to catch the error here, otherwise it will be thrown to the caller + return await insertDocumentIntoDocumentDB(node, document as ItemDefinition); + } + } catch (e) { + return { document, error: parseError(e).message }; + } + + return { document, error: 'Unknown error' }; +} + +async function insertDocumentIntoDocumentDB( + node: DocumentDBContainerResourceItem, + document: ItemDefinition, +): Promise<{ document: ItemDefinition; error: string }> { + const { endpoint, credentials, isEmulator } = node.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const response = await cosmosClient + .database(node.model.database.id) + .container(node.model.container.id) + .items.create(document); + + if (response.resource) { + return { document, error: '' }; + } else { + return { document, error: `The insertion failed with status code ${response.statusCode}` }; + } +} + +async function insertDocumentIntoMongoCluster( + node: CollectionItem, + document: Document, +): Promise<{ document: Document; error: string }> { + const client = await MongoClustersClient.getClient(node.mongoCluster.id); + const response = await client.insertDocuments(node.databaseInfo.name, node.collectionInfo.name, [document]); + + if (response?.acknowledged) { + return { document, error: '' }; + } else { + return { document, error: `The insertion failed. The operation was not acknowledged by the database.` }; + } +} diff --git a/src/commands/launchShell/launchShell.ts b/src/commands/launchShell/launchShell.ts new file mode 100644 index 000000000..14542992f --- /dev/null +++ b/src/commands/launchShell/launchShell.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { MongoClustersClient } from '../../mongoClusters/MongoClustersClient'; +import { type CollectionItem } from '../../mongoClusters/tree/CollectionItem'; +import { type DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; +import { MongoClusterResourceItem } from '../../mongoClusters/tree/MongoClusterResourceItem'; +import { MongoClusterWorkspaceItem } from '../../mongoClusters/tree/workspace/MongoClusterWorkspaceItem'; +import { MongoAccountResourceItem } from '../../tree/mongo/MongoAccountResourceItem'; + +import { ConnectionString } from 'mongodb-connection-string-url'; + +export async function launchShell( + context: IActionContext, + node?: + | DatabaseItem + | CollectionItem + | MongoClusterWorkspaceItem + | MongoClusterResourceItem + | MongoAccountResourceItem, +): Promise { + if (!node) { + throw new Error('No database or collection selected.'); + } + + context.telemetry.properties.experience = node.experience.api; + + let rawConnectionString: string | undefined; + + // connection string discovery for these items can be slow, so we need to run it with a temporary description + + if ( + node instanceof MongoClusterResourceItem || + node instanceof MongoAccountResourceItem || + node instanceof MongoClusterWorkspaceItem + ) { + rawConnectionString = await ext.state.runWithTemporaryDescription(node.id, 'Working...', async () => { + // WorkspaceItems are fast as there is no connection string discovery happening + return node.getConnectionString(); + }); + } else { + // everything else has the connection string available in memory as we're connected to the server + const client: MongoClustersClient = await MongoClustersClient.getClient(node.mongoCluster.id); + rawConnectionString = client.getConnectionStringWithPassword(); + } + + if (!rawConnectionString) { + void vscode.window.showErrorMessage('Failed to extract the connection string from the selected cluster.'); + return; + } + + const connectionString: ConnectionString = new ConnectionString(rawConnectionString); + + const username = connectionString.username; + const password = connectionString.password; + + const isWindows = process.platform === 'win32'; + connectionString.username = isWindows ? '%USERNAME%' : '$USERNAME'; + connectionString.password = isWindows ? '%PASSWORD%' : '$PASSWORD'; + + if ('databaseInfo' in node && node.databaseInfo?.name) { + connectionString.pathname = node.databaseInfo.name; + } + + // } else if (node instanceof CollectionItem) { // --> --eval terminates, we'd have to launch with a script etc. let's look into it latter + // const connStringWithDb = addDatabasePathToConnectionString(connectionStringWithUserName, node.databaseInfo.name); + // shellParameters = `"${connStringWithDb}" --eval 'db.getCollection("${node.collectionInfo.name}")'` + // } + + const terminal: vscode.Terminal = vscode.window.createTerminal({ + name: `MongoDB Shell (${username})`, + hideFromUser: false, + env: { + USERNAME: username, + PASSWORD: password, + }, + }); + + terminal.sendText(`mongosh "${connectionString.toString()}"`); + terminal.show(); +} diff --git a/src/docdb/commands/openTrigger.ts b/src/commands/loadMore/loadMore.ts similarity index 54% rename from src/docdb/commands/openTrigger.ts rename to src/commands/loadMore/loadMore.ts index 55d9ef399..d260024fe 100644 --- a/src/docdb/commands/openTrigger.ts +++ b/src/commands/loadMore/loadMore.ts @@ -5,12 +5,14 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import { ext } from '../../extensionVariables'; -import { DocDBTriggerTreeItem } from '../tree/DocDBTriggerTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; -export async function openTrigger(context: IActionContext, node?: DocDBTriggerTreeItem): Promise { - if (!node) { - node = await pickDocDBAccount(context, DocDBTriggerTreeItem.contextValue); +export async function documentDBLoadMore( + context: IActionContext, + nodeId: string, + loadMoreFn: (context: IActionContext) => Promise | undefined, +): Promise { + if (loadMoreFn) { + await loadMoreFn(context); + ext.state.notifyChildrenChanged(nodeId); } - await ext.fileSystem.showTextDocument(node); } diff --git a/src/commands/openDocument/openDocument.ts b/src/commands/openDocument/openDocument.ts new file mode 100644 index 000000000..da96462ab --- /dev/null +++ b/src/commands/openDocument/openDocument.ts @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { ext } from '../../extensionVariables'; +import { type DocumentDBItemResourceItem } from '../../tree/docdb/DocumentDBItemResourceItem'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import { DocumentsViewController } from '../../webviews/mongoClusters/documentView/documentsViewController'; + +export async function openDocumentDBItem(context: IActionContext, node?: DocumentDBItemResourceItem): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.document'], + }); + } + + if (!node) { + return; + } + + context.telemetry.properties.experience = node.experience.api; + + const fsNode = new DocumentFileDescriptor(node.id, node.model, node.experience); + // 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); +} + +export function openMongoDocumentView( + _context: IActionContext, + props: { + id: string; + + clusterId: string; + databaseName: string; + collectionName: string; + documentId: string; + + mode: string; + }, +): void { + const view = new DocumentsViewController({ + id: props.id, + + clusterId: props.clusterId, + databaseName: props.databaseName, + collectionName: props.collectionName, + documentId: props.documentId, + + mode: props.mode, + }); + + view.revealToForeground(ViewColumn.Active); +} diff --git a/src/commands/openGraphExplorer/openGraphExplorer.ts b/src/commands/openGraphExplorer/openGraphExplorer.ts new file mode 100644 index 000000000..370d5c4e2 --- /dev/null +++ b/src/commands/openGraphExplorer/openGraphExplorer.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { localize } from '../../utils/localize'; +import { openUrl } from '../../utils/openUrl'; + +const alternativeGraphVisualizationToolsDocLink = 'https://aka.ms/cosmosdb-graph-alternative-tools'; + +export async function openGraphExplorer() { + const message: string = localize('mustInstallGraph', 'Cosmos DB Graph extension has been retired.'); + const alternativeToolsOption = 'Alternative Tools'; + const result = await vscode.window.showErrorMessage(message, alternativeToolsOption); + if (result === alternativeToolsOption) { + await openUrl(alternativeGraphVisualizationToolsDocLink); + } +} diff --git a/src/commands/openNoSqlQueryEditor/openNoSqlQueryEditor.ts b/src/commands/openNoSqlQueryEditor/openNoSqlQueryEditor.ts new file mode 100644 index 000000000..898f60101 --- /dev/null +++ b/src/commands/openNoSqlQueryEditor/openNoSqlQueryEditor.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { QueryEditorTab } from '../../panels/QueryEditorTab'; +import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; +import { type DocumentDBItemsResourceItem } from '../../tree/docdb/DocumentDBItemsResourceItem'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function openNoSqlQueryEditor( + context: IActionContext, + node?: DocumentDBContainerResourceItem | DocumentDBItemsResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: AzExtResourceType.AzureCosmosDb, + expectedChildContextValue: ['treeItem.container', 'treeItem.items'], + }); + } + + if (!node) { + return undefined; + } + + 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); +} diff --git a/src/commands/openStoredProcedure/openStoredProcedure.ts b/src/commands/openStoredProcedure/openStoredProcedure.ts new file mode 100644 index 000000000..90a41c40c --- /dev/null +++ b/src/commands/openStoredProcedure/openStoredProcedure.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { StoredProcedureFileDescriptor } from '../../docdb/fs/StoredProcedureFileDescriptor'; +import { ext } from '../../extensionVariables'; +import { type DocumentDBStoredProcedureResourceItem } from '../../tree/docdb/DocumentDBStoredProcedureResourceItem'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function openDocumentDBStoredProcedure( + context: IActionContext, + node?: DocumentDBStoredProcedureResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.storedProcedure'], + }); + } + + if (!node) { + return; + } + + context.telemetry.properties.experience = node.experience.api; + + const fsNode = new StoredProcedureFileDescriptor(node.id, node.model, node.experience); + await ext.fileSystem.showTextDocument(fsNode); +} diff --git a/src/commands/openTrigger/openTrigger.ts b/src/commands/openTrigger/openTrigger.ts new file mode 100644 index 000000000..0ce623bcc --- /dev/null +++ b/src/commands/openTrigger/openTrigger.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { TriggerFileDescriptor } from '../../docdb/fs/TriggerFileDescriptor'; +import { ext } from '../../extensionVariables'; +import { type DocumentDBTriggerResourceItem } from '../../tree/docdb/DocumentDBTriggerResourceItem'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; + +export async function openDocumentDBTrigger( + context: IActionContext, + node?: DocumentDBTriggerResourceItem, +): Promise { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.trigger'], + }); + } + + if (!node) { + return undefined; + } + + context.telemetry.properties.experience = node.experience.api; + + const fsNode = new TriggerFileDescriptor(node.id, node.model, node.experience); + await ext.fileSystem.showTextDocument(fsNode); +} diff --git a/src/commands/refreshTreeElement/refreshTreeElement.ts b/src/commands/refreshTreeElement/refreshTreeElement.ts new file mode 100644 index 000000000..0b6b8f96e --- /dev/null +++ b/src/commands/refreshTreeElement/refreshTreeElement.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { AzExtTreeItem, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { ext } from '../../extensionVariables'; +import { type CosmosDBTreeElement } from '../../tree/CosmosDBTreeElement'; + +export async function refreshTreeElement( + context: IActionContext, + node: AzExtTreeItem | CosmosDBTreeElement, +): Promise { + if (node instanceof AzExtTreeItem) { + return node.refresh(context); + } + + if (node && 'refresh' in node && typeof node.refresh === 'function') { + await node.refresh.call(node, context); + return; + } + + if (node && 'contextValue' in node && typeof node.contextValue === 'string') { + if (/experience[.](mongocluster|mongodb)/i.test(node.contextValue)) { + return ext.mongoClustersBranchDataProvider.refresh(node); + } + + if (/experience[.](table|cassandra|core|graph)/i.test(node.contextValue)) { + return ext.cosmosDBBranchDataProvider.refresh(node); + } + } + + if (node && 'id' in node && typeof node.id === 'string') { + return ext.state.notifyChildrenChanged(node.id); + } +} diff --git a/src/commands/registerCommands.ts b/src/commands/registerCommands.ts new file mode 100644 index 000000000..36518a771 --- /dev/null +++ b/src/commands/registerCommands.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type IActionContext, + registerCommand, + registerCommandWithTreeNodeUnwrapping, +} from '@microsoft/vscode-azext-utils'; +import type vscode from 'vscode'; +import { doubleClickDebounceDelay } from '../constants'; +import { registerDocDBCommands } from '../docdb/registerDocDBCommands'; +import { ext } from '../extensionVariables'; +import { registerMongoCommands } from '../mongo/registerMongoCommands'; +import { registerPostgresCommands } from '../postgres/commands/registerPostgresCommands'; +import { attachAccount } from './attachAccount/attachAccount'; +import { attachEmulator } from './attachEmulator/attachEmulator'; +import { copyAzureConnectionString } from './copyConnectionString/copyConnectionString'; +import { createDocumentDBContainer, createGraph } from './createContainer/createContainer'; +import { createAzureDatabase } from './createDatabase/createDatabase'; +import { createDocumentDBDocument } from './createDocument/createDocument'; +import { createServer } from './createServer/createServer'; +import { createDocumentDBStoredProcedure } from './createStoredProcedure/createStoredProcedure'; +import { createDocumentDBTrigger } from './createTrigger/createTrigger'; +import { deleteAzureContainer, deleteGraph } from './deleteContainer/deleteContainer'; +import { deleteAzureDatabase } from './deleteDatabase/deleteDatabase'; +import { deleteAzureDatabaseAccount } from './deleteDatabaseAccount/deleteDatabaseAccount'; +import { deleteDocumentDBItem } from './deleteItems/deleteItems'; +import { deleteDocumentDBStoredProcedure } from './deleteStoredProcedure/deleteStoredProcedure'; +import { deleteDocumentDBTrigger } from './deleteTrigger/deleteTrigger'; +import { detachAzureDatabaseAccount } from './detachDatabaseAccount/detachDatabaseAccount'; +import { executeDocumentDBStoredProcedure } from './executeStoredProcedure/executeStoredProcedure'; +import { importDocuments } from './importDocuments/importDocuments'; +import { documentDBLoadMore } from './loadMore/loadMore'; +import { openDocumentDBItem } from './openDocument/openDocument'; +import { openGraphExplorer } from './openGraphExplorer/openGraphExplorer'; +import { openNoSqlQueryEditor } from './openNoSqlQueryEditor/openNoSqlQueryEditor'; +import { openDocumentDBStoredProcedure } from './openStoredProcedure/openStoredProcedure'; +import { openDocumentDBTrigger } from './openTrigger/openTrigger'; +import { refreshTreeElement } from './refreshTreeElement/refreshTreeElement'; +import { viewDocumentDBContainerOffer, viewDocumentDBDatabaseOffer } from './viewOffer/viewOffer'; + +/** + * DISCLAIMER: + * It does not any matter to which category the command belongs to as long as it is a command. + * Today it might be a resource group command, tomorrow it might be a subscription command. + * Therefore, it is better to categorize the command as a command. + * + * However, in this file the commands might be categorized using different functions. + */ + +export function registerCommands(): void { + registerCommandWithTreeNodeUnwrapping('azureDatabases.createServer', createServer); + + registerAccountCommands(); + registerDatabaseCommands(); + registerContainerCommands(); + registerDocumentCommands(); + registerStoredProcedureCommands(); + registerTriggerCommands(); + + // Scrapbooks and old commands + registerDocDBCommands(); + registerMongoCommands(); + registerPostgresCommands(); + + registerCommandWithTreeNodeUnwrapping('azureDatabases.refresh', refreshTreeElement); + + // For DocumentDB FileSystem (Scrapbook) + registerCommandWithTreeNodeUnwrapping( + 'azureDatabases.update', + async (_actionContext: IActionContext, uri: vscode.Uri) => await ext.fileSystem.updateWithoutPrompt(uri), + ); +} + +export function registerAccountCommands() { + registerCommandWithTreeNodeUnwrapping('cosmosDB.createDatabase', createAzureDatabase); + registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteAccount', deleteAzureDatabaseAccount); + registerCommandWithTreeNodeUnwrapping('cosmosDB.attachDatabaseAccount', attachAccount); + registerCommandWithTreeNodeUnwrapping('cosmosDB.attachEmulator', attachEmulator); + registerCommandWithTreeNodeUnwrapping('cosmosDB.detachDatabaseAccount', detachAzureDatabaseAccount); + registerCommandWithTreeNodeUnwrapping('cosmosDB.copyConnectionString', copyAzureConnectionString); +} + +export function registerDatabaseCommands() { + registerCommandWithTreeNodeUnwrapping('cosmosDB.createGraph', createGraph); + registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBContainer', createDocumentDBContainer); + registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDatabase', deleteAzureDatabase); + registerCommandWithTreeNodeUnwrapping('cosmosDB.viewDocDBDatabaseOffer', viewDocumentDBDatabaseOffer); +} + +export function registerContainerCommands() { + registerCommandWithTreeNodeUnwrapping('cosmosDB.openNoSqlQueryEditor', openNoSqlQueryEditor); + registerCommandWithTreeNodeUnwrapping('cosmosDB.importDocument', importDocuments); + registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteGraph', deleteGraph); + registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBContainer', deleteAzureContainer); + registerCommandWithTreeNodeUnwrapping('cosmosDB.viewDocDBContainerOffer', viewDocumentDBContainerOffer); +} + +export function registerDocumentCommands() { + registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBDocument', createDocumentDBDocument); + registerCommandWithTreeNodeUnwrapping('cosmosDB.openGraphExplorer', openGraphExplorer); + registerCommandWithTreeNodeUnwrapping('cosmosDB.openDocument', openDocumentDBItem, doubleClickDebounceDelay); + registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBDocument', deleteDocumentDBItem); + registerCommand('cosmosDB.loadMore', documentDBLoadMore); +} + +export function registerStoredProcedureCommands() { + registerCommandWithTreeNodeUnwrapping( + 'cosmosDB.openStoredProcedure', + openDocumentDBStoredProcedure, + doubleClickDebounceDelay, + ); + registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBStoredProcedure', createDocumentDBStoredProcedure); + registerCommandWithTreeNodeUnwrapping('cosmosDB.executeDocDBStoredProcedure', executeDocumentDBStoredProcedure); + registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBStoredProcedure', deleteDocumentDBStoredProcedure); +} + +export function registerTriggerCommands() { + registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBTrigger', createDocumentDBTrigger); + registerCommandWithTreeNodeUnwrapping('cosmosDB.openTrigger', openDocumentDBTrigger, doubleClickDebounceDelay); + registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBTrigger', deleteDocumentDBTrigger); +} diff --git a/src/commands/viewOffer/viewOffer.ts b/src/commands/viewOffer/viewOffer.ts new file mode 100644 index 000000000..a2180acd4 --- /dev/null +++ b/src/commands/viewOffer/viewOffer.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; +import { type DocumentDBDatabaseResourceItem } from '../../tree/docdb/DocumentDBDatabaseResourceItem'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import * as vscodeUtil from '../../utils/vscodeUtils'; + +export async function viewDocumentDBDatabaseOffer(context: IActionContext, node?: DocumentDBDatabaseResourceItem) { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: 'treeItem.database', + }); + } + + if (!node) { + return undefined; + } + + context.telemetry.properties.experience = node.experience.api; + + const accountInfo = node.model.accountInfo; + const databaseId = node.model.database.id; + const { endpoint, credentials, isEmulator } = accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + const offer = await cosmosClient.database(databaseId).readOffer(); + await vscodeUtil.showNewFile(JSON.stringify(offer.resource, undefined, 2), `offer of ${databaseId}`, '.json'); +} + +export async function viewDocumentDBContainerOffer(context: IActionContext, node?: DocumentDBContainerResourceItem) { + if (!node) { + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: 'treeItem.container', + }); + } + + if (!node) { + return undefined; + } + + context.telemetry.properties.experience = node.experience.api; + + const accountInfo = node.model.accountInfo; + const databaseId = node.model.database.id; + const containerId = node.model.container.id; + const { endpoint, credentials, isEmulator } = accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + + const offer = await cosmosClient.database(databaseId).container(containerId).readOffer(); + if (!offer.resource) { + const dbOffer = await cosmosClient.database(databaseId).readOffer(); + await vscodeUtil.showNewFile(JSON.stringify(dbOffer.resource, undefined, 2), `offer of ${databaseId}`, '.json'); + } else { + await vscodeUtil.showNewFile(JSON.stringify(offer.resource, undefined, 2), `offer of ${containerId}`, '.json'); + } +} diff --git a/src/constants.ts b/src/constants.ts index 97c59147a..094a79c85 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ export const isWindows: boolean = /^win/.test(process.platform); +export const isLinux: boolean = /^linux/.test(process.platform); +export const isMacOS: boolean = /^darwin/.test(process.platform); import * as fs from 'fs'; import assert from 'node:assert'; @@ -93,6 +95,8 @@ export const defaultTrigger = `function trigger() { export const emulatorPassword = 'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=='; +export const isEmulatorSupported = isWindows || isLinux || (isMacOS && process.arch === 'x64'); + // https://docs.mongodb.com/manual/mongo/#working-with-the-mongo-shell export const testDb: string = 'test'; @@ -153,3 +157,5 @@ export const postgresFlexibleFilter = { export const postgresSingleFilter = { type: 'Microsoft.DBForPostgreSQL/servers', }; + +export const DocumentDBHiddenFields: string[] = ['_rid', '_self', '_etag', '_attachments', '_ts']; diff --git a/src/docdb/NoSqlCodeLensProvider.ts b/src/docdb/NoSqlCodeLensProvider.ts index abc3910eb..eb5162046 100644 --- a/src/docdb/NoSqlCodeLensProvider.ts +++ b/src/docdb/NoSqlCodeLensProvider.ts @@ -23,6 +23,7 @@ export type NoSqlQueryConnection = { endpoint: string; masterKey?: string; isEmulator: boolean; + tenantId: string | undefined; }; export const noSqlQueryConnectionKey = 'NO_SQL_QUERY_CONNECTION_KEY.v1'; diff --git a/src/docdb/commands/connectNoSqlContainer.ts b/src/docdb/commands/connectNoSqlContainer.ts index ceddbf648..670d6afb3 100644 --- a/src/docdb/commands/connectNoSqlContainer.ts +++ b/src/docdb/commands/connectNoSqlContainer.ts @@ -3,42 +3,27 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; import { KeyValueStore } from '../../KeyValueStore'; import { ext } from '../../extensionVariables'; -import { noSqlQueryConnectionKey, type NoSqlQueryConnection } from '../NoSqlCodeLensProvider'; -import { getCosmosKeyCredential } from '../getCosmosClient'; -import { DocDBCollectionTreeItem } from '../tree/DocDBCollectionTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; +import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import { noSqlQueryConnectionKey } from '../NoSqlCodeLensProvider'; +import { createNoSqlQueryConnection } from '../utils/NoSqlQueryConnection'; -export function createNoSqlQueryConnection(node: DocDBCollectionTreeItem): NoSqlQueryConnection { - const root = node.root; - const keyCred = getCosmosKeyCredential(root.credentials); - return { - databaseId: node.parent.id, - containerId: node.id, - endpoint: root.endpoint, - masterKey: keyCred?.key, - isEmulator: !!root.isEmulator, - }; -} - -export function setConnectedNoSqlContainer(node: DocDBCollectionTreeItem): void { +export function setConnectedNoSqlContainer(node: DocumentDBContainerResourceItem): void { const noSqlQueryConnection = createNoSqlQueryConnection(node); KeyValueStore.instance.set(noSqlQueryConnectionKey, noSqlQueryConnection); ext.noSqlCodeLensProvider.updateCodeLens(); } export async function connectNoSqlContainer(context: IActionContext): Promise { - const node = await pickDocDBAccount(context, DocDBCollectionTreeItem.contextValue); - setConnectedNoSqlContainer(node); -} - -export async function getNoSqlQueryConnection(): Promise { - return callWithTelemetryAndErrorHandling('cosmosDB.connectToDatabase', async (context) => { - const node = await pickDocDBAccount(context, DocDBCollectionTreeItem.contextValue); - return createNoSqlQueryConnection(node); + const node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.container'], }); + setConnectedNoSqlContainer(node); } export async function disconnectNoSqlContainer(): Promise { diff --git a/src/docdb/commands/createDocDBDatabase.ts b/src/docdb/commands/createDocDBDatabase.ts deleted file mode 100644 index 3b95b5ce5..000000000 --- a/src/docdb/commands/createDocDBDatabase.ts +++ /dev/null @@ -1,17 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { type DocDBAccountTreeItem } from '../tree/DocDBAccountTreeItem'; -import { type DocDBDatabaseTreeItem } from '../tree/DocDBDatabaseTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function createDocDBDatabase(context: IActionContext, node?: DocDBAccountTreeItem): Promise { - if (!node) { - node = await pickDocDBAccount(context); - } - const databaseNode: DocDBDatabaseTreeItem = await node.createChild(context); - await databaseNode.createChild(context); -} diff --git a/src/docdb/commands/createDocDBDocument.ts b/src/docdb/commands/createDocDBDocument.ts deleted file mode 100644 index 12fe7cefa..000000000 --- a/src/docdb/commands/createDocDBDocument.ts +++ /dev/null @@ -1,30 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { ViewColumn } from 'vscode'; -import { DocumentTab } from '../../panels/DocumentTab'; -import { DocDBCollectionTreeItem } from '../tree/DocDBCollectionTreeItem'; -import { type DocDBDocumentsTreeItem } from '../tree/DocDBDocumentsTreeItem'; -import { createNoSqlQueryConnection } from './connectNoSqlContainer'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function createDocDBDocument(context: IActionContext, node?: DocDBDocumentsTreeItem): Promise { - let collectionNode: DocDBCollectionTreeItem | undefined; - - if (!node) { - collectionNode = await pickDocDBAccount(context, DocDBCollectionTreeItem.contextValue); - } else { - collectionNode = node.parent; - } - - const connection = collectionNode ? createNoSqlQueryConnection(collectionNode) : undefined; - - if (!connection) { - return; - } - - DocumentTab.render(connection, 'add', undefined, ViewColumn.Active); -} diff --git a/src/docdb/commands/createDocDBStoredProcedure.ts b/src/docdb/commands/createDocDBStoredProcedure.ts deleted file mode 100644 index 27406c801..000000000 --- a/src/docdb/commands/createDocDBStoredProcedure.ts +++ /dev/null @@ -1,23 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { commands } from 'vscode'; -import { DocDBStoredProceduresTreeItem } from '../tree/DocDBStoredProceduresTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function createDocDBStoredProcedure( - context: IActionContext, - node?: DocDBStoredProceduresTreeItem, -): Promise { - if (!node) { - node = await pickDocDBAccount( - context, - DocDBStoredProceduresTreeItem.contextValue, - ); - } - const childNode = await node.createChild(context); - await commands.executeCommand('cosmosDB.openStoredProcedure', childNode); -} diff --git a/src/docdb/commands/createDocDBTrigger.ts b/src/docdb/commands/createDocDBTrigger.ts deleted file mode 100644 index 718d9e827..000000000 --- a/src/docdb/commands/createDocDBTrigger.ts +++ /dev/null @@ -1,17 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { commands } from 'vscode'; -import { DocDBTriggersTreeItem } from '../tree/DocDBTriggersTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function createDocDBTrigger(context: IActionContext, node?: DocDBTriggersTreeItem): Promise { - if (!node) { - node = await pickDocDBAccount(context, DocDBTriggersTreeItem.contextValue); - } - const childNode = await node.createChild(context); - await commands.executeCommand('cosmosDB.openTrigger', childNode); -} diff --git a/src/docdb/commands/deleteDocDBCollection.ts b/src/docdb/commands/deleteDocDBCollection.ts deleted file mode 100644 index 94f0701e0..000000000 --- a/src/docdb/commands/deleteDocDBCollection.ts +++ /dev/null @@ -1,17 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { DocDBCollectionTreeItem } from '../tree/DocDBCollectionTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function deleteDocDBCollection(context: IActionContext, node?: DocDBCollectionTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickDocDBAccount(context, DocDBCollectionTreeItem.contextValue); - } - await node.deleteTreeItem(context); -} diff --git a/src/docdb/commands/deleteDocDBDatabase.ts b/src/docdb/commands/deleteDocDBDatabase.ts deleted file mode 100644 index b947cbb18..000000000 --- a/src/docdb/commands/deleteDocDBDatabase.ts +++ /dev/null @@ -1,23 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { ext } from '../../extensionVariables'; -import { localize } from '../../utils/localize'; -import { DocDBDatabaseTreeItem } from '../tree/DocDBDatabaseTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function deleteDocDBDatabase(context: IActionContext, node?: DocDBDatabaseTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickDocDBAccount(context, DocDBDatabaseTreeItem.contextValue); - } - await node.deleteTreeItem(context); - const successMessage = localize('deleteMongoDatabaseMsg', 'Successfully deleted database "{0}"', node.databaseName); - void vscode.window.showInformationMessage(successMessage); - ext.outputChannel.info(successMessage); -} diff --git a/src/docdb/commands/deleteDocDBDocument.ts b/src/docdb/commands/deleteDocDBDocument.ts deleted file mode 100644 index 02b2a9ea2..000000000 --- a/src/docdb/commands/deleteDocDBDocument.ts +++ /dev/null @@ -1,17 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { DocDBDocumentTreeItem } from '../tree/DocDBDocumentTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function deleteDocDBDocument(context: IActionContext, node?: DocDBDocumentTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickDocDBAccount(context, DocDBDocumentTreeItem.contextValue); - } - await node.deleteTreeItem(context); -} diff --git a/src/docdb/commands/deleteDocDBStoredProcedure.ts b/src/docdb/commands/deleteDocDBStoredProcedure.ts deleted file mode 100644 index 88c6b7f52..000000000 --- a/src/docdb/commands/deleteDocDBStoredProcedure.ts +++ /dev/null @@ -1,20 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { DocDBStoredProcedureTreeItem } from '../tree/DocDBStoredProcedureTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function deleteDocDBStoredProcedure( - context: IActionContext, - node?: DocDBStoredProcedureTreeItem, -): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickDocDBAccount(context, DocDBStoredProcedureTreeItem.contextValue); - } - await node.deleteTreeItem(context); -} diff --git a/src/docdb/commands/deleteDocDBTrigger.ts b/src/docdb/commands/deleteDocDBTrigger.ts deleted file mode 100644 index 85d6f2f6e..000000000 --- a/src/docdb/commands/deleteDocDBTrigger.ts +++ /dev/null @@ -1,17 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { DocDBTriggerTreeItem } from '../tree/DocDBTriggerTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function deleteDocDBTrigger(context: IActionContext, node?: DocDBTriggerTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickDocDBAccount(context, DocDBTriggerTreeItem.contextValue); - } - await node.deleteTreeItem(context); -} diff --git a/src/docdb/commands/executeDocDBStoredProcedure.ts b/src/docdb/commands/executeDocDBStoredProcedure.ts deleted file mode 100644 index 03f39b838..000000000 --- a/src/docdb/commands/executeDocDBStoredProcedure.ts +++ /dev/null @@ -1,45 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../utils/localize'; -import { DocDBStoredProcedureTreeItem } from '../tree/DocDBStoredProcedureTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function executeDocDBStoredProcedure( - context: IActionContext, - node?: DocDBStoredProcedureTreeItem, -): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickDocDBAccount(context, DocDBStoredProcedureTreeItem.contextValue); - } - - const partitionKey = await context.ui.showInputBox({ - title: 'Partition Key', - // @todo: add a learnMoreLink - }); - - const paramString = await context.ui.showInputBox({ - title: 'Parameters', - placeHolder: localize( - 'executeCosmosStoredProcedureParameters', - 'empty or array of values e.g. [1, {key: value}]', - ), - // @todo: add a learnMoreLink - }); - - let parameters: (string | number | object)[] | undefined = undefined; - if (paramString !== '') { - try { - parameters = JSON.parse(paramString) as (string | number | object)[]; - } catch { - // Ignore parameters if they are invalid - } - } - - await node.execute(context, partitionKey, parameters); -} diff --git a/src/docdb/commands/executeNoSqlQuery.ts b/src/docdb/commands/executeNoSqlQuery.ts index 24790c1bb..c10690bb7 100644 --- a/src/docdb/commands/executeNoSqlQuery.ts +++ b/src/docdb/commands/executeNoSqlQuery.ts @@ -37,13 +37,13 @@ export async function executeNoSqlQuery( ); } else { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { databaseId, containerId, endpoint, masterKey, isEmulator } = + const { databaseId, containerId, endpoint, masterKey, isEmulator, tenantId } = connectedCollection as NoSqlQueryConnection; const credentials: CosmosDBCredential[] = []; if (masterKey !== undefined) { credentials.push({ type: 'key', key: masterKey }); } - credentials.push({ type: 'auth' }); + credentials.push({ type: 'auth', tenantId: tenantId }); const client = getCosmosClient(endpoint, credentials, isEmulator); const options = { populateQueryMetrics }; const response = await client diff --git a/src/docdb/commands/getNoSqlQueryPlan.ts b/src/docdb/commands/getNoSqlQueryPlan.ts index 5a93ed73b..b2bdf0c0f 100644 --- a/src/docdb/commands/getNoSqlQueryPlan.ts +++ b/src/docdb/commands/getNoSqlQueryPlan.ts @@ -32,13 +32,13 @@ export async function getNoSqlQueryPlan( throw new Error('Unable to get query plan due to missing node data. Please connect to a Cosmos DB collection.'); } else { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { databaseId, containerId, endpoint, masterKey, isEmulator } = + const { databaseId, containerId, endpoint, masterKey, isEmulator, tenantId } = connectedCollection as NoSqlQueryConnection; const credentials: CosmosDBCredential[] = []; if (masterKey !== undefined) { credentials.push({ type: 'key', key: masterKey }); } - credentials.push({ type: 'auth' }); + credentials.push({ type: 'auth', tenantId: tenantId }); const client = getCosmosClient(endpoint, credentials, isEmulator); const response = await client.database(databaseId).container(containerId).getQueryPlan(queryText); await vscodeUtil.showNewFile( diff --git a/src/docdb/commands/openNoSqlQueryEditor.ts b/src/docdb/commands/openNoSqlQueryEditor.ts deleted file mode 100644 index f9d22f5e2..000000000 --- a/src/docdb/commands/openNoSqlQueryEditor.ts +++ /dev/null @@ -1,23 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { QueryEditorTab } from '../../panels/QueryEditorTab'; -import { type DocDBCollectionTreeItem } from '../tree/DocDBCollectionTreeItem'; -import { DocDBDocumentsTreeItem } from '../tree/DocDBDocumentsTreeItem'; -import { createNoSqlQueryConnection } from './connectNoSqlContainer'; - -export const openNoSqlQueryEditor = ( - _context: IActionContext, - node?: DocDBCollectionTreeItem | DocDBDocumentsTreeItem, -) => { - const connection = node - ? node instanceof DocDBDocumentsTreeItem - ? createNoSqlQueryConnection(node.parent) - : createNoSqlQueryConnection(node) - : undefined; - - QueryEditorTab.render(connection); -}; diff --git a/src/docdb/commands/openStoredProcedure.ts b/src/docdb/commands/openStoredProcedure.ts deleted file mode 100644 index 6a788a811..000000000 --- a/src/docdb/commands/openStoredProcedure.ts +++ /dev/null @@ -1,16 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { ext } from '../../extensionVariables'; -import { DocDBStoredProcedureTreeItem } from '../tree/DocDBStoredProcedureTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function openStoredProcedure(context: IActionContext, node?: DocDBStoredProcedureTreeItem): Promise { - if (!node) { - node = await pickDocDBAccount(context, DocDBStoredProcedureTreeItem.contextValue); - } - await ext.fileSystem.showTextDocument(node); -} diff --git a/src/docdb/commands/pickDocDBAccount.ts b/src/docdb/commands/pickDocDBAccount.ts deleted file mode 100644 index 0adf3419c..000000000 --- a/src/docdb/commands/pickDocDBAccount.ts +++ /dev/null @@ -1,18 +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 { type AzExtTreeItem, type IActionContext } from '@microsoft/vscode-azext-utils'; -import { sqlFilter } from '../../constants'; -import { ext } from '../../extensionVariables'; - -export async function pickDocDBAccount( - context: IActionContext, - expectedContextValue?: string | RegExp | (string | RegExp)[], -): Promise { - return await ext.rgApi.pickAppResource(context, { - filter: [sqlFilter], - expectedChildContextValue: expectedContextValue, - }); -} diff --git a/src/docdb/commands/viewDocDBCollectionOffer.ts b/src/docdb/commands/viewDocDBCollectionOffer.ts deleted file mode 100644 index 6f612974f..000000000 --- a/src/docdb/commands/viewDocDBCollectionOffer.ts +++ /dev/null @@ -1,30 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import * as vscodeUtil from '../../utils/vscodeUtils'; -import { DocDBCollectionTreeItem } from '../tree/DocDBCollectionTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function viewDocDBCollectionOffer(context: IActionContext, node?: DocDBCollectionTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickDocDBAccount(context, DocDBCollectionTreeItem.contextValue); - } - const client = node.root.getCosmosClient(); - let offer = await node.getContainerClient(client).readOffer(); - if (!offer.resource) { - // collections with shared throughput will not have an offer, show the database offer instead - offer = await node.parent.getDatabaseClient(client).readOffer(); - await vscodeUtil.showNewFile( - JSON.stringify(offer.resource, undefined, 2), - `offer of ${node.parent.label}`, - '.json', - ); - } else { - await vscodeUtil.showNewFile(JSON.stringify(offer.resource, undefined, 2), `offer of ${node.label}`, '.json'); - } -} diff --git a/src/docdb/commands/viewDocDBDatabaseOffer.ts b/src/docdb/commands/viewDocDBDatabaseOffer.ts deleted file mode 100644 index c4ab8432c..000000000 --- a/src/docdb/commands/viewDocDBDatabaseOffer.ts +++ /dev/null @@ -1,20 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import * as vscodeUtil from '../../utils/vscodeUtils'; -import { DocDBDatabaseTreeItem } from '../tree/DocDBDatabaseTreeItem'; -import { pickDocDBAccount } from './pickDocDBAccount'; - -export async function viewDocDBDatabaseOffer(context: IActionContext, node?: DocDBDatabaseTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickDocDBAccount(context, DocDBDatabaseTreeItem.contextValue); - } - const client = node.root.getCosmosClient(); - const offer = await node.getDatabaseClient(client).readOffer(); - await vscodeUtil.showNewFile(JSON.stringify(offer.resource, undefined, 2), `offer of ${node.label}`, '.json'); -} diff --git a/src/docdb/commands/writeNoSqlQuery.ts b/src/docdb/commands/writeNoSqlQuery.ts index 4856797f4..34bea7789 100644 --- a/src/docdb/commands/writeNoSqlQuery.ts +++ b/src/docdb/commands/writeNoSqlQuery.ts @@ -4,16 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { type DocumentDBContainerResourceItem } from '../../tree/docdb/DocumentDBContainerResourceItem'; +import { pickAppResource } from '../../utils/pickItem/pickAppResource'; import * as vscodeUtil from '../../utils/vscodeUtils'; -import { DocDBCollectionTreeItem } from '../tree/DocDBCollectionTreeItem'; import { setConnectedNoSqlContainer } from './connectNoSqlContainer'; -import { pickDocDBAccount } from './pickDocDBAccount'; -export async function writeNoSqlQuery(context: IActionContext, node?: DocDBCollectionTreeItem): Promise { +export async function writeNoSqlQuery(context: IActionContext, node?: DocumentDBContainerResourceItem): Promise { if (!node) { - node = await pickDocDBAccount(context, DocDBCollectionTreeItem.contextValue); + node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.container'], + }); } setConnectedNoSqlContainer(node); - const sampleQuery = `SELECT * FROM ${node.id}`; - await vscodeUtil.showNewFile(sampleQuery, `query for ${node.label}`, '.nosql'); + const sampleQuery = `SELECT * FROM ${node.model.container.id}`; + await vscodeUtil.showNewFile(sampleQuery, `query for ${node.model.container.id}`, '.nosql'); } diff --git a/src/docdb/docDBConnectionStrings.ts b/src/docdb/docDBConnectionStrings.ts index 946490d21..dd0f5452b 100644 --- a/src/docdb/docDBConnectionStrings.ts +++ b/src/docdb/docDBConnectionStrings.ts @@ -11,9 +11,11 @@ export function parseDocDBConnectionString(connectionString: string): ParsedDocD const endpoint = getPropertyFromConnectionString(connectionString, 'AccountEndpoint'); const masterKey = getPropertyFromConnectionString(connectionString, 'AccountKey'); const databaseName = getPropertyFromConnectionString(connectionString, 'Database'); + if (!endpoint || !masterKey) { throw new Error('Invalid Document DB connection string.'); } + return new ParsedDocDBConnectionString(connectionString, endpoint, masterKey, databaseName); } @@ -36,7 +38,7 @@ export class ParsedDocDBConnectionString extends ParsedConnectionString { this.masterKey = masterKey; const parsedEndpoint = url.parse(endpoint); - this.hostName = nonNullProp(parsedEndpoint, 'hostname'); - this.port = nonNullProp(parsedEndpoint, 'port'); + this.hostName = nonNullProp(parsedEndpoint, 'hostname', 'hostname'); + this.port = nonNullProp(parsedEndpoint, 'port', 'port'); } } diff --git a/src/docdb/fs/DocumentFileDescriptor.ts b/src/docdb/fs/DocumentFileDescriptor.ts new file mode 100644 index 000000000..da9a1627b --- /dev/null +++ b/src/docdb/fs/DocumentFileDescriptor.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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 { getDocumentTreeItemLabel } from '../../utils/vscodeUtils'; +import { getCosmosClient } from '../getCosmosClient'; + +export class DocumentFileDescriptor implements EditableFileSystemItem { + public readonly cTime: number = Date.now(); + public mTime: number = Date.now(); + + constructor( + public readonly id: string, + public readonly model: DocumentDBItemModel, + public readonly experience: Experience, + ) {} + + public get filePath(): string { + return getDocumentTreeItemLabel(this.model.item) + '-cosmos-document.json'; + } + + public getFileContent(): Promise { + const clonedDoc: ItemDefinition = { ...this.model.item }; + + // TODO: Why user can't change/see them? + for (const field of DocumentDBHiddenFields) { + delete clonedDoc[field]; + } + + return Promise.resolve(JSON.stringify(clonedDoc, null, 2)); + } + + public async writeFileContent(_context: IActionContext, content: string): Promise { + const newData: JSONValue = JSON.parse(content) as JSONValue; + + if (typeof newData !== 'object' || newData === null) { + throw new Error('The document content is not a valid JSON object'); + } + + if (!newData['id'] || typeof newData['id'] !== 'string') { + throw new Error('The "id" field is required to update a document'); + } + + // 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]; + } + + // 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`); + } + + 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); + + if (response.resource) { + this.model.item = response.resource; + } else { + throw new Error('Failed to update the document'); + } + } +} diff --git a/src/docdb/fs/StoredProcedureFileDescriptor.ts b/src/docdb/fs/StoredProcedureFileDescriptor.ts new file mode 100644 index 000000000..33f2eb646 --- /dev/null +++ b/src/docdb/fs/StoredProcedureFileDescriptor.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; + +export class StoredProcedureFileDescriptor implements EditableFileSystemItem { + public readonly cTime: number = Date.now(); + public mTime: number = Date.now(); + + constructor( + public readonly id: string, + public readonly model: DocumentDBStoredProcedureModel, + public readonly experience: Experience, + ) {} + + public get filePath(): string { + return this.model.procedure.id + '-cosmos-stored-procedure.js'; + } + + public getFileContent(): Promise { + return Promise.resolve(typeof this.model.procedure.body === 'string' ? this.model.procedure.body : ''); + } + + public async writeFileContent(_context: IActionContext, content: string): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const replace = await cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .scripts.storedProcedure(this.model.procedure.id) + .replace({ id: this.model.procedure.id, body: content }); + this.model.procedure = nonNullProp(replace, 'resource'); + } +} diff --git a/src/docdb/fs/TriggerFileDescriptor.ts b/src/docdb/fs/TriggerFileDescriptor.ts new file mode 100644 index 000000000..9517723d7 --- /dev/null +++ b/src/docdb/fs/TriggerFileDescriptor.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; + +export async function getTriggerType(context: IActionContext): Promise { + const options = Object.keys(TriggerType).map((type) => ({ label: type })); + const triggerTypeOption = await context.ui.showQuickPick(options, { + placeHolder: localize('createDocDBTriggerSelectType', 'Select the trigger type'), + }); + return triggerTypeOption.label === 'Pre' ? TriggerType.Pre : TriggerType.Post; +} + +export async function getTriggerOperation(context: IActionContext): Promise { + const options = Object.keys(TriggerOperation).map((key) => ({ label: key })); + const triggerOperationOption = await context.ui.showQuickPick(options, { + placeHolder: localize('createDocDBTriggerSelectOperation', 'Select the trigger operation'), + }); + return TriggerOperation[triggerOperationOption.label as keyof typeof TriggerOperation]; +} + +export class TriggerFileDescriptor implements EditableFileSystemItem { + public readonly cTime: number = Date.now(); + public mTime: number = Date.now(); + + constructor( + public readonly id: string, + public readonly model: DocumentDBTriggerModel, + public readonly experience: Experience, + ) {} + + public get filePath(): string { + return this.model.trigger.id + '-cosmos-trigger.js'; + } + + public getFileContent(): Promise { + return Promise.resolve(typeof this.model.trigger.body === 'string' ? this.model.trigger.body : ''); + } + + public async writeFileContent(context: IActionContext, content: string): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const readResponse = await cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .scripts.trigger(this.model.trigger.id) + .read(); + + let triggerType = readResponse.resource?.triggerType; + let triggerOperation = readResponse.resource?.triggerOperation; + + if (!triggerType) { + triggerType = await getTriggerType(context); + } + if (!triggerOperation) { + triggerOperation = await getTriggerOperation(context); + } + + const replace = await cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .scripts.trigger(this.model.trigger.id) + .replace({ + id: this.model.trigger.id, + triggerType: triggerType, + triggerOperation: triggerOperation, + body: content, + }); + this.model.trigger = nonNullProp(replace, 'resource'); + } +} diff --git a/src/docdb/getCosmosClient.ts b/src/docdb/getCosmosClient.ts index 711904fa9..3744b1977 100644 --- a/src/docdb/getCosmosClient.ts +++ b/src/docdb/getCosmosClient.ts @@ -20,6 +20,7 @@ export type CosmosDBKeyCredential = { export type CosmosDBAuthCredential = { type: 'auth'; + tenantId: string | undefined; }; export type CosmosDBCredential = CosmosDBKeyCredential | CosmosDBAuthCredential; @@ -36,7 +37,7 @@ export function getCosmosClientByConnection( connection: NoSqlQueryConnection, options?: Partial, ): CosmosClient { - const { endpoint, masterKey, isEmulator } = connection; + const { endpoint, masterKey, isEmulator, tenantId } = connection; const vscodeStrictSSL: boolean | undefined = vscode.workspace .getConfiguration() @@ -59,7 +60,7 @@ export function getCosmosClientByConnection( } else { commonProperties.aadCredentials = { getToken: async (scopes, _options) => { - const session = await getSessionFromVSCode(scopes, undefined, { createIfNone: true }); + const session = await getSessionFromVSCode(scopes, tenantId, { createIfNone: true }); return { token: session?.accessToken ?? '', expiresOnTimestamp: 0, @@ -106,7 +107,7 @@ export function getCosmosClient( ...commonProperties, aadCredentials: { getToken: async (scopes, _options) => { - const session = await getSessionFromVSCode(scopes, undefined, { createIfNone: true }); + const session = await getSessionFromVSCode(scopes, authCred.tenantId, { createIfNone: true }); return { token: session?.accessToken ?? '', expiresOnTimestamp: 0, diff --git a/src/docdb/registerDocDBCommands.ts b/src/docdb/registerDocDBCommands.ts index e7df94814..d00b3b60c 100644 --- a/src/docdb/registerDocDBCommands.ts +++ b/src/docdb/registerDocDBCommands.ts @@ -5,27 +5,10 @@ import { registerCommand, registerCommandWithTreeNodeUnwrapping } from '@microsoft/vscode-azext-utils'; import { languages } from 'vscode'; -import { doubleClickDebounceDelay } from '../constants'; import { ext } from '../extensionVariables'; import { connectNoSqlContainer } from './commands/connectNoSqlContainer'; -import { createDocDBCollection } from './commands/createDocDBCollection'; -import { createDocDBDatabase } from './commands/createDocDBDatabase'; -import { createDocDBDocument } from './commands/createDocDBDocument'; -import { createDocDBStoredProcedure } from './commands/createDocDBStoredProcedure'; -import { createDocDBTrigger } from './commands/createDocDBTrigger'; -import { deleteDocDBCollection } from './commands/deleteDocDBCollection'; -import { deleteDocDBDatabase } from './commands/deleteDocDBDatabase'; -import { deleteDocDBDocument } from './commands/deleteDocDBDocument'; -import { deleteDocDBStoredProcedure } from './commands/deleteDocDBStoredProcedure'; -import { deleteDocDBTrigger } from './commands/deleteDocDBTrigger'; -import { executeDocDBStoredProcedure } from './commands/executeDocDBStoredProcedure'; import { executeNoSqlQuery } from './commands/executeNoSqlQuery'; import { getNoSqlQueryPlan } from './commands/getNoSqlQueryPlan'; -import { openNoSqlQueryEditor } from './commands/openNoSqlQueryEditor'; -import { openStoredProcedure } from './commands/openStoredProcedure'; -import { openTrigger } from './commands/openTrigger'; -import { viewDocDBCollectionOffer } from './commands/viewDocDBCollectionOffer'; -import { viewDocDBDatabaseOffer } from './commands/viewDocDBDatabaseOffer'; import { writeNoSqlQuery } from './commands/writeNoSqlQuery'; import { NoSqlCodeLensProvider } from './NoSqlCodeLensProvider'; @@ -35,73 +18,10 @@ export function registerDocDBCommands(): void { ext.noSqlCodeLensProvider = new NoSqlCodeLensProvider(); ext.context.subscriptions.push(languages.registerCodeLensProvider(nosqlLanguageId, ext.noSqlCodeLensProvider)); + // # region Scrapbook command + registerCommandWithTreeNodeUnwrapping('cosmosDB.writeNoSqlQuery', writeNoSqlQuery); registerCommand('cosmosDB.connectNoSqlContainer', connectNoSqlContainer); registerCommand('cosmosDB.executeNoSqlQuery', executeNoSqlQuery); registerCommand('cosmosDB.getNoSqlQueryPlan', getNoSqlQueryPlan); - - // #region Account command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBDatabase', createDocDBDatabase); - - // #endregion - - // #region Database command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBCollection', createDocDBCollection); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBDatabase', deleteDocDBDatabase); - registerCommandWithTreeNodeUnwrapping('cosmosDB.viewDocDBDatabaseOffer', viewDocDBDatabaseOffer); - - // #endregion - - // #region Collection command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.writeNoSqlQuery', writeNoSqlQuery); - registerCommandWithTreeNodeUnwrapping('cosmosDB.openNoSqlQueryEditor', openNoSqlQueryEditor); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBCollection', deleteDocDBCollection); - registerCommandWithTreeNodeUnwrapping('cosmosDB.viewDocDBCollectionOffer', viewDocDBCollectionOffer); - - // #endregion - - // #region DocumentGroup command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBDocument', createDocDBDocument); - - // #endregion - - // #region Document command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBDocument', deleteDocDBDocument); - - // #endregion - - // #region StoredProcedureGroup command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBStoredProcedure', createDocDBStoredProcedure); - - // #endregion - - // #region StoredProcedure command - - registerCommandWithTreeNodeUnwrapping( - 'cosmosDB.openStoredProcedure', - openStoredProcedure, - doubleClickDebounceDelay, - ); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBStoredProcedure', deleteDocDBStoredProcedure); - registerCommandWithTreeNodeUnwrapping('cosmosDB.executeDocDBStoredProcedure', executeDocDBStoredProcedure); - - // #endregion - - // #region TriggerGroup command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.createDocDBTrigger', createDocDBTrigger); - - // #endregion - - // #region Trigger command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.openTrigger', openTrigger, doubleClickDebounceDelay); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteDocDBTrigger', deleteDocDBTrigger); - // #endregion } diff --git a/src/docdb/session/DocumentSession.ts b/src/docdb/session/DocumentSession.ts index 0f2fc2510..6a0d65439 100644 --- a/src/docdb/session/DocumentSession.ts +++ b/src/docdb/session/DocumentSession.ts @@ -40,12 +40,12 @@ export class DocumentSession { private isDisposed = false; constructor(connection: NoSqlQueryConnection, channel: Channel) { - const { databaseId, containerId, endpoint, masterKey, isEmulator } = connection; + const { databaseId, containerId, endpoint, masterKey, isEmulator, tenantId } = connection; const credentials: CosmosDBCredential[] = []; if (masterKey !== undefined) { credentials.push({ type: 'key', key: masterKey }); } - credentials.push({ type: 'auth' }); + credentials.push({ type: 'auth', tenantId: tenantId }); this.id = uuid(); this.channel = channel; diff --git a/src/docdb/tree/DocDBAccountTreeItem.ts b/src/docdb/tree/DocDBAccountTreeItem.ts deleted file mode 100644 index ea6ceceb3..000000000 --- a/src/docdb/tree/DocDBAccountTreeItem.ts +++ /dev/null @@ -1,37 +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 { type DatabaseDefinition, type Resource } from '@azure/cosmos'; -import { DocDBAccountTreeItemBase } from './DocDBAccountTreeItemBase'; -import { DocDBCollectionTreeItem } from './DocDBCollectionTreeItem'; -import { DocDBDatabaseTreeItem } from './DocDBDatabaseTreeItem'; -import { DocDBDocumentsTreeItem } from './DocDBDocumentsTreeItem'; -import { DocDBDocumentTreeItem } from './DocDBDocumentTreeItem'; -import { DocDBStoredProceduresTreeItem } from './DocDBStoredProceduresTreeItem'; -import { DocDBStoredProcedureTreeItem } from './DocDBStoredProcedureTreeItem'; - -export class DocDBAccountTreeItem extends DocDBAccountTreeItemBase { - public static contextValue: string = 'cosmosDBDocumentServer'; - public contextValue: string = DocDBAccountTreeItem.contextValue; - - public initChild(resource: DatabaseDefinition & Resource): DocDBDatabaseTreeItem { - this.valuesToMask.push(resource._rid, resource._self); - return new DocDBDatabaseTreeItem(this, resource); - } - - public isAncestorOfImpl(contextValue: string): boolean { - switch (contextValue) { - case DocDBDatabaseTreeItem.contextValue: - case DocDBCollectionTreeItem.contextValue: - case DocDBDocumentTreeItem.contextValue: - case DocDBStoredProcedureTreeItem.contextValue: - case DocDBDocumentsTreeItem.contextValue: - case DocDBStoredProceduresTreeItem.contextValue: - return true; - default: - return false; - } - } -} diff --git a/src/docdb/tree/DocDBAccountTreeItemBase.ts b/src/docdb/tree/DocDBAccountTreeItemBase.ts deleted file mode 100644 index 716da8e40..000000000 --- a/src/docdb/tree/DocDBAccountTreeItemBase.ts +++ /dev/null @@ -1,171 +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 { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb/src/models'; -import { - type CosmosClient, - type DatabaseDefinition, - type DatabaseResponse, - type FeedOptions, - type QueryIterator, - type Resource, -} from '@azure/cosmos'; -import { - callWithTelemetryAndErrorHandling, - type AzExtParentTreeItem, - type AzExtTreeItem, - type IActionContext, - type ICreateChildImplContext, -} from '@microsoft/vscode-azext-utils'; -import type * as vscode from 'vscode'; -import { API } from '../../AzureDBExperiences'; -import { type IDeleteWizardContext } from '../../commands/deleteDatabaseAccount/IDeleteWizardContext'; -import { deleteCosmosDBAccount } from '../../commands/deleteDatabaseAccount/deleteCosmosDBAccount'; -import { getThemeAgnosticIconPath, SERVERLESS_CAPABILITY_NAME } from '../../constants'; -import { nonNullProp } from '../../utils/nonNull'; -import { rejectOnTimeout } from '../../utils/timeout'; -import { getCosmosClient, getCosmosKeyCredential, type CosmosDBCredential } from '../getCosmosClient'; -import { getSignedInPrincipalIdForAccountEndpoint } from '../utils/azureSessionHelper'; -import { ensureRbacPermission, isRbacException, showRbacPermissionError } from '../utils/rbacUtils'; -import { DocDBTreeItemBase } from './DocDBTreeItemBase'; - -/** - * This class provides common logic for DocumentDB, Graph, and Table accounts - * (DocumentDB is the base type for all Cosmos DB accounts) - */ -export abstract class DocDBAccountTreeItemBase extends DocDBTreeItemBase { - public readonly label: string; - public readonly childTypeLabel: string = 'Database'; - private hasShownRbacNotification: boolean = false; - - constructor( - parent: AzExtParentTreeItem, - id: string, - label: string, - endpoint: string, - credentials: CosmosDBCredential[], - isEmulator: boolean | undefined, - readonly databaseAccount?: DatabaseAccountGetResults, - ) { - super(parent); - this.id = id; - this.label = label; - this.root = { - endpoint, - credentials, - isEmulator, - getCosmosClient: () => getCosmosClient(endpoint, credentials, isEmulator), - }; - - const keys = credentials - .map((cred) => (cred.type === 'key' ? cred.key : undefined)) - .filter((value): value is string => value !== undefined); - this.valuesToMask.push(id, endpoint, ...keys); - } - - public get connectionString(): string { - const firstKey = getCosmosKeyCredential(this.root.credentials); - if (firstKey) { - return `AccountEndpoint=${this.root.endpoint};AccountKey=${firstKey.key}`; - } else { - return `AccountEndpoint=${this.root.endpoint}`; - } - } - - public get iconPath(): string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } { - return getThemeAgnosticIconPath('CosmosDBAccount.svg'); - } - - public get isServerless(): boolean { - return this.databaseAccount?.capabilities - ? this.databaseAccount.capabilities.some((cap) => cap.name === SERVERLESS_CAPABILITY_NAME) - : false; - } - - public getIterator(client: CosmosClient, feedOptions: FeedOptions): QueryIterator { - return client.databases.readAll(feedOptions); - } - - public async createChildImpl(context: ICreateChildImplContext): Promise { - const databaseName = await context.ui.showInputBox({ - placeHolder: 'Database Name', - validateInput: validateDatabaseName, - stepName: 'createDatabase', - }); - - const client = this.root.getCosmosClient(); - const database: DatabaseResponse = await client.databases.create({ id: databaseName }); - return this.initChild(nonNullProp(database, 'resource')); - } - - public async loadMoreChildrenImpl(clearCache: boolean): Promise { - const result = await callWithTelemetryAndErrorHandling( - 'getChildren', - async (context: IActionContext): Promise => { - context.telemetry.properties.parentNodeContext = this.contextValue; - - // move this to a shared file, currently it's defined in DocDBAccountTreeItem so I can't reference it here - if (this.contextValue.includes('cosmosDBDocumentServer')) { - context.telemetry.properties.experience = API.Core; - // same issue as above - } else if (this.contextValue.includes('cosmosDBGraphAccount')) { - context.telemetry.properties.experience = API.Graph; - // same issue as above - } else if (this.contextValue.includes('cosmosDBTableAccount')) { - context.telemetry.properties.experience = API.Table; - } - - if (this.root.isEmulator) { - const unableToReachEmulatorMessage: string = - "Unable to reach emulator. Please ensure it is started and connected to the port specified by the 'cosmosDB.emulator.port' setting, then try again."; - return await rejectOnTimeout( - 2000, - () => super.loadMoreChildrenImpl(clearCache), - unableToReachEmulatorMessage, - ); - } else { - try { - return await super.loadMoreChildrenImpl(clearCache); - } catch (e) { - if (e instanceof Error && isRbacException(e) && !this.hasShownRbacNotification) { - this.hasShownRbacNotification = true; - const principalId = - (await getSignedInPrincipalIdForAccountEndpoint(this.root.endpoint)) ?? ''; - // chedck if the principal ID matches the one that is signed in, otherwise this might be a security problem, hence show the error message - if ( - e.message.includes(`[${principalId}]`) && - (await ensureRbacPermission(this, principalId)) - ) { - return await super.loadMoreChildrenImpl(clearCache); - } else { - void showRbacPermissionError(this.fullId, principalId); - } - } - throw e; // rethrowing tells the resources extension to show the exception message in the tree - } - } - }, - ); - - return result ?? []; - } - - public async deleteTreeItemImpl(context: IDeleteWizardContext): Promise { - await deleteCosmosDBAccount(context, this); - } -} - -function validateDatabaseName(name: string): string | undefined | null { - if (!name || name.length < 1 || name.length > 255) { - return 'Name has to be between 1 and 255 chars long'; - } - if (name.endsWith(' ')) { - return 'Database name cannot end with space'; - } - if (/[/\\?#=]/.test(name)) { - return `Database name cannot contain the characters '\\', '/', '#', '?', '='`; - } - return undefined; -} diff --git a/src/docdb/tree/DocDBCollectionTreeItem.ts b/src/docdb/tree/DocDBCollectionTreeItem.ts deleted file mode 100644 index 796232ebb..000000000 --- a/src/docdb/tree/DocDBCollectionTreeItem.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 { - type Container, - type ContainerDefinition, - type CosmosClient, - type PartitionKeyDefinition, - type Resource, -} from '@azure/cosmos'; -import { - AzExtParentTreeItem, - DialogResponses, - type AzExtTreeItem, - type IActionContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { type DocDBDatabaseTreeItem } from './DocDBDatabaseTreeItem'; -import { DocDBDocumentTreeItem } from './DocDBDocumentTreeItem'; -import { DocDBDocumentsTreeItem } from './DocDBDocumentsTreeItem'; -import { DocDBStoredProcedureTreeItem } from './DocDBStoredProcedureTreeItem'; -import { DocDBStoredProceduresTreeItem } from './DocDBStoredProceduresTreeItem'; -import { DocDBTriggerTreeItem } from './DocDBTriggerTreeItem'; -import { DocDBTriggersTreeItem } from './DocDBTriggersTreeItem'; -import { type IDocDBTreeRoot } from './IDocDBTreeRoot'; - -/** - * Represents a DocumentDB collection - */ -export class DocDBCollectionTreeItem extends AzExtParentTreeItem { - public static contextValue: string = 'cosmosDBDocumentCollection'; - public readonly contextValue: string = DocDBCollectionTreeItem.contextValue; - public declare readonly parent: DocDBDatabaseTreeItem; - - public readonly documentsTreeItem: DocDBDocumentsTreeItem; - private readonly _storedProceduresTreeItem: DocDBStoredProceduresTreeItem; - private readonly _triggersTreeItem: DocDBTriggersTreeItem; - - constructor( - parent: DocDBDatabaseTreeItem, - private _container: ContainerDefinition & Resource, - ) { - super(parent); - this.documentsTreeItem = new DocDBDocumentsTreeItem(this); - this._storedProceduresTreeItem = new DocDBStoredProceduresTreeItem(this); - this._triggersTreeItem = new DocDBTriggersTreeItem(this); - } - - public get root(): IDocDBTreeRoot { - return this.parent.root; - } - - public get id(): string { - return this._container.id; - } - - public get label(): string { - return this._container.id; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('files'); - } - - public get link(): string { - return this._container._self; - } - - public get partitionKey(): PartitionKeyDefinition | undefined { - return this._container.partitionKey; - } - - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = `Are you sure you want to delete collection '${this.label}' and its contents?`; - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteCollection' }, - DialogResponses.deleteResponse, - ); - const client = this.root.getCosmosClient(); - await this.getContainerClient(client).delete(); - } - - public async loadMoreChildrenImpl(_clearCache: boolean): Promise { - return [this.documentsTreeItem, this._storedProceduresTreeItem, this._triggersTreeItem]; - } - - public hasMoreChildrenImpl(): boolean { - return false; - } - - public pickTreeItemImpl(expectedContextValues: (string | RegExp)[]): AzExtTreeItem | undefined { - for (const expectedContextValue of expectedContextValues) { - switch (expectedContextValue) { - case DocDBDocumentsTreeItem.contextValue: - case DocDBDocumentTreeItem.contextValue: - return this.documentsTreeItem; - case DocDBStoredProceduresTreeItem.contextValue: - case DocDBStoredProcedureTreeItem.contextValue: - return this._storedProceduresTreeItem; - case DocDBTriggersTreeItem.contextValue: - case DocDBTriggerTreeItem.contextValue: - return this._triggersTreeItem; - default: - } - } - - return undefined; - } - - public getContainerClient(client: CosmosClient): Container { - return this.parent.getDatabaseClient(client).container(this.id); - } -} diff --git a/src/docdb/tree/DocDBDatabaseTreeItem.ts b/src/docdb/tree/DocDBDatabaseTreeItem.ts deleted file mode 100644 index 58aac64b2..000000000 --- a/src/docdb/tree/DocDBDatabaseTreeItem.ts +++ /dev/null @@ -1,22 +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 { type ContainerDefinition, type CosmosClient, type Database, type Resource } from '@azure/cosmos'; -import { DocDBCollectionTreeItem } from './DocDBCollectionTreeItem'; -import { DocDBDatabaseTreeItemBase } from './DocDBDatabaseTreeItemBase'; - -export class DocDBDatabaseTreeItem extends DocDBDatabaseTreeItemBase { - public static contextValue: string = 'cosmosDBDocumentDatabase'; - public readonly contextValue: string = DocDBDatabaseTreeItem.contextValue; - public readonly childTypeLabel: string = 'Container'; - - public initChild(container: ContainerDefinition & Resource): DocDBCollectionTreeItem { - return new DocDBCollectionTreeItem(this, container); - } - - public getDatabaseClient(client: CosmosClient): Database { - return client.database(this.id); - } -} diff --git a/src/docdb/tree/DocDBDatabaseTreeItemBase.ts b/src/docdb/tree/DocDBDatabaseTreeItemBase.ts deleted file mode 100644 index 571a062ec..000000000 --- a/src/docdb/tree/DocDBDatabaseTreeItemBase.ts +++ /dev/null @@ -1,184 +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 { - type ContainerDefinition, - type ContainerResponse, - type CosmosClient, - type DatabaseDefinition, - type FeedOptions, - type QueryIterator, - type RequestOptions, - type Resource, -} from '@azure/cosmos'; -import { - DialogResponses, - type AzExtTreeItem, - type IActionContext, - type ICreateChildImplContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { nonNullProp } from '../../utils/nonNull'; -import { type DocDBAccountTreeItemBase } from './DocDBAccountTreeItemBase'; -import { DocDBTreeItemBase } from './DocDBTreeItemBase'; - -const minThroughputFixed: number = 400; -const minThroughputPartitioned: number = 400; -const maxThroughput: number = 100000; -const throughputStepSize = 100; - -/** - * This class provides common logic for DocumentDB, Graph, and Table databases - * (DocumentDB is the base type for all Cosmos DB accounts) - */ -export abstract class DocDBDatabaseTreeItemBase extends DocDBTreeItemBase { - public declare readonly parent: DocDBAccountTreeItemBase; - private readonly _database: DatabaseDefinition & Resource; - - constructor(parent: DocDBAccountTreeItemBase, database: DatabaseDefinition & Resource) { - super(parent); - this._database = database; - this.root = this.parent.root; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('database'); - } - - public get id(): string { - return nonNullProp(this._database, 'id'); - } - - public get label(): string { - return nonNullProp(this._database, 'id'); - } - - public get link(): string { - return nonNullProp(this._database, '_self'); - } - - public get connectionString(): string { - return this.parent.connectionString.concat(`;Database=${this.id}`); - } - - public get databaseName(): string { - return this._database.id; - } - - public getIterator(client: CosmosClient, feedOptions: FeedOptions): QueryIterator { - return client.database(this._database.id).containers.readAll(feedOptions); - } - - // Delete the database - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = `Are you sure you want to delete database '${this.label}' and its contents?`; - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteDatabase' }, - DialogResponses.deleteResponse, - ); - const client = this.root.getCosmosClient(); - await client.database(this.id).delete(); - } - - // Create a DB collection - public async createChildImpl(context: ICreateChildImplContext): Promise { - const containerName = await context.ui.showInputBox({ - placeHolder: `Enter an id for your ${this.childTypeLabel}`, - validateInput: this.validateCollectionName.bind(this) as (name: string) => string | undefined | null, - stepName: `create${this.childTypeLabel}`, - }); - - const containerDefinition: ContainerDefinition = { - id: containerName, - }; - - const partitionKey = await this.getNewPartitionKey(context); - if (partitionKey) { - containerDefinition.partitionKey = { - paths: [partitionKey], - }; - } - const options: RequestOptions = {}; - - if (!this.parent.isServerless) { - const isFixed: boolean = !containerDefinition.partitionKey; - const minThroughput = isFixed ? minThroughputFixed : minThroughputPartitioned; - const throughput: number = Number( - await context.ui.showInputBox({ - value: minThroughput.toString(), - prompt: `Initial throughput capacity, between ${minThroughput} and ${maxThroughput} inclusive in increments of ${throughputStepSize}. Enter 0 if the account doesn't support throughput.`, - stepName: 'throughputCapacity', - validateInput: (input: string) => validateThroughput(isFixed, input), - }), - ); - - if (throughput !== 0) { - options.offerThroughput = throughput; - } - } - - context.showCreatingTreeItem(containerName); - const client = this.root.getCosmosClient(); - const container: ContainerResponse = await client - .database(this.id) - .containers.create(containerDefinition, options); - - return this.initChild(nonNullProp(container, 'resource')); - } - - protected async getNewPartitionKey(context: IActionContext): Promise { - let partitionKey: string | undefined = await context.ui.showInputBox({ - prompt: 'Enter the partition key for the collection, or leave blank for fixed size.', - stepName: 'partitionKeyForCollection', - validateInput: this.validatePartitionKey, - placeHolder: 'e.g. /address/zipCode', - }); - - if (partitionKey && partitionKey.length && partitionKey[0] !== '/') { - partitionKey = '/' + partitionKey; - } - - return partitionKey; - } - - protected validatePartitionKey(key: string): string | undefined { - if (/[#?\\]/.test(key)) { - return 'Cannot contain these characters: ?,#,\\, etc.'; - } - return undefined; - } - - protected validateCollectionName(name: string): string | undefined | null { - if (!name) { - return `${this.childTypeLabel} name cannot be empty`; - } - if (name.endsWith(' ')) { - return `${this.childTypeLabel} name cannot end with space`; - } - if (/[/\\?#]/.test(name)) { - return `${this.childTypeLabel} name cannot contain the characters '\\', '/', '#', '?'`; - } - return undefined; - } -} - -function validateThroughput(isFixed: boolean, input: string): string | undefined | null { - if (input === '0') { - return undefined; - } - - try { - const minThroughput = isFixed ? minThroughputFixed : minThroughputPartitioned; - const value = Number(input); - if (value < minThroughput || value > maxThroughput || (value - minThroughput) % throughputStepSize !== 0) { - return `Value must be between ${minThroughput} and ${maxThroughput} in increments of ${throughputStepSize}`; - } - } catch { - return 'Input must be a number'; - } - return undefined; -} diff --git a/src/docdb/tree/DocDBDocumentTreeItem.ts b/src/docdb/tree/DocDBDocumentTreeItem.ts deleted file mode 100644 index 0d93a6c72..000000000 --- a/src/docdb/tree/DocDBDocumentTreeItem.ts +++ /dev/null @@ -1,147 +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 { type CosmosClient, type Item, type ItemDefinition, type RequestOptions } from '@azure/cosmos'; -import { - AzExtTreeItem, - DialogResponses, - type IActionContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { type IEditableTreeItem } from '../../DatabasesFileSystem'; -import { ext } from '../../extensionVariables'; -import { nonNullProp } from '../../utils/nonNull'; -import { getDocumentTreeItemLabel } from '../../utils/vscodeUtils'; -import { type DocDBDocumentsTreeItem } from './DocDBDocumentsTreeItem'; -import { sanitizeId } from './DocDBUtils'; -import { type IDocDBTreeRoot } from './IDocDBTreeRoot'; - -const hiddenFields: string[] = ['_rid', '_self', '_etag', '_attachments', '_ts']; - -/** - * Represents a Cosmos DB DocumentDB (SQL) document - */ -export class DocDBDocumentTreeItem extends AzExtTreeItem implements IEditableTreeItem { - public static contextValue: string = 'cosmosDBDocument'; - public readonly contextValue: string = DocDBDocumentTreeItem.contextValue; - public declare readonly parent: DocDBDocumentsTreeItem; - public readonly cTime: number = Date.now(); - public mTime: number = Date.now(); - private _label: string; - private _document: ItemDefinition; - - constructor(parent: DocDBDocumentsTreeItem, document: ItemDefinition) { - super(parent); - this._document = document; - this._label = getDocumentTreeItemLabel(this._document); - ext.fileSystem.fireChangedEvent(this); - this.commandId = 'cosmosDB.openDocument'; - } - - public get root(): IDocDBTreeRoot { - return this.parent.root; - } - - public get id(): string { - return sanitizeId(`${this.document.id}:${this.getPartitionKeyValue()}`); - } - - public get filePath(): string { - return this.label + '-cosmos-document.json'; - } - - public async refreshImpl(): Promise { - this._label = getDocumentTreeItemLabel(this._document); - ext.fileSystem.fireChangedEvent(this); - } - - public get link(): string { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return this.document._self; - } - - get document(): ItemDefinition { - return this._document; - } - - get label(): string { - return this._label; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('file'); - } - - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = `Are you sure you want to delete document '${this.label}'?`; - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteDocument' }, - DialogResponses.deleteResponse, - ); - const client = this.root.getCosmosClient(); - await this.getDocumentClient(client).delete(); - } - - public async getFileContent(): Promise { - // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types - const clonedDoc: Object = { ...this.document }; - for (const field of hiddenFields) { - delete clonedDoc[field]; - } - return JSON.stringify(clonedDoc, null, 2); - } - - public async writeFileContent(_context: IActionContext, content: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const newData = JSON.parse(content); - for (const field of hiddenFields) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - newData[field] = this.document[field]; - } - - const client: CosmosClient = this.root.getCosmosClient(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (['_etag'].some((element) => !newData[element])) { - throw new Error(`The "_self" and "_etag" fields are required to update a document`); - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const options: RequestOptions = { accessCondition: { type: 'IfMatch', condition: newData._etag } }; - const response = await this.getDocumentClient(client).replace(newData, options); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - this._document = response.resource; - } - } - - private getPartitionKeyValue(): string | number | undefined { - const partitionKey = this.parent.parent.partitionKey; - if (!partitionKey) { - //Fixed collections -> no partitionKeyValue - return undefined; - } - const fields = partitionKey.paths[0].split('/'); - if (fields[0] === '') { - fields.shift(); - } - let value; - for (const field of fields) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - value = value ? value[field] : this.document[field]; - if (!value) { - //Partition Key exists, but this document doesn't have a value - return ''; - } - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return value; - } - - private getDocumentClient(client: CosmosClient): Item { - return this.parent - .getContainerClient(client) - .item(nonNullProp(this.document, 'id'), this.getPartitionKeyValue()); - } -} diff --git a/src/docdb/tree/DocDBDocumentsTreeItem.ts b/src/docdb/tree/DocDBDocumentsTreeItem.ts deleted file mode 100644 index 3e4d3117d..000000000 --- a/src/docdb/tree/DocDBDocumentsTreeItem.ts +++ /dev/null @@ -1,145 +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 { - type Container, - type CosmosClient, - type FeedOptions, - type ItemDefinition, - type ItemResponse, - type QueryIterator, -} from '@azure/cosmos'; -import { - type IActionContext, - type ICreateChildImplContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { nonNullProp } from '../../utils/nonNull'; -import { type DocDBCollectionTreeItem } from './DocDBCollectionTreeItem'; -import { DocDBDocumentTreeItem } from './DocDBDocumentTreeItem'; -import { DocDBTreeItemBase } from './DocDBTreeItemBase'; - -/** - * This class provides logic for DocumentDB collections - */ -export class DocDBDocumentsTreeItem extends DocDBTreeItemBase { - public static contextValue: string = 'cosmosDBDocumentsGroup'; - public readonly contextValue: string = DocDBDocumentsTreeItem.contextValue; - public readonly childTypeLabel: string = 'Documents'; - public declare readonly parent: DocDBCollectionTreeItem; - public suppressMaskLabel = true; - - constructor(parent: DocDBCollectionTreeItem) { - super(parent); - this.root = this.parent.root; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('files'); - } - - public get id(): string { - return '$Documents'; - } - - public get label(): string { - return 'Documents'; - } - - public get link(): string { - return this.parent.link; - } - - public getIterator(client: CosmosClient, feedOptions: FeedOptions): QueryIterator { - return this.getContainerClient(client).items.readAll(feedOptions); - } - - public initChild(document: ItemDefinition): DocDBDocumentTreeItem { - return new DocDBDocumentTreeItem(this, document); - } - - public async createChildImpl(context: ICreateChildImplContext): Promise { - let docID = await context.ui.showInputBox({ - prompt: 'Enter a document ID or leave blank for a generated ID', - stepName: 'createDocument', - }); - - docID = docID.trim(); - let body: ItemDefinition = { id: docID }; - body = await this.promptForPartitionKey(context, body); - context.showCreatingTreeItem(docID); - const item: ItemDefinition = await this.createDocument(body); - - return this.initChild(item); - } - - public async createDocument(body: ItemDefinition): Promise { - const item: ItemResponse = await this.getContainerClient( - this.root.getCosmosClient(), - ).items.create(body); - return nonNullProp(item, 'resource'); - } - - public documentHasPartitionKey(doc: object): boolean { - let interim = doc; - let partitionKey: string | undefined = this.parent.partitionKey && this.parent.partitionKey.paths[0]; - if (!partitionKey) { - return true; - } - if (partitionKey[0] === '/') { - partitionKey = partitionKey.slice(1); - } - const partitionKeyPath = partitionKey.split('/'); - - for (const prop of partitionKeyPath) { - // eslint-disable-next-line no-prototype-builtins - if (interim.hasOwnProperty(prop)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - interim = interim[prop]; - } else { - return false; - } - } - return true; - } - - public async promptForPartitionKey(context: IActionContext, body: ItemDefinition): Promise { - const partitionKey: string | undefined = this.parent.partitionKey && this.parent.partitionKey.paths[0]; - if (partitionKey) { - const partitionKeyValue: string = await context.ui.showInputBox({ - prompt: `Enter a value for the partition key ("${partitionKey}")`, - stepName: 'valueforParititionKey', - }); - // Unlike delete/replace, createDocument does not accept a partition key value via an options parameter. - // We need to present the partitionKey value as part of the document contents - Object.assign(body, this.createPartitionPathObject(partitionKey, partitionKeyValue)); - } - return body; - } - - public getContainerClient(client: CosmosClient): Container { - return this.parent.getContainerClient(client); - } - - // Create a nested Object given the partition key path and value - private createPartitionPathObject(partitionKey: string, partitionKeyValue: string): object { - //remove leading slash - if (partitionKey[0] === '/') { - partitionKey = partitionKey.slice(1); - } - const keyPath = partitionKey.split('/'); - const PartitionPath: object = {}; - let interim: object = PartitionPath; - let i: number; - for (i = 0; i < keyPath.length - 1; i++) { - interim[keyPath[i]] = {}; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - interim = interim[keyPath[i]]; - } - interim[keyPath[i]] = partitionKeyValue; - return PartitionPath; - } -} diff --git a/src/docdb/tree/DocDBStoredProcedureTreeItem.ts b/src/docdb/tree/DocDBStoredProcedureTreeItem.ts deleted file mode 100644 index 385fca7b1..000000000 --- a/src/docdb/tree/DocDBStoredProcedureTreeItem.ts +++ /dev/null @@ -1,112 +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 { type Resource, type StoredProcedureDefinition } from '@azure/cosmos'; -import { - AzExtTreeItem, - DialogResponses, - openReadOnlyJson, - randomUtils, - type IActionContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { type IEditableTreeItem } from '../../DatabasesFileSystem'; -import { ext } from '../../extensionVariables'; -import { localize } from '../../utils/localize'; -import { nonNullProp } from '../../utils/nonNull'; -import { type DocDBStoredProceduresTreeItem } from './DocDBStoredProceduresTreeItem'; -import { type IDocDBTreeRoot } from './IDocDBTreeRoot'; - -/** - * Represents a Cosmos DB DocumentDB (SQL) stored procedure - */ -export class DocDBStoredProcedureTreeItem extends AzExtTreeItem implements IEditableTreeItem { - public static contextValue: string = 'cosmosDBStoredProcedure'; - public readonly contextValue: string = DocDBStoredProcedureTreeItem.contextValue; - public readonly cTime: number = Date.now(); - public declare readonly parent: DocDBStoredProceduresTreeItem; - public mTime: number = Date.now(); - - constructor( - parent: DocDBStoredProceduresTreeItem, - public procedure: StoredProcedureDefinition & Resource, - ) { - super(parent); - ext.fileSystem.fireChangedEvent(this); - this.commandId = 'cosmosDB.openStoredProcedure'; - } - - public get root(): IDocDBTreeRoot { - return this.parent.root; - } - - public get filePath(): string { - return this.label + '-cosmos-stored-procedure.js'; - } - - public get id(): string { - return this.procedure.id; - } - - public get label(): string { - return this.procedure.id; - } - - public get link(): string { - return this.procedure._self; - } - - public async getFileContent(): Promise { - return typeof this.procedure.body === 'string' ? this.procedure.body : ''; - } - - public async refreshImpl(): Promise { - ext.fileSystem.fireChangedEvent(this); - } - - public async writeFileContent(_context: IActionContext, content: string): Promise { - const client = this.root.getCosmosClient(); - const replace = await this.parent - .getContainerClient(client) - .scripts.storedProcedure(this.id) - .replace({ id: this.id, body: content }); - this.procedure = nonNullProp(replace, 'resource'); - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('server-process'); - } - - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = localize( - 'deleteCosmosStoredProcedure', - `Are you sure you want to delete stored procedure '{0}'?`, - this.label, - ); - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteStoredProcedure' }, - DialogResponses.deleteResponse, - ); - const client = this.root.getCosmosClient(); - await this.parent.getContainerClient(client).scripts.storedProcedure(this.id).delete(); - } - - public async execute(context: IActionContext, partitionKey: string, parameters?: unknown[]): Promise { - const client = this.root.getCosmosClient(); - const result = await this.parent - .getContainerClient(client) - .scripts.storedProcedure(this.id) - .execute(partitionKey, parameters); - - try { - const resultFileName = `${this.label}-result`; - await openReadOnlyJson({ label: resultFileName, fullId: randomUtils.getRandomHexString() }, result); - } catch { - await context.ui.showWarningMessage(`Unable to parse execution result`); - } - } -} diff --git a/src/docdb/tree/DocDBStoredProceduresTreeItem.ts b/src/docdb/tree/DocDBStoredProceduresTreeItem.ts deleted file mode 100644 index eb8ee7b56..000000000 --- a/src/docdb/tree/DocDBStoredProceduresTreeItem.ts +++ /dev/null @@ -1,108 +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 { - type Container, - type CosmosClient, - type FeedOptions, - type QueryIterator, - type Resource, - type StoredProcedureDefinition, -} from '@azure/cosmos'; -import { type AzExtTreeItem, type ICreateChildImplContext, type TreeItemIconPath } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { defaultStoredProcedure } from '../../constants'; -import { type GraphCollectionTreeItem } from '../../graph/tree/GraphCollectionTreeItem'; -import { localize } from '../../utils/localize'; -import { nonNullProp } from '../../utils/nonNull'; -import { type DocDBCollectionTreeItem } from './DocDBCollectionTreeItem'; -import { DocDBStoredProcedureTreeItem } from './DocDBStoredProcedureTreeItem'; -import { DocDBTreeItemBase } from './DocDBTreeItemBase'; - -/** - * This class represents the DocumentDB "Stored Procedures" node in the tree - */ -export class DocDBStoredProceduresTreeItem extends DocDBTreeItemBase { - public static contextValue: string = 'cosmosDBStoredProceduresGroup'; - public readonly contextValue: string = DocDBStoredProceduresTreeItem.contextValue; - public readonly childTypeLabel: string = 'Stored Procedure'; - public declare readonly parent: DocDBCollectionTreeItem | GraphCollectionTreeItem; - public suppressMaskLabel = true; - - constructor(parent: DocDBCollectionTreeItem | GraphCollectionTreeItem) { - super(parent); - this.root = this.parent.root; - } - - public initChild(resource: StoredProcedureDefinition & Resource): DocDBStoredProcedureTreeItem { - return new DocDBStoredProcedureTreeItem(this, resource); - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('server-process'); - } - - public async createChildImpl(context: ICreateChildImplContext): Promise { - const client = this.root.getCosmosClient(); - const currStoredProcedureList: AzExtTreeItem[] = await this.getCachedChildren(context); - const currStoredProcedureNames: string[] = []; - for (const sp of currStoredProcedureList) { - currStoredProcedureNames.push(nonNullProp(sp, 'id')); - } - const spID = ( - await context.ui.showInputBox({ - prompt: 'Enter a unique stored procedure ID', - stepName: 'createStoredProcedure', - validateInput: (name: string) => this.validateStoredProcedureName(name, currStoredProcedureNames), - }) - ).trim(); - const body: StoredProcedureDefinition = { id: spID, body: defaultStoredProcedure }; - context.showCreatingTreeItem(spID); - const sproc = await this.getContainerClient(client).scripts.storedProcedures.create(body); - - return this.initChild(nonNullProp(sproc, 'resource')); - } - - public get id(): string { - return '$StoredProcedures'; - } - - public get label(): string { - return 'Stored Procedures'; - } - - public get link(): string { - return this.parent.link; - } - - public getIterator( - client: CosmosClient, - feedOptions: FeedOptions, - ): QueryIterator { - return this.getContainerClient(client).scripts.storedProcedures.readAll(feedOptions); - } - - public getContainerClient(client: CosmosClient): Container { - return this.parent.getContainerClient(client); - } - - private validateStoredProcedureName(name: string, currStoredProcedureNames: string[]): string | undefined { - if (name.length < 1 || name.length > 255) { - return localize('nameLength', 'Name has to be between 1 and 255 chars long'); - } - - if (/[/\\?#&]/.test(name)) { - return localize('illegalChars', 'Name contains illegal chars: /, \\, ?, #, &'); - } - if (name[name.length - 1] === ' ') { - return localize('endsWithSpace', 'Name cannot end with a space.'); - } - if (currStoredProcedureNames.includes(name)) { - return localize('nameExists', 'Stored Procedure "{0}" already exists.', name); - } - - return undefined; - } -} diff --git a/src/docdb/tree/DocDBTreeItemBase.ts b/src/docdb/tree/DocDBTreeItemBase.ts deleted file mode 100644 index f81bcac9d..000000000 --- a/src/docdb/tree/DocDBTreeItemBase.ts +++ /dev/null @@ -1,55 +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 { type CosmosClient, type FeedOptions, type QueryIterator } from '@azure/cosmos'; -import { AzExtParentTreeItem, type AzExtTreeItem } from '@microsoft/vscode-azext-utils'; -import { getBatchSizeSetting } from '../../utils/workspacUtils'; -import { type IDocDBTreeRoot } from './IDocDBTreeRoot'; - -/** - * This class provides common iteration logic for DocumentDB accounts, databases, and collections - */ -export abstract class DocDBTreeItemBase extends AzExtParentTreeItem { - public abstract readonly label: string; - public abstract readonly contextValue: string; - public abstract readonly childTypeLabel: string; - - private _hasMoreChildren: boolean = true; - private _iterator: QueryIterator | undefined; - private _batchSize: number = getBatchSizeSetting(); - - public hasMoreChildrenImpl(): boolean { - return this._hasMoreChildren; - } - - public root: IDocDBTreeRoot; - - public abstract initChild(resource: T): AzExtTreeItem; - - public abstract getIterator(client: CosmosClient, feedOptions: FeedOptions): QueryIterator; - - public async refreshImpl(): Promise { - this._batchSize = getBatchSizeSetting(); - } - - public async loadMoreChildrenImpl(clearCache: boolean): Promise { - if (clearCache || this._iterator === undefined) { - this._hasMoreChildren = true; - const client = this.root.getCosmosClient(); - this._iterator = this.getIterator(client, { maxItemCount: this._batchSize }); - } - - const resourceArray: T[] = []; - const resourceFeed: T[] | undefined = (await this._iterator.fetchNext()).resources; - if (resourceFeed) { - resourceArray.push(...resourceFeed); - } - this._hasMoreChildren = this._iterator.hasMoreResults(); - - this._batchSize *= 2; - - return resourceArray.map((resource: T) => this.initChild(resource)); - } -} diff --git a/src/docdb/tree/DocDBTriggerTreeItem.ts b/src/docdb/tree/DocDBTriggerTreeItem.ts deleted file mode 100644 index 587803923..000000000 --- a/src/docdb/tree/DocDBTriggerTreeItem.ts +++ /dev/null @@ -1,108 +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 { type Resource, type TriggerDefinition } from '@azure/cosmos'; -import { - AzExtTreeItem, - DialogResponses, - type IActionContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { type IEditableTreeItem } from '../../DatabasesFileSystem'; -import { ext } from '../../extensionVariables'; -import { localize } from '../../utils/localize'; -import { nonNullProp } from '../../utils/nonNull'; -import { getTriggerOperation, getTriggerType, type DocDBTriggersTreeItem } from './DocDBTriggersTreeItem'; -import { type IDocDBTreeRoot } from './IDocDBTreeRoot'; - -/** - * Represents a Cosmos DB DocumentDB (SQL) trigger - */ -export class DocDBTriggerTreeItem extends AzExtTreeItem implements IEditableTreeItem { - public static contextValue: string = 'cosmosDBTrigger'; - public readonly contextValue: string = DocDBTriggerTreeItem.contextValue; - public readonly cTime: number = Date.now(); - public declare readonly parent: DocDBTriggersTreeItem; - public trigger: TriggerDefinition & Resource; - public mTime: number = Date.now(); - - constructor(parent: DocDBTriggersTreeItem, trigger: TriggerDefinition & Resource) { - super(parent); - this.trigger = trigger; - ext.fileSystem.fireChangedEvent(this); - this.commandId = 'cosmosDB.openTrigger'; - } - - public get root(): IDocDBTreeRoot { - return this.parent.root; - } - - public get filePath(): string { - return this.label + '-cosmos-trigger.js'; - } - - public get id(): string { - return this.trigger.id; - } - - public get label(): string { - return this.trigger.id; - } - - public get link(): string { - return this.trigger._self; - } - - public async getFileContent(): Promise { - return typeof this.trigger.body === 'string' ? this.trigger.body : ''; - } - - public async refreshImpl(): Promise { - ext.fileSystem.fireChangedEvent(this); - } - - public async writeFileContent(context: IActionContext, content: string): Promise { - const client = this.root.getCosmosClient(); - - const readResponse = await this.parent.getContainerClient(client).scripts.trigger(this.id).read(); - let triggerType = readResponse.resource?.triggerType; - let triggerOperation = readResponse.resource?.triggerOperation; - - if (!triggerType) { - triggerType = await getTriggerType(context); - } - if (!triggerOperation) { - triggerOperation = await getTriggerOperation(context); - } - - const replace = await this.parent.getContainerClient(client).scripts.trigger(this.id).replace({ - id: this.id, - triggerType: triggerType, - triggerOperation: triggerOperation, - body: content, - }); - this.trigger = nonNullProp(replace, 'resource'); - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('zap'); - } - - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = localize( - 'deleteCosmosTrigger', - `Are you sure you want to delete trigger '{0}'?`, - this.label, - ); - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteTrigger' }, - DialogResponses.deleteResponse, - ); - const client = this.root.getCosmosClient(); - await this.parent.getContainerClient(client).scripts.trigger(this.id).delete(); - } -} diff --git a/src/docdb/tree/DocDBTriggersTreeItem.ts b/src/docdb/tree/DocDBTriggersTreeItem.ts deleted file mode 100644 index 55717e95a..000000000 --- a/src/docdb/tree/DocDBTriggersTreeItem.ts +++ /dev/null @@ -1,138 +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 { - TriggerOperation, - TriggerType, - type Container, - type CosmosClient, - type FeedOptions, - type QueryIterator, - type Resource, - type TriggerDefinition, -} from '@azure/cosmos'; -import { - type AzExtTreeItem, - type IActionContext, - type ICreateChildImplContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { defaultTrigger } from '../../constants'; -import { type GraphCollectionTreeItem } from '../../graph/tree/GraphCollectionTreeItem'; -import { localize } from '../../utils/localize'; -import { nonNullProp } from '../../utils/nonNull'; -import { type DocDBCollectionTreeItem } from './DocDBCollectionTreeItem'; -import { DocDBTreeItemBase } from './DocDBTreeItemBase'; -import { DocDBTriggerTreeItem } from './DocDBTriggerTreeItem'; - -/** - * This class represents the DocumentDB "Triggers" node in the tree - */ -export class DocDBTriggersTreeItem extends DocDBTreeItemBase { - public static contextValue: string = 'cosmosDBTriggersGroup'; - public readonly contextValue: string = DocDBTriggersTreeItem.contextValue; - public readonly childTypeLabel: string = 'Trigger'; - public declare readonly parent: DocDBCollectionTreeItem | GraphCollectionTreeItem; - public suppressMaskLabel = true; - - constructor(parent: DocDBCollectionTreeItem | GraphCollectionTreeItem) { - super(parent); - this.root = this.parent.root; - } - - public initChild(resource: TriggerDefinition & Resource): DocDBTriggerTreeItem { - return new DocDBTriggerTreeItem(this, resource); - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('zap'); - } - - public async createChildImpl(context: ICreateChildImplContext): Promise { - const client = this.root.getCosmosClient(); - const currTriggerList: AzExtTreeItem[] = await this.getCachedChildren(context); - const currTriggerNames: string[] = []; - for (const sp of currTriggerList) { - currTriggerNames.push(nonNullProp(sp, 'id')); - } - - const triggerID = ( - await context.ui.showInputBox({ - prompt: 'Enter a unique trigger ID', - stepName: 'createTrigger', - validateInput: (name: string) => this.validateTriggerName(name, currTriggerNames), - }) - ).trim(); - - const triggerType = await getTriggerType(context); - const triggerOperation = await getTriggerOperation(context); - - const body: TriggerDefinition = { - id: triggerID, - body: defaultTrigger, - triggerType: triggerType, - triggerOperation: triggerOperation, - }; - context.showCreatingTreeItem(triggerID); - const response = await this.getContainerClient(client).scripts.triggers.create(body); - - return this.initChild(nonNullProp(response, 'resource')); - } - - public get id(): string { - return '$Triggers'; - } - - public get label(): string { - return 'Triggers'; - } - - public get link(): string { - return this.parent.link; - } - - public getIterator(client: CosmosClient, feedOptions: FeedOptions): QueryIterator { - return this.getContainerClient(client).scripts.triggers.readAll(feedOptions); - } - - public getContainerClient(client: CosmosClient): Container { - return this.parent.getContainerClient(client); - } - - private validateTriggerName(name: string, currTriggerNames: string[]): string | undefined { - if (name.length < 1 || name.length > 255) { - return localize('nameLength', 'Name has to be between 1 and 255 chars long'); - } - - if (/[/\\?#&]/.test(name)) { - return localize('illegalChars', 'Name contains illegal chars: /, \\, ?, #, &'); - } - if (name[name.length - 1] === ' ') { - return localize('endsWithSpace', 'Name cannot end with a space.'); - } - if (currTriggerNames.includes(name)) { - return localize('nameExists', 'Trigger "{0}" already exists.', name); - } - - return undefined; - } -} - -export async function getTriggerType(context: IActionContext): Promise { - const options = Object.keys(TriggerType).map((type) => ({ label: type })); - const triggerTypeOption = await context.ui.showQuickPick(options, { - placeHolder: localize('createDocDBTriggerSelectType', 'Select the trigger type'), - }); - return triggerTypeOption.label === 'Pre' ? TriggerType.Pre : TriggerType.Post; -} - -export async function getTriggerOperation(context: IActionContext): Promise { - const options = Object.keys(TriggerOperation).map((key) => ({ label: key })); - const triggerOperationOption = await context.ui.showQuickPick(options, { - placeHolder: localize('createDocDBTriggerSelectOperation', 'Select the trigger operation'), - }); - return TriggerOperation[triggerOperationOption.label as keyof typeof TriggerOperation]; -} diff --git a/src/docdb/utils/NoSqlQueryConnection.ts b/src/docdb/utils/NoSqlQueryConnection.ts new file mode 100644 index 000000000..3b3fbe526 --- /dev/null +++ b/src/docdb/utils/NoSqlQueryConnection.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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 { pickAppResource } from '../../utils/pickItem/pickAppResource'; +import { getCosmosAuthCredential, getCosmosKeyCredential } from '../getCosmosClient'; +import { type NoSqlQueryConnection } from '../NoSqlCodeLensProvider'; + +export function createNoSqlQueryConnection( + node: DocumentDBContainerResourceItem | DocumentDBItemsResourceItem, +): NoSqlQueryConnection { + const accountInfo = node.model.accountInfo; + const databaseId = node.model.database.id; + const containerId = node.model.container.id; + const keyCred = getCosmosKeyCredential(accountInfo.credentials); + const tenantId = getCosmosAuthCredential(accountInfo.credentials)?.tenantId; + + return { + databaseId: databaseId, + containerId: containerId, + endpoint: accountInfo.endpoint, + masterKey: keyCred?.key, + isEmulator: accountInfo.isEmulator, + tenantId: tenantId, + }; +} + +export async function getNoSqlQueryConnection(): Promise { + return callWithTelemetryAndErrorHandling('cosmosDB.connectToDatabase', async (context) => { + const node = await pickAppResource(context, { + type: [AzExtResourceType.AzureCosmosDb], + expectedChildContextValue: ['treeItem.container'], + }); + return createNoSqlQueryConnection(node); + }); +} diff --git a/src/docdb/utils/azureSessionHelper.ts b/src/docdb/utils/azureSessionHelper.ts index 20e43789d..e7b5626da 100644 --- a/src/docdb/utils/azureSessionHelper.ts +++ b/src/docdb/utils/azureSessionHelper.ts @@ -7,14 +7,20 @@ import { getSessionFromVSCode } from '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode'; import type * as vscode from 'vscode'; -export async function getSignedInPrincipalIdForAccountEndpoint(accountEndpoint: string): Promise { - const session = await getSessionForDatabaseAccount(accountEndpoint); +export async function getSignedInPrincipalIdForAccountEndpoint( + accountEndpoint: string, + tenantId: string | undefined, +): Promise { + const session = await getSessionForDatabaseAccount(accountEndpoint, tenantId); const principalId = session?.account.id.split('/')[1] ?? session?.account.id; return principalId; } -async function getSessionForDatabaseAccount(endpoint: string): Promise { +async function getSessionForDatabaseAccount( + endpoint: string, + tenantId: string | undefined, +): Promise { const endpointUrl = new URL(endpoint); const scrope = `${endpointUrl.origin}${endpointUrl.pathname}.default`; - return await getSessionFromVSCode(scrope, undefined, { createIfNone: false }); + return await getSessionFromVSCode(scrope, tenantId, { createIfNone: false }); } diff --git a/src/docdb/utils/rbacUtils.ts b/src/docdb/utils/rbacUtils.ts index fd3317ee7..0a3aed48b 100644 --- a/src/docdb/utils/rbacUtils.ts +++ b/src/docdb/utils/rbacUtils.ts @@ -7,34 +7,40 @@ import { type SqlRoleAssignmentCreateUpdateParameters } from '@azure/arm-cosmosd import { getResourceGroupFromId } from '@microsoft/vscode-azext-azureutils'; import { callWithTelemetryAndErrorHandling, + createSubscriptionContext, type IActionContext, type IAzureMessageOptions, type ISubscriptionContext, } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; import { randomUUID } from 'crypto'; import * as vscode from 'vscode'; import { createCosmosDBClient } from '../../utils/azureClients'; import { getDatabaseAccountNameFromId } from '../../utils/azureUtils'; import { localize } from '../../utils/localize'; -import { type DocDBAccountTreeItemBase } from '../tree/DocDBAccountTreeItemBase'; -export async function ensureRbacPermission(docDbItem: DocDBAccountTreeItemBase, principalId: string): Promise { +export async function ensureRbacPermissionV2( + fullId: string, + subscription: AzureSubscription, + principalId: string, +): Promise { return ( (await callWithTelemetryAndErrorHandling('cosmosDB.addMissingRbacRole', async (context: IActionContext) => { context.errorHandling.suppressDisplay = false; context.errorHandling.rethrow = false; - const accountName: string = getDatabaseAccountNameFromId(docDbItem.fullId); - if (await askForRbacPermissions(accountName, docDbItem.subscription.subscriptionDisplayName, context)) { + const subscriptionContext = createSubscriptionContext(subscription); + const accountName: string = getDatabaseAccountNameFromId(fullId); + if (await askForRbacPermissions(accountName, subscriptionContext.subscriptionDisplayName, context)) { context.telemetry.properties.lastStep = 'addRbacContributorPermission'; - const resourceGroup: string = getResourceGroupFromId(docDbItem.fullId); + const resourceGroup: string = getResourceGroupFromId(fullId); const start: number = Date.now(); await addRbacContributorPermission( accountName, principalId, resourceGroup, context, - docDbItem.subscription, + subscriptionContext, ); //send duration of the previous call (in seconds) in addition to the duration of the whole event including user prompt context.telemetry.measurements['createRoleAssignment'] = (Date.now() - start) / 1000; diff --git a/src/docdb/utils/validateDocument.ts b/src/docdb/utils/validateDocument.ts new file mode 100644 index 000000000..e23a52b6c --- /dev/null +++ b/src/docdb/utils/validateDocument.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type JSONObject, type PartitionKeyDefinition } from '@azure/cosmos'; +import { parse as parseJson } from '@prantlf/jsonlint'; +import { extractPartitionKey } from '../../utils/document'; + +export function validateDocument(content: string, partitionKey?: PartitionKeyDefinition) { + const errors: string[] = []; + + try { + // Check JSON schema + const resource = parseJson(content) as JSONObject; + + if (resource && typeof resource !== 'object') { + throw new Error('Document must be an object.'); + } + + // Check partition key + const partitionKeyError = validatePartitionKey(resource, partitionKey); + if (partitionKeyError) { + errors.push(...partitionKeyError); + } + + const idError = validateDocumentId(resource); + if (idError) { + errors.push(...idError); + } + } catch (err) { + if (err instanceof SyntaxError) { + errors.push(err.message); + } else if (err instanceof Error) { + errors.push(err.message); + } else { + errors.push('Unknown error'); + } + } + + return errors; +} + +export function validatePartitionKey( + resource: JSONObject, + partitionKey?: PartitionKeyDefinition, +): string[] | undefined { + if (!partitionKey) { + return undefined; + } + + const errors: string[] = []; + const partitionKeyPaths = partitionKey.paths.map((path) => (path.startsWith('/') ? path.slice(1) : path)); + const partitionKeyValues = extractPartitionKey(resource, partitionKey); + + if (!partitionKeyValues) { + errors.push('Partition key is incomplete.'); + } + + if (Array.isArray(partitionKeyValues)) { + partitionKeyValues + .map((value, index) => { + if (!value) { + return `Partition key ${partitionKeyPaths[index]} is invalid.`; + } + return null; + }) + .filter((value) => value !== null) + .forEach((value) => errors.push(value)); + } + + return errors.length ? errors : undefined; +} + +export function validateDocumentId(resource: JSONObject): string[] | undefined { + const errors: string[] = []; + + if ('id' in resource && resource.id) { + if (typeof resource.id !== 'string') { + errors.push('Id must be a string.'); + } else { + if ( + resource.id.indexOf('/') !== -1 || + resource.id.indexOf('\\') !== -1 || + resource.id.indexOf('?') !== -1 || + resource.id.indexOf('#') !== -1 + ) { + errors.push('Id contains illegal chars (/, \\, ?, #).'); + } + + if (resource.id[resource.id.length - 1] === ' ') { + errors.push('Id ends with a space.'); + } + } + } + + return errors.length ? errors : undefined; +} diff --git a/src/extension.ts b/src/extension.ts index 3f769b93d..04bb38382 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,7 +10,6 @@ import { callWithTelemetryAndErrorHandling, createApiProvider, createAzExtLogOutputChannel, - registerCommandWithTreeNodeUnwrapping, registerErrorHandler, registerEvent, registerReportIssueCommand, @@ -18,56 +17,28 @@ import { TreeElementStateManager, type apiUtils, type AzExtParentTreeItem, - type AzExtTreeItem, type AzureExtensionApi, type IActionContext, - type ITreeItemPickerContext, } from '@microsoft/vscode-azext-utils'; +import { type AzureResourcesExtensionApiWithActivity } from '@microsoft/vscode-azext-utils/activity'; import { AzExtResourceType, getAzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; -import { platform } from 'os'; import * as vscode from 'vscode'; import { findTreeItem } from './commands/api/findTreeItem'; import { pickTreeItem } from './commands/api/pickTreeItem'; import { revealTreeItem } from './commands/api/revealTreeItem'; -import { deleteDatabaseAccount } from './commands/deleteDatabaseAccount/deleteDatabaseAccount'; -import { importDocuments } from './commands/importDocuments'; -import { - cosmosGremlinFilter, - cosmosMongoFilter, - cosmosTableFilter, - doubleClickDebounceDelay, - sqlFilter, -} from './constants'; +import { registerCommands } from './commands/registerCommands'; import { DatabasesFileSystem } from './DatabasesFileSystem'; -import { registerDocDBCommands } from './docdb/registerDocDBCommands'; -import { DocDBAccountTreeItem } from './docdb/tree/DocDBAccountTreeItem'; -import { type DocDBAccountTreeItemBase } from './docdb/tree/DocDBAccountTreeItemBase'; -import { type DocDBCollectionTreeItem } from './docdb/tree/DocDBCollectionTreeItem'; -import { DocDBDocumentTreeItem } from './docdb/tree/DocDBDocumentTreeItem'; import { ext } from './extensionVariables'; import { getResourceGroupsApi } from './getExtensionApi'; -import { registerGraphCommands } from './graph/registerGraphCommands'; -import { GraphAccountTreeItem } from './graph/tree/GraphAccountTreeItem'; -import { registerMongoCommands } from './mongo/registerMongoCommands'; -import { setConnectedNode } from './mongo/setConnectedNode'; -import { MongoAccountTreeItem } from './mongo/tree/MongoAccountTreeItem'; -import { type MongoCollectionTreeItem } from './mongo/tree/MongoCollectionTreeItem'; -import { MongoDocumentTreeItem } from './mongo/tree/MongoDocumentTreeItem'; import { MongoClustersExtension } from './mongoClusters/MongoClustersExtension'; -import { registerPostgresCommands } from './postgres/commands/registerPostgresCommands'; import { DatabaseResolver } from './resolver/AppResolver'; import { DatabaseWorkspaceProvider } from './resolver/DatabaseWorkspaceProvider'; -import { TableAccountTreeItem } from './table/tree/TableAccountTreeItem'; -import { AttachedAccountSuffix } from './tree/AttachedAccountsTreeItem'; -import { SubscriptionTreeItem } from './tree/SubscriptionTreeItem'; -import { localize } from './utils/localize'; - -const cosmosDBTopLevelContextValues: string[] = [ - GraphAccountTreeItem.contextValue, - DocDBAccountTreeItem.contextValue, - TableAccountTreeItem.contextValue, - MongoAccountTreeItem.contextValue, -]; +import { CosmosDBBranchDataProvider } from './tree/CosmosDBBranchDataProvider'; +import { CosmosDBWorkspaceBranchDataProvider } from './tree/CosmosDBWorkspaceBranchDataProvider'; +import { + SharedWorkspaceResourceProvider, + WorkspaceResourceType, +} from './tree/workspace/SharedWorkspaceResourceProvider'; export async function activateInternal( context: vscode.ExtensionContext, @@ -94,9 +65,20 @@ export async function activateInternal( // AzureResourceGraph API V1 provided by the getResourceGroupsApi call above. // TreeElementStateManager is needed here too ext.state = new TreeElementStateManager(); - ext.rgApiV2 = await getAzureResourcesExtensionApi(context, '2.0.0'); + ext.rgApiV2 = (await getAzureResourcesExtensionApi(context, '2.0.0')) as AzureResourcesExtensionApiWithActivity; + + ext.cosmosDBBranchDataProvider = new CosmosDBBranchDataProvider(); + ext.cosmosDBWorkspaceBranchDataProvider = new CosmosDBWorkspaceBranchDataProvider(); + ext.rgApiV2.resources.registerAzureResourceBranchDataProvider( + AzExtResourceType.AzureCosmosDb, + ext.cosmosDBBranchDataProvider, + ); + ext.rgApiV2.resources.registerWorkspaceResourceProvider(new SharedWorkspaceResourceProvider()); + ext.rgApiV2.resources.registerWorkspaceResourceBranchDataProvider( + WorkspaceResourceType.AttachedAccounts, + ext.cosmosDBWorkspaceBranchDataProvider, + ); - ext.rgApi.registerApplicationResourceResolver(AzExtResourceType.AzureCosmosDb, new DatabaseResolver()); ext.rgApi.registerApplicationResourceResolver( AzExtResourceType.PostgresqlServersStandard, new DatabaseResolver(), @@ -114,10 +96,9 @@ export async function activateInternal( ext.fileSystem = new DatabasesFileSystem(ext.rgApi.appResourceTree); - registerDocDBCommands(); - registerGraphCommands(); - registerPostgresCommands(); - registerMongoCommands(); + registerCommands(); + // Old commands for old tree view. If need to be quickly returned to V1, uncomment the line below + // registerCommandsCompatibility(); // init and activate mongoClusters-support (branch data provider, commands, ...) const mongoClustersSupport: MongoClustersExtension = new MongoClustersExtension(); @@ -128,112 +109,6 @@ export async function activateInternal( vscode.workspace.registerFileSystemProvider(DatabasesFileSystem.scheme, ext.fileSystem), ); - registerCommandWithTreeNodeUnwrapping('cosmosDB.selectSubscriptions', () => - vscode.commands.executeCommand('azure-account.selectSubscriptions'), - ); - - registerCommandWithTreeNodeUnwrapping('azureDatabases.createServer', createServer); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteAccount', deleteAccount); - registerCommandWithTreeNodeUnwrapping( - 'cosmosDB.attachDatabaseAccount', - async (actionContext: IActionContext) => { - await ext.attachedAccountsNode.attachNewAccount(actionContext); - await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); - }, - ); - registerCommandWithTreeNodeUnwrapping('cosmosDB.attachEmulator', async (actionContext: IActionContext) => { - if (platform() !== 'win32') { - actionContext.errorHandling.suppressReportIssue = true; - throw new Error( - localize('emulatorNotSupported', 'The Cosmos DB emulator is only supported on Windows.'), - ); - } - - await ext.attachedAccountsNode.attachEmulator(actionContext); - await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); - }); - registerCommandWithTreeNodeUnwrapping( - 'azureDatabases.refresh', - async (actionContext: IActionContext, node?: AzExtTreeItem) => { - if (node) { - await node.refresh(actionContext); - } else { - await ext.rgApi.appResourceTree.refresh(actionContext, node); - } - }, - ); - - registerCommandWithTreeNodeUnwrapping( - 'azureDatabases.detachDatabaseAccount', - async (actionContext: IActionContext & ITreeItemPickerContext, node?: AzExtTreeItem) => { - const children = await ext.attachedAccountsNode.loadAllChildren(actionContext); - if (children.length < 2) { - const message = localize('noAttachedAccounts', 'There are no Attached Accounts.'); - void vscode.window.showInformationMessage(message); - } else { - if (!node) { - node = await ext.rgApi.workspaceResourceTree.showTreeItemPicker( - cosmosDBTopLevelContextValues.map((val: string) => (val += AttachedAccountSuffix)), - actionContext, - ); - } - if (node instanceof MongoAccountTreeItem) { - if (ext.connectedMongoDB && node.fullId === ext.connectedMongoDB.parent.fullId) { - setConnectedNode(undefined); - await node.refresh(actionContext); - } - } - await ext.attachedAccountsNode.detach(node); - await ext.rgApi.workspaceResourceTree.refresh(actionContext, ext.attachedAccountsNode); - } - }, - ); - registerCommandWithTreeNodeUnwrapping( - 'cosmosDB.importDocument', - async ( - actionContext: IActionContext, - selectedNode: vscode.Uri | MongoCollectionTreeItem | DocDBCollectionTreeItem, - uris: vscode.Uri[], - ) => { - if (selectedNode instanceof vscode.Uri) { - await importDocuments(actionContext, uris || [selectedNode], undefined); - } else { - await importDocuments(actionContext, undefined, selectedNode); - } - }, - ); - registerCommandWithTreeNodeUnwrapping('cosmosDB.copyConnectionString', cosmosDBCopyConnectionString); - registerCommandWithTreeNodeUnwrapping( - 'cosmosDB.openDocument', - async (actionContext: IActionContext, node?: MongoDocumentTreeItem | DocDBDocumentTreeItem) => { - if (!node) { - node = await ext.rgApi.pickAppResource( - actionContext, - { - filter: [cosmosMongoFilter, sqlFilter], - expectedChildContextValue: [ - MongoDocumentTreeItem.contextValue, - DocDBDocumentTreeItem.contextValue, - ], - }, - ); - } - - // Clear un-uploaded local changes to the document before opening https://github.com/microsoft/vscode-cosmosdb/issues/1619 - ext.fileSystem.fireChangedEvent(node); - await ext.fileSystem.showTextDocument(node); - }, - doubleClickDebounceDelay, - ); - registerCommandWithTreeNodeUnwrapping( - 'azureDatabases.update', - async (_actionContext: IActionContext, uri: vscode.Uri) => await ext.fileSystem.updateWithoutPrompt(uri), - ); - registerCommandWithTreeNodeUnwrapping( - 'azureDatabases.loadMore', - async (actionContext: IActionContext, node: AzExtTreeItem) => - await ext.rgApi.appResourceTree.loadMore(node, actionContext), - ); registerEvent( 'cosmosDB.onDidChangeConfiguration', vscode.workspace.onDidChangeConfiguration, @@ -265,41 +140,3 @@ export async function activateInternal( export function deactivateInternal(_context: vscode.ExtensionContext): void { // NOOP } - -export async function createServer(context: IActionContext, node?: SubscriptionTreeItem): Promise { - if (!node) { - node = await ext.rgApi.appResourceTree.showTreeItemPicker( - SubscriptionTreeItem.contextValue, - context, - ); - } - - await SubscriptionTreeItem.createChild(context, node); -} - -export async function deleteAccount(context: IActionContext, node?: AzExtTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await ext.rgApi.pickAppResource(context, { - filter: [cosmosMongoFilter, cosmosTableFilter, cosmosGremlinFilter, sqlFilter], - }); - } - - await deleteDatabaseAccount(context, node, false); -} - -export async function cosmosDBCopyConnectionString( - context: IActionContext, - node?: MongoAccountTreeItem | DocDBAccountTreeItemBase, -): Promise { - const message = 'The connection string has been copied to the clipboard'; - if (!node) { - node = await ext.rgApi.pickAppResource(context, { - filter: [cosmosMongoFilter, cosmosTableFilter, cosmosGremlinFilter, sqlFilter], - }); - } - - await vscode.env.clipboard.writeText(node.connectionString); - void vscode.window.showInformationMessage(message); -} diff --git a/src/extensionVariables.ts b/src/extensionVariables.ts index 2ad559b6f..56b03b756 100644 --- a/src/extensionVariables.ts +++ b/src/extensionVariables.ts @@ -3,61 +3,64 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - type AzExtTreeDataProvider, - type AzExtTreeItem, - type IAzExtLogOutputChannel, - type TreeElementStateManager, -} from '@microsoft/vscode-azext-utils'; +import { type IAzExtLogOutputChannel, type TreeElementStateManager } from '@microsoft/vscode-azext-utils'; +import { type AzureResourcesExtensionApiWithActivity } from '@microsoft/vscode-azext-utils/activity'; import { type AzureHostExtensionApi } from '@microsoft/vscode-azext-utils/hostapi'; -import { type AzureResourcesExtensionApi } from '@microsoft/vscode-azureresources-api'; -import { type ExtensionContext, type SecretStorage, type TreeView } from 'vscode'; +import { type ExtensionContext, type SecretStorage } from 'vscode'; import { type DatabasesFileSystem } from './DatabasesFileSystem'; import { type NoSqlCodeLensProvider } from './docdb/NoSqlCodeLensProvider'; import { type MongoDBLanguageClient } from './mongo/languageClient'; -import { type MongoCodeLensProvider } from './mongo/services/MongoCodeLensProvider'; -import { type MongoDatabaseTreeItem } from './mongo/tree/MongoDatabaseTreeItem'; import { type MongoClustersBranchDataProvider } from './mongoClusters/tree/MongoClustersBranchDataProvider'; import { type MongoClustersWorkspaceBranchDataProvider } from './mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider'; +import { type MongoDBAccountsWorkspaceItem } from './mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem'; import { type PostgresCodeLensProvider } from './postgres/services/PostgresCodeLensProvider'; import { type PostgresDatabaseTreeItem } from './postgres/tree/PostgresDatabaseTreeItem'; import { type AttachedAccountsTreeItem } from './tree/AttachedAccountsTreeItem'; -import { type AzureAccountTreeItemWithAttached } from './tree/AzureAccountTreeItemWithAttached'; -import { type SharedWorkspaceResourceProvider } from './tree/workspace/sharedWorkspaceResourceProvider'; +import { type CosmosDBBranchDataProvider } from './tree/CosmosDBBranchDataProvider'; +import { type CosmosDBWorkspaceBranchDataProvider } from './tree/CosmosDBWorkspaceBranchDataProvider'; +import { type CosmosDBAttachedAccountsResourceItem } from './tree/attached/CosmosDBAttachedAccountsResourceItem'; /** * Namespace for common variables used throughout the extension. They must be initialized in the activate() method of extension.ts */ export namespace ext { - export let connectedMongoDB: MongoDatabaseTreeItem | undefined; export let connectedPostgresDB: PostgresDatabaseTreeItem | undefined; + export let postgresCodeLensProvider: PostgresCodeLensProvider | undefined; + export let context: ExtensionContext; export let outputChannel: IAzExtLogOutputChannel; - export let tree: AzExtTreeDataProvider; - export let treeView: TreeView; export let attachedAccountsNode: AttachedAccountsTreeItem; export let isBundle: boolean | undefined; - export let azureAccountTreeItem: AzureAccountTreeItemWithAttached; export let secretStorage: SecretStorage; - export let postgresCodeLensProvider: PostgresCodeLensProvider | undefined; export const prefix: string = 'azureDatabases'; export let fileSystem: DatabasesFileSystem; - export let mongoCodeLensProvider: MongoCodeLensProvider; export let noSqlCodeLensProvider: NoSqlCodeLensProvider; export let mongoLanguageClient: MongoDBLanguageClient; export let rgApi: AzureHostExtensionApi; - export let rgApiV2: AzureResourcesExtensionApi; + + // Since the Azure Resources extension did not update API interface, but added a new interface with activity + // we have to use the new interface AzureResourcesExtensionApiWithActivity instead of AzureResourcesExtensionApi + export let rgApiV2: AzureResourcesExtensionApiWithActivity; export let state: TreeElementStateManager; - // used for the resources tree - export let mongoClustersBranchDataProvider: MongoClustersBranchDataProvider; + // TODO: To avoid these stupid variables below the rgApiV2 should have the following public fields (but they are private): + // - AzureResourceProviderManager, + // - AzureResourceBranchDataProviderManager, + // - WorkspaceResourceProviderManager, + // - WorkspaceResourceBranchDataProviderManager, - // used for the workspace: this is the general provider - export let workspaceDataProvider: SharedWorkspaceResourceProvider; + // used for the resources tree and the workspace tree REFRESH + export let cosmosDBBranchDataProvider: CosmosDBBranchDataProvider; + // used for the workspace: these are the dedicated providers + export let cosmosDBWorkspaceBranchDataProvider: CosmosDBWorkspaceBranchDataProvider; + export let cosmosDBWorkspaceBranchDataResource: CosmosDBAttachedAccountsResourceItem; + // used for the resources tree + export let mongoClustersBranchDataProvider: MongoClustersBranchDataProvider; // used for the workspace: these are the dedicated providers export let mongoClustersWorkspaceBranchDataProvider: MongoClustersWorkspaceBranchDataProvider; + export let mongoClusterWorkspaceBranchDataResource: MongoDBAccountsWorkspaceItem; export namespace settingsKeys { export const mongoShellPath = 'mongo.shell.path'; diff --git a/src/graph/gremlinEndpoints.ts b/src/graph/gremlinEndpoints.ts deleted file mode 100644 index 45a2c0200..000000000 --- a/src/graph/gremlinEndpoints.ts +++ /dev/null @@ -1,54 +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 { type CosmosDBManagementClient } from '@azure/arm-cosmosdb'; -import { nonNullValue } from '../utils/nonNull'; -import { type IGremlinEndpoint } from '../vscode-cosmosdbgraph.api'; - -export async function tryGetGremlinEndpointFromAzure( - client: CosmosDBManagementClient, - resourceGroup: string, - account: string, -): Promise { - // Only 'bodyOfText' property of 'response' contains the 'gremlinEndpoint' property in the @azure/arm-cosmosdb@9 sdk - const response = await client.databaseAccounts.get(resourceGroup, account); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const endpointUri = response.documentEndpoint; - // If it doesn't have gremlinEndpoint in its properties, it must be a pre-GA endpoint - return endpointUri ? parseEndpointUrl(endpointUri) : undefined; -} - -export function getPossibleGremlinEndpoints(documentEndpoint: string): IGremlinEndpoint[] { - // E.g., given a document endpoint from Azure such as https://.documents.azure.com:443/ - - const documentSuffix = '.documents.azure.com'; - if (documentEndpoint.indexOf(documentSuffix) >= 0) { - // Pre-GA style (Dec 2017) - const preGAEndpoint = documentEndpoint.replace(documentSuffix, '.graphs.azure.com'); - - // Post-GA style (Dec 2017) - const postGAEndpoint = documentEndpoint.replace(documentSuffix, '.gremlin.cosmosdb.azure.com'); - - return [parseEndpointUrl(postGAEndpoint), parseEndpointUrl(preGAEndpoint)]; - } else { - console.warn(`Unexpected document URL format: ${documentEndpoint}`); - return [parseEndpointUrl(documentEndpoint)]; - } -} - -/** - * Parses a IGremlinPoint from a URL - * @param url An account URL such as 'https://.documents.azure.com:443/' - */ -function parseEndpointUrl(url: string): IGremlinEndpoint { - const [, protocol, host, , portString] = nonNullValue( - url.match(/^([^:]+):\/\/([^:]+)(:([0-9]+))?\/?$/), - 'urlMatch', - ); - console.assert(!!protocol && !!host, 'Unexpected endpoint format'); - const port = parseInt(portString || '443', 10); - console.assert(port > 0, 'Unexpected port'); - return { host, port, ssl: protocol.toLowerCase() === 'https' }; -} diff --git a/src/graph/registerGraphCommands.ts b/src/graph/registerGraphCommands.ts deleted file mode 100644 index 46ec385e4..000000000 --- a/src/graph/registerGraphCommands.ts +++ /dev/null @@ -1,78 +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 { - registerCommandWithTreeNodeUnwrapping, - type AzExtTreeItem, - type IActionContext, - type ITreeItemPickerContext, -} from '@microsoft/vscode-azext-utils'; -import { cosmosGremlinFilter, doubleClickDebounceDelay } from '../constants'; -import { ext } from '../extensionVariables'; -import { type GraphAccountTreeItem } from './tree/GraphAccountTreeItem'; -import { GraphCollectionTreeItem } from './tree/GraphCollectionTreeItem'; -import { GraphDatabaseTreeItem } from './tree/GraphDatabaseTreeItem'; -import { GraphTreeItem } from './tree/GraphTreeItem'; - -export function registerGraphCommands(): void { - registerCommandWithTreeNodeUnwrapping('cosmosDB.createGraphDatabase', createGraphDatabase); - registerCommandWithTreeNodeUnwrapping('cosmosDB.createGraph', createGraph); - registerCommandWithTreeNodeUnwrapping( - 'cosmosDB.deleteGraphDatabase', - async (context: IActionContext, node?: GraphDatabaseTreeItem) => { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickGraph(context, GraphDatabaseTreeItem.contextValue); - } - await node.deleteTreeItem(context); - }, - ); - registerCommandWithTreeNodeUnwrapping( - 'cosmosDB.deleteGraph', - async (context: IActionContext, node?: GraphCollectionTreeItem) => { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickGraph(context, GraphCollectionTreeItem.contextValue); - } - await node.deleteTreeItem(context); - }, - ); - registerCommandWithTreeNodeUnwrapping( - 'cosmosDB.openGraphExplorer', - async (context: IActionContext, node: GraphTreeItem) => { - if (!node) { - node = await pickGraph(context, GraphTreeItem.contextValue); - } - await node.showExplorer(context); - }, - doubleClickDebounceDelay, - ); -} - -export async function createGraphDatabase(context: IActionContext, node?: GraphAccountTreeItem): Promise { - if (!node) { - node = await pickGraph(context); - } - await node.createChild(context); -} - -export async function createGraph(context: IActionContext, node?: GraphDatabaseTreeItem): Promise { - if (!node) { - node = await pickGraph(context, GraphDatabaseTreeItem.contextValue); - } - await node.createChild(context); -} - -async function pickGraph( - context: IActionContext, - expectedContextValue?: string | RegExp | (string | RegExp)[], -): Promise { - return await ext.rgApi.pickAppResource(context, { - filter: [cosmosGremlinFilter], - expectedChildContextValue: expectedContextValue, - }); -} diff --git a/src/graph/tree/GraphAccountTreeItem.ts b/src/graph/tree/GraphAccountTreeItem.ts deleted file mode 100644 index 4a560a003..000000000 --- a/src/graph/tree/GraphAccountTreeItem.ts +++ /dev/null @@ -1,55 +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 { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb/src/models'; -import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; -import { type AzExtParentTreeItem } from '@microsoft/vscode-azext-utils'; -import { type CosmosDBCredential } from '../../docdb/getCosmosClient'; -import { DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTreeItemBase'; -import { DocDBStoredProcedureTreeItem } from '../../docdb/tree/DocDBStoredProcedureTreeItem'; -import { DocDBStoredProceduresTreeItem } from '../../docdb/tree/DocDBStoredProceduresTreeItem'; -import { type IGremlinEndpoint } from '../../vscode-cosmosdbgraph.api'; -import { GraphCollectionTreeItem } from './GraphCollectionTreeItem'; -import { GraphDatabaseTreeItem } from './GraphDatabaseTreeItem'; -import { GraphTreeItem } from './GraphTreeItem'; - -export class GraphAccountTreeItem extends DocDBAccountTreeItemBase { - public static contextValue: string = 'cosmosDBGraphAccount'; - public contextValue: string = GraphAccountTreeItem.contextValue; - - constructor( - parent: AzExtParentTreeItem, - id: string, - label: string, - documentEndpoint: string, - private _gremlinEndpoint: IGremlinEndpoint | undefined, - credentials: CosmosDBCredential[], - isEmulator: boolean | undefined, - readonly databaseAccount?: DatabaseAccountGetResults, - ) { - super(parent, id, label, documentEndpoint, credentials, isEmulator, databaseAccount); - this.valuesToMask.push(documentEndpoint); - if (_gremlinEndpoint) { - this.valuesToMask.push(_gremlinEndpoint.host); - } - } - - public initChild(database: DatabaseDefinition & Resource): GraphDatabaseTreeItem { - return new GraphDatabaseTreeItem(this, this._gremlinEndpoint, database); - } - - public isAncestorOfImpl(contextValue: string): boolean { - switch (contextValue) { - case GraphDatabaseTreeItem.contextValue: - case GraphCollectionTreeItem.contextValue: - case DocDBStoredProceduresTreeItem.contextValue: - case DocDBStoredProcedureTreeItem.contextValue: - case GraphTreeItem.contextValue: - return true; - default: - return false; - } - } -} diff --git a/src/graph/tree/GraphCollectionTreeItem.ts b/src/graph/tree/GraphCollectionTreeItem.ts deleted file mode 100644 index 96be53487..000000000 --- a/src/graph/tree/GraphCollectionTreeItem.ts +++ /dev/null @@ -1,96 +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 { type Container, type ContainerDefinition, type CosmosClient, type Resource } from '@azure/cosmos'; -import { - AzExtParentTreeItem, - DialogResponses, - type AzExtTreeItem, - type IActionContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { DocDBStoredProceduresTreeItem } from '../../docdb/tree/DocDBStoredProceduresTreeItem'; -import { DocDBStoredProcedureTreeItem } from '../../docdb/tree/DocDBStoredProcedureTreeItem'; -import { type IDocDBTreeRoot } from '../../docdb/tree/IDocDBTreeRoot'; -import { type GraphDatabaseTreeItem } from './GraphDatabaseTreeItem'; -import { GraphTreeItem } from './GraphTreeItem'; - -export class GraphCollectionTreeItem extends AzExtParentTreeItem { - public static contextValue: string = 'cosmosDBGraph'; - public readonly contextValue: string = GraphCollectionTreeItem.contextValue; - public declare readonly parent: GraphDatabaseTreeItem; - - private readonly _graphTreeItem: GraphTreeItem; - private readonly _storedProceduresTreeItem: DocDBStoredProceduresTreeItem; - - private readonly _collection: ContainerDefinition & Resource; - - constructor(parent: GraphDatabaseTreeItem, collection: ContainerDefinition & Resource) { - super(parent); - this._collection = collection; - this._graphTreeItem = new GraphTreeItem(this, this._collection); - this._storedProceduresTreeItem = new DocDBStoredProceduresTreeItem(this); - } - - public get root(): IDocDBTreeRoot { - return this.parent.root; - } - - public get id(): string { - return this._collection.id; - } - - public get label(): string { - return this._collection.id; - } - - public get link(): string { - return this._collection._self; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('files'); - } - - public async loadMoreChildrenImpl(_clearCache: boolean): Promise { - return [this._graphTreeItem, this._storedProceduresTreeItem]; - } - - public hasMoreChildrenImpl(): boolean { - return false; - } - - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = `Are you sure you want to delete graph '${this.label}' and its contents?`; - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteGraphCollection' }, - DialogResponses.deleteResponse, - ); - const client = this.root.getCosmosClient(); - await this.getContainerClient(client).delete(); - } - - public pickTreeItemImpl(expectedContextValues: (string | RegExp)[]): AzExtTreeItem | undefined { - for (const expectedContextValue of expectedContextValues) { - switch (expectedContextValue) { - case GraphTreeItem.contextValue: - return this._graphTreeItem; - case DocDBStoredProceduresTreeItem.contextValue: - case DocDBStoredProcedureTreeItem.contextValue: - return this._storedProceduresTreeItem; - - default: - } - } - - return undefined; - } - - public getContainerClient(client: CosmosClient): Container { - return this.parent.getDatabaseClient(client).container(this.id); - } -} diff --git a/src/graph/tree/GraphDatabaseTreeItem.ts b/src/graph/tree/GraphDatabaseTreeItem.ts deleted file mode 100644 index ffd4caa36..000000000 --- a/src/graph/tree/GraphDatabaseTreeItem.ts +++ /dev/null @@ -1,74 +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 { - type ContainerDefinition, - type CosmosClient, - type Database, - type DatabaseDefinition, - type Resource, -} from '@azure/cosmos'; -import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { DocDBDatabaseTreeItemBase } from '../../docdb/tree/DocDBDatabaseTreeItemBase'; -import { type IGremlinEndpoint } from '../../vscode-cosmosdbgraph.api'; -import { getPossibleGremlinEndpoints } from '../gremlinEndpoints'; -import { type GraphAccountTreeItem } from './GraphAccountTreeItem'; -import { GraphCollectionTreeItem } from './GraphCollectionTreeItem'; - -export class GraphDatabaseTreeItem extends DocDBDatabaseTreeItemBase { - public static contextValue: string = 'cosmosDBGraphDatabase'; - public readonly contextValue: string = GraphDatabaseTreeItem.contextValue; - public readonly childTypeLabel: string = 'Graph'; - - constructor( - parent: GraphAccountTreeItem, - private _gremlinEndpoint: IGremlinEndpoint | undefined, - database: DatabaseDefinition & Resource, - ) { - super(parent, database); - } - - public initChild(collection: ContainerDefinition & Resource): GraphCollectionTreeItem { - return new GraphCollectionTreeItem(this, collection); - } - - // Gremlin endpoint, if definitely known - get gremlinEndpoint(): IGremlinEndpoint | undefined { - return this._gremlinEndpoint; - } - - get possibleGremlinEndpoints(): IGremlinEndpoint[] { - return getPossibleGremlinEndpoints(this.root.endpoint); - } - - public getDatabaseClient(client: CosmosClient): Database { - return client.database(this.id); - } - - protected override async getNewPartitionKey(context: IActionContext): Promise { - let partitionKey: string | undefined = await context.ui.showInputBox({ - prompt: 'Enter the partition key for the collection, or leave blank for fixed size.', - stepName: 'partitionKeyForCollection', - validateInput: this.validatePartitionKey, - placeHolder: 'e.g. /address', - }); - - if (partitionKey && partitionKey.length && partitionKey[0] !== '/') { - partitionKey = '/' + partitionKey; - } - - return partitionKey; - } - - protected validatePartitionKey(key: string): string | undefined { - if (/[#?\\]/.test(key)) { - return 'Cannot contain these characters: ?,#,\\, etc.'; - } - if (/.+\//.test(key)) { - return 'Cannot be a nested path'; - } - return undefined; - } -} diff --git a/src/graph/tree/GraphTreeItem.ts b/src/graph/tree/GraphTreeItem.ts deleted file mode 100644 index b7af3db67..000000000 --- a/src/graph/tree/GraphTreeItem.ts +++ /dev/null @@ -1,53 +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 { type ContainerDefinition, type Resource } from '@azure/cosmos'; -import { AzExtTreeItem, type IActionContext, type TreeItemIconPath } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { localize } from '../../utils/localize'; -import { openUrl } from '../../utils/openUrl'; -import { type GraphCollectionTreeItem } from './GraphCollectionTreeItem'; - -const alternativeGraphVisualizationToolsDocLink = 'https://aka.ms/cosmosdb-graph-alternative-tools'; - -export class GraphTreeItem extends AzExtTreeItem { - public static contextValue: string = 'cosmosDBGraphGraph'; - public readonly contextValue: string = GraphTreeItem.contextValue; - public declare readonly parent: GraphCollectionTreeItem; - public suppressMaskLabel = true; - - private readonly _collection: ContainerDefinition & Resource; - - constructor(parent: GraphCollectionTreeItem, collection: ContainerDefinition & Resource) { - super(parent); - this.commandId = 'cosmosDB.openGraphExplorer'; - this._collection = collection; - } - - public get id(): string { - return this._collection.id; - } - - public get label(): string { - return 'Graph'; - } - - public get link(): string { - return this._collection._self; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('files'); - } - - public async showExplorer(_context: IActionContext): Promise { - const message: string = localize('mustInstallGraph', 'Cosmos DB Graph extension has been retired.'); - const alternativeToolsOption = 'Alternative Tools'; - const result = await vscode.window.showErrorMessage(message, alternativeToolsOption); - if (result === alternativeToolsOption) { - await openUrl(alternativeGraphVisualizationToolsDocLink); - } - } -} diff --git a/src/mongo/MongoScrapbook.ts b/src/mongo/MongoScrapbookHelpers.ts similarity index 72% rename from src/mongo/MongoScrapbook.ts rename to src/mongo/MongoScrapbookHelpers.ts index 038328569..ac3d9d841 100644 --- a/src/mongo/MongoScrapbook.ts +++ b/src/mongo/MongoScrapbookHelpers.ts @@ -3,39 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - openReadOnlyContent, - parseError, - type IActionContext, - type IParsedError, - type ReadOnlyContent, -} from '@microsoft/vscode-azext-utils'; +import { parseError, type IParsedError } from '@microsoft/vscode-azext-utils'; import { ANTLRInputStream as InputStream } from 'antlr4ts/ANTLRInputStream'; import { CommonTokenStream } from 'antlr4ts/CommonTokenStream'; import { ErrorNode } from 'antlr4ts/tree/ErrorNode'; import { type ParseTree } from 'antlr4ts/tree/ParseTree'; import { TerminalNode } from 'antlr4ts/tree/TerminalNode'; import { EJSON, ObjectId } from 'bson'; -import { type Collection } from 'mongodb'; -import { EOL } from 'os'; import * as vscode from 'vscode'; -import { ext } from '../extensionVariables'; import { filterType, findType } from '../utils/array'; -import { localize } from '../utils/localize'; import { nonNullProp, nonNullValue } from '../utils/nonNull'; import { LexerErrorListener, ParserErrorListener } from './errorListeners'; import { mongoLexer } from './grammar/mongoLexer'; import * as mongoParser from './grammar/mongoParser'; import { MongoVisitor } from './grammar/visitors'; import { type ErrorDescription, type MongoCommand } from './MongoCommand'; -import { MongoCollectionTreeItem } from './tree/MongoCollectionTreeItem'; -import { stripQuotes, type MongoDatabaseTreeItem } from './tree/MongoDatabaseTreeItem'; -import { MongoDocumentTreeItem, type IMongoDocument } from './tree/MongoDocumentTreeItem'; -const notInScrapbookMessage = 'You must have a MongoDB scrapbook (*.mongo) open to run a MongoDB command.'; +export function stripQuotes(term: string): string { + if ((term.startsWith("'") && term.endsWith("'")) || (term.startsWith('"') && term.endsWith('"'))) { + return term.substring(1, term.length - 1); + } + return term; +} export function getAllErrorsFromTextDocument(document: vscode.TextDocument): vscode.Diagnostic[] { - const commands = getAllCommandsFromTextDocument(document); + const commands = getAllCommandsFromText(document.getText()); const errors: vscode.Diagnostic[] = []; for (const command of commands) { for (const error of command.errors || []) { @@ -47,158 +39,6 @@ export function getAllErrorsFromTextDocument(document: vscode.TextDocument): vsc return errors; } -export async function executeAllCommandsFromActiveEditor(context: IActionContext): Promise { - ext.outputChannel.appendLog('Executing all commands in scrapbook...'); - const commands = getAllCommandsFromActiveEditor(); - await executeCommands(context, commands); -} - -export async function executeCommandFromActiveEditor( - context: IActionContext, - position?: vscode.Position, -): Promise { - const commands = getAllCommandsFromActiveEditor(); - const command = findCommandAtPosition(commands, position || vscode.window.activeTextEditor?.selection.start); - return await executeCommand(context, command); -} - -function getAllCommandsFromActiveEditor(): MongoCommand[] { - const activeEditor = vscode.window.activeTextEditor; - if (activeEditor) { - return getAllCommandsFromTextDocument(activeEditor.document); - } else { - // Shouldn't be able to reach this - throw new Error(notInScrapbookMessage); - } -} - -export function getAllCommandsFromTextDocument(document: vscode.TextDocument): MongoCommand[] { - return getAllCommandsFromText(document.getText()); -} - -async function executeCommands(context: IActionContext, commands: MongoCommand[]): Promise { - const label: string = 'Scrapbook-execute-all-results'; - const fullId: string = `${ext.connectedMongoDB?.fullId}/${label}`; - const readOnlyContent: ReadOnlyContent = await openReadOnlyContent({ label, fullId }, '', '.txt', { - viewColumn: vscode.ViewColumn.Beside, - }); - - for (const command of commands) { - try { - await executeCommand(context, command, readOnlyContent); - } catch (e) { - const err = parseError(e); - if (err.isUserCancelledError) { - throw e; - } else { - const message = `${command.text.split('(')[0]} at ${command.range.start.line + 1}:${command.range.start.character + 1}: ${err.message}`; - throw new Error(message); - } - } - } -} - -async function executeCommand( - context: IActionContext, - command: MongoCommand, - readOnlyContent?: ReadOnlyContent, -): Promise { - if (command) { - try { - context.telemetry.properties.command = command.name; - context.telemetry.properties.argsCount = String(command.arguments ? command.arguments.length : 0); - } catch { - // Ignore - } - - const database = ext.connectedMongoDB; - if (!database) { - throw new Error( - 'Please select a MongoDB database to run against by selecting it in the explorer and selecting the "Connect" context menu item', - ); - } - if (command.errors && command.errors.length > 0) { - //Currently, we take the first error pushed. Tests correlate that the parser visits errors in left-to-right, top-to-bottom. - const err = command.errors[0]; - throw new Error( - localize( - 'unableToParseSyntax', - `Unable to parse syntax. Error near line ${err.range.start.line + 1}, column ${err.range.start.character + 1}: "${err.message}"`, - ), - ); - } - - // we don't handle chained commands so we can only handle "find" if isn't chained - if (command.name === 'find' && !command.chained) { - const db = await database.connectToDb(); - const collectionName: string = nonNullProp(command, 'collection'); - const collection: Collection = db.collection(collectionName); - // NOTE: Intentionally creating a _new_ tree item rather than searching for a cached node in the tree because - // the executed 'find' command could have a filter or projection that is not handled by a cached tree node - const node = new MongoCollectionTreeItem(database, collection, command.argumentObjects); - await ext.fileSystem.showTextDocument(node, { viewColumn: vscode.ViewColumn.Beside }); - } else { - const result = await database.executeCommand(command, context); - if (command.name === 'findOne') { - if (result === 'null') { - throw new Error(`Could not find any documents`); - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const document: IMongoDocument = EJSON.parse(result); - const collectionName: string = nonNullProp(command, 'collection'); - - const collectionId: string = `${database.fullId}/${collectionName}`; - const colNode: MongoCollectionTreeItem | undefined = await ext.rgApi.appResourceTree.findTreeItem( - collectionId, - context, - ); - if (!colNode) { - throw new Error(localize('failedToFind', 'Failed to find collection "{0}".', collectionName)); - } - const docNode = new MongoDocumentTreeItem(colNode, document); - await ext.fileSystem.showTextDocument(docNode, { viewColumn: vscode.ViewColumn.Beside }); - } else { - if (readOnlyContent) { - await readOnlyContent.append(`${result}${EOL}${EOL}`); - } else { - const label: string = 'Scrapbook-results'; - const fullId: string = `${database.fullId}/${label}`; - await openReadOnlyContent({ label, fullId }, result, '.json', { - viewColumn: vscode.ViewColumn.Beside, - }); - } - - await refreshTreeAfterCommand(database, command, context); - } - } - } else { - throw new Error('No MongoDB command found at the current cursor location.'); - } -} - -async function refreshTreeAfterCommand( - database: MongoDatabaseTreeItem, - command: MongoCommand, - context: IActionContext, -): Promise { - if (command.name === 'drop') { - await database.refresh(context); - } else if ( - command.collection && - command.name && - /^(insert|update|delete|replace|remove|write|bulkWrite)/i.test(command.name) - ) { - const collectionNode = await ext.rgApi.appResourceTree.findTreeItem( - database.fullId + '/' + command.collection, - context, - ); - if (collectionNode) { - await collectionNode.refresh(context); - } - } -} - export function getAllCommandsFromText(content: string): MongoCommand[] { const lexer = new mongoLexer(new InputStream(content)); const lexerListener = new LexerErrorListener(); diff --git a/src/mongo/MongoScrapbookService.ts b/src/mongo/MongoScrapbookService.ts new file mode 100644 index 000000000..01c4b0a68 --- /dev/null +++ b/src/mongo/MongoScrapbookService.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { openReadOnlyContent, type IActionContext } from '@microsoft/vscode-azext-utils'; +import { EOL } from 'os'; +import * as vscode from 'vscode'; +import { ext } from '../extensionVariables'; +import { CredentialCache } from '../mongoClusters/CredentialCache'; +import { type DatabaseItemModel } from '../mongoClusters/MongoClustersClient'; +import { type MongoClusterModel } from '../mongoClusters/tree/MongoClusterModel'; +import { type MongoAccountModel } from '../tree/mongo/MongoAccountModel'; +import { type MongoCommand } from './MongoCommand'; +import { findCommandAtPosition, getAllCommandsFromText } from './MongoScrapbookHelpers'; +import { MongoShellScriptRunner } from './MongoShellScriptRunner'; +import { MongoCodeLensProvider } from './services/MongoCodeLensProvider'; + +export class MongoScrapbookServiceImpl { + //-------------------------------------------------------------------------------- + // Connection Management + //-------------------------------------------------------------------------------- + + private _cluster: MongoClusterModel | MongoAccountModel | undefined; + private _database: DatabaseItemModel | undefined; + private readonly _mongoCodeLensProvider = new MongoCodeLensProvider(); + + /** + * Provides a CodeLens provider for the workspace. + */ + public getCodeLensProvider(): MongoCodeLensProvider { + return this._mongoCodeLensProvider; + } + + /** + * Sets the current cluster and database, updating the CodeLens provider. + */ + public async setConnectedCluster(cluster: MongoClusterModel | MongoAccountModel, database: DatabaseItemModel) { + // Update information + this._cluster = cluster; + this._database = database; + this._mongoCodeLensProvider.updateCodeLens(); + + // Update the Language Client/Server + // The language server needs credentials to connect to the cluster.. + await ext.mongoLanguageClient.connect( + CredentialCache.getConnectionStringWithPassword(this._cluster.id), + this._database.name, + ); + } + + /** + * Clears the current connection. + */ + public async clearConnection() { + this._cluster = undefined; + this._database = undefined; + this._mongoCodeLensProvider.updateCodeLens(); + await ext.mongoLanguageClient.disconnect(); + } + + /** + * Returns true if a cluster and database are set. + */ + public isConnected(): boolean { + return !!this._cluster && !!this._database; + } + + /** + * Returns the current database name. + */ + public getDatabaseName(): string | undefined { + return this._database?.name; + } + + /** + * Returns the current cluster ID. + */ + public getClusterId(): string | undefined { + return this._cluster?.id; + } + + /** + * Returns a friendly display name of the connected cluster/database. + */ + public getDisplayName(): string | undefined { + return this._cluster && this._database ? `${this._cluster.name}/${this._database.name}` : undefined; + } + + //-------------------------------------------------------------------------------- + // Command Execution + //-------------------------------------------------------------------------------- + + private _isExecutingAllCommands: boolean = false; + private _singleCommandInExecution: MongoCommand | undefined; + + /** + * Executes all Mongo commands in the given document. + * + * Note: This method will call use() before executing the commands to + * ensure that the commands are run in the correct database. It's done for backwards + * compatibility with the previous behavior. + */ + public async executeAllCommands(context: IActionContext, document: vscode.TextDocument): Promise { + if (!this.isConnected()) { + throw new Error('Please connect to a MongoDB database before running a Scrapbook command.'); + } + + const commands: MongoCommand[] = getAllCommandsFromText(document.getText()); + if (!commands.length) { + void vscode.window.showInformationMessage('No commands found in this document.'); + return; + } + + this.setExecutingAllCommandsFlag(true); + try { + const label = 'Scrapbook-run-all-results'; + const fullId = `${this.getDisplayName()}/${label}`; + + const readOnlyContent = await openReadOnlyContent({ label, fullId }, '', '.json', { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + + const shellRunner = await MongoShellScriptRunner.createShell(context, { + connectionString: CredentialCache.getConnectionStringWithPassword(this.getClusterId()!), + isEmulator: false, + }); + + try { + // preselect the database for the user + // this is done for backwards compatibility with the previous behavior + await shellRunner.executeScript(`use(\`${MongoScrapbookService.getDatabaseName()}\`)`); + + for (const cmd of commands) { + await this.executeSingleCommand(context, cmd, readOnlyContent, shellRunner); + } + } finally { + shellRunner.dispose(); + } + } finally { + this.setExecutingAllCommandsFlag(false); + } + } + + /** + * Executes a single Mongo command defined at the specified position in the document. + * + * Note: This method will call use() before executing the command to + * ensure that the command are is in the correct database. It's done for backwards + * compatibility with the previous behavior. + */ + public async executeCommandAtPosition( + context: IActionContext, + document: vscode.TextDocument, + position: vscode.Position, + ): Promise { + if (!this.isConnected()) { + throw new Error('Please connect to a MongoDB database before running a Scrapbook command.'); + } + + const commands = getAllCommandsFromText(document.getText()); + const command = findCommandAtPosition(commands, position); + + const label = 'Scrapbook-run-command-results'; + const fullId = `${this.getDisplayName()}/${label}`; + const readOnlyContent = await openReadOnlyContent({ label, fullId }, '', '.json', { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + + await this.executeSingleCommand(context, command, readOnlyContent, undefined, this.getDatabaseName()); + } + + /** + * Indicates whether multiple commands are being executed at once. + */ + public isExecutingAllCommands(): boolean { + return this._isExecutingAllCommands; + } + + /** + * Records the state for whether all commands are executing. + */ + public setExecutingAllCommandsFlag(state: boolean): void { + this._isExecutingAllCommands = state; + this._mongoCodeLensProvider.updateCodeLens(); + } + + /** + * Returns the command currently in execution, if any. + */ + public getSingleCommandInExecution(): MongoCommand | undefined { + return this._singleCommandInExecution; + } + + /** + * Sets or clears the command currently being executed. + */ + public setSingleCommandInExecution(command: MongoCommand | undefined): void { + this._singleCommandInExecution = command; + this._mongoCodeLensProvider.updateCodeLens(); + } + + //-------------------------------------------------------------------------------- + // Internal Helpers + //-------------------------------------------------------------------------------- + + /** + * Runs a single command against the Mongo shell. If a shell instance is not provided, + * this method creates its own, executes the command, then disposes the shell. This + * includes error handling for parse problems, ephemeral shell usage, and optional + * output to a read-only content view. + */ + private async executeSingleCommand( + context: IActionContext, + command: MongoCommand, + readOnlyContent?: { append(value: string): Promise }, + shellRunner?: MongoShellScriptRunner, + preselectedDatabase?: string, // this will run the 'use ' command before the actual command. + ): Promise { + if (!this.isConnected()) { + throw new Error('Not connected to any MongoDB database.'); + } + + if (command.errors?.length) { + const firstErr = command.errors[0]; + throw new Error( + `Unable to parse syntax near line ${firstErr.range.start.line + 1}, col ${firstErr.range.start.character + 1}: ${firstErr.message}`, + ); + } + + this.setSingleCommandInExecution(command); + let ephemeralShell = false; + + try { + if (!shellRunner) { + shellRunner = await MongoShellScriptRunner.createShell(context, { + connectionString: CredentialCache.getConnectionStringWithPassword(this.getClusterId()!), + isEmulator: false, + }); + ephemeralShell = true; + } + + if (preselectedDatabase) { + await shellRunner.executeScript(`use(\`${preselectedDatabase}\`)`); + } + + const result = await shellRunner.executeScript(command.text); + if (!result) { + throw new Error('No result returned from the MongoDB shell.'); + } + + if (readOnlyContent) { + await readOnlyContent.append(result + EOL + EOL); + } else { + const fallbackLabel = 'Scrapbook-results'; + const fallbackId = `${this.getDatabaseName()}/${fallbackLabel}`; + await openReadOnlyContent({ label: fallbackLabel, fullId: fallbackId }, result, '.json', { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + } + } finally { + this.setSingleCommandInExecution(undefined); + + if (ephemeralShell) { + shellRunner?.dispose(); + } + } + } +} + +// Export a single instance that the rest of your extension can import +export const MongoScrapbookService = new MongoScrapbookServiceImpl(); diff --git a/src/mongo/MongoShell.ts b/src/mongo/MongoShell.ts deleted file mode 100644 index 54bddba84..000000000 --- a/src/mongo/MongoShell.ts +++ /dev/null @@ -1,204 +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 { parseError } from '@microsoft/vscode-azext-utils'; -import * as os from 'os'; -import * as vscode from 'vscode'; -import { InteractiveChildProcess } from '../utils/InteractiveChildProcess'; -import { randomUtils } from '../utils/randomUtils'; -import { getBatchSizeSetting } from '../utils/workspacUtils'; -import { wrapError } from '../utils/wrapError'; - -const timeoutMessage = - "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting."; - -const mongoShellMoreMessage = 'Type "it" for more'; -const extensionMoreMessage = '(More)'; - -const sentinelBase = 'EXECUTION COMPLETED'; -const sentinelRegex = /"?EXECUTION COMPLETED [0-9a-fA-F]{10}"?/; -function createSentinel(): string { - return `${sentinelBase} ${randomUtils.getRandomHexString(10)}`; -} - -export class MongoShell extends vscode.Disposable { - constructor( - private _process: InteractiveChildProcess, - private _timeoutSeconds: number, - ) { - super(() => this.dispose()); - } - - public static async create( - execPath: string, - execArgs: string[], - connectionString: string, - isEmulator: boolean | undefined, - outputChannel: vscode.OutputChannel, - timeoutSeconds: number, - ): Promise { - try { - const args: string[] = execArgs.slice() || []; // Snapshot since we modify it - args.push(connectionString); - - if (isEmulator) { - // Without these the connection will fail due to the self-signed DocDB certificate - if (args.indexOf('--ssl') < 0) { - args.push('--ssl'); - } - if (args.indexOf('--sslAllowInvalidCertificates') < 0) { - args.push('--sslAllowInvalidCertificates'); - } - } - - const process: InteractiveChildProcess = await InteractiveChildProcess.create({ - outputChannel: outputChannel, - command: execPath, - args, - outputFilterSearch: sentinelRegex, - outputFilterReplace: '', - }); - const shell: MongoShell = new MongoShell(process, timeoutSeconds); - - // Try writing an empty script to verify the process is running correctly and allow us - // to catch any errors related to the start-up of the process before trying to write to it. - await shell.executeScript(''); - - // Configure the batch size - await shell.executeScript(`DBQuery.shellBatchSize = ${getBatchSizeSetting()}`); - - return shell; - } catch (error) { - throw wrapCheckOutputWindow(error); - } - } - - public dispose(): void { - this._process.kill(); - } - - public async useDatabase(database: string): Promise { - return await this.executeScript(`use ${database}`); - } - - public async executeScript(script: string): Promise { - script = convertToSingleLine(script); - - let stdOut = ''; - const sentinel = createSentinel(); - - const disposables: vscode.Disposable[] = []; - try { - // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor - const result = await new Promise(async (resolve, reject) => { - try { - startScriptTimeout(this._timeoutSeconds, reject); - - // Hook up events - disposables.push( - this._process.onStdOut((text) => { - stdOut += text; - // eslint-disable-next-line prefer-const - let { text: stdOutNoSentinel, removed } = removeSentinel(stdOut, sentinel); - if (removed) { - // The sentinel was found, which means we are done. - - // Change the "type 'it' for more" message to one that doesn't ask users to type anything, - // since we're not currently interactive like that. - // CONSIDER: Ideally we would allow users to click a button to iterate through more data, - // or even just do it for them - stdOutNoSentinel = stdOutNoSentinel.replace( - mongoShellMoreMessage, - extensionMoreMessage, - ); - - resolve(stdOutNoSentinel); - } - }), - ); - disposables.push( - this._process.onStdErr((text) => { - // Mongo shell only writes to STDERR for errors relating to starting up. Script errors go to STDOUT. - // So consider this an error. - // (It's okay if we fire this multiple times, the first one wins.) - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(wrapCheckOutputWindow(text.trim())); - }), - ); - disposables.push( - this._process.onError((error) => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(error); - }), - ); - - // Write the script to STDIN - if (script) { - this._process.writeLine(script); - } - - // Mark end of result by sending the sentinel wrapped in quotes so the console will spit - // it back out as a string value after it's done processing the script - const quotedSentinel = `"${sentinel}"`; - this._process.writeLine(quotedSentinel); // (Don't display the sentinel) - } catch (error) { - // new Promise() doesn't seem to catch exceptions in an async function, we need to explicitly reject it - - if ((<{ code?: string }>error).code === 'EPIPE') { - // Give a chance for start-up errors to show up before rejecting with this more general error message - await delay(500); - // eslint-disable-next-line no-ex-assign - error = new Error('The process exited prematurely.'); - } - - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors - reject(wrapCheckOutputWindow(error)); - } - }); - - return result.trim(); - } finally { - // Dispose event handlers - for (const d of disposables) { - d.dispose(); - } - } - } -} - -function startScriptTimeout(timeoutSeconds: number, reject: (err: unknown) => void): void { - if (timeoutSeconds > 0) { - setTimeout(() => { - reject(timeoutMessage); - }, timeoutSeconds * 1000); - } -} - -function convertToSingleLine(script: string): string { - return script - .split(os.EOL) - .map((line) => line.trim()) - .join(''); -} - -function removeSentinel(text: string, sentinel: string): { text: string; removed: boolean } { - const index = text.indexOf(sentinel); - if (index >= 0) { - return { text: text.slice(0, index), removed: true }; - } else { - return { text, removed: false }; - } -} - -async function delay(milliseconds: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} - -function wrapCheckOutputWindow(error: unknown): unknown { - const checkOutputMsg = 'The output window may contain additional information.'; - return parseError(error).message.includes(checkOutputMsg) ? error : wrapError(error, checkOutputMsg); -} diff --git a/src/mongo/MongoShellScriptRunner.ts b/src/mongo/MongoShellScriptRunner.ts new file mode 100644 index 000000000..6015fed4a --- /dev/null +++ b/src/mongo/MongoShellScriptRunner.ts @@ -0,0 +1,469 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { nonNullValue, parseError, UserCancelledError, type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as fse from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { ext } from '../extensionVariables'; +import * as cpUtils from '../utils/cp'; +import { InteractiveChildProcess } from '../utils/InteractiveChildProcess'; +import { randomUtils } from '../utils/randomUtils'; +import { getBatchSizeSetting } from '../utils/workspacUtils'; +import { wrapError } from '../utils/wrapError'; + +const mongoExecutableFileName = process.platform === 'win32' ? 'mongo.exe' : 'mongosh'; + +const timeoutMessage = + "Timed out trying to execute the Mongo script. To use a longer timeout, modify the VS Code 'mongo.shell.timeout' setting."; + +const mongoShellMoreMessage = 'Type "it" for more'; +const extensionMoreMessage = '(More)'; + +const sentinelBase = 'EXECUTION COMPLETED'; +const sentinelRegex = /"?EXECUTION COMPLETED [0-9a-fA-F]{10}"?/; +function createSentinel(): string { + return `${sentinelBase} ${randomUtils.getRandomHexString(10)}`; +} + +export class MongoShellScriptRunner extends vscode.Disposable { + private static _previousShellPathSetting: string | undefined; + private static _cachedShellPathOrCmd: string | undefined; + + private constructor( + private _process: InteractiveChildProcess, + private _timeoutSeconds: number, + ) { + super(() => this.dispose()); + } + + public static async createShellProcessHelper( + execPath: string, + execArgs: string[], + connectionString: string, + isEmulator: boolean | undefined, + outputChannel: vscode.OutputChannel, + timeoutSeconds: number, + ): Promise { + try { + const args: string[] = execArgs.slice() || []; // Snapshot since we modify it + args.push(connectionString); + + if (isEmulator) { + // Without these the connection will fail due to the self-signed DocDB certificate + if (args.indexOf('--ssl') < 0) { + args.push('--ssl'); + } + if (args.indexOf('--sslAllowInvalidCertificates') < 0) { + args.push('--sslAllowInvalidCertificates'); + } + } + + const process: InteractiveChildProcess = await InteractiveChildProcess.create({ + outputChannel: outputChannel, + command: execPath, + args, + outputFilterSearch: sentinelRegex, + outputFilterReplace: '', + }); + const shell: MongoShellScriptRunner = new MongoShellScriptRunner(process, timeoutSeconds); + + /** + * The 'unwrapIfCursor' helper is used to safely handle MongoDB queries in the shell, + * especially for commands like db.movies.find() that return a cursor. + * + * When a user runs a command returning a cursor, it points to a query's result set + * and exposes methods such as hasNext and next. Attempting to stringify + * the raw cursor directly with EJSON.stringify can fail due to circular references + * and other internal structures. + * + * To avoid this issue, 'unwrapIfCursor' checks if the returned object is indeed a + * cursor. If it is, we manually iterate up to a fixed limit of documents, and + * return those as a plain array. This prevents the shell from crashing or throwing + * errors about circular structures, while still returning actual document data in + * JSON format. + * + * For non-cursor commands (like db.hostInfo() or db.movies.findOne()), we + * simply return the object unchanged. + */ + const unwrapIfCursorFunction = + 'function unwrapIfCursor(value) {\n' + + " if (value && typeof value.hasNext === 'function' && typeof value.next === 'function') {\n" + + ' const docs = [];\n' + + ' const MAX_DOCS = 50;\n' + + ' let count = 0;\n' + + ' while (value.hasNext() && count < MAX_DOCS) {\n' + + ' docs.push(value.next());\n' + + ' count++;\n' + + ' }\n' + + ' if (value.hasNext()) {\n' + + ' docs.push({ cursor: "omitted", note: "Additional results are not displayed." });\n' + + ' }\n' + + ' return docs;\n' + + ' }\n' + + ' return value;\n' + + '}'; + process.writeLine(`${convertToSingleLine(unwrapIfCursorFunction)}`); + + // Try writing an empty script to verify the process is running correctly and allow us + // to catch any errors related to the start-up of the process before trying to write to it. + await shell.executeScript(''); + + ext.outputChannel.appendLine('Mongo Shell connected.'); + + // Configure the batch size + await shell.executeScript(`config.set("displayBatchSize", ${getBatchSizeSetting()})`); + + return shell; + } catch (error) { + throw wrapCheckOutputWindow(error); + } + } + + public static async createShell( + context: IActionContext, + connectionInfo: { connectionString: string; isEmulator: boolean }, + ): Promise { + const config = vscode.workspace.getConfiguration(); + let shellPath: string | undefined = config.get(ext.settingsKeys.mongoShellPath); + const shellArgs: string[] = config.get(ext.settingsKeys.mongoShellArgs, []); + + if ( + !shellPath || + !MongoShellScriptRunner._cachedShellPathOrCmd || + MongoShellScriptRunner._previousShellPathSetting !== shellPath + ) { + // Only do this if setting changed since last time + shellPath = await MongoShellScriptRunner._determineShellPathOrCmd(context, shellPath); + MongoShellScriptRunner._previousShellPathSetting = shellPath; + } + MongoShellScriptRunner._cachedShellPathOrCmd = shellPath; + + const timeout = + 1000 * nonNullValue(config.get(ext.settingsKeys.mongoShellTimeout), 'mongoShellTimeout'); + return MongoShellScriptRunner.createShellProcessHelper( + shellPath, + shellArgs, + connectionInfo.connectionString, + connectionInfo.isEmulator, + ext.outputChannel, + timeout, + ); + } + + public dispose(): void { + this._process.kill(); + } + + public async useDatabase(database: string): Promise { + return await this.executeScript(`use ${database}`); + } + + public async executeScript(script: string): Promise { + // 1. Convert to single line (existing logic) + script = convertToSingleLine(script); + + // 2. If the user typed something, wrap it in EJSON.stringify(...) + // This assumes the user has typed exactly one expression that + // returns something (e.g. db.hostInfo(), db.myCollection.find(), etc.) + if (script.trim().length > 0 && !script.startsWith('print(EJSON.stringify(')) { + // Remove trailing semicolons plus any trailing space + // e.g. "db.hostInfo(); " => "db.hostInfo()" + script = script.replace(/;+\s*$/, ''); + + // Wrap in EJSON.stringify() and unwrapIfCursor + script = `print(EJSON.stringify(unwrapIfCursor(${script}), null, 4))`; + } + + let stdOut = ''; + const sentinel = createSentinel(); + + const disposables: vscode.Disposable[] = []; + try { + // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor + const result = await new Promise(async (resolve, reject) => { + try { + startScriptTimeout(this._timeoutSeconds, reject); + + // Hook up events + disposables.push( + this._process.onStdOut((text) => { + stdOut += text; + // eslint-disable-next-line prefer-const + let { text: stdOutNoSentinel, removed } = removeSentinel(stdOut, sentinel); + if (removed) { + // The sentinel was found, which means we are done. + + // Change the "type 'it' for more" message to one that doesn't ask users to type anything, + // since we're not currently interactive like that. + // CONSIDER: Ideally we would allow users to click a button to iterate through more data, + // or even just do it for them + stdOutNoSentinel = stdOutNoSentinel.replace( + mongoShellMoreMessage, + extensionMoreMessage, + ); + + const responseText = removePromptLeadingAndTrailing(stdOutNoSentinel); + + resolve(responseText); + } + }), + ); + disposables.push( + this._process.onStdErr((text) => { + // Mongo shell only writes to STDERR for errors relating to starting up. Script errors go to STDOUT. + // So consider this an error. + // (It's okay if we fire this multiple times, the first one wins.) + + // Split the stderr text into lines, trim them, and remove empty lines + const lines: string[] = text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + + // Filter out lines recognized as benign debug/telemetry info + const unknownErrorLines: string[] = lines.filter( + (line) => !this.isNonErrorMongoshStderrLine(line), + ); + + // If there are any lines left after filtering, assume they are real errors + if (unknownErrorLines.length > 0) { + for (const line of unknownErrorLines) { + ext.outputChannel.appendLine('Mongo Shell Error: ' + line); + } + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(wrapCheckOutputWindow(unknownErrorLines.join('\n'))); + } else { + // Otherwise, ignore the lines since they're known safe + // (e.g. "Debugger listening on ws://..." or "Using Mongosh: 1.9.0", etc.) + } + }), + ); + disposables.push( + this._process.onError((error) => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(error); + }), + ); + + // Write the script to STDIN + if (script) { + this._process.writeLine(script); + } + + // Mark end of result by sending the sentinel wrapped in quotes so the console will spit + // it back out as a string value after it's done processing the script + const quotedSentinel = `"${sentinel}"`; + this._process.writeLine(quotedSentinel); // (Don't display the sentinel) + } catch (error) { + // new Promise() doesn't seem to catch exceptions in an async function, we need to explicitly reject it + + if ((<{ code?: string }>error).code === 'EPIPE') { + // Give a chance for start-up errors to show up before rejecting with this more general error message + await delay(500); + // eslint-disable-next-line no-ex-assign + error = new Error('The process exited prematurely.'); + } + + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + reject(wrapCheckOutputWindow(error)); + } + }); + + return result.trim(); + } finally { + // Dispose event handlers + for (const d of disposables) { + d.dispose(); + } + } + } + + /** + * Checks if the stderr line from mongosh is a known "benign" message that + * should NOT be treated as an error. + */ + private isNonErrorMongoshStderrLine(line: string): boolean { + /** + * Certain versions of mongosh can print debug or telemetry messages to stderr + * that are not actually errors (especially if VS Code auto-attach is running). + * Below is a list of known message fragments that we can safely ignore. + * + * IMPORTANT: This list is not exhaustive and may need to be updated as new + * versions of mongosh introduce new messages. + */ + const knownNonErrorSubstrings: string[] = [ + // Node.js Inspector (auto-attach) messages: + 'Debugger listening on ws://', + 'Debugger attached.', + 'For help, see: https://nodejs.org/en/docs/inspector', + + // MongoDB Shell general info messages: + 'Current Mongosh Log ID:', + 'Using Mongosh:', + 'Using MongoDB:', + + // Telemetry or analytics prompts: + 'To enable telemetry, run:', + 'Disable telemetry by running:', + + // Occasionally, devtools or local shell info: + 'DevTools listening on ws://', + 'The server generated these startup warnings:', + ]; + + return knownNonErrorSubstrings.some((pattern) => line.includes(pattern)); + } + + private static async _determineShellPathOrCmd( + context: IActionContext, + shellPathSetting: string | undefined, + ): Promise { + if (!shellPathSetting) { + // User hasn't specified the path + if (await cpUtils.commandSucceeds('mongo', '--version')) { + // If the user already has mongo in their system path, just use that + return 'mongo'; + } else { + // If all else fails, prompt the user for the mongo path + const openFile: vscode.MessageItem = { title: `Browse to ${mongoExecutableFileName}` }; + const browse: vscode.MessageItem = { title: 'Open installation page' }; + const noMongoError: string = + 'This functionality requires the Mongo DB shell, but we could not find it in the path or using the mongo.shell.path setting.'; + const response = await context.ui.showWarningMessage( + noMongoError, + { stepName: 'promptForMongoPath' }, + browse, + openFile, + ); + if (response === openFile) { + // eslint-disable-next-line no-constant-condition + while (true) { + const newPath: vscode.Uri[] = await context.ui.showOpenDialog({ + filters: { 'Executable Files': [process.platform === 'win32' ? 'exe' : ''] }, + openLabel: `Select ${mongoExecutableFileName}`, + stepName: 'openMongoExeFile', + }); + const fsPath = newPath[0].fsPath; + const baseName = path.basename(fsPath); + if (baseName !== mongoExecutableFileName) { + const useAnyway: vscode.MessageItem = { title: 'Use anyway' }; + const tryAgain: vscode.MessageItem = { title: 'Try again' }; + const response2 = await context.ui.showWarningMessage( + `Expected a file named "${mongoExecutableFileName}, but the selected filename is "${baseName}"`, + { stepName: 'confirmMongoExeFile' }, + useAnyway, + tryAgain, + ); + if (response2 === tryAgain) { + continue; + } + } + + await vscode.workspace + .getConfiguration() + .update(ext.settingsKeys.mongoShellPath, fsPath, vscode.ConfigurationTarget.Global); + return fsPath; + } + } else if (response === browse) { + void vscode.commands.executeCommand( + 'vscode.open', + vscode.Uri.parse('https://docs.mongodb.com/manual/installation/'), + ); + // default down to cancel error because MongoShell.create errors out if undefined is passed as the shellPath + } + + throw new UserCancelledError('createShell'); + } + } else { + // User has specified the path or command. Sometimes they set the folder instead of a path to the file, let's check that and auto fix + if (await fse.pathExists(shellPathSetting)) { + const stat = await fse.stat(shellPathSetting); + if (stat.isDirectory()) { + return path.join(shellPathSetting, mongoExecutableFileName); + } + } + + return shellPathSetting; + } + } +} + +function startScriptTimeout(timeoutSeconds: number, reject: (err: unknown) => void): void { + if (timeoutSeconds > 0) { + setTimeout(() => { + reject(timeoutMessage); + }, timeoutSeconds * 1000); + } +} + +function convertToSingleLine(script: string): string { + return script + .split(os.EOL) + .map((line) => line.trim()) + .join(''); +} + +function removeSentinel(text: string, sentinel: string): { text: string; removed: boolean } { + const index = text.indexOf(sentinel); + if (index >= 0) { + return { text: text.slice(0, index), removed: true }; + } else { + return { text, removed: false }; + } +} + +/** + * Removes a Mongo shell prompt line if it exists at the very start or the very end of `text`. + */ +function removePromptLeadingAndTrailing(text: string): string { + // Trim trailing spaces/newlines, but keep internal newlines. + text = text.replace(/\s+$/, ''); + + // Regex to detect standard MongoDB shell prompts: + // 1) [mongos] secondDb> + // 2) [mongo] test> + // 3) globaldb [primary] SampleDB> + const promptRegex = /^(\[mongo.*?\].*?>|.*?\[.*?\]\s+\S+>)$/; + + // Check if the *first line* contains a prompt + const firstNewlineIndex = text.indexOf('\n'); + if (firstNewlineIndex === -1) { + return text.replace(promptRegex, '').trim(); + } + + // Extract the first line + const firstLine = text.substring(0, firstNewlineIndex).trim(); + if (promptRegex.test(firstLine)) { + // Remove the prompt from the first line + text = text.replace(firstLine, firstLine.replace(promptRegex, '').trim()); + } + + // Check if the *last line* contains a prompt + const lastNewlineIndex = text.lastIndexOf('\n'); + if (lastNewlineIndex === -1) { + return text.replace(promptRegex, '').trim(); + } + + const lastLine = text.substring(lastNewlineIndex + 1).trim(); + if (promptRegex.test(lastLine)) { + // Remove the prompt from the last line + text = text.replace(lastLine, lastLine.replace(promptRegex, '').trim()); + } + + return text; +} + +async function delay(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds); + }); +} + +function wrapCheckOutputWindow(error: unknown): unknown { + const checkOutputMsg = 'The output window may contain additional information.'; + return parseError(error).message.includes(checkOutputMsg) ? error : wrapError(error, checkOutputMsg); +} diff --git a/src/mongo/commands/connectMongoDatabase.ts b/src/mongo/commands/connectMongoDatabase.ts index 20de5c4f9..dafabbd24 100644 --- a/src/mongo/commands/connectMongoDatabase.ts +++ b/src/mongo/commands/connectMongoDatabase.ts @@ -3,64 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - callWithTelemetryAndErrorHandling, - type AzExtTreeItem, - type IActionContext, - type ITreeItemPickerContext, -} from '@microsoft/vscode-azext-utils'; -import { MongoExperience, type Experience } from '../../AzureDBExperiences'; -import { ext } from '../../extensionVariables'; -import { setConnectedNode } from '../setConnectedNode'; -import { MongoDatabaseTreeItem } from '../tree/MongoDatabaseTreeItem'; -import { pickMongo } from './pickMongo'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import * as vscode from 'vscode'; +import { type CollectionItem } from '../../mongoClusters/tree/CollectionItem'; +import { type DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; +import { localize } from '../../utils/localize'; +import { MongoScrapbookService } from '../MongoScrapbookService'; -export const connectedMongoKey: string = 'ms-azuretools.vscode-cosmosdb.connectedDB'; - -export async function loadPersistedMongoDB(): Promise { - return callWithTelemetryAndErrorHandling('cosmosDB.loadPersistedMongoDB', async (context: IActionContext) => { - context.errorHandling.suppressDisplay = true; - context.telemetry.properties.isActivationEvent = 'true'; - - try { - const persistedNodeId: string | undefined = ext.context.globalState.get(connectedMongoKey); - if (persistedNodeId && (!ext.connectedMongoDB || ext.connectedMongoDB.fullId !== persistedNodeId)) { - const persistedNode = await ext.rgApi.appResourceTree.findTreeItem(persistedNodeId, context); - if (persistedNode) { - await ext.mongoLanguageClient.client.onReady(); - await connectMongoDatabase(context, persistedNode as MongoDatabaseTreeItem); - } - } - } finally { - // Get code lens provider out of initializing state if there's no connected DB - if (!ext.connectedMongoDB) { - ext.mongoCodeLensProvider.setConnectedDatabase(undefined); - } - } - }); -} - -export async function connectMongoDatabase(context: IActionContext, node?: MongoDatabaseTreeItem): Promise { +export async function connectMongoDatabase( + _context: IActionContext, + node?: DatabaseItem | CollectionItem, +): Promise { if (!node) { - // Include defaultExperience in the context to prevent https://github.com/microsoft/vscode-cosmosdb/issues/1517 - const experienceContext: ITreeItemPickerContext & { defaultExperience?: Experience } = { - ...context, - defaultExperience: MongoExperience, - }; - node = await pickMongo(experienceContext, MongoDatabaseTreeItem.contextValue); + await vscode.window.showInformationMessage( + localize( + 'mongo.scrapbook.howtoconnect', + 'You can connect to a different Mongo Cluster by:\n\n' + + "1. Locating the one you'd like from the resource view,\n" + + '2. Selecting a database or a collection,\n' + + '3. Right-clicking and then choosing the "Mongo Scrapbook" submenu,\n' + + '4. Selecting the "Connect to this database" command.', + ), + { modal: true }, + ); + return; } - const oldNodeId: string | undefined = ext.connectedMongoDB && ext.connectedMongoDB.fullId; - await ext.mongoLanguageClient.connect(node.connectionString, node.databaseName); - void ext.context.globalState.update(connectedMongoKey, node.fullId); - setConnectedNode(node); - await node.refresh(context); - - if (oldNodeId) { - // We have to use findTreeItem to get the instance of the old node that's being displayed in the ext.rgApi.appResourceTree. Our specific instance might have been out-of-date - const oldNode: AzExtTreeItem | undefined = await ext.rgApi.appResourceTree.findTreeItem(oldNodeId, context); - if (oldNode) { - await oldNode.refresh(context); - } - } + await MongoScrapbookService.setConnectedCluster(node.mongoCluster, node.databaseInfo); } diff --git a/src/mongo/commands/createMongoCollection.ts b/src/mongo/commands/createMongoCollection.ts deleted file mode 100644 index b5493b457..000000000 --- a/src/mongo/commands/createMongoCollection.ts +++ /dev/null @@ -1,17 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { MongoDatabaseTreeItem } from '../tree/MongoDatabaseTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function createMongoCollection(context: IActionContext, node?: MongoDatabaseTreeItem): Promise { - if (!node) { - node = await pickMongo(context, MongoDatabaseTreeItem.contextValue); - } - const collectionNode = await node.createChild(context); - await vscode.commands.executeCommand('cosmosDB.connectMongoDB', collectionNode.parent); -} diff --git a/src/mongo/commands/createMongoDatabase.ts b/src/mongo/commands/createMongoDatabase.ts deleted file mode 100644 index a527ecaea..000000000 --- a/src/mongo/commands/createMongoDatabase.ts +++ /dev/null @@ -1,20 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { type MongoAccountTreeItem } from '../tree/MongoAccountTreeItem'; -import { type MongoDatabaseTreeItem } from '../tree/MongoDatabaseTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function createMongoDatabase(context: IActionContext, node?: MongoAccountTreeItem): Promise { - if (!node) { - node = await pickMongo(context); - } - const databaseNode = await node.createChild(context); - await databaseNode.createChild(context); - - await vscode.commands.executeCommand('cosmosDB.connectMongoDB', databaseNode); -} diff --git a/src/mongo/commands/createMongoDocument.ts b/src/mongo/commands/createMongoDocument.ts deleted file mode 100644 index b00994c5a..000000000 --- a/src/mongo/commands/createMongoDocument.ts +++ /dev/null @@ -1,17 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { MongoCollectionTreeItem } from '../tree/MongoCollectionTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function createMongoDocument(context: IActionContext, node?: MongoCollectionTreeItem): Promise { - if (!node) { - node = await pickMongo(context, MongoCollectionTreeItem.contextValue); - } - const documentNode = await node.createChild(context); - await vscode.commands.executeCommand('cosmosDB.openDocument', documentNode); -} diff --git a/src/mongo/commands/createMongoScrapbook.ts b/src/mongo/commands/createMongoScrapbook.ts index 253594ffe..1b77353a6 100644 --- a/src/mongo/commands/createMongoScrapbook.ts +++ b/src/mongo/commands/createMongoScrapbook.ts @@ -3,8 +3,25 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type CollectionItem } from '../../mongoClusters/tree/CollectionItem'; +import { type DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; import * as vscodeUtil from '../../utils/vscodeUtils'; +import { MongoScrapbookService } from '../MongoScrapbookService'; -export async function createMongoSrapbook(): Promise { - await vscodeUtil.showNewFile('', 'Scrapbook', '.mongo'); +export async function createMongoScrapbook( + _context: IActionContext, + node: DatabaseItem | CollectionItem, +): Promise { + const initialFileContents: string = '// MongoDB API Scrapbook: Use this file to run MongoDB API commands\n\n'; + + // if (node instanceof CollectionItem) { + // initialFileContents += `\n\n// You are connected to the "${node.collectionInfo.name}" collection in the "${node.databaseInfo.name}" database.`; + // } else if (node instanceof DatabaseItem) { + // initialFileContents += `\n\n// You are connected to the "${node.databaseInfo.name}" database.`; + // } + + await MongoScrapbookService.setConnectedCluster(node.mongoCluster, node.databaseInfo); + + await vscodeUtil.showNewFile(initialFileContents, 'Scrapbook', '.mongo'); } diff --git a/src/mongo/commands/deleteMongoCollection.ts b/src/mongo/commands/deleteMongoCollection.ts deleted file mode 100644 index 1cbe0a3e0..000000000 --- a/src/mongo/commands/deleteMongoCollection.ts +++ /dev/null @@ -1,17 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { MongoCollectionTreeItem } from '../tree/MongoCollectionTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function deleteMongoCollection(context: IActionContext, node?: MongoCollectionTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickMongo(context, MongoCollectionTreeItem.contextValue); - } - await node.deleteTreeItem(context); -} diff --git a/src/mongo/commands/deleteMongoDatabase.ts b/src/mongo/commands/deleteMongoDatabase.ts deleted file mode 100644 index c9069e07b..000000000 --- a/src/mongo/commands/deleteMongoDatabase.ts +++ /dev/null @@ -1,31 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { ext } from '../../extensionVariables'; -import { localize } from '../../utils/localize'; -import { setConnectedNode } from '../setConnectedNode'; -import { MongoDatabaseTreeItem } from '../tree/MongoDatabaseTreeItem'; -import { connectedMongoKey } from './connectMongoDatabase'; -import { pickMongo } from './pickMongo'; - -export async function deleteMongoDB(context: IActionContext, node?: MongoDatabaseTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickMongo(context, MongoDatabaseTreeItem.contextValue); - } - await node.deleteTreeItem(context); - if (ext.connectedMongoDB && ext.connectedMongoDB.fullId === node.fullId) { - setConnectedNode(undefined); - void ext.context.globalState.update(connectedMongoKey, undefined); - // Temporary workaround for https://github.com/microsoft/vscode-cosmosdb/issues/1754 - void ext.mongoLanguageClient.disconnect(); - } - const successMessage = localize('deleteMongoDatabaseMsg', 'Successfully deleted database "{0}"', node.databaseName); - void vscode.window.showInformationMessage(successMessage); - ext.outputChannel.info(successMessage); -} diff --git a/src/mongo/commands/deleteMongoDocument.ts b/src/mongo/commands/deleteMongoDocument.ts deleted file mode 100644 index 86e0122b9..000000000 --- a/src/mongo/commands/deleteMongoDocument.ts +++ /dev/null @@ -1,17 +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 { type IActionContext, type ITreeItemPickerContext } from '@microsoft/vscode-azext-utils'; -import { MongoDocumentTreeItem } from '../tree/MongoDocumentTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function deleteMongoDocument(context: IActionContext, node?: MongoDocumentTreeItem): Promise { - const suppressCreateContext: ITreeItemPickerContext = context; - suppressCreateContext.suppressCreatePick = true; - if (!node) { - node = await pickMongo(context, MongoDocumentTreeItem.contextValue); - } - await node.deleteTreeItem(context); -} diff --git a/src/mongo/commands/executeAllMongoCommand.ts b/src/mongo/commands/executeAllMongoCommand.ts index 8ff3fadab..be23a9eb1 100644 --- a/src/mongo/commands/executeAllMongoCommand.ts +++ b/src/mongo/commands/executeAllMongoCommand.ts @@ -4,10 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { executeAllCommandsFromActiveEditor } from '../MongoScrapbook'; -import { loadPersistedMongoDB } from './connectMongoDatabase'; +import * as vscode from 'vscode'; +import { withProgress } from '../../utils/withProgress'; +import { MongoScrapbookService } from '../MongoScrapbookService'; export async function executeAllMongoCommand(context: IActionContext): Promise { - await loadPersistedMongoDB(); - await executeAllCommandsFromActiveEditor(context); + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('You must open a *.mongo file to run commands.'); + } + await withProgress( + MongoScrapbookService.executeAllCommands(context, editor.document), + 'Executing all Mongo commands in shell...', + ); } diff --git a/src/mongo/commands/executeMongoCommand.ts b/src/mongo/commands/executeMongoCommand.ts index 834817ae9..836356f4d 100644 --- a/src/mongo/commands/executeMongoCommand.ts +++ b/src/mongo/commands/executeMongoCommand.ts @@ -4,11 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import type * as vscode from 'vscode'; -import { executeCommandFromActiveEditor } from '../MongoScrapbook'; -import { loadPersistedMongoDB } from './connectMongoDatabase'; +import * as vscode from 'vscode'; +import { withProgress } from '../../utils/withProgress'; +import { MongoScrapbookService } from '../MongoScrapbookService'; export async function executeMongoCommand(context: IActionContext, position?: vscode.Position): Promise { - await loadPersistedMongoDB(); - await executeCommandFromActiveEditor(context, position); + const editor = vscode.window.activeTextEditor; + if (!editor) { + throw new Error('You must open a *.mongo file to run commands.'); + } + + const pos = position ?? editor.selection.start; + + await withProgress( + MongoScrapbookService.executeCommandAtPosition(context, editor.document, pos), + 'Executing Mongo command in shell...', + ); } diff --git a/src/mongo/commands/openMongoCollection.ts b/src/mongo/commands/openMongoCollection.ts deleted file mode 100644 index b97f32c82..000000000 --- a/src/mongo/commands/openMongoCollection.ts +++ /dev/null @@ -1,16 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { ext } from '../../extensionVariables'; -import { MongoCollectionTreeItem } from '../tree/MongoCollectionTreeItem'; -import { pickMongo } from './pickMongo'; - -export async function openMongoCollection(context: IActionContext, node?: MongoCollectionTreeItem): Promise { - if (!node) { - node = await pickMongo(context, MongoCollectionTreeItem.contextValue); - } - await ext.fileSystem.showTextDocument(node); -} diff --git a/src/mongo/commands/pickMongo.ts b/src/mongo/commands/pickMongo.ts deleted file mode 100644 index 4d8f20d0a..000000000 --- a/src/mongo/commands/pickMongo.ts +++ /dev/null @@ -1,18 +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 { type AzExtTreeItem, type IActionContext } from '@microsoft/vscode-azext-utils'; -import { cosmosMongoFilter } from '../../constants'; -import { ext } from '../../extensionVariables'; - -export async function pickMongo( - context: IActionContext, - expectedContextValue?: string | RegExp | (string | RegExp)[], -): Promise { - return await ext.rgApi.pickAppResource(context, { - filter: [cosmosMongoFilter], - expectedChildContextValue: expectedContextValue, - }); -} diff --git a/src/mongo/registerMongoCommands.ts b/src/mongo/registerMongoCommands.ts index 88b2627de..23fcac4d0 100644 --- a/src/mongo/registerMongoCommands.ts +++ b/src/mongo/registerMongoCommands.ts @@ -13,22 +13,14 @@ import { } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { ext } from '../extensionVariables'; -import { connectMongoDatabase, loadPersistedMongoDB } from './commands/connectMongoDatabase'; -import { createMongoCollection } from './commands/createMongoCollection'; -import { createMongoDatabase } from './commands/createMongoDatabase'; -import { createMongoDocument } from './commands/createMongoDocument'; -import { createMongoSrapbook } from './commands/createMongoScrapbook'; -import { deleteMongoCollection } from './commands/deleteMongoCollection'; -import { deleteMongoDB } from './commands/deleteMongoDatabase'; -import { deleteMongoDocument } from './commands/deleteMongoDocument'; +import { connectMongoDatabase } from './commands/connectMongoDatabase'; +import { createMongoScrapbook } from './commands/createMongoScrapbook'; import { executeAllMongoCommand } from './commands/executeAllMongoCommand'; import { executeMongoCommand } from './commands/executeMongoCommand'; -import { launchMongoShell } from './commands/launchMongoShell'; -import { openMongoCollection } from './commands/openMongoCollection'; import { MongoConnectError } from './connectToMongoClient'; import { MongoDBLanguageClient } from './languageClient'; -import { getAllErrorsFromTextDocument } from './MongoScrapbook'; -import { MongoCodeLensProvider } from './services/MongoCodeLensProvider'; +import { getAllErrorsFromTextDocument } from './MongoScrapbookHelpers'; +import { MongoScrapbookService } from './MongoScrapbookService'; let diagnosticsCollection: vscode.DiagnosticCollection; const mongoLanguageId: string = 'mongo'; @@ -36,47 +28,22 @@ const mongoLanguageId: string = 'mongo'; export function registerMongoCommands(): void { ext.mongoLanguageClient = new MongoDBLanguageClient(); - ext.mongoCodeLensProvider = new MongoCodeLensProvider(); ext.context.subscriptions.push( - vscode.languages.registerCodeLensProvider(mongoLanguageId, ext.mongoCodeLensProvider), + vscode.languages.registerCodeLensProvider(mongoLanguageId, MongoScrapbookService.getCodeLensProvider()), ); diagnosticsCollection = vscode.languages.createDiagnosticCollection('cosmosDB.mongo'); ext.context.subscriptions.push(diagnosticsCollection); setUpErrorReporting(); - void loadPersistedMongoDB(); - registerCommandWithTreeNodeUnwrapping('cosmosDB.launchMongoShell', launchMongoShell); - registerCommandWithTreeNodeUnwrapping('cosmosDB.newMongoScrapbook', createMongoSrapbook); + registerCommandWithTreeNodeUnwrapping('cosmosDB.newMongoScrapbook', createMongoScrapbook); registerCommandWithTreeNodeUnwrapping('cosmosDB.executeMongoCommand', executeMongoCommand); registerCommandWithTreeNodeUnwrapping('cosmosDB.executeAllMongoCommands', executeAllMongoCommand); - // #region Account command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.createMongoDatabase', createMongoDatabase); - - // #endregion - // #region Database command registerCommandWithTreeNodeUnwrapping('cosmosDB.connectMongoDB', connectMongoDatabase); - registerCommandWithTreeNodeUnwrapping('cosmosDB.createMongoCollection', createMongoCollection); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteMongoDB', deleteMongoDB); - - // #endregion - - // #region Collection command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.openCollection', openMongoCollection); - registerCommandWithTreeNodeUnwrapping('cosmosDB.createMongoDocument', createMongoDocument); - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteMongoCollection', deleteMongoCollection); - - // #endregion - - // #region Document command - - registerCommandWithTreeNodeUnwrapping('cosmosDB.deleteMongoDocument', deleteMongoDocument); // #endregion } diff --git a/src/mongo/services/MongoCodeLensProvider.ts b/src/mongo/services/MongoCodeLensProvider.ts index a9a1d8764..c3dd4982a 100644 --- a/src/mongo/services/MongoCodeLensProvider.ts +++ b/src/mongo/services/MongoCodeLensProvider.ts @@ -5,74 +5,107 @@ import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; -import { getAllCommandsFromTextDocument } from '../MongoScrapbook'; +import { getAllCommandsFromText } from '../MongoScrapbookHelpers'; +import { MongoScrapbookService } from '../MongoScrapbookService'; +/** + * Provides Code Lens functionality for the Mongo Scrapbook editor. + * + * @remarks + * This provider enables several helpful actions directly within the editor: + * + * 1. **Connection Status Lens**: + * - Displays the current database connection state (e.g., connecting, connected). + * - Offers the ability to connect to a MongoDB database if one is not yet connected. + * + * 2. **Execute All Commands Lens**: + * - Runs all detected MongoDB commands in the scrapbook document at once when triggered. + * + * 3. **Execute Single Command Lens**: + * - Appears for each individual MongoDB command found in the scrapbook. + * - Invokes execution of the command located at the specified range in the document. + */ export class MongoCodeLensProvider implements vscode.CodeLensProvider { private _onDidChangeEmitter: vscode.EventEmitter = new vscode.EventEmitter(); - private _connectedDatabase: string | undefined; - private _connectedDatabaseInitialized: boolean; + /** + * An event to signal that the code lenses from this provider have changed. + */ public get onDidChangeCodeLenses(): vscode.Event { return this._onDidChangeEmitter.event; } - public setConnectedDatabase(database: string | undefined): void { - this._connectedDatabase = database; - this._connectedDatabaseInitialized = true; + public updateCodeLens(): void { this._onDidChangeEmitter.fire(); } - public provideCodeLenses( document: vscode.TextDocument, _token: vscode.CancellationToken, ): vscode.ProviderResult { return callWithTelemetryAndErrorHandling('mongo.provideCodeLenses', (context: IActionContext) => { - // Suppress except for errors - this can fire on every keystroke context.telemetry.suppressIfSuccessful = true; - const isInitialized = this._connectedDatabaseInitialized; - const isConnected = !!this._connectedDatabase; - const database = isConnected && this._connectedDatabase; const lenses: vscode.CodeLens[] = []; - // Allow displaying and changing connected database - lenses.push({ - command: { - title: !isInitialized - ? 'Initializing...' - : isConnected - ? `Connected to ${database}` - : `Connect to a database`, - command: isInitialized && 'cosmosDB.connectMongoDB', - }, - range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), - }); - - if (isConnected) { - // Run all - lenses.push({ - command: { - title: 'Execute All', - command: 'cosmosDB.executeAllMongoCommands', - }, - range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), - }); - - const commands = getAllCommandsFromTextDocument(document); - for (const cmd of commands) { - // run individual - lenses.push({ - command: { - title: 'Execute', - command: 'cosmosDB.executeMongoCommand', - arguments: [cmd.range.start], - }, - range: cmd.range, - }); - } - } + // Create connection status lens + lenses.push(this.createConnectionStatusLens()); + + // Create run-all lens + lenses.push(this.createRunAllCommandsLens()); + + // Create lenses for each individual command + const commands = getAllCommandsFromText(document.getText()); + lenses.push(...this.createIndividualCommandLenses(commands)); return lenses; }); } + + private createConnectionStatusLens(): vscode.CodeLens { + const title = MongoScrapbookService.isConnected() + ? `Connected to "${MongoScrapbookService.getDisplayName()}"` + : 'Connect to a database'; + + const shortenedTitle = + title.length > 64 ? title.slice(0, 64 / 2) + '...' + title.slice(-(64 - 3 - 64 / 2)) : title; + + return { + command: { + title: '🌐 ' + shortenedTitle, + tooltip: title, + command: 'cosmosDB.connectMongoDB', + }, + range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), + }; + } + + private createRunAllCommandsLens(): vscode.CodeLens { + const title = MongoScrapbookService.isExecutingAllCommands() ? '⏳ Running All...' : '⏩ Run All'; + + return { + command: { + title, + command: 'cosmosDB.executeAllMongoCommands', + }, + range: new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)), + }; + } + + private createIndividualCommandLenses(commands: { range: vscode.Range }[]): vscode.CodeLens[] { + const currentCommandInExectution = MongoScrapbookService.getSingleCommandInExecution(); + + return commands.map((cmd) => { + const running = currentCommandInExectution && cmd.range.isEqual(currentCommandInExectution.range); + const title = running ? '⏳ Running Command...' : '▶️ Run Command'; + + return { + command: { + title, + command: 'cosmosDB.executeMongoCommand', + arguments: [cmd.range.start], + }, + range: cmd.range, + }; + }); + } } diff --git a/src/mongo/tree/MongoAccountTreeItem.ts b/src/mongo/tree/MongoAccountTreeItem.ts deleted file mode 100644 index 3834b37d8..000000000 --- a/src/mongo/tree/MongoAccountTreeItem.ts +++ /dev/null @@ -1,180 +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 { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb/src/models'; -import { - appendExtensionUserAgent, - AzExtParentTreeItem, - callWithTelemetryAndErrorHandling, - parseError, - type AzExtTreeItem, - type IActionContext, - type ICreateChildImplContext, -} from '@microsoft/vscode-azext-utils'; -import { type MongoClient } from 'mongodb'; -import type * as vscode from 'vscode'; -import { API } from '../../AzureDBExperiences'; -import { deleteCosmosDBAccount } from '../../commands/deleteDatabaseAccount/deleteCosmosDBAccount'; -import { type IDeleteWizardContext } from '../../commands/deleteDatabaseAccount/IDeleteWizardContext'; -import { getThemeAgnosticIconPath, Links, testDb } from '../../constants'; -import { nonNullProp } from '../../utils/nonNull'; -import { connectToMongoClient } from '../connectToMongoClient'; -import { getDatabaseNameFromConnectionString } from '../mongoConnectionStrings'; -import { type IMongoTreeRoot } from './IMongoTreeRoot'; -import { MongoCollectionTreeItem } from './MongoCollectionTreeItem'; -import { MongoDatabaseTreeItem } from './MongoDatabaseTreeItem'; -import { MongoDocumentTreeItem } from './MongoDocumentTreeItem'; - -export class MongoAccountTreeItem extends AzExtParentTreeItem { - public static contextValue: string = 'cosmosDBMongoServer'; - public readonly contextValue: string = MongoAccountTreeItem.contextValue; - public readonly childTypeLabel: string = 'Database'; - public readonly label: string; - public readonly connectionString: string; - - private _root: IMongoTreeRoot; - - constructor( - parent: AzExtParentTreeItem, - id: string, - label: string, - connectionString: string, - isEmulator: boolean | undefined, - readonly databaseAccount?: DatabaseAccountGetResults, - ) { - super(parent); - this.id = id; - this.label = label; - this.connectionString = connectionString; - this._root = { isEmulator }; - this.valuesToMask.push(connectionString); - } - - // overrides ISubscriptionContext with an object that also has Mongo info - public get root(): IMongoTreeRoot { - return this._root; - } - - public get iconPath(): string | vscode.Uri | { light: string | vscode.Uri; dark: string | vscode.Uri } { - return getThemeAgnosticIconPath('CosmosDBAccount.svg'); - } - - public hasMoreChildrenImpl(): boolean { - return false; - } - - public async loadMoreChildrenImpl(_clearCache: boolean): Promise { - const result = await callWithTelemetryAndErrorHandling( - 'getChildren', - async (context: IActionContext): Promise => { - context.telemetry.properties.experience = API.MongoDB; - context.telemetry.properties.parentNodeContext = this.contextValue; - - let mongoClient: MongoClient | undefined; - try { - let databases: IDatabaseInfo[]; - - if (!this.connectionString) { - throw new Error('Missing connection string'); - } - - // Azure MongoDB accounts need to have the name passed in for private endpoints - mongoClient = await connectToMongoClient( - this.connectionString, - this.databaseAccount ? nonNullProp(this.databaseAccount, 'name') : appendExtensionUserAgent(), - ); - - const databaseInConnectionString = getDatabaseNameFromConnectionString(this.connectionString); - if (databaseInConnectionString && !this.root.isEmulator) { - // emulator violates the connection string format - // If the database is in the connection string, that's all we connect to (we might not even have permissions to list databases) - databases = [ - { - name: databaseInConnectionString, - empty: false, - }, - ]; - } else { - // https://mongodb.github.io/node-mongodb-native/3.1/api/index.html - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const result: { databases: IDatabaseInfo[] } = await mongoClient - .db(testDb) - .admin() - .listDatabases(); - databases = result.databases; - } - return databases - .filter( - (database: IDatabaseInfo) => - !(database.name && database.name.toLowerCase() === 'admin' && database.empty), - ) // Filter out the 'admin' database if it's empty - .map( - (database) => - new MongoDatabaseTreeItem(this, nonNullProp(database, 'name'), this.connectionString), - ); - } catch (error) { - const message = parseError(error).message; - if (this.root?.isEmulator && message.includes('ECONNREFUSED')) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - error.message = `Unable to reach emulator. See ${Links.LocalConnectionDebuggingTips} for debugging tips.\n${message}`; - } - throw error; - } finally { - if (mongoClient) { - void mongoClient.close(); - } - } - }, - ); - - return result ?? []; - } - - public async createChildImpl(context: ICreateChildImplContext): Promise { - const databaseName = await context.ui.showInputBox({ - placeHolder: 'Database Name', - prompt: 'Enter the name of the database', - stepName: 'createMongoDatabase', - validateInput: validateDatabaseName, - }); - context.showCreatingTreeItem(databaseName); - - return new MongoDatabaseTreeItem(this, databaseName, this.connectionString); - } - - public isAncestorOfImpl(contextValue: string): boolean { - switch (contextValue) { - case MongoDatabaseTreeItem.contextValue: - case MongoCollectionTreeItem.contextValue: - case MongoDocumentTreeItem.contextValue: - return true; - default: - return false; - } - } - - public async deleteTreeItemImpl(context: IDeleteWizardContext): Promise { - await deleteCosmosDBAccount(context, this); - } -} - -export function validateDatabaseName(database: string): string | undefined | null { - // https://docs.mongodb.com/manual/reference/limits/#naming-restrictions - // "#?" are restricted characters for CosmosDB - MongoDB accounts - const min = 1; - const max = 63; - if (!database || database.length < min || database.length > max) { - return `Database name must be between ${min} and ${max} characters.`; - } - if (/[/\\. "$#?=]/.test(database)) { - return 'Database name cannot contain these characters - `/\\. "$#?=`'; - } - return undefined; -} - -export interface IDatabaseInfo { - name?: string; - empty?: boolean; -} diff --git a/src/mongo/tree/MongoCollectionTreeItem.ts b/src/mongo/tree/MongoCollectionTreeItem.ts deleted file mode 100644 index 30cb61dd2..000000000 --- a/src/mongo/tree/MongoCollectionTreeItem.ts +++ /dev/null @@ -1,388 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import { - AzExtParentTreeItem, - DialogResponses, - type AzExtTreeItem, - type IActionContext, - type ICreateChildImplContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import assert from 'assert'; -import { EJSON } from 'bson'; -import { omit } from 'lodash'; -import { - type AnyBulkWriteOperation, - type BulkWriteOptions, - type BulkWriteResult, - type Collection, - type CountOptions, - type DeleteResult, - type Filter, - type FindCursor, - type InsertManyResult, - type InsertOneResult, - type Document as MongoDocument, -} from 'mongodb'; -import * as vscode from 'vscode'; -import { type IEditableTreeItem } from '../../DatabasesFileSystem'; -import { ext } from '../../extensionVariables'; -import { nonNullValue } from '../../utils/nonNull'; -import { getDocumentTreeItemLabel } from '../../utils/vscodeUtils'; -import { getBatchSizeSetting } from '../../utils/workspacUtils'; -import { type MongoCommand } from '../MongoCommand'; -import { MongoDocumentTreeItem, type IMongoDocument } from './MongoDocumentTreeItem'; - -// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types -type MongoFunction = (...args: (Object | Object[] | undefined)[]) => Thenable; -class FunctionDescriptor { - public constructor( - public mongoFunction: MongoFunction, - public text: string, - public minShellArgs: number, - public maxShellArgs: number, - public maxHandledArgs: number, - ) {} -} - -export class MongoCollectionTreeItem extends AzExtParentTreeItem implements IEditableTreeItem { - public static contextValue: string = 'MongoCollection'; - public readonly contextValue: string = MongoCollectionTreeItem.contextValue; - public readonly childTypeLabel: string = 'Document'; - public readonly collection: Collection; - public declare parent: AzExtParentTreeItem; - // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types - public findArgs?: Object[]; - public readonly cTime: number = Date.now(); - public mTime: number = Date.now(); - - private readonly _query: Filter | undefined; - private readonly _projection: object | undefined; - private _cursor: FindCursor | undefined; - private _hasMoreChildren: boolean = true; - private _batchSize: number = getBatchSizeSetting(); - - // eslint-disable-next-line @typescript-eslint/no-wrapper-object-types - constructor(parent: AzExtParentTreeItem, collection: Collection, findArgs?: Object[]) { - super(parent); - this.collection = collection; - this.findArgs = findArgs; - if (findArgs && findArgs.length) { - this._query = findArgs[0]; - this._projection = findArgs.length > 1 ? findArgs[1] : undefined; - } - ext.fileSystem.fireChangedEvent(this); - } - - public async writeFileContent(context: IActionContext, content: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const documents: IMongoDocument[] = EJSON.parse(content); - const operations: AnyBulkWriteOperation[] = documents.map((document) => { - return { - replaceOne: { - filter: { _id: document._id }, - replacement: omit(document, '_id'), - upsert: false, - }, - }; - }); - - const result: BulkWriteResult = await this.collection.bulkWrite(operations); - ext.outputChannel.appendLog( - `Successfully updated ${result.modifiedCount} document(s), inserted ${result.insertedCount} document(s)`, - ); - - // The current tree item may have been a temporary one used to execute a scrapbook command. - // We want to refresh children for this one _and_ the actual one in the tree (if it's different) - const nodeInTree: MongoCollectionTreeItem | undefined = await ext.rgApi.appResourceTree.findTreeItem( - this.fullId, - context, - ); - const nodesToRefresh: MongoCollectionTreeItem[] = [this]; - if (nodeInTree && this !== nodeInTree) { - nodesToRefresh.push(nodeInTree); - } - - await Promise.all(nodesToRefresh.map((n) => n.refreshChildren(context, documents))); - - if (nodeInTree && this !== nodeInTree) { - // Don't need to fire a changed event on the item being saved at the moment. Just the node in the tree if it's different - ext.fileSystem.fireChangedEvent(nodeInTree); - } - } - - public async getFileContent(context: IActionContext): Promise { - const children = await this.getCachedChildren(context); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - return EJSON.stringify( - children.map((c) => c.document), - undefined, - 2, - ); - } - - public get id(): string { - return this.collection.collectionName; - } - - public get label(): string { - return this.collection.collectionName; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('files'); - } - - public get filePath(): string { - return this.label + '-cosmos-collection.json'; - } - - public async refreshImpl(): Promise { - this._batchSize = getBatchSizeSetting(); - ext.fileSystem.fireChangedEvent(this); - } - - public async refreshChildren(context: IActionContext, docs: IMongoDocument[]): Promise { - const documentNodes = await this.getCachedChildren(context); - for (const doc of docs) { - const documentNode = documentNodes.find((node) => node.document._id.toString() === doc._id.toString()); - if (documentNode) { - documentNode.document = doc; - await documentNode.refresh(context); - } - } - } - - public hasMoreChildrenImpl(): boolean { - return this._hasMoreChildren; - } - - public async loadMoreChildrenImpl(clearCache: boolean): Promise { - if (clearCache || this._cursor === undefined) { - if (this._query) { - this._cursor = this.collection.find(this._query).batchSize(this._batchSize); - } else { - this._cursor = this.collection.find().batchSize(this._batchSize); - } - if (this._projection) { - this._cursor = this._cursor.project(this._projection); - } - } - - const documents: IMongoDocument[] = []; - let count: number = 0; - while (count < this._batchSize) { - this._hasMoreChildren = await this._cursor.hasNext(); - if (this._hasMoreChildren) { - documents.push(await this._cursor.next()); - count += 1; - } else { - break; - } - } - this._batchSize *= 2; - - return this.createTreeItemsWithErrorHandling( - documents, - 'invalidMongoDocument', - (doc) => new MongoDocumentTreeItem(this, doc), - getDocumentTreeItemLabel, - ); - } - - public async createChildImpl(context: ICreateChildImplContext): Promise { - context.showCreatingTreeItem(''); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const result: InsertOneResult = await this.collection.insertOne({}); - const newDocument: IMongoDocument = nonNullValue( - await this.collection.findOne({ _id: result.insertedId }), - 'newDocument', - ); - return new MongoDocumentTreeItem(this, newDocument); - } - - public async tryExecuteCommandDirectly( - command: Partial, - ): Promise<{ deferToShell: true; result: undefined } | { deferToShell: false; result: string }> { - // range and text are not necessary properties for this function so partial should suffice - const parameters = command.arguments ? command.arguments.map(parseJSContent) : []; - - const functions = { - drop: new FunctionDescriptor(this.drop, 'Dropping collection', 0, 0, 0), - count: new FunctionDescriptor(this.count, 'Counting documents', 0, 2, 2), - findOne: new FunctionDescriptor(this.findOne, 'Finding document', 0, 2, 2), - insert: new FunctionDescriptor(this.insert, 'Inserting document', 1, 1, 1), - insertMany: new FunctionDescriptor(this.insertMany, 'Inserting documents', 1, 2, 2), - insertOne: new FunctionDescriptor(this.insertOne, 'Inserting document', 1, 2, 2), - deleteMany: new FunctionDescriptor(this.deleteMany, 'Deleting documents', 1, 2, 1), - deleteOne: new FunctionDescriptor(this.deleteOne, 'Deleting document', 1, 2, 1), - remove: new FunctionDescriptor(this.remove, 'Deleting document(s)', 1, 2, 1), - }; - - // eslint-disable-next-line no-prototype-builtins - if (command.name && functions.hasOwnProperty(command.name)) { - // currently no logic to handle chained commands so just defer to the shell right away - if (command.chained) { - return { deferToShell: true, result: undefined }; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const descriptor: FunctionDescriptor = functions[command.name]; - - if (parameters.length < descriptor.minShellArgs) { - throw new Error(`Too few arguments passed to command ${command.name}.`); - } - if (parameters.length > descriptor.maxShellArgs) { - throw new Error(`Too many arguments passed to command ${command.name}`); - } - if (parameters.length > descriptor.maxHandledArgs) { - //this function won't handle these arguments, but the shell will - return { deferToShell: true, result: undefined }; - } - const result = await reportProgress( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - descriptor.mongoFunction.apply(this, parameters), - descriptor.text, - ); - return { deferToShell: false, result }; - } - return { deferToShell: true, result: undefined }; - } - - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = `Are you sure you want to delete collection '${this.label}'?`; - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteMongoCollection' }, - DialogResponses.deleteResponse, - ); - await this.drop(); - } - - private async drop(): Promise { - try { - await this.collection.drop(); - return `Dropped collection '${this.collection.collectionName}'.`; - } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const error: { code?: number; name?: string } = e; - const NamespaceNotFoundCode = 26; - if (error.name === 'MongoError' && error.code === NamespaceNotFoundCode) { - return `Collection '${this.collection.collectionName}' could not be dropped because it does not exist.`; - } else { - // eslint-disable-next-line @typescript-eslint/only-throw-error - throw error; - } - } - } - - private async findOne(query?: Filter, fieldsOption?: MongoDocument): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const result = await this.collection.findOne(query || {}, { projection: fieldsOption }); - // findOne is the only command in this file whose output requires EJSON support. - // Hence that's the only function which uses EJSON.stringify rather than this.stringify. - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - return EJSON.stringify(result, undefined, '\t'); - } - - private async insert(document: MongoDocument): Promise { - if (!document) { - throw new Error('The insert command requires at least one argument'); - } - - const insertResult = await this.collection.insertOne(document); - return this.stringify(insertResult); - } - - private async insertOne(document: MongoDocument, options?: any): Promise { - const insertOneResult: InsertOneResult = await this.collection.insertOne(document, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - writeConcern: options && options.writeConcern, - }); - return this.stringify(insertOneResult); - } - - private async insertMany(documents: MongoDocument[], options?: any): Promise { - assert.notEqual(documents.length, 0, 'Array of documents cannot be empty'); - const insertManyOptions: BulkWriteOptions = {}; - if (options) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (options.ordered) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - insertManyOptions.ordered = options.ordered; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (options.writeConcern) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - insertManyOptions.writeConcern = options.writeConcern; - } - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const insertManyResult: InsertManyResult = await this.collection.insertMany( - documents, - insertManyOptions, - ); - return this.stringify(insertManyResult); - } - - private async remove(filter: Filter): Promise { - const removeResult = await this.collection.deleteOne(filter); - return this.stringify(removeResult); - } - - private async deleteOne(filter: Filter): Promise { - const deleteOneResult: DeleteResult = await this.collection.deleteOne(filter); - return this.stringify(deleteOneResult); - } - - private async deleteMany(filter: Filter): Promise { - const deleteOpResult: DeleteResult = await this.collection.deleteMany(filter); - return this.stringify(deleteOpResult); - } - - private async count(query?: Filter, options?: CountOptions): Promise { - if (!query) { - const count = await this.collection.countDocuments(); - return this.stringify(count); - } else { - if (!options) { - const count = await this.collection.count(query); - return this.stringify(count); - } else { - const count = await this.collection.count(query, options); - return this.stringify(count); - } - } - } - - private stringify(result: any): string { - return JSON.stringify(result, null, '\t'); - } -} - -function reportProgress(promise: Thenable, title: string): Thenable { - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.Window, - title: title, - }, - (_progress) => { - return promise; - }, - ); -} - -function parseJSContent(content: string): any { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - return EJSON.parse(content); - } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - throw error.message; - } -} diff --git a/src/mongo/tree/MongoDatabaseTreeItem.ts b/src/mongo/tree/MongoDatabaseTreeItem.ts deleted file mode 100644 index a36df8026..000000000 --- a/src/mongo/tree/MongoDatabaseTreeItem.ts +++ /dev/null @@ -1,305 +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 { - appendExtensionUserAgent, - AzExtParentTreeItem, - DialogResponses, - UserCancelledError, - type IActionContext, - type ICreateChildImplContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import * as fse from 'fs-extra'; -import { type Collection, type CreateCollectionOptions, type Db } from 'mongodb'; -import * as path from 'path'; -import * as process from 'process'; -import * as vscode from 'vscode'; -import { ext } from '../../extensionVariables'; -import * as cpUtils from '../../utils/cp'; -import { nonNullProp, nonNullValue } from '../../utils/nonNull'; -import { connectToMongoClient } from '../connectToMongoClient'; -import { type MongoCommand } from '../MongoCommand'; -import { addDatabaseToAccountConnectionString } from '../mongoConnectionStrings'; -import { MongoShell } from '../MongoShell'; -import { type IMongoTreeRoot } from './IMongoTreeRoot'; -import { type MongoAccountTreeItem } from './MongoAccountTreeItem'; -import { MongoCollectionTreeItem } from './MongoCollectionTreeItem'; - -const mongoExecutableFileName = process.platform === 'win32' ? 'mongo.exe' : 'mongo'; -const executingInShellMsg = 'Executing command in Mongo shell'; - -export class MongoDatabaseTreeItem extends AzExtParentTreeItem { - public static contextValue: string = 'mongoDb'; - public readonly contextValue: string = MongoDatabaseTreeItem.contextValue; - public readonly childTypeLabel: string = 'Collection'; - public readonly connectionString: string; - public readonly databaseName: string; - public declare readonly parent: MongoAccountTreeItem; - - private _previousShellPathSetting: string | undefined; - private _cachedShellPathOrCmd: string | undefined; - - constructor(parent: MongoAccountTreeItem, databaseName: string, connectionString: string) { - super(parent); - this.databaseName = databaseName; - this.connectionString = addDatabaseToAccountConnectionString(connectionString, this.databaseName); - } - - public get root(): IMongoTreeRoot { - return this.parent.root; - } - - public get label(): string { - return this.databaseName; - } - - public get description(): string { - return ext.connectedMongoDB && ext.connectedMongoDB.fullId === this.fullId ? 'Connected' : ''; - } - - public get id(): string { - return this.databaseName; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('database'); - } - - public hasMoreChildrenImpl(): boolean { - return false; - } - - public async loadMoreChildrenImpl(_clearCache: boolean): Promise { - const db: Db = await this.connectToDb(); - const collections: Collection[] = await db.collections(); - return collections.map((collection) => new MongoCollectionTreeItem(this, collection)); - } - - public async createChildImpl(context: ICreateChildImplContext): Promise { - const collectionName = await context.ui.showInputBox({ - placeHolder: 'Collection Name', - prompt: 'Enter the name of the collection', - stepName: 'createMongoCollection', - validateInput: validateMongoCollectionName, - }); - - context.showCreatingTreeItem(collectionName); - return await this.createCollection(collectionName); - } - - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = `Are you sure you want to delete database '${this.label}'?`; - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteMongoDatabase' }, - DialogResponses.deleteResponse, - ); - const db = await this.connectToDb(); - await db.dropDatabase(); - } - - public async connectToDb(): Promise { - const accountConnection = await connectToMongoClient(this.connectionString, appendExtensionUserAgent()); - return accountConnection.db(this.databaseName); - } - - public async executeCommand(command: MongoCommand, context: IActionContext): Promise { - if (command.collection) { - const db = await this.connectToDb(); - const collection = db.collection(command.collection); - if (collection) { - const collectionTreeItem = new MongoCollectionTreeItem(this, collection, command.arguments); - const result = await collectionTreeItem.tryExecuteCommandDirectly(command); - if (!result.deferToShell) { - return result.result; - } - } - return withProgress(this.executeCommandInShell(command, context), executingInShellMsg); - } - - if (command.name === 'createCollection') { - // arguments are all strings so DbCollectionOptions is represented as a JSON string which is why we pass argumentObjects instead - return withProgress( - this.createCollection( - stripQuotes(nonNullProp(command, 'arguments')[0]), - nonNullProp(command, 'argumentObjects')[1], - ).then(() => JSON.stringify({ Created: 'Ok' })), - 'Creating collection', - ); - } else { - return withProgress(this.executeCommandInShell(command, context), executingInShellMsg); - } - } - - public async createCollection( - collectionName: string, - options?: CreateCollectionOptions, - ): Promise { - const db: Db = await this.connectToDb(); - const newCollection: Collection = await db.createCollection(collectionName, options); - // db.createCollection() doesn't create empty collections for some reason - // However, we can 'insert' and then 'delete' a document, which has the side-effect of creating an empty collection - const result = await newCollection.insertOne({}); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - await newCollection.deleteOne({ _id: result.insertedId }); - return new MongoCollectionTreeItem(this, newCollection); - } - - private async executeCommandInShell(command: MongoCommand, context: IActionContext): Promise { - context.telemetry.properties.executeInShell = 'true'; - - if (this.root.isEmulator) { - // Ensure the emulator is running before creating the shell. Shell errors are generic and don't include emulator specific info - await this.connectToDb(); - } - - // CONSIDER: Re-using the shell instead of disposing it each time would allow us to keep state - // (JavaScript variables, etc.), but we would need to deal with concurrent requests, or timed-out - // requests. - const shell = await this.createShell(context); - try { - await shell.useDatabase(this.databaseName); - return await shell.executeScript(command.text); - } finally { - shell.dispose(); - } - } - - private async createShell(context: IActionContext): Promise { - const config = vscode.workspace.getConfiguration(); - let shellPath: string | undefined = config.get(ext.settingsKeys.mongoShellPath); - const shellArgs: string[] = config.get(ext.settingsKeys.mongoShellArgs, []); - - if (!shellPath || !this._cachedShellPathOrCmd || this._previousShellPathSetting !== shellPath) { - // Only do this if setting changed since last time - shellPath = await this._determineShellPathOrCmd(context, shellPath); - this._previousShellPathSetting = shellPath; - } - this._cachedShellPathOrCmd = shellPath; - - const timeout = - 1000 * nonNullValue(config.get(ext.settingsKeys.mongoShellTimeout), 'mongoShellTimeout'); - return MongoShell.create( - shellPath, - shellArgs, - this.connectionString, - this.root.isEmulator, - ext.outputChannel, - timeout, - ); - } - - private async _determineShellPathOrCmd( - context: IActionContext, - shellPathSetting: string | undefined, - ): Promise { - if (!shellPathSetting) { - // User hasn't specified the path - if (await cpUtils.commandSucceeds('mongo', '--version')) { - // If the user already has mongo in their system path, just use that - return 'mongo'; - } else { - // If all else fails, prompt the user for the mongo path - const openFile: vscode.MessageItem = { title: `Browse to ${mongoExecutableFileName}` }; - const browse: vscode.MessageItem = { title: 'Open installation page' }; - const noMongoError: string = - 'This functionality requires the Mongo DB shell, but we could not find it in the path or using the mongo.shell.path setting.'; - const response = await context.ui.showWarningMessage( - noMongoError, - { stepName: 'promptForMongoPath' }, - browse, - openFile, - ); - if (response === openFile) { - // eslint-disable-next-line no-constant-condition - while (true) { - const newPath: vscode.Uri[] = await context.ui.showOpenDialog({ - filters: { 'Executable Files': [process.platform === 'win32' ? 'exe' : ''] }, - openLabel: `Select ${mongoExecutableFileName}`, - stepName: 'openMongoExeFile', - }); - const fsPath = newPath[0].fsPath; - const baseName = path.basename(fsPath); - if (baseName !== mongoExecutableFileName) { - const useAnyway: vscode.MessageItem = { title: 'Use anyway' }; - const tryAgain: vscode.MessageItem = { title: 'Try again' }; - const response2 = await context.ui.showWarningMessage( - `Expected a file named "${mongoExecutableFileName}, but the selected filename is "${baseName}"`, - { stepName: 'confirmMongoExeFile' }, - useAnyway, - tryAgain, - ); - if (response2 === tryAgain) { - continue; - } - } - - await vscode.workspace - .getConfiguration() - .update(ext.settingsKeys.mongoShellPath, fsPath, vscode.ConfigurationTarget.Global); - return fsPath; - } - } else if (response === browse) { - void vscode.commands.executeCommand( - 'vscode.open', - vscode.Uri.parse('https://docs.mongodb.com/manual/installation/'), - ); - // default down to cancel error because MongoShell.create errors out if undefined is passed as the shellPath - } - - throw new UserCancelledError('createShell'); - } - } else { - // User has specified the path or command. Sometimes they set the folder instead of a path to the file, let's check that and auto fix - if (await fse.pathExists(shellPathSetting)) { - const stat = await fse.stat(shellPathSetting); - if (stat.isDirectory()) { - return path.join(shellPathSetting, mongoExecutableFileName); - } - } - - return shellPathSetting; - } - } -} - -export function validateMongoCollectionName(collectionName: string): string | undefined | null { - // https://docs.mongodb.com/manual/reference/limits/#Restriction-on-Collection-Names - if (!collectionName) { - return 'Collection name cannot be empty'; - } - const systemPrefix = 'system.'; - if (collectionName.startsWith(systemPrefix)) { - return `"${systemPrefix}" prefix is reserved for internal use`; - } - if (/[$]/.test(collectionName)) { - return 'Collection name cannot contain $'; - } - return undefined; -} - -function withProgress( - promise: Thenable, - title: string, - location: vscode.ProgressLocation = vscode.ProgressLocation.Window, -): Thenable { - return vscode.window.withProgress( - { - location: location, - title: title, - }, - (_progress) => { - return promise; - }, - ); -} - -export function stripQuotes(term: string): string { - if ((term.startsWith("'") && term.endsWith("'")) || (term.startsWith('"') && term.endsWith('"'))) { - return term.substring(1, term.length - 1); - } - return term; -} diff --git a/src/mongo/tree/MongoDocumentTreeItem.ts b/src/mongo/tree/MongoDocumentTreeItem.ts deleted file mode 100644 index 3d7918c1a..000000000 --- a/src/mongo/tree/MongoDocumentTreeItem.ts +++ /dev/null @@ -1,111 +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 { - AzExtTreeItem, - DialogResponses, - type IActionContext, - type TreeItemIconPath, -} from '@microsoft/vscode-azext-utils'; -import { EJSON } from 'bson'; -import { omit } from 'lodash'; -import { - type Collection, - type DeleteResult, - type Document as MongoDocument, - type ObjectId, - type UpdateResult, -} from 'mongodb'; -import * as vscode from 'vscode'; -import { type IEditableTreeItem } from '../../DatabasesFileSystem'; -import { ext } from '../../extensionVariables'; -import { getDocumentTreeItemLabel } from '../../utils/vscodeUtils'; -import { type MongoCollectionTreeItem } from './MongoCollectionTreeItem'; - -export interface IMongoDocument { - _id: ObjectId; - - // custom properties - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; -} - -export class MongoDocumentTreeItem extends AzExtTreeItem implements IEditableTreeItem { - public static contextValue: string = 'MongoDocument'; - public readonly contextValue: string = MongoDocumentTreeItem.contextValue; - public document: IMongoDocument; - public declare readonly parent: MongoCollectionTreeItem; - public readonly cTime: number = Date.now(); - public mTime: number = Date.now(); - - private _label: string; - - constructor(parent: MongoCollectionTreeItem, document: IMongoDocument) { - super(parent); - this.document = document; - this._label = getDocumentTreeItemLabel(this.document); - this.commandId = 'cosmosDB.openDocument'; - ext.fileSystem.fireChangedEvent(this); - } - - public get id(): string { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return String(this.document!._id); - } - - public get label(): string { - return this._label; - } - - public get iconPath(): TreeItemIconPath { - return new vscode.ThemeIcon('file'); - } - - public get filePath(): string { - return this.label + '-cosmos-document.json'; - } - - public static async update(collection: Collection, newDocument: IMongoDocument): Promise { - if (!newDocument._id) { - throw new Error(`The "_id" field is required to update a document.`); - } - const filter: object = { _id: newDocument._id }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const result: MongoDocument | UpdateResult = await collection.replaceOne(filter, omit(newDocument, '_id')); - if (result.modifiedCount !== 1) { - throw new Error(`Failed to update document with _id '${newDocument._id}'.`); - } - return newDocument; - } - - public async getFileContent(): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - return EJSON.stringify(this.document, undefined, 2); - } - - public async refreshImpl(): Promise { - this._label = getDocumentTreeItemLabel(this.document); - ext.fileSystem.fireChangedEvent(this); - } - - public async deleteTreeItemImpl(context: IActionContext): Promise { - const message: string = `Are you sure you want to delete document '${this._label}'?`; - await context.ui.showWarningMessage( - message, - { modal: true, stepName: 'deleteMongoDocument' }, - DialogResponses.deleteResponse, - ); - const deleteResult: DeleteResult = await this.parent.collection.deleteOne({ _id: this.document._id }); - if (deleteResult.deletedCount !== 1) { - throw new Error(`Failed to delete document with _id '${this.document._id}'.`); - } - } - - public async writeFileContent(_context: IActionContext, content: string): Promise { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const newDocument: IMongoDocument = EJSON.parse(content); - this.document = await MongoDocumentTreeItem.update(this.parent.collection, newDocument); - } -} diff --git a/src/mongoClusters/CredentialCache.ts b/src/mongoClusters/CredentialCache.ts index d79c59457..acdf5877d 100644 --- a/src/mongoClusters/CredentialCache.ts +++ b/src/mongoClusters/CredentialCache.ts @@ -7,7 +7,7 @@ import { addAuthenticationDataToConnectionString } from './utils/connectionStrin export interface MongoClustersCredentials { mongoClusterId: string; - connectionStringWithPassword?: string; // wipe it after use + connectionStringWithPassword?: string; connectionString: string; connectionUser: string; } diff --git a/src/mongoClusters/MongoClustersClient.ts b/src/mongoClusters/MongoClustersClient.ts index 3df82ca9e..d67252d28 100644 --- a/src/mongoClusters/MongoClustersClient.ts +++ b/src/mongoClusters/MongoClustersClient.ts @@ -14,6 +14,7 @@ import { EJSON } from 'bson'; import { MongoClient, ObjectId, + type Collection, type DeleteResult, type Document, type Filter, @@ -130,9 +131,16 @@ export class MongoClustersClient { return CredentialCache.getCredentials(this._credentialId)?.connectionString; } + getConnectionStringWithPassword() { + return CredentialCache.getConnectionStringWithPassword(this._credentialId); + } + async listDatabases(): Promise { const rawDatabases: ListDatabasesResult = await this._mongoClient.db().admin().listDatabases(); - const databases: DatabaseItemModel[] = rawDatabases.databases; + const databases: DatabaseItemModel[] = rawDatabases.databases.filter( + // Filter out the 'admin' database if it's empty + (databaseInfo) => !(databaseInfo.name && databaseInfo.name.toLowerCase() === 'admin' && databaseInfo.empty), + ); return databases; } @@ -339,30 +347,16 @@ export class MongoClustersClient { return this._mongoClient.db(databaseName).dropDatabase(); } - async createCollection(databaseName: string, collectionName: string): Promise { - let newCollection; - try { - newCollection = await this._mongoClient.db(databaseName).createCollection(collectionName); - } catch (_e) { - console.error(_e); //todo: add to telemetry - return false; - } - - return newCollection !== undefined; + async createCollection(databaseName: string, collectionName: string): Promise> { + return this._mongoClient.db(databaseName).createCollection(collectionName); } - async createDatabase(databaseName: string): Promise { - try { - const newCollection = await this._mongoClient - .db(databaseName) - .createCollection('_dummy_collection_creation_forces_db_creation'); - await newCollection.drop(); - } catch (_e) { - console.error(_e); //todo: add to telemetry - return false; - } - - return true; + async createDatabase(databaseName: string): Promise { + // TODO: add logging of failures to the telemetry somewhere in the call chain + const newCollection = await this._mongoClient + .db(databaseName) + .createCollection('_dummy_collection_creation_forces_db_creation'); + await newCollection.drop({ writeConcern: { w: 'majority', wtimeoutMS: 5000 } }); } async insertDocuments( diff --git a/src/mongoClusters/MongoClustersExtension.ts b/src/mongoClusters/MongoClustersExtension.ts index 3156e9094..4b2e278a9 100644 --- a/src/mongoClusters/MongoClustersExtension.ts +++ b/src/mongoClusters/MongoClustersExtension.ts @@ -17,22 +17,17 @@ import { } from '@microsoft/vscode-azext-utils'; import { AzExtResourceType } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; +import { createMongoCollection } from '../commands/createContainer/createContainer'; +import { createMongoDocument } from '../commands/createDocument/createDocument'; +import { deleteAzureContainer } from '../commands/deleteContainer/deleteContainer'; +import { deleteAzureDatabase } from '../commands/deleteDatabase/deleteDatabase'; +import { importDocuments } from '../commands/importDocuments/importDocuments'; +import { launchShell } from '../commands/launchShell/launchShell'; +import { openMongoDocumentView } from '../commands/openDocument/openDocument'; import { ext } from '../extensionVariables'; -import { - SharedWorkspaceResourceProvider, - WorkspaceResourceType, -} from '../tree/workspace/sharedWorkspaceResourceProvider'; -import { addWorkspaceConnection } from './commands/addWorkspaceConnection'; -import { createCollection } from './commands/createCollection'; -import { createDatabase } from './commands/createDatabase'; -import { dropCollection } from './commands/dropCollection'; -import { dropDatabase } from './commands/dropDatabase'; +import { WorkspaceResourceType } from '../tree/workspace/SharedWorkspaceResourceProvider'; import { mongoClustersExportEntireCollection, mongoClustersExportQueryResults } from './commands/exportDocuments'; -import { mongoClustersImportDocuments } from './commands/importDocuments'; -import { launchShell } from './commands/launchShell'; -import { openCollectionView } from './commands/openCollectionView'; -import { openDocumentView } from './commands/openDocumentView'; -import { removeWorkspaceConnection } from './commands/removeWorkspaceConnection'; +import { openCollectionView, openCollectionViewInternal } from './commands/openCollectionView'; import { MongoClustersBranchDataProvider } from './tree/MongoClustersBranchDataProvider'; import { MongoClustersWorkspaceBranchDataProvider } from './tree/workspace/MongoClustersWorkbenchBranchDataProvider'; import { isMongoClustersSupportenabled } from './utils/isMongoClustersSupportenabled'; @@ -71,8 +66,9 @@ export class MongoClustersExtension implements vscode.Disposable { ext.mongoClustersBranchDataProvider, ); - ext.workspaceDataProvider = new SharedWorkspaceResourceProvider(); - ext.rgApiV2.resources.registerWorkspaceResourceProvider(ext.workspaceDataProvider); + // Moved to extension.ts + // ext.workspaceDataProvider = new SharedWorkspaceResourceProvider(); + // ext.rgApiV2.resources.registerWorkspaceResourceProvider(ext.workspaceDataProvider); ext.mongoClustersWorkspaceBranchDataProvider = new MongoClustersWorkspaceBranchDataProvider(); ext.rgApiV2.resources.registerWorkspaceResourceBranchDataProvider( @@ -83,21 +79,31 @@ export class MongoClustersExtension implements vscode.Disposable { // using registerCommand instead of vscode.commands.registerCommand for better telemetry: // https://github.com/microsoft/vscode-azuretools/tree/main/utils#telemetry-and-error-handling - registerCommand('command.internal.mongoClusters.containerView.open', openCollectionView); - registerCommand('command.internal.mongoClusters.documentView.open', openDocumentView); + /** + * Here, opening the collection view is done in two ways: one is accessible from the tree view + * via a context menu, and the other is accessible programmatically. Both of them + * use the same underlying function to open the collection view. + * + * openCollectionView calls openCollectionViewInternal with no additional parameters. + * + * It was possible to merge the two commands into one, but it would result in code that is + * harder to understand and maintain. + */ + registerCommand('command.internal.mongoClusters.containerView.open', openCollectionViewInternal); + registerCommandWithTreeNodeUnwrapping('command.mongoClusters.containerView.open', openCollectionView); + + registerCommand('command.internal.mongoClusters.documentView.open', openMongoDocumentView); registerCommandWithTreeNodeUnwrapping('command.mongoClusters.launchShell', launchShell); - registerCommandWithTreeNodeUnwrapping('command.mongoClusters.dropCollection', dropCollection); - registerCommandWithTreeNodeUnwrapping('command.mongoClusters.dropDatabase', dropDatabase); + registerCommandWithTreeNodeUnwrapping('command.mongoClusters.dropCollection', deleteAzureContainer); + registerCommandWithTreeNodeUnwrapping('command.mongoClusters.dropDatabase', deleteAzureDatabase); - registerCommandWithTreeNodeUnwrapping('command.mongoClusters.createCollection', createCollection); - registerCommandWithTreeNodeUnwrapping('command.mongoClusters.createDatabase', createDatabase); + registerCommandWithTreeNodeUnwrapping('command.mongoClusters.createCollection', createMongoCollection); - registerCommandWithTreeNodeUnwrapping( - 'command.mongoClusters.importDocuments', - mongoClustersImportDocuments, - ); + registerCommandWithTreeNodeUnwrapping('command.mongoClusters.createDocument', createMongoDocument); + + registerCommandWithTreeNodeUnwrapping('command.mongoClusters.importDocuments', importDocuments); /** * Here, exporting documents is done in two ways: one is accessible from the tree view @@ -115,12 +121,6 @@ export class MongoClustersExtension implements vscode.Disposable { mongoClustersExportEntireCollection, ); - registerCommand('command.mongoClusters.addWorkspaceConnection', addWorkspaceConnection); - registerCommandWithTreeNodeUnwrapping( - 'command.mongoClusters.removeWorkspaceConnection', - removeWorkspaceConnection, - ); - ext.outputChannel.appendLine(`MongoDB Clusters: activated.`); }, ); diff --git a/src/mongoClusters/commands/addWorkspaceConnection.ts b/src/mongoClusters/commands/addWorkspaceConnection.ts deleted file mode 100644 index a6ff01ad2..000000000 --- a/src/mongoClusters/commands/addWorkspaceConnection.ts +++ /dev/null @@ -1,103 +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 { AzureWizard, UserCancelledError, type IActionContext } from '@microsoft/vscode-azext-utils'; -import ConnectionString from 'mongodb-connection-string-url'; -import * as vscode from 'vscode'; -import { API } from '../../AzureDBExperiences'; -import { ext } from '../../extensionVariables'; -import { WorkspaceResourceType } from '../../tree/workspace/sharedWorkspaceResourceProvider'; -import { SharedWorkspaceStorage } from '../../tree/workspace/sharedWorkspaceStorage'; -import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; -import { localize } from '../../utils/localize'; -import { areMongoDBRU } from '../utils/connectionStringHelpers'; -import { type AddWorkspaceConnectionContext } from '../wizards/addWorkspaceConnection/AddWorkspaceConnectionContext'; -import { ConnectionStringStep } from '../wizards/addWorkspaceConnection/ConnectionStringStep'; -import { PasswordStep } from '../wizards/addWorkspaceConnection/PasswordStep'; -import { UsernameStep } from '../wizards/addWorkspaceConnection/UsernameStep'; - -export async function addWorkspaceConnection(context: IActionContext): Promise { - const wizardContext: AddWorkspaceConnectionContext = context; - - const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: localize('mongoClusters.addWorkspaceConnection.title', 'Add new MongoDB Clusters connection'), - promptSteps: [new ConnectionStringStep(), new UsernameStep(), new PasswordStep()], - }); - - context.errorHandling.rethrow = true; - context.errorHandling.suppressDisplay = true; - - try { - await wizard.prompt(); - } catch (error) { - if (error instanceof UserCancelledError) { - // The user cancelled the wizard - wizardContext.aborted = true; - return; - } else { - throw error; - } - } - - if (wizardContext.aborted) { - return; - } - - wizardContext.valuesToMask = [wizardContext.connectionString as string, wizardContext.password as string]; - - // construct the connection string - const connectionString = new ConnectionString(wizardContext.connectionString as string); - connectionString.username = wizardContext.username as string; - connectionString.password = wizardContext.password as string; - - const connectionStringWithCredentials = connectionString.toString(); - wizardContext.valuesToMask.push(connectionStringWithCredentials); - - // discover whether it's a MongoDB RU connection string and abort here. - const isRU = areMongoDBRU(connectionString.hosts); - - if (isRU) { - try { - await vscode.window.showInformationMessage( - localize( - 'mongoClusters.addWorkspaceConnection.addingRU', - 'The connection string you provided targets an Azure CosmosDB for MongoDB RU cluster.\n' + - 'It will be added to the "Attached Database Accounts" section.', - ), - { modal: true }, - ); - - void ext.attachedAccountsNode - .attachConnectionString(context, connectionStringWithCredentials, API.MongoDB) - .then((newItem) => { - ext.rgApi.workspaceResourceTreeView.reveal(newItem, { select: true, focus: true }); - }); - } catch (error) { - void vscode.window.showErrorMessage( - localize( - 'mongoClusters.addWorkspaceConnection.errorRU', - 'Failed to add the link to your Azure Cosmos DB for MongoDB RU cluster. \n\n' + error, - ), - { modal: true }, - ); - } - - return; - } - - // Save the connection string - void (await SharedWorkspaceStorage.push(WorkspaceResourceType.MongoClusters, { - id: connectionString.username + '@' + connectionString.redact().toString(), - name: connectionString.username + '@' + connectionString.hosts.join(','), - secrets: [connectionStringWithCredentials], - })); - - // refresh the workspace tree view - ext.mongoClustersWorkspaceBranchDataProvider.refresh(); - - showConfirmationAsInSettings( - localize('showConfirmation.addedWorkspaceConnecdtion', 'New connection has been added to your workspace.'), - ); -} diff --git a/src/mongoClusters/commands/createCollection.ts b/src/mongoClusters/commands/createCollection.ts deleted file mode 100644 index 59d5fa123..000000000 --- a/src/mongoClusters/commands/createCollection.ts +++ /dev/null @@ -1,42 +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 { AzureWizard, nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils'; -import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; -import { localize } from '../../utils/localize'; -import { type DatabaseItem } from '../tree/DatabaseItem'; -import { type CreateCollectionWizardContext } from '../wizards/create/createWizardContexts'; -import { CollectionNameStep } from '../wizards/create/PromptCollectionNameStep'; - -export async function createCollection(context: IActionContext, databaseNode?: DatabaseItem): Promise { - // node ??= ... pick a node if not provided - if (!databaseNode) { - throw new Error('No database selected.'); - } - - const wizardContext: CreateCollectionWizardContext = { - ...context, - credentialsId: databaseNode.mongoCluster.id, - databaseItem: databaseNode, - }; - - const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: localize('mongoClusters.createCollection.title', 'Create collection'), - promptSteps: [new CollectionNameStep()], - showLoadingPrompt: true, - }); - - await wizard.prompt(); - - const newCollectionName = nonNullValue(wizardContext.newCollectionName); - - const success = await databaseNode.createCollection(context, newCollectionName); - - if (success) { - showConfirmationAsInSettings( - localize('showConfirmation.createdDatabase', 'The "{0}" collection has been created.', newCollectionName), - ); - } -} diff --git a/src/mongoClusters/commands/createDatabase.ts b/src/mongoClusters/commands/createDatabase.ts deleted file mode 100644 index a949e2c29..000000000 --- a/src/mongoClusters/commands/createDatabase.ts +++ /dev/null @@ -1,56 +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 { AzureWizard, nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils'; -import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; -import { localize } from '../../utils/localize'; -import { CredentialCache } from '../CredentialCache'; -import { type MongoClusterResourceItem } from '../tree/MongoClusterResourceItem'; -import { - type CreateCollectionWizardContext, - type CreateDatabaseWizardContext, -} from '../wizards/create/createWizardContexts'; -import { DatabaseNameStep } from '../wizards/create/PromptDatabaseNameStep'; - -export async function createDatabase(context: IActionContext, clusterNode?: MongoClusterResourceItem): Promise { - // node ??= ... pick a node if not provided - if (!clusterNode) { - throw new Error('No cluster selected.'); - } - - if (!CredentialCache.hasCredentials(clusterNode.mongoCluster.id)) { - throw new Error( - localize( - 'mongoClusters.notSignedIn', - 'You are not signed in to the MongoDB Cluster. Please sign in (by expanding the node "{0}") and try again.', - clusterNode.mongoCluster.name, - ), - ); - } - - const wizardContext: CreateDatabaseWizardContext = { - ...context, - credentialsId: clusterNode.mongoCluster.id, - mongoClusterItem: clusterNode, - }; - - const wizard: AzureWizard = new AzureWizard(wizardContext, { - title: localize('mongoClusters.createDatabase.title', 'Create database'), - promptSteps: [new DatabaseNameStep()], - showLoadingPrompt: true, - }); - - await wizard.prompt(); - - const newDatabaseName = nonNullValue(wizardContext.newDatabaseName); - - const success = await clusterNode.createDatabase(context, newDatabaseName); - - if (success) { - showConfirmationAsInSettings( - localize('showConfirmation.createdDatabase', 'The "{0}" database has been created.', newDatabaseName), - ); - } -} diff --git a/src/mongoClusters/commands/dropCollection.ts b/src/mongoClusters/commands/dropCollection.ts deleted file mode 100644 index 9499829ca..000000000 --- a/src/mongoClusters/commands/dropCollection.ts +++ /dev/null @@ -1,39 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; -import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; -import { localize } from '../../utils/localize'; -import { type CollectionItem } from '../tree/CollectionItem'; - -export async function dropCollection(context: IActionContext, node?: CollectionItem): Promise { - // node ??= ... pick a node if not provided - if (!node) { - throw new Error('No collection selected.'); - } - - const confirmed = await getConfirmationAsInSettings( - `Drop "${node?.collectionInfo.name}"?`, - `Drop collection "${node?.collectionInfo.name}" and its contents?\nThis can't be undone.`, - node?.collectionInfo.name, - ); - - if (!confirmed) { - return; - } - - const success = await node.delete(context); - - if (success) { - showConfirmationAsInSettings( - localize( - 'showConfirmation.droppedCollection', - 'The "{0}" collection has been dropped.', - node.collectionInfo.name, - ), - ); - } -} diff --git a/src/mongoClusters/commands/dropDatabase.ts b/src/mongoClusters/commands/dropDatabase.ts deleted file mode 100644 index 52f3b860b..000000000 --- a/src/mongoClusters/commands/dropDatabase.ts +++ /dev/null @@ -1,39 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { getConfirmationAsInSettings } from '../../utils/dialogs/getConfirmation'; -import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; -import { localize } from '../../utils/localize'; -import { type DatabaseItem } from '../tree/DatabaseItem'; - -export async function dropDatabase(context: IActionContext, node?: DatabaseItem): Promise { - // node ??= ... pick a node if not provided - if (!node) { - throw new Error('No database selected.'); - } - - const confirmed = await getConfirmationAsInSettings( - `Drop "${node?.databaseInfo.name}"?`, - `Drop database "${node?.databaseInfo.name}" and its contents?\nThis can't be undone.`, - node?.databaseInfo.name, - ); - - if (!confirmed) { - return; - } - - const success = await node.delete(context); - - if (success) { - showConfirmationAsInSettings( - localize( - 'showConfirmation.droppedDatabase', - 'The "{0}" database has been dropped.', - node.databaseInfo.name, - ), - ); - } -} diff --git a/src/mongoClusters/commands/exportDocuments.ts b/src/mongoClusters/commands/exportDocuments.ts index f885da3b9..f166a1b2a 100644 --- a/src/mongoClusters/commands/exportDocuments.ts +++ b/src/mongoClusters/commands/exportDocuments.ts @@ -13,6 +13,8 @@ import { MongoClustersClient } from '../MongoClustersClient'; import { type CollectionItem } from '../tree/CollectionItem'; export async function mongoClustersExportEntireCollection(context: IActionContext, node?: CollectionItem) { + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + return mongoClustersExportQueryResults(context, node); } @@ -21,6 +23,8 @@ export async function mongoClustersExportQueryResults( node?: CollectionItem, props?: { queryText?: string; source?: string }, ): Promise { + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + // node ??= ... pick a node if not provided if (!node) { throw new Error('No collection selected.'); diff --git a/src/mongoClusters/commands/importDocuments.ts b/src/mongoClusters/commands/importDocuments.ts deleted file mode 100644 index 5b4250867..000000000 --- a/src/mongoClusters/commands/importDocuments.ts +++ /dev/null @@ -1,20 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { importDocuments } from '../../commands/importDocuments'; -import { type CollectionItem } from '../tree/CollectionItem'; - -export async function mongoClustersImportDocuments( - context: IActionContext, - collectionNode?: CollectionItem, - _collectionNodes?: CollectionItem[], // required by the TreeNodeCommandCallback, but not used - ...args: unknown[] -): Promise { - const source = (args[0] as { source?: string })?.source || 'contextMenu'; - context.telemetry.properties.calledFrom = source; - - return importDocuments(context, undefined, collectionNode); -} diff --git a/src/mongoClusters/commands/launchShell.ts b/src/mongoClusters/commands/launchShell.ts deleted file mode 100644 index 92e999369..000000000 --- a/src/mongoClusters/commands/launchShell.ts +++ /dev/null @@ -1,57 +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 { nonNullValue, type IActionContext } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { MongoClustersClient } from '../MongoClustersClient'; -import { type CollectionItem } from '../tree/CollectionItem'; -import { type DatabaseItem } from '../tree/DatabaseItem'; -import { MongoClusterItemBase } from '../tree/MongoClusterItemBase'; -import { type MongoClusterResourceItem } from '../tree/MongoClusterResourceItem'; -import { - addAuthenticationDataToConnectionString, - addDatabasePathToConnectionString, -} from '../utils/connectionStringHelpers'; - -export async function launchShell( - _context: IActionContext, - node?: DatabaseItem | CollectionItem | MongoClusterResourceItem, -): Promise { - if (!node) { - throw new Error('No database or collection selected.'); - } - - const client: MongoClustersClient = await MongoClustersClient.getClient(node.mongoCluster.id); - - const connectionString = client.getConnectionString(); - const username = client.getUserName(); - - const connectionStringWithUserName = addAuthenticationDataToConnectionString( - nonNullValue(connectionString), - nonNullValue(username), - undefined, - ); - - let shellParameters = ''; - - if (node instanceof MongoClusterItemBase) { - shellParameters = `"${connectionStringWithUserName}"`; - } /*if (node instanceof DatabaseItem)*/ else { - const connStringWithDb = addDatabasePathToConnectionString( - connectionStringWithUserName, - node.databaseInfo.name, - ); - shellParameters = `"${connStringWithDb}"`; - } - // } else if (node instanceof CollectionItem) { // --> --eval terminates, we'd have to launch with a script etc. let's look into it latter - // const connStringWithDb = addDatabasePathToConnectionString(connectionStringWithUserName, node.databaseInfo.name); - // shellParameters = `"${connStringWithDb}" --eval 'db.getCollection("${node.collectionInfo.name}")'` - // } - - const terminal: vscode.Terminal = vscode.window.createTerminal('MongoDB Clusters Shell'); - - terminal.sendText('mongosh ' + shellParameters); - terminal.show(); -} diff --git a/src/mongoClusters/commands/openCollectionView.ts b/src/mongoClusters/commands/openCollectionView.ts index 560fdd0dc..80de2a7ef 100644 --- a/src/mongoClusters/commands/openCollectionView.ts +++ b/src/mongoClusters/commands/openCollectionView.ts @@ -8,11 +8,27 @@ import { CollectionViewController } from '../../webviews/mongoClusters/collectio import { MongoClustersSession } from '../MongoClusterSession'; import { type CollectionItem } from '../tree/CollectionItem'; -export async function openCollectionView( +export async function openCollectionView(context: IActionContext, node?: CollectionItem) { + if (!node) { + throw new Error('Invalid collection node'); + } + + context.telemetry.properties.experience = node?.mongoCluster.dbExperience?.api; + + return openCollectionViewInternal(context, { + id: node.id, + clusterId: node.mongoCluster.id, + databaseName: node.databaseInfo.name, + collectionName: node.collectionInfo.name, + collectionTreeItem: node, + }); +} + +export async function openCollectionViewInternal( _context: IActionContext, props: { id: string; - liveConnectionId: string; + clusterId: string; databaseName: string; collectionName: string; collectionTreeItem: CollectionItem; @@ -22,12 +38,13 @@ export async function openCollectionView( * We're starting a new "session" using the existing connection. * A session can cache data, handle paging, and convert data. */ - const sessionId = await MongoClustersSession.initNewSession(props.liveConnectionId); + const sessionId = await MongoClustersSession.initNewSession(props.clusterId); const view = new CollectionViewController({ id: props.id, sessionId: sessionId, + clusterId: props.clusterId, databaseName: props.databaseName, collectionName: props.collectionName, collectionTreeItem: props.collectionTreeItem, diff --git a/src/mongoClusters/commands/openDocumentView.ts b/src/mongoClusters/commands/openDocumentView.ts deleted file mode 100644 index 65d6b5b86..000000000 --- a/src/mongoClusters/commands/openDocumentView.ts +++ /dev/null @@ -1,35 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { ViewColumn } from 'vscode'; -import { DocumentsViewController } from '../../webviews/mongoClusters/documentView/documentsViewController'; - -export function openDocumentView( - _context: IActionContext, - props: { - id: string; - - sessionId: string; - databaseName: string; - collectionName: string; - documentId: string; - - mode: string; - }, -): void { - const view = new DocumentsViewController({ - id: props.id, - - sessionId: props.sessionId, - databaseName: props.databaseName, - collectionName: props.collectionName, - documentId: props.documentId, - - mode: props.mode, - }); - - view.revealToForeground(ViewColumn.Active); -} diff --git a/src/mongoClusters/commands/removeWorkspaceConnection.ts b/src/mongoClusters/commands/removeWorkspaceConnection.ts deleted file mode 100644 index 123299ed3..000000000 --- a/src/mongoClusters/commands/removeWorkspaceConnection.ts +++ /dev/null @@ -1,30 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { ext } from '../../extensionVariables'; -import { WorkspaceResourceType } from '../../tree/workspace/sharedWorkspaceResourceProvider'; -import { SharedWorkspaceStorage } from '../../tree/workspace/sharedWorkspaceStorage'; -import { showConfirmationAsInSettings } from '../../utils/dialogs/showConfirmation'; -import { localize } from '../../utils/localize'; -import { type MongoClusterWorkspaceItem } from '../tree/workspace/MongoClusterWorkspaceItem'; - -export async function removeWorkspaceConnection( - _context: IActionContext, - node: MongoClusterWorkspaceItem, -): Promise { - await ext.state.showDeleting(node.id, async () => { - await SharedWorkspaceStorage.delete(WorkspaceResourceType.MongoClusters, node.id); - }); - - ext.mongoClustersWorkspaceBranchDataProvider.refresh(); - - showConfirmationAsInSettings( - localize( - 'showConfirmation.removedWorkspaceConnecdtion', - 'The selected connection has been removed from your workspace.', - ), - ); -} diff --git a/src/mongoClusters/tree/CollectionItem.ts b/src/mongoClusters/tree/CollectionItem.ts index 672ed39dd..e9047c34c 100644 --- a/src/mongoClusters/tree/CollectionItem.ts +++ b/src/mongoClusters/tree/CollectionItem.ts @@ -3,21 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type IActionContext, type TreeElementBase } from '@microsoft/vscode-azext-utils'; -import { type Document } from 'bson'; -import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; -import { ext } from '../../extensionVariables'; -import { - MongoClustersClient, - type CollectionItemModel, - type DatabaseItemModel, - type InsertDocumentsResult, -} from '../MongoClustersClient'; +import { createContextValue, createGenericElement } from '@microsoft/vscode-azext-utils'; +import { ThemeIcon, type TreeItem, TreeItemCollapsibleState } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../../tree/CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; +import { type CollectionItemModel, type DatabaseItemModel } from '../MongoClustersClient'; import { IndexesItem } from './IndexesItem'; import { type MongoClusterModel } from './MongoClusterModel'; -export class CollectionItem { - id: string; +export class CollectionItem implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue { + public readonly id: string; + public readonly experience: Experience; + public readonly contextValue: string = 'treeItem.collection'; + + private readonly experienceContextValue: string = ''; constructor( readonly mongoCluster: MongoClusterModel, @@ -25,12 +26,15 @@ export class CollectionItem { readonly collectionInfo: CollectionItemModel, ) { this.id = `${mongoCluster.id}/${databaseInfo.name}/${collectionInfo.name}`; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience.api}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } - async getChildren(): Promise { + async getChildren(): Promise { return [ createGenericElement({ - contextValue: 'mongoClusters.item.documents', + contextValue: createContextValue(['treeItem.documents', this.experienceContextValue]), id: `${this.id}/documents`, label: 'Documents', commandId: 'command.internal.mongoClusters.containerView.open', @@ -40,47 +44,22 @@ export class CollectionItem { viewTitle: `${this.collectionInfo.name}`, // viewTitle: `${this.mongoCluster.name}/${this.databaseInfo.name}/${this.collectionInfo.name}`, // using '/' as a separator to use VSCode's "title compression"(?) feature - liveConnectionId: this.mongoCluster.id, + clusterId: this.mongoCluster.id, databaseName: this.databaseInfo.name, collectionName: this.collectionInfo.name, collectionTreeItem: this, }, ], iconPath: new ThemeIcon('explorer-view-icon'), - }), + }) as CosmosDBTreeElement, new IndexesItem(this.mongoCluster, this.databaseInfo, this.collectionInfo), ]; } - async delete(_context: IActionContext): Promise { - const client = await MongoClustersClient.getClient(this.mongoCluster.id); - - let success = false; - await ext.state.showDeleting(this.id, async () => { - success = await client.dropCollection(this.databaseInfo.name, this.collectionInfo.name); - }); - - ext.state.notifyChildrenChanged(`${this.mongoCluster.id}/${this.databaseInfo.name}`); - - return success; - } - - async insertDocuments(_context: IActionContext, documents: Document[]): Promise { - const client = await MongoClustersClient.getClient(this.mongoCluster.id); - - let result: InsertDocumentsResult = { acknowledged: false, insertedCount: 0 }; - - await ext.state.runWithTemporaryDescription(this.id, 'Importing...', async () => { - result = await client.insertDocuments(this.databaseInfo.name, this.collectionInfo.name, documents); - }); - - return result; - } - getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.collection', + contextValue: this.contextValue, label: this.collectionInfo.name, iconPath: new ThemeIcon('folder-opened'), collapsibleState: TreeItemCollapsibleState.Collapsed, diff --git a/src/mongoClusters/tree/DatabaseItem.ts b/src/mongoClusters/tree/DatabaseItem.ts index 12c85180d..bca0bce0c 100644 --- a/src/mongoClusters/tree/DatabaseItem.ts +++ b/src/mongoClusters/tree/DatabaseItem.ts @@ -3,26 +3,35 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type IActionContext, type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { createContextValue, createGenericElement } from '@microsoft/vscode-azext-utils'; import * as vscode from 'vscode'; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; -import { ext } from '../../extensionVariables'; -import { localize } from '../../utils/localize'; +import { API, type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../../tree/CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { MongoClustersClient, type DatabaseItemModel } from '../MongoClustersClient'; import { CollectionItem } from './CollectionItem'; import { type MongoClusterModel } from './MongoClusterModel'; -export class DatabaseItem { - id: string; +export class DatabaseItem implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue { + public readonly id: string; + public readonly experience: Experience; + public readonly contextValue: string = 'treeItem.database'; + + private readonly experienceContextValue: string = ''; constructor( readonly mongoCluster: MongoClusterModel, readonly databaseInfo: DatabaseItemModel, ) { this.id = `${mongoCluster.id}/${databaseInfo.name}`; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience?.api ?? API.Common}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } - async getChildren(): Promise { + async getChildren(): Promise { const client: MongoClustersClient = await MongoClustersClient.getClient(this.mongoCluster.id); const collections = await client.listCollections(this.databaseInfo.name); @@ -30,13 +39,13 @@ export class DatabaseItem { // no databases in there: return [ createGenericElement({ - contextValue: 'mongoClusters.item.no-collection', - id: `${this.id}/no-databases`, + contextValue: createContextValue(['treeItem.no-collections', this.experienceContextValue]), + id: `${this.id}/no-collections`, label: 'Create collection...', iconPath: new vscode.ThemeIcon('plus'), commandId: 'command.mongoClusters.createCollection', commandArgs: [this], - }), + }) as CosmosDBTreeElement, ]; } @@ -45,41 +54,12 @@ export class DatabaseItem { }); } - async delete(_context: IActionContext): Promise { - const client = await MongoClustersClient.getClient(this.mongoCluster.id); - - let success = false; - await ext.state.showDeleting(this.id, async () => { - success = await client.dropDatabase(this.databaseInfo.name); - }); - - ext.state.notifyChildrenChanged(this.mongoCluster.id); - - return success; - } - - async createCollection(_context: IActionContext, collectionName: string): Promise { - const client = await MongoClustersClient.getClient(this.mongoCluster.id); - - let success = false; - - await ext.state.showCreatingChild( - this.id, - localize('mongoClusters.tree.creating', 'Creating "{0}"...', collectionName), - async () => { - success = await client.createCollection(this.databaseInfo.name, collectionName); - }, - ); - - return success; - } - getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.database', + contextValue: this.contextValue, label: this.databaseInfo.name, - iconPath: new ThemeIcon('database'), // TODO: create our onw icon here, this one's shape can change + iconPath: new ThemeIcon('database'), // TODO: create our own icon here, this one's shape can change collapsibleState: TreeItemCollapsibleState.Collapsed, }; } diff --git a/src/mongoClusters/tree/IndexItem.ts b/src/mongoClusters/tree/IndexItem.ts index b42fb5f73..91d22b218 100644 --- a/src/mongoClusters/tree/IndexItem.ts +++ b/src/mongoClusters/tree/IndexItem.ts @@ -3,13 +3,21 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { createContextValue, createGenericElement } from '@microsoft/vscode-azext-utils'; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../../tree/CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { type CollectionItemModel, type DatabaseItemModel, type IndexItemModel } from '../MongoClustersClient'; import { type MongoClusterModel } from './MongoClusterModel'; -export class IndexItem { - id: string; +export class IndexItem implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue { + public readonly id: string; + public readonly experience: Experience; + public readonly contextValue: string = 'treeItem.index'; + + private readonly experienceContextValue: string = ''; constructor( readonly mongoCluster: MongoClusterModel, @@ -18,9 +26,12 @@ export class IndexItem { readonly indexInfo: IndexItemModel, ) { this.id = `${mongoCluster.id}/${databaseInfo.name}/${collectionInfo.name}/indexes/${indexInfo.name}`; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience.api}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } - async getChildren(): Promise { + async getChildren(): Promise { return Object.keys(this.indexInfo.key).map((key) => { const value = this.indexInfo.key[key]; @@ -31,14 +42,14 @@ export class IndexItem { // TODO: add a custom icons, and more options here description: value === -1 ? 'desc' : value === 1 ? 'asc' : value.toString(), iconPath: new ThemeIcon('combine'), - }); + }) as CosmosDBTreeElement; }); } getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.index', + contextValue: this.contextValue, label: this.indexInfo.name, iconPath: new ThemeIcon('combine'), // TODO: create our onw icon here, this one's shape can change collapsibleState: TreeItemCollapsibleState.Collapsed, diff --git a/src/mongoClusters/tree/IndexesItem.ts b/src/mongoClusters/tree/IndexesItem.ts index deb059f89..8cc9d0c05 100644 --- a/src/mongoClusters/tree/IndexesItem.ts +++ b/src/mongoClusters/tree/IndexesItem.ts @@ -3,14 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { createContextValue } from '@microsoft/vscode-azext-utils'; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../../tree/CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { MongoClustersClient, type CollectionItemModel, type DatabaseItemModel } from '../MongoClustersClient'; import { IndexItem } from './IndexItem'; import { type MongoClusterModel } from './MongoClusterModel'; -export class IndexesItem { - id: string; +export class IndexesItem implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue { + public readonly id: string; + public readonly experience: Experience; + public readonly contextValue: string = 'treeItem.indexes'; + + private readonly experienceContextValue: string = ''; constructor( readonly mongoCluster: MongoClusterModel, @@ -18,9 +26,12 @@ export class IndexesItem { readonly collectionInfo: CollectionItemModel, ) { this.id = `${mongoCluster.id}/${databaseInfo.name}/${collectionInfo.name}/indexes`; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience.api}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } - async getChildren(): Promise { + async getChildren(): Promise { const client: MongoClustersClient = await MongoClustersClient.getClient(this.mongoCluster.id); const indexes = await client.listIndexes(this.databaseInfo.name, this.collectionInfo.name); return indexes.map((index) => { @@ -31,7 +42,7 @@ export class IndexesItem { getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.indexes', + contextValue: this.contextValue, label: 'Indexes', iconPath: new ThemeIcon('combine'), // TODO: create our onw icon here, this one's shape can change collapsibleState: TreeItemCollapsibleState.Collapsed, diff --git a/src/mongoClusters/tree/MongoClusterItemBase.ts b/src/mongoClusters/tree/MongoClusterItemBase.ts index 5b2b96681..0af9eb71f 100644 --- a/src/mongoClusters/tree/MongoClusterItemBase.ts +++ b/src/mongoClusters/tree/MongoClusterItemBase.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type IActionContext, type TreeElementBase } from '@microsoft/vscode-azext-utils'; +import { createContextValue, createGenericElement } from '@microsoft/vscode-azext-utils'; import { type TreeItem } from 'vscode'; import * as vscode from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; import { ext } from '../../extensionVariables'; -import { localize } from '../../utils/localize'; +import { type CosmosDBTreeElement } from '../../tree/CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../../tree/TreeElementWithExperience'; import { regionToDisplayName } from '../../utils/regionToDisplayName'; import { CredentialCache } from '../CredentialCache'; import { MongoClustersClient, type DatabaseItemModel } from '../MongoClustersClient'; @@ -16,14 +19,20 @@ import { DatabaseItem } from './DatabaseItem'; import { type MongoClusterModel } from './MongoClusterModel'; // This info will be available at every level in the tree for immediate access -export abstract class MongoClusterItemBase implements TreeElementBase { - id: string; +export abstract class MongoClusterItemBase + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly experience: Experience; + public readonly contextValue: string = 'treeItem.mongoCluster'; - constructor( - // public readonly subscription: AzureSubscription, - public mongoCluster: MongoClusterModel, - ) { + private readonly experienceContextValue: string = ''; + + protected constructor(public mongoCluster: MongoClusterModel) { this.id = mongoCluster.id ?? ''; + this.experience = mongoCluster.dbExperience; + this.experienceContextValue = `experience.${this.experience.api}`; + this.contextValue = createContextValue([this.contextValue, this.experienceContextValue]); } /** @@ -35,6 +44,14 @@ export abstract class MongoClusterItemBase implements TreeElementBase { */ protected abstract authenticateAndConnect(): Promise; + /** + * Abstract method to get the connection string for the MongoDB cluster. + * Must be implemented by subclasses. + * + * @returns A promise that resolves to the connection string if successful; otherwise, undefined. + */ + public abstract getConnectionString(): Promise; + /** * Authenticates and connects to the cluster to list all available databases. * Here, the MongoDB client is created and cached for future use. @@ -48,7 +65,7 @@ export abstract class MongoClusterItemBase implements TreeElementBase { * * @returns A list of databases in the cluster or a single element to create a new database. */ - async getChildren(): Promise { + async getChildren(): Promise { ext.outputChannel.appendLine(`MongoDB Clusters: Loading cluster details for "${this.mongoCluster.name}"`); let mongoClustersClient: MongoClustersClient | null; @@ -73,7 +90,7 @@ export abstract class MongoClusterItemBase implements TreeElementBase { label: 'Failed to authenticate (click to retry)', iconPath: new vscode.ThemeIcon('error'), commandId: 'azureResourceGroups.refreshTree', - }), + }) as CosmosDBTreeElement, ]; } @@ -82,13 +99,13 @@ export abstract class MongoClusterItemBase implements TreeElementBase { if (databases.length === 0) { return [ createGenericElement({ - contextValue: 'mongoClusters.item.no-databases', + contextValue: createContextValue(['treeItem.no-databases', this.experienceContextValue]), id: `${this.id}/no-databases`, label: 'Create database...', iconPath: new vscode.ThemeIcon('plus'), - commandId: 'command.mongoClusters.createDatabase', + commandId: 'cosmosDB.createDatabase', commandArgs: [this], - }), + }) as CosmosDBTreeElement, ]; } @@ -97,28 +114,6 @@ export abstract class MongoClusterItemBase implements TreeElementBase { }); } - /** - * Creates a new database in the cluster. - * @param _context The action context. - * @param databaseName The name of the database to create. - * @returns A boolean indicating success. - */ - async createDatabase(_context: IActionContext, databaseName: string): Promise { - const client = await MongoClustersClient.getClient(this.mongoCluster.id); - - let success = false; - - await ext.state.showCreatingChild( - this.id, - localize('mongoClusters.tree.creating', 'Creating "{0}"...', databaseName), - async () => { - success = await client.createDatabase(databaseName); - }, - ); - - return success; - } - /** * Returns the tree item representation of the cluster. * @returns The TreeItem object. @@ -126,7 +121,7 @@ export abstract class MongoClusterItemBase implements TreeElementBase { getTreeItem(): TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.mongoCluster', + contextValue: this.contextValue, label: this.mongoCluster.name, description: this.mongoCluster.sku !== undefined ? `(${this.mongoCluster.sku})` : false, // iconPath: getThemeAgnosticIconPath('CosmosDBAccount.svg'), // Uncomment if icon is available diff --git a/src/mongoClusters/tree/MongoClusterModel.ts b/src/mongoClusters/tree/MongoClusterModel.ts index 0ed3993ad..e83dfbe81 100644 --- a/src/mongoClusters/tree/MongoClusterModel.ts +++ b/src/mongoClusters/tree/MongoClusterModel.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { type MongoCluster, type Resource } from '@azure/arm-cosmosdb'; +import { type Experience } from '../../AzureDBExperiences'; // Selecting only the properties used in the extension, but keeping an easy option to extend the model later and offer full coverage of MongoCluster // '|' means that you can only access properties that are common to both types. @@ -15,6 +16,10 @@ interface ResourceModelInUse extends Resource { name: string; administratorLoginPassword?: string; + + /** + * This connection string does not contain user credentials. + */ connectionString?: string; location?: string; @@ -32,4 +37,9 @@ interface ResourceModelInUse extends Resource { // introduced new properties resourceGroup?: string; + + // adding support for MongoRU and vCore + dbExperience: Experience; + + isServerless?: boolean; } diff --git a/src/mongoClusters/tree/MongoClusterResourceItem.ts b/src/mongoClusters/tree/MongoClusterResourceItem.ts index eeb7c6b4b..14e5272b8 100644 --- a/src/mongoClusters/tree/MongoClusterResourceItem.ts +++ b/src/mongoClusters/tree/MongoClusterResourceItem.ts @@ -25,14 +25,47 @@ import { ProvideUserNameStep } from '../wizards/authenticate/ProvideUsernameStep import { MongoClusterItemBase } from './MongoClusterItemBase'; import { type MongoClusterModel } from './MongoClusterModel'; +import ConnectionString from 'mongodb-connection-string-url'; + export class MongoClusterResourceItem extends MongoClusterItemBase { constructor( - private readonly subscription: AzureSubscription, + readonly subscription: AzureSubscription, mongoCluster: MongoClusterModel, ) { super(mongoCluster); } + public async getConnectionString(): Promise { + return callWithTelemetryAndErrorHandling( + 'cosmosDB.mongoClusters.getConnectionString', + async (context: IActionContext) => { + // Create a client to interact with the MongoDB vCore management API and read the cluster details + const managementClient = await createMongoClustersManagementClient(context, this.subscription); + + const clusterInformation = await managementClient.mongoClusters.get( + this.mongoCluster.resourceGroup as string, + this.mongoCluster.name, + ); + + if (!clusterInformation.connectionString) { + return undefined; + } + + context.valuesToMask.push(clusterInformation.connectionString); + const connectionString = new ConnectionString(clusterInformation.connectionString as string); + + if (clusterInformation.administratorLogin) { + context.valuesToMask.push(clusterInformation.administratorLogin); + connectionString.username = clusterInformation.administratorLogin; + } + + connectionString.password = ''; + + return connectionString.toString(); + }, + ); + } + /** * Authenticates and connects to the MongoDB cluster. * @param context The action context. diff --git a/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts b/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts index 7a9810608..b37d4aeee 100644 --- a/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts +++ b/src/mongoClusters/tree/MongoClustersBranchDataProvider.ts @@ -3,69 +3,60 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { getResourceGroupFromId, uiUtils } from '@microsoft/vscode-azext-azureutils'; import { callWithTelemetryAndErrorHandling, nonNullProp, type IActionContext } from '@microsoft/vscode-azext-utils'; -import { - type AzureResource, - type AzureResourceBranchDataProvider, - type AzureSubscription, - type ResourceModelBase, -} from '@microsoft/vscode-azureresources-api'; +import { type AzureSubscription, type BranchDataProvider } from '@microsoft/vscode-azureresources-api'; import * as vscode from 'vscode'; -import { API } from '../../AzureDBExperiences'; +import { API, MongoClustersExperience } from '../../AzureDBExperiences'; import { ext } from '../../extensionVariables'; +import { type CosmosDBResource } from '../../tree/CosmosAccountModel'; +import { type CosmosDBTreeElement } from '../../tree/CosmosDBTreeElement'; +import { isTreeElementWithContextValue } from '../../tree/TreeElementWithContextValue'; import { createMongoClustersManagementClient } from '../../utils/azureClients'; import { type MongoClusterModel } from './MongoClusterModel'; import { MongoClusterResourceItem } from './MongoClusterResourceItem'; -export interface TreeElementBase extends ResourceModelBase { - getChildren?(): vscode.ProviderResult; - getTreeItem(): vscode.TreeItem | Thenable; - - //viewProperties?: ViewPropertiesModel; -} - export class MongoClustersBranchDataProvider extends vscode.Disposable - implements AzureResourceBranchDataProvider + implements BranchDataProvider { private detailsCacheUpdateRequested = true; private detailsCache: Map = new Map(); private itemsToUpdateInfo: Map = new Map(); - private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); constructor() { - super(() => { - this.onDidChangeTreeDataEmitter.dispose(); - }); + super(() => this.onDidChangeTreeDataEmitter.dispose()); } - get onDidChangeTreeData(): vscode.Event { + get onDidChangeTreeData(): vscode.Event { return this.onDidChangeTreeDataEmitter.event; } - async getChildren(element: TreeElementBase): Promise { + async getChildren(element: CosmosDBTreeElement): Promise { /** * getChildren is called for every element in the tree when expanding, the element being expanded is being passed as an argument */ - return await callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { + const result = await callWithTelemetryAndErrorHandling('getChildren', async (context: IActionContext) => { context.telemetry.properties.experience = API.MongoClusters; - context.telemetry.properties.parentNodeContext = (await element.getTreeItem()).contextValue || 'unknown'; - return (await element.getChildren?.())?.map((child) => { - if (child.id) { - return ext.state.wrapItemInStateHandling(child as TreeElementBase & { id: string }, () => - this.refresh(child), - ); - } - return child; + if (isTreeElementWithContextValue(element)) { + context.telemetry.properties.parentNodeContext = element.contextValue; + } + + const children = (await element.getChildren?.()) ?? []; + return children.map((child) => { + return ext.state.wrapItemInStateHandling(child, (child: CosmosDBTreeElement) => + this.refresh(child), + ) as CosmosDBTreeElement; }); }); + + return result ?? []; } - async getResourceItem(element: AzureResource): Promise { + async getResourceItem(element: CosmosDBResource): Promise { /** * This function is being called when the resource tree is being built, it is called for every resource element in the tree. */ @@ -97,7 +88,10 @@ export class MongoClustersBranchDataProvider } // 1. extract the basic info from the element (subscription, resource group, etc., provided by Azure Resources) - let clusterInfo: MongoClusterModel = element as MongoClusterModel; + let clusterInfo: MongoClusterModel = { + ...element, + dbExperience: MongoClustersExperience, + } as MongoClusterModel; // 2. lookup the details in the cache, on subsequent refreshes, the details will be available in the cache if (this.detailsCache.has(clusterInfo.id)) { @@ -116,8 +110,13 @@ export class MongoClustersBranchDataProvider }, ); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return ext.state.wrapItemInStateHandling(resourceItem!, () => this.refresh(resourceItem)); + if (resourceItem) { + return ext.state.wrapItemInStateHandling(resourceItem, (item: CosmosDBTreeElement) => + this.refresh(item), + ) as CosmosDBTreeElement; + } + + return null as unknown as CosmosDBTreeElement; } async updateResourceCache( @@ -144,6 +143,7 @@ export class MongoClustersBranchDataProvider accounts.map((MongoClustersAccount) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument this.detailsCache.set(nonNullProp(MongoClustersAccount, 'id'), { + dbExperience: MongoClustersExperience, id: MongoClustersAccount.id as string, name: MongoClustersAccount.name as string, resourceGroup: getResourceGroupFromId(MongoClustersAccount.id as string), @@ -183,12 +183,11 @@ export class MongoClustersBranchDataProvider // onDidChangeTreeData?: vscode.Event | undefined; - async getTreeItem(element: TreeElementBase): Promise { - const ti = await element.getTreeItem(); - return ti; + async getTreeItem(element: CosmosDBTreeElement): Promise { + return element.getTreeItem(); } - refresh(element?: TreeElementBase): void { + refresh(element?: CosmosDBTreeElement): void { this.onDidChangeTreeDataEmitter.fire(element); } } diff --git a/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts b/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts index b64f7844b..73cac086e 100644 --- a/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts +++ b/src/mongoClusters/tree/workspace/MongoClusterWorkspaceItem.ts @@ -30,6 +30,10 @@ export class MongoClusterWorkspaceItem extends MongoClusterItemBase { super(mongoCluster); } + public getConnectionString(): Promise { + return Promise.resolve(this.mongoCluster.connectionString); + } + /** * Authenticates and connects to the MongoDB cluster. * @param context The action context. @@ -153,7 +157,7 @@ export class MongoClusterWorkspaceItem extends MongoClusterItemBase { getTreeItem(): vscode.TreeItem { return { id: this.id, - contextValue: 'mongoClusters.item.mongoCluster', + contextValue: this.contextValue, label: this.mongoCluster.name, description: this.mongoCluster.sku !== undefined ? `(${this.mongoCluster.sku})` : false, iconPath: new vscode.ThemeIcon('server-environment'), // Uncomment if icon is available diff --git a/src/mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider.ts b/src/mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider.ts index 35fa65093..82b38ac48 100644 --- a/src/mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider.ts +++ b/src/mongoClusters/tree/workspace/MongoClustersWorkbenchBranchDataProvider.ts @@ -50,6 +50,8 @@ export class MongoClustersWorkspaceBranchDataProvider getResourceItem(): TreeElementBase | Thenable { const resourceItem = new MongoDBAccountsWorkspaceItem(); + // Workspace picker relies on this value + ext.mongoClusterWorkspaceBranchDataResource = resourceItem; return ext.state.wrapItemInStateHandling(resourceItem!, () => this.refresh(resourceItem)); } diff --git a/src/mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem.ts b/src/mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem.ts index e29c37760..142c0ecf1 100644 --- a/src/mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem.ts +++ b/src/mongoClusters/tree/workspace/MongoDBAccountsWorkspaceItem.ts @@ -3,21 +3,26 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createGenericElement, type TreeElementBase } from '@microsoft/vscode-azext-utils'; import { ThemeIcon, TreeItemCollapsibleState, type TreeItem } from 'vscode'; -import { WorkspaceResourceType } from '../../../tree/workspace/sharedWorkspaceResourceProvider'; -import { SharedWorkspaceStorage } from '../../../tree/workspace/sharedWorkspaceStorage'; +import { MongoClustersExperience, type Experience } from '../../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../../../tree/CosmosDBTreeElement'; +import { type TreeElementWithExperience } from '../../../tree/TreeElementWithExperience'; +import { WorkspaceResourceType } from '../../../tree/workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage } from '../../../tree/workspace/SharedWorkspaceStorage'; import { type MongoClusterModel } from '../MongoClusterModel'; import { MongoClusterWorkspaceItem } from './MongoClusterWorkspaceItem'; +import { MongoDBAttachAccountResourceItem } from './MongoDBAttachAccountResourceItem'; -export class MongoDBAccountsWorkspaceItem implements TreeElementBase { - id: string; +export class MongoDBAccountsWorkspaceItem implements CosmosDBTreeElement, TreeElementWithExperience { + public readonly id: string; + public readonly experience: Experience; constructor() { this.id = `vscode.cosmosdb.workspace.mongoclusters.accounts`; + this.experience = MongoClustersExperience; } - async getChildren(): Promise { + async getChildren(): Promise { const items = await SharedWorkspaceStorage.getItems(WorkspaceResourceType.MongoClusters); return [ @@ -25,17 +30,12 @@ export class MongoDBAccountsWorkspaceItem implements TreeElementBase { const model: MongoClusterModel = { id: item.id, name: item.name, + dbExperience: MongoClustersExperience, connectionString: item?.secrets?.[0] ?? undefined, }; return new MongoClusterWorkspaceItem(model); }), - createGenericElement({ - contextValue: this.id + '/newConnection', - id: this.id + '/newConnection', - label: 'New Connection...', - iconPath: new ThemeIcon('plus'), - commandId: 'command.mongoClusters.addWorkspaceConnection', - }), + new MongoDBAttachAccountResourceItem(this.id), ]; } diff --git a/src/mongoClusters/tree/workspace/MongoDBAttachAccountResourceItem.ts b/src/mongoClusters/tree/workspace/MongoDBAttachAccountResourceItem.ts new file mode 100644 index 000000000..908d95d13 --- /dev/null +++ b/src/mongoClusters/tree/workspace/MongoDBAttachAccountResourceItem.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import vscode from 'vscode'; +import { type CosmosDBTreeElement } from '../../../tree/CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../../../tree/TreeElementWithContextValue'; + +export class MongoDBAttachAccountResourceItem implements CosmosDBTreeElement, TreeElementWithContextValue { + public readonly id: string; + public readonly contextValue: string = 'treeItem.newConnection'; + + constructor(public readonly parentId: string) { + this.id = `${parentId}/newConnection`; + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: 'New Connection\u2026', + iconPath: new vscode.ThemeIcon('plus'), + command: { + command: 'cosmosDB.attachDatabaseAccount', + title: '', + arguments: [this], + }, + }; + } +} diff --git a/src/mongoClusters/wizards/addWorkspaceConnection/PasswordStep.ts b/src/mongoClusters/wizards/addWorkspaceConnection/PasswordStep.ts deleted file mode 100644 index 18ffc5d87..000000000 --- a/src/mongoClusters/wizards/addWorkspaceConnection/PasswordStep.ts +++ /dev/null @@ -1,42 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { assert } from 'console'; -import ConnectionString from 'mongodb-connection-string-url'; -import { localize } from '../../../utils/localize'; -import { type AddWorkspaceConnectionContext } from './AddWorkspaceConnectionContext'; - -export class PasswordStep extends AzureWizardPromptStep { - private passwordFromCS: string = ''; - - public configureBeforePrompt(wizardContext: AddWorkspaceConnectionContext): void | Promise { - assert(wizardContext.connectionString, 'connectionString is required for UsernameStep'); - - const parsedCS = new ConnectionString(wizardContext.connectionString as string); - this.passwordFromCS = parsedCS.password || ''; - } - - public async prompt(context: AddWorkspaceConnectionContext): Promise { - const prompt: string = localize( - 'mongoClusters.addWorkspaceConnection.password.prompt', - 'Enter the password for the MongoDB cluster.', - ); - - context.password = await context.ui.showInputBox({ - prompt: prompt, - value: this.passwordFromCS, - ignoreFocusOut: true, - password: true, - }); - - context.valuesToMask.push(context.password); - } - - public shouldPrompt(): boolean { - return true; - } -} diff --git a/src/mongoClusters/wizards/addWorkspaceConnection/UsernameStep.ts b/src/mongoClusters/wizards/addWorkspaceConnection/UsernameStep.ts deleted file mode 100644 index 2f1612f07..000000000 --- a/src/mongoClusters/wizards/addWorkspaceConnection/UsernameStep.ts +++ /dev/null @@ -1,41 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* eslint-disable @typescript-eslint/no-unused-vars */ -import { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { assert } from 'console'; -import ConnectionString from 'mongodb-connection-string-url'; -import { localize } from '../../../utils/localize'; -import { type AddWorkspaceConnectionContext } from './AddWorkspaceConnectionContext'; - -export class UsernameStep extends AzureWizardPromptStep { - private usernameFromCS: string = ''; - - public configureBeforePrompt(wizardContext: AddWorkspaceConnectionContext): void | Promise { - assert(wizardContext.connectionString, 'connectionString is required for UsernameStep'); - - const parsedCS = new ConnectionString(wizardContext.connectionString as string); - this.usernameFromCS = parsedCS.username || ''; - } - - public async prompt(context: AddWorkspaceConnectionContext): Promise { - const prompt: string = localize( - 'mongoClusters.addWorkspaceConnection.username.prompt', - 'Enter the username for the MongoDB cluster.', - ); - - context.username = await context.ui.showInputBox({ - prompt: prompt, - ignoreFocusOut: true, - value: this.usernameFromCS, - }); - - context.valuesToMask.push(context.username); - } - - public shouldPrompt(): boolean { - return true; - } -} diff --git a/src/mongoClusters/wizards/create/createWizardContexts.ts b/src/mongoClusters/wizards/create/createWizardContexts.ts deleted file mode 100644 index 5648a5627..000000000 --- a/src/mongoClusters/wizards/create/createWizardContexts.ts +++ /dev/null @@ -1,26 +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 { type IActionContext } from '@microsoft/vscode-azext-utils'; -import { type DatabaseItem } from '../../tree/DatabaseItem'; -import { type MongoClusterResourceItem } from '../../tree/MongoClusterResourceItem'; - -export interface CreateCollectionWizardContext extends IActionContext { - /** These values have to be provided for the wizard to function correctly. */ - credentialsId: string; - databaseItem: DatabaseItem; - - /** These values will be populated by the wizard. */ - newCollectionName?: string; -} - -export interface CreateDatabaseWizardContext extends IActionContext { - /** These values have to be provided for the wizard to function correctly. */ - credentialsId: string; - mongoClusterItem: MongoClusterResourceItem; - - /** These values will be populated by the wizard. */ - newDatabaseName?: string; -} diff --git a/src/panels/QueryEditorTab.ts b/src/panels/QueryEditorTab.ts index 88a7d56db..b35467a6a 100644 --- a/src/panels/QueryEditorTab.ts +++ b/src/panels/QueryEditorTab.ts @@ -7,12 +7,13 @@ import { type PartitionKeyDefinition } from '@azure/cosmos'; import { callWithTelemetryAndErrorHandling } from '@microsoft/vscode-azext-utils'; import * as crypto from 'crypto'; import * as vscode from 'vscode'; -import { getNoSqlQueryConnection } from '../docdb/commands/connectNoSqlContainer'; + import { getCosmosClientByConnection } from '../docdb/getCosmosClient'; import { type NoSqlQueryConnection } from '../docdb/NoSqlCodeLensProvider'; import { DocumentSession } from '../docdb/session/DocumentSession'; import { QuerySession } from '../docdb/session/QuerySession'; import { type CosmosDbRecordIdentifier, type ResultViewMetadata } from '../docdb/types/queryResult'; +import { getNoSqlQueryConnection } from '../docdb/utils/NoSqlQueryConnection'; import * as vscodeUtil from '../utils/vscodeUtils'; import { BaseTab, type CommandPayload } from './BaseTab'; import { DocumentTab } from './DocumentTab'; diff --git a/src/postgres/commands/copyConnectionString.ts b/src/postgres/commands/copyConnectionString.ts index 59bce8f68..e68c64eea 100644 --- a/src/postgres/commands/copyConnectionString.ts +++ b/src/postgres/commands/copyConnectionString.ts @@ -8,7 +8,7 @@ import * as vscode from 'vscode'; import { postgresFlexibleFilter, postgresSingleFilter } from '../../constants'; import { ext } from '../../extensionVariables'; import { localize } from '../../utils/localize'; -import { addDatabaseToConnectionString, copyPostgresConnectionString } from '../postgresConnectionStrings'; +import { addDatabaseToConnectionString, buildPostgresConnectionString } from '../postgresConnectionStrings'; import { PostgresDatabaseTreeItem } from '../tree/PostgresDatabaseTreeItem'; import { checkAuthentication } from './checkAuthentication'; @@ -25,7 +25,7 @@ export async function copyConnectionString(context: IActionContext, node: Postgr let connectionString: string; if (node.parent.azureName) { const parsedCS = await node.parent.getFullConnectionString(); - connectionString = copyPostgresConnectionString( + connectionString = buildPostgresConnectionString( parsedCS.hostName, parsedCS.port, parsedCS.username, diff --git a/src/postgres/commands/createPostgresServer/IPostgresServerWizardContext.ts b/src/postgres/commands/createPostgresServer/IPostgresServerWizardContext.ts deleted file mode 100644 index 5e482f053..000000000 --- a/src/postgres/commands/createPostgresServer/IPostgresServerWizardContext.ts +++ /dev/null @@ -1,24 +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 { type ExecuteActivityContext } from '@microsoft/vscode-azext-utils'; -import { type IAzureDBWizardContext } from '../../../tree/IAzureDBWizardContext'; -import { type AbstractSku, type PostgresAbstractServer, type PostgresServerType } from '../../abstract/models'; - -export interface IPostgresServerWizardContext extends IAzureDBWizardContext, ExecuteActivityContext { - /** - * Username without server, i.e. "user1" - */ - shortUserName?: string; - /** - * Username with server, i.e. "user1@server1" - */ - longUserName?: string; - adminPassword?: string; - - server?: PostgresAbstractServer; - sku?: AbstractSku; - serverType?: PostgresServerType; -} diff --git a/src/postgres/commands/createPostgresServer/steps/PostgresServerConfirmPWStep.ts b/src/postgres/commands/createPostgresServer/steps/PostgresServerConfirmPWStep.ts deleted file mode 100644 index 684a98c30..000000000 --- a/src/postgres/commands/createPostgresServer/steps/PostgresServerConfirmPWStep.ts +++ /dev/null @@ -1,35 +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 { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../../../utils/localize'; -import { type IPostgresServerWizardContext } from '../IPostgresServerWizardContext'; - -export class PostgresServerConfirmPWStep extends AzureWizardPromptStep { - public async prompt(context: IPostgresServerWizardContext): Promise { - const prompt: string = localize('confirmPW', 'Confirm your password'); - await context.ui.showInputBox({ - prompt, - password: true, - validateInput: async (value: string | undefined): Promise => - await this.validatePassword(context, value), - }); - } - - public shouldPrompt(context: IPostgresServerWizardContext): boolean { - return !!context.adminPassword; - } - - private async validatePassword( - context: IPostgresServerWizardContext, - passphrase: string | undefined, - ): Promise { - if (passphrase !== context.adminPassword) { - return localize('pwMatch', 'The passwords must match.'); - } - - return undefined; - } -} diff --git a/src/postgres/commands/createPostgresServer/steps/PostgresServerCreateStep.ts b/src/postgres/commands/createPostgresServer/steps/PostgresServerCreateStep.ts deleted file mode 100644 index 22fceb266..000000000 --- a/src/postgres/commands/createPostgresServer/steps/PostgresServerCreateStep.ts +++ /dev/null @@ -1,118 +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 * as SingleModels from '@azure/arm-postgresql'; -import * as FlexibleModels from '@azure/arm-postgresql-flexible'; -import { LocationListStep } from '@microsoft/vscode-azext-azureutils'; -import { AzureWizardExecuteStep, callWithMaskHandling } from '@microsoft/vscode-azext-utils'; -import { type AppResource } from '@microsoft/vscode-azext-utils/hostapi'; -import { type Progress } from 'vscode'; -import { ext } from '../../../../extensionVariables'; -import { createPostgreSQLClient, createPostgreSQLFlexibleClient } from '../../../../utils/azureClients'; -import { localize } from '../../../../utils/localize'; -import { nonNullProp } from '../../../../utils/nonNull'; -import { PostgresServerType, type AbstractServerCreate } from '../../../abstract/models'; -import { type IPostgresServerWizardContext } from '../IPostgresServerWizardContext'; - -export class PostgresServerCreateStep extends AzureWizardExecuteStep { - public priority: number = 150; - - public async execute( - context: IPostgresServerWizardContext, - progress: Progress<{ message?: string; increment?: number }>, - ): Promise { - const locationName: string = (await LocationListStep.getLocation(context)).name; - const rgName: string = nonNullProp(nonNullProp(context, 'resourceGroup'), 'name'); - const size: string = nonNullProp(nonNullProp(context, 'sku'), 'size'); - const newServerName = nonNullProp(context, 'newServerName'); - const password: string = nonNullProp(context, 'adminPassword'); - - return await callWithMaskHandling(async () => { - const serverType = nonNullProp(context, 'serverType'); - const createMessage: string = localize( - 'creatingPostgresServer', - 'Creating PostgreSQL Server "{0}"... It should be ready in several minutes.', - context.newServerName, - ); - - ext.outputChannel.appendLog(createMessage); - progress.report({ message: createMessage }); - const options: AbstractServerCreate = { - location: locationName, - sku: nonNullProp(context, 'sku'), - administratorLogin: nonNullProp(context, 'shortUserName'), - administratorLoginPassword: password, - size: parseInt(size), - }; - - switch (serverType) { - case PostgresServerType.Single: { - const singleClient: SingleModels.PostgreSQLManagementClient = await createPostgreSQLClient(context); - context.server = await singleClient.servers.beginCreateAndWait( - rgName, - newServerName, - this.asSingleParameters(options), - ); - break; - } - case PostgresServerType.Flexible: { - const flexiClient: FlexibleModels.PostgreSQLManagementFlexibleServerClient = - await createPostgreSQLFlexibleClient(context); - context.server = await flexiClient.servers.beginCreateAndWait( - rgName, - newServerName, - this.asFlexibleParameters(options), - ); - break; - } - } - context.server.serverType = serverType; - context.activityResult = context.server as AppResource; - }, password); - } - - public shouldExecute(context: IPostgresServerWizardContext): boolean { - return !context.server; - } - - private asFlexibleParameters(parameters: AbstractServerCreate): FlexibleModels.Server { - return { - location: parameters.location, - version: FlexibleModels.KnownServerVersion.Fourteen, - administratorLogin: parameters.administratorLogin, - administratorLoginPassword: parameters.administratorLoginPassword, - storage: { - storageSizeGB: parameters.size, - }, - sku: { - name: parameters.sku.name, - tier: parameters.sku.tier, - }, - }; - } - - private asSingleParameters(parameters: AbstractServerCreate): SingleModels.ServerForCreate { - return { - location: parameters.location, - sku: { - name: parameters.sku.name, - capacity: parameters.sku.capacity, - size: parameters.sku.size, - family: parameters.sku.family, - tier: parameters.sku.tier as SingleModels.SkuTier, - }, - properties: { - administratorLogin: parameters.administratorLogin, - administratorLoginPassword: parameters.administratorLoginPassword, - sslEnforcement: 'Enabled', - createMode: 'Default', - version: SingleModels.KnownServerVersion.Eleven, - storageProfile: { - storageMB: parameters.size, - }, - }, - }; - } -} diff --git a/src/postgres/commands/createPostgresServer/steps/PostgresServerCredPWStep.ts b/src/postgres/commands/createPostgresServer/steps/PostgresServerCredPWStep.ts deleted file mode 100644 index f2518d80b..000000000 --- a/src/postgres/commands/createPostgresServer/steps/PostgresServerCredPWStep.ts +++ /dev/null @@ -1,56 +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 { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../../../utils/localize'; -import { nonNullProp } from '../../../../utils/nonNull'; -import { type IPostgresServerWizardContext } from '../IPostgresServerWizardContext'; - -const pwConditionMsg = localize( - 'passwordConditionMsg', - 'Password must contain characters from three of the following categories - uppercase letters, lowercase letters, numbers (0-9), and non-alphanumeric characters (!, $, etc.).', -); - -export class PostgresServerCredPWStep extends AzureWizardPromptStep { - public async prompt(context: IPostgresServerWizardContext): Promise { - const user = nonNullProp(context, 'shortUserName'); - context.adminPassword = await context.ui.showInputBox({ - placeHolder: localize('pwPlaceholder', 'Administrator Password'), - prompt: pwConditionMsg, - password: true, - validateInput: (password: string) => validatePassword(user, password), - }); - } - - public shouldPrompt(context: IPostgresServerWizardContext): boolean { - return !context.adminPassword; - } -} - -async function validatePassword(username: string, password: string): Promise { - password = password ? password : ''; - - const min = 8; - const max = 128; - - const regex = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^a-zA-Z\d\s]/]; - let numOccurrence = 0; - - regex.map((substring) => { - if (password.match(substring)) { - numOccurrence++; - } - }); - - if (password.length < min || password.length > max) { - return localize('pwLengthCheck', 'Password must be between {0} and {1} characters.', min, max); - } else if (numOccurrence < 3) { - return pwConditionMsg; - } else if (password.includes(username)) { - return localize('pwUserSimalarityCheck', 'Password cannot contain the username.'); - } else { - return undefined; - } -} diff --git a/src/postgres/commands/createPostgresServer/steps/PostgresServerCredUserStep.ts b/src/postgres/commands/createPostgresServer/steps/PostgresServerCredUserStep.ts deleted file mode 100644 index ba9b2b487..000000000 --- a/src/postgres/commands/createPostgresServer/steps/PostgresServerCredUserStep.ts +++ /dev/null @@ -1,54 +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 { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../../../utils/localize'; -import { nonNullProp } from '../../../../utils/nonNull'; -import { type IPostgresServerWizardContext } from '../IPostgresServerWizardContext'; - -export class PostgresServerCredUserStep extends AzureWizardPromptStep { - public async prompt(context: IPostgresServerWizardContext): Promise { - context.shortUserName = ( - await context.ui.showInputBox({ - placeHolder: localize('usernamePlaceholder', 'Administrator Username'), - validateInput: validateUser, - }) - ).trim(); - const usernameSuffix: string = `@${nonNullProp(context, 'newServerName')}`; - context.longUserName = context.shortUserName + usernameSuffix; - } - - public shouldPrompt(context: IPostgresServerWizardContext): boolean { - return !context.shortUserName; - } -} - -async function validateUser(username: string): Promise { - username = username ? username.trim() : ''; - - const min = 1; - const max = 63; - - const restricted = ['azure_superuser', 'azure_pg_admin', 'admin', 'administrator', 'root', 'guest', 'public']; - - if (username.length < min || username.length > max) { - return localize('usernameLenghtMatch', 'The name must be between {0} and {1} characters.', min, max); - } else if (!username.match(/^[a-zA-Z0-9_]+$/)) { - return localize('usernameCharacterCheck', 'The name can only contain letters, numbers, and the "_" character.'); - } else if (username.match(/^[0-9]+/)) { - return localize('usernameBeginningMatch', 'The name cannot start with a number.'); - } else if (username.toLowerCase().startsWith('pg_')) { - return localize('usernameStartWithCheck', 'Admin username cannot start with "pg_".'); - } else if (restricted.includes(username.toLowerCase())) { - const restrictedString = restricted.map((d) => `"${d}"`).join(', '); - return localize( - 'usernameRestrictedCheck', - 'Admin username cannot be any of the following: {0}.', - restrictedString, - ); - } else { - return undefined; - } -} diff --git a/src/postgres/commands/createPostgresServer/steps/PostgresServerNameStep.ts b/src/postgres/commands/createPostgresServer/steps/PostgresServerNameStep.ts deleted file mode 100644 index d560f139d..000000000 --- a/src/postgres/commands/createPostgresServer/steps/PostgresServerNameStep.ts +++ /dev/null @@ -1,74 +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 { ResourceGroupListStep, resourceGroupNamingRules } from '@microsoft/vscode-azext-azureutils'; -import { AzureNameStep } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../../../utils/localize'; -import { nonNullProp } from '../../../../utils/nonNull'; -import { createAbstractPostgresClient, type AbstractPostgresClient } from '../../../abstract/AbstractPostgresClient'; -import { PostgresServerType, type AbstractNameAvailability } from '../../../abstract/models'; -import { type IPostgresServerWizardContext } from '../IPostgresServerWizardContext'; - -export class PostgresServerNameStep extends AzureNameStep { - public async prompt(context: IPostgresServerWizardContext): Promise { - const client = await createAbstractPostgresClient(nonNullProp(context, 'serverType'), context); - context.newServerName = ( - await context.ui.showInputBox({ - placeHolder: localize('serverNamePlaceholder', 'Server name'), - prompt: localize('enterServerNamePrompt', 'Provide a name for the PostgreSQL Server.'), - validateInput: (name: string) => - validatePostgresServerName(name, client, nonNullProp(context, 'serverType')), - }) - ).trim(); - context.valuesToMask.push(context.newServerName); - context.relatedNameTask = this.generateRelatedName(context, context.newServerName, resourceGroupNamingRules); - } - - public shouldPrompt(context: IPostgresServerWizardContext): boolean { - return !context.newServerName; - } - - protected async isRelatedNameAvailable(context: IPostgresServerWizardContext, name: string): Promise { - return await ResourceGroupListStep.isNameAvailable(context, name); - } -} - -async function validatePostgresServerName( - name: string, - client: AbstractPostgresClient, - serverType: PostgresServerType, -): Promise { - name = name ? name.trim() : ''; - - const min = 3; - const max = 63; - - if (name.length < min || name.length > max) { - return localize('serverNameLengthCheck', 'The name must be between {0} and {1} characters.', min, max); - } else if (!/^[a-z0-9-]+$/.test(name)) { - return localize( - 'serverNameCharacterCheck', - 'Server name must only contain lowercase letters, numbers, and hyphens.', - ); - } else if (name.startsWith('-') || name.endsWith('-')) { - return localize('serverNamePrefixSuffixCheck', 'Server name must not start or end in a hyphen.'); - } - const resourceType = - serverType === PostgresServerType.Single - ? 'Microsoft.DBforPostgreSQL' - : 'Microsoft.DBforPostgreSQL/flexibleServers'; - const availability: AbstractNameAvailability = await client.checkNameAvailability.execute({ - name: name, - type: resourceType, - }); - - if (!availability.nameAvailable) { - return availability.message - ? availability.message - : localize('serverNameAvailabilityCheck', 'Server name "{0}" is not available.', name); - } - - return undefined; -} diff --git a/src/postgres/commands/createPostgresServer/steps/PostgresServerSetCredentialsStep.ts b/src/postgres/commands/createPostgresServer/steps/PostgresServerSetCredentialsStep.ts deleted file mode 100644 index 56384ccdc..000000000 --- a/src/postgres/commands/createPostgresServer/steps/PostgresServerSetCredentialsStep.ts +++ /dev/null @@ -1,56 +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 { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { type Progress } from 'vscode'; -import { ext } from '../../../../extensionVariables'; -import { localize } from '../../../../utils/localize'; -import { nonNullProp } from '../../../../utils/nonNull'; -import { PostgresServerType, type PostgresAbstractServer } from '../../../abstract/models'; -import { setPostgresCredentials } from '../../setPostgresCredentials'; -import { type IPostgresServerWizardContext } from '../IPostgresServerWizardContext'; - -export class PostgresServerSetCredentialsStep extends AzureWizardExecuteStep { - public priority: number = 200; - - public async execute( - context: IPostgresServerWizardContext, - progress: Progress<{ message?: string; increment?: number }>, - ): Promise { - let user: string; - // Username doesn't contain servername prefix for Postgres Flexible Servers only - // As present on the portal for any Flexbile Server instance - if (context.serverType && context.serverType === PostgresServerType.Single) { - user = nonNullProp(context, 'longUserName'); - } else { - user = nonNullProp(context, 'shortUserName'); - } - const newServerName: string = nonNullProp(context, 'newServerName'); - - const setupMessage: string = localize( - 'setupCredentialsMessage', - 'Setting up Credentials for server "{0}"...', - newServerName, - ); - progress.report({ message: setupMessage }); - ext.outputChannel.appendLog(setupMessage); - const password: string = nonNullProp(context, 'adminPassword'); - const server: PostgresAbstractServer = nonNullProp(context, 'server'); - - await setPostgresCredentials(user, password, nonNullProp(server, 'id')); - const completedMessage: string = localize( - 'addedCredentialsMessage', - 'Successfully setup credentials for server "{0}".', - newServerName, - ); - void vscode.window.showInformationMessage(completedMessage); - ext.outputChannel.appendLog(completedMessage); - } - - public shouldExecute(): boolean { - return true; - } -} diff --git a/src/postgres/commands/createPostgresServer/steps/PostgresServerSkuStep.ts b/src/postgres/commands/createPostgresServer/steps/PostgresServerSkuStep.ts deleted file mode 100644 index 7a9bf1139..000000000 --- a/src/postgres/commands/createPostgresServer/steps/PostgresServerSkuStep.ts +++ /dev/null @@ -1,253 +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 { AzureWizardPromptStep, type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../../../utils/localize'; -import { nonNullProp } from '../../../../utils/nonNull'; -import { openUrl } from '../../../../utils/openUrl'; -import { PostgresServerType, type AbstractSku } from '../../../abstract/models'; -import { type IPostgresServerWizardContext } from '../IPostgresServerWizardContext'; - -interface ISkuOption { - label: string; - description: string; - sku: AbstractSku; - group?: string; -} - -export class PostgresServerSkuStep extends AzureWizardPromptStep { - public async prompt(context: IPostgresServerWizardContext): Promise { - const placeHolder: string = localize('selectPostgresSku', 'Select the Postgres SKU and options.'); - const pricingTiers: IAzureQuickPickItem[] = await this.getPicks( - nonNullProp(context, 'serverType'), - ); - pricingTiers.push({ - label: localize('ShowPricingCalculator', '$(link-external) Show pricing information...'), - onPicked: async () => { - await openUrl('https://aka.ms/AAcxhvm'); - }, - data: undefined, - }); - - context.sku = ( - await context.ui.showQuickPick(pricingTiers, { - placeHolder, - suppressPersistence: true, - enableGrouping: true, - }) - ).data; - } - - public shouldPrompt(context: IPostgresServerWizardContext): boolean { - return context.sku === undefined; - } - - public async getPicks(serverType: PostgresServerType): Promise[]> { - const options: IAzureQuickPickItem[] = []; - const skuOptions: ISkuOption[] = - serverType === PostgresServerType.Single ? singleServerSkus : flexibleServerSkus; - - skuOptions.forEach((option) => { - options.push({ - label: option.label, - description: localize(nonNullProp(option.sku, 'name'), option.description), - data: option.sku, - group: option.group || localize('addlOptions', 'Additional Options'), - }); - }); - return options; - } -} - -const recommendedGroup = localize('recommendGroup', 'Recommended'); -const singleServerSkus: ISkuOption[] = [ - { - label: 'B1', - description: 'Basic, 1 vCore, 2GiB Memory, 5GB storage', - sku: { - name: 'B_Gen5_1', - tier: 'Basic', - capacity: 1, - family: 'Gen5', - size: '5120', - }, - group: recommendedGroup, - }, - { - label: 'B2', - description: 'Basic, 2 vCores, 4GiB Memory, 50GB storage', - sku: { - name: 'B_Gen5_2', - tier: 'Basic', - capacity: 2, - family: 'Gen5', - size: '51200', - }, - }, - { - label: 'GP2', - description: 'General Purpose, 2 vCores, 10GiB Memory, 50GB storage', - sku: { - name: 'GP_Gen5_2', - tier: 'GeneralPurpose', - capacity: 2, - family: 'Gen5', - size: '51200', - }, - group: recommendedGroup, - }, - { - label: 'GP4', - description: 'General Purpose, 4 vCores, 20GiB Memory, 50GB storage', - sku: { - name: 'GP_Gen5_4', - tier: 'GeneralPurpose', - capacity: 4, - family: 'Gen5', - size: '51200', - }, - }, - { - label: 'GP8', - description: 'General Purpose, 8 vCores, 40GiB Memory, 200GB storage', - sku: { - name: 'GP_Gen5_8', - tier: 'GeneralPurpose', - capacity: 8, - family: 'Gen5', - size: '204800', - }, - }, - { - label: 'GP16', - description: 'General Purpose, 16 vCores, 80GiB Memory, 200GB storage', - sku: { - name: 'GP_Gen5_16', - tier: 'GeneralPurpose', - capacity: 16, - family: 'Gen5', - size: '204800', - }, - }, - { - label: 'GP32', - description: 'General Purpose, 32 vCores, 160GiB Memory, 200GB storage', - sku: { - name: 'GP_Gen5_32', - tier: 'GeneralPurpose', - capacity: 32, - family: 'Gen5', - size: '204800', - }, - }, - { - label: 'GP64', - description: 'General Purpose, 64 vCores, 320GiB Memory, 200GB storage', - sku: { - name: 'GP_Gen5_64', - tier: 'GeneralPurpose', - capacity: 64, - family: 'Gen5', - size: '204800', - }, - }, -]; - -// Official storage sizes are 32768, 65536, 131072, 262144, 524288, 1048576, 2097152, 4194304, 8388608, 16777216 -// See https://docs.microsoft.com/en-au/azure/postgresql/flexible-server/concepts-compute-storage#storage -const flexibleServerSkus: ISkuOption[] = [ - { - label: 'B1ms', - description: 'Basic, 1 vCore, 2GiB Memory, 32GB storage', - sku: { - name: 'Standard_B1ms', - tier: 'Burstable', - capacity: 1, - size: '32', - }, - group: recommendedGroup, - }, - { - label: 'B2s', - description: 'Basic, 2 vCore, 4GiB Memory, 32GB storage', - sku: { - name: 'Standard_B2s', - tier: 'Burstable', - capacity: 2, - size: '32', - }, - }, - { - label: 'D2s_v3', - description: 'General Purpose, 2 vCore, 8GiB Memory, 32GB storage', - sku: { - name: 'Standard_D2s_v3', - tier: 'GeneralPurpose', - capacity: 2, - size: '32', - }, - group: recommendedGroup, - }, - { - label: 'D4s_v3', - description: 'General Purpose, 4 vCore, 16GiB Memory, 32GB storage', - sku: { - name: 'Standard_D4s_v3', - tier: 'GeneralPurpose', - capacity: 4, - size: '32', - }, - }, - { - label: 'D8s_v3', - description: 'General Purpose, 8 vCore, 32GiB Memory, 64GB storage', - sku: { - name: 'Standard_D8s_v3', - tier: 'GeneralPurpose', - capacity: 8, - size: '64', - }, - }, - { - label: 'D16s_v3', - description: 'General Purpose, 16 vCore, 64GiB Memory, 64GB storage', - sku: { - name: 'Standard_D16s_v3', - tier: 'GeneralPurpose', - capacity: 16, - size: '64', - }, - }, - { - label: 'D32s_v3', - description: 'General Purpose, 32 vCore, 128GiB Memory, 64GB storage', - sku: { - name: 'Standard_D32s_v3', - tier: 'GeneralPurpose', - capacity: 32, - size: '64', - }, - }, - { - label: 'D48s_v3', - description: 'General Purpose, 48 vCore, 192GiB Memory, 256GB storage', - sku: { - name: 'Standard_D48s_v3', - tier: 'GeneralPurpose', - capacity: 48, - size: '256', - }, - }, - { - label: 'D64s_v3', - description: 'General Purpose, 64 vCore, 256GiB Memory, 256GB storage', - sku: { - name: 'Standard_D64s_v3', - tier: 'GeneralPurpose', - capacity: 64, - size: '256', - }, - }, -]; diff --git a/src/postgres/commands/deletePostgresServer.ts b/src/postgres/commands/deletePostgresServer.ts index 607951255..0a60841ce 100644 --- a/src/postgres/commands/deletePostgresServer.ts +++ b/src/postgres/commands/deletePostgresServer.ts @@ -18,5 +18,5 @@ export async function deletePostgresServer(context: IActionContext, node?: Postg }); } - await deleteDatabaseAccount(context, node, true); + await deleteDatabaseAccount(context, node); } diff --git a/src/postgres/commands/registerPostgresCommands.ts b/src/postgres/commands/registerPostgresCommands.ts index 0405adc4d..6af472f6d 100644 --- a/src/postgres/commands/registerPostgresCommands.ts +++ b/src/postgres/commands/registerPostgresCommands.ts @@ -6,6 +6,7 @@ import { registerCommandWithTreeNodeUnwrapping } from '@microsoft/vscode-azext-utils'; import { defaults } from 'pg'; import { languages } from 'vscode'; +import { detachDatabaseAccountV1 } from '../../commands/detachDatabaseAccount/detachDatabaseAccount'; import { doubleClickDebounceDelay, postgresDefaultDatabase, postgresLanguageId } from '../../constants'; import { ext } from '../../extensionVariables'; import { PostgresCodeLensProvider } from '../services/PostgresCodeLensProvider'; @@ -42,6 +43,7 @@ export function registerPostgresCommands(): void { // #region Server command + registerCommandWithTreeNodeUnwrapping('postgreSQL.detachServer', detachDatabaseAccountV1); registerCommandWithTreeNodeUnwrapping('postgreSQL.deleteServer', deletePostgresServer); registerCommandWithTreeNodeUnwrapping('postgreSQL.enterCredentials', enterPostgresCredentials); registerCommandWithTreeNodeUnwrapping('postgreSQL.configureFirewall', configurePostgresFirewall); diff --git a/src/postgres/postgresConnectionStrings.ts b/src/postgres/postgresConnectionStrings.ts index 9d7889d5d..705df3881 100644 --- a/src/postgres/postgresConnectionStrings.ts +++ b/src/postgres/postgresConnectionStrings.ts @@ -44,7 +44,7 @@ export function createPostgresConnectionString( return connectionString; } -export function copyPostgresConnectionString( +export function buildPostgresConnectionString( hostName: string, port: string = postgresDefaultPort, username?: string, diff --git a/src/resolver/AppResolver.ts b/src/resolver/AppResolver.ts index 21acce620..ef5bbf8f7 100644 --- a/src/resolver/AppResolver.ts +++ b/src/resolver/AppResolver.ts @@ -9,22 +9,15 @@ import { nonNullProp, nonNullValue, type AzExtParentTreeItem, - type AzExtTreeItem, type IActionContext, type ISubscriptionContext, } from '@microsoft/vscode-azext-utils'; import { type AppResource, type AppResourceResolver } from '@microsoft/vscode-azext-utils/hostapi'; -import { API, tryGetExperience } from '../AzureDBExperiences'; -import { type DocDBAccountTreeItem } from '../docdb/tree/DocDBAccountTreeItem'; import { ext } from '../extensionVariables'; -import { type MongoAccountTreeItem } from '../mongo/tree/MongoAccountTreeItem'; -import { type PostgresAbstractServer } from '../postgres/abstract/models'; -import { type PostgresServerTreeItem } from '../postgres/tree/PostgresServerTreeItem'; -import { SubscriptionTreeItem } from '../tree/SubscriptionTreeItem'; -import { createCosmosDBClient, createPostgreSQLClient, createPostgreSQLFlexibleClient } from '../utils/azureClients'; +import { createPostgresConnectionString, parsePostgresConnectionString } from '../postgres/postgresConnectionStrings'; +import { PostgresServerTreeItem } from '../postgres/tree/PostgresServerTreeItem'; +import { createPostgreSQLClient, createPostgreSQLFlexibleClient } from '../utils/azureClients'; import { type ResolvedDatabaseAccountResource } from './ResolvedDatabaseAccountResource'; -import { ResolvedDocDBAccountResource } from './ResolvedDocDBAccountResource'; -import { ResolvedMongoAccountResource } from './ResolvedMongoAccountResource'; import { ResolvedPostgresServerResource } from './ResolvedPostgresServerResource'; const resourceTypes = [ @@ -48,24 +41,8 @@ export class DatabaseResolver implements AppResourceResolver { const name = nonNullProp(resource, 'name'); context.valuesToMask.push(resource.id); context.valuesToMask.push(resource.name); - let postgresServer: PostgresAbstractServer; - let dbChild: AzExtTreeItem; switch (resource.type.toLowerCase()) { - case resourceTypes[0]: { - const client = await createCosmosDBClient({ ...context, ...subContext }); - const databaseAccount = await client.databaseAccounts.get(resourceGroupName, name); - dbChild = await SubscriptionTreeItem.initCosmosDBChild( - client, - databaseAccount, - nonNullValue(subNode), - ); - const experience = tryGetExperience(databaseAccount); - - return experience?.api === API.MongoDB - ? new ResolvedMongoAccountResource(dbChild as MongoAccountTreeItem, resource) - : new ResolvedDocDBAccountResource(dbChild as DocDBAccountTreeItem, resource); - } case resourceTypes[1]: case resourceTypes[2]: { const postgresClient = @@ -73,10 +50,16 @@ export class DatabaseResolver implements AppResourceResolver { ? await createPostgreSQLClient({ ...context, ...subContext }) : await createPostgreSQLFlexibleClient({ ...context, ...subContext }); - postgresServer = await postgresClient.servers.get(resourceGroupName, name); - dbChild = await SubscriptionTreeItem.initPostgresChild(postgresServer, nonNullValue(subNode)); + const postgresServer = await postgresClient.servers.get(resourceGroupName, name); + const fullyQualifiedDomainName = nonNullProp(postgresServer, 'fullyQualifiedDomainName'); + const connectionString = createPostgresConnectionString(fullyQualifiedDomainName); + const parsedCS = parsePostgresConnectionString(connectionString); + const parent = nonNullValue(subNode); - return new ResolvedPostgresServerResource(dbChild as PostgresServerTreeItem, resource); + return new ResolvedPostgresServerResource( + new PostgresServerTreeItem(parent, parsedCS, postgresServer), + resource, + ); } default: return null; diff --git a/src/resolver/DatabaseWorkspaceProvider.ts b/src/resolver/DatabaseWorkspaceProvider.ts index e899733cd..83f0ba4a6 100644 --- a/src/resolver/DatabaseWorkspaceProvider.ts +++ b/src/resolver/DatabaseWorkspaceProvider.ts @@ -10,13 +10,10 @@ import { type IActionContext, } from '@microsoft/vscode-azext-utils'; import { type WorkspaceResourceProvider } from '@microsoft/vscode-azext-utils/hostapi'; -import { Disposable } from 'vscode'; import { ext } from '../extensionVariables'; import { AttachedAccountsTreeItem } from '../tree/AttachedAccountsTreeItem'; export class DatabaseWorkspaceProvider implements WorkspaceResourceProvider { - public disposables: Disposable[] = []; - constructor(parent: AzExtParentTreeItem) { ext.attachedAccountsNode = new AttachedAccountsTreeItem(parent); } @@ -24,14 +21,7 @@ export class DatabaseWorkspaceProvider implements WorkspaceResourceProvider { public async provideResources(): Promise { return await callWithTelemetryAndErrorHandling( 'AzureAccountTreeItemWithProjects.provideResources', - async (_context: IActionContext) => { - return [ext.attachedAccountsNode]; - }, + (_context: IActionContext) => [ext.attachedAccountsNode], ); } - private _projectDisposables: Disposable[] = []; - - public dispose(): void { - Disposable.from(...this._projectDisposables).dispose(); - } } diff --git a/src/resolver/ResolvedDatabaseAccountResource.ts b/src/resolver/ResolvedDatabaseAccountResource.ts index 72aa22b79..bd4e354d0 100644 --- a/src/resolver/ResolvedDatabaseAccountResource.ts +++ b/src/resolver/ResolvedDatabaseAccountResource.ts @@ -10,9 +10,7 @@ import { type TreeItemIconPath, } from '@microsoft/vscode-azext-utils'; import { type AppResource, type ResolvedAppResourceBase } from '@microsoft/vscode-azext-utils/hostapi'; -import { type DocDBAccountTreeItemBase } from '../docdb/tree/DocDBAccountTreeItemBase'; -import { type MongoAccountTreeItem } from '../mongo/tree/MongoAccountTreeItem'; -import { PostgresServerTreeItem } from '../postgres/tree/PostgresServerTreeItem'; +import { type PostgresServerTreeItem } from '../postgres/tree/PostgresServerTreeItem'; export class ResolvedDatabaseAccountResource implements ResolvedAppResourceBase { public id: string; @@ -39,15 +37,12 @@ export class ResolvedDatabaseAccountResource implements ResolvedAppResourceBase isAncestorOfImpl?(contextValue: string): boolean; connectionString: string; - maskedValuestoAdd: string[] = []; + maskedValuesToAdd: string[] = []; - public constructor( - ti: DocDBAccountTreeItemBase | MongoAccountTreeItem | PostgresServerTreeItem, - resource: AppResource, - ) { + public constructor(ti: PostgresServerTreeItem, resource: AppResource) { this.id = ti.id ?? resource.id; // PostgresServerTreeItem require on a property on the server so wait to do this - this.description = ti instanceof PostgresServerTreeItem ? undefined : ti.description; + this.description = undefined; this.iconPath = ti.iconPath; this.label = ti.label; this.childTypeLabel = ti.childTypeLabel; @@ -63,6 +58,6 @@ export class ResolvedDatabaseAccountResource implements ResolvedAppResourceBase this.isAncestorOfImpl = ti.isAncestorOfImpl; this.contextValuesToAdd.push(ti.contextValue); - this.maskedValuestoAdd.push(...ti.valuesToMask); + this.maskedValuesToAdd.push(...ti.valuesToMask); } } diff --git a/src/resolver/ResolvedDocDBAccountResource.ts b/src/resolver/ResolvedDocDBAccountResource.ts deleted file mode 100644 index a3b719624..000000000 --- a/src/resolver/ResolvedDocDBAccountResource.ts +++ /dev/null @@ -1,36 +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 { - type CosmosClient, - type DatabaseDefinition, - type FeedOptions, - type QueryIterator, - type Resource, -} from '@azure/cosmos'; -import { type AzExtTreeItem } from '@microsoft/vscode-azext-utils'; -import { type AppResource, type ResolvedAppResourceBase } from '@microsoft/vscode-azext-utils/hostapi'; -import { type DocDBAccountTreeItemBase } from '../docdb/tree/DocDBAccountTreeItemBase'; -import { type IDocDBTreeRoot } from '../docdb/tree/IDocDBTreeRoot'; -import { ResolvedDatabaseAccountResource } from './ResolvedDatabaseAccountResource'; - -export class ResolvedDocDBAccountResource extends ResolvedDatabaseAccountResource implements ResolvedAppResourceBase { - public root: IDocDBTreeRoot; - - initChild: (resource: Resource) => AzExtTreeItem; - isServerless?: boolean; - getIterator?: (client: CosmosClient, feedOptions: FeedOptions) => QueryIterator; - - public constructor(ti: DocDBAccountTreeItemBase, resource: AppResource) { - super(ti, resource); - - this.connectionString = ti.connectionString; - this.root = ti.root; - - this.isServerless = ti.isServerless; - this.getIterator = ti.getIterator; - this.initChild = ti.initChild; - } -} diff --git a/src/resolver/ResolvedMongoAccountResource.ts b/src/resolver/ResolvedMongoAccountResource.ts deleted file mode 100644 index d0e56a0c9..000000000 --- a/src/resolver/ResolvedMongoAccountResource.ts +++ /dev/null @@ -1,20 +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 { type AppResource, type ResolvedAppResourceBase } from '@microsoft/vscode-azext-utils/hostapi'; -import { type IMongoTreeRoot } from '../mongo/tree/IMongoTreeRoot'; -import { type MongoAccountTreeItem } from '../mongo/tree/MongoAccountTreeItem'; -import { ResolvedDatabaseAccountResource } from './ResolvedDatabaseAccountResource'; - -export class ResolvedMongoAccountResource extends ResolvedDatabaseAccountResource implements ResolvedAppResourceBase { - root: IMongoTreeRoot; - - public constructor(ti: MongoAccountTreeItem, resource: AppResource) { - super(ti, resource); - - this.connectionString = ti.connectionString; - this.root = ti.root; - } -} diff --git a/src/services/SettingsService.ts b/src/services/SettingsService.ts new file mode 100644 index 000000000..39fc2044b --- /dev/null +++ b/src/services/SettingsService.ts @@ -0,0 +1,145 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import path from 'path'; +import { ConfigurationTarget, Uri, workspace, type WorkspaceConfiguration, type WorkspaceFolder } from 'vscode'; + +export const vscodeFolder: string = '.vscode'; +export const settingsFile: string = 'settings.json'; + +export class SettingUtils { + /** + * Directly updates one of the user's `Global` configuration settings. + * @param key The key of the setting to update + * @param value The value of the setting to update + * @param prefix The optional extension prefix. + */ + async updateGlobalSetting(key: string, value: T, prefix?: string): Promise { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix); + await projectConfiguration.update(key, value, ConfigurationTarget.Global); + } + + /** + * Directly retrieves one of the user's `Global` or `Default` configuration settings. + * @param key The key of the setting to retrieve + * @param prefix The optional extension prefix. + */ + getGlobalSetting(key: string, prefix?: string): T | undefined { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix); + const result: { globalValue?: T; defaultValue?: T } | undefined = projectConfiguration.inspect(key); + return result?.globalValue === undefined ? result?.defaultValue : result?.globalValue; + } + + /** + * Directly updates one of the user's `Workspace` or `WorkspaceFolder` settings. + * @param key The key of the setting to update + * @param value The value of the setting to update + * @param fsPath The path of the workspace configuration settings + * @param targetSetting The optional workspace setting to target. Uses the `Workspace` configuration target unless otherwise specified + * @param prefix The optional extension prefix. + */ + async updateWorkspaceSetting( + key: string, + value: T, + prefix?: string, + fsPath?: string, + targetSetting: + | ConfigurationTarget.Workspace + | ConfigurationTarget.WorkspaceFolder = ConfigurationTarget.Workspace, + ): Promise { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration( + prefix, + fsPath ? Uri.file(fsPath) : undefined, + ); + await projectConfiguration.update(key, value, targetSetting); + } + + /** + * Iteratively retrieves one of the user's workspace settings - sequentially checking for a defined value starting from the `WorkspaceFolder` up to the provided target configuration limit. + * @param key The key of the setting to retrieve + * @param fsPath The optional path of the workspace configuration settings + * @param targetLimit The optional target configuration limit (inclusive). Uses the `Workspace` configuration target unless otherwise specified + * @param prefix The optional extension prefix + */ + getWorkspaceSetting( + key: string, + prefix?: string, + fsPath?: string, + targetLimit: + | ConfigurationTarget.Workspace + | ConfigurationTarget.WorkspaceFolder = ConfigurationTarget.Workspace, + ): T | undefined { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration( + prefix, + fsPath ? Uri.file(fsPath) : undefined, + ); + + const configurationLevel = this.getLowestConfigurationLevel(projectConfiguration, key); + if (!configurationLevel || configurationLevel < targetLimit) { + return undefined; + } + + return projectConfiguration.get(key); + } + + /** + * Iteratively retrieves one of the user's settings - sequentially checking for a defined value starting from the `WorkspaceFolder` up to the `Global` configuration target. + * @param key The key of the setting to retrieve + * @param fsPath The optional path of the workspace configuration settings + * @param prefix The optional extension prefix. + */ + getSetting(key: string, prefix?: string, fsPath?: string): T | undefined { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration( + prefix, + fsPath ? Uri.file(fsPath) : undefined, + ); + return projectConfiguration.get(key); + } + + /** + * Searches through all open folders and gets the current workspace setting (as long as there are no conflicts) + */ + getWorkspaceSettingFromAnyFolder(key: string, prefix?: string): string | undefined { + if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { + let result: string | undefined; + for (const folder of workspace.workspaceFolders) { + const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix, folder.uri); + const folderResult: string | undefined = projectConfiguration.get(key); + if (!result) { + result = folderResult; + } else if (folderResult && result !== folderResult) { + return undefined; + } + } + return result; + } else { + return this.getGlobalSetting(key, prefix); + } + } + + getDefaultRootWorkspaceSettingsPath(rootWorkspaceFolder: WorkspaceFolder): string { + return path.join(rootWorkspaceFolder.uri.fsPath, vscodeFolder, settingsFile); + } + + getLowestConfigurationLevel( + projectConfiguration: WorkspaceConfiguration, + key: string, + ): ConfigurationTarget | undefined { + const configuration = projectConfiguration.inspect(key); + + let lowestLevelConfiguration: ConfigurationTarget | undefined; + if (configuration?.workspaceFolderValue !== undefined) { + lowestLevelConfiguration = ConfigurationTarget.WorkspaceFolder; + } else if (configuration?.workspaceValue !== undefined) { + lowestLevelConfiguration = ConfigurationTarget.Workspace; + } else if (configuration?.globalValue !== undefined) { + lowestLevelConfiguration = ConfigurationTarget.Global; + } + + return lowestLevelConfiguration; + } +} + +export const SettingsService = new SettingUtils(); diff --git a/src/table/tree/TableAccountTreeItem.ts b/src/table/tree/TableAccountTreeItem.ts deleted file mode 100644 index d387ffe41..000000000 --- a/src/table/tree/TableAccountTreeItem.ts +++ /dev/null @@ -1,55 +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 { - callWithTelemetryAndErrorHandling, - GenericTreeItem, - type AzExtTreeItem, - type IActionContext, -} from '@microsoft/vscode-azext-utils'; -import { API } from '../../AzureDBExperiences'; -import { deleteCosmosDBAccount } from '../../commands/deleteDatabaseAccount/deleteCosmosDBAccount'; -import { type IDeleteWizardContext } from '../../commands/deleteDatabaseAccount/IDeleteWizardContext'; -import { DocDBAccountTreeItemBase } from '../../docdb/tree/DocDBAccountTreeItemBase'; - -export class TableAccountTreeItem extends DocDBAccountTreeItemBase { - public static contextValue: string = 'cosmosDBTableAccount'; - public contextValue: string = TableAccountTreeItem.contextValue; - - public hasMoreChildrenImpl(): boolean { - return false; - } - - public initChild(): AzExtTreeItem { - throw new Error('Table Accounts are not supported yet.'); - } - - public async loadMoreChildrenImpl(_clearCache: boolean): Promise { - const result = await callWithTelemetryAndErrorHandling( - 'getChildren', - (context: IActionContext): AzExtTreeItem[] => { - context.telemetry.properties.experience = API.Table; - context.telemetry.properties.parentNodeContext = this.contextValue; - - const tableNotFoundTreeItem: AzExtTreeItem = new GenericTreeItem(this, { - contextValue: 'tableNotSupported', - label: 'Table Accounts are not supported yet.', - }); - tableNotFoundTreeItem.suppressMaskLabel = true; - return [tableNotFoundTreeItem]; - }, - ); - - return result ?? []; - } - - public async deleteTreeItemImpl(context: IDeleteWizardContext): Promise { - await deleteCosmosDBAccount(context, this); - } - - public isAncestorOfImpl(): boolean { - return false; - } -} diff --git a/src/tree/AttachedAccountsTreeItem.test.ts b/src/tree/AttachedAccountsTreeItem.test.ts deleted file mode 100644 index 3be223003..000000000 --- a/src/tree/AttachedAccountsTreeItem.test.ts +++ /dev/null @@ -1,37 +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 { AttachedAccountsTreeItem, MONGO_CONNECTION_EXPECTED } from './AttachedAccountsTreeItem'; - -describe(`attachedAccountsTreeItem`, () => { - describe(`validateDocDBConnectionString`, () => { - // Connection strings follow the following format (https://docs.mongodb.com/manual/reference/connection-string/): - // mongodb[+srv]://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]] - - it('allows "mongodb://"', () => { - const actual = AttachedAccountsTreeItem.validateMongoConnectionString( - `mongodb://your-mongo.documents.azure.com:10255`, - ); - expect(actual).toEqual(undefined); - }); - - it('allows "mongodb+srv://"', () => { - const actual = AttachedAccountsTreeItem.validateMongoConnectionString( - `mongodb+srv://usr:pwd@mongodb.net:27017`, - ); - expect(actual).toEqual(undefined); - }); - - it('rejects bad prefix', () => { - const actual = AttachedAccountsTreeItem.validateMongoConnectionString(`http://localhost/`); - expect(actual).toEqual(MONGO_CONNECTION_EXPECTED); - }); - - it('rejects null', () => { - const actual = AttachedAccountsTreeItem.validateMongoConnectionString(null!); - expect(actual).toEqual(MONGO_CONNECTION_EXPECTED); - }); - }); -}); diff --git a/src/tree/AttachedAccountsTreeItem.ts b/src/tree/AttachedAccountsTreeItem.ts index e815f397b..a5a86a6d1 100644 --- a/src/tree/AttachedAccountsTreeItem.ts +++ b/src/tree/AttachedAccountsTreeItem.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { - appendExtensionUserAgent, AzExtParentTreeItem, GenericTreeItem, type AzExtTreeItem, @@ -12,48 +11,42 @@ import { type ISubscriptionContext, type TreeItemIconPath, } from '@microsoft/vscode-azext-utils'; -import { type MongoClient } from 'mongodb'; import * as vscode from 'vscode'; -import { API, getExperienceFromApi, getExperienceQuickPick, getExperienceQuickPicks } from '../AzureDBExperiences'; +import { API, getExperienceFromApi } from '../AzureDBExperiences'; import { removeTreeItemFromCache } from '../commands/api/apiCache'; -import { emulatorPassword, isWindows } from '../constants'; -import { parseDocDBConnectionString } from '../docdb/docDBConnectionStrings'; -import { type CosmosDBCredential } from '../docdb/getCosmosClient'; -import { DocDBAccountTreeItem } from '../docdb/tree/DocDBAccountTreeItem'; -import { DocDBAccountTreeItemBase } from '../docdb/tree/DocDBAccountTreeItemBase'; +import { isEmulatorSupported } from '../constants'; import { ext } from '../extensionVariables'; -import { GraphAccountTreeItem } from '../graph/tree/GraphAccountTreeItem'; -import { connectToMongoClient } from '../mongo/connectToMongoClient'; -import { parseMongoConnectionString } from '../mongo/mongoConnectionStrings'; -import { MongoAccountTreeItem } from '../mongo/tree/MongoAccountTreeItem'; import { parsePostgresConnectionString } from '../postgres/postgresConnectionStrings'; import { PostgresServerTreeItem } from '../postgres/tree/PostgresServerTreeItem'; -import { TableAccountTreeItem } from '../table/tree/TableAccountTreeItem'; import { getSecretStorageKey } from '../utils/getSecretStorageKey'; -import { localize } from '../utils/localize'; import { nonNullProp, nonNullValue } from '../utils/nonNull'; -import { SubscriptionTreeItem } from './SubscriptionTreeItem'; -interface IPersistedAccount { +export interface PersistedAccount { id: string; // defaultExperience is not the same as API but we can't change the name due to backwards compatibility defaultExperience: API; isEmulator: boolean | undefined; } -export const AttachedAccountSuffix: string = 'Attached'; -export const MONGO_CONNECTION_EXPECTED: string = 'Connection string must start with "mongodb://" or "mongodb+srv://"'; +export interface PersistedAccountWithConnectionString { + api: API; + connectionString: string; + id: string; + isEmulator: boolean | undefined; + label: string; +} -const localMongoConnectionString: string = 'mongodb://127.0.0.1:27017'; +export const AttachedAccountSuffix: string = 'Attached'; export class AttachedAccountsTreeItem extends AzExtParentTreeItem { - public static contextValue: string = 'cosmosDBAttachedAccounts' + (isWindows ? 'WithEmulator' : 'WithoutEmulator'); + public static contextValue: string = + 'cosmosDBAttachedAccounts' + (isEmulatorSupported ? 'WithEmulator' : 'WithoutEmulator'); + public static readonly serviceName: string = 'ms-azuretools.vscode-cosmosdb.connectionStrings'; public readonly contextValue: string = AttachedAccountsTreeItem.contextValue; - public readonly label: string = 'Attached Database Accounts'; + public readonly label: string = 'Attached Database Accounts (Postgres)'; public childTypeLabel: string = 'Account'; public suppressMaskLabel = true; - private readonly _serviceName: string = 'ms-azuretools.vscode-cosmosdb.connectionStrings'; private _attachedAccounts: AzExtTreeItem[] | undefined; private _root: ISubscriptionContext; @@ -74,35 +67,53 @@ export class AttachedAccountsTreeItem extends AzExtParentTreeItem { return new vscode.ThemeIcon('plug'); } - public static validateMongoConnectionString(value: string): string | undefined { - value = value ? value.trim() : ''; - - if (value && value.match(/^mongodb(\+srv)?:\/\//)) { - return undefined; - } - - return MONGO_CONNECTION_EXPECTED; - } - - public static validatePostgresConnectionString(value: string): string | undefined { - value = value ? value.trim() : ''; - - if (value && value.match(/^postgres:\/\//)) { - return undefined; + public static async getPersistedAccounts(): Promise { + const persistedAccounts: PersistedAccountWithConnectionString[] = []; + const value: string | undefined = ext.context.globalState.get(AttachedAccountsTreeItem.serviceName); + if (value) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const accounts: (string | PersistedAccount)[] = JSON.parse(value); + await Promise.all( + accounts.map(async (account) => { + let id: string; + let label: string; + let api: API; + let isEmulator: boolean | undefined; + if (typeof account === 'string') { + // Default to Mongo if the value is a string for the sake of backwards compatibility + // (Mongo was originally the only account type that could be attached) + id = account; + api = API.MongoDB; + label = `${account} (${getExperienceFromApi(api).shortName})`; + isEmulator = false; + } else { + id = (account).id; + api = (account).defaultExperience; + isEmulator = (account).isEmulator; + label = isEmulator + ? `${getExperienceFromApi(api).shortName} Emulator` + : `${id} (${getExperienceFromApi(api).shortName})`; + } + // TODO: keytar: migration plan? + const connectionString: string = nonNullValue( + await ext.secretStorage.get(getSecretStorageKey(AttachedAccountsTreeItem.serviceName, id)), + 'connectionString', + ); + // TODO: Left only Postgres, other types are moved to new tree api v2 + if (api === API.PostgresSingle || api === API.PostgresFlexible) { + persistedAccounts.push({ + api: api, + id: id, + label: label, + connectionString: connectionString, + isEmulator: isEmulator, + }); + } + }), + ); } - return localize('invalidPostgresConnectionString', 'Connection string must start with "postgres://"'); - } - - private static validateDocDBConnectionString(value: string): string | undefined { - value = value ? value.trim() : ''; - - try { - parseDocDBConnectionString(value); - return undefined; - } catch { - return 'Connection string must be of the form "AccountEndpoint=...;AccountKey=..."'; - } + return persistedAccounts; } public hasMoreChildrenImpl(): boolean { @@ -123,7 +134,7 @@ export class AttachedAccountsTreeItem extends AzExtParentTreeItem { void vscode.window.showErrorMessage(errorMessage); } - return [...attachedAccounts, ...this.getAttachAccountActionItems()]; + return [...this.getAttachAccountActionItems(), ...attachedAccounts]; } private getAttachAccountActionItems(): AzExtTreeItem[] { @@ -134,139 +145,43 @@ export class AttachedAccountsTreeItem extends AzExtParentTreeItem { commandId: 'cosmosDB.attachDatabaseAccount', includeInTreeItemPicker: true, }); - const attachEmulator = new GenericTreeItem(this, { - contextValue: 'cosmosDBAttachEmulator', - label: 'Attach Emulator...', - iconPath: new vscode.ThemeIcon('plus'), - commandId: 'cosmosDB.attachEmulator', - includeInTreeItemPicker: true, - }); - return isWindows ? [attachDatabaseAccount, attachEmulator] : [attachDatabaseAccount]; + return [attachDatabaseAccount]; } public isAncestorOfImpl(contextValue: string): boolean { switch (contextValue) { // We have to make sure the Attached Accounts node is not shown for commands like // 'Open in Portal', which only work for the non-attached version - case GraphAccountTreeItem.contextValue: - case MongoAccountTreeItem.contextValue: - case DocDBAccountTreeItem.contextValue: - case TableAccountTreeItem.contextValue: case PostgresServerTreeItem.contextValue: - case SubscriptionTreeItem.contextValue: return false; default: return true; } } - public async attachNewAccount(context: IActionContext): Promise { - const defaultExperiencePick = await context.ui.showQuickPick(getExperienceQuickPicks(true), { - placeHolder: 'Select a Database type...', - stepName: 'attachNewAccount', - }); - const defaultExperience = defaultExperiencePick.data; - let placeholder: string; - let defaultValue: string | undefined; - let validateInput: (value: string) => string | undefined | null; - if (defaultExperience.api === API.MongoDB) { - placeholder = 'mongodb://host:port'; - if (await this.canConnectToLocalMongoDB()) { - defaultValue = placeholder = localMongoConnectionString; - } - validateInput = AttachedAccountsTreeItem.validateMongoConnectionString; - } else if (defaultExperience.api === API.PostgresSingle || defaultExperience.api === API.PostgresFlexible) { - placeholder = localize( - 'attachedPostgresPlaceholder', - '"postgres://username:password@host" or "postgres://username:password@host/database"', - ); - validateInput = AttachedAccountsTreeItem.validatePostgresConnectionString; - } else { - placeholder = 'AccountEndpoint=...;AccountKey=...'; - validateInput = AttachedAccountsTreeItem.validateDocDBConnectionString; - } - - const connectionString = ( - await context.ui.showInputBox({ - placeHolder: placeholder, - prompt: 'Enter the connection string for your database account', - stepName: 'attachNewAccountConnectionString', - validateInput: validateInput, - value: defaultValue, - }) - ).trim(); - - const treeItem: AzExtTreeItem = await this.createTreeItem(connectionString, defaultExperience.api); - await this.attachAccount(context, treeItem, connectionString); - } - public async attachConnectionString( context: IActionContext, connectionString: string, - api: API.MongoDB | API.Core | API.PostgresSingle, - ): Promise { - const treeItem = ( - await this.createTreeItem(connectionString, api) - ); + api: API.PostgresSingle | API.PostgresFlexible, + ): Promise { + const treeItem = await this.createTreeItem(connectionString, api); await this.attachAccount(context, treeItem, connectionString); await this.refresh(context); return treeItem; } - public async attachEmulator(context: IActionContext): Promise { - let connectionString: string; - const defaultExperiencePick = await context.ui.showQuickPick( - [getExperienceQuickPick(API.MongoDB), getExperienceQuickPick(API.Core)], - { - placeHolder: 'Select a Database Account API...', - stepName: 'attachEmulator', - }, - ); - const defaultExperience = defaultExperiencePick.data; - let port: number | undefined; - if (defaultExperience.api === API.MongoDB) { - port = vscode.workspace.getConfiguration().get('cosmosDB.emulator.mongoPort'); - } else { - port = vscode.workspace.getConfiguration().get('cosmosDB.emulator.port'); - } - if (port) { - if (defaultExperience.api === API.MongoDB) { - // Mongo shell doesn't parse passwords with slashes, so we need to URI encode it. The '/' before the options is required by mongo conventions - connectionString = `mongodb://localhost:${encodeURIComponent(emulatorPassword)}@localhost:${port}/?ssl=true`; - } else { - connectionString = `AccountEndpoint=https://localhost:${port}/;AccountKey=${emulatorPassword};`; - } - const label = `${defaultExperience.shortName} Emulator`; - const treeItem: AzExtTreeItem = await this.createTreeItem(connectionString, defaultExperience.api, label); - if ( - treeItem instanceof DocDBAccountTreeItem || - treeItem instanceof GraphAccountTreeItem || - treeItem instanceof TableAccountTreeItem || - treeItem instanceof MongoAccountTreeItem - ) { - // CONSIDER: Why isn't this passed in to createTreeItem above? - treeItem.root.isEmulator = true; - } - await this.attachAccount(context, treeItem, connectionString); - } - } - public async detach(node: AzExtTreeItem): Promise { const attachedAccounts: AzExtTreeItem[] = await this.getAttachedAccounts(); const index = attachedAccounts.findIndex((account) => account.fullId === node.fullId); if (index !== -1) { attachedAccounts.splice(index, 1); - await ext.secretStorage.delete(getSecretStorageKey(this._serviceName, nonNullProp(node, 'id'))); // intentionally using 'id' instead of 'fullId' for the sake of backwards compatibility + await ext.secretStorage.delete( + getSecretStorageKey(AttachedAccountsTreeItem.serviceName, nonNullProp(node, 'id')), + ); // intentionally using 'id' instead of 'fullId' for the sake of backwards compatibility await this.persistIds(attachedAccounts); - if (node instanceof MongoAccountTreeItem) { - const parsedCS = await parseMongoConnectionString(node.connectionString); - removeTreeItemFromCache(parsedCS); - } else if (node instanceof DocDBAccountTreeItemBase) { - const parsedCS = parseDocDBConnectionString(node.connectionString); - removeTreeItemFromCache(parsedCS); - } else if (node instanceof PostgresServerTreeItem) { + if (node instanceof PostgresServerTreeItem) { const parsedCS = node.partialConnectionString; removeTreeItemFromCache(parsedCS); } @@ -286,26 +201,6 @@ export class AttachedAccountsTreeItem extends AzExtParentTreeItem { return this._attachedAccounts; } - private async canConnectToLocalMongoDB(): Promise { - async function timeout(): Promise { - await delay(1000); - return false; - } - async function connect(): Promise { - try { - const db: MongoClient = await connectToMongoClient( - localMongoConnectionString, - appendExtensionUserAgent(), - ); - void db.close(); - return true; - } catch { - return false; - } - } - return await Promise.race([timeout(), connect()]); - } - private async attachAccount( context: IActionContext, treeItem: AzExtTreeItem, @@ -320,7 +215,7 @@ export class AttachedAccountsTreeItem extends AzExtParentTreeItem { } else { attachedAccounts.push(treeItem); await ext.secretStorage.store( - getSecretStorageKey(this._serviceName, nonNullProp(treeItem, 'id')), + getSecretStorageKey(AttachedAccountsTreeItem.serviceName, nonNullProp(treeItem, 'id')), connectionString, ); await this.persistIds(attachedAccounts); @@ -328,105 +223,29 @@ export class AttachedAccountsTreeItem extends AzExtParentTreeItem { } private async loadPersistedAccounts(): Promise { - const persistedAccounts: AzExtTreeItem[] = []; - const value: string | undefined = ext.context.globalState.get(this._serviceName); - if (value) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const accounts: (string | IPersistedAccount)[] = JSON.parse(value); - await Promise.all( - accounts.map(async (account) => { - let id: string; - let label: string; - let api: API; - let isEmulator: boolean | undefined; - if (typeof account === 'string') { - // Default to Mongo if the value is a string for the sake of backwards compatibility - // (Mongo was originally the only account type that could be attached) - id = account; - api = API.MongoDB; - label = `${account} (${getExperienceFromApi(api).shortName})`; - isEmulator = false; - } else { - id = (account).id; - api = (account).defaultExperience; - isEmulator = (account).isEmulator; - label = isEmulator - ? `${getExperienceFromApi(api).shortName} Emulator` - : `${id} (${getExperienceFromApi(api).shortName})`; - } - // TODO: keytar: migration plan? - const connectionString: string = nonNullValue( - await ext.secretStorage.get(getSecretStorageKey(this._serviceName, id)), - 'connectionString', - ); - persistedAccounts.push(await this.createTreeItem(connectionString, api, label, id, isEmulator)); - }), - ); - } + const persistedAccounts = await AttachedAccountsTreeItem.getPersistedAccounts(); - return persistedAccounts; + return Promise.all( + persistedAccounts.map((account) => { + const { connectionString, api, id, label, isEmulator } = account; + return this.createTreeItem(connectionString, api, label, id, isEmulator); + }), + ); } private async createTreeItem( connectionString: string, api: API, - label?: string, - id?: string, - isEmulator?: boolean, + _label?: string, + _id?: string, + _isEmulator?: boolean, ): Promise { let treeItem: AzExtTreeItem; - if (api === API.MongoDB) { - if (id === undefined) { - const parsedCS = await parseMongoConnectionString(connectionString); - id = parsedCS.fullId; - } - - label = label || `${id} (${getExperienceFromApi(api).shortName})`; - treeItem = new MongoAccountTreeItem(this, id, label, connectionString, isEmulator); - } else if (api === API.PostgresSingle || api === API.PostgresFlexible) { + if (api === API.PostgresSingle || api === API.PostgresFlexible) { const parsedPostgresConnString = parsePostgresConnectionString(connectionString); treeItem = new PostgresServerTreeItem(this, parsedPostgresConnString); } else { - const parsedCS = parseDocDBConnectionString(connectionString); - - label = label || `${parsedCS.accountId} (${getExperienceFromApi(api).shortName})`; - - const credentials: CosmosDBCredential[] = [{ type: 'key', key: parsedCS.masterKey }]; - switch (api) { - case API.Table: - treeItem = new TableAccountTreeItem( - this, - parsedCS.accountId, - label, - parsedCS.documentEndpoint, - credentials, - isEmulator, - ); - break; - case API.Graph: - treeItem = new GraphAccountTreeItem( - this, - parsedCS.accountId, - label, - parsedCS.documentEndpoint, - undefined, - credentials, - isEmulator, - ); - break; - case API.Core: - treeItem = new DocDBAccountTreeItem( - this, - parsedCS.accountId, - label, - parsedCS.documentEndpoint, - credentials, - isEmulator, - ); - break; - default: - throw new Error(`Unexpected defaultExperience "${api}".`); - } + throw new Error(`Unexpected defaultExperience "${api}".`); } treeItem.contextValue += AttachedAccountSuffix; @@ -434,33 +253,16 @@ export class AttachedAccountsTreeItem extends AzExtParentTreeItem { } private async persistIds(attachedAccounts: AzExtTreeItem[]): Promise { - const value: IPersistedAccount[] = attachedAccounts.map((node: AzExtTreeItem) => { + const value: PersistedAccount[] = attachedAccounts.map((node: AzExtTreeItem) => { let api: API; - let isEmulator: boolean | undefined; - if ( - node instanceof MongoAccountTreeItem || - node instanceof DocDBAccountTreeItem || - node instanceof GraphAccountTreeItem || - node instanceof TableAccountTreeItem - ) { - isEmulator = node.root.isEmulator; - } - if (node instanceof MongoAccountTreeItem) { - api = API.MongoDB; - } else if (node instanceof GraphAccountTreeItem) { - api = API.Graph; - } else if (node instanceof TableAccountTreeItem) { - api = API.Table; - } else if (node instanceof DocDBAccountTreeItem) { - api = API.Core; - } else if (node instanceof PostgresServerTreeItem) { + if (node instanceof PostgresServerTreeItem) { api = API.PostgresSingle; } else { throw new Error(`Unexpected account node "${node.constructor.name}".`); } - return { id: nonNullProp(node, 'id'), defaultExperience: api, isEmulator: isEmulator }; + return { id: nonNullProp(node, 'id'), defaultExperience: api, isEmulator: false }; }); - await ext.context.globalState.update(this._serviceName, JSON.stringify(value)); + await ext.context.globalState.update(AttachedAccountsTreeItem.serviceName, JSON.stringify(value)); } } @@ -503,9 +305,3 @@ class AttachedAccountRoot implements ISubscriptionContext { throw this._error; } } - -async function delay(milliseconds: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} diff --git a/src/tree/AzureAccountTreeItemWithAttached.ts b/src/tree/AzureAccountTreeItemWithAttached.ts deleted file mode 100644 index 226a1a43b..000000000 --- a/src/tree/AzureAccountTreeItemWithAttached.ts +++ /dev/null @@ -1,36 +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 { AzureAccountTreeItemBase } from '@microsoft/vscode-azext-azureutils'; -import { type AzExtTreeItem, type IActionContext, type ISubscriptionContext } from '@microsoft/vscode-azext-utils'; -import { ext } from '../extensionVariables'; -import { AttachedAccountsTreeItem } from './AttachedAccountsTreeItem'; -import { SubscriptionTreeItem } from './SubscriptionTreeItem'; - -export class AzureAccountTreeItemWithAttached extends AzureAccountTreeItemBase { - public constructor(testAccount?: object) { - super(undefined, testAccount); - ext.attachedAccountsNode = new AttachedAccountsTreeItem(this); - } - - public createSubscriptionTreeItem(root: ISubscriptionContext): SubscriptionTreeItem { - return new SubscriptionTreeItem(this, root); - } - - public async loadMoreChildrenImpl(clearCache: boolean, context: IActionContext): Promise { - const children: AzExtTreeItem[] = await super.loadMoreChildrenImpl(clearCache, context); - return children.concat(ext.attachedAccountsNode); - } - - public compareChildrenImpl(item1: AzExtTreeItem, item2: AzExtTreeItem): number { - if (item1 instanceof AttachedAccountsTreeItem) { - return 1; - } else if (item2 instanceof AttachedAccountsTreeItem) { - return -1; - } else { - return super.compareChildrenImpl(item1, item2); - } - } -} diff --git a/src/tree/AzureDBAPIStep.ts b/src/tree/AzureDBAPIStep.ts deleted file mode 100644 index 3c296a882..000000000 --- a/src/tree/AzureDBAPIStep.ts +++ /dev/null @@ -1,85 +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 { VerifyProvidersStep } from '@microsoft/vscode-azext-azureutils'; -import { - AzureWizardPromptStep, - type AzureWizardExecuteStep, - type IAzureQuickPickItem, - type IWizardOptions, -} from '@microsoft/vscode-azext-utils'; -import { API, getExperienceQuickPicks, type Experience } from '../AzureDBExperiences'; -import { PostgresServerType } from '../postgres/abstract/models'; -import { type IPostgresServerWizardContext } from '../postgres/commands/createPostgresServer/IPostgresServerWizardContext'; -import { PostgresServerConfirmPWStep } from '../postgres/commands/createPostgresServer/steps/PostgresServerConfirmPWStep'; -import { PostgresServerCreateStep } from '../postgres/commands/createPostgresServer/steps/PostgresServerCreateStep'; -import { PostgresServerCredPWStep } from '../postgres/commands/createPostgresServer/steps/PostgresServerCredPWStep'; -import { PostgresServerCredUserStep } from '../postgres/commands/createPostgresServer/steps/PostgresServerCredUserStep'; -import { PostgresServerNameStep } from '../postgres/commands/createPostgresServer/steps/PostgresServerNameStep'; -import { PostgresServerSetCredentialsStep } from '../postgres/commands/createPostgresServer/steps/PostgresServerSetCredentialsStep'; -import { PostgresServerSkuStep } from '../postgres/commands/createPostgresServer/steps/PostgresServerSkuStep'; -import { localize } from '../utils/localize'; -import { CosmosDBAccountCapacityStep } from './CosmosDBAccountWizard/CosmosDBAccountCapacityStep'; -import { CosmosDBAccountCreateStep } from './CosmosDBAccountWizard/CosmosDBAccountCreateStep'; -import { CosmosDBAccountNameStep } from './CosmosDBAccountWizard/CosmosDBAccountNameStep'; -import { type ICosmosDBWizardContext } from './CosmosDBAccountWizard/ICosmosDBWizardContext'; -import { MongoVersionStep } from './CosmosDBAccountWizard/MongoDBVersionStep'; -import { type IAzureDBWizardContext } from './IAzureDBWizardContext'; - -export class AzureDBAPIStep extends AzureWizardPromptStep { - public async prompt(context: IAzureDBWizardContext): Promise { - const picks: IAzureQuickPickItem[] = getExperienceQuickPicks(); - - const result: IAzureQuickPickItem = await context.ui.showQuickPick(picks, { - placeHolder: localize('selectDBServerMsg', 'Select an Azure Database Server'), - }); - - context.defaultExperience = result.data; - } - - public async getSubWizard( - context: IAzureDBWizardContext, - ): Promise> { - let promptSteps: AzureWizardPromptStep[]; - let executeSteps: AzureWizardExecuteStep[]; - if ( - context.defaultExperience?.api === API.PostgresSingle || - context.defaultExperience?.api === API.PostgresFlexible - ) { - switch (context.defaultExperience?.api) { - case API.PostgresFlexible: - (context as IPostgresServerWizardContext).serverType = PostgresServerType.Flexible; - break; - case API.PostgresSingle: - (context as IPostgresServerWizardContext).serverType = PostgresServerType.Single; - break; - } - promptSteps = [ - new PostgresServerNameStep(), - new PostgresServerSkuStep(), - new PostgresServerCredUserStep(), - new PostgresServerCredPWStep(), - new PostgresServerConfirmPWStep(), - ]; - executeSteps = [ - new PostgresServerCreateStep(), - new PostgresServerSetCredentialsStep(), - new VerifyProvidersStep(['Microsoft.DBforPostgreSQL']), - ]; - } else { - promptSteps = [ - new CosmosDBAccountNameStep(), - new CosmosDBAccountCapacityStep(), - context.defaultExperience?.api === API.MongoDB ? new MongoVersionStep() : undefined, - ].filter((step): step is AzureWizardPromptStep => step !== undefined); - executeSteps = [new CosmosDBAccountCreateStep(), new VerifyProvidersStep(['Microsoft.DocumentDB'])]; - } - return { promptSteps, executeSteps }; - } - - public shouldPrompt(context: IAzureDBWizardContext): boolean { - return !context.defaultExperience; - } -} diff --git a/src/mongo/setConnectedNode.ts b/src/tree/CosmosAccountModel.ts similarity index 51% rename from src/mongo/setConnectedNode.ts rename to src/tree/CosmosAccountModel.ts index 2b7bd4944..4f5a0e43e 100644 --- a/src/mongo/setConnectedNode.ts +++ b/src/tree/CosmosAccountModel.ts @@ -3,11 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ext } from '../extensionVariables'; -import { type MongoDatabaseTreeItem } from './tree/MongoDatabaseTreeItem'; +import { type GenericResource } from '@azure/arm-resources'; +import { type AzureResource } from '@microsoft/vscode-azureresources-api'; -export function setConnectedNode(node: MongoDatabaseTreeItem | undefined): void { - ext.connectedMongoDB = node; - const dbName = node && node.label; - ext.mongoCodeLensProvider.setConnectedDatabase(dbName); -} +export type CosmosDBResource = AzureResource & + GenericResource & { + readonly raw: GenericResource; // Resource object from Azure SDK + }; + +export type CosmosAccountModel = CosmosDBResource; diff --git a/src/tree/CosmosDBAccountResourceItemBase.ts b/src/tree/CosmosDBAccountResourceItemBase.ts new file mode 100644 index 000000000..cf4f4a2cb --- /dev/null +++ b/src/tree/CosmosDBAccountResourceItemBase.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import { type ResourceBase } from '@microsoft/vscode-azureresources-api'; +import { v4 as uuid } from 'uuid'; +import * as vscode from 'vscode'; +import { type TreeItem } from 'vscode'; +import { type Experience } from '../AzureDBExperiences'; +import { type CosmosDBTreeElement } from './CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from './TreeElementWithContextValue'; +import { type TreeElementWithExperience } from './TreeElementWithExperience'; + +export abstract class CosmosDBAccountResourceItemBase + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.account'; + + protected constructor( + public readonly account: ResourceBase, + public readonly experience: Experience, + ) { + this.id = account.id ?? uuid(); + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + /** + * Returns the children of the cluster. + * @returns The children of the cluster. + */ + getChildren(): Promise { + return Promise.resolve([]); + } + + /** + * Returns the tree item representation of the cluster. + * @returns The TreeItem object. + */ + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: this.account.name, + description: `(${this.experience.shortName})`, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + abstract getConnectionString(): Promise; +} diff --git a/src/tree/CosmosDBAccountWizard/CosmosDBAccountCapacityStep.ts b/src/tree/CosmosDBAccountWizard/CosmosDBAccountCapacityStep.ts deleted file mode 100644 index b81e82a58..000000000 --- a/src/tree/CosmosDBAccountWizard/CosmosDBAccountCapacityStep.ts +++ /dev/null @@ -1,75 +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 { AzureWizardPromptStep, UserCancelledError, type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; -import { API } from '../../AzureDBExperiences'; -import { localize } from '../../utils/localize'; -import { openUrl } from '../../utils/openUrl'; -import { type ICosmosDBWizardContext } from './ICosmosDBWizardContext'; - -export class CosmosDBAccountCapacityStep extends AzureWizardPromptStep { - public async prompt(context: ICosmosDBWizardContext): Promise { - const learnMoreLink: string = 'https://aka.ms/cosmos-models'; - const placeHolder: string = localize('selectDBServerMsg', 'Select a capacity model'); - const picks: IAzureQuickPickItem[] = [ - { - label: localize('provisionedOption', 'Provisioned Throughput'), - detail: localize( - 'provisionedOptionDescription', - 'Workloads with sustained traffic requiring predictable performance', - ), - data: false, - }, - { - label: localize('serverlessOption', 'Serverless'), - detail: localize( - 'serverlessOptionDescription', - 'Workloads with intermittent or unpredictable traffic and low average-to-peak traffic ratio', - ), - data: true, - }, - ]; - const vcore: IAzureQuickPickItem = { - label: localize('vCoreOption', '$(link-external) vCore cluster'), - detail: localize('vCoreOptionDescription', 'Fully managed MongoDB-compatible database service'), - description: localize('vCoreOptionPortalHint', '(Create in Azure Portal...)'), - data: false, - }; - if (context.defaultExperience?.api === API.MongoDB) { - picks.push(vcore); - } - const learnMore: IAzureQuickPickItem = { - label: localize('learnMore', '$(link-external) Learn more...'), - data: undefined, - }; - picks.push(learnMore); - let pick: IAzureQuickPickItem; - - do { - pick = await context.ui.showQuickPick(picks, { - placeHolder, - suppressPersistence: true, - learnMoreLink: learnMoreLink, - }); - if (pick === learnMore) { - await openUrl(learnMoreLink); - } - } while (pick === learnMore); - - if (pick.data) { - context.isServerless = pick.data; - context.telemetry.properties.isServerless = pick.data ? 'true' : 'false'; - } - if (pick === vcore) { - await openUrl('https://learn.microsoft.com/azure/cosmos-db/mongodb/vcore/quickstart-portal'); - context.telemetry.properties.isvCore = 'true'; - throw new UserCancelledError(); - } - } - - public shouldPrompt(context: ICosmosDBWizardContext): boolean { - return context.isServerless === undefined; - } -} diff --git a/src/tree/CosmosDBAccountWizard/CosmosDBAccountCreateStep.ts b/src/tree/CosmosDBAccountWizard/CosmosDBAccountCreateStep.ts deleted file mode 100644 index 5c58cd7f9..000000000 --- a/src/tree/CosmosDBAccountWizard/CosmosDBAccountCreateStep.ts +++ /dev/null @@ -1,78 +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 { type DatabaseAccountCreateUpdateParameters } from '@azure/arm-cosmosdb/src/models'; -import { LocationListStep } from '@microsoft/vscode-azext-azureutils'; -import { AzureWizardExecuteStep } from '@microsoft/vscode-azext-utils'; -import { type AppResource } from '@microsoft/vscode-azext-utils/hostapi'; -import { type Progress } from 'vscode'; -import { API } from '../../AzureDBExperiences'; -import { SERVERLESS_CAPABILITY_NAME } from '../../constants'; -import { ext } from '../../extensionVariables'; -import { createCosmosDBClient } from '../../utils/azureClients'; -import { localize } from '../../utils/localize'; -import { nonNullProp } from '../../utils/nonNull'; -import { type ICosmosDBWizardContext } from './ICosmosDBWizardContext'; - -export class CosmosDBAccountCreateStep extends AzureWizardExecuteStep { - public priority: number = 130; - - public async execute( - context: ICosmosDBWizardContext, - progress: Progress<{ message?: string; increment?: number }>, - ): Promise { - const locationName: string = (await LocationListStep.getLocation(context)).name; - const defaultExperience = nonNullProp(context, 'defaultExperience'); - const rgName: string = nonNullProp(nonNullProp(context, 'resourceGroup'), 'name'); - const accountName = nonNullProp(context, 'newServerName'); - - const client = await createCosmosDBClient(context); - const creatingMessage: string = localize( - 'creatingCosmosDBAccount', - 'Creating Cosmos DB account "{0}" with the "{1}" API... It should be ready in several minutes.', - accountName, - defaultExperience.shortName, - ); - ext.outputChannel.appendLog(creatingMessage); - progress.report({ message: creatingMessage }); - - const options: DatabaseAccountCreateUpdateParameters = { - location: locationName, - locations: [{ locationName: locationName }], - kind: defaultExperience.kind, - capabilities: [], - databaseAccountOfferType: 'Standard', - // Note: Setting this tag has no functional effect in the portal, but we'll keep doing it to imitate portal behavior - tags: { defaultExperience: nonNullProp(defaultExperience, 'tag') }, - }; - - if (defaultExperience?.api === API.MongoDB) { - if (context.mongoVersion !== undefined) { - options.apiProperties = { serverVersion: context.mongoVersion }; - } - } - - if (defaultExperience.capability) { - options.capabilities?.push({ name: defaultExperience.capability }); - } - - if (context.isServerless) { - options.capabilities?.push({ name: SERVERLESS_CAPABILITY_NAME }); - } - - context.databaseAccount = await client.databaseAccounts.beginCreateOrUpdateAndWait( - rgName, - accountName, - options, - ); - context.activityResult = context.databaseAccount as AppResource; - - ext.outputChannel.appendLog(`Successfully created Cosmos DB account "${accountName}".`); - } - - public shouldExecute(context: ICosmosDBWizardContext): boolean { - return !context.databaseAccount; - } -} diff --git a/src/tree/CosmosDBAccountWizard/CosmosDBAccountNameStep.ts b/src/tree/CosmosDBAccountWizard/CosmosDBAccountNameStep.ts deleted file mode 100644 index dc41fb4d0..000000000 --- a/src/tree/CosmosDBAccountWizard/CosmosDBAccountNameStep.ts +++ /dev/null @@ -1,53 +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 { type CosmosDBManagementClient } from '@azure/arm-cosmosdb'; -import { ResourceGroupListStep, resourceGroupNamingRules } from '@microsoft/vscode-azext-azureutils'; -import { AzureNameStep } from '@microsoft/vscode-azext-utils'; -import { createCosmosDBClient } from '../../utils/azureClients'; -import { type ICosmosDBWizardContext } from './ICosmosDBWizardContext'; - -export class CosmosDBAccountNameStep extends AzureNameStep { - public async prompt(context: ICosmosDBWizardContext): Promise { - const client = await createCosmosDBClient(context); - context.newServerName = ( - await context.ui.showInputBox({ - placeHolder: 'Account name', - prompt: 'Provide a Cosmos DB account name', - validateInput: (name: string) => validateCosmosDBAccountName(name, client), - }) - ).trim(); - context.valuesToMask.push(context.newServerName); - context.relatedNameTask = this.generateRelatedName(context, context.newServerName, resourceGroupNamingRules); - } - - public shouldPrompt(context: ICosmosDBWizardContext): boolean { - return !context.newServerName; - } - - protected async isRelatedNameAvailable(context: ICosmosDBWizardContext, name: string): Promise { - return await ResourceGroupListStep.isNameAvailable(context, name); - } -} - -async function validateCosmosDBAccountName( - name: string, - client: CosmosDBManagementClient, -): Promise { - name = name ? name.trim() : ''; - - const min = 3; - const max = 31; - - if (name.length < min || name.length > max) { - return `The name must be between ${min} and ${max} characters.`; - } else if (name.match(/[^a-z0-9-]/)) { - return "The name can only contain lowercase letters, numbers, and the '-' character."; - } else if ((await client.databaseAccounts.checkNameExists(name)).body) { - return `Account name "${name}" is not available.`; - } else { - return undefined; - } -} diff --git a/src/tree/CosmosDBAccountWizard/ICosmosDBWizardContext.ts b/src/tree/CosmosDBAccountWizard/ICosmosDBWizardContext.ts deleted file mode 100644 index 75d68e25d..000000000 --- a/src/tree/CosmosDBAccountWizard/ICosmosDBWizardContext.ts +++ /dev/null @@ -1,19 +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 { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb'; -import { type ExecuteActivityContext } from '@microsoft/vscode-azext-utils'; -import { type IAzureDBWizardContext } from '../IAzureDBWizardContext'; - -export interface ICosmosDBWizardContext extends IAzureDBWizardContext, ExecuteActivityContext { - /** - * The newly created Cosmos DB account - * This will be defined after `CosmosDBAccountStep.execute` occurs. - */ - databaseAccount?: DatabaseAccountGetResults; - isServerless?: boolean; - - mongoVersion?: string; -} diff --git a/src/tree/CosmosDBAccountWizard/MongoDBVersionStep.ts b/src/tree/CosmosDBAccountWizard/MongoDBVersionStep.ts deleted file mode 100644 index 832fc19f7..000000000 --- a/src/tree/CosmosDBAccountWizard/MongoDBVersionStep.ts +++ /dev/null @@ -1,28 +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 { AzureWizardPromptStep } from '@microsoft/vscode-azext-utils'; -import { localize } from '../../utils/localize'; -import { type ICosmosDBWizardContext } from './ICosmosDBWizardContext'; - -export class MongoVersionStep extends AzureWizardPromptStep { - public async prompt(context: ICosmosDBWizardContext): Promise { - const mongoVersionOption = await context.ui.showQuickPick( - [ - { label: 'v4.0', detail: '4.0' }, - { label: 'v3.6', detail: '3.6' }, - { label: 'v3.2', detail: '3.2' }, - ], - { - placeHolder: localize('selectMongoVersion', 'Select MongoDB version'), - }, - ); - context.mongoVersion = mongoVersionOption.detail; - } - - public shouldPrompt(_context: ICosmosDBWizardContext): boolean { - return true; - } -} diff --git a/src/tree/CosmosDBBranchDataProvider.ts b/src/tree/CosmosDBBranchDataProvider.ts new file mode 100644 index 000000000..ca1be4315 --- /dev/null +++ b/src/tree/CosmosDBBranchDataProvider.ts @@ -0,0 +1,151 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + parseError, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import { type BranchDataProvider } from '@microsoft/vscode-azureresources-api'; +import * as vscode from 'vscode'; +import { API, CoreExperience, tryGetExperience } from '../AzureDBExperiences'; +import { databaseAccountType } from '../constants'; +import { ext } from '../extensionVariables'; +import { localize } from '../utils/localize'; +import { nonNullProp } from '../utils/nonNull'; +import { type CosmosAccountModel, type CosmosDBResource } from './CosmosAccountModel'; +import { type CosmosDBTreeElement } from './CosmosDBTreeElement'; +import { GraphAccountResourceItem } from './graph/GraphAccountResourceItem'; +import { MongoAccountResourceItem } from './mongo/MongoAccountResourceItem'; +import { NoSqlAccountResourceItem } from './nosql/NoSqlAccountResourceItem'; +import { TableAccountResourceItem } from './table/TableAccountResourceItem'; +import { isTreeElementWithContextValue } from './TreeElementWithContextValue'; +import { isTreeElementWithExperience } from './TreeElementWithExperience'; + +export class CosmosDBBranchDataProvider + extends vscode.Disposable + implements BranchDataProvider +{ + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + + constructor() { + super(() => this.onDidChangeTreeDataEmitter.dispose()); + } + + get onDidChangeTreeData(): vscode.Event { + return this.onDidChangeTreeDataEmitter.event; + } + + /** + * This function is called for every element in the tree when expanding, the element being expanded is being passed as an argument + */ + async getChildren(element: CosmosDBTreeElement): Promise { + try { + const result = await callWithTelemetryAndErrorHandling( + 'CosmosDBBranchDataProvider.getChildren', + async (context: IActionContext) => { + context.errorHandling.suppressDisplay = true; + context.errorHandling.rethrow = true; + context.errorHandling.forceIncludeInReportIssueCommand = true; + + if (isTreeElementWithContextValue(element)) { + context.telemetry.properties.parentContext = element.contextValue; + } + + if (isTreeElementWithExperience(element)) { + context.telemetry.properties.experience = element.experience?.api ?? API.Common; + } + + // TODO: values to mask. New TreeElements do not have valueToMask field + // I assume this array should be filled after element.getChildren() call + // And these values should be masked in the context + + const children = (await element.getChildren?.()) ?? []; + return children.map((child) => { + return ext.state.wrapItemInStateHandling(child, (child: CosmosDBTreeElement) => + this.refresh(child), + ) as CosmosDBTreeElement; + }); + }, + ); + + return result ?? []; + } catch (error) { + return [ + createGenericElement({ + contextValue: 'cosmosDB.item.error', + label: localize('Error: {0}', parseError(error).message), + }) as CosmosDBTreeElement, + ]; + } + } + + /** + * This function is being called when the resource tree is being built, it is called for every top level of resources. + * @param resource + */ + async getResourceItem(resource: CosmosDBResource): Promise { + const resourceItem = await callWithTelemetryAndErrorHandling( + 'CosmosDBBranchDataProvider.getResourceItem', + (context: IActionContext) => { + const id = nonNullProp(resource, 'id'); + const name = nonNullProp(resource, 'name'); + const type = nonNullProp(resource, 'type'); + + context.valuesToMask.push(id); + context.valuesToMask.push(name); + + if (type.toLocaleLowerCase() === databaseAccountType.toLocaleLowerCase()) { + const accountModel = resource as CosmosAccountModel; + const experience = tryGetExperience(resource); + + if (experience?.api === API.MongoDB) { + return new MongoAccountResourceItem(accountModel, experience); + } + + if (experience?.api === API.Cassandra) { + return new NoSqlAccountResourceItem(accountModel, experience); + } + + if (experience?.api === API.Core) { + return new NoSqlAccountResourceItem(accountModel, experience); + } + + if (experience?.api === API.Graph) { + return new GraphAccountResourceItem(accountModel, experience); + } + + if (experience?.api === API.Table) { + return new TableAccountResourceItem(accountModel, experience); + } + + // Unknown experience fallback + return new NoSqlAccountResourceItem(accountModel, CoreExperience); + } else { + // Unknown resource type + } + + return null as unknown as CosmosDBTreeElement; + }, + ); + + if (resourceItem) { + return ext.state.wrapItemInStateHandling(resourceItem, (item: CosmosDBTreeElement) => + this.refresh(item), + ) as CosmosDBTreeElement; + } + + return null as unknown as CosmosDBTreeElement; + } + + async getTreeItem(element: CosmosDBTreeElement): Promise { + return element.getTreeItem(); + } + + refresh(element?: CosmosDBTreeElement): void { + this.onDidChangeTreeDataEmitter.fire(element); + } +} diff --git a/src/tree/CosmosDBTreeElement.ts b/src/tree/CosmosDBTreeElement.ts new file mode 100644 index 000000000..be2ddd16c --- /dev/null +++ b/src/tree/CosmosDBTreeElement.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type TreeElementWithId } from '@microsoft/vscode-azext-utils'; +import type * as vscode from 'vscode'; + +export interface ExtTreeElementBase extends TreeElementWithId { + getChildren?(): vscode.ProviderResult; + getTreeItem(): vscode.TreeItem | Thenable; +} + +export type CosmosDBTreeElement = ExtTreeElementBase; diff --git a/src/tree/CosmosDBWorkspaceBranchDataProvider.ts b/src/tree/CosmosDBWorkspaceBranchDataProvider.ts new file mode 100644 index 000000000..b3ec972e4 --- /dev/null +++ b/src/tree/CosmosDBWorkspaceBranchDataProvider.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + type IActionContext, + parseError, +} from '@microsoft/vscode-azext-utils'; +import { type BranchDataProvider } from '@microsoft/vscode-azureresources-api'; +import * as vscode from 'vscode'; +import { API } from '../AzureDBExperiences'; +import { ext } from '../extensionVariables'; +import { localize } from '../utils/localize'; +import { CosmosDBAttachedAccountsResourceItem } from './attached/CosmosDBAttachedAccountsResourceItem'; +import { type CosmosDBResource } from './CosmosAccountModel'; +import { type CosmosDBTreeElement } from './CosmosDBTreeElement'; +import { isTreeElementWithContextValue } from './TreeElementWithContextValue'; +import { isTreeElementWithExperience } from './TreeElementWithExperience'; + +export class CosmosDBWorkspaceBranchDataProvider + extends vscode.Disposable + implements BranchDataProvider +{ + private readonly onDidChangeTreeDataEmitter = new vscode.EventEmitter(); + + constructor() { + super(() => this.onDidChangeTreeDataEmitter.dispose()); + } + + get onDidChangeTreeData(): vscode.Event { + return this.onDidChangeTreeDataEmitter.event; + } + + /** + * This function is called for every element in the tree when expanding, the element being expanded is being passed as an argument + */ + async getChildren(element: CosmosDBTreeElement): Promise { + try { + const result = await callWithTelemetryAndErrorHandling( + 'CosmosDBWorkspaceBranchDataProvider.getChildren', + async (context: IActionContext) => { + context.telemetry.properties.view = 'workspace'; + context.errorHandling.suppressDisplay = true; + context.errorHandling.rethrow = true; + context.errorHandling.forceIncludeInReportIssueCommand = true; + + if (isTreeElementWithContextValue(element)) { + context.telemetry.properties.parentContext = element.contextValue; + } + + if (isTreeElementWithExperience(element)) { + context.telemetry.properties.experience = element.experience?.api ?? API.Common; + } + + // TODO: values to mask. New TreeElements do not have valueToMask field + // I assume this array should be filled after element.getChildren() call + // And these values should be masked in the context + + const children = (await element.getChildren?.()) ?? []; + return children.map((child) => { + return ext.state.wrapItemInStateHandling(child, (child: CosmosDBTreeElement) => + this.refresh(child), + ) as CosmosDBTreeElement; + }); + }, + ); + + return result ?? []; + } catch (error) { + return [ + createGenericElement({ + contextValue: 'cosmosDB.workspace.item.error', + label: localize('Error: {0}', parseError(error).message), + }) as CosmosDBTreeElement, + ]; + } + } + + /** + * This function is being called when the resource tree is being built, it is called for every top level of resources. + */ + async getResourceItem(): Promise { + const resourceItem = await callWithTelemetryAndErrorHandling( + 'CosmosDBWorkspaceBranchDataProvider.getResourceItem', + () => new CosmosDBAttachedAccountsResourceItem(), + ); + + if (resourceItem) { + // Workspace picker relies on this value + ext.cosmosDBWorkspaceBranchDataResource = resourceItem; + + return ext.state.wrapItemInStateHandling(resourceItem, (item: CosmosDBTreeElement) => + this.refresh(item), + ) as CosmosDBTreeElement; + } + + return null as unknown as CosmosDBTreeElement; + } + + async getTreeItem(element: CosmosDBTreeElement): Promise { + return element.getTreeItem(); + } + + refresh(element?: CosmosDBTreeElement): void { + this.onDidChangeTreeDataEmitter.fire(element); + } +} diff --git a/src/tree/SubscriptionTreeItem.ts b/src/tree/SubscriptionTreeItem.ts deleted file mode 100644 index 9d70c58b9..000000000 --- a/src/tree/SubscriptionTreeItem.ts +++ /dev/null @@ -1,317 +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 { type CosmosDBManagementClient } from '@azure/arm-cosmosdb'; -import { type DatabaseAccountGetResults, type DatabaseAccountListKeysResult } from '@azure/arm-cosmosdb/src/models'; -import { - LocationListStep, - ResourceGroupListStep, - SubscriptionTreeItemBase, - getResourceGroupFromId, - uiUtils, - type ILocationWizardContext, -} from '@microsoft/vscode-azext-azureutils'; -import { - AzExtTreeItem, - AzureWizard, - callWithTelemetryAndErrorHandling, - type AzExtParentTreeItem, - type AzureWizardPromptStep, - type IActionContext, -} from '@microsoft/vscode-azext-utils'; -import * as vscode from 'vscode'; -import { API, getExperienceLabel, tryGetExperience, type Experience } from '../AzureDBExperiences'; -import { type CosmosDBCredential, type CosmosDBKeyCredential } from '../docdb/getCosmosClient'; -import { DocDBAccountTreeItem } from '../docdb/tree/DocDBAccountTreeItem'; -import { ext } from '../extensionVariables'; -import { tryGetGremlinEndpointFromAzure } from '../graph/gremlinEndpoints'; -import { GraphAccountTreeItem } from '../graph/tree/GraphAccountTreeItem'; -import { MongoAccountTreeItem } from '../mongo/tree/MongoAccountTreeItem'; -import { PostgresServerType, type PostgresAbstractServer } from '../postgres/abstract/models'; -import { type IPostgresServerWizardContext } from '../postgres/commands/createPostgresServer/IPostgresServerWizardContext'; -import { - createPostgresConnectionString, - parsePostgresConnectionString, - type ParsedPostgresConnectionString, -} from '../postgres/postgresConnectionStrings'; -import { PostgresServerTreeItem } from '../postgres/tree/PostgresServerTreeItem'; -import { TableAccountTreeItem } from '../table/tree/TableAccountTreeItem'; -import { createActivityContext } from '../utils/activityUtils'; -import { createCosmosDBClient, createPostgreSQLClient, createPostgreSQLFlexibleClient } from '../utils/azureClients'; -import { localize } from '../utils/localize'; -import { nonNullProp } from '../utils/nonNull'; -import { AzureDBAPIStep } from './AzureDBAPIStep'; -import { type ICosmosDBWizardContext } from './CosmosDBAccountWizard/ICosmosDBWizardContext'; - -export class SubscriptionTreeItem extends SubscriptionTreeItemBase { - public childTypeLabel: string = 'Account'; - - public hasMoreChildrenImpl(): boolean { - return false; - } - - public async loadMoreChildrenImpl(_clearCache: boolean, context: IActionContext): Promise { - //Postgres - const postgresSingleClient = await createPostgreSQLClient([context, this.subscription]); - const postgresFlexibleClient = await createPostgreSQLFlexibleClient([context, this.subscription]); - const postgresServers: PostgresAbstractServer[] = [ - ...(await uiUtils.listAllIterator(postgresSingleClient.servers.list())).map((s) => - Object.assign(s, { serverType: PostgresServerType.Single }), - ), - ...(await uiUtils.listAllIterator(postgresFlexibleClient.servers.list())).map((s) => - Object.assign(s, { serverType: PostgresServerType.Flexible }), - ), - ]; - - const treeItemPostgres: AzExtTreeItem[] = await this.createTreeItemsWithErrorHandling( - postgresServers, - 'invalidPostgreSQLAccount', - async (server: PostgresAbstractServer) => await SubscriptionTreeItem.initPostgresChild(server, this), - (server: PostgresAbstractServer) => server.name, - ); - - //CosmosDB - const client = await createCosmosDBClient([context, this]); - const accounts = await uiUtils.listAllIterator(client.databaseAccounts.list()); - const treeItem: AzExtTreeItem[] = await this.createTreeItemsWithErrorHandling( - accounts, - 'invalidCosmosDBAccount', - async (db: DatabaseAccountGetResults) => await SubscriptionTreeItem.initCosmosDBChild(client, db, this), - (db: DatabaseAccountGetResults) => db.name, - ); - - treeItem.push(...treeItemPostgres); - return treeItem; - } - - public static async createChild( - context: IActionContext & { defaultExperience?: Experience }, - node: SubscriptionTreeItem, - ): Promise { - const client = await createCosmosDBClient([context, node.subscription]); - const wizardContext: IPostgresServerWizardContext & ICosmosDBWizardContext = Object.assign( - context, - node.subscription, - { ...(await createActivityContext()) }, - ); - - const promptSteps: AzureWizardPromptStep[] = [ - new AzureDBAPIStep(), - new ResourceGroupListStep(), - ]; - LocationListStep.addStep(wizardContext, promptSteps); - - const wizard = new AzureWizard(wizardContext, { - promptSteps, - executeSteps: [], - title: localize('createDBServerMsg', 'Create new Azure Database Server'), - }); - - await wizard.prompt(); - - wizardContext.telemetry.properties.defaultExperience = wizardContext.defaultExperience?.api; - - const newServerName: string = nonNullProp(wizardContext, 'newServerName'); - wizardContext.activityTitle = localize( - 'createDBServerMsgActivityTitle', - 'Create new Azure Database Server "{0}"', - newServerName, - ); - - await wizard.execute(); - await ext.rgApi.appResourceTree.refresh(context); - if ( - wizardContext.defaultExperience?.api === API.PostgresSingle || - wizardContext.defaultExperience?.api === API.PostgresFlexible - ) { - const createMessage: string = localize( - 'createdServerOutput', - 'Successfully created PostgreSQL server "{0}".', - wizardContext.newServerName, - ); - void vscode.window.showInformationMessage(createMessage); - ext.outputChannel.appendLog(createMessage); - const server = nonNullProp(wizardContext, 'server'); - const host = nonNullProp(server, 'fullyQualifiedDomainName'); - const username: string = - wizardContext.serverType === PostgresServerType.Flexible - ? nonNullProp(wizardContext, 'shortUserName') - : nonNullProp(wizardContext, 'longUserName'); - const password: string = nonNullProp(wizardContext, 'adminPassword'); - const connectionString: string = createPostgresConnectionString(host, undefined, username, password); - const parsedCS: ParsedPostgresConnectionString = parsePostgresConnectionString(connectionString); - return new PostgresServerTreeItem(node, parsedCS, server); - } else { - return await SubscriptionTreeItem.initCosmosDBChild( - client, - nonNullProp(wizardContext, 'databaseAccount'), - node, - ); - } - } - - public isAncestorOfImpl(contextValue: string | RegExp): boolean { - return typeof contextValue !== 'string' || !/attached/i.test(contextValue); - } - - public static async initCosmosDBChild( - client: CosmosDBManagementClient, - databaseAccount: DatabaseAccountGetResults, - parent: AzExtParentTreeItem, - ): Promise { - const experience = tryGetExperience(databaseAccount); - const id: string = nonNullProp(databaseAccount, 'id'); - const name: string = nonNullProp(databaseAccount, 'name', `of the database account ${databaseAccount.id}`); - const documentEndpoint: string = nonNullProp( - databaseAccount, - 'documentEndpoint', - `of the database account ${databaseAccount.id}`, - ); - - const resourceGroup: string = getResourceGroupFromId(id); - const accountKindLabel = getExperienceLabel(databaseAccount); - const label: string = name + (accountKindLabel ? ` (${accountKindLabel})` : ``); - const isEmulator: boolean = false; - - const newNode = await callWithTelemetryAndErrorHandling( - 'cosmosDB.initCosmosDBChild', - async (context: IActionContext) => { - // leave error handling to the caller (command or tree node) - context.errorHandling.suppressDisplay = true; - // rethrow all errors to satisfy initCosmosDBChild contract - context.errorHandling.rethrow = true; - context.telemetry.properties.experience = experience?.api; - - if (experience && experience.api === API.MongoDB) { - const result = await client.databaseAccounts.listConnectionStrings(resourceGroup, name); - const connectionString: URL = new URL( - nonNullProp(nonNullProp(result, 'connectionStrings')[0], 'connectionString'), - ); - // for any Mongo connectionString, append this query param because the Cosmos Mongo API v3.6 doesn't support retrywrites - // but the newer node.js drivers started breaking this - const searchParam: string = 'retrywrites'; - if (!connectionString.searchParams.has(searchParam)) { - connectionString.searchParams.set(searchParam, 'false'); - } - - // Use the default connection string - return new MongoAccountTreeItem( - parent, - id, - label, - connectionString.toString(), - isEmulator, - databaseAccount, - ); - } else { - let keyCred: CosmosDBKeyCredential | undefined = undefined; - - const forceOAuth = vscode.workspace - .getConfiguration() - .get('azureDatabases.useCosmosOAuth'); - context.telemetry.properties.useCosmosOAuth = (forceOAuth ?? false).toString(); - - // disable key auth if the user has opted in to OAuth (AAD/Entra ID) - if (!forceOAuth) { - try { - const acc = await client.databaseAccounts.get(resourceGroup, name); - const localAuthDisabled = acc.disableLocalAuth === true; - context.telemetry.properties.localAuthDisabled = localAuthDisabled.toString(); - let keyResult: DatabaseAccountListKeysResult | undefined; - // If the account has local auth disabled, don't even try to use key auth - if (!localAuthDisabled) { - keyResult = await client.databaseAccounts.listKeys(resourceGroup, name); - keyCred = keyResult?.primaryMasterKey - ? { - type: 'key', - key: keyResult.primaryMasterKey, - } - : undefined; - context.telemetry.properties.receivedKeyCreds = 'true'; - } else { - throw new Error('Local auth is disabled'); - } - } catch { - context.telemetry.properties.receivedKeyCreds = 'false'; - const message = localize( - 'keyPermissionErrorMsg', - 'You do not have the required permissions to list auth keys for [{0}].\nFalling back to using Entra ID.\nYou can change the default authentication in the settings.', - name, - ); - const openSettingsItem = localize('openSettings', 'Open Settings'); - void vscode.window.showWarningMessage(message, ...[openSettingsItem]).then((item) => { - if (item === openSettingsItem) { - void vscode.commands.executeCommand( - 'workbench.action.openSettings', - 'azureDatabases.useCosmosOAuth', - ); - } - }); - } - } - - // OAuth is always enabled for Cosmos DB and will be used as a fall back if key auth is unavailable - const authCred = { type: 'auth' }; - const credentials = [keyCred, authCred].filter( - (cred): cred is CosmosDBCredential => cred !== undefined, - ); - switch (experience && experience.api) { - case API.Table: - return new TableAccountTreeItem( - parent, - id, - label, - documentEndpoint, - credentials, - isEmulator, - databaseAccount, - ); - case API.Graph: { - const gremlinEndpoint = await tryGetGremlinEndpointFromAzure(client, resourceGroup, name); - return new GraphAccountTreeItem( - parent, - id, - label, - documentEndpoint, - gremlinEndpoint, - credentials, - isEmulator, - databaseAccount, - ); - } - case API.Core: - default: - // Default to DocumentDB, the base type for all Cosmos DB Accounts - return new DocDBAccountTreeItem( - parent, - id, - label, - documentEndpoint, - credentials, - isEmulator, - databaseAccount, - ); - } - } - }, - ); - if (!(newNode instanceof AzExtTreeItem)) { - // note: this should never happen, callWithTelemetryAndErrorHandling will rethrow all errors - throw new Error(localize('invalidCosmosDBAccount', 'Invalid Cosmos DB account.')); - } - return newNode; - } - - public static async initPostgresChild( - server: PostgresAbstractServer, - parent: AzExtParentTreeItem, - ): Promise { - const connectionString: string = createPostgresConnectionString( - nonNullProp(server, 'fullyQualifiedDomainName'), - ); - const parsedCS: ParsedPostgresConnectionString = parsePostgresConnectionString(connectionString); - return new PostgresServerTreeItem(parent, parsedCS, server); - } -} diff --git a/src/docdb/tree/DocDBUtils.test.ts b/src/tree/TreeElementWithContextValue.ts similarity index 57% rename from src/docdb/tree/DocDBUtils.test.ts rename to src/tree/TreeElementWithContextValue.ts index 182a905bc..a7bed5d25 100644 --- a/src/docdb/tree/DocDBUtils.test.ts +++ b/src/tree/TreeElementWithContextValue.ts @@ -3,12 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { sanitizeId } from './DocDBUtils'; +export type TreeElementWithContextValue = { + readonly contextValue: string; +}; -describe('DocDBUtils', function () { - it('Replaces + with whitespace', function () { - const id = 'a+b+c'; - const sanitizedId = sanitizeId(id); - expect(sanitizedId).toStrictEqual('a b c'); - }); -}); +export function isTreeElementWithContextValue(node: unknown): node is TreeElementWithContextValue { + return typeof node === 'object' && node !== null && 'contextValue' in node; +} diff --git a/src/tree/TreeElementWithExperience.ts b/src/tree/TreeElementWithExperience.ts new file mode 100644 index 000000000..dbca09234 --- /dev/null +++ b/src/tree/TreeElementWithExperience.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../AzureDBExperiences'; + +/** + * It's currently being kept separately from the CosmosDbTreeElement as we need to discuss it with the team, + * as we're working on an overlapping feature in parallel, keeping the 'experience' property in a separate + * interface simplifies parallel development and can still be easily merged once ready for it. + */ +export type TreeElementWithExperience = { + experience: Experience; +}; + +/** + * Type guard function to check if a given node is a `TreeElementWithExperience`. + * + * @param node - The node to check. + * @returns `true` if the node is an object and has an `experience` property, otherwise `false`. + */ +export function isTreeElementWithExperience(node: unknown): node is TreeElementWithExperience { + return typeof node === 'object' && node !== null && 'experience' in node; +} diff --git a/src/tree/attached/CosmosDBAttachAccountResourceItem.ts b/src/tree/attached/CosmosDBAttachAccountResourceItem.ts new file mode 100644 index 000000000..4e1774b58 --- /dev/null +++ b/src/tree/attached/CosmosDBAttachAccountResourceItem.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import vscode from 'vscode'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; + +export class CosmosDBAttachAccountResourceItem implements CosmosDBTreeElement, TreeElementWithContextValue { + public readonly id: string; + public readonly contextValue: string = 'treeItem.attachAccount'; + + constructor(public readonly parentId: string) { + this.id = `${parentId}/attachAccount`; + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: 'Attach Database Account\u2026', + iconPath: new vscode.ThemeIcon('plus'), + command: { + command: 'cosmosDB.attachDatabaseAccount', + title: '', + arguments: [this], + }, + }; + } +} diff --git a/src/tree/attached/CosmosDBAttachEmulatorResourceItem.ts b/src/tree/attached/CosmosDBAttachEmulatorResourceItem.ts new file mode 100644 index 000000000..e5ca8f870 --- /dev/null +++ b/src/tree/attached/CosmosDBAttachEmulatorResourceItem.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import vscode from 'vscode'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; + +export class CosmosDBAttachEmulatorResourceItem implements CosmosDBTreeElement, TreeElementWithContextValue { + public readonly id: string; + public readonly contextValue: string = 'treeItem.attachEmulator'; + + constructor(public readonly parentId: string) { + this.id = `${parentId}/attachEmulator`; + } + + public getTreeItem(): vscode.TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + label: 'Attach Emulator\u2026', + iconPath: new vscode.ThemeIcon('plus'), + command: { + command: 'cosmosDB.attachEmulator', + title: '', + arguments: [this], + }, + }; + } +} diff --git a/src/tree/attached/CosmosDBAttachedAccountModel.ts b/src/tree/attached/CosmosDBAttachedAccountModel.ts new file mode 100644 index 000000000..f1574b7c0 --- /dev/null +++ b/src/tree/attached/CosmosDBAttachedAccountModel.ts @@ -0,0 +1,11 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export type CosmosDBAttachedAccountModel = { + connectionString: string; + id: string; + isEmulator: boolean; + name: string; +}; diff --git a/src/tree/attached/CosmosDBAttachedAccountsResourceItem.ts b/src/tree/attached/CosmosDBAttachedAccountsResourceItem.ts new file mode 100644 index 000000000..0e0ea86fd --- /dev/null +++ b/src/tree/attached/CosmosDBAttachedAccountsResourceItem.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, createContextValue, nonNullValue } from '@microsoft/vscode-azext-utils'; +import { ThemeIcon, TreeItemCollapsibleState } from 'vscode'; +import { API, getExperienceFromApi } from '../../AzureDBExperiences'; +import { isEmulatorSupported } from '../../constants'; +import { ext } from '../../extensionVariables'; +import { type PersistedAccount } from '../AttachedAccountsTreeItem'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { GraphAccountAttachedResourceItem } from '../graph/GraphAccountAttachedResourceItem'; +import { NoSqlAccountAttachedResourceItem } from '../nosql/NoSqlAccountAttachedResourceItem'; +import { TableAccountAttachedResourceItem } from '../table/TableAccountAttachedResourceItem'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { WorkspaceResourceType } from '../workspace/SharedWorkspaceResourceProvider'; +import { SharedWorkspaceStorage, type SharedWorkspaceStorageItem } from '../workspace/SharedWorkspaceStorage'; +import { CosmosDBAttachAccountResourceItem } from './CosmosDBAttachAccountResourceItem'; +import { type CosmosDBAttachedAccountModel } from './CosmosDBAttachedAccountModel'; +import { CosmosDBAttachEmulatorResourceItem } from './CosmosDBAttachEmulatorResourceItem'; + +export class CosmosDBAttachedAccountsResourceItem implements CosmosDBTreeElement, TreeElementWithContextValue { + public readonly id: string = WorkspaceResourceType.AttachedAccounts; + public readonly contextValue: string = 'treeItem.accounts'; + + constructor() { + this.contextValue = createContextValue([this.contextValue, `attachedAccounts`]); + } + + public async getChildren(): Promise { + // TODO: remove after a few releases + await this.pickSupportedAccounts(); // Move accounts from the old storage format to the new one + + const attachDatabaseAccount = new CosmosDBAttachAccountResourceItem(this.id); + const attachEmulator = new CosmosDBAttachEmulatorResourceItem(this.id); + + const items = await SharedWorkspaceStorage.getItems(this.id); + const children = await this.getChildrenImpl(items); + const auxItems = isEmulatorSupported ? [attachDatabaseAccount, attachEmulator] : [attachDatabaseAccount]; + + return [...children, ...auxItems]; + } + + public getTreeItem() { + return { + id: this.id, + contextValue: this.contextValue, + label: 'Attached Database Accounts', + iconPath: new ThemeIcon('plug'), + collapsibleState: TreeItemCollapsibleState.Collapsed, + }; + } + + protected async getChildrenImpl(items: SharedWorkspaceStorageItem[]): Promise { + return Promise.resolve( + items + .map((item) => { + const { id, name, properties, secrets } = item; + const api: API = nonNullValue(properties?.api, 'api') as API; + const isEmulator: boolean = !!nonNullValue(properties?.isEmulator, 'isEmulator'); + const connectionString: string = nonNullValue(secrets?.[0], 'connectionString'); + const experience = getExperienceFromApi(api); + const accountModel: CosmosDBAttachedAccountModel = { + id, + name, + connectionString, + isEmulator, + }; + + if (experience?.api === API.Cassandra) { + return new NoSqlAccountAttachedResourceItem(accountModel, experience); + } + + if (experience?.api === API.Core) { + return new NoSqlAccountAttachedResourceItem(accountModel, experience); + } + + if (experience?.api === API.Graph) { + return new GraphAccountAttachedResourceItem(accountModel, experience); + } + + if (experience?.api === API.Table) { + return new TableAccountAttachedResourceItem(accountModel, experience); + } + + // Unknown experience + return undefined; + }) + .filter((r) => r !== undefined), + ); + } + + protected async pickSupportedAccounts(): Promise { + return callWithTelemetryAndErrorHandling( + 'CosmosDBAttachedAccountsResourceItem.pickSupportedAccounts', + async () => { + const serviceName = 'ms-azuretools.vscode-cosmosdb.connectionStrings'; + const value: string | undefined = ext.context.globalState.get(serviceName); + + if (!value) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const accounts: (string | PersistedAccount)[] = JSON.parse(value); + for (const account of accounts) { + let id: string; + let name: string; + let isEmulator: boolean; + let api: API; + + if (typeof account === 'string') { + // Default to Mongo if the value is a string for the sake of backwards compatibility + // (Mongo was originally the only account type that could be attached) + id = account; + name = account; + api = API.MongoDB; + isEmulator = false; + } else { + id = (account).id; + name = (account).id; + api = (account).defaultExperience; + isEmulator = (account).isEmulator ?? false; + } + + // TODO: Ignore Postgres accounts until we have a way to handle them + if (api === API.PostgresSingle || api === API.PostgresFlexible) { + continue; + } + + const connectionString: string = nonNullValue( + await ext.secretStorage.get(`${serviceName}.${id}`), + 'connectionString', + ); + + const storageItem: SharedWorkspaceStorageItem = { + id, + name, + properties: { isEmulator, api }, + secrets: [connectionString], + }; + + await SharedWorkspaceStorage.push(WorkspaceResourceType.AttachedAccounts, storageItem, true); + } + }, + ); + } + + protected async migrateV1AccountsToV2(): Promise { + const serviceName = 'ms-azuretools.vscode-cosmosdb.connectionStrings'; + const value: string | undefined = ext.context.globalState.get(serviceName); + + if (!value) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const accounts: (string | PersistedAccount)[] = JSON.parse(value); + const result = await Promise.allSettled( + accounts.map(async (account) => { + return callWithTelemetryAndErrorHandling( + 'CosmosDBAttachedAccountsResourceItem.migrateV1AccountsToV2', + async (context) => { + context.errorHandling.rethrow = true; + context.errorHandling.forceIncludeInReportIssueCommand = true; + + let id: string; + let name: string; + let isEmulator: boolean; + let api: API; + + if (typeof account === 'string') { + // Default to Mongo if the value is a string for the sake of backwards compatibility + // (Mongo was originally the only account type that could be attached) + id = account; + name = account; + api = API.MongoDB; + isEmulator = false; + } else { + id = (account).id; + name = (account).id; + api = (account).defaultExperience; + isEmulator = (account).isEmulator ?? false; + } + + const connectionString: string = nonNullValue( + await ext.secretStorage.get(`${serviceName}.${id}`), + 'connectionString', + ); + + const storageItem: SharedWorkspaceStorageItem = { + id, + name, + properties: { + isEmulator, + api, + }, + secrets: [connectionString], + }; + + await SharedWorkspaceStorage.push(WorkspaceResourceType.AttachedAccounts, storageItem); + await ext.secretStorage.delete(`${serviceName}.${id}`); + + return storageItem; + }, + ); + }), + ); + + const notMovedAccounts = result + .map((r, index) => { + if (r.status === 'rejected') { + // Couldn't migrate the account, won't remove it from the old list + return accounts[index]; + } + + const storageItem = r.value; + + if (storageItem?.properties?.api === API.MongoDB) { + // TODO: Tomasz will handle this + return accounts[index]; + } + + if ( + storageItem?.properties?.api === API.PostgresSingle || + storageItem?.properties?.api === API.PostgresFlexible + ) { + // TODO: Need to handle Postgres + return accounts[index]; + } + + return undefined; + }) + .filter((r) => r !== undefined); + + if (notMovedAccounts.length > 0) { + await ext.context.globalState.update(serviceName, JSON.stringify(notMovedAccounts)); + } else { + await ext.context.globalState.update(serviceName, undefined); + } + } +} diff --git a/src/tree/docdb/AccountInfo.ts b/src/tree/docdb/AccountInfo.ts new file mode 100644 index 000000000..d5d6dab22 --- /dev/null +++ b/src/tree/docdb/AccountInfo.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosDBManagementClient, type DatabaseAccountGetResults } from '@azure/arm-cosmosdb'; +import { type DatabaseAccountListKeysResult } from '@azure/arm-cosmosdb/src/models'; +import { callWithTelemetryAndErrorHandling, type IActionContext } from '@microsoft/vscode-azext-utils'; +import vscode from 'vscode'; +import { SERVERLESS_CAPABILITY_NAME } from '../../constants'; +import { parseDocDBConnectionString } from '../../docdb/docDBConnectionStrings'; +import { type CosmosDBCredential, type CosmosDBKeyCredential, getCosmosClient } from '../../docdb/getCosmosClient'; +import { createCosmosDBManagementClient } from '../../utils/azureClients'; +import { localize } from '../../utils/localize'; +import { nonNullProp } from '../../utils/nonNull'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { type CosmosAccountModel } from '../CosmosAccountModel'; + +export interface AccountInfo { + credentials: CosmosDBCredential[]; + endpoint: string; + id: string; + isEmulator: boolean; + isServerless: boolean; + name: string; +} + +function isCosmosDBAttachedAccountModel(account: unknown): account is CosmosDBAttachedAccountModel { + return ( + !!account && + typeof account === 'object' && + 'connectionString' in account && + 'id' in account && + 'isEmulator' in account && + 'name' in account + ); +} + +export async function getAccountInfo( + account: CosmosAccountModel | CosmosDBAttachedAccountModel, +): Promise | never { + if (isCosmosDBAttachedAccountModel(account)) { + return getAccountInfoForAttached(account); + } else { + return getAccountInfoForGeneric(account); + } +} + +async function getAccountInfoForGeneric(account: CosmosAccountModel): Promise | never { + const id = nonNullProp(account, 'id'); + const name = nonNullProp(account, 'name'); + const resourceGroup = nonNullProp(account, 'resourceGroup'); + + const client = await callWithTelemetryAndErrorHandling( + 'createCosmosDBManagementClient', + async (context: IActionContext) => { + return createCosmosDBManagementClient(context, account.subscription); + }, + ); + + if (!client) { + throw new Error('Failed to connect to Cosmos DB account'); + } + + const databaseAccount = await client.databaseAccounts.get(resourceGroup, name); + const tenantId = account?.subscription?.tenantId; + const credentials = await getCredentialsForGeneric(name, resourceGroup, tenantId, client, databaseAccount); + const documentEndpoint = nonNullProp(databaseAccount, 'documentEndpoint', `of the database account ${id}`); + const isServerless = databaseAccount?.capabilities + ? databaseAccount.capabilities.some((cap) => cap.name === SERVERLESS_CAPABILITY_NAME) + : false; + + return { + credentials, + endpoint: documentEndpoint, + id, + isEmulator: false, + isServerless, + name, + }; +} + +async function getCredentialsForGeneric( + name: string, + resourceGroup: string, + tenantId: string, + client: CosmosDBManagementClient, + databaseAccount: DatabaseAccountGetResults, +): Promise { + const result = await callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.valuesToMask.push(name, resourceGroup); + context.telemetry.properties.attachedAccount = 'false'; + + const forceOAuth = vscode.workspace.getConfiguration().get('azureDatabases.useCosmosOAuth'); + context.telemetry.properties.useCosmosOAuth = (forceOAuth ?? false).toString(); + + let keyCred: CosmosDBKeyCredential | undefined = undefined; + // disable key auth if the user has opted in to OAuth (AAD/Entra ID) + if (!forceOAuth) { + try { + const localAuthDisabled = databaseAccount.disableLocalAuth === true; + context.telemetry.properties.localAuthDisabled = localAuthDisabled.toString(); + + let keyResult: DatabaseAccountListKeysResult | undefined; + // If the account has local auth disabled, don't even try to use key auth + if (!localAuthDisabled) { + keyResult = await client.databaseAccounts.listKeys(resourceGroup, name); + keyCred = keyResult?.primaryMasterKey + ? { + type: 'key', + key: keyResult.primaryMasterKey, + } + : undefined; + context.telemetry.properties.receivedKeyCreds = 'true'; + } else { + throw new Error('Local auth is disabled'); + } + } catch { + context.telemetry.properties.receivedKeyCreds = 'false'; + + const message = localize( + 'keyPermissionErrorMsg', + 'You do not have the required permissions to list auth keys for [{0}].\nFalling back to using Entra ID.\nYou can change the default authentication in the settings.', + name, + ); + const openSettingsItem = localize('openSettings', 'Open Settings'); + void vscode.window.showWarningMessage(message, ...[openSettingsItem]).then((item) => { + if (item === openSettingsItem) { + void vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'azureDatabases.useCosmosOAuth', + ); + } + }); + } + } + + // OAuth is always enabled for Cosmos DB and will be used as a fall back if key auth is unavailable + const authCred = { type: 'auth', tenantId: tenantId }; + return [keyCred, authCred].filter((cred) => cred !== undefined) as CosmosDBCredential[]; + }); + + return result ?? []; +} + +async function getAccountInfoForAttached(account: CosmosDBAttachedAccountModel): Promise | never { + const id = account.id; + const name = account.name; + const isEmulator = account.isEmulator; + const parsedCS = parseDocDBConnectionString(account.connectionString); + const documentEndpoint = parsedCS.documentEndpoint; + const credentials = await getCredentialsForAttached(account); + const isServerless = false; + + return { + credentials, + endpoint: documentEndpoint, + id, + isEmulator, + isServerless, + name, + }; +} + +async function getCredentialsForAttached(account: CosmosDBAttachedAccountModel): Promise { + const result = await callWithTelemetryAndErrorHandling('getCredentials', async (context: IActionContext) => { + context.valuesToMask.push(account.connectionString); + context.telemetry.properties.attachedAccount = 'true'; + + const forceOAuth = vscode.workspace.getConfiguration().get('azureDatabases.useCosmosOAuth'); + context.telemetry.properties.useCosmosOAuth = (forceOAuth ?? false).toString(); + + let keyCred: CosmosDBKeyCredential | undefined = undefined; + // disable key auth if the user has opted in to OAuth (AAD/Entra ID), or if the account is the emulator + if (!forceOAuth || account.isEmulator) { + let localAuthDisabled = false; + + const parsedCS = parseDocDBConnectionString(account.connectionString); + if (parsedCS.masterKey) { + context.telemetry.properties.receivedKeyCreds = 'true'; + + keyCred = { + type: 'key', + key: parsedCS.masterKey, + }; + + try { + // Since here we don't have subscription, + // we can't get DatabaseAccountGetResults to retrieve disableLocalAuth property + // Will try to connect to the account and if it fails, we will assume local auth is disabled + const cosmosClient = getCosmosClient(parsedCS.documentEndpoint, [keyCred], account.isEmulator); + await cosmosClient.getDatabaseAccount(); + } catch { + context.telemetry.properties.receivedKeyCreds = 'false'; + localAuthDisabled = true; + } + } + + context.telemetry.properties.localAuthDisabled = localAuthDisabled.toString(); + if (localAuthDisabled) { + // Clean up keyCred if local auth is disabled + keyCred = undefined; + + const message = localize( + 'keyPermissionErrorMsg', + 'You do not have the required permissions to list auth keys for [{0}].\nFalling back to using Entra ID.\nYou can change the default authentication in the settings.', + account.name, + ); + const openSettingsItem = localize('openSettings', 'Open Settings'); + void vscode.window.showWarningMessage(message, ...[openSettingsItem]).then((item) => { + if (item === openSettingsItem) { + void vscode.commands.executeCommand( + 'workbench.action.openSettings', + 'azureDatabases.useCosmosOAuth', + ); + } + }); + } + } + + // OAuth is always enabled for Cosmos DB and will be used as a fallback if key auth is unavailable + // TODO: we need to preserve the tenantId in the connection string, otherwise we can't use OAuth for foreign tenants + const authCred = { type: 'auth', tenantId: undefined }; + return [keyCred, authCred].filter((cred) => cred !== undefined) as CosmosDBCredential[]; + }); + + return result ?? []; +} diff --git a/src/tree/docdb/DocumentDBAccountAttachedResourceItem.ts b/src/tree/docdb/DocumentDBAccountAttachedResourceItem.ts new file mode 100644 index 000000000..43315d949 --- /dev/null +++ b/src/tree/docdb/DocumentDBAccountAttachedResourceItem.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RestError, type CosmosClient, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getThemeAgnosticIconPath } from '../../constants'; +import { getCosmosAuthCredential, getCosmosClient } from '../../docdb/getCosmosClient'; +import { getSignedInPrincipalIdForAccountEndpoint } from '../../docdb/utils/azureSessionHelper'; +import { isRbacException, showRbacPermissionError } from '../../docdb/utils/rbacUtils'; +import { localize } from '../../utils/localize'; +import { rejectOnTimeout } from '../../utils/timeout'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { CosmosDBAccountResourceItemBase } from '../CosmosDBAccountResourceItemBase'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { getAccountInfo, type AccountInfo } from './AccountInfo'; + +export abstract class DocumentDBAccountAttachedResourceItem extends CosmosDBAccountResourceItemBase { + public declare readonly account: CosmosDBAttachedAccountModel; + + // To prevent the RBAC notification from showing up multiple times + protected hasShownRbacNotification: boolean = false; + + protected constructor(account: CosmosDBAttachedAccountModel, experience: Experience) { + super(account, experience); + } + + public async getChildren(): Promise { + const accountInfo = await getAccountInfo(this.account); + const cosmosClient = getCosmosClient(accountInfo.endpoint, accountInfo.credentials, false); + const databases = await this.getDatabases(accountInfo, cosmosClient); + + return this.getChildrenImpl(accountInfo, databases); + } + + public getTreeItem(): TreeItem { + return { ...super.getTreeItem(), iconPath: getThemeAgnosticIconPath('CosmosDBAccount.svg') }; + } + + public getConnectionString(): Promise { + return Promise.resolve(this.account.connectionString); + } + + protected async getDatabases( + accountInfo: AccountInfo, + cosmosClient: CosmosClient, + ): Promise<(DatabaseDefinition & Resource)[]> | never { + const getResources = async () => { + const result = await cosmosClient.databases.readAll().fetchAll(); + return result.resources; + }; + + try { + // Await is required here to ensure that the error is caught in the catch block + if (this.account.isEmulator) { + return await rejectOnTimeout( + 2000, + () => getResources(), + "Unable to reach emulator. Please ensure it is started and connected to the port specified by the 'cosmosDB.emulator.port' setting, then try again.", + ); + } else { + return await getResources(); + } + } catch (e) { + if (e instanceof Error) { + if (isRbacException(e) && !this.hasShownRbacNotification) { + this.hasShownRbacNotification = true; + const tenantId = getCosmosAuthCredential(accountInfo.credentials)?.tenantId; + const principalId = + (await getSignedInPrincipalIdForAccountEndpoint(accountInfo.endpoint, tenantId)) ?? ''; + void showRbacPermissionError(this.id, principalId); + } + if (this.account.isEmulator && e instanceof RestError && e.code === 'DEPTH_ZERO_SELF_SIGNED_CERT') { + const message = localize( + 'keyPermissionErrorMsg', + "The Cosmos DB emulator is using a self-signed certificate. To connect to the emulator, you must import the emulator's TLS/SSL certificate.", // or disable the 'http.proxyStrictSSL' setting but we don't recommend this for security reasons. + ); + const readMoreItem = localize('learnMore', 'Learn More'); + void vscode.window.showErrorMessage(message, ...[readMoreItem]).then((item) => { + if (item === readMoreItem) { + void vscode.env.openExternal( + vscode.Uri.parse( + 'https://learn.microsoft.com/en-us/azure/cosmos-db/how-to-develop-emulator?tabs=docker-linux%2Ccsharp&pivots=api-nosql#import-the-emulators-tlsssl-certificate', + ), + ); + } + }); + } + } + throw e; // rethrowing tells the resources extension to show the exception message in the tree + } + } + + protected abstract getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise; +} diff --git a/src/tree/docdb/DocumentDBAccountResourceItem.ts b/src/tree/docdb/DocumentDBAccountResourceItem.ts new file mode 100644 index 000000000..bf3bfffef --- /dev/null +++ b/src/tree/docdb/DocumentDBAccountResourceItem.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosClient, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getThemeAgnosticIconPath } from '../../constants'; +import { getCosmosAuthCredential, getCosmosClient } from '../../docdb/getCosmosClient'; +import { getSignedInPrincipalIdForAccountEndpoint } from '../../docdb/utils/azureSessionHelper'; +import { ensureRbacPermissionV2, isRbacException, showRbacPermissionError } from '../../docdb/utils/rbacUtils'; +import { type CosmosAccountModel } from '../CosmosAccountModel'; +import { CosmosDBAccountResourceItemBase } from '../CosmosDBAccountResourceItemBase'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { getAccountInfo, type AccountInfo } from './AccountInfo'; + +export abstract class DocumentDBAccountResourceItem extends CosmosDBAccountResourceItemBase { + public declare readonly account: CosmosAccountModel; + + // To prevent the RBAC notification from showing up multiple times + protected hasShownRbacNotification: boolean = false; + + protected constructor(account: CosmosAccountModel, experience: Experience) { + super(account, experience); + } + + public async getChildren(): Promise { + const accountInfo = await getAccountInfo(this.account); + const cosmosClient = getCosmosClient(accountInfo.endpoint, accountInfo.credentials, false); + const databases = await this.getDatabases(accountInfo, cosmosClient); + + return this.getChildrenImpl(accountInfo, databases); + } + + public getTreeItem(): TreeItem { + return { ...super.getTreeItem(), iconPath: getThemeAgnosticIconPath('CosmosDBAccount.svg') }; + } + + public async getConnectionString(): Promise { + const accountInfo = await getAccountInfo(this.account); + const keyCred = accountInfo.credentials.find((cred) => cred.type === 'key'); + + // supporting only one known success path + if (keyCred) { + return `AccountEndpoint=${accountInfo.endpoint};AccountKey=${keyCred.key}`; + } else { + return `AccountEndpoint=${accountInfo.endpoint}`; + } + } + + protected async getDatabases( + accountInfo: AccountInfo, + cosmosClient: CosmosClient, + ): Promise<(DatabaseDefinition & Resource)[]> | never { + const getResources = async () => { + const result = await cosmosClient.databases.readAll().fetchAll(); + return result.resources; + }; + + try { + // Await is required here to ensure that the error is caught in the catch block + return await getResources(); + } catch (e) { + if (e instanceof Error && isRbacException(e) && !this.hasShownRbacNotification) { + this.hasShownRbacNotification = true; + + const tenantId = getCosmosAuthCredential(accountInfo.credentials)?.tenantId; + const principalId = + (await getSignedInPrincipalIdForAccountEndpoint(accountInfo.endpoint, tenantId)) ?? ''; + // check if the principal ID matches the one that is signed in, + // otherwise this might be a security problem, hence show the error message + if ( + e.message.includes(`[${principalId}]`) && + (await ensureRbacPermissionV2(this.id, this.account.subscription, principalId)) + ) { + return getResources(); + } else { + void showRbacPermissionError(this.id, principalId); + } + } + throw e; // rethrowing tells the resources extension to show the exception message in the tree + } + } + + protected abstract getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise; +} diff --git a/src/tree/docdb/DocumentDBContainerResourceItem.ts b/src/tree/docdb/DocumentDBContainerResourceItem.ts new file mode 100644 index 000000000..7714a786f --- /dev/null +++ b/src/tree/docdb/DocumentDBContainerResourceItem.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBContainerModel } from './models/DocumentDBContainerModel'; + +export abstract class DocumentDBContainerResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.container'; + + protected constructor( + public readonly model: DocumentDBContainerModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + async getChildren(): Promise { + const triggers = await this.getChildrenTriggersImpl(); + const storedProcedures = await this.getChildrenStoredProceduresImpl(); + const items = await this.getChildrenItemsImpl(); + + return [items, storedProcedures, triggers].filter((r) => r !== undefined); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('files'), + label: this.model.container.id, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected abstract getChildrenTriggersImpl(): Promise; + protected abstract getChildrenStoredProceduresImpl(): Promise; + protected abstract getChildrenItemsImpl(): Promise; +} diff --git a/src/tree/docdb/DocumentDBDatabaseResourceItem.ts b/src/tree/docdb/DocumentDBDatabaseResourceItem.ts new file mode 100644 index 000000000..c199ab92b --- /dev/null +++ b/src/tree/docdb/DocumentDBDatabaseResourceItem.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type CosmosClient, type Resource } from '@azure/cosmos'; +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBDatabaseModel } from './models/DocumentDBDatabaseModel'; + +export abstract class DocumentDBDatabaseResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.database'; + + protected constructor( + public readonly model: DocumentDBDatabaseModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + async getChildren(): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const containers = await this.getContainers(cosmosClient); + + return this.getChildrenImpl(containers); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('database'), + label: this.model.database.id, + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected async getContainers(cosmosClient: CosmosClient): Promise<(ContainerDefinition & Resource)[]> { + const result = await cosmosClient.database(this.model.database.id).containers.readAll().fetchAll(); + return result.resources; + } + + protected abstract getChildrenImpl(containers: (ContainerDefinition & Resource)[]): Promise; +} diff --git a/src/tree/docdb/DocumentDBItemResourceItem.ts b/src/tree/docdb/DocumentDBItemResourceItem.ts new file mode 100644 index 000000000..ec7b4ba20 --- /dev/null +++ b/src/tree/docdb/DocumentDBItemResourceItem.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { extractPartitionKey, getDocumentId } from '../../utils/document'; +import { getDocumentTreeItemLabel } from '../../utils/vscodeUtils'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBItemModel } from './models/DocumentDBItemModel'; + +/** + * Sanitize the id of a DocDB tree item so it can be safely used in a query string. + * Learn more at: https://github.com/ljharb/qs#rfc-3986-and-rfc-1738-space-encoding + */ +export function sanitizeId(id: string): string { + return id.replace(/\+/g, ' '); +} + +export abstract class DocumentDBItemResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.document'; + + protected constructor( + public readonly model: DocumentDBItemModel, + public readonly experience: Experience, + ) { + const uniqueId = this.generateUniqueId(this.model); + this.id = sanitizeId( + `${model.accountInfo.id}/${model.database.id}/${model.container.id}/documents/${uniqueId}`, + ); + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('file'), + label: getDocumentTreeItemLabel(this.model.item), + tooltip: new vscode.MarkdownString( + `${this.generateDocumentTooltip()}\n${this.generatePartitionKeyTooltip()}`, + ), + collapsibleState: vscode.TreeItemCollapsibleState.None, + command: { + title: 'Open Document', + command: 'cosmosDB.openDocument', + arguments: [this], + }, + }; + } + + protected generateDocumentTooltip(): string { + return ( + '### Document\n' + + '---\n' + + `${this.model.item.id ? `- ID: **${this.model.item.id}**\n` : ''}` + + `${this.model.item._id ? `- ID (_id): **${this.model.item._id}**\n` : ''}` + + `${this.model.item._rid ? `- RID: **${this.model.item._rid}**\n` : ''}` + + `${this.model.item._self ? `- Self Link: **${this.model.item._self}**\n` : ''}` + + `${this.model.item._etag ? `- ETag: **${this.model.item._etag}**\n` : ''}` + + `${this.model.item._ts ? `- Timestamp: **${this.model.item._ts}**\n` : ''}` + ); + } + + protected generatePartitionKeyTooltip(): string { + if (!this.model.container.partitionKey || this.model.container.partitionKey.paths.length === 0) { + return ''; + } + + const partitionKeyPaths = this.model.container.partitionKey.paths.join(', '); + const partitionKeyValues = this.generatePartitionKeyValue(this.model); + + return ( + '### Partition Key\n' + + '---\n' + + `- Paths: **${partitionKeyPaths}**\n` + + `- Values: **${partitionKeyValues}**\n` + ); + } + + /** + * Warning: This method is used to generate a unique ID for the document tree item. + * It is not used to generate the actual document ID. + */ + protected generateUniqueId(model: DocumentDBItemModel): string { + const documentId = getDocumentId(model.item, model.container.partitionKey); + const id = documentId?.id; + const rid = documentId?._rid; + const partitionKeyValues = this.generatePartitionKeyValue(model); + + return `${id || ''}|${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 new file mode 100644 index 000000000..584f8f53b --- /dev/null +++ b/src/tree/docdb/DocumentDBItemsResourceItem.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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 { createContextValue, createGenericElement, type IActionContext } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { getBatchSizeSetting } from '../../utils/workspacUtils'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBItemsModel } from './models/DocumentDBItemsModel'; + +export abstract class DocumentDBItemsResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.documents'; + + protected hasMoreChildren: boolean = true; + protected batchSize: number; + + protected constructor( + public readonly model: DocumentDBItemsModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/documents`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + this.batchSize = getBatchSizeSetting(); + } + + public async getChildren(): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const iterator = this.getIterator(cosmosClient, { maxItemCount: this.batchSize }); + const items = await this.getItems(iterator); + + const result = await this.getChildrenImpl(items); + + if (this.hasMoreChildren) { + result.push( + createGenericElement({ + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('refresh'), + label: 'Load more\u2026', + id: `${this.id}/loadMore`, + commandId: 'cosmosDB.loadMore', + commandArgs: [ + this.id, + (context: IActionContext) => { + context.telemetry.properties.experience = this.experience.api; + context.telemetry.properties.parentContext = this.contextValue; + + this.batchSize *= 2; + }, + ], + }) as CosmosDBTreeElement, + ); + } + + return result; + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('files'), + label: 'Documents', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected getIterator(cosmosClient: CosmosClient, feedOptions: FeedOptions): QueryIterator { + return cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .items.readAll(feedOptions); + } + + protected async getItems(iterator: QueryIterator): Promise { + const result = await iterator.fetchNext(); + const items = result.resources; + this.hasMoreChildren = result.hasMoreResults; + + return items; + } + + protected abstract getChildrenImpl(items: ItemDefinition[]): Promise; +} diff --git a/src/tree/docdb/DocumentDBStoredProcedureResourceItem.ts b/src/tree/docdb/DocumentDBStoredProcedureResourceItem.ts new file mode 100644 index 000000000..ce405ca6f --- /dev/null +++ b/src/tree/docdb/DocumentDBStoredProcedureResourceItem.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBStoredProcedureModel } from './models/DocumentDBStoredProcedureModel'; + +export abstract class DocumentDBStoredProcedureResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.storedProcedure'; + + protected constructor( + public readonly model: DocumentDBStoredProcedureModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/storedProcedures/${model.procedure.id}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('server-process'), + label: this.model.procedure.id, + collapsibleState: vscode.TreeItemCollapsibleState.None, + command: { + title: 'Open Stored Procedure', + command: 'cosmosDB.openStoredProcedure', + arguments: [this], + }, + }; + } +} diff --git a/src/tree/docdb/DocumentDBStoredProceduresResourceItem.ts b/src/tree/docdb/DocumentDBStoredProceduresResourceItem.ts new file mode 100644 index 000000000..36d7d93c8 --- /dev/null +++ b/src/tree/docdb/DocumentDBStoredProceduresResourceItem.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosClient, type Resource, type StoredProcedureDefinition } from '@azure/cosmos'; +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBStoredProceduresModel } from './models/DocumentDBStoredProceduresModel'; + +export abstract class DocumentDBStoredProceduresResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.storedProcedures'; + + protected constructor( + public readonly model: DocumentDBStoredProceduresModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/storedProcedures`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + public async getChildren(): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const storedProcedures = await this.getStoredProcedures(cosmosClient); + + return this.getChildrenImpl(storedProcedures); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('server-process'), + label: 'StoredProcedures', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected async getStoredProcedures(cosmosClient: CosmosClient): Promise<(StoredProcedureDefinition & Resource)[]> { + const result = await cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .scripts.storedProcedures.readAll() + .fetchAll(); + + return result.resources; + } + + protected abstract getChildrenImpl( + storedProcedures: (StoredProcedureDefinition & Resource)[], + ): Promise; +} diff --git a/src/tree/docdb/DocumentDBTriggerResourceItem.ts b/src/tree/docdb/DocumentDBTriggerResourceItem.ts new file mode 100644 index 000000000..fd6959a8f --- /dev/null +++ b/src/tree/docdb/DocumentDBTriggerResourceItem.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBTriggerModel } from './models/DocumentDBTriggerModel'; + +export abstract class DocumentDBTriggerResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.trigger'; + + protected constructor( + public readonly model: DocumentDBTriggerModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/triggers/${model.trigger.id}`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('zap'), + label: this.model.trigger.id, + collapsibleState: vscode.TreeItemCollapsibleState.None, + command: { + title: 'Open Trigger', + command: 'cosmosDB.openTrigger', + arguments: [this], + }, + }; + } +} diff --git a/src/tree/docdb/DocumentDBTriggersResourceItem.ts b/src/tree/docdb/DocumentDBTriggersResourceItem.ts new file mode 100644 index 000000000..e3dcd3351 --- /dev/null +++ b/src/tree/docdb/DocumentDBTriggersResourceItem.ts @@ -0,0 +1,58 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CosmosClient, type Resource, type TriggerDefinition } from '@azure/cosmos'; +import { createContextValue } from '@microsoft/vscode-azext-utils'; +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { getCosmosClient } from '../../docdb/getCosmosClient'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type TreeElementWithContextValue } from '../TreeElementWithContextValue'; +import { type TreeElementWithExperience } from '../TreeElementWithExperience'; +import { type DocumentDBTriggersModel } from './models/DocumentDBTriggersModel'; + +export abstract class DocumentDBTriggersResourceItem + implements CosmosDBTreeElement, TreeElementWithExperience, TreeElementWithContextValue +{ + public readonly id: string; + public readonly contextValue: string = 'treeItem.triggers'; + + protected constructor( + public readonly model: DocumentDBTriggersModel, + public readonly experience: Experience, + ) { + this.id = `${model.accountInfo.id}/${model.database.id}/${model.container.id}/triggers`; + this.contextValue = createContextValue([this.contextValue, `experience.${this.experience.api}`]); + } + + public async getChildren(): Promise { + const { endpoint, credentials, isEmulator } = this.model.accountInfo; + const cosmosClient = getCosmosClient(endpoint, credentials, isEmulator); + const triggers = await this.getTriggers(cosmosClient); + + return this.getChildrenImpl(triggers); + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('zap'), + label: 'Triggers', + collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, + }; + } + + protected async getTriggers(cosmosClient: CosmosClient): Promise<(TriggerDefinition & Resource)[]> { + const result = await cosmosClient + .database(this.model.database.id) + .container(this.model.container.id) + .scripts.triggers.readAll() + .fetchAll(); + return result.resources; + } + + protected abstract getChildrenImpl(triggers: (TriggerDefinition & Resource)[]): Promise; +} diff --git a/src/mongo/tree/IMongoTreeRoot.ts b/src/tree/docdb/models/DocumentDBAccountModel.ts similarity index 73% rename from src/mongo/tree/IMongoTreeRoot.ts rename to src/tree/docdb/models/DocumentDBAccountModel.ts index ffb9eb0c8..4a1e9eda8 100644 --- a/src/mongo/tree/IMongoTreeRoot.ts +++ b/src/tree/docdb/models/DocumentDBAccountModel.ts @@ -3,6 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export interface IMongoTreeRoot { - isEmulator: boolean | undefined; -} +import { type CosmosAccountModel } from '../../CosmosAccountModel'; + +export type DocumentDBAccountModel = CosmosAccountModel; diff --git a/src/tree/docdb/models/DocumentDBContainerModel.ts b/src/tree/docdb/models/DocumentDBContainerModel.ts new file mode 100644 index 000000000..626801afb --- /dev/null +++ b/src/tree/docdb/models/DocumentDBContainerModel.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBContainerModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBDatabaseModel.ts b/src/tree/docdb/models/DocumentDBDatabaseModel.ts new file mode 100644 index 000000000..0c442c02b --- /dev/null +++ b/src/tree/docdb/models/DocumentDBDatabaseModel.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBDatabaseModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBItemModel.ts b/src/tree/docdb/models/DocumentDBItemModel.ts new file mode 100644 index 000000000..cd7c4fb8e --- /dev/null +++ b/src/tree/docdb/models/DocumentDBItemModel.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type ItemDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBItemModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; + item: ItemDefinition; +}; diff --git a/src/tree/docdb/models/DocumentDBItemsModel.ts b/src/tree/docdb/models/DocumentDBItemsModel.ts new file mode 100644 index 000000000..cb8150427 --- /dev/null +++ b/src/tree/docdb/models/DocumentDBItemsModel.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBItemsModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBStoredProcedureModel.ts b/src/tree/docdb/models/DocumentDBStoredProcedureModel.ts new file mode 100644 index 000000000..1b4e43ef2 --- /dev/null +++ b/src/tree/docdb/models/DocumentDBStoredProcedureModel.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type ContainerDefinition, + type DatabaseDefinition, + type Resource, + type StoredProcedureDefinition, +} from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBStoredProcedureModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; + procedure: StoredProcedureDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBStoredProceduresModel.ts b/src/tree/docdb/models/DocumentDBStoredProceduresModel.ts new file mode 100644 index 000000000..0b541fac3 --- /dev/null +++ b/src/tree/docdb/models/DocumentDBStoredProceduresModel.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBStoredProceduresModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBTriggerModel.ts b/src/tree/docdb/models/DocumentDBTriggerModel.ts new file mode 100644 index 000000000..a555c9fec --- /dev/null +++ b/src/tree/docdb/models/DocumentDBTriggerModel.ts @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + type ContainerDefinition, + type DatabaseDefinition, + type Resource, + type TriggerDefinition, +} from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBTriggerModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; + trigger: TriggerDefinition & Resource; +}; diff --git a/src/tree/docdb/models/DocumentDBTriggersModel.ts b/src/tree/docdb/models/DocumentDBTriggersModel.ts new file mode 100644 index 000000000..7e42fb09b --- /dev/null +++ b/src/tree/docdb/models/DocumentDBTriggersModel.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type AccountInfo } from '../AccountInfo'; + +export type DocumentDBTriggersModel = { + accountInfo: AccountInfo; + database: DatabaseDefinition & Resource; + container: ContainerDefinition & Resource; +}; diff --git a/src/tree/graph/GraphAccountAttachedResourceItem.ts b/src/tree/graph/GraphAccountAttachedResourceItem.ts new file mode 100644 index 000000000..17237bd0a --- /dev/null +++ b/src/tree/graph/GraphAccountAttachedResourceItem.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from '../docdb/AccountInfo'; +import { DocumentDBAccountAttachedResourceItem } from '../docdb/DocumentDBAccountAttachedResourceItem'; +import { GraphDatabaseResourceItem } from './GraphDatabaseResourceItem'; + +export class GraphAccountAttachedResourceItem extends DocumentDBAccountAttachedResourceItem { + constructor(account: CosmosDBAttachedAccountModel, experience: Experience) { + super(account, experience); + } + + protected getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise { + return Promise.resolve( + databases.map((db) => { + return new GraphDatabaseResourceItem( + { + accountInfo: accountInfo, + database: db, + }, + this.experience, + ); + }), + ); + } +} diff --git a/src/tree/graph/GraphAccountResourceItem.ts b/src/tree/graph/GraphAccountResourceItem.ts new file mode 100644 index 000000000..a985015c8 --- /dev/null +++ b/src/tree/graph/GraphAccountResourceItem.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from '../docdb/AccountInfo'; +import { DocumentDBAccountResourceItem } from '../docdb/DocumentDBAccountResourceItem'; +import { type DocumentDBAccountModel } from '../docdb/models/DocumentDBAccountModel'; +import { GraphDatabaseResourceItem } from './GraphDatabaseResourceItem'; + +export class GraphAccountResourceItem extends DocumentDBAccountResourceItem { + constructor(account: DocumentDBAccountModel, experience: Experience) { + super(account, experience); + } + + protected getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise { + return Promise.resolve( + databases.map((db) => { + return new GraphDatabaseResourceItem( + { + accountInfo: accountInfo, + database: db, + }, + this.experience, + ); + }), + ); + } +} diff --git a/src/tree/graph/GraphContainerResourceItem.ts b/src/tree/graph/GraphContainerResourceItem.ts new file mode 100644 index 000000000..3f225aaf8 --- /dev/null +++ b/src/tree/graph/GraphContainerResourceItem.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBContainerResourceItem } from '../docdb/DocumentDBContainerResourceItem'; +import { type DocumentDBContainerModel } from '../docdb/models/DocumentDBContainerModel'; +import { GraphItemsResourceItem } from './GraphItemsResourceItem'; +import { GraphStoredProceduresResourceItem } from './GraphStoredProceduresResourceItem'; + +export class GraphContainerResourceItem extends DocumentDBContainerResourceItem { + constructor(model: DocumentDBContainerModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenTriggersImpl(): Promise { + return Promise.resolve(undefined); + } + + protected getChildrenStoredProceduresImpl(): Promise { + return Promise.resolve(new GraphStoredProceduresResourceItem({ ...this.model }, this.experience)); + } + + protected getChildrenItemsImpl(): Promise { + return Promise.resolve(new GraphItemsResourceItem({ ...this.model }, this.experience)); + } +} diff --git a/src/tree/graph/GraphDatabaseResourceItem.ts b/src/tree/graph/GraphDatabaseResourceItem.ts new file mode 100644 index 000000000..fa0b658f0 --- /dev/null +++ b/src/tree/graph/GraphDatabaseResourceItem.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBDatabaseResourceItem } from '../docdb/DocumentDBDatabaseResourceItem'; +import { type DocumentDBDatabaseModel } from '../docdb/models/DocumentDBDatabaseModel'; +import { GraphContainerResourceItem } from './GraphContainerResourceItem'; + +export class GraphDatabaseResourceItem extends DocumentDBDatabaseResourceItem { + constructor(model: DocumentDBDatabaseModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(containers: (ContainerDefinition & Resource)[]): Promise { + return Promise.resolve( + containers.map( + (container) => new GraphContainerResourceItem({ ...this.model, container }, this.experience), + ), + ); + } +} diff --git a/src/tree/graph/GraphItemsResourceItem.ts b/src/tree/graph/GraphItemsResourceItem.ts new file mode 100644 index 000000000..8aab3f4ce --- /dev/null +++ b/src/tree/graph/GraphItemsResourceItem.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import vscode, { type TreeItem } from 'vscode'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBItemsResourceItem } from '../docdb/DocumentDBItemsResourceItem'; +import { type DocumentDBItemsModel } from '../docdb/models/DocumentDBItemsModel'; + +export class GraphItemsResourceItem extends DocumentDBItemsResourceItem { + constructor(model: DocumentDBItemsModel, experience: Experience) { + super(model, experience); + } + + async getChildren(): Promise { + return []; + } + + getTreeItem(): TreeItem { + return { + id: this.id, + contextValue: this.contextValue, + iconPath: new vscode.ThemeIcon('file'), + label: 'Graph', + collapsibleState: vscode.TreeItemCollapsibleState.None, + command: { + title: 'Open Graph Explorer', + command: 'cosmosDB.openGraphExplorer', + }, + }; + } + + protected getChildrenImpl(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/tree/graph/GraphStoredProcedureResourceItem.ts b/src/tree/graph/GraphStoredProcedureResourceItem.ts new file mode 100644 index 000000000..0be71766e --- /dev/null +++ b/src/tree/graph/GraphStoredProcedureResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBStoredProcedureResourceItem } from '../docdb/DocumentDBStoredProcedureResourceItem'; +import { type DocumentDBStoredProcedureModel } from '../docdb/models/DocumentDBStoredProcedureModel'; + +export class GraphStoredProcedureResourceItem extends DocumentDBStoredProcedureResourceItem { + constructor(model: DocumentDBStoredProcedureModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/graph/GraphStoredProceduresResourceItem.ts b/src/tree/graph/GraphStoredProceduresResourceItem.ts new file mode 100644 index 000000000..a98d522d2 --- /dev/null +++ b/src/tree/graph/GraphStoredProceduresResourceItem.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Resource, type StoredProcedureDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBStoredProceduresResourceItem } from '../docdb/DocumentDBStoredProceduresResourceItem'; +import { type DocumentDBStoredProceduresModel } from '../docdb/models/DocumentDBStoredProceduresModel'; +import { GraphStoredProcedureResourceItem } from './GraphStoredProcedureResourceItem'; + +export class GraphStoredProceduresResourceItem extends DocumentDBStoredProceduresResourceItem { + constructor(model: DocumentDBStoredProceduresModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl( + storedProcedures: (StoredProcedureDefinition & Resource)[], + ): Promise { + return Promise.resolve( + storedProcedures.map( + (procedure) => new GraphStoredProcedureResourceItem({ ...this.model, procedure }, this.experience), + ), + ); + } +} diff --git a/src/mongo/commands/launchMongoShell.ts b/src/tree/mongo/MongoAccountModel.ts similarity index 62% rename from src/mongo/commands/launchMongoShell.ts rename to src/tree/mongo/MongoAccountModel.ts index 68113676d..8451d85ea 100644 --- a/src/mongo/commands/launchMongoShell.ts +++ b/src/tree/mongo/MongoAccountModel.ts @@ -3,10 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as vscode from 'vscode'; +import { type CosmosAccountModel } from '../CosmosAccountModel'; -export function launchMongoShell(): void { - const terminal: vscode.Terminal = vscode.window.createTerminal('Mongo Shell'); - terminal.sendText(`mongo`); - terminal.show(); -} +export type MongoAccountModel = CosmosAccountModel & { + connectionString?: string; +}; diff --git a/src/tree/mongo/MongoAccountResourceItem.ts b/src/tree/mongo/MongoAccountResourceItem.ts new file mode 100644 index 000000000..99263fadf --- /dev/null +++ b/src/tree/mongo/MongoAccountResourceItem.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseAccountGetResults } from '@azure/arm-cosmosdb'; +import { callWithTelemetryAndErrorHandling, type IActionContext, nonNullProp } from '@microsoft/vscode-azext-utils'; +import { type AzureSubscription } from '@microsoft/vscode-azureresources-api'; +import ConnectionString from 'mongodb-connection-string-url'; +import { type Experience } from '../../AzureDBExperiences'; +import { ext } from '../../extensionVariables'; +import { CredentialCache } from '../../mongoClusters/CredentialCache'; +import { type DatabaseItemModel, MongoClustersClient } from '../../mongoClusters/MongoClustersClient'; +import { DatabaseItem } from '../../mongoClusters/tree/DatabaseItem'; +import { type MongoClusterModel } from '../../mongoClusters/tree/MongoClusterModel'; +import { createCosmosDBManagementClient } from '../../utils/azureClients'; +import { CosmosDBAccountResourceItemBase } from '../CosmosDBAccountResourceItemBase'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type MongoAccountModel } from './MongoAccountModel'; + +/** + * This implementation relies on information from the MongoAccountModel, i.e. + * will only behave as expected when used in the context of an Azure Subscription. + */ + +// TODO: currently MongoAccountResourceItem does not reuse MongoClusterItemBase, this will be refactored after the v1 to v2 tree migration + +export class MongoAccountResourceItem extends CosmosDBAccountResourceItemBase { + public declare readonly account: MongoAccountModel; + public readonly contextValue: string = 'treeItem.mongoCluster'; // TODO: this is a bug and overwrites the contextValue from the base class, fix this. + + constructor( + account: MongoAccountModel, + experience: Experience, + readonly databaseAccount?: DatabaseAccountGetResults, // TODO: exploring during v1->v2 migration + readonly isEmulator?: boolean, // TODO: exploring during v1->v2 migration + ) { + super(account, experience); + } + + async getConnectionString(): Promise { + return callWithTelemetryAndErrorHandling( + 'cosmosDB.mongo.getConnectionString', + async (context: IActionContext) => { + // Create a client to interact with the MongoDB vCore management API and read the cluster details + const managementClient = await createCosmosDBManagementClient( + context, + this.account.subscription as AzureSubscription, + ); + const connectionStringsInfo = await managementClient.databaseAccounts.listConnectionStrings( + this.account.resourceGroup as string, + this.account.name, + ); + + const connectionString: URL = new URL( + nonNullProp(nonNullProp(connectionStringsInfo, 'connectionStrings')[0], 'connectionString'), + ); + + // for any Mongo connectionString, append this query param because the Cosmos Mongo API v3.6 doesn't support retrywrites + // but the newer node.js drivers started breaking this + const searchParam: string = 'retrywrites'; + if (!connectionString.searchParams.has(searchParam)) { + connectionString.searchParams.set(searchParam, 'false'); + } + + const cString = connectionString.toString(); + context.valuesToMask.push(cString); + + return cString; + }, + ); + } + + async getChildren(): Promise { + ext.outputChannel.appendLine(`Cosmos DB for MongoDB (RU): Loading details for "${this.account.name}"`); + + let mongoClient: MongoClustersClient | null = null; + + // Check if credentials are cached, and return the cached client if available + if (CredentialCache.hasCredentials(this.id)) { + ext.outputChannel.appendLine( + `${this.experience.longName}: Reusing active connection details for "${this.account.name}".`, + ); + mongoClient = await MongoClustersClient.getClient(this.id); + } else { + ext.outputChannel.appendLine( + `${this.experience.longName}: Activating connection for "${this.account.name}"`, + ); + + if (this.account.subscription) { + this.account.connectionString = await this.getConnectionString(); + } + + if (!this.account.connectionString) { + throw new Error('Connection string not found.'); + } + + const cString = new ConnectionString(this.account.connectionString); + + // // Azure MongoDB accounts need to have the name passed in for private endpoints + // mongoClient = await connectToMongoClient( + // this.account.connectionString, + // this.databaseAccount ? nonNullProp(this.databaseAccount, 'name') : appendExtensionUserAgent(), + // ); + + //TODO: simplify the api for CrednetialCache to accept full connection strings with credentials + const username: string | undefined = cString.username; + const password: string | undefined = cString.password; + CredentialCache.setCredentials(this.id, cString.toString(), username, password); + + mongoClient = await MongoClustersClient.getClient(this.id).catch(async (error) => { + console.error(error); + // If connection fails, remove cached credentials, as they might be invalid + await MongoClustersClient.deleteClient(this.id); + CredentialCache.deleteCredentials(this.id); + return null; + }); + } + + if (!mongoClient) { + throw new Error('Failed to connect.'); + } + + // TODO: add support for single databases via connection string. move it to monogoclustersclient + // + // const databaseInConnectionString = getDatabaseNameFromConnectionString(this.account.connectionString); + // if (databaseInConnectionString && !this.isEmulator) { + // // emulator violates the connection string format + // // If the database is in the connection string, that's all we connect to (we might not even have permissions to list databases) + // databases = [ + // { + // name: databaseInConnectionString, + // empty: false, + // }, + // ]; + // } + + const databases = await mongoClient.listDatabases(); + + return databases.map((database) => { + const clusterInfo = { ...this.account, dbExperience: this.experience } as MongoClusterModel; + + // eslint-disable-next-line no-unused-vars + const databaseInfo: DatabaseItemModel = { + name: database.name, + empty: database.empty, + }; + + return new DatabaseItem(clusterInfo, databaseInfo); + }); + + // } catch (error) { + // const message = parseError(error).message; + // if (this.isEmulator && message.includes('ECONNREFUSED')) { + // // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + // error.message = `Unable to reach emulator. See ${Links.LocalConnectionDebuggingTips} for debugging tips.\n${message}`; + // } + // throw error; + // } finally { + // if (mongoClient) { + // void mongoClient.close(); + // } + // } + } +} diff --git a/src/tree/nosql/NoSqlAccountAttachedResourceItem.ts b/src/tree/nosql/NoSqlAccountAttachedResourceItem.ts new file mode 100644 index 000000000..749561ef5 --- /dev/null +++ b/src/tree/nosql/NoSqlAccountAttachedResourceItem.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from '../docdb/AccountInfo'; +import { DocumentDBAccountAttachedResourceItem } from '../docdb/DocumentDBAccountAttachedResourceItem'; +import { NoSqlDatabaseResourceItem } from './NoSqlDatabaseResourceItem'; + +export class NoSqlAccountAttachedResourceItem extends DocumentDBAccountAttachedResourceItem { + constructor(account: CosmosDBAttachedAccountModel, experience: Experience) { + super(account, experience); + } + + protected getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise { + return Promise.resolve( + databases.map((db) => { + return new NoSqlDatabaseResourceItem( + { + accountInfo: accountInfo, + database: db, + }, + this.experience, + ); + }), + ); + } +} diff --git a/src/tree/nosql/NoSqlAccountResourceItem.ts b/src/tree/nosql/NoSqlAccountResourceItem.ts new file mode 100644 index 000000000..8860dd0c0 --- /dev/null +++ b/src/tree/nosql/NoSqlAccountResourceItem.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type DatabaseDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { type AccountInfo } from '../docdb/AccountInfo'; +import { DocumentDBAccountResourceItem } from '../docdb/DocumentDBAccountResourceItem'; +import { type DocumentDBAccountModel } from '../docdb/models/DocumentDBAccountModel'; +import { NoSqlDatabaseResourceItem } from './NoSqlDatabaseResourceItem'; + +export class NoSqlAccountResourceItem extends DocumentDBAccountResourceItem { + constructor(account: DocumentDBAccountModel, experience: Experience) { + super(account, experience); + } + + protected getChildrenImpl( + accountInfo: AccountInfo, + databases: (DatabaseDefinition & Resource)[], + ): Promise { + return Promise.resolve( + databases.map((db) => { + return new NoSqlDatabaseResourceItem( + { + accountInfo: accountInfo, + database: db, + }, + this.experience, + ); + }), + ); + } +} diff --git a/src/tree/nosql/NoSqlContainerResourceItem.ts b/src/tree/nosql/NoSqlContainerResourceItem.ts new file mode 100644 index 000000000..46078a585 --- /dev/null +++ b/src/tree/nosql/NoSqlContainerResourceItem.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBContainerResourceItem } from '../docdb/DocumentDBContainerResourceItem'; +import { type DocumentDBContainerModel } from '../docdb/models/DocumentDBContainerModel'; +import { NoSqlItemsResourceItem } from './NoSqlItemsResourceItem'; +import { NoSqlStoredProceduresResourceItem } from './NoSqlStoredProceduresResourceItem'; +import { NoSqlTriggersResourceItem } from './NoSqlTriggersResourceItem'; + +export class NoSqlContainerResourceItem extends DocumentDBContainerResourceItem { + constructor(model: DocumentDBContainerModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenTriggersImpl(): Promise { + return Promise.resolve(new NoSqlTriggersResourceItem({ ...this.model }, this.experience)); + } + + protected getChildrenStoredProceduresImpl(): Promise { + return Promise.resolve(new NoSqlStoredProceduresResourceItem({ ...this.model }, this.experience)); + } + + protected getChildrenItemsImpl(): Promise { + return Promise.resolve(new NoSqlItemsResourceItem({ ...this.model }, this.experience)); + } +} diff --git a/src/tree/nosql/NoSqlDatabaseResourceItem.ts b/src/tree/nosql/NoSqlDatabaseResourceItem.ts new file mode 100644 index 000000000..8dbe327e6 --- /dev/null +++ b/src/tree/nosql/NoSqlDatabaseResourceItem.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ContainerDefinition, type Resource } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBDatabaseResourceItem } from '../docdb/DocumentDBDatabaseResourceItem'; +import { type DocumentDBDatabaseModel } from '../docdb/models/DocumentDBDatabaseModel'; +import { NoSqlContainerResourceItem } from './NoSqlContainerResourceItem'; + +export class NoSqlDatabaseResourceItem extends DocumentDBDatabaseResourceItem { + constructor(model: DocumentDBDatabaseModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(containers: (ContainerDefinition & Resource)[]): Promise { + return Promise.resolve( + containers.map( + (container) => new NoSqlContainerResourceItem({ ...this.model, container }, this.experience), + ), + ); + } +} diff --git a/src/tree/nosql/NoSqlItemResourceItem.ts b/src/tree/nosql/NoSqlItemResourceItem.ts new file mode 100644 index 000000000..9620a738f --- /dev/null +++ b/src/tree/nosql/NoSqlItemResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBItemResourceItem } from '../docdb/DocumentDBItemResourceItem'; +import { type DocumentDBItemModel } from '../docdb/models/DocumentDBItemModel'; + +export class NoSqlItemResourceItem extends DocumentDBItemResourceItem { + constructor(model: DocumentDBItemModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/nosql/NoSqlItemsResourceItem.ts b/src/tree/nosql/NoSqlItemsResourceItem.ts new file mode 100644 index 000000000..42da5ab92 --- /dev/null +++ b/src/tree/nosql/NoSqlItemsResourceItem.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ItemDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBItemsResourceItem } from '../docdb/DocumentDBItemsResourceItem'; +import { type DocumentDBItemsModel } from '../docdb/models/DocumentDBItemsModel'; +import { NoSqlItemResourceItem } from './NoSqlItemResourceItem'; + +export class NoSqlItemsResourceItem extends DocumentDBItemsResourceItem { + constructor(model: DocumentDBItemsModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(items: ItemDefinition[]): Promise { + return Promise.resolve( + items.map((item) => new NoSqlItemResourceItem({ ...this.model, item }, this.experience)), + ); + } +} diff --git a/src/tree/nosql/NoSqlStoredProcedureResourceItem.ts b/src/tree/nosql/NoSqlStoredProcedureResourceItem.ts new file mode 100644 index 000000000..9ef0874af --- /dev/null +++ b/src/tree/nosql/NoSqlStoredProcedureResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBStoredProcedureResourceItem } from '../docdb/DocumentDBStoredProcedureResourceItem'; +import { type DocumentDBStoredProcedureModel } from '../docdb/models/DocumentDBStoredProcedureModel'; + +export class NoSqlStoredProcedureResourceItem extends DocumentDBStoredProcedureResourceItem { + constructor(model: DocumentDBStoredProcedureModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/nosql/NoSqlStoredProceduresResourceItem.ts b/src/tree/nosql/NoSqlStoredProceduresResourceItem.ts new file mode 100644 index 000000000..760926298 --- /dev/null +++ b/src/tree/nosql/NoSqlStoredProceduresResourceItem.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Resource, type StoredProcedureDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBStoredProceduresResourceItem } from '../docdb/DocumentDBStoredProceduresResourceItem'; +import { type DocumentDBStoredProceduresModel } from '../docdb/models/DocumentDBStoredProceduresModel'; +import { NoSqlStoredProcedureResourceItem } from './NoSqlStoredProcedureResourceItem'; + +export class NoSqlStoredProceduresResourceItem extends DocumentDBStoredProceduresResourceItem { + constructor(model: DocumentDBStoredProceduresModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl( + storedProcedures: (StoredProcedureDefinition & Resource)[], + ): Promise { + return Promise.resolve( + storedProcedures.map( + (procedure) => new NoSqlStoredProcedureResourceItem({ ...this.model, procedure }, this.experience), + ), + ); + } +} diff --git a/src/tree/nosql/NoSqlTriggerResourceItem.ts b/src/tree/nosql/NoSqlTriggerResourceItem.ts new file mode 100644 index 000000000..52a6f0f25 --- /dev/null +++ b/src/tree/nosql/NoSqlTriggerResourceItem.ts @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Experience } from '../../AzureDBExperiences'; +import { DocumentDBTriggerResourceItem } from '../docdb/DocumentDBTriggerResourceItem'; +import { type DocumentDBTriggerModel } from '../docdb/models/DocumentDBTriggerModel'; + +export class NoSqlTriggerResourceItem extends DocumentDBTriggerResourceItem { + constructor(model: DocumentDBTriggerModel, experience: Experience) { + super(model, experience); + } +} diff --git a/src/tree/nosql/NoSqlTriggersResourceItem.ts b/src/tree/nosql/NoSqlTriggersResourceItem.ts new file mode 100644 index 000000000..b8569394f --- /dev/null +++ b/src/tree/nosql/NoSqlTriggersResourceItem.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type Resource, type TriggerDefinition } from '@azure/cosmos'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBTriggersResourceItem } from '../docdb/DocumentDBTriggersResourceItem'; +import { type DocumentDBTriggersModel } from '../docdb/models/DocumentDBTriggersModel'; +import { NoSqlTriggerResourceItem } from './NoSqlTriggerResourceItem'; + +export class NoSqlTriggersResourceItem extends DocumentDBTriggersResourceItem { + constructor(model: DocumentDBTriggersModel, experience: Experience) { + super(model, experience); + } + + protected getChildrenImpl(triggers: (TriggerDefinition & Resource)[]): Promise { + return Promise.resolve( + triggers.map((trigger) => new NoSqlTriggerResourceItem({ ...this.model, trigger }, this.experience)), + ); + } +} diff --git a/src/tree/table/TableAccountAttachedResourceItem.ts b/src/tree/table/TableAccountAttachedResourceItem.ts new file mode 100644 index 000000000..6950cb746 --- /dev/null +++ b/src/tree/table/TableAccountAttachedResourceItem.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBAttachedAccountModel } from '../attached/CosmosDBAttachedAccountModel'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBAccountAttachedResourceItem } from '../docdb/DocumentDBAccountAttachedResourceItem'; + +export class TableAccountAttachedResourceItem extends DocumentDBAccountAttachedResourceItem { + constructor(account: CosmosDBAttachedAccountModel, experience: Experience) { + super(account, experience); + } + + public async getChildren(): Promise { + const result = await callWithTelemetryAndErrorHandling('getChildren', (context: IActionContext) => { + context.telemetry.properties.experience = this.experience.api; + context.telemetry.properties.parentContext = this.contextValue; + + return Promise.resolve([ + createGenericElement({ + contextValue: `${this.contextValue}/notSupported`, + label: 'Table Accounts are not supported yet.', + id: `${this.id}/notSupported`, + }) as CosmosDBTreeElement, + ]); + }); + + return result ?? []; + } + + protected getChildrenImpl(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/tree/table/TableAccountResourceItem.ts b/src/tree/table/TableAccountResourceItem.ts new file mode 100644 index 000000000..262d89f3e --- /dev/null +++ b/src/tree/table/TableAccountResourceItem.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + callWithTelemetryAndErrorHandling, + createGenericElement, + type IActionContext, +} from '@microsoft/vscode-azext-utils'; +import { type Experience } from '../../AzureDBExperiences'; +import { type CosmosDBTreeElement } from '../CosmosDBTreeElement'; +import { DocumentDBAccountResourceItem } from '../docdb/DocumentDBAccountResourceItem'; +import { type DocumentDBAccountModel } from '../docdb/models/DocumentDBAccountModel'; + +export class TableAccountResourceItem extends DocumentDBAccountResourceItem { + constructor(account: DocumentDBAccountModel, experience: Experience) { + super(account, experience); + } + + public async getChildren(): Promise { + const result = await callWithTelemetryAndErrorHandling('getChildren', (context: IActionContext) => { + context.telemetry.properties.experience = this.experience.api; + context.telemetry.properties.parentContext = this.contextValue; + + return Promise.resolve([ + createGenericElement({ + contextValue: `${this.contextValue}/notSupported`, + label: 'Table Accounts are not supported yet.', + id: `${this.id}/notSupported`, + }) as CosmosDBTreeElement, + ]); + }); + + return result ?? []; + } + + protected getChildrenImpl(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/src/tree/workspace/sharedWorkspaceResourceProvider.ts b/src/tree/workspace/SharedWorkspaceResourceProvider.ts similarity index 90% rename from src/tree/workspace/sharedWorkspaceResourceProvider.ts rename to src/tree/workspace/SharedWorkspaceResourceProvider.ts index 83df9f50f..c7de7c34a 100644 --- a/src/tree/workspace/sharedWorkspaceResourceProvider.ts +++ b/src/tree/workspace/SharedWorkspaceResourceProvider.ts @@ -32,6 +32,7 @@ import type * as vscode from 'vscode'; */ export enum WorkspaceResourceType { MongoClusters = 'vscode.cosmosdb.workspace.mongoclusters-resourceType', + AttachedAccounts = 'vscode.cosmosdb.workspace.attachedaccounts-resourceType', } /** @@ -57,6 +58,11 @@ export class SharedWorkspaceResourceProvider implements WorkspaceResourceProvide id: 'vscode.cosmosdb.workspace.mongoclusters', name: 'MongoDB Cluster Accounts', // this name will be displayed in the workspace view, when no WorkspaceResourceBranchDataProvider is registered }, + { + resourceType: WorkspaceResourceType.AttachedAccounts, + id: 'vscode.cosmosdb.workspace.attachedaccounts', + name: 'Attached Database Accounts', + }, ]; } } diff --git a/src/tree/workspace/sharedWorkspaceStorage.ts b/src/tree/workspace/SharedWorkspaceStorage.ts similarity index 99% rename from src/tree/workspace/sharedWorkspaceStorage.ts rename to src/tree/workspace/SharedWorkspaceStorage.ts index 68f903812..800b0ecd2 100644 --- a/src/tree/workspace/sharedWorkspaceStorage.ts +++ b/src/tree/workspace/SharedWorkspaceStorage.ts @@ -25,7 +25,7 @@ export type SharedWorkspaceStorageItem = { /** * Optional properties associated with the item. */ - properties?: Record; + properties?: Record; /** * Optional array of secrets associated with the item. diff --git a/src/utils/InteractiveChildProcess.ts b/src/utils/InteractiveChildProcess.ts index 8015f3745..00880dc2f 100644 --- a/src/utils/InteractiveChildProcess.ts +++ b/src/utils/InteractiveChildProcess.ts @@ -12,8 +12,6 @@ import { EventEmitter, type Event } from 'vscode'; import { improveError } from './improveError'; // We add these when we display to the output window -const stdInPrefix = '> '; -const stdErrPrefix = 'ERR> '; const errorPrefix = 'Error running process: '; const processStartupTimeout = 60; @@ -67,13 +65,11 @@ export class InteractiveChildProcess { } public writeLine(text: string): void { - this.writeLineToOutputChannel(text, stdInPrefix); this._childProc.stdin?.write(text + os.EOL); } private async startCore(): Promise { this._startTime = Date.now(); - const formattedArgs: string = this._options.args.join(' '); const workingDirectory = this._options.workingDirectory || os.tmpdir(); const options: cp.SpawnOptions = { @@ -85,19 +81,17 @@ export class InteractiveChildProcess { shell: false, }; - this.writeLineToOutputChannel(`Starting executable: "${this._options.command}" ${formattedArgs}`); + this.writeLineToOutputChannel(`Starting executable: "${this._options.command}"`); this._childProc = cp.spawn(this._options.command, this._options.args, options); this._childProc.stdout?.on('data', (data: string | Buffer) => { const text = data.toString(); this._onStdOutEmitter.fire(text); - this.writeLineToOutputChannel(text); }); this._childProc.stderr?.on('data', (data: string | Buffer) => { const text = data.toString(); this._onStdErrEmitter.fire(text); - this.writeLineToOutputChannel(text, stdErrPrefix); }); this._childProc.on('error', (error: unknown) => { @@ -111,6 +105,7 @@ export class InteractiveChildProcess { } else if (!this._isKilling) { this.setError(`The process exited prematurely.`); } + this.writeLineToOutputChannel(`Process exited: "${this._options.command}"`); }); // Wait for the process to start up @@ -136,6 +131,8 @@ export class InteractiveChildProcess { } } }); + + this.writeLineToOutputChannel(`Started executable: "${this._options.command}". Connecting to host...`); } private writeLineToOutputChannel(text: string, displayPrefix?: string): void { diff --git a/src/utils/activityUtils.ts b/src/utils/activityUtils.ts index 652c0b8d6..0e01096ee 100644 --- a/src/utils/activityUtils.ts +++ b/src/utils/activityUtils.ts @@ -5,15 +5,20 @@ import { type ExecuteActivityContext } from '@microsoft/vscode-azext-utils'; import { ext } from '../extensionVariables'; -import { getWorkspaceSetting } from './settingUtils'; +import { SettingsService } from '../services/SettingsService'; -export async function createActivityContext(): Promise { +export async function createActivityContext(withChildren?: boolean): Promise { return { registerActivity: async (activity) => ext.rgApi.registerActivity(activity), - suppressNotification: await getWorkspaceSetting( - 'suppressActivityNotifications', - undefined, - 'azureResourceGroups', - ), + suppressNotification: await SettingsService.getSetting('suppressActivityNotifications', 'azureResourceGroups'), + activityChildren: withChildren ? [] : undefined, + }; +} + +export async function createActivityContextV2(withChildren?: boolean): Promise { + return { + registerActivity: async (activity) => ext.rgApiV2.activity.registerActivity(activity), + suppressNotification: await SettingsService.getSetting('suppressActivityNotifications', 'azureResourceGroups'), + activityChildren: withChildren ? [] : undefined, }; } diff --git a/src/utils/azureClients.ts b/src/utils/azureClients.ts index 8762db87d..26b40803f 100644 --- a/src/utils/azureClients.ts +++ b/src/utils/azureClients.ts @@ -17,6 +17,14 @@ export async function createCosmosDBClient(context: AzExtClientContext): Promise return createAzureClient(context, (await import('@azure/arm-cosmosdb')).CosmosDBManagementClient); } +export async function createCosmosDBManagementClient( + context: IActionContext, + subscription: AzureSubscription, +): Promise { + const subContext = createSubscriptionContext(subscription); + return createAzureClient([context, subContext], (await import('@azure/arm-cosmosdb')).CosmosDBManagementClient); +} + export async function createMongoClustersManagementClient( context: IActionContext, subscription: AzureSubscription, diff --git a/src/utils/document.ts b/src/utils/document.ts index 374127354..3b75b8994 100644 --- a/src/utils/document.ts +++ b/src/utils/document.ts @@ -22,7 +22,7 @@ export const extractPartitionKey = (document: ItemDefinition, partitionKey: Part // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment interim = interim[prop]; } else { - return null; // It is not correct to return null, in other cases it should exception + return null; // It is not correct to return null, in other cases it should be an exception } } if ( diff --git a/src/utils/pickItem/pickAppResource.ts b/src/utils/pickItem/pickAppResource.ts new file mode 100644 index 000000000..a10d149ac --- /dev/null +++ b/src/utils/pickItem/pickAppResource.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + azureResourceExperience, + type ContextValueFilter, + type ITreeItemPickerContext, +} from '@microsoft/vscode-azext-utils'; +import { type AzExtResourceType } from '@microsoft/vscode-azureresources-api'; +import { type TreeItem, type TreeItemLabel } from 'vscode'; +import { ext } from '../../extensionVariables'; +import { type CosmosDBTreeElement } from '../../tree/CosmosDBTreeElement'; +import { WorkspaceResourceType } from '../../tree/workspace/SharedWorkspaceResourceProvider'; + +export interface PickAppResourceOptions { + type?: AzExtResourceType | AzExtResourceType[]; + expectedChildContextValue?: string | RegExp | (string | RegExp)[]; + unexpectedContextValue?: string | RegExp | (string | RegExp)[]; +} + +export interface PickWorkspaceResourceOptions { + type: WorkspaceResourceType | WorkspaceResourceType[]; + expectedChildContextValue?: string | RegExp | (string | RegExp)[]; +} + +export async function pickAppResource( + context: ITreeItemPickerContext, + options?: PickAppResourceOptions, +): Promise { + let filter: ContextValueFilter | undefined = undefined; + if (options?.expectedChildContextValue) { + filter ??= { include: options.expectedChildContextValue }; + + // Only if expectedChildContextValue is set, we will exclude unexpectedContextValue + if (options?.unexpectedContextValue) { + filter.exclude = options.unexpectedContextValue; + } + } + + return await azureResourceExperience( + context, + ext.rgApiV2.resources.azureResourceTreeDataProvider, + options?.type ? (Array.isArray(options.type) ? options.type : [options.type]) : undefined, + filter, + ); +} + +const isPick = (node: TreeItem, contextValueFilter?: ContextValueFilter): boolean => { + if (!contextValueFilter) { + return true; + } + + const includeOption = contextValueFilter.include; + const excludeOption = contextValueFilter.exclude; + + const includeArray: (string | RegExp)[] = Array.isArray(includeOption) ? includeOption : [includeOption]; + const excludeArray: (string | RegExp)[] = excludeOption + ? Array.isArray(excludeOption) + ? excludeOption + : [excludeOption] + : []; + + const nodeContextValues: string[] = node.contextValue?.split(';') ?? []; + const matchesSingleFilter = (matcher: string | RegExp, nodeContextValues: string[]) => { + return nodeContextValues.some((c) => { + if (matcher instanceof RegExp) { + return matcher.test(c); + } + + // Context value matcher is a string, do full equality (same as old behavior) + return c === matcher; + }); + }; + + return ( + includeArray.some((i) => matchesSingleFilter(i, nodeContextValues)) && + !excludeArray.some((e) => matchesSingleFilter(e, nodeContextValues)) + ); +}; + +export async function pickWorkspaceResource( + context: ITreeItemPickerContext, + options?: PickWorkspaceResourceOptions, +): Promise { + options ??= { + type: [WorkspaceResourceType.AttachedAccounts, WorkspaceResourceType.MongoClusters], + expectedChildContextValue: ['treeItem.account', 'treeItem.mongoCluster'], + }; + + const types = Array.isArray(options.type) ? options.type : [options.type]; + const contextValueFilter = options?.expectedChildContextValue + ? { include: options.expectedChildContextValue } + : undefined; + + const firstWorkspaceResources = types + .map((type) => { + if (type === WorkspaceResourceType.AttachedAccounts) { + return ext.cosmosDBWorkspaceBranchDataResource; + } else if (type === WorkspaceResourceType.MongoClusters) { + return ext.mongoClusterWorkspaceBranchDataResource; + } + + return undefined; + }) + .filter((resource) => resource !== undefined); + + const childrenPromise = await Promise.allSettled(firstWorkspaceResources.map((item) => item?.getChildren())); + const items = childrenPromise.map((promise) => (promise.status === 'fulfilled' ? promise.value : [])).flat(); + const quickPickItemsPromise = await Promise.allSettled( + items.map(async (item) => [await item.getTreeItem(), item] as const), + ); + const quickPickItems = quickPickItemsPromise + .map((promise) => (promise.status === 'fulfilled' ? promise.value : undefined)) + .filter((item) => item !== undefined) + .filter(([treeItem]) => isPick(treeItem, contextValueFilter)) + .map(([treeItem, item]) => { + return { + label: ((treeItem.label as TreeItemLabel)?.label || treeItem.label) as string, + description: treeItem.description as string, + data: item, + }; + }); + + const pickedItem = await context.ui.showQuickPick(quickPickItems, {}); + const node = pickedItem.data; + + return node as T; +} diff --git a/src/utils/pickItem/pickExperience.ts b/src/utils/pickItem/pickExperience.ts new file mode 100644 index 000000000..34d602c73 --- /dev/null +++ b/src/utils/pickItem/pickExperience.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type IActionContext, type IAzureQuickPickItem } from '@microsoft/vscode-azext-utils'; +import { + getCosmosExperienceQuickPicks, + getExperienceQuickPicks, + getMongoCoreExperienceQuickPicks, + getPostgresExperienceQuickPicks, + type Experience, +} from '../../AzureDBExperiences'; +import { localize } from '../localize'; + +export enum QuickPickType { + ALL, + Postgres, + Cosmos, + Mongo, +} + +export async function pickExperience(context: IActionContext, type: QuickPickType): Promise { + const quickPicks: IAzureQuickPickItem[] = []; + switch (type) { + case QuickPickType.Postgres: + quickPicks.push(...getPostgresExperienceQuickPicks()); + break; + case QuickPickType.Cosmos: + quickPicks.push(...getCosmosExperienceQuickPicks()); + break; + case QuickPickType.Mongo: + quickPicks.push(...getMongoCoreExperienceQuickPicks()); + break; + case QuickPickType.ALL: + default: + quickPicks.push(...getExperienceQuickPicks()); + } + + if (quickPicks.length === 0) { + throw new Error('No experiences found'); + } + + if (quickPicks.length === 1) { + return quickPicks[0].data; + } + + const result: IAzureQuickPickItem = await context.ui.showQuickPick(quickPicks, { + placeHolder: localize('selectDBServerMsg', 'Select an Azure Database Server'), + }); + + return result.data; +} diff --git a/src/utils/settingUtils.ts b/src/utils/settingUtils.ts deleted file mode 100644 index af837141d..000000000 --- a/src/utils/settingUtils.ts +++ /dev/null @@ -1,74 +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 { ConfigurationTarget, Uri, workspace, type WorkspaceConfiguration } from 'vscode'; -import { ext } from '../extensionVariables'; - -/** - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export async function updateGlobalSetting( - section: string, - value: T, - prefix: string = ext.prefix, -): Promise { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix); - await projectConfiguration.update(section, value, ConfigurationTarget.Global); -} - -/** - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export async function updateWorkspaceSetting( - section: string, - value: T, - fsPath: string, - prefix: string = ext.prefix, -): Promise { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix, Uri.file(fsPath)); - await projectConfiguration.update(section, value); -} - -/** - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export function getGlobalSetting(key: string, prefix: string = ext.prefix): T | undefined { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix); - const result: { globalValue?: T } | undefined = projectConfiguration.inspect(key); - return result && result.globalValue; -} - -/** - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export function getWorkspaceSetting(key: string, fsPath?: string, prefix: string = ext.prefix): T | undefined { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration( - prefix, - fsPath ? Uri.file(fsPath) : undefined, - ); - return projectConfiguration.get(key); -} - -/** - * Searches through all open folders and gets the current workspace setting (as long as there are no conflicts) - * Uses ext.prefix 'azureDatabases' unless otherwise specified - */ -export function getWorkspaceSettingFromAnyFolder(key: string, prefix: string = ext.prefix): string | undefined { - if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) { - let result: string | undefined; - for (const folder of workspace.workspaceFolders) { - const projectConfiguration: WorkspaceConfiguration = workspace.getConfiguration(prefix, folder.uri); - const folderResult: string | undefined = projectConfiguration.get(key); - if (!result) { - result = folderResult; - } else if (folderResult && result !== folderResult) { - return undefined; - } - } - return result; - } else { - return getGlobalSetting(key, prefix); - } -} diff --git a/src/utils/vscodeUtils.ts b/src/utils/vscodeUtils.ts index 92889c726..390d3ef21 100644 --- a/src/utils/vscodeUtils.ts +++ b/src/utils/vscodeUtils.ts @@ -4,14 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { type ItemDefinition } from '@azure/cosmos'; -import { type AzExtTreeItem } from '@microsoft/vscode-azext-utils'; +import { AzExtTreeItem } from '@microsoft/vscode-azext-utils'; import * as fse from 'fs-extra'; import * as path from 'path'; import * as vscode from 'vscode'; -import { DocDBAccountTreeItemBase } from '../docdb/tree/DocDBAccountTreeItemBase'; +import { type EditableFileSystemItem } from '../DatabasesFileSystem'; import { ext } from '../extensionVariables'; -import { MongoAccountTreeItem } from '../mongo/tree/MongoAccountTreeItem'; -import { type IMongoDocument } from '../mongo/tree/MongoDocumentTreeItem'; +import { type CosmosDBTreeElement } from '../tree/CosmosDBTreeElement'; import { getRootPath } from './workspacUtils'; export interface IDisposable { @@ -82,23 +81,21 @@ async function getUniqueFileName(folderPath: string, fileName: string, fileExten throw new Error('Could not find unique name for new file.'); } -export function getNodeEditorLabel(node: AzExtTreeItem): string { - const labels = [node.label]; - while (node.parent) { - node = node.parent; - labels.unshift(node.label); - if (isAccountTreeItem(node)) { - break; +export function getNodeEditorLabel(node: AzExtTreeItem | CosmosDBTreeElement | EditableFileSystemItem): string { + if (node instanceof AzExtTreeItem) { + const labels = [node.label]; + const azExtNode = node as AzExtTreeItem; + while (azExtNode.parent) { + node = azExtNode.parent; + labels.unshift(azExtNode.label); } + return labels.join('/'); } - return labels.join('/'); -} -function isAccountTreeItem(treeItem: AzExtTreeItem): boolean { - return treeItem instanceof MongoAccountTreeItem || treeItem instanceof DocDBAccountTreeItemBase; + return node.id; } -export function getDocumentTreeItemLabel(document: IMongoDocument | ItemDefinition): string { +export function getDocumentTreeItemLabel(document: ItemDefinition): string { for (const field of getDocumentLabelFields()) { // eslint-disable-next-line no-prototype-builtins if (document.hasOwnProperty(field)) { diff --git a/src/utils/withProgress.ts b/src/utils/withProgress.ts new file mode 100644 index 000000000..46d58a47e --- /dev/null +++ b/src/utils/withProgress.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +export function withProgress( + promise: Thenable, + title: string, + location: vscode.ProgressLocation = vscode.ProgressLocation.Notification, +): Thenable { + return vscode.window.withProgress( + { + location: location, + title: title, + }, + (_progress) => { + return promise; + }, + ); +} diff --git a/src/vscode-cosmosdbgraph.api.d.ts b/src/vscode-cosmosdbgraph.api.d.ts deleted file mode 100644 index 95cde2531..000000000 --- a/src/vscode-cosmosdbgraph.api.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export interface CosmosDBGraphExtensionApi { - apiVersion: string; - - openGraphExplorer(config: IGraphConfiguration): Promise; -} - -export interface IGremlinEndpoint { - host: string; - port: number; - ssl: boolean; -} - -export interface IGraphConfiguration { - // e.g. https://graphaccount.documents.azure.com:443 - documentEndpoint: string; - - gremlinEndpoint?: IGremlinEndpoint; - possibleGremlinEndpoints: IGremlinEndpoint[]; - - key: string; - databaseName: string; - graphName: string; - tabTitle: string; -} diff --git a/src/webviews/Document/DocumentPanel.tsx b/src/webviews/Document/DocumentPanel.tsx index b129b98ee..3474f060e 100644 --- a/src/webviews/Document/DocumentPanel.tsx +++ b/src/webviews/Document/DocumentPanel.tsx @@ -3,12 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type JSONObject, type PartitionKeyDefinition } from '@azure/cosmos'; import { makeStyles, MessageBar, ProgressBar } from '@fluentui/react-components'; -import { parse as parseJson } from '@prantlf/jsonlint'; import { useEffect, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; -import { extractPartitionKey } from '../../utils/document'; +import { validateDocument } from '../../docdb/utils/validateDocument'; import { MonacoEditor } from '../MonacoEditor'; import { DocumentToolbar } from './DocumentToolbar'; import { useDocumentDispatcher, useDocumentState } from './state/DocumentContext'; @@ -31,65 +29,6 @@ const useClasses = makeStyles({ }, }); -const validateDocument = (content: string, partitionKey?: PartitionKeyDefinition) => { - const errors: string[] = []; - - try { - // Check JSON schema - const resource = parseJson(content) as JSONObject; - - // Check partition key - if (partitionKey) { - const partitionKeyPaths = partitionKey.paths.map((path) => (path.startsWith('/') ? path.slice(1) : path)); - const partitionKeyValues = extractPartitionKey(resource, partitionKey); - if (!partitionKeyValues) { - errors.push('Partition key is incomplete.'); - } - - if (Array.isArray(partitionKeyValues)) { - partitionKeyValues - .map((value, index) => { - if (!value) { - return `Partition key ${partitionKeyPaths[index]} is invalid.`; - } - return null; - }) - .filter((value) => value !== null) - .forEach((value) => errors.push(value)); - } - } - - // Check document id - if (resource.id) { - if (typeof resource.id !== 'string') { - errors.push('Id must be a string.'); - } else { - if ( - resource.id.indexOf('/') !== -1 || - resource.id.indexOf('\\') !== -1 || - resource.id.indexOf('?') !== -1 || - resource.id.indexOf('#') !== -1 - ) { - errors.push('Id contains illegal chars (/, \\, ?, #).'); - } - if (resource.id[resource.id.length - 1] === ' ') { - errors.push('Id ends with a space.'); - } - } - } - } catch (err) { - if (err instanceof SyntaxError) { - errors.push(err.message); - } else if (err instanceof Error) { - errors.push(err.message); - } else { - errors.push('Unknown error'); - } - } - - return errors; -}; - export const DocumentPanel = () => { const classes = useClasses(); const state = useDocumentState(); diff --git a/src/webviews/mongoClusters/collectionView/collectionViewController.ts b/src/webviews/mongoClusters/collectionView/collectionViewController.ts index ea7324e83..7b17208e8 100644 --- a/src/webviews/mongoClusters/collectionView/collectionViewController.ts +++ b/src/webviews/mongoClusters/collectionView/collectionViewController.ts @@ -13,6 +13,7 @@ export type CollectionViewWebviewConfigurationType = { id: string; // move to base type sessionId: string; + clusterId: string; databaseName: string; collectionName: string; collectionTreeItem: CollectionItem; // needed to execute commands on the collection as the tree APIv2 doesn't support id-based search for tree items. @@ -31,6 +32,7 @@ export class CollectionViewController extends WebviewController { assert(mongoDErrors === ''); let previousEnv: IDisposable | undefined; - let shell: MongoShell | undefined; + let shell: MongoShellScriptRunner | undefined; const outputChannel = new FakeOutputChannel(); try { previousEnv = setEnvironmentVariables(options.env || {}); - shell = await MongoShell.create( + shell = await MongoShellScriptRunner.createShellProcessHelper( options.mongoPath || mongoPath, options.args || [], '', @@ -291,7 +293,14 @@ suite('MongoShell', async function (this: Mocha.Suite): Promise { }); await testIfSupported("More results than displayed (type 'it' for more -> (More))", async () => { - const shell = await MongoShell.create(mongoPath, [], '', false, new FakeOutputChannel(), 5); + const shell = await MongoShellScriptRunner.createShellProcessHelper( + mongoPath, + [], + '', + false, + new FakeOutputChannel(), + 5, + ); await shell.executeScript('db.mongoShellTest.drop()'); await shell.executeScript('for (var i = 0; i < 50; ++i) { db.mongoShellTest.insert({a:i}); }'); diff --git a/test/runWithSetting.ts b/test/runWithSetting.ts index eb0e5df71..97d42b537 100644 --- a/test/runWithSetting.ts +++ b/test/runWithSetting.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ext, getGlobalSetting, updateGlobalSetting } from '../extension.bundle'; +import { ext } from '../extension.bundle'; +import { SettingsService } from '../src/services/SettingsService'; export async function runWithDatabasesSetting( key: string, @@ -27,11 +28,11 @@ async function runWithSettingInternal( prefix: string, callback: () => Promise, ): Promise { - const oldValue: string | boolean | undefined = getGlobalSetting(key, prefix); + const oldValue: string | boolean | undefined = SettingsService.getGlobalSetting(key, prefix); try { - await updateGlobalSetting(key, value, prefix); + await SettingsService.updateGlobalSetting(key, value, prefix); await callback(); } finally { - await updateGlobalSetting(key, oldValue, prefix); + await SettingsService.updateGlobalSetting(key, oldValue, prefix); } } diff --git a/test/util/getIp.test.ts b/test/util/getIp.test.ts index 021e81561..3239ebc98 100644 --- a/test/util/getIp.test.ts +++ b/test/util/getIp.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { createTestActionContext } from '@microsoft/vscode-azext-dev'; +import { IActionContext } from '@microsoft/vscode-azext-utils'; import assert from 'assert'; import { isIPv4 } from 'net'; -import { getPublicIpv4, isIpInRanges, type IActionContext } from '../../extension.bundle'; +import { getPublicIpv4, isIpInRanges } from '../../extension.bundle'; suite('getPublicIpv4', () => { test('get IP', async () => {