Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MVP working dataconnect:sql:shell #7778

Merged
merged 12 commits into from
Oct 17, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added new command `dataconnect:sql:shell` which run queries against Data Connect CloudSQL instances (#7778).
139 changes: 139 additions & 0 deletions src/commands/dataconnect-sql-shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import * as pg from "pg";
import * as clc from "colorette";
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, Question } from "../prompt";
import { logger } from "../logger";
import { FirebaseError } from "../error";
import { FBToolsAuthClient } from "../gcp/cloudsql/fbToolsAuthClient";
import { confirmDangerousQuery, interactiveExecuteQuery } from "../gcp/cloudsql/interactive";

// Not a comprehensive list, used for keyword coloring.
const sqlKeywords = [
"SELECT",
"FROM",
"WHERE",
"INSERT",
"UPDATE",
"DELETE",
"JOIN",
"GROUP",
"ORDER",
"LIMIT",
"GRANT",
"CREATE",
"DROP",
];

async function promptForQuery(): Promise<string> {
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()) ? clc.cyan(word) : word))
.join(" ");
},
};

({ line } = await prompt({ nonInteractive: false }, [question]));

Check warning on line 56 in src/commands/dataconnect-sql-shell.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
line = line.trimEnd();

if (line.toLowerCase() === ".exit") {
return ".exit";
}

query += (query ? "\n" : "") + line;
} while (line !== "" && !query.endsWith(";"));
return query;
}

async function mainShellLoop(conn: pg.PoolClient) {

Check warning on line 68 in src/commands/dataconnect-sql-shell.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
while (true) {

Check warning on line 69 in src/commands/dataconnect-sql-shell.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected constant condition
const query = await promptForQuery();
if (query.toLowerCase() === ".exit") {
break;
}

if (query === "") {
continue;
}

if (await confirmDangerousQuery(query)) {
await interactiveExecuteQuery(query, conn);
} else {
logger.info(clc.yellow("Query cancelled."));
}
}
}

export const command = new Command("dataconnect:sql:shell [serviceId]")
.description("Starts a shell connected directly to your dataconnect cloudsql instance.")
.before(requirePermissions, ["firebasedataconnect.services.list", "cloudsql.instances.connect"])
tammam-g marked this conversation as resolved.
Show resolved Hide resolved
.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}`,

Check warning on line 103 in src/commands/dataconnect-sql-shell.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "unknown" of template literal expression

Check warning on line 103 in src/commands/dataconnect-sql-shell.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Invalid type "unknown" of template literal expression
);
}
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(clc.cyan("Welcome to Data Connect Cloud SQL Shell"));
logger.info(
clc.gray(
"Type your your SQL query or '.exit' to quit, queries should end with ';' or add empty line to execute.",
),
);

// Start accepting queries
await mainShellLoop(conn);

// Cleanup after exit
logger.info(clc.yellow("Exiting shell..."));
conn.release();
await pool.end();
connector.close();

return { projectId, serviceId };
});
1 change: 1 addition & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
/**
* Loads all commands for our parser.
*/
export function load(client: any): any {

Check warning on line 5 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 5 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
function loadCommand(name: string) {

Check warning on line 6 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
const t0 = process.hrtime.bigint();
const { command: cmd } = require(`./${name}`);

Check warning on line 8 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value

Check warning on line 8 in src/commands/index.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Require statement not part of import statement
cmd.register(client);
const t1 = process.hrtime.bigint();
const diffMS = (t1 - t0) / BigInt(1e6);
Expand Down Expand Up @@ -217,6 +217,7 @@
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");
Expand Down
2 changes: 1 addition & 1 deletion src/dataconnect/schemaMigration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 55 additions & 0 deletions src/gcp/cloudsql/interactive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as pg from "pg";
import * as ora from "ora";
import * as clc from "colorette";
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<boolean> {
if (checkIsDestructiveSql(query)) {
return await confirm({
message: clc.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(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) => clc.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(clc.yellow("No results returned"));
}
}
} catch (err) {
spinner.fail(clc.red(`Failed executing query: ${err}`));
}
}
Loading