From e6eb99e4c71eb824c9df638cd5a3ee8b1b2c3cc1 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 15:40:30 +0800 Subject: [PATCH 01/10] install zod --- package.json | 3 ++- pnpm-lock.yaml | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 774126ef..0d60f4c5 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "figlet": "^1.7.0", "ora": "^8.0.1", "pluralize": "^8.0.0", - "strip-json-comments": "^5.0.1" + "strip-json-comments": "^5.0.1", + "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^20.4.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01016a75..b84ea118 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ dependencies: strip-json-comments: specifier: ^5.0.1 version: 5.0.1 + zod: + specifier: ^3.22.4 + version: 3.22.4 devDependencies: '@types/node': @@ -1398,3 +1401,7 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + dev: false From dbeafab398f59c1a1a7766f6624d4d32198932cb Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 15:46:30 +0800 Subject: [PATCH 02/10] move columns types definition to utils --- src/types.d.ts | 52 ++++++++++---------------------------------------- src/utils.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/src/types.d.ts b/src/types.d.ts index 654181b8..e448d1d5 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,4 +1,10 @@ import { AuthProvider } from "./commands/add/auth/next-auth/utils.ts"; +import { + pgColumns, + mysqlColumns, + sqliteColumns, + prismaColumns, +} from "./utils.ts"; export type DBType = "pg" | "mysql" | "sqlite"; export type DBProviderItem = { @@ -127,48 +133,10 @@ export type ScaffoldSchema = { index?: string; }; -export type pgColumnType = - | "varchar" - | "text" - | "number" - | "float" - | "boolean" - | "references" - | "timestamp" - | "date"; -// | "json"; - -export type mysqlColumnType = - | "varchar" - | "text" - | "number" - | "float" - | "boolean" - | "references" - | "date" - | "timestamp"; -// | "json"; - -export type sqliteColumnType = - | "string" - | "number" - | "boolean" - | "date" - | "timestamp" - | "references"; -// | "blob"; - -export type PrismaColumnType = - | "String" - | "Boolean" - | "Int" - | "BigInt" - | "Float" - | "Decimal" - | "Boolean" - | "DateTime" - | "References"; -// | "Json"; +export type pgColumnType = (typeof pgColumns)[number]; +export type mysqlColumnType = (typeof mysqlColumns)[number]; +export type sqliteColumnType = (typeof sqliteColumns)[number]; +export type PrismaColumnType = (typeof prismaColumns)[number]; export type DotEnvItem = { key: string; diff --git a/src/utils.ts b/src/utils.ts index 69acef04..d3010cea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -269,3 +269,50 @@ export const sendEvent = async ( return; } }; + +export const pgColumns = [ + "varchar", + "text", + "number", + "float", + "boolean", + "references", + "timestamp", + "date", + // "json", +] as const; + +export const mysqlColumns = [ + "varchar", + "text", + "number", + "float", + "boolean", + "references", + "date", + "timestamp", + // "json", +] as const; + +export const sqliteColumns = [ + "string", + "number", + "boolean", + "date", + "timestamp", + "references", + // "blob", +] as const; + +export const prismaColumns = [ + "String", + "Boolean", + "Int", + "BigInt", + "Float", + "Decimal", + "Boolean", + "DateTime", + "References", + // "Json", +] as const; From 161d19c29cef483e57061ab439ec23a774d553a2 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 15:53:42 +0800 Subject: [PATCH 03/10] add option to load from file to generate command --- src/commands/generate/index.ts | 7 ++++--- src/index.ts | 1 + src/types.d.ts | 4 ++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/commands/generate/index.ts b/src/commands/generate/index.ts index 373fa711..5c54da0c 100644 --- a/src/commands/generate/index.ts +++ b/src/commands/generate/index.ts @@ -6,6 +6,7 @@ import { DBField, DBType, DrizzleColumnType, + GenerateOptions, ORMType, PrismaColumnType, } from "../../types.js"; @@ -175,8 +176,8 @@ async function askForResourceType() { disabled: !packages.includes("trpc") ? "[You need to have tRPC installed. Run 'kirimase add']" : viewRequested === "views_and_components_trpc" - ? "[Already generated with your selected view]" - : false, + ? "[Already generated with your selected view]" + : false, }, ].filter((item) => viewRequested ? !viewRequested.includes(item.value.split("_")[0]) : item @@ -495,7 +496,7 @@ async function generateResources( await installShadcnComponentList(); } -export async function buildSchema() { +export async function buildSchema(options: GenerateOptions) { const ready = preBuild(); if (!ready) return; diff --git a/src/index.ts b/src/index.ts index 65535cbd..475f1b38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ addCommonOptions(program.command("init")) program .command("generate") .description("Generate a new resource") + .option("-f, --from-file ", "load schema from a JSON file") .action(buildSchema); addCommonOptions(program.command("add")) diff --git a/src/types.d.ts b/src/types.d.ts index e448d1d5..b8c1e006 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -118,6 +118,10 @@ export type InitOptions = { includeExample?: boolean; }; +export type GenerateOptions = { + fromFile?: string | null; +}; + // export type BuildOptions = { // resources?: ("model" | "api_route" | "trpc_route" | "views_and_components")[]; // table?: string; From 44a2f27333b24530cb1febdbefd08917287816ec Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 16:05:00 +0800 Subject: [PATCH 04/10] add function to validate schemas --- src/commands/generate/utils.ts | 48 +++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/commands/generate/utils.ts b/src/commands/generate/utils.ts index a56ec023..924f948b 100644 --- a/src/commands/generate/utils.ts +++ b/src/commands/generate/utils.ts @@ -1,12 +1,20 @@ import path from "path"; import pluralize from "pluralize"; +import { z } from "zod"; import { DBField, DBType, DrizzleColumnType, PrismaColumnType, } from "../../types.js"; -import { readConfigFile, replaceFile } from "../../utils.js"; +import { + mysqlColumns, + pgColumns, + prismaColumns, + readConfigFile, + replaceFile, + sqliteColumns, +} from "../../utils.js"; import fs, { existsSync, readFileSync } from "fs"; import { consola } from "consola"; import { formatFilePath, getFilePaths } from "../filePaths/index.js"; @@ -478,3 +486,41 @@ ${createNotesList([ "Documentation (https://kirimase.dev/commands/generate)", ])}`); }; + +export const validateSchemas = (schemas: Schema[]) => { + const validationSchema: z.ZodSchema = z.lazy(() => + z.object({ + tableName: z + .string() + .regex( + /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/, + "Table name must be in snake_case if more than one word, and plural." + ), + fields: z.array( + z.object({ + name: z + .string() + .regex( + /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/, + "Field name must be in snake_case if more than one word." + ), + type: z.enum([ + ...new Set([ + ...pgColumns, + ...mysqlColumns, + ...sqliteColumns, + ...prismaColumns, + ]), + ] as [string, ...string[]]), + notNull: z.boolean(), + }) + ), + index: z.union([z.string(), z.null()]), + belongsToUser: z.boolean(), + includeTimestamps: z.boolean(), + children: z.array(validationSchema), + }) + ); + + return validationSchema.array().parse(schemas); +}; From c20013e5c055652f9e907f4a395c16f41cc16319 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 16:06:03 +0800 Subject: [PATCH 05/10] add functions to format Zod errors --- src/commands/generate/utils.ts | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/commands/generate/utils.ts b/src/commands/generate/utils.ts index 924f948b..a6c157ad 100644 --- a/src/commands/generate/utils.ts +++ b/src/commands/generate/utils.ts @@ -524,3 +524,74 @@ export const validateSchemas = (schemas: Schema[]) => { return validationSchema.array().parse(schemas); }; + +// Pull only relevant field based on a given Zod issue path +function getInvalidSchemaFieldFromIssuePath(path: (string | number)[]) { + let fieldNamePath = []; + + const lastPart = path[path.length - 1]; + const fieldProperties: (keyof DBField)[] = [ + "name", + "type", + "references", + "notNull", + "cascade", + ]; + // If the invalid field is from the "fields" property, we want to also log + // the "fields" property and the index. + // For example "fields.0.name" + if ( + typeof lastPart === "string" && + fieldProperties.includes(lastPart as keyof DBField) + ) { + fieldNamePath.push("fields"); + fieldNamePath.push(path[path.length - 2]); // field property index + } + fieldNamePath.push(lastPart); + + return fieldNamePath.join("."); +} + +// Build schema tableName path based on a given Zod issue path +function getSchemaTableNamePathFromIssuePath( + schemas: Schema[], + path: (string | number)[] +) { + let currentSchema: Schema; + let currentSchemas: Schema[] = schemas; + let tableNamePath = []; + + for (let i = 0; i < path.length; i++) { + const part = path[i]; + const isIndex = !isNaN(Number(part)); + + if (isIndex) { + const index = Number(part); + currentSchema = currentSchemas[index]; + } else { + const fieldName = part; + tableNamePath.push(currentSchema.tableName); + if (fieldName === "children") { + currentSchemas = currentSchema.children; + } else { + break; + } + } + } + + return tableNamePath.join("."); +} + +export const formatSchemaValidationError = ( + error: z.ZodError, + schemas: Schema[] +) => { + const formattedError = error.issues.map((issue) => { + return { + message: issue.message, + field: getInvalidSchemaFieldFromIssuePath(issue.path), + tableName: getSchemaTableNamePathFromIssuePath(schemas, issue.path), + }; + }); + return JSON.stringify(formattedError, null, 2); +}; From 9a701cbf56c2b0b60d1666cfb52ef1f2d1d977b4 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 16:09:10 +0800 Subject: [PATCH 06/10] add function to parse schemas file --- src/commands/generate/index.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/commands/generate/index.ts b/src/commands/generate/index.ts index 5c54da0c..7e2898f2 100644 --- a/src/commands/generate/index.ts +++ b/src/commands/generate/index.ts @@ -1,6 +1,7 @@ import { checkbox, confirm, input, select } from "@inquirer/prompts"; import { consola } from "consola"; import pluralize from "pluralize"; +import { z } from "zod"; import { Config, DBField, @@ -25,10 +26,12 @@ import { ExtendedSchema, Schema } from "./types.js"; import { scaffoldViewsAndComponents } from "./generators/views.js"; import { camelCaseToSnakeCase, + formatSchemaValidationError, formatTableName, getCurrentSchemas, printGenerateNextSteps, toCamelCase, + validateSchemas, } from "./utils.js"; import { scaffoldModel } from "./generators/model/index.js"; import { scaffoldServerActions } from "./generators/serverActions.js"; @@ -496,6 +499,29 @@ async function generateResources( await installShadcnComponentList(); } +function parseSchemaFile(jsonString: string): Schema[] | null { + let schemas: Schema[] = []; + try { + schemas = JSON.parse(jsonString); + const validatedSchemas = validateSchemas(schemas); + return validatedSchemas; + } catch (error) { + if (error instanceof z.ZodError) { + consola.error( + `Error parsing schema file:\n${formatSchemaValidationError( + error, + schemas + )}` + ); + } else if (error instanceof SyntaxError) { + consola.error(`Failed to parse JSON: ${error.message}`); + } else { + consola.error(`Unexpected error: ${error}`); + } + return null; + } +} + export async function buildSchema(options: GenerateOptions) { const ready = preBuild(); if (!ready) return; From 02aba8893eba051bc0fb54e92a0687c9a0e29f0a Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 16:12:07 +0800 Subject: [PATCH 07/10] add steps to read and parse schema in buildSchema function --- src/commands/generate/index.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/commands/generate/index.ts b/src/commands/generate/index.ts index 7e2898f2..37ae20d6 100644 --- a/src/commands/generate/index.ts +++ b/src/commands/generate/index.ts @@ -1,5 +1,6 @@ import { checkbox, confirm, input, select } from "@inquirer/prompts"; import { consola } from "consola"; +import fs from "fs"; import pluralize from "pluralize"; import { z } from "zod"; import { @@ -531,20 +532,28 @@ export async function buildSchema(options: GenerateOptions) { if (config.orm !== null) { provideInstructions(); const resourceType = await askForResourceType(); - const schema = await getSchema(config, resourceType); + const schemas = options.fromFile + ? parseSchemaFile(fs.readFileSync(options.fromFile, "utf8")) + : [await getSchema(config, resourceType)]; + // would want to have something that formatted the schema object into: // an array of items that needed to be created using code commented below // would also need extra stuff like urls // TODO - const schemas = formatSchemaForGeneration(schema); + // Stop generate if schema parsing failed + if (!schemas) return; + + const formattedSchemas = schemas.flatMap((schema) => + formatSchemaForGeneration(schema) + ); await sendEvent("generate", { - schemas: JSON.stringify(anonymiseSchemas(schemas)), + schemas: JSON.stringify(anonymiseSchemas(formattedSchemas)), resources: resourceType, }); - for (let schema of schemas) { + for (let schema of formattedSchemas) { await generateResources(schema, resourceType); } printGenerateNextSteps(schema, resourceType); From ebae874f179b9a7b6eec38d08cc10463d39f7c7a Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 16:14:51 +0800 Subject: [PATCH 08/10] update printGenerateNextSteps to match the new format --- src/commands/generate/index.ts | 2 +- src/commands/generate/utils.ts | 20 +++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/commands/generate/index.ts b/src/commands/generate/index.ts index 37ae20d6..a6c5b3a4 100644 --- a/src/commands/generate/index.ts +++ b/src/commands/generate/index.ts @@ -556,7 +556,7 @@ export async function buildSchema(options: GenerateOptions) { for (let schema of formattedSchemas) { await generateResources(schema, resourceType); } - printGenerateNextSteps(schema, resourceType); + printGenerateNextSteps(schemas, resourceType); } else { consola.warn( "You need to have an ORM installed in order to use the scaffold command." diff --git a/src/commands/generate/utils.ts b/src/commands/generate/utils.ts index a6c157ad..a5f2e741 100644 --- a/src/commands/generate/utils.ts +++ b/src/commands/generate/utils.ts @@ -448,12 +448,20 @@ const resourceMapping: Record = { }; export const printGenerateNextSteps = ( - schema: Schema, + schemas: Schema[], resources: TResource[] ) => { const config = readConfigFile(); - const { tableNameNormalEnglishSingular, tableNameKebabCase } = - formatTableName(schema.tableName); + const { resourceNames, resourceEndpoints } = schemas.reduce( + (acc, schema) => { + const { tableNameNormalEnglishSingular, tableNameKebabCase } = + formatTableName(schema.tableName); + acc.resourceNames.push(tableNameNormalEnglishSingular); + acc.resourceEndpoints.push(tableNameKebabCase); + return acc; + }, + { resourceNames: [], resourceEndpoints: [] } + ); const ppm = config?.preferredPackageManager ?? "npm"; const dbMigration = [ @@ -463,7 +471,9 @@ export const printGenerateNextSteps = ( const viewInBrowser = [ `Run \`${ppm} run dev\``, - `Open http://localhost:3000/${tableNameKebabCase} in your browser`, + `Open the following URLs in your browser:\n${resourceEndpoints + .map((endpoint) => `👉 http://localhost:3000/${endpoint}`) + .join("\n")}`, ]; const nextStepsList = [ @@ -476,7 +486,7 @@ export const printGenerateNextSteps = ( consola.box(`🎉 Success! -Kirimase generated the following resources for \`${tableNameNormalEnglishSingular}\`: +Kirimase generated the following resources for \`${resourceNames.join(",")}\`: ${resources.map((r) => `- ${resourceMapping[r]}`).join("\n")} ${createNextStepsList(nextStepsList)} From 7cd94dfb1bcd32fd23aff8cf4c7d6f05b4064445 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 5 Mar 2024 16:50:06 +0800 Subject: [PATCH 09/10] add some docs --- README.md | 10 +++ .../generate/generate-schema-from-file.md | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/commands/generate/generate-schema-from-file.md diff --git a/README.md b/README.md index e4225e84..4fd899c6 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,16 @@ Kirimase generates: - Scaffolds views using Shadcn-UI to enable immediate CRUD operations (including select fields for adding relations and datepickers for dates). - Option to use either React Hook Form with tRPC or plain React (useOptimistic and useValidated Form hooks) +### Generate schemas from JSON file + +You can also import and generate schemas from a JSON file via the command + +```sh +kirimase generate -f +``` + +More info [here](commands/generate/generate-schema-from-file.md) + ## Run in non-interactive mode As of v0.0.23, you can run `kirimase init` and `kirimase add` entirely via the command line as follows: diff --git a/src/commands/generate/generate-schema-from-file.md b/src/commands/generate/generate-schema-from-file.md new file mode 100644 index 00000000..583bf10f --- /dev/null +++ b/src/commands/generate/generate-schema-from-file.md @@ -0,0 +1,76 @@ +## Generating schemas from JSON file + +You can also import and generate schemas from a JSON file via the command + +```sh +kirimase generate -f +``` + +### Input format + +You must provide an array of schema, and each of them must follows the `Schema` type definition + +```typescript +type Schema = { + tableName: string; + fields: { + name: string; + type: T; + references?: string; + notNull?: boolean; + cascade?: boolean; + }; + index: string; + belongsToUser?: boolean; + includeTimestamps: boolean; + children?: Schema[]; +}; +``` + +As you can guess, providing more than one schema will make Kirimase generates multiple schemas in a single `generate` command + +### Examples + +Here's a sample of a valid `schema.json` + +```json +[ + { + "tableName": "menu", + "fields": [ + { "name": "name", "type": "varchar", "notNull": true }, + { "name": "price", "type": "number", "notNull": true } + ], + "index": "name", + "belongsToUser": false, + "includeTimestamps": true, + "children": [] + } +] +``` + +Another example with children + +```json +[ + { + "tableName": "menu_discount", + "fields": [ + { "name": "code", "type": "number", "notNull": true } + ], + "index": null, + "belongsToUser": false, + "includeTimestamps": false, + "children": [ + { + "tableName": "menu_discount_analytics", + "fields": [{ "name": "used_count", "type": "number", "notNull": true }], + "index": null, + "belongsToUser": false, + "includeTimestamps": false, + "children": [] + } + ] + } +] +``` From 6daafb55de092a7ada8fc9b31019b4651827716a Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 6 Mar 2024 01:30:00 +0800 Subject: [PATCH 10/10] update docs with proper example --- src/commands/generate/generate-schema-from-file.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/generate/generate-schema-from-file.md b/src/commands/generate/generate-schema-from-file.md index 583bf10f..f33bac31 100644 --- a/src/commands/generate/generate-schema-from-file.md +++ b/src/commands/generate/generate-schema-from-file.md @@ -36,7 +36,7 @@ Here's a sample of a valid `schema.json` ```json [ { - "tableName": "menu", + "tableName": "products", "fields": [ { "name": "name", "type": "varchar", "notNull": true }, { "name": "price", "type": "number", "notNull": true } @@ -54,16 +54,17 @@ Another example with children ```json [ { - "tableName": "menu_discount", + "tableName": "product_discounts", "fields": [ - { "name": "code", "type": "number", "notNull": true } + { "name": "code", "type": "number", "notNull": true }, + { "name": "amount", "type": "number", "notNull": true } ], "index": null, "belongsToUser": false, "includeTimestamps": false, "children": [ { - "tableName": "menu_discount_analytics", + "tableName": "product_discount_analytics", "fields": [{ "name": "used_count", "type": "number", "notNull": true }], "index": null, "belongsToUser": false,