Skip to content

Commit

Permalink
feat(cli): add seed generate command (#17262)
Browse files Browse the repository at this point in the history
Co-authored-by: Alyx <zoe@ephys.dev>
  • Loading branch information
WikiRik and ephys committed Apr 8, 2024
1 parent e24e856 commit b07ad40
Show file tree
Hide file tree
Showing 13 changed files with 262 additions and 3 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Expand Up @@ -211,6 +211,7 @@ module.exports = {
'packages/**/skeletons/**/*',
'.typedoc-build',
'packages/cli/migrations/**/*',
'packages/cli/seeds/**/*',
],
env: {
node: true,
Expand Down
5 changes: 4 additions & 1 deletion .github/workflows/pr-title.yml
Expand Up @@ -28,6 +28,8 @@ jobs:
docs
meta
scopes: |
cli
core
db2
ibmi
mariadb
Expand All @@ -37,6 +39,7 @@ jobs:
postgres
snowflake
sqlite
types
utils
validator.js
ignoreLabels: |
ignore-semantic-pull-request
1 change: 1 addition & 0 deletions .prettierignore
@@ -1,2 +1,3 @@
.yarn
/packages/cli/migrations
/packages/cli/seeds
7 changes: 5 additions & 2 deletions CONTRIBUTING.md
Expand Up @@ -246,7 +246,11 @@ We will then use the title of your PR as the message of the Squash Commit. It wi
We use a simple conventional commits convention:

- The allowed commit types are: `docs`, `feat`, `fix`, `meta`.
- We allow the following commit scopes (they're the list of dialects we support, plus `types` for TypeScript-only changes):
- We allow the following commit scopes (they're the list of packages):
- `core`
- `utils`
- `cli`
- `validator.js`
- `postgres`
- `mysql`
- `mariadb`
Expand All @@ -255,7 +259,6 @@ We use a simple conventional commits convention:
- `db2`
- `ibmi`
- `snowflake`
- `types`
- If your changes impact more than one scope, simply omit the scope.

Example:
Expand Down
1 change: 1 addition & 0 deletions packages/cli/.gitignore
Expand Up @@ -5,3 +5,4 @@ node_modules
oclif.lock
oclif.manifest.json
/migrations
/seeds
3 changes: 3 additions & 0 deletions packages/cli/package.json
Expand Up @@ -50,6 +50,9 @@
"topics": {
"migration": {
"description": "Commands for managing database migrations"
},
"seed": {
"description": "Commands for managing database seeding"
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/_internal/config.ts
Expand Up @@ -12,6 +12,10 @@ const configSchema = z.object({
.string()
.default('/migrations')
.transform(val => path.join(projectRoot, val)),
seedFolder: z
.string()
.default('/seeds')
.transform(val => path.join(projectRoot, val)),
});

export const config = configSchema.parse(result?.config || {});
47 changes: 47 additions & 0 deletions packages/cli/src/api/generate-seed.ts
@@ -0,0 +1,47 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { SKELETONS_FOLDER } from '../_internal/skeletons.js';
import { getCurrentYYYYMMDDHHmms, slugify } from '../_internal/utils.js';

export const SUPPORTED_SEED_FORMATS = ['sql', 'typescript', 'cjs', 'esm'] as const;
export type SupportedSeedFormat = (typeof SUPPORTED_SEED_FORMATS)[number];

const FORMAT_EXTENSIONS: Record<SupportedSeedFormat, string> = {
sql: 'sql',
typescript: 'ts',
cjs: 'cjs',
esm: 'mjs',
};

export interface GenerateSeedOptions {
format: SupportedSeedFormat;
seedName: string;
seedFolder: string;
}

export async function generateSeed(options: GenerateSeedOptions): Promise<string> {
const { format, seedName, seedFolder } = options;

const seedFilename = `${getCurrentYYYYMMDDHHmms()}-${slugify(seedName)}`;
const seedPath = path.join(seedFolder, seedFilename);

if (format === 'sql') {
await fs.mkdir(seedPath, { recursive: true });
await Promise.all([
fs.writeFile(path.join(seedPath, 'up.sql'), ''),
fs.writeFile(path.join(seedPath, 'down.sql'), ''),
]);

return seedPath;
}

await fs.mkdir(seedFolder, { recursive: true });

const extension = FORMAT_EXTENSIONS[format];
const targetPath = `${seedPath}.${extension}`;
const sourcePath = path.join(SKELETONS_FOLDER, `seed.${extension}`);

await fs.copyFile(sourcePath, targetPath);

return targetPath;
}
70 changes: 70 additions & 0 deletions packages/cli/src/commands/seed/generate.test.ts
@@ -0,0 +1,70 @@
import { expect, test } from '@oclif/test';
import { fileUrlToDirname } from '@sequelize/utils/node';
import fs from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';

const __dirname = fileUrlToDirname(import.meta.url);
const packageRoot = path.join(__dirname, '..', '..', '..');

function oclifTest() {
return test.loadConfig({
root: packageRoot,
});
}

describe('seed:generate', () => {
oclifTest()
.stdout()
.command(['seed:generate', '--format=sql', '--name=test-seed', '--json'])
.it('generates an SQL seed', async ctx => {
const asJson = JSON.parse(ctx.stdout);

expect(Object.keys(asJson)).to.deep.eq(['path']);
expect(pathToFileURL(asJson.path).pathname).to.match(/seeds\/[\d\-t]{19}-test-seed/);
expect(await fs.readdir(asJson.path)).to.have.members(['up.sql', 'down.sql']);
});

oclifTest()
.stdout()
.command(['seed:generate', '--format=typescript', '--name=test-seed', '--json'])
.it('generates a TypeScript seed', async ctx => {
const asJson = JSON.parse(ctx.stdout);

expect(Object.keys(asJson)).to.deep.eq(['path']);
expect(pathToFileURL(asJson.path).pathname).to.match(/seeds\/[\d\-t]{19}-test-seed\.ts/);
await fs.access(asJson.path);
});

oclifTest()
.stdout()
.command(['seed:generate', '--format=cjs', '--name=test-seed', '--json'])
.it('generates a CJS seed', async ctx => {
const asJson = JSON.parse(ctx.stdout);

expect(Object.keys(asJson)).to.deep.eq(['path']);
expect(pathToFileURL(asJson.path).pathname).to.match(/seeds\/[\d\-t]{19}-test-seed\.cjs/);
await fs.access(asJson.path);
});

oclifTest()
.stdout()
.command(['seed:generate', '--format=esm', '--name=test-seed', '--json'])
.it('generates an ESM seed', async ctx => {
const asJson = JSON.parse(ctx.stdout);

expect(Object.keys(asJson)).to.deep.eq(['path']);
expect(pathToFileURL(asJson.path).pathname).to.match(/seeds\/[\d\-t]{19}-test-seed\.mjs/);
await fs.access(asJson.path);
});

oclifTest()
.stdout()
.command(['seed:generate', '--format=sql', '--no-interactive', '--json'])
.it('supports not specifying a name', async ctx => {
const asJson = JSON.parse(ctx.stdout);

expect(Object.keys(asJson)).to.deep.eq(['path']);
expect(pathToFileURL(asJson.path).pathname).to.match(/seeds\/[\d\-t]{19}-unnamed/);
});
});
50 changes: 50 additions & 0 deletions packages/cli/src/commands/seed/generate.ts
@@ -0,0 +1,50 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { config } from '../../_internal/config.js';
import { SequelizeCommand } from '../../_internal/sequelize-command.js';
import type { SupportedSeedFormat } from '../../api/generate-seed.js';
import { SUPPORTED_SEED_FORMATS, generateSeed } from '../../api/generate-seed.js';

export class GenerateSeed extends SequelizeCommand<(typeof GenerateSeed)['flags']> {
static enableJsonFlag = true;

static flags = {
format: Flags.string({
options: SUPPORTED_SEED_FORMATS,
summary: 'The format of the seed file to generate',
required: true,
}),
name: Flags.string({
summary: 'A short name for the seed file',
default: 'unnamed',
}),
};

static summary = 'Generates a new seed file';

static examples = [
`<%= config.bin %> <%= command.id %>`,
`<%= config.bin %> <%= command.id %> --format=sql`,
`<%= config.bin %> <%= command.id %> --name="users table test data"`,
];

async run(): Promise<{ path: string }> {
const { format, name: seedName } = this.flags;
const { seedFolder } = config;

const seedPath = await generateSeed({
format: format as SupportedSeedFormat,
seedName,
seedFolder,
});

if (format === 'sql') {
this.log(`SQL seed files generated in ${chalk.green(seedPath)}`);
} else {
this.log(`Seed file generated at ${chalk.green(seedPath)}`);
}

// JSON output
return { path: seedPath };
}
}
26 changes: 26 additions & 0 deletions packages/cli/static/skeletons/seed.cjs
@@ -0,0 +1,26 @@
'use strict';

module.exports = {
/** @type {import('@sequelize/cli').SeedFunction} */
async up(queryInterface, sequelize) {
/**
* Add seed commands here.
*
* Example:
* await queryInterface.bulkInsert('People', [{
* name: 'John Doe',
* isBetaMember: false
* }], {});
*/
},

/** @type {import('@sequelize/cli').SeedFunction} */
async down(queryInterface, sequelize) {
/**
* Add commands to revert seed here.
*
* Example:
* await queryInterface.bulkDelete('People', null, {});
*/
},
};
22 changes: 22 additions & 0 deletions packages/cli/static/skeletons/seed.mjs
@@ -0,0 +1,22 @@
/** @type {import('@sequelize/cli').SeedFunction} */
export async function up(queryInterface, sequelize) {
/**
* Add seed commands here.
*
* Example:
* await queryInterface.bulkInsert('People', [{
* name: 'John Doe',
* isBetaMember: false
* }], {});
*/
}

/** @type {import('@sequelize/cli').SeedFunction} */
export async function down(queryInterface, sequelize) {
/**
* Add commands to revert seed here.
*
* Example:
* await queryInterface.bulkDelete('People', null, {});
*/
}
28 changes: 28 additions & 0 deletions packages/cli/static/skeletons/seed.ts
@@ -0,0 +1,28 @@
import { AbstractQueryInterface, Sequelize } from '@sequelize/core';

export async function up(
queryInterface: AbstractQueryInterface,
sequelize: Sequelize,
): Promise<void> {
/**
* Add seed commands here.
*
* Example:
* await queryInterface.bulkInsert('People', [{
* name: 'John Doe',
* isBetaMember: false
* }], {});
*/
}

export async function down(
queryInterface: AbstractQueryInterface,
sequelize: Sequelize,
): Promise<void> {
/**
* Add commands to revert seed here.
*
* Example:
* await queryInterface.bulkDelete('People', null, {});
*/
}

0 comments on commit b07ad40

Please sign in to comment.