From 7c2cfc132af0a6b7fc06c0e51b157e9b8599b344 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Mon, 26 Aug 2024 15:04:52 +0530 Subject: [PATCH] feature: add db:create command (#8760) --- packages/cli/medusa-cli/src/create-cli.ts | 22 ++++ packages/core/utils/package.json | 2 + packages/core/utils/src/common/env-editor.ts | 20 +++ packages/core/utils/src/index.ts | 1 + packages/core/utils/src/pg/index.ts | 52 ++++++++ .../__tests__/pg_utils.spec.ts | 118 ++++++++++++++++++ packages/medusa/package.json | 2 + packages/medusa/src/commands/db/create.ts | 111 ++++++++++++++++ yarn.lock | 69 +++++++++- 9 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 packages/core/utils/src/pg/index.ts create mode 100644 packages/core/utils/src/pg/integration-tests/__tests__/pg_utils.spec.ts create mode 100644 packages/medusa/src/commands/db/create.ts diff --git a/packages/cli/medusa-cli/src/create-cli.ts b/packages/cli/medusa-cli/src/create-cli.ts index 02790986a3..cdaa2fcf73 100644 --- a/packages/cli/medusa-cli/src/create-cli.ts +++ b/packages/cli/medusa-cli/src/create-cli.ts @@ -125,6 +125,28 @@ function buildLocalCommands(cli, isLocalProject) { desc: `Create a new Medusa project.`, handler: handlerP(newStarter), }) + .command({ + command: "db:create", + desc: "Create the database used by your application", + builder: (builder) => { + builder.option("db", { + type: "string", + describe: "Specify the name of the database you want to create", + }) + builder.option("interactive", { + type: "boolean", + default: true, + describe: + "Display prompts. Use --no-interactive flag to run the command without prompts", + }) + }, + handler: handlerP( + getCommandHandler("db/create", (args, cmd) => { + process.env.NODE_ENV = process.env.NODE_ENV || `development` + return cmd(args) + }) + ), + }) .command({ command: `telemetry`, describe: `Enable or disable collection of anonymous usage data.`, diff --git a/packages/core/utils/package.json b/packages/core/utils/package.json index e7e9f16b2a..e88ece259e 100644 --- a/packages/core/utils/package.json +++ b/packages/core/utils/package.json @@ -40,6 +40,8 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "jsonwebtoken": "^9.0.2", + "pg": "^8.12.0", + "pg-connection-string": "^2.6.4", "pluralize": "^8.0.0", "ulid": "^2.3.0" }, diff --git a/packages/core/utils/src/common/env-editor.ts b/packages/core/utils/src/common/env-editor.ts index 7a577cd11f..ecc0a1aac9 100644 --- a/packages/core/utils/src/common/env-editor.ts +++ b/packages/core/utils/src/common/env-editor.ts @@ -51,6 +51,26 @@ export class EnvEditor { ) } + /** + * Returns the value for an existing key from the + * ".env" file + */ + get(key: string): string | null { + const envFile = this.#files.find((file) => file.filePath.endsWith(".env")) + if (!envFile) { + return null + } + const matchingLine = envFile.contents.find((line) => + line.startsWith(`${key}=`) + ) + if (!matchingLine) { + return null + } + + const [_, ...rest] = matchingLine.split("=") + return rest.join("=") + } + /** * Set key-value pair to the dot-env files. * diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index d11b798b67..2e330a8b63 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -26,5 +26,6 @@ export * from "./shipping" export * from "./totals" export * from "./totals/big-number" export * from "./user" +export * from "./pg" export const MedusaModuleType = Symbol.for("MedusaModule") diff --git a/packages/core/utils/src/pg/index.ts b/packages/core/utils/src/pg/index.ts new file mode 100644 index 0000000000..df47c16188 --- /dev/null +++ b/packages/core/utils/src/pg/index.ts @@ -0,0 +1,52 @@ +/* +|-------------------------------------------------------------------------- +| PostgreSQL utilities +|-------------------------------------------------------------------------- +| +| @note +| These utlities does not rely on an MedusaJS application and neither +| uses the Mikro ORM. +| The goal here is to run DB operations without booting the application. +| +| For example: +| Creating a database from CLI, or checking if a database exists +| +*/ + +import { Client, type ClientConfig } from "pg" +import { parse } from "pg-connection-string" + +/** + * Parsers the database connection string into an object + * of postgreSQL options + */ +export function parseConnectionString(connectionString: string) { + return parse(connectionString) +} + +/** + * Creates a PostgreSQL database client using the connection + * string or database options + */ +export function createClient(options: string | ClientConfig) { + return new Client(options) +} + +/** + * Creates a database using the client. Make sure to call + * `client.connect` before using this utility. + */ +export async function createDb(client: Client, databaseName: string) { + await client.query(`CREATE DATABASE "${databaseName}"`) +} + +/** + * Checks if a database exists using the Client. Make sure to call + * `client.connect` before using this utility. + */ +export async function dbExists(client: Client, databaseName: string) { + const result = await client.query( + `SELECT datname FROM pg_catalog.pg_database WHERE datname='${databaseName}';` + ) + return !!result.rowCount +} diff --git a/packages/core/utils/src/pg/integration-tests/__tests__/pg_utils.spec.ts b/packages/core/utils/src/pg/integration-tests/__tests__/pg_utils.spec.ts new file mode 100644 index 0000000000..c6daff7648 --- /dev/null +++ b/packages/core/utils/src/pg/integration-tests/__tests__/pg_utils.spec.ts @@ -0,0 +1,118 @@ +import { dropDatabase } from "pg-god" +import { + createClient, + parseConnectionString, + dbExists, + createDb, +} from "../../index" + +const DB_HOST = process.env.DB_HOST ?? "localhost" +const DB_USERNAME = process.env.DB_USERNAME ?? "" +const DB_PASSWORD = process.env.DB_PASSWORD ?? "" + +export const pgGodCredentials = { + user: DB_USERNAME, + password: DB_PASSWORD, + host: DB_HOST, +} + +describe("Pg | createClient", () => { + test("create client connection from connection options", async () => { + const client = createClient(pgGodCredentials) + await client.connect() + await client.end() + }) + + test("create client connection from connectionString without db name", async () => { + const client = createClient( + `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}` + ) + await client.connect() + await client.end() + }) + + test("create client connection from connectionString with db name", async () => { + const client = createClient( + `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/foo` + ) + await expect(() => client.connect()).rejects.toMatchInlineSnapshot( + `[error: database "foo" does not exist]` + ) + }) +}) + +describe("Pg | parseConnectionString", () => { + test("parse connection string without db name", async () => { + const options = parseConnectionString( + `postgres://${DB_USERNAME}:@${DB_HOST}` + ) + expect(options).toEqual({ + user: DB_USERNAME, + password: "", + host: DB_HOST, + port: "", + database: null, + }) + }) + + test("parse connection string with db name", async () => { + const options = parseConnectionString( + `postgres://${DB_USERNAME}:@${DB_HOST}/foo` + ) + expect(options).toEqual({ + user: DB_USERNAME, + password: "", + host: DB_HOST, + port: "", + database: "foo", + }) + }) +}) + +describe("Pg | dbExists", () => { + beforeEach(async () => { + await dropDatabase({ databaseName: "foo" }, pgGodCredentials) + }) + + afterAll(async () => { + await dropDatabase({ databaseName: "foo" }, pgGodCredentials) + }) + + test("return false when db does not exist", async () => { + const options = parseConnectionString( + `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/foo` + ) + const client = createClient({ + host: options.host!, + port: options.port ? Number(options.port) : undefined, + user: options.user, + password: options.password, + }) + + await client.connect() + const exists = await dbExists(client, options.database!) + await client.end() + + expect(exists).toBe(false) + }) + + test("return true when db exist", async () => { + const options = parseConnectionString( + `postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}/foo` + ) + + const client = createClient({ + host: options.host!, + port: options.port ? Number(options.port) : undefined, + user: options.user, + password: options.password, + }) + + await client.connect() + await createDb(client, options.database!) + const exists = await dbExists(client, options.database!) + await client.end() + + expect(exists).toBe(true) + }) +}) diff --git a/packages/medusa/package.json b/packages/medusa/package.json index 1d54433dc5..ab2ae95f24 100644 --- a/packages/medusa/package.json +++ b/packages/medusa/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@inquirer/checkbox": "^2.3.11", + "@inquirer/input": "^2.2.9", "@medusajs/admin-sdk": "0.0.1", "@medusajs/core-flows": "^0.0.9", "@medusajs/framework": "^0.0.1", @@ -75,6 +76,7 @@ "node-schedule": "^2.1.1", "qs": "^6.11.2", "request-ip": "^3.3.0", + "slugify": "^1.6.6", "uuid": "^9.0.0", "zod": "3.22.4" }, diff --git a/packages/medusa/src/commands/db/create.ts b/packages/medusa/src/commands/db/create.ts new file mode 100644 index 0000000000..9d7d02b649 --- /dev/null +++ b/packages/medusa/src/commands/db/create.ts @@ -0,0 +1,111 @@ +import slugify from "slugify" +import { basename } from "path" +import input from "@inquirer/input" +import { logger } from "@medusajs/framework" +import { + createDb, + dbExists, + EnvEditor, + createClient, + parseConnectionString, +} from "@medusajs/utils" + +const main = async function ({ directory, interactive, db }) { + let dbName = db + + /** + * Loading the ".env" file in editor mode so that + * we can read values from it and update its + * contents. + */ + const envEditor = new EnvEditor(directory) + await envEditor.load() + + /** + * Ensure the "DATABASE_URL" is defined before we attempt to + * create the database. + * + * Also we will discard the database name from the connection + * string because the mentioned database might not exist + */ + const dbConnectionString = envEditor.get("DATABASE_URL") + if (!dbConnectionString) { + logger.error( + `Missing "DATABASE_URL" inside the .env file. The value is required to connect to the PostgreSQL server` + ) + process.exitCode = 1 + return + } + + /** + * Use default value + prompt only when the dbName is not + * provided via a flag + */ + if (!dbName) { + const defaultValue = + envEditor.get("DB_NAME") ?? `medusa-${slugify(basename(directory))}` + if (interactive) { + try { + dbName = await input({ + message: "Enter the database name", + default: defaultValue, + required: true, + }) + } catch (error) { + if (error.name === "ExitPromptError") { + process.exit() + } + throw error + } + } else { + dbName = defaultValue + } + } + + /** + * Parse connection string specified as "DATABASE_URL" inside the + * .env file and create a client instance from it. + * + * Remember we should not specify the database name from the + * connection string, because this database might not + * exist + */ + const connectionOptions = parseConnectionString(dbConnectionString) + const client = createClient({ + host: connectionOptions.host!, + port: connectionOptions.port ? Number(connectionOptions.port) : undefined, + user: connectionOptions.user, + password: connectionOptions.password, + }) + + try { + await client.connect() + logger.info(`Connection established with the database "${dbName}"`) + } catch (error) { + process.exitCode = 1 + logger.error( + "Unable to establish database connection because of the following error" + ) + logger.error(error) + return + } + + if (await dbExists(client, dbName)) { + logger.info(`Database "${dbName}" already exists`) + + envEditor.set("DB_NAME", dbName) + await envEditor.save() + logger.info(`Updated .env file with "DB_NAME=${dbName}"`) + + return + } + + await createDb(client, dbName) + logger.info(`Created database "${dbName}"`) + + envEditor.set("DB_NAME", dbName) + await envEditor.save() + logger.info(`Updated .env file with "DB_NAME=${dbName}"`) +} + +export default main diff --git a/yarn.lock b/yarn.lock index aed7700963..1806917e1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3669,6 +3669,27 @@ __metadata: languageName: node linkType: hard +"@inquirer/core@npm:^9.0.10": + version: 9.0.10 + resolution: "@inquirer/core@npm:9.0.10" + dependencies: + "@inquirer/figures": ^1.0.5 + "@inquirer/type": ^1.5.2 + "@types/mute-stream": ^0.0.4 + "@types/node": ^22.1.0 + "@types/wrap-ansi": ^3.0.0 + ansi-escapes: ^4.3.2 + cli-spinners: ^2.9.2 + cli-width: ^4.1.0 + mute-stream: ^1.0.0 + signal-exit: ^4.1.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^6.2.0 + yoctocolors-cjs: ^2.1.2 + checksum: 117f50a55b5ebee8bfc62ea6adec87035f28ee7ace1efea67895c3d32ab50bf569ecd3ca33c457d0c7ae4240b9fe4d7b698ab70946ac561ab579d0920ddc98bb + languageName: node + linkType: hard + "@inquirer/core@npm:^9.0.3": version: 9.0.3 resolution: "@inquirer/core@npm:9.0.3" @@ -3704,6 +3725,23 @@ __metadata: languageName: node linkType: hard +"@inquirer/figures@npm:^1.0.5": + version: 1.0.5 + resolution: "@inquirer/figures@npm:1.0.5" + checksum: ec9ba23db42cb33fa18eb919abf2a18e750e739e64c1883ce4a98345cd5711c60cac12d1faf56a859f52d387deb221c8d3dfe60344ee07955a9a262f8b821fe3 + languageName: node + linkType: hard + +"@inquirer/input@npm:^2.2.9": + version: 2.2.9 + resolution: "@inquirer/input@npm:2.2.9" + dependencies: + "@inquirer/core": ^9.0.10 + "@inquirer/type": ^1.5.2 + checksum: 0fcdc5d9c17712f9a2c79f39d1e03ed4a58cfe1dd1095209b4c83621dba2cb94db03b7df0df34e22584bce9e53403a284c76721def66a452e4751666d945d99f + languageName: node + linkType: hard + "@inquirer/type@npm:^1.3.1": version: 1.3.1 resolution: "@inquirer/type@npm:1.3.1" @@ -3720,6 +3758,15 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^1.5.2": + version: 1.5.2 + resolution: "@inquirer/type@npm:1.5.2" + dependencies: + mute-stream: ^1.0.0 + checksum: e2c91562c07440620bd805a60438b78c188d2727d86f396a68c480e4357469a72cd80bd2c158faa6b987671911566bd4fc12976f4bdda1a3441594e318c40058 + languageName: node + linkType: hard + "@internationalized/date@npm:^3.5.4": version: 3.5.4 resolution: "@internationalized/date@npm:3.5.4" @@ -4860,6 +4907,7 @@ __metadata: resolution: "@medusajs/medusa@workspace:packages/medusa" dependencies: "@inquirer/checkbox": ^2.3.11 + "@inquirer/input": ^2.2.9 "@medusajs/admin-sdk": 0.0.1 "@medusajs/core-flows": ^0.0.9 "@medusajs/framework": ^0.0.1 @@ -4899,6 +4947,7 @@ __metadata: qs: ^6.11.2 request-ip: ^3.3.0 rimraf: ^5.0.1 + slugify: ^1.6.6 supertest: ^4.0.2 typescript: ^5.3.3 uuid: ^9.0.0 @@ -5423,6 +5472,8 @@ __metadata: express: ^4.18.2 jest: ^29.7.0 jsonwebtoken: ^9.0.2 + pg: ^8.12.0 + pg-connection-string: ^2.6.4 pluralize: ^8.0.0 rimraf: ^5.0.1 ts-jest: ^29.1.1 @@ -12160,6 +12211,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.1.0": + version: 22.5.0 + resolution: "@types/node@npm:22.5.0" + dependencies: + undici-types: ~6.19.2 + checksum: 45aa75c5e71645fac42dced4eff7f197c3fdfff6e8a9fdacd0eb2e748ff21ee70ffb73982f068a58e8d73b2c088a63613142c125236cdcf3c072ea97eada1559 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.4 resolution: "@types/normalize-package-data@npm:2.4.4" @@ -24557,7 +24617,7 @@ __metadata: languageName: node linkType: hard -"pg-connection-string@npm:^2.6.2": +"pg-connection-string@npm:^2.6.2, pg-connection-string@npm:^2.6.4": version: 2.6.4 resolution: "pg-connection-string@npm:2.6.4" checksum: 0d0b617df0fc6507bf6a94bdcd56c7a305788a1402d69bff9773350947c8f525d6d8136128065370749a3325e99658ae40fbdcce620fb8e60126181f0591a6a6 @@ -30181,6 +30241,13 @@ __metadata: languageName: node linkType: hard +"undici-types@npm:~6.19.2": + version: 6.19.8 + resolution: "undici-types@npm:6.19.8" + checksum: 078afa5990fba110f6824823ace86073b4638f1d5112ee26e790155f481f2a868cc3e0615505b6f4282bdf74a3d8caad715fd809e870c2bb0704e3ea6082f344 + languageName: node + linkType: hard + "unicode-canonical-property-names-ecmascript@npm:^2.0.0": version: 2.0.0 resolution: "unicode-canonical-property-names-ecmascript@npm:2.0.0"