mutano 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Alisson Cavalcante Agiani
4
+ Copyright (c) 2023 Rise Works, Inc.
5
+ Copyright (c) 2023 Erwin Stone
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # mutano
2
+
3
+ Converts Prisma/MySQL schemas to Zod interfaces
4
+
5
+ ## Installation
6
+
7
+ Install `mutano` with npm
8
+
9
+ ```bash
10
+ npm install mutano --save-dev
11
+ ```
12
+
13
+ ## Usage/Examples
14
+
15
+ Create user table:
16
+
17
+ ```sql
18
+ CREATE TABLE `user` (
19
+ `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
20
+ `name` varchar(255) NOT NULL COMMENT '@zod(z.string().min(10).max(255))', -- this will override the type
21
+ `username` varchar(255) NOT NULL,
22
+ `password` varchar(255) NOT NULL,
23
+ `profile_picture` varchar(255) DEFAULT NULL,
24
+ `role` enum('admin','user') NOT NULL,
25
+ PRIMARY KEY (`id`)
26
+ );
27
+ ```
28
+ Use the mutano API:
29
+
30
+ ```typescript
31
+ import { generate } from 'mutano'
32
+
33
+ await generate({
34
+ origin: {
35
+ type: 'mysql',
36
+ host: '127.0.0.1',
37
+ port: 3306,
38
+ user: 'root',
39
+ password: 'secret',
40
+ database: 'myapp',
41
+ },
42
+ })
43
+ ```
44
+
45
+ The generator will create a `user.ts` file with the following contents:
46
+
47
+ ```typescript
48
+ import z from 'zod'
49
+
50
+ export const user = z.object({
51
+ id: z.number().nonnegative(),
52
+ name: z.string().min(10).max(255),
53
+ username: z.string(),
54
+ password: z.string(),
55
+ profile_picture: z.string().nullable(),
56
+ role: z.enum(['admin', 'user']),
57
+ })
58
+
59
+ export const insertable_user = z.object({
60
+ name: z.string().min(10).max(255),
61
+ username: z.string(),
62
+ password: z.string(),
63
+ profile_picture: z.string().nullable(),
64
+ role: z.enum(['admin', 'user']),
65
+ })
66
+
67
+ export const updateable_user = z.object({
68
+ name: z.string().min(10).max(255),
69
+ username: z.string(),
70
+ password: z.string(),
71
+ profile_picture: z.string().nullable(),
72
+ role: z.enum(['admin', 'user']),
73
+ })
74
+
75
+ export const selectable_user = z.object({
76
+ id: z.number().nonnegative(),
77
+ name: z.string().min(10).max(255),
78
+ username: z.string(),
79
+ password: z.string(),
80
+ profile_picture: z.string().nullable(),
81
+ role: z.enum(['admin', 'user']),
82
+ })
83
+
84
+ export type userType = z.infer<typeof user>
85
+ export type InsertableUserType = z.infer<typeof insertable_user>
86
+ export type UpdateableUserType = z.infer<typeof updateable_user>
87
+ export type SelectableUserType = z.infer<typeof selectable_user>
88
+ ```
89
+
90
+ ## Config
91
+
92
+ ```json
93
+ {
94
+ "origin": {
95
+ "type": "mysql",
96
+ "host": "127.0.0.1",
97
+ "port": 3306,
98
+ "user": "root",
99
+ "password": "secret",
100
+ "database": "myapp",
101
+ "ssl": {
102
+ "ca": "path/to/ca.pem",
103
+ "cert": "path/to/cert.pem",
104
+ "key": "path/to/key.pem"
105
+ },
106
+ } | {
107
+ "type": "prisma"
108
+ "path": "path/to/schema.prisma"
109
+ },
110
+ "tables": ["user", "log"],
111
+ "ignore": ["log", "/^temp/"],
112
+ "folder": "@zod",
113
+ "suffix": "table",
114
+ "camelCase": false,
115
+ "nullish": false,
116
+ "requiredString": false,
117
+ "useTrim": false,
118
+ "useDateType": false,
119
+ "silent": false,
120
+ "zodCommentTypes": true,
121
+ "overrideTypes": {
122
+ "tinyint": "z.boolean()"
123
+ }
124
+ }
125
+ ```
126
+
127
+ | Option | Description |
128
+ | ------ | ----------- |
129
+ | tables | Filter the tables to include only those specified. |
130
+ | ignore | Filter the tables to exclude those specified. If a table name begins and ends with "/", it will be processed as a regular expression. |
131
+ | folder | Specify the output directory. |
132
+ | suffix | Suffix to the name of a generated file. (eg: `user.table.ts`) |
133
+ | camelCase | Convert all table names and their properties to camelcase. (eg: `profile_picture` becomes `profilePicture`) |
134
+ | nullish | Set schema as `nullish` instead of `nullable` |
135
+ | requiredString | Add `min(1)` for string schema |
136
+ | useDateType | Use a specialized Zod type for date-like fields instead of string
137
+ | useTrim | Use `z.string().trim()` instead of `z.string()` |
138
+ | silent | Don't log anything to the console |
139
+ | overrideTypes | Override zod types for specific field types |
140
+ | zodCommentTypes | Use @zod comment to override entire type |
package/dist/main.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ export declare function getType(op: 'table' | 'insertable' | 'updateable' | 'selectable', desc: Desc, config: Config): string;
2
+ export declare function generate(config: Config): Promise<string[]>;
3
+ type ValidTypes = 'date' | 'datetime' | 'timestamp' | 'time' | 'year' | 'char' | 'varchar' | 'tinytext' | 'text' | 'mediumtext' | 'longtext' | 'json' | 'decimal' | 'tinyint' | 'smallint' | 'mediumint' | 'int' | 'bigint' | 'float' | 'double';
4
+ export interface Desc {
5
+ Field: string;
6
+ Default: string | null;
7
+ EnumOptions?: string[];
8
+ Extra: string;
9
+ Type: string;
10
+ Null: 'YES' | 'NO';
11
+ Comment: string;
12
+ }
13
+ export interface Config {
14
+ origin: {
15
+ type: 'prisma';
16
+ path: string;
17
+ } | {
18
+ type: 'mysql';
19
+ host: string;
20
+ port: number;
21
+ user: string;
22
+ password: string;
23
+ database: string;
24
+ };
25
+ tables?: string[];
26
+ ignore?: string[];
27
+ folder?: string;
28
+ suffix?: string;
29
+ camelCase?: boolean;
30
+ nullish?: boolean;
31
+ requiredString?: boolean;
32
+ useDateType?: boolean;
33
+ useTrim?: boolean;
34
+ silent?: boolean;
35
+ zodCommentTypes?: boolean;
36
+ ssl?: Record<string, any>;
37
+ overrideTypes?: {
38
+ [k in ValidTypes]?: string;
39
+ };
40
+ }
41
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,337 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import {
4
+ createPrismaSchemaBuilder
5
+ } from "@mrleebo/prisma-ast";
6
+ import camelCase from "camelcase";
7
+ import fs from "fs-extra";
8
+ import knex from "knex";
9
+ function extractZodExpression(comment) {
10
+ const zodStart = comment.indexOf("@zod(");
11
+ if (zodStart === -1)
12
+ return null;
13
+ let openParens = 0;
14
+ let position = zodStart + 5;
15
+ while (position < comment.length) {
16
+ if (comment[position] === "(") {
17
+ openParens++;
18
+ } else if (comment[position] === ")") {
19
+ if (openParens === 0) {
20
+ return comment.substring(zodStart + 5, position);
21
+ }
22
+ openParens--;
23
+ }
24
+ position++;
25
+ }
26
+ return null;
27
+ }
28
+ const prismaValidTypes = [
29
+ "BigInt",
30
+ "Boolean",
31
+ "Bytes",
32
+ "DateTime",
33
+ "Decimal",
34
+ "Float",
35
+ "Int",
36
+ "Json",
37
+ "String",
38
+ "Enum"
39
+ ];
40
+ const dateTypes = {
41
+ mysql: ["date", "datetime", "timestamp"],
42
+ prisma: ["DateTime"]
43
+ };
44
+ const stringTypes = {
45
+ mysql: [
46
+ "tinytext",
47
+ "text",
48
+ "mediumtext",
49
+ "longtext",
50
+ "json",
51
+ "decimal",
52
+ "time",
53
+ "year",
54
+ "char",
55
+ "varchar"
56
+ ],
57
+ prisma: ["String", "Decimal", "BigInt", "Bytes", "Json"]
58
+ };
59
+ const numberTypes = {
60
+ mysql: ["smallint", "mediumint", "int", "bigint", "float", "double"],
61
+ prisma: ["Int", "Float"]
62
+ };
63
+ const booleanTypes = { mysql: ["tinyint"], prisma: ["Boolean"] };
64
+ const enumTypes = { mysql: ["enum"], prisma: ["Enum"] };
65
+ function getType(op, desc, config) {
66
+ const schemaType = config.origin.type;
67
+ const { Default, Extra, Null, Type, Comment, EnumOptions } = desc;
68
+ const isNullish = config.nullish && config.nullish === true;
69
+ const isTrim = config.useTrim && config.useTrim === true && op !== "selectable";
70
+ const hasDefaultValue = Default !== null && op !== "selectable";
71
+ const isGenerated = ["DEFAULT_GENERATED", "auto_increment"].includes(Extra);
72
+ const isNull = Null === "YES";
73
+ if (isGenerated && !isNull && ["insertable", "updateable"].includes(op))
74
+ return;
75
+ const isRequiredString = config.requiredString && config.requiredString === true && op !== "selectable";
76
+ const isUseDateType = config.useDateType && config.useDateType === true;
77
+ const type = Type.split("(")[0].split(" ")[0];
78
+ const zDate = [
79
+ "z.union([z.number(), z.string(), z.date()]).pipe(z.coerce.date())"
80
+ ];
81
+ const string = [isTrim ? "z.string().trim()" : "z.string()"];
82
+ const number = ["z.number()"];
83
+ const boolean = [
84
+ "z.union([z.number(),z.string(),z.boolean()]).pipe(z.coerce.boolean())"
85
+ ];
86
+ const dateField = isUseDateType ? zDate : string;
87
+ const nullable = isNullish && op !== "selectable" ? "nullish()" : "nullable()";
88
+ const optional = "optional()";
89
+ const nonnegative = "nonnegative()";
90
+ const isUpdateableFormat = op === "updateable" && !isNull && !hasDefaultValue;
91
+ const min1 = "min(1)";
92
+ const zodOverrideType = config.zodCommentTypes ? extractZodExpression(Comment) : null;
93
+ const typeOverride = zodOverrideType ?? config.overrideTypes?.[type];
94
+ const generateDateLikeField = () => {
95
+ const field = typeOverride ? [typeOverride] : dateField;
96
+ if (isNull && !typeOverride)
97
+ field.push(nullable);
98
+ else if (hasDefaultValue)
99
+ field.push(optional);
100
+ if (hasDefaultValue && !isGenerated)
101
+ field.push(`default('${Default}')`);
102
+ if (isUpdateableFormat)
103
+ field.push(optional);
104
+ return field.join(".");
105
+ };
106
+ const generateStringLikeField = () => {
107
+ const field = typeOverride ? [typeOverride] : string;
108
+ if (isNull && !typeOverride)
109
+ field.push(nullable);
110
+ else if (hasDefaultValue)
111
+ field.push(optional);
112
+ else if (isRequiredString && !typeOverride)
113
+ field.push(min1);
114
+ if (hasDefaultValue && !isGenerated)
115
+ field.push(`default('${Default}')`);
116
+ if (isUpdateableFormat)
117
+ field.push(optional);
118
+ return field.join(".");
119
+ };
120
+ const generateBooleanLikeField = () => {
121
+ const field = typeOverride ? [typeOverride] : boolean;
122
+ if (isNull && !typeOverride)
123
+ field.push(nullable);
124
+ else if (hasDefaultValue)
125
+ field.push(optional);
126
+ if (hasDefaultValue && !isGenerated)
127
+ field.push(`default(${Boolean(+Default)})`);
128
+ if (isUpdateableFormat)
129
+ field.push(optional);
130
+ return field.join(".");
131
+ };
132
+ const generateNumberLikeField = () => {
133
+ const unsigned = Type.endsWith(" unsigned");
134
+ const field = typeOverride ? [typeOverride] : number;
135
+ if (unsigned && !typeOverride)
136
+ field.push(nonnegative);
137
+ if (isNull && !typeOverride)
138
+ field.push(nullable);
139
+ else if (hasDefaultValue)
140
+ field.push(optional);
141
+ if (hasDefaultValue && !isGenerated)
142
+ field.push(`default(${Default})`);
143
+ if (isUpdateableFormat)
144
+ field.push(optional);
145
+ return field.join(".");
146
+ };
147
+ const generateEnumLikeField = () => {
148
+ const value = schemaType === "mysql" ? Type.replace("enum(", "").replace(")", "").replace(/,/g, ",") : EnumOptions?.map((e) => `'${e}'`).join(",");
149
+ const field = [`z.enum([${value}])`];
150
+ if (isNull)
151
+ field.push(nullable);
152
+ else if (hasDefaultValue)
153
+ field.push(optional);
154
+ if (hasDefaultValue && !isGenerated)
155
+ field.push(`default('${Default}')`);
156
+ if (isUpdateableFormat)
157
+ field.push(optional);
158
+ return field.join(".");
159
+ };
160
+ if (dateTypes[schemaType].includes(type))
161
+ return generateDateLikeField();
162
+ if (stringTypes[schemaType].includes(type))
163
+ return generateStringLikeField();
164
+ if (numberTypes[schemaType].includes(type))
165
+ return generateNumberLikeField();
166
+ if (booleanTypes[schemaType].includes(type))
167
+ return generateBooleanLikeField();
168
+ if (enumTypes[schemaType].includes(type))
169
+ return generateEnumLikeField();
170
+ throw new Error(`Unsupported column type: ${type}`);
171
+ }
172
+ async function generate(config) {
173
+ let tables = [];
174
+ let prismaTables = [];
175
+ let schema = null;
176
+ const db = config.origin.type === "mysql" ? knex({
177
+ client: "mysql2",
178
+ connection: {
179
+ host: config.origin.host,
180
+ port: config.origin.port,
181
+ user: config.origin.user,
182
+ password: config.origin.password,
183
+ database: config.origin.database,
184
+ ssl: config.ssl
185
+ }
186
+ }) : null;
187
+ const isCamelCase = config.camelCase && config.camelCase === true;
188
+ if (config.origin.type === "prisma") {
189
+ const schemaContents = readFileSync(config.origin.path).toString();
190
+ schema = createPrismaSchemaBuilder(schemaContents);
191
+ prismaTables = schema.findAllByType("model", {});
192
+ tables = prismaTables.filter((t) => t !== null).map((table) => table.name);
193
+ } else {
194
+ const t = await db.raw(
195
+ "SELECT table_name as table_name FROM information_schema.tables WHERE table_schema = ?",
196
+ [config.origin.database]
197
+ );
198
+ tables = t[0].map((row) => row.table_name).sort();
199
+ }
200
+ const dests = [];
201
+ const includedTables = config.tables;
202
+ if (includedTables?.length)
203
+ tables = tables.filter((table) => includedTables.includes(table));
204
+ const allIgnoredTables = config.ignore;
205
+ const ignoredTablesRegex = allIgnoredTables?.filter((ignoreString) => {
206
+ return ignoreString.startsWith("/") && ignoreString.endsWith("/");
207
+ });
208
+ const ignoredTableNames = allIgnoredTables?.filter(
209
+ (table) => !ignoredTablesRegex?.includes(table)
210
+ );
211
+ if (ignoredTableNames?.length)
212
+ tables = tables.filter((table) => !ignoredTableNames.includes(table));
213
+ if (ignoredTablesRegex?.length) {
214
+ tables = tables.filter((table) => {
215
+ let useTable = true;
216
+ for (const text of ignoredTablesRegex) {
217
+ const pattern = text.substring(1, text.length - 1);
218
+ if (table.match(pattern) !== null)
219
+ useTable = false;
220
+ }
221
+ return useTable;
222
+ });
223
+ }
224
+ let describes = [];
225
+ for (let table of tables) {
226
+ if (config.origin.type === "mysql") {
227
+ const d = await db.raw(`SHOW FULL COLUMNS FROM ${table}`);
228
+ describes = d[0];
229
+ } else {
230
+ const prismaTable = prismaTables.find((t) => t?.name === table);
231
+ let enumOptions;
232
+ describes = prismaTable.properties.filter((p) => p.type === "field").map((field) => {
233
+ const defaultValueField = field.attributes ? field.attributes.find((a) => a.name === "default") : null;
234
+ const defaultValue = defaultValueField?.args?.[0].value;
235
+ const parsedDefaultValue = !!defaultValue && typeof defaultValue !== "object" ? defaultValue.toString() : null;
236
+ let fieldType = field.fieldType.toString();
237
+ if (!prismaValidTypes.includes(fieldType)) {
238
+ fieldType = "Enum";
239
+ enumOptions = schema.findAllByType("enum", {
240
+ name: fieldType
241
+ })[0]?.enumerators.filter(
242
+ (e) => e.type === "enumerator"
243
+ ).map((e) => e.name);
244
+ }
245
+ return {
246
+ Field: field.name,
247
+ Default: parsedDefaultValue,
248
+ EnumOptions: enumOptions,
249
+ Extra: defaultValue ? "DEFAULT_GENERATED" : "",
250
+ Type: field.fieldType.toString(),
251
+ Null: field.optional ? "YES" : "NO",
252
+ Comment: field.comment ?? ""
253
+ };
254
+ });
255
+ }
256
+ if (isCamelCase)
257
+ table = camelCase(table);
258
+ let content = `import { z } from 'zod'
259
+
260
+ export const ${table} = z.object({`;
261
+ for (const desc of describes) {
262
+ const field = isCamelCase ? camelCase(desc.Field) : desc.Field;
263
+ const type = getType("table", desc, config);
264
+ if (type) {
265
+ content = `${content}
266
+ ${field}: ${type},`;
267
+ }
268
+ }
269
+ content = `${content}
270
+ })
271
+
272
+ export const insertable_${table} = z.object({`;
273
+ for (const desc of describes) {
274
+ const field = isCamelCase ? camelCase(desc.Field) : desc.Field;
275
+ const type = getType("insertable", desc, config);
276
+ if (type) {
277
+ content = `${content}
278
+ ${field}: ${type},`;
279
+ }
280
+ }
281
+ content = `${content}
282
+ })
283
+
284
+ export const updateable_${table} = z.object({`;
285
+ for (const desc of describes) {
286
+ const field = isCamelCase ? camelCase(desc.Field) : desc.Field;
287
+ const type = getType("updateable", desc, config);
288
+ if (type) {
289
+ content = `${content}
290
+ ${field}: ${type},`;
291
+ }
292
+ }
293
+ content = `${content}
294
+ })
295
+
296
+ export const selectable_${table} = z.object({`;
297
+ for (const desc of describes) {
298
+ const field = isCamelCase ? camelCase(desc.Field) : desc.Field;
299
+ const type = getType("selectable", desc, config);
300
+ if (type) {
301
+ content = `${content}
302
+ ${field}: ${type},`;
303
+ }
304
+ }
305
+ content = `${content}
306
+ })
307
+
308
+ export type ${camelCase(`${table}Type`, {
309
+ pascalCase: true
310
+ })} = z.infer<typeof ${table}>
311
+ export type Insertable${camelCase(`${table}Type`, {
312
+ pascalCase: true
313
+ })} = z.infer<typeof insertable_${table}>
314
+ export type Updateable${camelCase(`${table}Type`, {
315
+ pascalCase: true
316
+ })} = z.infer<typeof updateable_${table}>
317
+ export type Selectable${camelCase(`${table}Type`, {
318
+ pascalCase: true
319
+ })} = z.infer<typeof selectable_${table}>
320
+ `;
321
+ const dir = config.folder && config.folder !== "" ? config.folder : ".";
322
+ const file = config.suffix && config.suffix !== "" ? `${table}.${config.suffix}.ts` : `${table}.ts`;
323
+ const dest = path.join(dir, file);
324
+ dests.push(dest);
325
+ if (!config.silent)
326
+ console.log("Created:", dest);
327
+ fs.outputFileSync(dest, content);
328
+ }
329
+ if (config.origin.type === "mysql") {
330
+ await db.destroy();
331
+ }
332
+ return dests;
333
+ }
334
+ export {
335
+ generate,
336
+ getType
337
+ };
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "mutano",
3
+ "type": "module",
4
+ "version": "1.0.0",
5
+ "description": "Converts Prisma/MySQL schemas to Zod interfaces",
6
+ "author": "Alisson Cavalcante Agiani <thelinuxlich@gmail.com>",
7
+ "license": "MIT",
8
+ "repository": "git@github.com:thelinuxlich/mutano.git",
9
+ "main": "dist/main.js",
10
+ "types": "dist/main.d.ts",
11
+ "files": ["dist"],
12
+ "scripts": {
13
+ "build": "esbuild src/main.ts --format=esm --platform=node --outfile=dist/main.js && tsc src/main.ts -d --emitDeclarationOnly --esModuleInterop --outDir dist",
14
+ "test": "vitest run"
15
+ },
16
+ "dependencies": {
17
+ "@mrleebo/prisma-ast": "^0.12.1",
18
+ "camelcase": "^8.0.0",
19
+ "fs-extra": "^11.1.1",
20
+ "knex": "^3.0.1",
21
+ "mysql2": "^3.6.3"
22
+ },
23
+ "devDependencies": {
24
+ "@types/fs-extra": "^11.0.3",
25
+ "esbuild": "^0.19.5",
26
+ "typescript": "^5.2.2",
27
+ "vitest": "^0.34.6"
28
+ }
29
+ }