diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b15559c..5b98f05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,5 +49,8 @@ jobs: sudo apt-get install -y libnss3-dev libasound2 libgdk-pixbuf2.0-dev libgtk-3-dev libxss-dev libatk1.0-0 - run: npm install - - run: xvfb-run -a npm test - if: runner.os == 'Linux' \ No newline at end of file + - name: Run Tests on Linux + if: runner.os == 'Linux' + run: | + export MZ_CONFIG_PATH=$HOME/.config/materialize/test + xvfb-run -a npm test \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 05ec941..ab0ffa8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -28,7 +28,10 @@ "outFiles": [ "${workspaceFolder}/out/test/**/*.js" ], - "preLaunchTask": "${defaultBuildTask}" + "preLaunchTask": "${defaultBuildTask}", + "env": { + "MZ_CONFIG_PATH": "${env:HOME}/.config/materialize/test" + } } ] } \ No newline at end of file diff --git a/src/clients/admin.ts b/src/clients/admin.ts index 2944939..90f9ad8 100644 --- a/src/clients/admin.ts +++ b/src/clients/admin.ts @@ -38,7 +38,7 @@ export default class AdminClient { async getToken() { // TODO: Expire should be at the half of the expiring time. - if (!this.auth || (new Date(this.auth.expires) > new Date())) { + if (!this.auth || (new Date(this.auth.expires) < new Date())) { const authRequest: AuthenticationRequest = { clientId: this.appPassword.clientId, secret: this.appPassword.secretKey @@ -67,7 +67,7 @@ export default class AdminClient { } /// Returns the JSON Web Key Set (JWKS) from the well known endpoint: `/.well-known/jwks.json` - async getJwks() { + private async getJwks() { const client = jwksClient({ jwksUri: this.jwksEndpoint }); @@ -78,7 +78,7 @@ export default class AdminClient { /// Verifies the JWT signature using a JWK from the well-known endpoint and /// returns the user claims. - async getClaims() { + private async getClaims() { console.log("[AdminClient]", "Getting Token."); const token = await this.getToken(); diff --git a/src/clients/cloud.ts b/src/clients/cloud.ts index cf5d647..e96e2dd 100644 --- a/src/clients/cloud.ts +++ b/src/clients/cloud.ts @@ -1,6 +1,5 @@ import fetch from "node-fetch"; import AdminClient from "./admin"; -import * as vscode from 'vscode'; import { Errors } from "../utilities/error"; const DEFAULT_API_CLOUD_ENDPOINT = 'https://api.cloud.materialize.com'; diff --git a/src/clients/sql.ts b/src/clients/sql.ts index 9d4fd98..75a727a 100644 --- a/src/clients/sql.ts +++ b/src/clients/sql.ts @@ -1,21 +1,21 @@ import { Pool, QueryResult } from "pg"; import AdminClient from "./admin"; import CloudClient from "./cloud"; -import { Context, EventType } from "../context"; import { Profile } from "../context/config"; +import AsyncContext from "../context/asyncContext"; export default class SqlClient { private pool: Promise; private adminClient: AdminClient; private cloudClient: CloudClient; - private context: Context; + private context: AsyncContext; private profile: Profile; constructor( adminClient: AdminClient, cloudClient: CloudClient, profile: Profile, - context: Context, + context: AsyncContext, ) { this.adminClient = adminClient; this.cloudClient = cloudClient; @@ -39,7 +39,7 @@ export default class SqlClient { }); } catch (err) { console.error("[SqlClient]", "Error creating pool: ", err); - this.context.emit("event", { type: EventType.error, message: err }); + rej(err); } }; diff --git a/src/context/asyncContext.ts b/src/context/asyncContext.ts new file mode 100644 index 0000000..24a18e0 --- /dev/null +++ b/src/context/asyncContext.ts @@ -0,0 +1,497 @@ +import { AdminClient, CloudClient, SqlClient } from "../clients"; +import { ExtensionContext } from "vscode"; +import { Context } from "./context"; +import { Errors, ExtensionError } from "../utilities/error"; +import AppPassword from "./appPassword"; +import { ActivityLogTreeProvider, AuthProvider, DatabaseTreeProvider, ResultsProvider } from "../providers"; +import * as vscode from 'vscode'; +import { QueryResult } from "pg"; + +/** + * Represents the different providers available in the extension. + */ +interface Providers { + activity: ActivityLogTreeProvider; + database: DatabaseTreeProvider; + auth: AuthProvider; + results: ResultsProvider; +} + +/** + * The context serves as a centralized point for handling + * asynchronous calls and distributing errors. + * + * All asynchronous methods should be declared in this class + * and must handle errors using try/catch. + * Public methods should never reject a Promise (`rej(..)`) + * or throw errors (`throw new Error()`). + * + * Unhandled rejections in VS Code may result in undesired + * notifications to the user. + */ +export default class AsyncContext extends Context { + + protected providers: Providers; + private isReadyPromise: Promise; + + constructor(vsContext: ExtensionContext) { + super(vsContext); + this.isReadyPromise = new Promise((res) => { + const asyncOp = async () => { + try { + await this.loadContext(); + } catch (err) { + this.handleErr(err, "Error loading context."); + res(false); + } finally { + res(true); + } + }; + asyncOp(); + }); + + // Providers must always initialize after the `isReadyPromise`. + // Otherwise, it will be undefined. + this.providers = this.buildProviders(); + } + + /** + * Builds the different providers in the extension. + * + * This is the only function that it is not async. + * The only reason is here is the circular dependency betwee providers and + * context. + */ + private buildProviders() { + const activity = new ActivityLogTreeProvider(this.vsContext); + const database = new DatabaseTreeProvider(this); + const auth = new AuthProvider(this.vsContext.extensionUri, this); + const results = new ResultsProvider(this.vsContext.extensionUri); + + // Register providers + vscode.window.registerTreeDataProvider('activityLog', activity); + vscode.window.createTreeView('explorer', { treeDataProvider: database }); + this.vsContext.subscriptions.push(vscode.commands.registerCommand('materialize.refresh', () => { + database.refresh(); + })); + this.vsContext.subscriptions.push( + vscode.window.registerWebviewViewProvider( + "profile", + auth + ) + ); + this.vsContext.subscriptions.push( + vscode.window.registerWebviewViewProvider( + "queryResults", + results, + { webviewOptions: { retainContextWhenHidden: true } } + ) + ); + + return { + activity, + database, + auth, + results + }; + } + + /** + * Loads the Admin and Cloud clients, and + * triggers the environment load. + * @returns + */ + private async loadContext(init?: boolean) { + const profile = this.config.getProfile(); + this.environment = undefined; + + // If there is no profile loaded skip, do not load a context. + if (!profile) { + this.loaded = true; + throw new Error(Errors.profileDoesNotExist); + } + + console.log("[AsyncContext]", "Loading context for profile."); + const adminEndpoint = this.config.getAdminEndpoint(); + const appPassword = await this.config.getAppPassword(); + if (!appPassword) { + throw new Error(Errors.missingAppPassword); + } + + const adminClient = new AdminClient(appPassword, adminEndpoint); + this.clients = { + ...this.clients, + admin: adminClient, + cloud: new CloudClient(adminClient, profile["cloud-endpoint"]) + }; + + await this.loadEnvironment(init); + return true; + } + + /** + * Loads all the environment information. + * @param reloadSchema + */ + private async loadEnvironment(init?: boolean, reloadSchema?: boolean): Promise { + this.loaded = false; + + if (!init) { + this.providers.auth.environmentChange(); + this.providers.database.refresh(); + } + + const profile = this.config.getProfile(); + + if (!this.clients.admin || !this.clients.cloud) { + throw new Error(Errors.unconfiguredClients); + } else if (!profile) { + throw new Error(Errors.unconfiguredProfile); + } else { + this.clients.sql = new SqlClient(this.clients.admin, this.clients.cloud, profile, this); + + try { + await this.clients.sql.connectErr(); + } catch (err) { + console.error("[AsyncContext]", "Sql Client connect err: ", err); + throw err; + } + + // Set environment + if (!this.environment) { + const environmentPromises = [ + this.query("SHOW CLUSTER;"), + this.query("SHOW DATABASE;"), + this.query("SHOW SCHEMA;"), + this.query(`SELECT id, name, owner_id as "ownerId" FROM mz_clusters;`), + this.query(`SELECT id, name, owner_id as "ownerId" FROM mz_databases;`), + this.query(`SELECT id, name, database_id as "databaseId", owner_id as "ownerId" FROM mz_schemas`), + ]; + + try { + const [ + { rows: [{ cluster }] }, + { rows: [{ database }] }, + { rows: [{ schema }] }, + { rows: clusters }, + { rows: databases }, + { rows: schemas } + ] = await Promise.all(environmentPromises); + + const databaseObj = databases.find(x => x.name === database); + + this.environment = { + cluster, + database, + schema, + databases, + schemas: schemas.filter(x => x.databaseId === databaseObj?.id), + clusters + }; + + console.log("[AsyncContext]", "Environment:", this.environment); + } catch (err) { + console.error("[AsyncContext]", "Error querying evnrionment information."); + throw err; + } + } + + if (reloadSchema && this.environment) { + console.log("[AsyncContext]", "Reloading schema."); + const schemaPromises = [ + this.query("SHOW SCHEMA;"), + this.query(`SELECT id, name, database_id as "databaseId", owner_id as "ownerId" FROM mz_schemas`) + ]; + const [ + { rows: [{ schema }] }, + { rows: schemas } + ] = await Promise.all(schemaPromises); + + const { databases, database } = this.environment; + const databaseObj = databases.find(x => x.name === database); + this.environment.schema = schema; + this.environment.schemas = schemas.filter(x => x.databaseId === databaseObj?.id); + } + + console.log("[AsyncContext]", "Environment loaded."); + this.loaded = true; + this.providers.auth.environmentLoaded(); + return true; + } + } + + /** + * Returns or create the SQL client once is ready. + * + * The SQL client abstracts the usage of the pg client. + * @returns {SqlClient} + */ + private async getSqlClient(): Promise { + if (this.clients.sql) { + return this.clients.sql; + } else { + // Profile needs to be created first. + return await new Promise((res, rej) => { + const asyncOp = async () => { + try { + await this.isReady; + } catch (err) { + console.error("[AsyncContext]", "Error getting SQL client: ", err); + } finally { + if (!this.clients.sql) { + rej(new Error("Error getting SQL client.")); + } else { + res(this.clients.sql); + } + } + }; + + asyncOp(); + }); + } + } + + /** + * Adds a new profile. + * + * Requires to reload the context. + * @param name new profile name. + * @param appPassword new app-password. + * @param region new region name. + */ + async addAndSaveProfile(name: string, appPassword: AppPassword, region: string) { + try { + await this.config.addAndSaveProfile(name, appPassword, region); + try { + const success = await this.reloadContext(); + return success; + } catch (err) { + this.handleErr(err, "Error reloading context."); + } + return true; + } catch (err) { + this.handleErr(err, "Error saving profile."); + + return false; + } + } + + /** + * Removes a profile from the configuration + * and saves the changes. + * @param name profile name. + */ + async removeAndSaveProfile(name: string) { + try { + this.config.removeAndSaveProfile(name); + const success = await this.reloadContext(); + return success; + } catch (err) { + this.handleErr(err, "Error reloading context."); + return false; + } + } + + /** + * Sets the current profile in the context. + * + * This requires to reload the context. + * @param name valid profile name. + */ + async setProfile(name: string) { + try { + this.config.setProfile(name); + this.environment = undefined; + try { + const success = await this.reloadContext(); + return success; + } catch (err) { + this.handleErr(err, "Error reloading context."); + console.error("[AsyncContext]", "Error reloading context: ", err); + } + } catch (err) { + this.handleErr(err, "Error setting profile."); + console.error("[AsyncContext]", "Error setting profile: ", err); + } + + return false; + } + + /** + * Handle any error and display the error in the profile/auth component. + * @param err + * @param altMessage + */ + private handleErr(err: unknown, altMessage: string) { + console.log("[AsyncContext]", altMessage); + + if (err instanceof ExtensionError || err instanceof Error) { + this.providers.auth.displayError(err.message); + } else { + this.providers.auth.displayError(altMessage || Errors.unexpectedErrorContext); + } + } + + /** + * Reloads the whole context. + */ + private async reloadContext() { + this.isReadyPromise = new Promise((res) => { + const asyncOp = async () => { + try { + const success = await this.loadContext(); + res(success); + } catch (err) { + this.handleErr(err, "Error reloading context."); + res(false); + } + }; + + asyncOp(); + }); + + return this.isReadyPromise; + } + + /** + * Reloads the environment with another configuration. + * @param reloadSchema only true when the database changes. + */ + private async reloadEnvironment(reloadSchema?: boolean) { + this.isReadyPromise = new Promise((res) => { + const asyncOp = async () => { + try { + await this.loadEnvironment(false, reloadSchema); + res(true); + } catch (err) { + this.handleErr(err, "Error reloading environment."); + res(false); + } + }; + + asyncOp(); + }); + + return this.isReadyPromise; + } + + /** + * Runs a query in the SQL client. + * + * WARNING: If using this method handle exceptions carefuly. + * @param text + * @param vals + * @returns + */ + async query(text: string, vals?: Array): Promise> { + const client = await this.getSqlClient(); + return await client.query(text, vals); + } + + /** + * Use this method to verify if the context is ready to receive requests. + * + * Return does not means it is healthy. + * @returns {Promise} + */ + async isReady() { + try { + await this.isReadyPromise; + return true; + } catch (err) { + this.handleErr(err, "Error waiting to be ready."); + return false; + } + } + + /** + * Sets a new database. + * Requires and environment and schema reload. + * @param name valid database name. + */ + async setDatabase(name: string) { + // Every database has different schemas. + // Setting an undefined schema before loading the env. + // Triggers a new search for a valid schema. + if (this.environment) { + // Reload schema after loading the environment. + this.environment = { + ...this.environment, + database: name, + schema: "", + }; + } + + try { + const success = await this.reloadEnvironment(true); + return success; + } catch (err) { + this.handleErr(err as Error, "Error reloading environment."); + return false; + } + } + + /** + * Sets a new cluster. + * Requires an environment reload. + * @param name valid cluster name. + */ + async setCluster(name: string) { + if (this.environment) { + this.environment = { + ...this.environment, + cluster: name, + }; + } + + try { + const success = await this.reloadEnvironment(); + return success; + } catch (err) { + this.handleErr(err as Error, "Error reloading environment."); + return false; + } + } + + /** + * Sets a new schema. + * Requires an environment reload. + * @param name valid schema name. + */ + async setSchema(name: string) { + if (this.environment) { + this.environment = { + ...this.environment, + schema: name, + }; + } + + try { + const success = await this.reloadEnvironment(); + return success; + } catch (err) { + this.handleErr(err as Error, "Error reloading environment."); + return false; + } + } + + /** + * @returns the current user app-password. + */ + async getAppPassword() { + try { + const appPassword = await this.config.getAppPassword(); + return appPassword; + } catch (err) { + this.handleErr(err, "Error getting app-password."); + return undefined; + } + } + + /** + * @returns the available providers. + */ + getProviders(): Providers { + return this.providers; + } +} \ No newline at end of file diff --git a/src/context/config.ts b/src/context/config.ts index b8bb266..a463da6 100644 --- a/src/context/config.ts +++ b/src/context/config.ts @@ -322,13 +322,10 @@ export class Config { */ async getAppPassword(): Promise { const { - vault, appPassword } = this.profile ? { - vault: this.profile.vault, appPassword: this.profile["app-password"] } : { - vault: this.config.vault, appPassword: undefined }; @@ -374,8 +371,13 @@ export class Config { if (this.config.profiles) { const profile = this.config.profiles[name]; - this.profile = profile; - this.profileName = name; + + if (!profile) { + throw new Error("Profile does not exists."); + } else { + this.profile = profile; + this.profileName = name; + } } else { console.error("Error loading profile. The profile is missing."); } diff --git a/src/context/context.ts b/src/context/context.ts index 5db0a77..8e21036 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,21 +1,12 @@ -import EventEmitter = require("node:events"); import { AdminClient, CloudClient, SqlClient } from "../clients"; import { Config } from "./config"; import { MaterializeObject, MaterializeSchemaObject } from "../providers/schema"; -import AppPassword from "./appPassword"; import LspClient from "../clients/lsp"; -import { Errors, ExtensionError } from "../utilities/error"; - -export enum EventType { - newProfiles, - newQuery, - sqlClientConnected, - queryResults, - environmentLoaded, - environmentChange, - error -} +import * as vscode from 'vscode'; +/** + * Contains Materialize environment info. + */ interface Environment { clusters: Array; schemas: Array; @@ -25,298 +16,113 @@ interface Environment { cluster: string; } -export class Context extends EventEmitter { - private config: Config; - private loaded: boolean; - - private adminClient?: AdminClient; - private cloudClient?: CloudClient; - private sqlClient?: SqlClient; - private lspClient: LspClient; - - private environment?: Environment; - - constructor() { - super(); - this.config = new Config(); - this.loaded = false; - this.lspClient = new LspClient(); - this.loadContext(); - } - - private async loadContext() { - const profile = this.config.getProfile(); - this.environment = undefined; +/** + * Represents the different clients available in the extension. + */ +interface Clients { + admin?: AdminClient; + cloud?: CloudClient; + sql?: SqlClient; + lsp: LspClient; +} - // If there is no profile loaded skip, do not load a context. - if (!profile) { - this.loaded = true; - return; - } +export class Context { + protected config: Config; + protected loaded: boolean; - console.log("[Context]", "Loading context for profile."); - try { - const adminEndpoint = this.config.getAdminEndpoint(); - const appPassword = await this.config.getAppPassword(); - if (!appPassword) { - console.error("[Context]", "Missing app-password."); - this.emit("event", { type: EventType.error, message: Errors.missingAppPassword }); - return; - } + // Visual Studio Code Context + protected vsContext: vscode.ExtensionContext; - this.adminClient = new AdminClient(appPassword, adminEndpoint); - this.cloudClient = new CloudClient(this.adminClient, profile["cloud-endpoint"]); - this.loadEnvironment(); - } catch (err) { - console.error("[Context]", err); - this.emit("event", { type: EventType.error, message: Errors.unexpectedErrorContext }); - } - } + // Clients + protected clients: Clients; - /** - * @returns the current user app-password. - */ - async getAppPassword() { - return this.config.getAppPassword(); - } + // User environment + protected environment?: Environment; - private async loadEnvironment(reloadSchema?: boolean) { + constructor(vsContext: vscode.ExtensionContext) { + this.vsContext = vsContext; + this.config = new Config(); this.loaded = false; - this.emit("event", { type: EventType.environmentChange }); - - const profile = this.config.getProfile(); - - if (!this.adminClient || !this.cloudClient) { - throw new Error(Errors.unconfiguredClients); - } else if (!profile) { - throw new Error(Errors.unconfiguredProfile); - } else { - this.sqlClient = new SqlClient(this.adminClient, this.cloudClient, profile, this); - this.sqlClient.connectErr().catch((err) => { - console.error("[Context]", "Sql Client connect err: ", err); - this.emit("event", { type: EventType.error, message: Errors.unexpectedSqlClientConnectionError }); - }); - - // Set environment - if (!this.environment) { - const environmentPromises = [ - this.query("SHOW CLUSTER;"), - this.query("SHOW DATABASE;"), - this.query("SHOW SCHEMA;"), - this.query(`SELECT id, name, owner_id as "ownerId" FROM mz_clusters;`), - this.query(`SELECT id, name, owner_id as "ownerId" FROM mz_databases;`), - this.query(`SELECT id, name, database_id as "databaseId", owner_id as "ownerId" FROM mz_schemas`), - ]; - - try { - const [ - { rows: [{ cluster }] }, - { rows: [{ database }] }, - { rows: [{ schema }] }, - { rows: clusters }, - { rows: databases }, - { rows: schemas } - ] = await Promise.all(environmentPromises); - - const databaseObj = databases.find(x => x.name === database); - - this.environment = { - cluster, - database, - schema, - databases, - schemas: schemas.filter(x => x.databaseId === databaseObj?.id), - clusters - }; - - console.log("[Context]", "Environment:", this.environment); - } catch (err) { - // TODO: Display error. - console.error("[Context]", "Error loading environment: ", err); - } - } - - if (reloadSchema && this.environment) { - // TODO: Improve this code. - console.log("[Context]", "Reloading schema."); - const schemaPromises = [ - this.query("SHOW SCHEMA;"), - this.query(`SELECT id, name, database_id as "databaseId", owner_id as "ownerId" FROM mz_schemas`) - ]; - const [ - { rows: [{ schema }] }, - { rows: schemas } - ] = await Promise.all(schemaPromises); - - const { databases, database } = this.environment; - const databaseObj = databases.find(x => x.name === database); - this.environment.schema = schema; - this.environment.schemas = schemas.filter(x => x.databaseId === databaseObj?.id); - } - - console.log("[Context]", "Environment loaded."); - this.loaded = true; - this.emit("event", { type: EventType.environmentLoaded }); - } - } - - isLoading(): boolean { - return !this.loaded; - } - - async waitReadyness(): Promise { - return await new Promise((res, rej) => { - if (this.loaded === true) { - res(true); - } else { - this.on("event", ({ type }) => { - if (type === EventType.environmentLoaded) { - if (!this.environment) { - rej(new Error("Error getting environment.")); - } else { - res(true); - } - } - }); - } - }); - } - - private async getSqlClient(): Promise { - if (this.sqlClient) { - return this.sqlClient; - } else { - // Profile needs to be created first. - return await new Promise((res, rej) => { - this.on("event", ({ type }) => { - if (type === EventType.sqlClientConnected) { - if (!this.sqlClient) { - rej(new Error("Error getting SQL client.")); - } else { - res(this.sqlClient); - } - } - }); - }); - } + this.clients = { + lsp: new LspClient() + }; } stop() { - this.lspClient.stop(); + this.clients.lsp.stop(); } - async query(text: string, vals?: Array) { - const client = await this.getSqlClient(); - - return await client.query(text, vals); + isLoading(): boolean { + return !this.loaded; } + /** + * Returns all the clusters available in the environment. + * @returns cluster objects. + */ getClusters(): MaterializeObject[] | undefined { return this.environment?.clusters; } + /** + * Return the current cluster name. + * @returns cluster name. + */ getCluster(): string | undefined { return this.environment?.cluster; } + /** + * Return all the available database in the environment. + * @returns database objects. + */ getDatabases(): MaterializeObject[] | undefined { return this.environment?.databases; } + /** + * Returns the current database. + * @returns database name. + */ getDatabase(): string | undefined { return this.environment?.database; } + /** + * Returns all the available schemas in the environment database. + * @returns schema objects. + */ getSchemas(): MaterializeSchemaObject[] | undefined { return this.environment?.schemas; } + /** + * Returns the current schema. + * @returns schema. + */ getSchema(): string | undefined { return this.environment?.schema; } + /** + * Returns all the valid profile names in the config file. + * @returns {array} profile names + */ getProfileNames() { return this.config.getProfileNames(); } + /** + * Returns the current profile name. + * @returns {string} profile name. + */ getProfileName() { return this.config.getProfileName(); } - async addAndSaveProfile(name: string, appPassword: AppPassword, region: string) { - await this.config.addAndSaveProfile(name, appPassword, region); - await this.loadContext(); - } - - async removeAndSaveProfile(name: string) { - this.config.removeAndSaveProfile(name); - await this.loadContext(); - } - - async setProfile(name: string) { - this.config.setProfile(name); - this.environment = undefined; - await this.loadContext(); - } - - handleErr(err: Error) { - if (err instanceof ExtensionError) { - this.emit("event", { type: EventType.error, message: err.message }); - } else { - this.emit("event", { type: EventType.error, message: Errors.unexpectedErrorContext }); - } - } - - setDatabase(name: string) { - // Every database has different schemas. - // Setting an undefined schema before loading the env. - // Triggers a new search for a valid schema. - if (this.environment) { - // Reload schema after loading the environment. - this.environment = { - ...this.environment, - database: name, - schema: "", - }; - } - - try { - this.loadEnvironment(true); - } catch (err) { - this.handleErr(err as Error); - } - } - - setCluster(name: string) { - if (this.environment) { - this.environment = { - ...this.environment, - cluster: name, - }; - } - - try { - this.loadEnvironment(); - } catch (err) { - this.handleErr(err as Error); - } - } - - setSchema(name: string) { - if (this.environment) { - this.environment = { - ...this.environment, - schema: name, - }; - } - - try { - this.loadEnvironment(); - } catch (err) { - this.handleErr(err as Error); - } - } - + /** + * @returns context environment + */ getEnvironment() { return this.environment; } diff --git a/src/context/index.ts b/src/context/index.ts index d5b0ef1..8e42791 100644 --- a/src/context/index.ts +++ b/src/context/index.ts @@ -1,6 +1,5 @@ -import Context, { EventType } from "./context"; +import Context from "./context"; export { Context, - EventType, }; \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index 4801a0a..d76470f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,44 +1,13 @@ import * as vscode from 'vscode'; -import { AuthProvider, ResultsProvider, DatabaseTreeProvider, ActivityLogTreeProvider } from './providers'; -import { Context, EventType } from './context'; -import { randomUUID } from 'crypto'; +import { buildRunSQLCommand } from './providers/query'; +import AsyncContext from './context/asyncContext'; // User context. Contains auth information, cluster, database, schema, etc. -let context: Context; +let context: AsyncContext; export function activate(vsContext: vscode.ExtensionContext) { console.log("[Extension]", "Activating Materialize extension."); - context = new Context(); - - // Register the activity log - const activityLogProvider = new ActivityLogTreeProvider(vsContext); - vscode.window.registerTreeDataProvider('activityLog', activityLogProvider); - - // Register the database explorer - const databaseTreeProvider = new DatabaseTreeProvider(context); - vscode.window.createTreeView('explorer', { treeDataProvider: databaseTreeProvider }); - - vsContext.subscriptions.push(vscode.commands.registerCommand('materialize.refresh', () => { - databaseTreeProvider.refresh(); - })); - - // Register the Auth Provider - const authProvider = new AuthProvider(vsContext.extensionUri, context); - vsContext.subscriptions.push( - vscode.window.registerWebviewViewProvider( - "profile", - authProvider - ) - ); - - const resultsProvider = new ResultsProvider(vsContext.extensionUri, context); - vsContext.subscriptions.push( - vscode.window.registerWebviewViewProvider( - "queryResults", - resultsProvider, - { webviewOptions: { retainContextWhenHidden: true } } - ) - ); + context = new AsyncContext(vsContext); // Register the `Run SQL` command. let runDisposable = vscode.commands.registerCommand('materialize.run', async () => { @@ -57,62 +26,8 @@ export function activate(vsContext: vscode.ExtensionContext) { } // Focus the query results panel. - vscode.commands.executeCommand('queryResults.focus').then(async () => { - const document = activeEditor.document; - const selection = activeEditor.selection; - const textSelected = activeEditor.document.getText(selection).trim(); - const query = textSelected ? textSelected : document.getText(); - - console.log("[RunSQLCommand]", "Running query: ", query); - - // Identify the query to not overlap results. - // When a user press many times the run query button - // the results from one query can overlap the results - // from another. We only want to display the last results. - const id = randomUUID(); - context.emit("event", { type: EventType.newQuery, data: { id } }); - - // Benchmark - const startTime = Date.now(); - try { - const results = await context.query(query); - const endTime = Date.now(); - const elapsedTime = endTime - startTime; - - console.log("[RunSQLCommand]", "Results: ", results); - console.log("[RunSQLCommand]", "Emitting results."); - - if (Array.isArray(results)) { - context.emit("event", { type: EventType.queryResults, data: { ...results[0], elapsedTime, id } }); - } else { - context.emit("event", { type: EventType.queryResults, data: { ...results, elapsedTime, id } }); - } - activityLogProvider.addLog({ - status: "success", - latency: elapsedTime, // assuming elapsedTime holds the time taken for the query to execute - sql: query - }); - } catch (error: any) { - console.log("[RunSQLCommand]", error.toString()); - console.log("[RunSQLCommand]", JSON.stringify(error)); - const endTime = Date.now(); - const elapsedTime = endTime - startTime; - - activityLogProvider.addLog({ - status: "failure", - latency: elapsedTime, // assuming elapsedTime holds the time taken before the error was caught - sql: query - }); - - context.emit("event", { type: EventType.queryResults, data: { id, rows: [], fields: [], error: { - message: error.toString(), - position: error.position, - query, - }, elapsedTime }}); - } finally { - resultsProvider._view?.show(); - } - }); + const sqlCommand = buildRunSQLCommand(context); + vscode.commands.executeCommand('queryResults.focus').then(sqlCommand); }); let copyDisposable = vscode.commands.registerCommand('materialize.copy', async ({ tooltip }) => { diff --git a/src/providers/auth.ts b/src/providers/auth.ts index b112499..40b011c 100644 --- a/src/providers/auth.ts +++ b/src/providers/auth.ts @@ -1,9 +1,9 @@ import * as vscode from "vscode"; import { Request, Response, Application } from 'express'; -import { Context, EventType } from "../context"; import { getUri } from "../utilities/getUri"; import AppPassword from "../context/appPassword"; import { getNonce } from "../utilities/getNonce"; +import AsyncContext from "../context/asyncContext"; // Please update this link if the logo location changes in the future. const LOGO_URL: String = "https://materialize.com/svgs/brand-guide/materialize-purple-mark.svg"; @@ -79,10 +79,10 @@ interface State { export default class AuthProvider implements vscode.WebviewViewProvider { _view?: vscode.WebviewView; _doc?: vscode.TextDocument; - context: Context; + context: AsyncContext; state: State; - constructor(private readonly _extensionUri: vscode.Uri, context: Context) { + constructor(private readonly _extensionUri: vscode.Uri, context: AsyncContext) { this._extensionUri = _extensionUri; this.context = context; this.state = { @@ -94,74 +94,65 @@ export default class AuthProvider implements vscode.WebviewViewProvider { // Await for readyness when the extension activates from the outside. // E.g. Running a query without opening the extension. - this.context.waitReadyness().then(() => { + this.context.isReady().then(() => { this.state = { ...this.state, isLoading: false, error: undefined, }; }); + } - this.context.on("event", (data) => { - const { type } = data; - switch (type) { - case EventType.error: { - const { message } = data; - console.log("[AuthProvider]", "Error detected: ", message, data); - this.state.error = message; - this.state.isLoading = false; - - if (this._view) { - this._view.webview.html = this._getHtmlForWebview(this._view.webview); - } - break; - } - case EventType.newProfiles: { - console.log("[AuthProvider]", "New profiles available."); - if (this._view) { - console.log("[AuthProvider]", "Posting new profiles."); - const profiles = this.context.getProfileNames(); - const profile = this.context.getProfileName(); - - const thenable = this._view.webview.postMessage({ type: "newProfile", data: { profiles, profile } }); - thenable.then((posted) => { - console.log("[AuthProvider]", "Profiles message posted: ", posted); - }); - } - break; - } + private capitalizeFirstLetter(str: string) { + if (typeof str !== "string") { + return; + } + if (str.length === 0) { + return str; + } - case EventType.environmentChange: { - console.log("[AuthProvider]", "Environment change."); - if (this._view) { - const thenable = this._view.webview.postMessage({ type: "environmentChange" }); - thenable.then((posted) => { - console.log("[AuthProvider]", "Environment change message posted: ", posted); - }); - } - break; - } + const firstChar = str.charAt(0); + const capitalized = firstChar.toUpperCase() + str.slice(1); - case EventType.environmentLoaded: { - console.log("[AuthProvider]", "New environment available."); - if (this._view) { - this.state.isLoading = false; - this.state.error = undefined; - - // Do not refresh the webview if the user is removing or adding a profile. - // The UI will auto update after this action ends. - if (this.state.isRemoveProfile || this.state.isAddNewProfile) { - return; - } - console.log("[AuthProvider]", "Triggering configuration webview."); - this._view.webview.html = this._getHtmlForWebview(this._view.webview); - } - break; - } - default: - break; + return capitalized; + }; + + async displayError(message: string) { + console.log("[AuthProvider]", "Error detected: ", message); + this.state.error = this.capitalizeFirstLetter(message); + this.state.isLoading = false; + + if (this._view) { + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } + } + + async environmentChange() { + console.log("[AuthProvider]", "Environment change."); + this.state.isLoading = true; + this.state.error = undefined; + + if (this._view) { + const thenable = this._view.webview.postMessage({ type: "environmentChange" }); + thenable.then((posted) => { + console.log("[AuthProvider]", "Environment change message posted: ", posted); + }); + } + } + + async environmentLoaded() { + console.log("[AuthProvider]", "New environment available."); + if (this._view) { + this.state.isLoading = false; + + // Do not refresh the webview if the user is removing or adding a profile. + // The UI will auto update after this action ends. + if (this.state.isRemoveProfile || this.state.isAddNewProfile) { + return; } - }); + console.log("[AuthProvider]", "Triggering configuration webview."); + this._view.webview.html = this._getHtmlForWebview(this._view.webview); + } } /** @@ -204,7 +195,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { // Listen for messages from the Sidebar component and execute action webviewView.webview.onDidReceiveMessage(async ({ data, type }) => { - console.log("[AuthProvider]", type); + console.log("[AuthProvider]", "Receive message: ", type); switch (type) { case "onLogin": { const { name } = data; @@ -282,7 +273,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { if (!messages) { return; } - console.log("[AuthProvider]", messages); + console.log("[AuthProvider]", "logInfo: ", messages); break; } case "logError": { @@ -290,7 +281,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { if (!error) { return; } - console.error("[AuthProvider]", error); + console.error("[AuthProvider]", "logError: ", error); break; } case "onConfigChange": { @@ -336,7 +327,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { // Use a nonce to only allow a specific script to be run. const nonce = getNonce(); - console.log("Is loading: ", this.state.isLoading); + console.log("[AuthProvider]", "Is loading: ", this.state.isLoading); let content = ( ` Profile Name @@ -348,7 +339,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { ); const profileNames = this.context.getProfileNames(); - console.log("[AuthProvider]", this.state, profileNames); + console.log("[AuthProvider]", "State:", this.state, profileNames); if (profileNames) { if (this.state.isAddNewProfile) { content = ( @@ -377,7 +368,7 @@ export default class AuthProvider implements vscode.WebviewViewProvider { const schema = this.context.getSchema(); const cluster = this.context.getCluster(); const profileName = this.context.getProfileName(); - console.log("[AuthProvider]", this.state.error); + console.log("[AuthProvider]", "State error?: ", this.state.error); content = ( `
diff --git a/src/providers/query.ts b/src/providers/query.ts new file mode 100644 index 0000000..59914b4 --- /dev/null +++ b/src/providers/query.ts @@ -0,0 +1,89 @@ +import * as vscode from 'vscode'; +import { randomUUID } from 'crypto'; +import AsyncContext from "../context/asyncContext"; + +export const buildRunSQLCommand = (context: AsyncContext) => { + const sqlCommand = async () => { + const { + activity: activityProvider, + results: resultsProvider + } = context.getProviders(); + console.log("[RunSQLCommand]", "Firing detected."); + + // Check for available profile before proceeding. + if (!context.getProfileName()) { + vscode.window.showErrorMessage('No available profile to run the query.'); + return; + } + + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + vscode.window.showErrorMessage('No active editor.'); + return; + } + + // Focus the query results panel. + vscode.commands.executeCommand('queryResults.focus').then(async () => { + const document = activeEditor.document; + const selection = activeEditor.selection; + const textSelected = activeEditor.document.getText(selection).trim(); + const query = textSelected ? textSelected : document.getText(); + + console.log("[RunSQLCommand]", "Running query: ", query); + + // Identify the query to not overlap results. + // When a user press many times the run query button + // the results from one query can overlap the results + // from another. We only want to display the last results. + const id = randomUUID(); + resultsProvider.setQueryId(id); + + // Benchmark + const startTime = Date.now(); + try { + const results = await context.query(query); + const endTime = Date.now(); + const elapsedTime = endTime - startTime; + + console.log("[RunSQLCommand]", "Results: ", results); + console.log("[RunSQLCommand]", "Emitting results."); + + if (Array.isArray(results)) { + resultsProvider.setResults(id, { ...results[0], elapsedTime, id }); + } else { + resultsProvider.setResults(id, { ...results, elapsedTime, id }); + } + + activityProvider.addLog({ + status: "success", + latency: elapsedTime, + sql: query + }); + } catch (error: any) { + console.log("[RunSQLCommand]", error.toString()); + console.log("[RunSQLCommand]", JSON.stringify(error)); + const endTime = Date.now(); + const elapsedTime = endTime - startTime; + + activityProvider.addLog({ + status: "failure", + latency: elapsedTime, // assuming elapsedTime holds the time taken before the error was caught + sql: query + }); + + + resultsProvider.setResults(id, + undefined, + { + message: error.toString(), + position: error.position, + query, + }); + } finally { + resultsProvider._view?.show(); + } + }); + }; + + return sqlCommand; +}; \ No newline at end of file diff --git a/src/providers/results.ts b/src/providers/results.ts index 72fa98d..48f2bd9 100644 --- a/src/providers/results.ts +++ b/src/providers/results.ts @@ -1,14 +1,22 @@ import * as vscode from "vscode"; -import { Context } from "../context"; -import { EventType } from "../context/context"; import { getUri } from "../utilities/getUri"; import { getNonce } from "../utilities/getNonce"; import { QueryResult } from "pg"; +interface Results extends QueryResult { + id: string, + elapsedTime: number, +} + +interface ResultsError { + message: string, + position: number, + query: string, +} + export default class ResultsProvider implements vscode.WebviewViewProvider { _view?: vscode.WebviewView; _doc?: vscode.TextDocument; - private context: Context; // Identifies the identifier of the last query run by the user. // It is used to display the results and not overlap them from the results of a laggy query. @@ -24,43 +32,56 @@ export default class ResultsProvider implements vscode.WebviewViewProvider { // new data arriving and to render the information. private isScriptReady: boolean; - constructor(private readonly _extensionUri: vscode.Uri, context: Context) { + constructor(private readonly _extensionUri: vscode.Uri) { this._extensionUri = _extensionUri; - this.context = context; this.isScriptReady = false; + } - this.context.on("event", ({ type, data }) => { - if (this._view) { - if (type === EventType.queryResults) { - const { id } = data; - console.log("[ResultsProvider]", "New query results.", this.lastQueryId); - - // Check if the results are from the last issued query. - if (this.lastQueryId === id || this.lastQueryId === undefined) { - console.log("[ResultsProvider]", "Is script ready : ", this.isScriptReady); - if (this.isScriptReady) { - const thenable = this._view.webview.postMessage({ type: "results", data }); - - thenable.then((posted) => { - console.log("[ResultsProvider]", "Message posted: ", posted); - }); - } else { - console.log("[ResultsProvider]", "The script is not ready yet."); - this.pendingDataToRender = data; - } - } - } else if (type === EventType.newQuery) { - const { id } = data; - this.lastQueryId = id; + /** + * Cleans the results and sets a latest query id. + * @param id + */ + public setQueryId(id: string) { + this.lastQueryId = id; + + if (this._view) { + console.log("[ResultsProvider]", "New query."); + const thenable = this._view.webview.postMessage({ type: "newQuery" }); + thenable.then((posted) => { + console.log("[ResultsProvider]", "Message posted: ", posted); + }); + } + } - console.log("[ResultsProvider]", "New query."); - const thenable = this._view.webview.postMessage({ type: "newQuery", data }); + /** + * Displays the results from the latest query. + * @param id + * @param queryResults + */ + public setResults(id: string, results?: Results, error?: ResultsError) { + console.log("[ResultsProvider]", "New query results.", this.lastQueryId); + + // Check if the results are from the last issued query. + if (this._view && (this.lastQueryId === id || this.lastQueryId === undefined)) { + console.log("[ResultsProvider]", "Is script ready : ", this.isScriptReady); + if (this.isScriptReady) { + + if (results) { + const thenable = this._view.webview.postMessage({ type: "results", data: results }); + thenable.then((posted) => { + console.log("[ResultsProvider]", "Message posted: ", posted); + }); + } else if (error) { + const thenable = this._view.webview.postMessage({ type: "results", data: { error } }); thenable.then((posted) => { console.log("[ResultsProvider]", "Message posted: ", posted); }); } + } else { + console.log("[ResultsProvider]", "The script is not ready yet."); + this.pendingDataToRender = results; } - }); + } } public resolveWebviewView(webviewView: vscode.WebviewView) { diff --git a/src/providers/schema.ts b/src/providers/schema.ts index 7d5d897..085fa08 100644 --- a/src/providers/schema.ts +++ b/src/providers/schema.ts @@ -1,21 +1,14 @@ import * as vscode from 'vscode'; -import Context, { EventType } from '../context/context'; -import path = require('path'); +import AsyncContext from '../context/asyncContext'; export default class DatabaseTreeProvider implements vscode.TreeDataProvider { private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; - private context: Context; + private context: AsyncContext; - constructor(context: Context) { + constructor(context: AsyncContext) { this.context = context; - this.context.on("event", ({ type }) => { - if (type === EventType.environmentChange) { - console.log("[DatabaseTreeProvider]", "Environment change detected. Refreshing provider."); - this.refresh(); - } - }); } refresh(): void { @@ -41,7 +34,7 @@ export default class DatabaseTreeProvider implements vscode.TreeDataProvider): Promise> { - const { rows } = await this.context.query(text, vals); + try { + const { rows } = await this.context.query(text, vals); - return rows; + return rows; + } catch (err) { + return []; + } } private async getChildrenFromNode(element: Node): Promise> { diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 7f5caee..4e7505f 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -2,13 +2,13 @@ // as well as import your extension to test it import * as vscode from 'vscode'; import * as assert from 'assert'; -import { Context, EventType } from '../../context'; import { mockServer } from './server'; import { Config } from '../../context/config'; import * as os from "os"; import * as fs from "fs"; import AppPassword from '../../context/appPassword'; import { randomUUID } from 'crypto'; +import AsyncContext from '../../context/asyncContext'; /** * Simple util function to use delay. @@ -19,23 +19,6 @@ function delay(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } -/** - * Waits for an event to happen inside the context. - * @param context - * @param eventType - * @returns when the event is received. - */ -function waitForEvent(context: Context, eventType: EventType): Promise { - return new Promise((res) => { - context.on("event", (data) => { - const { type } = data; - if (type === eventType) { - res(data); - }; - }); - }); -} - // Remove the configuration file if exists. const configDir = `${os.homedir()}/.config/materialize/test`; const filePath = `${configDir}/mz.toml`; @@ -43,6 +26,8 @@ const filePath = `${configDir}/mz.toml`; suite('Extension Test Suite', () => { vscode.window.showInformationMessage('Start all tests.'); + let extension: vscode.Extension; + suiteSetup(async () => { await vscode.commands.executeCommand('workbench.action.closeAllEditors'); await mockServer(); @@ -58,9 +43,6 @@ suite('Extension Test Suite', () => { } }); - let extension: vscode.Extension; - let context: Context; - // TODO: Remove after 0.3.0 test('Migration', async () => { const configDir = `${os.homedir()}/.config/materialize/test`; @@ -222,55 +204,56 @@ suite('Extension Test Suite', () => { }); test('Test context readiness', async () => { - const _context: Context = await extension.activate(); + const _context: AsyncContext = await extension.activate(); assert.ok(typeof _context !== null && typeof _context !== "undefined"); - context = _context; - await _context.waitReadyness(); + await _context.isReady(); }).timeout(10000); test('Test query execution', async () => { - const listenNewQueryChange = waitForEvent(context, EventType.newQuery); - const listenQueryResultsChange = waitForEvent(context, EventType.queryResults); - await vscode.commands.executeCommand("materialize.run"); + const _context: AsyncContext = await extension.activate(); + const rows = _context.query("SELECT 100"); - // TODO: Verify the rows are ok. - await listenNewQueryChange; - await listenQueryResultsChange; + assert.ok((await rows).rowCount > 0); },); test('Change cluster', async () => { - const listenEnvironmentChange = waitForEvent(context, EventType.environmentChange); + const context: AsyncContext = await extension.activate(); const clusterName = context.getCluster(); const altClusterName = context.getClusters()?.find(x => x.name !== clusterName); assert.ok(typeof altClusterName?.name === "string"); context.setCluster(altClusterName.name); - await listenEnvironmentChange; + const rows = await context.query("SHOW CLUSTER;"); + + assert.ok(rows.rows[0].cluster === altClusterName.name); }).timeout(10000); /** * Profiles */ test('Test profiles are loaded', async () => { + const context: AsyncContext = await extension.activate(); const profileNames = context.getProfileNames(); assert.ok(profileNames && profileNames.length > 0); }); test('Change profile', async () => { - const listenProfileChange = waitForEvent(context, EventType.environmentChange); + const context: AsyncContext = await extension.activate(); const profileName = context.getProfileName(); const altProfileName = context.getProfileNames()?.find(x => x !== profileName); + console.log("Alt Profile name: ", altProfileName); assert.ok(typeof altProfileName === "string"); - context.setProfile(altProfileName); - await listenProfileChange; + + await context.setProfile(altProfileName); assert.ok(altProfileName === context.getProfileName()); }).timeout(15000); test('Detect invalid password', async () => { - const listenErrorPromise = waitForEvent(context, EventType.error); - context.setProfile("invalid_profile"); + const context: AsyncContext = await extension.activate(); + const success = await context.setProfile("invalid_profile"); + + assert.ok(!success); - await listenErrorPromise; }).timeout(10000); }); \ No newline at end of file diff --git a/src/test/suite/server.ts b/src/test/suite/server.ts index a70f8b6..4f24050 100644 --- a/src/test/suite/server.ts +++ b/src/test/suite/server.ts @@ -38,17 +38,6 @@ export function mockServer(): Promise { return; } - // if (clientId === "52881e4b-8c72-4ec1-bcc6-f9d22155821b") { - // // TODO: Return a token with a missing email. - // res.json({ - // accessToken: token, - // expires: "Mon, 31 Jul 2023 10:59:33 GMT", - // expiresIn: 600, - // refreshToken: "MOCK", - // }); - // return; - // } - res.json({ accessToken: token, expires: "Mon, 31 Jul 2023 10:59:33 GMT", diff --git a/src/utilities/error.ts b/src/utilities/error.ts index f39f6a2..474b5c0 100644 --- a/src/utilities/error.ts +++ b/src/utilities/error.ts @@ -92,4 +92,12 @@ export enum Errors { * using the Postgres client. */ unexpectedSqlClientConnectionError = "An unexpected error happened while establishing a connection to your region environment.", + /** + * Raises when a profile that does not exists in the configuration. + */ + profileDoesNotExist = "The selected profile does not exist.", + /** + * Raises when a query fails. + */ + queryFailure = "Error querying server." }