From 2c3288dbd26195b2ddf80a5b3d314aa4bd1f862c Mon Sep 17 00:00:00 2001 From: Tammam Mustafa Date: Tue, 1 Oct 2024 19:19:04 -0400 Subject: [PATCH 1/8] MVP working dataconnect:sql:shell --- src/commands/dataconnect-sql-shell.ts | 152 ++++++++++++++++++++++++++ src/commands/index.ts | 1 + src/dataconnect/schemaMigration.ts | 2 +- 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 src/commands/dataconnect-sql-shell.ts diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts new file mode 100644 index 00000000000..719ade17ae2 --- /dev/null +++ b/src/commands/dataconnect-sql-shell.ts @@ -0,0 +1,152 @@ +import * as pg from "pg"; +import * as ora from "ora"; +import chalk from 'chalk'; +import { Connector, IpAddressTypes, AuthTypes } from "@google-cloud/cloud-sql-connector"; + +import { Command } from "../command"; +import { Options } from "../options"; +import { needProjectId } from "../projectUtils"; +import { ensureApis } from "../dataconnect/ensureApis"; +import { requirePermissions } from "../requirePermissions"; +import { pickService } from "../dataconnect/fileUtils"; +import { getIdentifiers } from "../dataconnect/schemaMigration"; +import { requireAuth } from "../requireAuth"; +import { getIAMUser } from "../gcp/cloudsql/connect"; +import * as cloudSqlAdminClient from "../gcp/cloudsql/cloudsqladmin"; +import { prompt, promptOnce, confirm, Question } from '../prompt'; +import { logger } from "../logger"; +import { FirebaseError } from "../error"; +import { FBToolsAuthClient } from "../gcp/cloudsql/fbToolsAuthClient"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Table = require("cli-table"); + +async function executeQuery(query: string, conn: pg.PoolClient) { + const spinner = ora('Executing query...').start(); + try { + const results = await conn.query(query); + spinner.succeed(chalk.green('Query executed successfully')); + + if (Array.isArray(results.rows) && results.rows.length > 0) { + const table = new Table({ + head: Object.keys(results.rows[0]).map(key => chalk.cyan(key)), + style: { head: [], border: [] } + }); + + results.rows.forEach(row => { + table.push(Object.values(row) as any); + }); + + logger.info(table.toString()); + } else { + // If nothing is returned and the query was select, let the user know there was no results. + if (query.toUpperCase().includes('SELECT')) + logger.info(chalk.yellow('No results returned')); + } + } catch (err) { + spinner.fail(chalk.red(`Failed executing query: ${err}`)); + } +} + +const sqlKeywords = ['SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'GROUP BY', 'ORDER BY', 'LIMIT', 'GRANT', 'CREATE', 'DROP']; + +async function promptForQuery(): Promise { + const question: Question = { + type: 'input', + name: 'query', + message: 'Enter your SQL query (or "exit" to quit):', + transformer: (input: string) => { + // Highlight SQL keywords + return input.split(' ').map(word => + sqlKeywords.includes(word.toUpperCase()) ? chalk.cyan(word) : word + ).join(' '); + } + }; + + const { query } = await prompt({ nonInteractive: false }, [question]); + return query; +} + +async function confirmDangerousQuery(query: string): Promise { + if (query.toUpperCase().includes('DROP') || query.toUpperCase().includes('DELETE')) { + return await confirm({ + message: chalk.yellow('This query may be destructive. Are you sure you want to proceed?'), + default: false, + }); + } + return true; +} + +export const command = new Command("dataconnect:sql:shell [serviceId]") + .description( + "Starts a shell connected directly to your cloudsql instance.", + ) + .before(requirePermissions, [ + "firebasedataconnect.services.list", + "cloudsql.instances.connect", + ]) + .before(requireAuth) + .action(async (serviceId: string, options: Options) => { + const projectId = needProjectId(options); + await ensureApis(projectId); + const serviceInfo = await pickService(projectId, options.config, serviceId); + + const { serviceName, instanceId, instanceName, databaseId } = getIdentifiers(serviceInfo.schema); + + const { user:username, mode } = await getIAMUser(options); + + const instance = await cloudSqlAdminClient.getInstance(projectId, instanceId); + + const connectionName = instance.connectionName; + if (!connectionName) { + throw new FirebaseError( + `Could not get instance connection string for ${options.instanceId}:${options.databaseId}`, + ); + } + + let connector: Connector = new Connector({ + auth: new FBToolsAuthClient(), + }); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: IpAddressTypes.PUBLIC, + authType: AuthTypes.IAM, + }); + let pool: pg.Pool = new pg.Pool({ + ...clientOpts, + user: username, + database: databaseId, + }); + + const conn: pg.PoolClient = await pool.connect(); + logger.info(`Logged in as ${username}`); + + + logger.info(chalk.cyan('Welcome to the GCP SQL Shell')); + logger.info(chalk.gray('Type your SQL queries or "exit" to quit.\n')); + + + while (true) { + const query = await promptForQuery(); + if (query.toLowerCase() === 'exit') { + break; + } + + if (query == '') { + continue; + } + + if (await confirmDangerousQuery(query)) { + await executeQuery(query, conn); + } else { + logger.info(chalk.yellow('Query cancelled.')); + } + } + + logger.info(chalk.yellow('Exiting shell...')); + conn.release(); + await pool.end(); + connector.close(); + + return { projectId, serviceId }; + }); diff --git a/src/commands/index.ts b/src/commands/index.ts index d49fe3a3504..a30633b357d 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -215,6 +215,7 @@ export function load(client: any): any { client.dataconnect.sql.diff = loadCommand("dataconnect-sql-diff"); client.dataconnect.sql.migrate = loadCommand("dataconnect-sql-migrate"); client.dataconnect.sql.grant = loadCommand("dataconnect-sql-grant"); + client.dataconnect.sql.shell = loadCommand("dataconnect-sql-shell"); client.dataconnect.sdk = {}; client.dataconnect.sdk.generate = loadCommand("dataconnect-sdk-generate"); client.target = loadCommand("target"); diff --git a/src/dataconnect/schemaMigration.ts b/src/dataconnect/schemaMigration.ts index a19b1019ae7..e26df809eed 100644 --- a/src/dataconnect/schemaMigration.ts +++ b/src/dataconnect/schemaMigration.ts @@ -282,7 +282,7 @@ function setSchemaValidationMode(schema: Schema, schemaValidation: SchemaValidat } } -function getIdentifiers(schema: Schema): { +export function getIdentifiers(schema: Schema): { instanceName: string; instanceId: string; databaseId: string; From 5dc52122ba30ccd2430dd335cf2b9b74324e9e76 Mon Sep 17 00:00:00 2001 From: Tammam Mustafa Date: Fri, 4 Oct 2024 19:02:45 -0400 Subject: [PATCH 2/8] Allow multiline SQL queries (copy paste not working however) --- src/commands/dataconnect-sql-shell.ts | 45 +++++++++++++++++---------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts index 719ade17ae2..fc354bb0ae3 100644 --- a/src/commands/dataconnect-sql-shell.ts +++ b/src/commands/dataconnect-sql-shell.ts @@ -41,7 +41,7 @@ async function executeQuery(query: string, conn: pg.PoolClient) { } else { // If nothing is returned and the query was select, let the user know there was no results. if (query.toUpperCase().includes('SELECT')) - logger.info(chalk.yellow('No results returned')); + logger.info(chalk.yellow('No results returned')); } } catch (err) { spinner.fail(chalk.red(`Failed executing query: ${err}`)); @@ -51,19 +51,32 @@ async function executeQuery(query: string, conn: pg.PoolClient) { const sqlKeywords = ['SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'GROUP BY', 'ORDER BY', 'LIMIT', 'GRANT', 'CREATE', 'DROP']; async function promptForQuery(): Promise { - const question: Question = { - type: 'input', - name: 'query', - message: 'Enter your SQL query (or "exit" to quit):', - transformer: (input: string) => { - // Highlight SQL keywords - return input.split(' ').map(word => - sqlKeywords.includes(word.toUpperCase()) ? chalk.cyan(word) : word - ).join(' '); + let query = '' + let line = '' + + do { + const question: Question = { + type: 'input', + name: 'line', + message: query ? '> ' : 'Enter your SQL query (or ".exit"):', + transformer: (input: string) => { + // Highlight SQL keywords + return input.split(' ').map(word => + sqlKeywords.includes(word.toUpperCase()) ? chalk.cyan(word) : word + ).join(' '); + } + }; + + ({ line } = await prompt({ nonInteractive: false }, [question])); + + line = line.trimEnd() + + if (line.toLowerCase() === '.exit') { + return '.exit'; } - }; - const { query } = await prompt({ nonInteractive: false }, [question]); + query += (query ? '\n' : '') + line; + } while (line !== '' && !query.endsWith(';')); return query; } @@ -93,7 +106,7 @@ export const command = new Command("dataconnect:sql:shell [serviceId]") const { serviceName, instanceId, instanceName, databaseId } = getIdentifiers(serviceInfo.schema); - const { user:username, mode } = await getIAMUser(options); + const { user: username, mode } = await getIAMUser(options); const instance = await cloudSqlAdminClient.getInstance(projectId, instanceId); @@ -123,12 +136,12 @@ export const command = new Command("dataconnect:sql:shell [serviceId]") logger.info(chalk.cyan('Welcome to the GCP SQL Shell')); - logger.info(chalk.gray('Type your SQL queries or "exit" to quit.\n')); + logger.info(chalk.gray('Type your your SQL query or ".exit" to quit, queries should end with \';\' or add empty line to execute.')); + - while (true) { const query = await promptForQuery(); - if (query.toLowerCase() === 'exit') { + if (query.toLowerCase() === '.exit') { break; } From 108ba709fb7c47dbf63a0d44ba67f68ad38c0030 Mon Sep 17 00:00:00 2001 From: Tammam Mustafa Date: Mon, 7 Oct 2024 17:19:47 -0400 Subject: [PATCH 3/8] Fix linting issues --- src/commands/dataconnect-sql-shell.ts | 274 +++++++++++++------------- 1 file changed, 142 insertions(+), 132 deletions(-) diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts index fc354bb0ae3..7ba1f3be8aa 100644 --- a/src/commands/dataconnect-sql-shell.ts +++ b/src/commands/dataconnect-sql-shell.ts @@ -1,6 +1,6 @@ import * as pg from "pg"; import * as ora from "ora"; -import chalk from 'chalk'; +import chalk from "chalk"; import { Connector, IpAddressTypes, AuthTypes } from "@google-cloud/cloud-sql-connector"; import { Command } from "../command"; @@ -13,7 +13,7 @@ import { getIdentifiers } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; import { getIAMUser } from "../gcp/cloudsql/connect"; import * as cloudSqlAdminClient from "../gcp/cloudsql/cloudsqladmin"; -import { prompt, promptOnce, confirm, Question } from '../prompt'; +import { prompt, confirm, Question } from "../prompt"; import { logger } from "../logger"; import { FirebaseError } from "../error"; import { FBToolsAuthClient } from "../gcp/cloudsql/fbToolsAuthClient"; @@ -21,145 +21,155 @@ import { FBToolsAuthClient } from "../gcp/cloudsql/fbToolsAuthClient"; // eslint-disable-next-line @typescript-eslint/no-var-requires const Table = require("cli-table"); +const sqlKeywords = [ + "SELECT", + "FROM", + "WHERE", + "INSERT", + "UPDATE", + "DELETE", + "JOIN", + "GROUP", + "ORDER", + "LIMIT", + "GRANT", + "CREATE", + "DROP", +]; + async function executeQuery(query: string, conn: pg.PoolClient) { - const spinner = ora('Executing query...').start(); - try { - const results = await conn.query(query); - spinner.succeed(chalk.green('Query executed successfully')); - - if (Array.isArray(results.rows) && results.rows.length > 0) { - const table = new Table({ - head: Object.keys(results.rows[0]).map(key => chalk.cyan(key)), - style: { head: [], border: [] } - }); - - results.rows.forEach(row => { - table.push(Object.values(row) as any); - }); - - logger.info(table.toString()); - } else { - // If nothing is returned and the query was select, let the user know there was no results. - if (query.toUpperCase().includes('SELECT')) - logger.info(chalk.yellow('No results returned')); - } - } catch (err) { - spinner.fail(chalk.red(`Failed executing query: ${err}`)); + const spinner = ora("Executing query...").start(); + try { + const results = await conn.query(query); + spinner.succeed(chalk.green("Query executed successfully")); + + if (Array.isArray(results.rows) && results.rows.length > 0) { + const table = new Table({ + head: Object.keys(results.rows[0]).map((key) => chalk.cyan(key)), + style: { head: [], border: [] }, + }); + + results.rows.forEach((row) => { + table.push(Object.values(row) as any); + }); + + logger.info(table.toString()); + } else { + // If nothing is returned and the query was select, let the user know there was no results. + if (query.toUpperCase().includes("SELECT")) { + logger.info(chalk.yellow("No results returned")); + } } + } catch (err) { + spinner.fail(chalk.red(`Failed executing query: ${err}`)); + } } -const sqlKeywords = ['SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'JOIN', 'GROUP BY', 'ORDER BY', 'LIMIT', 'GRANT', 'CREATE', 'DROP']; - async function promptForQuery(): Promise { - let query = '' - let line = '' - - do { - const question: Question = { - type: 'input', - name: 'line', - message: query ? '> ' : 'Enter your SQL query (or ".exit"):', - transformer: (input: string) => { - // Highlight SQL keywords - return input.split(' ').map(word => - sqlKeywords.includes(word.toUpperCase()) ? chalk.cyan(word) : word - ).join(' '); - } - }; - - ({ line } = await prompt({ nonInteractive: false }, [question])); - - line = line.trimEnd() - - if (line.toLowerCase() === '.exit') { - return '.exit'; - } - - query += (query ? '\n' : '') + line; - } while (line !== '' && !query.endsWith(';')); - return query; + let query = ""; + let line = ""; + + do { + const question: Question = { + type: "input", + name: "line", + message: query ? "> " : "Enter your SQL query (or '.exit'):", + transformer: (input: string) => { + // Highlight SQL keywords + return input + .split(" ") + .map((word) => (sqlKeywords.includes(word.toUpperCase()) ? chalk.cyan(word) : word)) + .join(" "); + }, + }; + + ({ line } = await prompt({ nonInteractive: false }, [question])); + line = line.trimEnd(); + + if (line.toLowerCase() === ".exit") { + return ".exit"; + } + + query += (query ? "\n" : "") + line; + } while (line !== "" && !query.endsWith(";")); + return query; } async function confirmDangerousQuery(query: string): Promise { - if (query.toUpperCase().includes('DROP') || query.toUpperCase().includes('DELETE')) { - return await confirm({ - message: chalk.yellow('This query may be destructive. Are you sure you want to proceed?'), - default: false, - }); - } - return true; + if (query.toUpperCase().includes("DROP") || query.toUpperCase().includes("DELETE")) { + return await confirm({ + message: chalk.yellow("This query may be destructive. Are you sure you want to proceed?"), + default: false, + }); + } + return true; } export const command = new Command("dataconnect:sql:shell [serviceId]") - .description( - "Starts a shell connected directly to your cloudsql instance.", - ) - .before(requirePermissions, [ - "firebasedataconnect.services.list", - "cloudsql.instances.connect", - ]) - .before(requireAuth) - .action(async (serviceId: string, options: Options) => { - const projectId = needProjectId(options); - await ensureApis(projectId); - const serviceInfo = await pickService(projectId, options.config, serviceId); - - const { serviceName, instanceId, instanceName, databaseId } = getIdentifiers(serviceInfo.schema); - - const { user: username, mode } = await getIAMUser(options); - - const instance = await cloudSqlAdminClient.getInstance(projectId, instanceId); - - const connectionName = instance.connectionName; - if (!connectionName) { - throw new FirebaseError( - `Could not get instance connection string for ${options.instanceId}:${options.databaseId}`, - ); - } - - let connector: Connector = new Connector({ - auth: new FBToolsAuthClient(), - }); - const clientOpts = await connector.getOptions({ - instanceConnectionName: connectionName, - ipType: IpAddressTypes.PUBLIC, - authType: AuthTypes.IAM, - }); - let pool: pg.Pool = new pg.Pool({ - ...clientOpts, - user: username, - database: databaseId, - }); - - const conn: pg.PoolClient = await pool.connect(); - logger.info(`Logged in as ${username}`); - - - logger.info(chalk.cyan('Welcome to the GCP SQL Shell')); - logger.info(chalk.gray('Type your your SQL query or ".exit" to quit, queries should end with \';\' or add empty line to execute.')); - - - while (true) { - const query = await promptForQuery(); - if (query.toLowerCase() === '.exit') { - break; - } - - if (query == '') { - continue; - } - - if (await confirmDangerousQuery(query)) { - await executeQuery(query, conn); - } else { - logger.info(chalk.yellow('Query cancelled.')); - } - } - - logger.info(chalk.yellow('Exiting shell...')); - conn.release(); - await pool.end(); - connector.close(); - - return { projectId, serviceId }; + .description("Starts a shell connected directly to your cloudsql instance.") + .before(requirePermissions, ["firebasedataconnect.services.list", "cloudsql.instances.connect"]) + .before(requireAuth) + .action(async (serviceId: string, options: Options) => { + const projectId = needProjectId(options); + await ensureApis(projectId); + const serviceInfo = await pickService(projectId, options.config, serviceId); + const { instanceId, databaseId } = getIdentifiers(serviceInfo.schema); + const { user: username } = await getIAMUser(options); + const instance = await cloudSqlAdminClient.getInstance(projectId, instanceId); + + // Setup the connection + const connectionName = instance.connectionName; + if (!connectionName) { + throw new FirebaseError( + `Could not get instance connection string for ${options.instanceId}:${options.databaseId}`, + ); + } + const connector: Connector = new Connector({ + auth: new FBToolsAuthClient(), + }); + const clientOpts = await connector.getOptions({ + instanceConnectionName: connectionName, + ipType: IpAddressTypes.PUBLIC, + authType: AuthTypes.IAM, + }); + const pool: pg.Pool = new pg.Pool({ + ...clientOpts, + user: username, + database: databaseId, }); + const conn: pg.PoolClient = await pool.connect(); + + logger.info(`Logged in as ${username}`); + logger.info(chalk.cyan("Welcome to the GCP SQL Shell")); + logger.info( + chalk.gray( + "Type your your SQL query or '.exit' to quit, queries should end with ';' or add empty line to execute.", + ), + ); + + // Start accepting queries + while (true) { + const query = await promptForQuery(); + if (query.toLowerCase() === ".exit") { + break; + } + + if (query === "") { + continue; + } + + if (await confirmDangerousQuery(query)) { + await executeQuery(query, conn); + } else { + logger.info(chalk.yellow("Query cancelled.")); + } + } + + // Cleanup after exit + logger.info(chalk.yellow("Exiting shell...")); + conn.release(); + await pool.end(); + connector.close(); + + return { projectId, serviceId }; + }); From 2d366467ce54d7cf467124a61b350f0fe4002d64 Mon Sep 17 00:00:00 2001 From: Tammam Mustafa Date: Tue, 8 Oct 2024 15:34:23 -0400 Subject: [PATCH 4/8] Update Changlog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b00b880932..640cb43cef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ - Updated emulator UI to version 1.14.0, which adds support for SDK defined extensions. - Added emulator support for SDK defined extensions. - Fixed various trigger handling issues in the Functions emualtor, including an issue where Eventarc functions would not be emulated correctly after a reload. +- Added new command dataconnect:sql:shell which allows users to directly connect and run queries against their dataconnect cloudsql instance. From c92e1f2e711b656e41a01fc3cdbbc55010e8b6c7 Mon Sep 17 00:00:00 2001 From: Tammam Mustafa Date: Tue, 8 Oct 2024 15:46:04 -0400 Subject: [PATCH 5/8] Minor text changes --- src/commands/dataconnect-sql-shell.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts index 7ba1f3be8aa..78c27d2f1f1 100644 --- a/src/commands/dataconnect-sql-shell.ts +++ b/src/commands/dataconnect-sql-shell.ts @@ -106,7 +106,7 @@ async function confirmDangerousQuery(query: string): Promise { } export const command = new Command("dataconnect:sql:shell [serviceId]") - .description("Starts a shell connected directly to your cloudsql instance.") + .description("Starts a shell connected directly to your dataconnect cloudsql instance.") .before(requirePermissions, ["firebasedataconnect.services.list", "cloudsql.instances.connect"]) .before(requireAuth) .action(async (serviceId: string, options: Options) => { @@ -140,7 +140,7 @@ export const command = new Command("dataconnect:sql:shell [serviceId]") const conn: pg.PoolClient = await pool.connect(); logger.info(`Logged in as ${username}`); - logger.info(chalk.cyan("Welcome to the GCP SQL Shell")); + logger.info(chalk.cyan("Welcome to Data Connect Cloud SQL Shell")); logger.info( chalk.gray( "Type your your SQL query or '.exit' to quit, queries should end with ';' or add empty line to execute.", From b9adc0c6774a3849cb9a426de87ca944aaa1ecff Mon Sep 17 00:00:00 2001 From: Tammam Mustafa Date: Tue, 15 Oct 2024 16:39:02 -0400 Subject: [PATCH 6/8] Move some interactive sql shell command to gcp/cloudsql/interactive.ts --- src/commands/dataconnect-sql-shell.ts | 76 +++++++-------------------- src/gcp/cloudsql/interactive.ts | 55 +++++++++++++++++++ 2 files changed, 75 insertions(+), 56 deletions(-) create mode 100644 src/gcp/cloudsql/interactive.ts diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts index 78c27d2f1f1..0416b9b6634 100644 --- a/src/commands/dataconnect-sql-shell.ts +++ b/src/commands/dataconnect-sql-shell.ts @@ -1,5 +1,4 @@ import * as pg from "pg"; -import * as ora from "ora"; import chalk from "chalk"; import { Connector, IpAddressTypes, AuthTypes } from "@google-cloud/cloud-sql-connector"; @@ -13,14 +12,13 @@ import { getIdentifiers } from "../dataconnect/schemaMigration"; import { requireAuth } from "../requireAuth"; import { getIAMUser } from "../gcp/cloudsql/connect"; import * as cloudSqlAdminClient from "../gcp/cloudsql/cloudsqladmin"; -import { prompt, confirm, Question } from "../prompt"; +import { prompt, Question } from "../prompt"; import { logger } from "../logger"; import { FirebaseError } from "../error"; import { FBToolsAuthClient } from "../gcp/cloudsql/fbToolsAuthClient"; +import { confirmDangerousQuery, interactiveExecuteQuery } from "../gcp/cloudsql/interactive"; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const Table = require("cli-table"); - +// Not a comprehensive list, used for keyword coloring. const sqlKeywords = [ "SELECT", "FROM", @@ -37,34 +35,6 @@ const sqlKeywords = [ "DROP", ]; -async function executeQuery(query: string, conn: pg.PoolClient) { - const spinner = ora("Executing query...").start(); - try { - const results = await conn.query(query); - spinner.succeed(chalk.green("Query executed successfully")); - - if (Array.isArray(results.rows) && results.rows.length > 0) { - const table = new Table({ - head: Object.keys(results.rows[0]).map((key) => chalk.cyan(key)), - style: { head: [], border: [] }, - }); - - results.rows.forEach((row) => { - table.push(Object.values(row) as any); - }); - - logger.info(table.toString()); - } else { - // If nothing is returned and the query was select, let the user know there was no results. - if (query.toUpperCase().includes("SELECT")) { - logger.info(chalk.yellow("No results returned")); - } - } - } catch (err) { - spinner.fail(chalk.red(`Failed executing query: ${err}`)); - } -} - async function promptForQuery(): Promise { let query = ""; let line = ""; @@ -95,14 +65,23 @@ async function promptForQuery(): Promise { return query; } -async function confirmDangerousQuery(query: string): Promise { - if (query.toUpperCase().includes("DROP") || query.toUpperCase().includes("DELETE")) { - return await confirm({ - message: chalk.yellow("This query may be destructive. Are you sure you want to proceed?"), - default: false, - }); +async function mainShellLoop(conn: pg.PoolClient) { + while (true) { + const query = await promptForQuery(); + if (query.toLowerCase() === ".exit") { + break; + } + + if (query === "") { + continue; + } + + if (await confirmDangerousQuery(query)) { + await interactiveExecuteQuery(query, conn); + } else { + logger.info(chalk.yellow("Query cancelled.")); + } } - return true; } export const command = new Command("dataconnect:sql:shell [serviceId]") @@ -148,22 +127,7 @@ export const command = new Command("dataconnect:sql:shell [serviceId]") ); // Start accepting queries - while (true) { - const query = await promptForQuery(); - if (query.toLowerCase() === ".exit") { - break; - } - - if (query === "") { - continue; - } - - if (await confirmDangerousQuery(query)) { - await executeQuery(query, conn); - } else { - logger.info(chalk.yellow("Query cancelled.")); - } - } + await mainShellLoop(conn); // Cleanup after exit logger.info(chalk.yellow("Exiting shell...")); diff --git a/src/gcp/cloudsql/interactive.ts b/src/gcp/cloudsql/interactive.ts new file mode 100644 index 00000000000..ff5ca621851 --- /dev/null +++ b/src/gcp/cloudsql/interactive.ts @@ -0,0 +1,55 @@ +import * as pg from "pg"; +import * as ora from "ora"; +import chalk from "chalk"; +import { logger } from "../../logger"; +import { confirm } from "../../prompt"; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const Table = require("cli-table"); + +// Not comprehensive list, used for best offer prompting. +const destructiveSqlKeywords = ["DROP", "DELETE"]; + +function checkIsDestructiveSql(query: string): boolean { + const upperCaseQuery = query.toUpperCase(); + return destructiveSqlKeywords.some((keyword) => upperCaseQuery.includes(keyword.toUpperCase())); +} + +export async function confirmDangerousQuery(query: string): Promise { + if (checkIsDestructiveSql(query)) { + return await confirm({ + message: chalk.yellow("This query may be destructive. Are you sure you want to proceed?"), + default: false, + }); + } + return true; +} + +// Pretty query execution display such as spinner and actual returned content for `SELECT` query. +export async function interactiveExecuteQuery(query: string, conn: pg.PoolClient) { + const spinner = ora("Executing query...").start(); + try { + const results = await conn.query(query); + spinner.succeed(chalk.green("Query executed successfully")); + + if (Array.isArray(results.rows) && results.rows.length > 0) { + const table: any[] = new Table({ + head: Object.keys(results.rows[0]).map((key) => chalk.cyan(key)), + style: { head: [], border: [] }, + }); + + for (const row of results.rows) { + table.push(Object.values(row) as any); + } + + logger.info(table.toString()); + } else { + // If nothing is returned and the query was select, let the user know there was no results. + if (query.toUpperCase().includes("SELECT")) { + logger.info(chalk.yellow("No results returned")); + } + } + } catch (err) { + spinner.fail(chalk.red(`Failed executing query: ${err}`)); + } +} From 057221c9415a7dce58c3ee71c78ef8f55470a125 Mon Sep 17 00:00:00 2001 From: Tammam Mustafa Date: Thu, 17 Oct 2024 16:44:56 -0400 Subject: [PATCH 7/8] Use colorette instead of chalk --- src/commands/dataconnect-sql-shell.ts | 12 ++++++------ src/gcp/cloudsql/interactive.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/commands/dataconnect-sql-shell.ts b/src/commands/dataconnect-sql-shell.ts index 0416b9b6634..9055267d6a5 100644 --- a/src/commands/dataconnect-sql-shell.ts +++ b/src/commands/dataconnect-sql-shell.ts @@ -1,5 +1,5 @@ import * as pg from "pg"; -import chalk from "chalk"; +import * as clc from "colorette"; import { Connector, IpAddressTypes, AuthTypes } from "@google-cloud/cloud-sql-connector"; import { Command } from "../command"; @@ -48,7 +48,7 @@ async function promptForQuery(): Promise { // Highlight SQL keywords return input .split(" ") - .map((word) => (sqlKeywords.includes(word.toUpperCase()) ? chalk.cyan(word) : word)) + .map((word) => (sqlKeywords.includes(word.toUpperCase()) ? clc.cyan(word) : word)) .join(" "); }, }; @@ -79,7 +79,7 @@ async function mainShellLoop(conn: pg.PoolClient) { if (await confirmDangerousQuery(query)) { await interactiveExecuteQuery(query, conn); } else { - logger.info(chalk.yellow("Query cancelled.")); + logger.info(clc.yellow("Query cancelled.")); } } } @@ -119,9 +119,9 @@ export const command = new Command("dataconnect:sql:shell [serviceId]") const conn: pg.PoolClient = await pool.connect(); logger.info(`Logged in as ${username}`); - logger.info(chalk.cyan("Welcome to Data Connect Cloud SQL Shell")); + logger.info(clc.cyan("Welcome to Data Connect Cloud SQL Shell")); logger.info( - chalk.gray( + clc.gray( "Type your your SQL query or '.exit' to quit, queries should end with ';' or add empty line to execute.", ), ); @@ -130,7 +130,7 @@ export const command = new Command("dataconnect:sql:shell [serviceId]") await mainShellLoop(conn); // Cleanup after exit - logger.info(chalk.yellow("Exiting shell...")); + logger.info(clc.yellow("Exiting shell...")); conn.release(); await pool.end(); connector.close(); diff --git a/src/gcp/cloudsql/interactive.ts b/src/gcp/cloudsql/interactive.ts index ff5ca621851..17a872c614e 100644 --- a/src/gcp/cloudsql/interactive.ts +++ b/src/gcp/cloudsql/interactive.ts @@ -1,6 +1,6 @@ import * as pg from "pg"; import * as ora from "ora"; -import chalk from "chalk"; +import * as clc from "colorette"; import { logger } from "../../logger"; import { confirm } from "../../prompt"; @@ -18,7 +18,7 @@ function checkIsDestructiveSql(query: string): boolean { export async function confirmDangerousQuery(query: string): Promise { if (checkIsDestructiveSql(query)) { return await confirm({ - message: chalk.yellow("This query may be destructive. Are you sure you want to proceed?"), + message: clc.yellow("This query may be destructive. Are you sure you want to proceed?"), default: false, }); } @@ -30,11 +30,11 @@ export async function interactiveExecuteQuery(query: string, conn: pg.PoolClient const spinner = ora("Executing query...").start(); try { const results = await conn.query(query); - spinner.succeed(chalk.green("Query executed successfully")); + spinner.succeed(clc.green("Query executed successfully")); if (Array.isArray(results.rows) && results.rows.length > 0) { const table: any[] = new Table({ - head: Object.keys(results.rows[0]).map((key) => chalk.cyan(key)), + head: Object.keys(results.rows[0]).map((key) => clc.cyan(key)), style: { head: [], border: [] }, }); @@ -46,10 +46,10 @@ export async function interactiveExecuteQuery(query: string, conn: pg.PoolClient } else { // If nothing is returned and the query was select, let the user know there was no results. if (query.toUpperCase().includes("SELECT")) { - logger.info(chalk.yellow("No results returned")); + logger.info(clc.yellow("No results returned")); } } } catch (err) { - spinner.fail(chalk.red(`Failed executing query: ${err}`)); + spinner.fail(clc.red(`Failed executing query: ${err}`)); } } From 9944b4bff3f67f6a56dfd73d107f4430c10976cc Mon Sep 17 00:00:00 2001 From: tammam-g Date: Thu, 17 Oct 2024 18:12:36 -0400 Subject: [PATCH 8/8] Update CHANGELOG.md Co-authored-by: joehan --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a01922cfe..cf43f820411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -- Added new command dataconnect:sql:shell which allows users to directly connect and run queries against their dataconnect cloudsql instance (#7778). +- Added new command `dataconnect:sql:shell` which run queries against Data Connect CloudSQL instances (#7778).