Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"request": "launch",
"skipFiles": ["<node_internals>/**"],
"type": "node",
"args": ["generate", "--schema", "${workspaceFolder}/samples/blog/zenstack/schema.zmodel"]
"args": ["generate"],
"cwd": "${workspaceFolder}/samples/blog/zenstack"
},
{
"name": "Debug with TSX",
Expand Down
110 changes: 79 additions & 31 deletions packages/cli/src/actions/generate.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { invariant } from '@zenstackhq/common-helpers';
import { isPlugin, LiteralExpr, type Model } from '@zenstackhq/language/ast';
import { PrismaSchemaGenerator, TsSchemaGenerator, type CliGenerator } from '@zenstackhq/sdk';
import { isPlugin, LiteralExpr, Plugin, type Model } from '@zenstackhq/language/ast';
import { getLiteral, getLiteralArray } from '@zenstackhq/language/utils';
import { type CliPlugin } from '@zenstackhq/sdk';
import colors from 'colors';
import fs from 'node:fs';
import path from 'node:path';
import ora from 'ora';
import { CliError } from '../cli-error';
import * as corePlugins from '../plugins';
import { getPkgJsonConfig, getSchemaFile, loadSchemaDocument } from './action-utils';

type Options = {
schema?: string;
output?: string;
silent?: boolean;
savePrismaSchema?: string | boolean;
};

/**
Expand All @@ -24,25 +26,10 @@ export async function run(options: Options) {
const model = await loadSchemaDocument(schemaFile);
const outputPath = getOutputPath(options, schemaFile);

// generate TS schema
const tsSchemaFile = path.join(outputPath, 'schema.ts');
await new TsSchemaGenerator().generate(schemaFile, [], outputPath);

await runPlugins(model, outputPath, tsSchemaFile);

// generate Prisma schema
if (options.savePrismaSchema) {
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
let prismaSchemaFile = path.join(outputPath, 'schema.prisma');
if (typeof options.savePrismaSchema === 'string') {
prismaSchemaFile = path.resolve(outputPath, options.savePrismaSchema);
fs.mkdirSync(path.dirname(prismaSchemaFile), { recursive: true });
}
fs.writeFileSync(prismaSchemaFile, prismaSchema);
}
await runPlugins(schemaFile, model, outputPath);

if (!options.silent) {
console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.`));
console.log(colors.green(`Generation completed successfully in ${Date.now() - start}ms.\n`));
console.log(`You can now create a ZenStack client with it.

\`\`\`ts
Expand All @@ -68,18 +55,79 @@ function getOutputPath(options: Options, schemaFile: string) {
}
}

async function runPlugins(model: Model, outputPath: string, tsSchemaFile: string) {
async function runPlugins(schemaFile: string, model: Model, outputPath: string) {
const plugins = model.declarations.filter(isPlugin);
const processedPlugins: { cliPlugin: CliPlugin; pluginOptions: Record<string, unknown> }[] = [];

for (const plugin of plugins) {
const providerField = plugin.fields.find((f) => f.name === 'provider');
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
const provider = (providerField.value as LiteralExpr).value as string;
let useProvider = provider;
if (useProvider.startsWith('@core/')) {
useProvider = `@zenstackhq/runtime/plugins/${useProvider.slice(6)}`;
const provider = getPluginProvider(plugin);

let cliPlugin: CliPlugin;
if (provider.startsWith('@core/')) {
cliPlugin = (corePlugins as any)[provider.slice('@core/'.length)];
if (!cliPlugin) {
throw new CliError(`Unknown core plugin: ${provider}`);
}
} else {
try {
cliPlugin = (await import(provider)).default as CliPlugin;
} catch (error) {
throw new CliError(`Failed to load plugin ${provider}: ${error}`);
}
}

processedPlugins.push({ cliPlugin, pluginOptions: getPluginOptions(plugin) });
}

const defaultPlugins = [corePlugins['typescript']].reverse();
defaultPlugins.forEach((d) => {
if (!processedPlugins.some((p) => p.cliPlugin === d)) {
processedPlugins.push({ cliPlugin: d, pluginOptions: {} });
}
});

for (const { cliPlugin, pluginOptions } of processedPlugins) {
invariant(
typeof cliPlugin.generate === 'function',
`Plugin ${cliPlugin.name} does not have a generate function`,
);

// run plugin generator
const spinner = ora(cliPlugin.statusText ?? `Running plugin ${cliPlugin.name}`).start();
try {
await cliPlugin.generate({
schemaFile,
model,
defaultOutputPath: outputPath,
pluginOptions,
});
spinner.succeed();
} catch (err) {
spinner.fail();
console.error(err);
}
}
}

function getPluginProvider(plugin: Plugin) {
const providerField = plugin.fields.find((f) => f.name === 'provider');
invariant(providerField, `Plugin ${plugin.name} does not have a provider field`);
const provider = (providerField.value as LiteralExpr).value as string;
return provider;
}

function getPluginOptions(plugin: Plugin): Record<string, unknown> {
const result: any = {};
for (const field of plugin.fields) {
if (field.name === 'provider') {
continue; // skip provider
}
const value = getLiteral(field.value) ?? getLiteralArray(field.value);
if (value === undefined) {
console.warn(`Plugin "${plugin.name}" option "${field.name}" has unsupported value, skipping`);
continue;
}
const generator = (await import(useProvider)).default as CliGenerator;
console.log('Running generator:', provider);
await generator({ model, outputPath, tsSchemaFile });
result[field.name] = value;
}
return result;
}
6 changes: 0 additions & 6 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,6 @@ export function createProgram() {
.description('Run code generation.')
.addOption(schemaOption)
.addOption(new Option('--silent', 'do not print any output'))
.addOption(
new Option(
'--save-prisma-schema [path]',
'save a Prisma schema file, by default into the output directory',
),
)
.addOption(new Option('-o, --output <path>', 'default output directory for core plugins'))
.action(generateAction);

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as prisma } from './prisma';
export { default as typescript } from './typescript';
22 changes: 22 additions & 0 deletions packages/cli/src/plugins/prisma.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { PrismaSchemaGenerator, type CliPlugin } from '@zenstackhq/sdk';
import fs from 'node:fs';
import path from 'node:path';

const plugin: CliPlugin = {
name: 'Prisma Schema Generator',
statusText: 'Generating Prisma schema',
async generate({ model, defaultOutputPath, pluginOptions }) {
let outFile = path.join(defaultOutputPath, 'schema.prisma');
if (typeof pluginOptions['output'] === 'string') {
const outDir = path.resolve(defaultOutputPath, pluginOptions['output']);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
outFile = path.join(outDir, 'schema.prisma');
}
const prismaSchema = await new PrismaSchemaGenerator(model).generate();
fs.writeFileSync(outFile, prismaSchema);
},
};

export default plugin;
21 changes: 21 additions & 0 deletions packages/cli/src/plugins/typescript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { CliPlugin } from '@zenstackhq/sdk';
import { TsSchemaGenerator } from '@zenstackhq/sdk';
import fs from 'node:fs';
import path from 'node:path';

const plugin: CliPlugin = {
name: 'TypeScript Schema Generator',
statusText: 'Generating TypeScript schema',
async generate({ model, defaultOutputPath, pluginOptions }) {
let ourDir = defaultOutputPath;
if (typeof pluginOptions['output'] === 'string') {
ourDir = path.resolve(defaultOutputPath, pluginOptions['output']);
if (!fs.existsSync(ourDir)) {
fs.mkdirSync(ourDir, { recursive: true });
}
}
await new TsSchemaGenerator().generate(model, ourDir);
},
};

export default plugin;
60 changes: 60 additions & 0 deletions packages/cli/test/plugins/prisma-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createProject, runCli } from '../utils';

describe('Core plugins tests', () => {
it('can automatically generate a TypeScript schema with default output', () => {
const workDir = createProject(`
model User {
id String @id @default(cuid())
}
`);
runCli('generate', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.ts'))).toBe(true);
});

it('can automatically generate a TypeScript schema with custom output', () => {
const workDir = createProject(`
plugin typescript {
provider = '@core/typescript'
output = '../generated-schema'
}

model User {
id String @id @default(cuid())
}
`);
runCli('generate', workDir);
expect(fs.existsSync(path.join(workDir, 'generated-schema/schema.ts'))).toBe(true);
});

it('can generate a Prisma schema with default output', () => {
const workDir = createProject(`
plugin prisma {
provider = '@core/prisma'
}

model User {
id String @id @default(cuid())
}
`);
runCli('generate', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/schema.prisma'))).toBe(true);
});

it('can generate a Prisma schema with custom output', () => {
const workDir = createProject(`
plugin prisma {
provider = '@core/prisma'
output = './prisma'
}

model User {
id String @id @default(cuid())
}
`);
runCli('generate', workDir);
expect(fs.existsSync(path.join(workDir, 'zenstack/prisma/schema.prisma'))).toBe(true);
});
});
3 changes: 0 additions & 3 deletions packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["src/**/*.ts"]
}
3 changes: 0 additions & 3 deletions packages/common-helpers/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["src/**/*.ts"]
}
3 changes: 0 additions & 3 deletions packages/create-zenstack/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["src/**/*.ts"]
}
3 changes: 0 additions & 3 deletions packages/dialects/sql.js/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["src/**/*"]
}
3 changes: 0 additions & 3 deletions packages/ide/vscode/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["src/**/*.ts"]
}
3 changes: 0 additions & 3 deletions packages/language/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
{
"extends": "@zenstackhq/typescript-config/base.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["src/**/*.ts"]
}
7 changes: 6 additions & 1 deletion packages/runtime/test/scripts/generate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { loadDocument } from '@zenstackhq/language';
import { TsSchemaGenerator } from '@zenstackhq/sdk';
import { glob } from 'glob';
import fs from 'node:fs';
Expand All @@ -20,7 +21,11 @@ async function generate(schemaPath: string) {
const outputDir = path.dirname(schemaPath);
const tsPath = path.join(outputDir, 'schema.ts');
const pluginModelFiles = glob.sync(path.resolve(dir, '../../dist/**/plugin.zmodel'));
await generator.generate(schemaPath, pluginModelFiles, outputDir);
const result = await loadDocument(schemaPath, pluginModelFiles);
if (!result.success) {
throw new Error(`Failed to load schema from ${schemaPath}: ${result.errors}`);
}
await generator.generate(result.model, outputDir);
const content = fs.readFileSync(tsPath, 'utf-8');
fs.writeFileSync(tsPath, content.replace(/@zenstackhq\/runtime/g, '../../../dist'));
console.log('TS schema generated at:', outputDir);
Expand Down
47 changes: 47 additions & 0 deletions packages/sdk/src/cli-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Model } from '@zenstackhq/language/ast';
import type { MaybePromise } from 'langium';

/**
* Context passed to CLI plugins when calling `generate`.
*/
export type CliGeneratorContext = {
/**
* ZModel file path.
*/
schemaFile: string;

/**
* ZModel AST.
*/
model: Model;

/**
* Default output path for code generation.
*/
defaultOutputPath: string;

/**
* Plugin options provided by the user.
*/
pluginOptions: Record<string, unknown>;
};

/**
* Contract for a CLI plugin.
*/
export interface CliPlugin {
/**
* Plugin's display name.
*/
name: string;

/**
* Text to show during generation.
*/
statusText?: string;

/**
* Code generation callback.
*/
generate(context: CliGeneratorContext): MaybePromise<void>;
}
10 changes: 0 additions & 10 deletions packages/sdk/src/generator.ts

This file was deleted.

Loading
Loading