schemock 0.0.1
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 +21 -0
- package/README.md +82 -0
- package/dist/adapters/index.d.mts +1364 -0
- package/dist/adapters/index.d.ts +1364 -0
- package/dist/adapters/index.js +36988 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/index.mjs +36972 -0
- package/dist/adapters/index.mjs.map +1 -0
- package/dist/cli/index.d.mts +831 -0
- package/dist/cli/index.d.ts +831 -0
- package/dist/cli/index.js +4425 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/index.mjs +4401 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/cli.js +6776 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +39439 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +39367 -0
- package/dist/index.mjs.map +1 -0
- package/dist/middleware/index.d.mts +688 -0
- package/dist/middleware/index.d.ts +688 -0
- package/dist/middleware/index.js +921 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/index.mjs +899 -0
- package/dist/middleware/index.mjs.map +1 -0
- package/dist/react/index.d.mts +316 -0
- package/dist/react/index.d.ts +316 -0
- package/dist/react/index.js +466 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/index.mjs +456 -0
- package/dist/react/index.mjs.map +1 -0
- package/dist/runtime/index.d.mts +814 -0
- package/dist/runtime/index.d.ts +814 -0
- package/dist/runtime/index.js +1270 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/index.mjs +1246 -0
- package/dist/runtime/index.mjs.map +1 -0
- package/dist/schema/index.d.mts +838 -0
- package/dist/schema/index.d.ts +838 -0
- package/dist/schema/index.js +696 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/index.mjs +681 -0
- package/dist/schema/index.mjs.map +1 -0
- package/dist/types-C1MiZh1d.d.ts +96 -0
- package/dist/types-C2bd2vgy.d.mts +773 -0
- package/dist/types-C2bd2vgy.d.ts +773 -0
- package/dist/types-C9VMgu3E.d.mts +289 -0
- package/dist/types-DV2DS7wj.d.mts +96 -0
- package/dist/types-c2AN3vky.d.ts +289 -0
- package/package.json +116 -0
|
@@ -0,0 +1,4401 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import { resolve, relative, join } from 'path';
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { stat, mkdir, writeFile, readdir } from 'fs/promises';
|
|
6
|
+
|
|
7
|
+
// src/cli/types.ts
|
|
8
|
+
function defineConfig(config) {
|
|
9
|
+
return {
|
|
10
|
+
schemas: "./src/schemas/**/*.ts",
|
|
11
|
+
output: "./src/generated",
|
|
12
|
+
adapter: "mock",
|
|
13
|
+
apiPrefix: "/api",
|
|
14
|
+
...config
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
var FakerMappingSchema = z.object({
|
|
18
|
+
hint: z.string().optional(),
|
|
19
|
+
type: z.string().optional(),
|
|
20
|
+
fieldName: z.instanceof(RegExp).optional(),
|
|
21
|
+
call: z.string()
|
|
22
|
+
});
|
|
23
|
+
var MockAdapterConfigSchema = z.object({
|
|
24
|
+
seed: z.record(z.string(), z.number()).optional(),
|
|
25
|
+
delay: z.number().min(0).optional(),
|
|
26
|
+
fakerSeed: z.number().optional(),
|
|
27
|
+
persist: z.boolean().optional(),
|
|
28
|
+
storageKey: z.string().optional()
|
|
29
|
+
}).strict();
|
|
30
|
+
var SupabaseAdapterConfigSchema = z.object({
|
|
31
|
+
tableMap: z.record(z.string(), z.string()).optional(),
|
|
32
|
+
envPrefix: z.string().optional()
|
|
33
|
+
}).strict();
|
|
34
|
+
var FirebaseAdapterConfigSchema = z.object({
|
|
35
|
+
collectionMap: z.record(z.string(), z.string()).optional()
|
|
36
|
+
}).strict();
|
|
37
|
+
var FetchAdapterConfigSchema = z.object({
|
|
38
|
+
baseUrl: z.string().url().optional(),
|
|
39
|
+
endpointPattern: z.string().optional()
|
|
40
|
+
}).strict();
|
|
41
|
+
var GraphQLAdapterConfigSchema = z.object({
|
|
42
|
+
operations: z.object({
|
|
43
|
+
findOne: z.string().optional(),
|
|
44
|
+
findMany: z.string().optional(),
|
|
45
|
+
create: z.string().optional(),
|
|
46
|
+
update: z.string().optional(),
|
|
47
|
+
delete: z.string().optional()
|
|
48
|
+
}).optional()
|
|
49
|
+
}).strict();
|
|
50
|
+
var PGliteAdapterConfigSchema = z.object({
|
|
51
|
+
persistence: z.enum(["memory", "indexeddb", "opfs"]).optional(),
|
|
52
|
+
dataDir: z.string().optional(),
|
|
53
|
+
fakerSeed: z.number().optional(),
|
|
54
|
+
seed: z.record(z.string(), z.number()).optional()
|
|
55
|
+
}).strict();
|
|
56
|
+
var PluralizeConfigSchema = z.object({
|
|
57
|
+
custom: z.record(z.string(), z.string()).optional()
|
|
58
|
+
}).strict();
|
|
59
|
+
var SchemockConfigSchema = z.object({
|
|
60
|
+
schemas: z.string().min(1, "schemas path is required"),
|
|
61
|
+
output: z.string().min(1, "output path is required"),
|
|
62
|
+
adapter: z.enum(["mock", "supabase", "firebase", "fetch", "graphql", "pglite"]),
|
|
63
|
+
apiPrefix: z.string(),
|
|
64
|
+
pluralization: PluralizeConfigSchema.optional(),
|
|
65
|
+
fakerMappings: z.array(FakerMappingSchema).optional(),
|
|
66
|
+
adapters: z.object({
|
|
67
|
+
mock: MockAdapterConfigSchema.optional(),
|
|
68
|
+
supabase: SupabaseAdapterConfigSchema.optional(),
|
|
69
|
+
firebase: FirebaseAdapterConfigSchema.optional(),
|
|
70
|
+
fetch: FetchAdapterConfigSchema.optional(),
|
|
71
|
+
graphql: GraphQLAdapterConfigSchema.optional(),
|
|
72
|
+
pglite: PGliteAdapterConfigSchema.optional()
|
|
73
|
+
}).optional()
|
|
74
|
+
}).strict();
|
|
75
|
+
function validateConfig(config, filePath) {
|
|
76
|
+
const result = SchemockConfigSchema.safeParse(config);
|
|
77
|
+
if (!result.success) {
|
|
78
|
+
const errors = result.error.errors.map((err) => {
|
|
79
|
+
const path = err.path.join(".");
|
|
80
|
+
return ` - ${path ? `${path}: ` : ""}${err.message}`;
|
|
81
|
+
}).join("\n");
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Invalid configuration in ${filePath}:
|
|
84
|
+
${errors}
|
|
85
|
+
|
|
86
|
+
Common issues:
|
|
87
|
+
- Typos in config keys (e.g., 'scemas' instead of 'schemas')
|
|
88
|
+
- Unknown adapter types
|
|
89
|
+
- Invalid adapter-specific options`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return result.data;
|
|
93
|
+
}
|
|
94
|
+
var CONFIG_FILES = ["schemock.config.ts", "schemock.config.js", "schemock.config.mjs"];
|
|
95
|
+
var DEFAULT_CONFIG = {
|
|
96
|
+
schemas: "./src/schemas/**/*.ts",
|
|
97
|
+
output: "./src/generated",
|
|
98
|
+
adapter: "mock",
|
|
99
|
+
apiPrefix: "/api"
|
|
100
|
+
};
|
|
101
|
+
async function loadConfig(configPath) {
|
|
102
|
+
if (configPath) {
|
|
103
|
+
const fullPath = resolve(configPath);
|
|
104
|
+
if (!existsSync(fullPath)) {
|
|
105
|
+
throw new Error(`Config file not found: ${configPath}`);
|
|
106
|
+
}
|
|
107
|
+
return await loadConfigFile(fullPath);
|
|
108
|
+
}
|
|
109
|
+
for (const filename of CONFIG_FILES) {
|
|
110
|
+
const fullPath = resolve(filename);
|
|
111
|
+
if (existsSync(fullPath)) {
|
|
112
|
+
return await loadConfigFile(fullPath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { ...DEFAULT_CONFIG };
|
|
116
|
+
}
|
|
117
|
+
async function loadConfigFile(fullPath) {
|
|
118
|
+
let rawConfig;
|
|
119
|
+
try {
|
|
120
|
+
const module = await import(fullPath);
|
|
121
|
+
rawConfig = module.default || module;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
try {
|
|
124
|
+
rawConfig = loadConfigViaTsx(fullPath);
|
|
125
|
+
} catch (tsxError) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`Failed to load config file: ${fullPath}
|
|
128
|
+
Original error: ${error}
|
|
129
|
+
tsx fallback error: ${tsxError}
|
|
130
|
+
|
|
131
|
+
Suggestions:
|
|
132
|
+
1. Ensure the config file has valid syntax
|
|
133
|
+
2. Install tsx globally: npm install -g tsx
|
|
134
|
+
3. Or compile TypeScript config to JavaScript first`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const mergedConfig = {
|
|
139
|
+
...DEFAULT_CONFIG,
|
|
140
|
+
...rawConfig
|
|
141
|
+
};
|
|
142
|
+
return validateConfig(mergedConfig, fullPath);
|
|
143
|
+
}
|
|
144
|
+
function loadConfigViaTsx(fullPath) {
|
|
145
|
+
const inlineScript = `
|
|
146
|
+
const path = process.argv[2];
|
|
147
|
+
const c = require(path);
|
|
148
|
+
console.log(JSON.stringify(c.default || c));
|
|
149
|
+
`;
|
|
150
|
+
const result = spawnSync("npx", ["tsx", "-e", inlineScript, fullPath], {
|
|
151
|
+
encoding: "utf-8",
|
|
152
|
+
// Don't use shell - pass args directly to avoid injection
|
|
153
|
+
shell: false,
|
|
154
|
+
// Set reasonable timeout to prevent hanging
|
|
155
|
+
timeout: 3e4
|
|
156
|
+
});
|
|
157
|
+
if (result.error) {
|
|
158
|
+
throw new Error(`Failed to execute tsx: ${result.error.message}`);
|
|
159
|
+
}
|
|
160
|
+
if (result.status !== 0) {
|
|
161
|
+
const stderr = result.stderr?.trim() || "Unknown error";
|
|
162
|
+
throw new Error(`tsx exited with code ${result.status}: ${stderr}`);
|
|
163
|
+
}
|
|
164
|
+
const output = result.stdout?.trim();
|
|
165
|
+
if (!output) {
|
|
166
|
+
throw new Error("tsx produced no output");
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(output);
|
|
170
|
+
} catch (parseError) {
|
|
171
|
+
throw new Error(`Failed to parse config output as JSON: ${output}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function getDefaultConfig() {
|
|
175
|
+
return { ...DEFAULT_CONFIG };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// src/schema/types.ts
|
|
179
|
+
function isEndpointSchema(value) {
|
|
180
|
+
return typeof value === "object" && value !== null && "_endpoint" in value && value._endpoint === true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// src/cli/discover.ts
|
|
184
|
+
function parseGlobPattern(pattern) {
|
|
185
|
+
const parts = pattern.split("/");
|
|
186
|
+
const baseParts = [];
|
|
187
|
+
const patternParts = [];
|
|
188
|
+
let foundGlob = false;
|
|
189
|
+
for (const part of parts) {
|
|
190
|
+
if (foundGlob || part.includes("*") || part.includes("?") || part.includes("[")) {
|
|
191
|
+
foundGlob = true;
|
|
192
|
+
patternParts.push(part);
|
|
193
|
+
} else {
|
|
194
|
+
baseParts.push(part);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
baseDir: baseParts.join("/") || ".",
|
|
199
|
+
patterns: patternParts
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function matchesPattern(filePath, patterns) {
|
|
203
|
+
const parts = filePath.split("/");
|
|
204
|
+
if (patterns.length === 0) return true;
|
|
205
|
+
const patternStr = patterns.join("/");
|
|
206
|
+
if (patternStr.includes("**")) {
|
|
207
|
+
let regexStr = patternStr.replace(/\*\*\//g, "<<<GLOBSTAR_SLASH>>>").replace(/\*\*/g, "<<<GLOBSTAR>>>").replace(/\?/g, "<<<QUESTION>>>").replace(/\*/g, "<<<STAR>>>").replace(/\./g, "\\.").replace(/<<<GLOBSTAR_SLASH>>>/g, "(?:.*/)?").replace(/<<<GLOBSTAR>>>/g, ".*").replace(/<<<STAR>>>/g, "[^/]*").replace(/<<<QUESTION>>>/g, ".");
|
|
208
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
209
|
+
return regex.test(filePath);
|
|
210
|
+
}
|
|
211
|
+
if (parts.length !== patterns.length) return false;
|
|
212
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
213
|
+
const pattern = patterns[i];
|
|
214
|
+
const part = parts[i];
|
|
215
|
+
if (pattern === "*") continue;
|
|
216
|
+
if (pattern.includes("*")) {
|
|
217
|
+
const regexStr = pattern.replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
218
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
219
|
+
if (!regex.test(part)) return false;
|
|
220
|
+
} else if (pattern !== part) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
async function findFiles(baseDir, patterns, currentPath = "") {
|
|
227
|
+
const fullPath = resolve(baseDir, currentPath);
|
|
228
|
+
const files = [];
|
|
229
|
+
try {
|
|
230
|
+
const entries = await readdir(fullPath, { withFileTypes: true });
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
|
233
|
+
if (entry.name === "node_modules" || entry.name.startsWith(".")) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (entry.isDirectory()) {
|
|
237
|
+
const subFiles = await findFiles(baseDir, patterns, entryPath);
|
|
238
|
+
files.push(...subFiles);
|
|
239
|
+
} else if (entry.isFile()) {
|
|
240
|
+
if (matchesPattern(entryPath, patterns)) {
|
|
241
|
+
if (!entry.name.endsWith(".d.ts") && !entry.name.endsWith(".test.ts") && !entry.name.endsWith(".spec.ts")) {
|
|
242
|
+
files.push(resolve(baseDir, entryPath));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch (error) {
|
|
248
|
+
console.warn(`Warning: Could not read directory ${fullPath}`);
|
|
249
|
+
}
|
|
250
|
+
return files;
|
|
251
|
+
}
|
|
252
|
+
function isEntitySchema(value) {
|
|
253
|
+
if (typeof value !== "object" || value === null) return false;
|
|
254
|
+
const obj = value;
|
|
255
|
+
return typeof obj.name === "string" && typeof obj.fields === "object" && obj.fields !== null;
|
|
256
|
+
}
|
|
257
|
+
async function discoverSchemas(pattern) {
|
|
258
|
+
let files;
|
|
259
|
+
const isGlobPattern = pattern.includes("*") || pattern.includes("?") || pattern.includes("[");
|
|
260
|
+
if (!isGlobPattern) {
|
|
261
|
+
const resolvedPath = resolve(pattern);
|
|
262
|
+
try {
|
|
263
|
+
const fileStat = await stat(resolvedPath);
|
|
264
|
+
if (fileStat.isFile()) {
|
|
265
|
+
files = [resolvedPath];
|
|
266
|
+
} else {
|
|
267
|
+
throw new Error(`Path is not a file: ${pattern}`);
|
|
268
|
+
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
throw new Error(`Schema file not found: ${pattern}`);
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
const { baseDir, patterns } = parseGlobPattern(pattern);
|
|
274
|
+
const resolvedBaseDir = resolve(baseDir);
|
|
275
|
+
files = await findFiles(resolvedBaseDir, patterns);
|
|
276
|
+
if (files.length === 0) {
|
|
277
|
+
throw new Error(`No schema files found matching: ${pattern}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const schemas = [];
|
|
281
|
+
const endpoints = [];
|
|
282
|
+
const loadedFiles = [];
|
|
283
|
+
for (const file of files) {
|
|
284
|
+
try {
|
|
285
|
+
const module = await import(file);
|
|
286
|
+
let foundSchema = false;
|
|
287
|
+
for (const [_exportName, value] of Object.entries(module)) {
|
|
288
|
+
if (isEntitySchema(value)) {
|
|
289
|
+
schemas.push(value);
|
|
290
|
+
foundSchema = true;
|
|
291
|
+
} else if (isEndpointSchema(value)) {
|
|
292
|
+
endpoints.push(value);
|
|
293
|
+
foundSchema = true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (foundSchema) {
|
|
297
|
+
loadedFiles.push(file);
|
|
298
|
+
}
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.warn(`Warning: Could not import ${file}: ${error}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (schemas.length === 0 && endpoints.length === 0) {
|
|
304
|
+
throw new Error("No schemas found. Make sure your schema files export defineData() or defineEndpoint() results.");
|
|
305
|
+
}
|
|
306
|
+
return { schemas, endpoints, files: loadedFiles };
|
|
307
|
+
}
|
|
308
|
+
function getRelativePath(absolutePath) {
|
|
309
|
+
return relative(process.cwd(), absolutePath);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/cli/utils/pluralize.ts
|
|
313
|
+
var irregulars = {
|
|
314
|
+
person: "people",
|
|
315
|
+
child: "children",
|
|
316
|
+
man: "men",
|
|
317
|
+
woman: "women",
|
|
318
|
+
tooth: "teeth",
|
|
319
|
+
foot: "feet",
|
|
320
|
+
mouse: "mice",
|
|
321
|
+
goose: "geese",
|
|
322
|
+
ox: "oxen",
|
|
323
|
+
leaf: "leaves",
|
|
324
|
+
life: "lives",
|
|
325
|
+
knife: "knives",
|
|
326
|
+
wife: "wives",
|
|
327
|
+
self: "selves",
|
|
328
|
+
elf: "elves",
|
|
329
|
+
loaf: "loaves",
|
|
330
|
+
potato: "potatoes",
|
|
331
|
+
tomato: "tomatoes",
|
|
332
|
+
cactus: "cacti",
|
|
333
|
+
focus: "foci",
|
|
334
|
+
fungus: "fungi",
|
|
335
|
+
nucleus: "nuclei",
|
|
336
|
+
syllabus: "syllabi",
|
|
337
|
+
analysis: "analyses",
|
|
338
|
+
diagnosis: "diagnoses",
|
|
339
|
+
thesis: "theses",
|
|
340
|
+
crisis: "crises",
|
|
341
|
+
phenomenon: "phenomena",
|
|
342
|
+
criterion: "criteria",
|
|
343
|
+
datum: "data"
|
|
344
|
+
};
|
|
345
|
+
var irregularsReverse = Object.fromEntries(
|
|
346
|
+
Object.entries(irregulars).map(([k, v]) => [v, k])
|
|
347
|
+
);
|
|
348
|
+
var uncountables = /* @__PURE__ */ new Set([
|
|
349
|
+
"sheep",
|
|
350
|
+
"fish",
|
|
351
|
+
"deer",
|
|
352
|
+
"species",
|
|
353
|
+
"series",
|
|
354
|
+
"news",
|
|
355
|
+
"money",
|
|
356
|
+
"rice",
|
|
357
|
+
"information",
|
|
358
|
+
"equipment"
|
|
359
|
+
]);
|
|
360
|
+
function isVowel(char) {
|
|
361
|
+
return "aeiou".includes(char?.toLowerCase() ?? "");
|
|
362
|
+
}
|
|
363
|
+
function singularize(word) {
|
|
364
|
+
const lower = word.toLowerCase();
|
|
365
|
+
if (irregularsReverse[lower]) {
|
|
366
|
+
return irregularsReverse[lower];
|
|
367
|
+
}
|
|
368
|
+
if (uncountables.has(lower)) {
|
|
369
|
+
return lower;
|
|
370
|
+
}
|
|
371
|
+
if (irregulars[lower]) {
|
|
372
|
+
return lower;
|
|
373
|
+
}
|
|
374
|
+
if (lower.endsWith("ies") && lower.length > 3) {
|
|
375
|
+
const base = lower.slice(0, -3);
|
|
376
|
+
if (base.length > 0 && !isVowel(base[base.length - 1])) {
|
|
377
|
+
return base + "y";
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (lower.endsWith("ves") && lower.length > 3) {
|
|
381
|
+
const base = lower.slice(0, -3);
|
|
382
|
+
const feWords = ["kni", "wi", "li"];
|
|
383
|
+
if (feWords.some((w) => base.endsWith(w))) {
|
|
384
|
+
return base + "fe";
|
|
385
|
+
}
|
|
386
|
+
return base + "f";
|
|
387
|
+
}
|
|
388
|
+
if (lower.endsWith("oes") && lower.length > 3) {
|
|
389
|
+
const base = lower.slice(0, -2);
|
|
390
|
+
if (base.length > 2) {
|
|
391
|
+
return base;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if ((lower.endsWith("ses") || lower.endsWith("xes") || lower.endsWith("zes") || lower.endsWith("ches") || lower.endsWith("shes")) && lower.length > 3) {
|
|
395
|
+
return lower.slice(0, -2);
|
|
396
|
+
}
|
|
397
|
+
if (lower.endsWith("s") && !lower.endsWith("ss") && lower.length > 1) {
|
|
398
|
+
return lower.slice(0, -1);
|
|
399
|
+
}
|
|
400
|
+
return lower;
|
|
401
|
+
}
|
|
402
|
+
function pluralize(word, config) {
|
|
403
|
+
const lower = word.toLowerCase();
|
|
404
|
+
if (config?.custom?.[lower]) {
|
|
405
|
+
return config.custom[lower];
|
|
406
|
+
}
|
|
407
|
+
const singular = singularize(lower);
|
|
408
|
+
if (irregulars[singular]) {
|
|
409
|
+
return irregulars[singular];
|
|
410
|
+
}
|
|
411
|
+
if (uncountables.has(singular)) {
|
|
412
|
+
return singular;
|
|
413
|
+
}
|
|
414
|
+
if (singular.endsWith("y") && !isVowel(singular[singular.length - 2])) {
|
|
415
|
+
return singular.slice(0, -1) + "ies";
|
|
416
|
+
}
|
|
417
|
+
if (singular.endsWith("s") || singular.endsWith("x") || singular.endsWith("z") || singular.endsWith("ch") || singular.endsWith("sh")) {
|
|
418
|
+
return singular + "es";
|
|
419
|
+
}
|
|
420
|
+
if (singular.endsWith("f")) {
|
|
421
|
+
return singular.slice(0, -1) + "ves";
|
|
422
|
+
}
|
|
423
|
+
if (singular.endsWith("fe")) {
|
|
424
|
+
return singular.slice(0, -2) + "ves";
|
|
425
|
+
}
|
|
426
|
+
return singular + "s";
|
|
427
|
+
}
|
|
428
|
+
function toPascalCase(str) {
|
|
429
|
+
return str.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
|
|
430
|
+
}
|
|
431
|
+
function toCamelCase(str) {
|
|
432
|
+
const pascal = toPascalCase(str);
|
|
433
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
434
|
+
}
|
|
435
|
+
function toSnakeCase(str) {
|
|
436
|
+
return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "").replace(/[-\s]+/g, "_");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// src/cli/utils/faker-mapping.ts
|
|
440
|
+
function escapeJsString(value) {
|
|
441
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
442
|
+
}
|
|
443
|
+
var defaultMappings = [
|
|
444
|
+
// By hint - Person
|
|
445
|
+
{ hint: "person.fullName", call: "faker.person.fullName()" },
|
|
446
|
+
{ hint: "person.firstName", call: "faker.person.firstName()" },
|
|
447
|
+
{ hint: "person.lastName", call: "faker.person.lastName()" },
|
|
448
|
+
{ hint: "person.bio", call: "faker.lorem.paragraph()" },
|
|
449
|
+
{ hint: "person.jobTitle", call: "faker.person.jobTitle()" },
|
|
450
|
+
// By hint - Internet
|
|
451
|
+
{ hint: "internet.email", call: "faker.internet.email()" },
|
|
452
|
+
{ hint: "internet.url", call: "faker.internet.url()" },
|
|
453
|
+
{ hint: "internet.avatar", call: "faker.image.avatar()" },
|
|
454
|
+
{ hint: "internet.username", call: "faker.internet.username()" },
|
|
455
|
+
{ hint: "internet.password", call: "faker.internet.password()" },
|
|
456
|
+
// By hint - Lorem
|
|
457
|
+
{ hint: "lorem.word", call: "faker.lorem.word()" },
|
|
458
|
+
{ hint: "lorem.sentence", call: "faker.lorem.sentence()" },
|
|
459
|
+
{ hint: "lorem.paragraph", call: "faker.lorem.paragraph()" },
|
|
460
|
+
{ hint: "lorem.paragraphs", call: "faker.lorem.paragraphs(3)" },
|
|
461
|
+
{ hint: "lorem.text", call: "faker.lorem.text()" },
|
|
462
|
+
// By hint - Image
|
|
463
|
+
{ hint: "image.avatar", call: "faker.image.avatar()" },
|
|
464
|
+
{ hint: "image.url", call: "faker.image.url()" },
|
|
465
|
+
// By hint - Location
|
|
466
|
+
{ hint: "location.city", call: "faker.location.city()" },
|
|
467
|
+
{ hint: "location.country", call: "faker.location.country()" },
|
|
468
|
+
{ hint: "location.streetAddress", call: "faker.location.streetAddress()" },
|
|
469
|
+
{ hint: "location.zipCode", call: "faker.location.zipCode()" },
|
|
470
|
+
{ hint: "location.latitude", call: "faker.location.latitude()" },
|
|
471
|
+
{ hint: "location.longitude", call: "faker.location.longitude()" },
|
|
472
|
+
// By hint - Commerce
|
|
473
|
+
{ hint: "commerce.price", call: "parseFloat(faker.commerce.price())" },
|
|
474
|
+
{ hint: "commerce.productName", call: "faker.commerce.productName()" },
|
|
475
|
+
{ hint: "commerce.department", call: "faker.commerce.department()" },
|
|
476
|
+
// By hint - Company
|
|
477
|
+
{ hint: "company.name", call: "faker.company.name()" },
|
|
478
|
+
{ hint: "company.catchPhrase", call: "faker.company.catchPhrase()" },
|
|
479
|
+
// By hint - Color
|
|
480
|
+
{ hint: "color.rgb", call: "faker.color.rgb()" },
|
|
481
|
+
{ hint: "color.human", call: "faker.color.human()" },
|
|
482
|
+
// By hint - Date
|
|
483
|
+
{ hint: "date.past", call: "faker.date.past()" },
|
|
484
|
+
{ hint: "date.future", call: "faker.date.future()" },
|
|
485
|
+
{ hint: "date.recent", call: "faker.date.recent()" },
|
|
486
|
+
{ hint: "date.birthdate", call: "faker.date.birthdate()" },
|
|
487
|
+
// By field name patterns
|
|
488
|
+
{ fieldName: /^email$/i, call: "faker.internet.email()" },
|
|
489
|
+
{ fieldName: /^name$/i, call: "faker.person.fullName()" },
|
|
490
|
+
{ fieldName: /firstName/i, call: "faker.person.firstName()" },
|
|
491
|
+
{ fieldName: /lastName/i, call: "faker.person.lastName()" },
|
|
492
|
+
{ fieldName: /phone/i, call: "faker.phone.number()" },
|
|
493
|
+
{ fieldName: /avatar/i, call: "faker.image.avatar()" },
|
|
494
|
+
{ fieldName: /image|photo|picture/i, call: "faker.image.url()" },
|
|
495
|
+
{ fieldName: /url|link|website/i, call: "faker.internet.url()" },
|
|
496
|
+
{ fieldName: /address/i, call: "faker.location.streetAddress()" },
|
|
497
|
+
{ fieldName: /city/i, call: "faker.location.city()" },
|
|
498
|
+
{ fieldName: /country/i, call: "faker.location.country()" },
|
|
499
|
+
{ fieldName: /zip|postal/i, call: "faker.location.zipCode()" },
|
|
500
|
+
{ fieldName: /price|cost|amount/i, call: "parseFloat(faker.commerce.price())" },
|
|
501
|
+
{ fieldName: /title/i, call: "faker.lorem.sentence({ min: 3, max: 8 })" },
|
|
502
|
+
{ fieldName: /description|content|body|text/i, call: "faker.lorem.paragraphs(2)" },
|
|
503
|
+
{ fieldName: /bio|about/i, call: "faker.lorem.paragraph()" },
|
|
504
|
+
{ fieldName: /color/i, call: "faker.color.rgb()" },
|
|
505
|
+
{ fieldName: /slug/i, call: "faker.helpers.slugify(faker.lorem.words(3))" },
|
|
506
|
+
{ fieldName: /token|key|secret/i, call: "faker.string.alphanumeric(32)" },
|
|
507
|
+
// By type (fallback)
|
|
508
|
+
{ type: "uuid", call: "faker.string.uuid()" },
|
|
509
|
+
{ type: "email", call: "faker.internet.email()" },
|
|
510
|
+
{ type: "url", call: "faker.internet.url()" },
|
|
511
|
+
{ type: "string", call: "faker.lorem.word()" },
|
|
512
|
+
{ type: "text", call: "faker.lorem.paragraphs(2)" },
|
|
513
|
+
{ type: "number", call: "faker.number.int({ min: 1, max: 1000 })" },
|
|
514
|
+
{ type: "int", call: "faker.number.int({ min: 1, max: 1000 })" },
|
|
515
|
+
{ type: "float", call: "faker.number.float({ min: 0, max: 1000, fractionDigits: 2 })" },
|
|
516
|
+
{ type: "boolean", call: "faker.datatype.boolean()" },
|
|
517
|
+
{ type: "date", call: "faker.date.recent()" },
|
|
518
|
+
{ type: "json", call: "{}" },
|
|
519
|
+
{ type: "ref", call: "faker.string.uuid()" }
|
|
520
|
+
];
|
|
521
|
+
function fieldToFakerCall(fieldName, field, config) {
|
|
522
|
+
const mappings = [...config.fakerMappings || [], ...defaultMappings];
|
|
523
|
+
if (field.type === "enum" || field.values && field.values.length > 0) {
|
|
524
|
+
const values = field.values.map((v) => `'${escapeJsString(v)}'`).join(", ");
|
|
525
|
+
return `faker.helpers.arrayElement([${values}])`;
|
|
526
|
+
}
|
|
527
|
+
if (field.type === "array") {
|
|
528
|
+
const itemCall = field.items ? fieldToFakerCall("item", field.items, config) : "faker.lorem.word()";
|
|
529
|
+
const min = field.constraints?.min ?? 1;
|
|
530
|
+
const max = field.constraints?.max ?? 5;
|
|
531
|
+
return `Array.from({ length: faker.number.int({ min: ${min}, max: ${max} }) }, () => ${itemCall})`;
|
|
532
|
+
}
|
|
533
|
+
if (field.type === "object" && field.shape) {
|
|
534
|
+
const props = Object.entries(field.shape).map(([k, v]) => `${k}: ${fieldToFakerCall(k, v, config)}`).join(", ");
|
|
535
|
+
return `({ ${props} })`;
|
|
536
|
+
}
|
|
537
|
+
if (field.hint) {
|
|
538
|
+
const match = mappings.find((m) => m.hint === field.hint);
|
|
539
|
+
if (match) return match.call;
|
|
540
|
+
}
|
|
541
|
+
for (const mapping of mappings) {
|
|
542
|
+
if (mapping.fieldName && mapping.fieldName.test(fieldName)) {
|
|
543
|
+
return mapping.call;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (field.type === "number" || field.type === "int") {
|
|
547
|
+
const min = field.constraints?.min ?? 1;
|
|
548
|
+
const max = field.constraints?.max ?? 1e3;
|
|
549
|
+
return `faker.number.int({ min: ${min}, max: ${max} })`;
|
|
550
|
+
}
|
|
551
|
+
if (field.type === "float") {
|
|
552
|
+
const min = field.constraints?.min ?? 0;
|
|
553
|
+
const max = field.constraints?.max ?? 1e3;
|
|
554
|
+
return `faker.number.float({ min: ${min}, max: ${max}, fractionDigits: 2 })`;
|
|
555
|
+
}
|
|
556
|
+
if (field.type === "string" && (field.constraints?.min || field.constraints?.max)) {
|
|
557
|
+
const min = field.constraints?.min ?? 1;
|
|
558
|
+
const max = field.constraints?.max ?? 100;
|
|
559
|
+
return `faker.string.alphanumeric({ length: { min: ${min}, max: ${max} } })`;
|
|
560
|
+
}
|
|
561
|
+
const typeMatch = mappings.find((m) => m.type === field.type);
|
|
562
|
+
if (typeMatch) return typeMatch.call;
|
|
563
|
+
return "faker.lorem.word()";
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/cli/utils/type-mapping.ts
|
|
567
|
+
function fieldToTsType(field) {
|
|
568
|
+
switch (field.type) {
|
|
569
|
+
case "uuid":
|
|
570
|
+
case "string":
|
|
571
|
+
case "email":
|
|
572
|
+
case "url":
|
|
573
|
+
case "text":
|
|
574
|
+
return "string";
|
|
575
|
+
case "number":
|
|
576
|
+
case "int":
|
|
577
|
+
case "integer":
|
|
578
|
+
case "float":
|
|
579
|
+
case "double":
|
|
580
|
+
return "number";
|
|
581
|
+
// BigInt support - use native bigint type
|
|
582
|
+
case "bigint":
|
|
583
|
+
case "bigserial":
|
|
584
|
+
return "bigint";
|
|
585
|
+
// Decimal/Money - use string for precision (avoids floating point issues)
|
|
586
|
+
case "decimal":
|
|
587
|
+
case "numeric":
|
|
588
|
+
case "money":
|
|
589
|
+
return "string";
|
|
590
|
+
// Binary data - use Uint8Array or Buffer
|
|
591
|
+
case "bytea":
|
|
592
|
+
case "binary":
|
|
593
|
+
case "blob":
|
|
594
|
+
return "Uint8Array";
|
|
595
|
+
case "boolean":
|
|
596
|
+
return "boolean";
|
|
597
|
+
// Date/time types
|
|
598
|
+
case "date":
|
|
599
|
+
case "datetime":
|
|
600
|
+
case "timestamp":
|
|
601
|
+
case "timestamptz":
|
|
602
|
+
return "Date";
|
|
603
|
+
// Time without date
|
|
604
|
+
case "time":
|
|
605
|
+
case "timetz":
|
|
606
|
+
return "string";
|
|
607
|
+
// HH:MM:SS format
|
|
608
|
+
// Interval (duration)
|
|
609
|
+
case "interval":
|
|
610
|
+
return "string";
|
|
611
|
+
// ISO 8601 duration or PostgreSQL interval format
|
|
612
|
+
case "enum":
|
|
613
|
+
if (field.values && field.values.length > 0) {
|
|
614
|
+
return field.values.map((v) => `'${v}'`).join(" | ");
|
|
615
|
+
}
|
|
616
|
+
return "string";
|
|
617
|
+
case "array":
|
|
618
|
+
if (field.items) {
|
|
619
|
+
return `${fieldToTsType(field.items)}[]`;
|
|
620
|
+
}
|
|
621
|
+
return "unknown[]";
|
|
622
|
+
case "object":
|
|
623
|
+
if (field.shape) {
|
|
624
|
+
const props = Object.entries(field.shape).map(([k, v]) => `${escapePropertyKey(k)}: ${fieldToTsType(v)}`).join("; ");
|
|
625
|
+
return `{ ${props} }`;
|
|
626
|
+
}
|
|
627
|
+
return "Record<string, unknown>";
|
|
628
|
+
case "json":
|
|
629
|
+
case "jsonb":
|
|
630
|
+
return "unknown";
|
|
631
|
+
case "ref":
|
|
632
|
+
return "string";
|
|
633
|
+
// FK is always string UUID
|
|
634
|
+
// PostGIS geometry types
|
|
635
|
+
case "point":
|
|
636
|
+
return "{ x: number; y: number }";
|
|
637
|
+
case "geometry":
|
|
638
|
+
case "geography":
|
|
639
|
+
return "unknown";
|
|
640
|
+
// GeoJSON or WKT - depends on use case
|
|
641
|
+
default:
|
|
642
|
+
return "unknown";
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function escapePropertyKey(key) {
|
|
646
|
+
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
|
|
647
|
+
return key;
|
|
648
|
+
}
|
|
649
|
+
return `'${key.replace(/'/g, "\\'")}'`;
|
|
650
|
+
}
|
|
651
|
+
function primitiveToTs(type) {
|
|
652
|
+
switch (type) {
|
|
653
|
+
case "string":
|
|
654
|
+
case "text":
|
|
655
|
+
case "uuid":
|
|
656
|
+
case "email":
|
|
657
|
+
case "url":
|
|
658
|
+
return "string";
|
|
659
|
+
case "number":
|
|
660
|
+
case "int":
|
|
661
|
+
case "integer":
|
|
662
|
+
case "float":
|
|
663
|
+
case "double":
|
|
664
|
+
return "number";
|
|
665
|
+
case "bigint":
|
|
666
|
+
case "bigserial":
|
|
667
|
+
return "bigint";
|
|
668
|
+
case "decimal":
|
|
669
|
+
case "numeric":
|
|
670
|
+
case "money":
|
|
671
|
+
return "string";
|
|
672
|
+
case "bytea":
|
|
673
|
+
case "binary":
|
|
674
|
+
case "blob":
|
|
675
|
+
return "Uint8Array";
|
|
676
|
+
case "boolean":
|
|
677
|
+
return "boolean";
|
|
678
|
+
case "date":
|
|
679
|
+
case "datetime":
|
|
680
|
+
case "timestamp":
|
|
681
|
+
case "timestamptz":
|
|
682
|
+
return "Date";
|
|
683
|
+
case "time":
|
|
684
|
+
case "timetz":
|
|
685
|
+
case "interval":
|
|
686
|
+
return "string";
|
|
687
|
+
default:
|
|
688
|
+
return "unknown";
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// src/cli/analyze.ts
|
|
693
|
+
function inferComputedType(name) {
|
|
694
|
+
const lowerName = name.toLowerCase();
|
|
695
|
+
if (lowerName.endsWith("at") || lowerName.endsWith("date") || lowerName.endsWith("time") || lowerName.endsWith("timestamp") || lowerName.endsWith("datetime") || lowerName.endsWith("since") || lowerName.endsWith("until") || lowerName.endsWith("deadline") || lowerName.startsWith("date") || lowerName.startsWith("time")) {
|
|
696
|
+
return "Date";
|
|
697
|
+
}
|
|
698
|
+
if (lowerName.endsWith("count") || lowerName.endsWith("total") || lowerName.endsWith("sum") || lowerName.endsWith("avg") || lowerName.endsWith("average") || lowerName.endsWith("min") || lowerName.endsWith("max") || lowerName.endsWith("length") || lowerName.endsWith("size") || lowerName.endsWith("index")) {
|
|
699
|
+
return "number";
|
|
700
|
+
}
|
|
701
|
+
if (lowerName.includes("amount") || lowerName.includes("price") || lowerName.includes("cost") || lowerName.includes("fee") || lowerName.includes("balance") || lowerName.includes("score") || lowerName.includes("rating") || lowerName.includes("rank") || lowerName.includes("level") || lowerName.includes("age") || lowerName.includes("weight") || lowerName.includes("height") || lowerName.includes("width") || lowerName.includes("depth") || lowerName.includes("quantity") || lowerName.includes("percent") || lowerName.includes("ratio") || lowerName.includes("progress") || lowerName.includes("position") || lowerName.includes("order") || lowerName.includes("duration") || lowerName.includes("offset") || lowerName.includes("limit")) {
|
|
702
|
+
return "number";
|
|
703
|
+
}
|
|
704
|
+
if (lowerName.startsWith("is") || lowerName.startsWith("has") || lowerName.startsWith("can") || lowerName.startsWith("should") || lowerName.startsWith("will") || lowerName.startsWith("was") || lowerName.startsWith("did") || lowerName.startsWith("does") || lowerName.startsWith("allow") || lowerName.startsWith("enable") || lowerName.startsWith("disable")) {
|
|
705
|
+
return "boolean";
|
|
706
|
+
}
|
|
707
|
+
if (lowerName.endsWith("enabled") || lowerName.endsWith("disabled") || lowerName.endsWith("active") || lowerName.endsWith("visible") || lowerName.endsWith("hidden") || lowerName.endsWith("valid") || lowerName.endsWith("invalid") || lowerName.endsWith("complete") || lowerName.endsWith("empty") || lowerName.endsWith("loading") || lowerName.endsWith("loaded") || lowerName.endsWith("ready") || lowerName.endsWith("available") || lowerName.endsWith("exists") || lowerName.endsWith("selected") || lowerName.endsWith("checked") || lowerName.endsWith("required") || lowerName.endsWith("optional")) {
|
|
708
|
+
return "boolean";
|
|
709
|
+
}
|
|
710
|
+
if (lowerName.endsWith("name") || lowerName.endsWith("title") || lowerName.endsWith("label") || lowerName.endsWith("description") || lowerName.endsWith("text") || lowerName.endsWith("content") || lowerName.endsWith("message") || lowerName.endsWith("str") || lowerName.endsWith("string") || lowerName.endsWith("slug") || lowerName.endsWith("path") || lowerName.endsWith("url") || lowerName.endsWith("uri") || lowerName.endsWith("email") || lowerName.endsWith("phone") || lowerName.endsWith("address") || lowerName.endsWith("display") || lowerName.endsWith("format") || lowerName.endsWith("html") || lowerName.endsWith("json") || lowerName.endsWith("type") || lowerName.endsWith("status") || lowerName.endsWith("key") || lowerName.endsWith("id") || lowerName.endsWith("code") || lowerName.endsWith("token") || lowerName.endsWith("hash") || lowerName.endsWith("signature")) {
|
|
711
|
+
return "string";
|
|
712
|
+
}
|
|
713
|
+
if (lowerName.startsWith("get") && (lowerName.includes("name") || lowerName.includes("title") || lowerName.includes("label") || lowerName.includes("display") || lowerName.includes("format") || lowerName.includes("string"))) {
|
|
714
|
+
return "string";
|
|
715
|
+
}
|
|
716
|
+
if (lowerName.endsWith("list") || lowerName.endsWith("array") || lowerName.endsWith("items") || lowerName.endsWith("collection") || lowerName.endsWith("all") || lowerName.endsWith("entries") || lowerName.endsWith("values") || lowerName.endsWith("keys") || lowerName.endsWith("ids") || lowerName.endsWith("names") || lowerName.startsWith("all") || lowerName.startsWith("list")) {
|
|
717
|
+
return "unknown[]";
|
|
718
|
+
}
|
|
719
|
+
if ((lowerName.startsWith("get") || lowerName.startsWith("fetch") || lowerName.startsWith("load") || lowerName.startsWith("find")) && (lowerName.endsWith("s") && !lowerName.endsWith("ss"))) {
|
|
720
|
+
return "unknown[]";
|
|
721
|
+
}
|
|
722
|
+
if (lowerName.endsWith("config") || lowerName.endsWith("options") || lowerName.endsWith("settings") || lowerName.endsWith("props") || lowerName.endsWith("properties") || lowerName.endsWith("data") || lowerName.endsWith("info") || lowerName.endsWith("meta") || lowerName.endsWith("metadata") || lowerName.endsWith("attrs") || lowerName.endsWith("attributes") || lowerName.endsWith("context") || lowerName.endsWith("state") || lowerName.endsWith("result") || lowerName.endsWith("response") || lowerName.endsWith("payload")) {
|
|
723
|
+
return "Record<string, unknown>";
|
|
724
|
+
}
|
|
725
|
+
if (lowerName.startsWith("get") || lowerName.startsWith("compute") || lowerName.startsWith("calculate") || lowerName.startsWith("derive")) {
|
|
726
|
+
const withoutPrefix = lowerName.replace(/^(get|compute|calculate|derive)/, "");
|
|
727
|
+
if (withoutPrefix) {
|
|
728
|
+
return inferComputedType(withoutPrefix);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return "unknown";
|
|
732
|
+
}
|
|
733
|
+
function findSchemaByName(schemaMap, name) {
|
|
734
|
+
const exact = schemaMap.get(name);
|
|
735
|
+
if (exact) return exact;
|
|
736
|
+
const singular = singularize(name);
|
|
737
|
+
const singularMatch = schemaMap.get(singular);
|
|
738
|
+
if (singularMatch) return singularMatch;
|
|
739
|
+
const plural = pluralize(name);
|
|
740
|
+
const pluralMatch = schemaMap.get(plural);
|
|
741
|
+
if (pluralMatch) return pluralMatch;
|
|
742
|
+
const lowerName = name.toLowerCase();
|
|
743
|
+
for (const [schemaName, schema] of schemaMap) {
|
|
744
|
+
if (schemaName.toLowerCase() === lowerName) return schema;
|
|
745
|
+
if (singularize(schemaName).toLowerCase() === lowerName) return schema;
|
|
746
|
+
if (pluralize(schemaName).toLowerCase() === lowerName) return schema;
|
|
747
|
+
}
|
|
748
|
+
return void 0;
|
|
749
|
+
}
|
|
750
|
+
function analyzeSchemas(schemas, config) {
|
|
751
|
+
const schemaMap = new Map(schemas.map((s) => [s.name, s]));
|
|
752
|
+
const analyzed = [];
|
|
753
|
+
for (const schema of schemas) {
|
|
754
|
+
analyzed.push(analyzeSchema(schema, schemaMap, config));
|
|
755
|
+
}
|
|
756
|
+
return topologicalSort(analyzed);
|
|
757
|
+
}
|
|
758
|
+
function analyzeSchema(schema, schemaMap, config) {
|
|
759
|
+
const singular = singularize(schema.name);
|
|
760
|
+
const plural = pluralize(schema.name, config.pluralization);
|
|
761
|
+
const adapterConfig = config.adapters?.[config.adapter];
|
|
762
|
+
let tableName = plural;
|
|
763
|
+
if (config.adapter === "supabase" && adapterConfig && "tableMap" in adapterConfig) {
|
|
764
|
+
tableName = adapterConfig.tableMap?.[schema.name] ?? plural;
|
|
765
|
+
} else if (config.adapter === "firebase" && adapterConfig && "collectionMap" in adapterConfig) {
|
|
766
|
+
tableName = adapterConfig.collectionMap?.[schema.name] ?? plural;
|
|
767
|
+
}
|
|
768
|
+
const result = {
|
|
769
|
+
name: schema.name,
|
|
770
|
+
singularName: singular,
|
|
771
|
+
pluralName: plural,
|
|
772
|
+
pascalName: toPascalCase(singular),
|
|
773
|
+
// Use singular for PascalCase (User, not Users)
|
|
774
|
+
pascalSingularName: toPascalCase(singular),
|
|
775
|
+
pascalPluralName: toPascalCase(plural),
|
|
776
|
+
tableName,
|
|
777
|
+
endpoint: `${config.apiPrefix}/${plural}`,
|
|
778
|
+
fields: [],
|
|
779
|
+
relations: [],
|
|
780
|
+
computed: [],
|
|
781
|
+
dependsOn: [],
|
|
782
|
+
hasTimestamps: schema.timestamps ?? true,
|
|
783
|
+
isJunctionTable: false,
|
|
784
|
+
rls: analyzeRLS(schema.rls),
|
|
785
|
+
indexes: [],
|
|
786
|
+
// Will be populated after fields analysis
|
|
787
|
+
rpc: [],
|
|
788
|
+
// Will be populated after RPC analysis
|
|
789
|
+
original: schema
|
|
790
|
+
};
|
|
791
|
+
let refCount = 0;
|
|
792
|
+
let nonRefNonIdCount = 0;
|
|
793
|
+
for (const [fieldName, field] of Object.entries(schema.fields)) {
|
|
794
|
+
const analyzedField = analyzeField(fieldName, field, config);
|
|
795
|
+
result.fields.push(analyzedField);
|
|
796
|
+
if (analyzedField.isRef) {
|
|
797
|
+
refCount++;
|
|
798
|
+
if (analyzedField.refTarget) {
|
|
799
|
+
result.dependsOn.push(analyzedField.refTarget);
|
|
800
|
+
}
|
|
801
|
+
} else if (fieldName !== "id") {
|
|
802
|
+
nonRefNonIdCount++;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
result.isJunctionTable = refCount >= 2 && nonRefNonIdCount <= 1;
|
|
806
|
+
if (schema.relations) {
|
|
807
|
+
for (const [relName, rel] of Object.entries(schema.relations)) {
|
|
808
|
+
const analyzedRel = analyzeRelation(relName, rel, singular, schema.fields, schemaMap, schema.name);
|
|
809
|
+
result.relations.push(analyzedRel);
|
|
810
|
+
if (analyzedRel.type === "belongsTo" && !result.dependsOn.includes(analyzedRel.target)) {
|
|
811
|
+
result.dependsOn.push(analyzedRel.target);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (schema.computed) {
|
|
816
|
+
for (const [compName, comp] of Object.entries(schema.computed)) {
|
|
817
|
+
const inferredType = inferComputedType(compName);
|
|
818
|
+
result.computed.push({
|
|
819
|
+
name: compName,
|
|
820
|
+
type: inferredType,
|
|
821
|
+
tsType: primitiveToTs(inferredType)
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
result.indexes = analyzeIndexes(schema, result.fields, tableName);
|
|
826
|
+
result.rpc = analyzeRPC(schema, tableName, schemaMap);
|
|
827
|
+
return result;
|
|
828
|
+
}
|
|
829
|
+
function analyzeField(name, field, config) {
|
|
830
|
+
const result = {
|
|
831
|
+
name,
|
|
832
|
+
type: field.type,
|
|
833
|
+
tsType: fieldToTsType(field),
|
|
834
|
+
fakerCall: fieldToFakerCall(name, field, config),
|
|
835
|
+
nullable: field.nullable ?? false,
|
|
836
|
+
unique: field.unique ?? false,
|
|
837
|
+
readOnly: field.readOnly ?? false,
|
|
838
|
+
hasDefault: field.default !== void 0,
|
|
839
|
+
defaultValue: field.default,
|
|
840
|
+
isRef: field.type === "ref",
|
|
841
|
+
refTarget: field.type === "ref" ? field.target : void 0,
|
|
842
|
+
isEnum: field.type === "enum" || (field.values?.length ?? 0) > 0,
|
|
843
|
+
enumValues: field.values,
|
|
844
|
+
isArray: field.type === "array",
|
|
845
|
+
isObject: field.type === "object",
|
|
846
|
+
min: field.constraints?.min,
|
|
847
|
+
max: field.constraints?.max,
|
|
848
|
+
pattern: field.constraints?.pattern?.source
|
|
849
|
+
};
|
|
850
|
+
if (field.type === "array" && field.items) {
|
|
851
|
+
result.itemType = analyzeField("item", field.items, config);
|
|
852
|
+
}
|
|
853
|
+
if (field.type === "object" && field.shape) {
|
|
854
|
+
result.shape = {};
|
|
855
|
+
for (const [k, v] of Object.entries(field.shape)) {
|
|
856
|
+
result.shape[k] = analyzeField(k, v, config);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return result;
|
|
860
|
+
}
|
|
861
|
+
function findForeignKeyField(fields, entityName) {
|
|
862
|
+
const lowerEntity = entityName.toLowerCase();
|
|
863
|
+
const singularEntity = singularize(entityName).toLowerCase();
|
|
864
|
+
const pluralEntity = pluralize(entityName).toLowerCase();
|
|
865
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
866
|
+
if (field.type === "ref" && field.target) {
|
|
867
|
+
const targetLower = field.target.toLowerCase();
|
|
868
|
+
const targetSingular = singularize(field.target).toLowerCase();
|
|
869
|
+
const targetPlural = pluralize(field.target).toLowerCase();
|
|
870
|
+
if (targetLower === lowerEntity || targetLower === singularEntity || targetLower === pluralEntity || targetSingular === singularEntity || targetPlural === pluralEntity) {
|
|
871
|
+
return fieldName;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const fieldNames = Object.keys(fields);
|
|
876
|
+
const patterns = [
|
|
877
|
+
`${singularEntity}Id`,
|
|
878
|
+
// projectId
|
|
879
|
+
`${singularEntity}_id`,
|
|
880
|
+
// project_id
|
|
881
|
+
`${singularEntity}ID`,
|
|
882
|
+
// projectID
|
|
883
|
+
`${pluralEntity}Id`,
|
|
884
|
+
// projectsId (less common but possible)
|
|
885
|
+
`${pluralEntity}_id`
|
|
886
|
+
// projects_id
|
|
887
|
+
];
|
|
888
|
+
for (const pattern of patterns) {
|
|
889
|
+
const match = fieldNames.find((f) => f.toLowerCase() === pattern.toLowerCase());
|
|
890
|
+
if (match) {
|
|
891
|
+
return match;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return void 0;
|
|
895
|
+
}
|
|
896
|
+
function analyzeRelation(name, rel, sourceEntitySingular, localFields, schemaMap, sourceEntityName) {
|
|
897
|
+
const singularTarget = singularize(rel.target);
|
|
898
|
+
let foreignKey;
|
|
899
|
+
let fkInferred = false;
|
|
900
|
+
let fkDefaultFallback = false;
|
|
901
|
+
if (rel.foreignKey) {
|
|
902
|
+
foreignKey = rel.foreignKey;
|
|
903
|
+
} else if (rel.type === "belongsTo") {
|
|
904
|
+
const foundField = findForeignKeyField(localFields, singularTarget);
|
|
905
|
+
if (foundField) {
|
|
906
|
+
foreignKey = foundField;
|
|
907
|
+
fkInferred = true;
|
|
908
|
+
} else {
|
|
909
|
+
foreignKey = `${singularTarget}Id`;
|
|
910
|
+
fkDefaultFallback = true;
|
|
911
|
+
}
|
|
912
|
+
} else {
|
|
913
|
+
const targetSchema = findSchemaByName(schemaMap, rel.target);
|
|
914
|
+
const targetFields = targetSchema?.fields ?? {};
|
|
915
|
+
const targetRelations = targetSchema?.relations ?? {};
|
|
916
|
+
let foundField;
|
|
917
|
+
for (const [, targetRel] of Object.entries(targetRelations)) {
|
|
918
|
+
if (targetRel.type === "belongsTo") {
|
|
919
|
+
const belongsToTarget = targetRel.target.toLowerCase();
|
|
920
|
+
const sourceVariants = [
|
|
921
|
+
sourceEntitySingular.toLowerCase(),
|
|
922
|
+
singularize(sourceEntitySingular).toLowerCase(),
|
|
923
|
+
pluralize(sourceEntitySingular).toLowerCase()
|
|
924
|
+
];
|
|
925
|
+
const targetMatches = sourceVariants.includes(belongsToTarget) || sourceVariants.includes(singularize(belongsToTarget).toLowerCase()) || sourceVariants.includes(pluralize(belongsToTarget).toLowerCase());
|
|
926
|
+
if (targetMatches) {
|
|
927
|
+
if (targetRel.foreignKey) {
|
|
928
|
+
foundField = targetRel.foreignKey;
|
|
929
|
+
} else {
|
|
930
|
+
const belongsToSingular = singularize(targetRel.target);
|
|
931
|
+
foundField = findForeignKeyField(targetFields, belongsToSingular);
|
|
932
|
+
}
|
|
933
|
+
if (foundField) break;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!foundField) {
|
|
938
|
+
foundField = findForeignKeyField(targetFields, sourceEntitySingular);
|
|
939
|
+
}
|
|
940
|
+
if (foundField) {
|
|
941
|
+
foreignKey = foundField;
|
|
942
|
+
fkInferred = true;
|
|
943
|
+
} else {
|
|
944
|
+
foreignKey = `${sourceEntitySingular}Id`;
|
|
945
|
+
fkDefaultFallback = true;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (fkDefaultFallback) {
|
|
949
|
+
const entityContext = sourceEntityName ? ` in '${sourceEntityName}'` : "";
|
|
950
|
+
const targetInfo = rel.type === "belongsTo" ? `Could not find a field matching '${singularTarget}' (tried: ${singularTarget}Id, ${singularTarget}_id, ref fields targeting ${rel.target})` : `Could not find a field in '${rel.target}' pointing back to '${sourceEntitySingular}'`;
|
|
951
|
+
console.warn(
|
|
952
|
+
`\x1B[33m\u26A0 FK Inference Warning:\x1B[0m Relation '${name}'${entityContext} (${rel.type} \u2192 ${rel.target})
|
|
953
|
+
${targetInfo}
|
|
954
|
+
Using default: '${foreignKey}'
|
|
955
|
+
\x1B[2mTo fix: Add 'foreignKey' option to the relation, e.g.: ${rel.type}('${rel.target}', { foreignKey: 'yourFieldName' })\x1B[0m`
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
const result = {
|
|
959
|
+
name,
|
|
960
|
+
type: rel.type,
|
|
961
|
+
target: rel.target,
|
|
962
|
+
targetPascal: toPascalCase(singularize(rel.target)),
|
|
963
|
+
foreignKey,
|
|
964
|
+
eager: rel.eager ?? false,
|
|
965
|
+
inferred: fkInferred
|
|
966
|
+
// Track whether FK was inferred vs explicit
|
|
967
|
+
};
|
|
968
|
+
if (rel.type === "belongsTo") {
|
|
969
|
+
result.localField = foreignKey;
|
|
970
|
+
}
|
|
971
|
+
if (rel.type === "hasMany" && rel.through) {
|
|
972
|
+
result.type = "manyToMany";
|
|
973
|
+
result.through = rel.through;
|
|
974
|
+
result.otherKey = rel.otherKey;
|
|
975
|
+
}
|
|
976
|
+
return result;
|
|
977
|
+
}
|
|
978
|
+
function analyzeIndexes(schema, fields, tableName) {
|
|
979
|
+
const indexes = [];
|
|
980
|
+
const existingIndexFields = /* @__PURE__ */ new Set();
|
|
981
|
+
if (schema.indexes) {
|
|
982
|
+
for (const indexConfig of schema.indexes) {
|
|
983
|
+
const indexName = indexConfig.name ?? `idx_${tableName}_${indexConfig.fields.join("_")}`;
|
|
984
|
+
indexes.push({
|
|
985
|
+
name: indexName,
|
|
986
|
+
tableName,
|
|
987
|
+
fields: indexConfig.fields,
|
|
988
|
+
type: indexConfig.type ?? "btree",
|
|
989
|
+
unique: indexConfig.unique ?? false,
|
|
990
|
+
using: indexConfig.using,
|
|
991
|
+
where: indexConfig.where,
|
|
992
|
+
concurrently: indexConfig.concurrently ?? false,
|
|
993
|
+
autoGenerated: false
|
|
994
|
+
});
|
|
995
|
+
indexConfig.fields.forEach((f) => existingIndexFields.add(f));
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
for (const field of fields) {
|
|
999
|
+
if (field.isRef && !existingIndexFields.has(field.name)) {
|
|
1000
|
+
indexes.push({
|
|
1001
|
+
name: `idx_${tableName}_${toSnakeCase(field.name)}`,
|
|
1002
|
+
tableName,
|
|
1003
|
+
fields: [field.name],
|
|
1004
|
+
type: "btree",
|
|
1005
|
+
unique: false,
|
|
1006
|
+
concurrently: false,
|
|
1007
|
+
autoGenerated: true
|
|
1008
|
+
});
|
|
1009
|
+
existingIndexFields.add(field.name);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
for (const field of fields) {
|
|
1013
|
+
if (field.unique && field.name !== "id" && !existingIndexFields.has(field.name)) {
|
|
1014
|
+
indexes.push({
|
|
1015
|
+
name: `idx_${tableName}_${toSnakeCase(field.name)}_unique`,
|
|
1016
|
+
tableName,
|
|
1017
|
+
fields: [field.name],
|
|
1018
|
+
type: "btree",
|
|
1019
|
+
unique: true,
|
|
1020
|
+
concurrently: false,
|
|
1021
|
+
autoGenerated: true
|
|
1022
|
+
});
|
|
1023
|
+
existingIndexFields.add(field.name);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return indexes;
|
|
1027
|
+
}
|
|
1028
|
+
function schemaToPgType(type) {
|
|
1029
|
+
const typeMap = {
|
|
1030
|
+
uuid: "UUID",
|
|
1031
|
+
string: "TEXT",
|
|
1032
|
+
text: "TEXT",
|
|
1033
|
+
email: "TEXT",
|
|
1034
|
+
url: "TEXT",
|
|
1035
|
+
int: "INTEGER",
|
|
1036
|
+
integer: "INTEGER",
|
|
1037
|
+
number: "DOUBLE PRECISION",
|
|
1038
|
+
float: "DOUBLE PRECISION",
|
|
1039
|
+
boolean: "BOOLEAN",
|
|
1040
|
+
date: "TIMESTAMPTZ",
|
|
1041
|
+
datetime: "TIMESTAMPTZ",
|
|
1042
|
+
json: "JSONB",
|
|
1043
|
+
jsonb: "JSONB",
|
|
1044
|
+
array: "JSONB",
|
|
1045
|
+
object: "JSONB"
|
|
1046
|
+
};
|
|
1047
|
+
return typeMap[type.toLowerCase()] ?? "TEXT";
|
|
1048
|
+
}
|
|
1049
|
+
function analyzeRPC(schema, tableName, schemaMap) {
|
|
1050
|
+
const rpcFunctions = [];
|
|
1051
|
+
if (!schema.rpc) {
|
|
1052
|
+
return rpcFunctions;
|
|
1053
|
+
}
|
|
1054
|
+
for (const [name, config] of Object.entries(schema.rpc)) {
|
|
1055
|
+
const returnsArray = config.returns.endsWith("[]");
|
|
1056
|
+
const baseReturnType = returnsArray ? config.returns.slice(0, -2) : config.returns;
|
|
1057
|
+
const isEntityReturn = schemaMap.has(baseReturnType) || baseReturnType === schema.name;
|
|
1058
|
+
let pgReturns;
|
|
1059
|
+
if (config.returns === "void") {
|
|
1060
|
+
pgReturns = "VOID";
|
|
1061
|
+
} else if (isEntityReturn) {
|
|
1062
|
+
const targetTable = baseReturnType === schema.name ? tableName : schemaMap.get(baseReturnType)?.name ?? baseReturnType;
|
|
1063
|
+
pgReturns = returnsArray ? `SETOF ${targetTable}` : targetTable;
|
|
1064
|
+
} else {
|
|
1065
|
+
pgReturns = returnsArray ? `${schemaToPgType(baseReturnType)}[]` : schemaToPgType(baseReturnType);
|
|
1066
|
+
}
|
|
1067
|
+
const args = (config.args ?? []).map((arg) => ({
|
|
1068
|
+
name: arg.name,
|
|
1069
|
+
type: arg.type,
|
|
1070
|
+
pgType: schemaToPgType(arg.type),
|
|
1071
|
+
default: arg.default
|
|
1072
|
+
}));
|
|
1073
|
+
rpcFunctions.push({
|
|
1074
|
+
name,
|
|
1075
|
+
entityName: schema.name,
|
|
1076
|
+
tableName,
|
|
1077
|
+
args,
|
|
1078
|
+
returns: config.returns,
|
|
1079
|
+
pgReturns,
|
|
1080
|
+
returnsArray,
|
|
1081
|
+
sql: config.sql,
|
|
1082
|
+
language: config.language ?? "sql",
|
|
1083
|
+
volatility: config.volatility ?? "volatile",
|
|
1084
|
+
security: config.security ?? "invoker",
|
|
1085
|
+
description: config.description
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
return rpcFunctions;
|
|
1089
|
+
}
|
|
1090
|
+
function extractFunctionBody(fnSource) {
|
|
1091
|
+
const trimmed = fnSource.trim();
|
|
1092
|
+
const arrowMatch = trimmed.match(/^\([^)]*\)\s*=>\s*(.+)$/s);
|
|
1093
|
+
if (!arrowMatch) {
|
|
1094
|
+
return trimmed;
|
|
1095
|
+
}
|
|
1096
|
+
const body = arrowMatch[1].trim();
|
|
1097
|
+
if (body.startsWith("{") && body.endsWith("}")) {
|
|
1098
|
+
return body.slice(1, -1).trim();
|
|
1099
|
+
}
|
|
1100
|
+
return `return ${body};`;
|
|
1101
|
+
}
|
|
1102
|
+
function serializeRLSFunction(fn) {
|
|
1103
|
+
if (typeof fn !== "function") return void 0;
|
|
1104
|
+
const source = fn.toString();
|
|
1105
|
+
return extractFunctionBody(source);
|
|
1106
|
+
}
|
|
1107
|
+
function analyzeRLS(rls) {
|
|
1108
|
+
if (!rls) {
|
|
1109
|
+
return {
|
|
1110
|
+
enabled: false,
|
|
1111
|
+
hasSelect: false,
|
|
1112
|
+
hasInsert: false,
|
|
1113
|
+
hasUpdate: false,
|
|
1114
|
+
hasDelete: false,
|
|
1115
|
+
scope: [],
|
|
1116
|
+
bypass: []
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
const hasScope = (rls.scope?.length ?? 0) > 0;
|
|
1120
|
+
const hasSelect = !!(rls.select || hasScope || rls.sql?.select);
|
|
1121
|
+
const hasInsert = !!(rls.insert || hasScope || rls.sql?.insert);
|
|
1122
|
+
const hasUpdate = !!(rls.update || hasScope || rls.sql?.update);
|
|
1123
|
+
const hasDelete = !!(rls.delete || hasScope || rls.sql?.delete);
|
|
1124
|
+
const enabled = hasSelect || hasInsert || hasUpdate || hasDelete;
|
|
1125
|
+
const selectSource = serializeRLSFunction(rls.select);
|
|
1126
|
+
const insertSource = serializeRLSFunction(rls.insert);
|
|
1127
|
+
const updateSource = serializeRLSFunction(rls.update);
|
|
1128
|
+
const deleteSource = serializeRLSFunction(rls.delete);
|
|
1129
|
+
return {
|
|
1130
|
+
enabled,
|
|
1131
|
+
hasSelect,
|
|
1132
|
+
hasInsert,
|
|
1133
|
+
hasUpdate,
|
|
1134
|
+
hasDelete,
|
|
1135
|
+
scope: rls.scope ?? [],
|
|
1136
|
+
bypass: rls.bypass ?? [],
|
|
1137
|
+
selectSource,
|
|
1138
|
+
insertSource,
|
|
1139
|
+
updateSource,
|
|
1140
|
+
deleteSource,
|
|
1141
|
+
sql: rls.sql,
|
|
1142
|
+
original: rls
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function topologicalSort(schemas) {
|
|
1146
|
+
const sorted = [];
|
|
1147
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1148
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
1149
|
+
const schemaMap = new Map(schemas.map((s) => [s.name, s]));
|
|
1150
|
+
function visit(schema) {
|
|
1151
|
+
if (visited.has(schema.name)) return;
|
|
1152
|
+
if (visiting.has(schema.name)) {
|
|
1153
|
+
console.warn(`Warning: Circular dependency involving ${schema.name}`);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
visiting.add(schema.name);
|
|
1157
|
+
for (const dep of schema.dependsOn) {
|
|
1158
|
+
const depSchema = schemaMap.get(dep);
|
|
1159
|
+
if (depSchema) {
|
|
1160
|
+
visit(depSchema);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
visiting.delete(schema.name);
|
|
1164
|
+
visited.add(schema.name);
|
|
1165
|
+
sorted.push(schema);
|
|
1166
|
+
}
|
|
1167
|
+
for (const schema of schemas) {
|
|
1168
|
+
visit(schema);
|
|
1169
|
+
}
|
|
1170
|
+
return sorted;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// src/cli/analyze-endpoints.ts
|
|
1174
|
+
function analyzeEndpoints(endpoints) {
|
|
1175
|
+
return endpoints.map(analyzeEndpoint);
|
|
1176
|
+
}
|
|
1177
|
+
function analyzeEndpoint(endpoint) {
|
|
1178
|
+
const name = deriveEndpointName(endpoint.path);
|
|
1179
|
+
const pascalName = toPascalCase2(name);
|
|
1180
|
+
const pathParams = extractPathParams(endpoint.path);
|
|
1181
|
+
const params = analyzeFields(endpoint.params);
|
|
1182
|
+
const body = analyzeFields(endpoint.body);
|
|
1183
|
+
const response = analyzeFields(endpoint.response);
|
|
1184
|
+
const mockResolverSource = serializeMockResolver(endpoint.mockResolver);
|
|
1185
|
+
return {
|
|
1186
|
+
path: endpoint.path,
|
|
1187
|
+
method: endpoint.method,
|
|
1188
|
+
name,
|
|
1189
|
+
pascalName,
|
|
1190
|
+
pathParams,
|
|
1191
|
+
params,
|
|
1192
|
+
body,
|
|
1193
|
+
response,
|
|
1194
|
+
mockResolverSource,
|
|
1195
|
+
description: endpoint.description
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
function deriveEndpointName(path) {
|
|
1199
|
+
let cleaned = path.replace(/^\/api\//, "");
|
|
1200
|
+
cleaned = cleaned.replace(/^\//, "");
|
|
1201
|
+
const parts = cleaned.split("/");
|
|
1202
|
+
const nameParts = [];
|
|
1203
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1204
|
+
const part = parts[i];
|
|
1205
|
+
if (part.startsWith(":")) {
|
|
1206
|
+
const paramName = part.slice(1);
|
|
1207
|
+
nameParts.push("By" + capitalize(paramName));
|
|
1208
|
+
} else if (part) {
|
|
1209
|
+
if (i === 0) {
|
|
1210
|
+
nameParts.push(part);
|
|
1211
|
+
} else {
|
|
1212
|
+
nameParts.push(capitalize(part));
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
return nameParts.join("");
|
|
1217
|
+
}
|
|
1218
|
+
function extractPathParams(path) {
|
|
1219
|
+
const matches = path.match(/:(\w+)/g) || [];
|
|
1220
|
+
return matches.map((m) => m.slice(1));
|
|
1221
|
+
}
|
|
1222
|
+
function toPascalCase2(str) {
|
|
1223
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1224
|
+
}
|
|
1225
|
+
function capitalize(str) {
|
|
1226
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1227
|
+
}
|
|
1228
|
+
function analyzeFields(fields) {
|
|
1229
|
+
return Object.entries(fields).map(([name, field]) => analyzeField2(name, field));
|
|
1230
|
+
}
|
|
1231
|
+
function analyzeField2(name, field) {
|
|
1232
|
+
const tsType = fieldToTsType2(field);
|
|
1233
|
+
const hasDefault = field.default !== void 0;
|
|
1234
|
+
const analyzed = {
|
|
1235
|
+
name,
|
|
1236
|
+
type: field.type,
|
|
1237
|
+
tsType,
|
|
1238
|
+
required: !hasDefault && !field.nullable,
|
|
1239
|
+
hasDefault,
|
|
1240
|
+
default: field.default,
|
|
1241
|
+
isArray: field.type === "array",
|
|
1242
|
+
isObject: field.type === "object"
|
|
1243
|
+
};
|
|
1244
|
+
if (field.values && field.values.length > 0) {
|
|
1245
|
+
analyzed.enumValues = field.values;
|
|
1246
|
+
}
|
|
1247
|
+
if (field.type === "array" && field.items) {
|
|
1248
|
+
analyzed.itemType = analyzeField2("item", field.items);
|
|
1249
|
+
}
|
|
1250
|
+
if (field.type === "object" && field.shape) {
|
|
1251
|
+
analyzed.shape = Object.entries(field.shape).map(([n, f]) => analyzeField2(n, f));
|
|
1252
|
+
}
|
|
1253
|
+
return analyzed;
|
|
1254
|
+
}
|
|
1255
|
+
function fieldToTsType2(field) {
|
|
1256
|
+
if (field.values && field.values.length > 0) {
|
|
1257
|
+
return field.values.map((v) => `'${v}'`).join(" | ");
|
|
1258
|
+
}
|
|
1259
|
+
if (field.type === "array") {
|
|
1260
|
+
if (field.items) {
|
|
1261
|
+
const itemType = fieldToTsType2(field.items);
|
|
1262
|
+
return `Array<${itemType}>`;
|
|
1263
|
+
}
|
|
1264
|
+
return "unknown[]";
|
|
1265
|
+
}
|
|
1266
|
+
if (field.type === "object") {
|
|
1267
|
+
if (field.shape) {
|
|
1268
|
+
const props = Object.entries(field.shape).map(([name, f]) => {
|
|
1269
|
+
const optional = f.default !== void 0 ? "?" : "";
|
|
1270
|
+
return `${name}${optional}: ${fieldToTsType2(f)}`;
|
|
1271
|
+
}).join("; ");
|
|
1272
|
+
return `{ ${props} }`;
|
|
1273
|
+
}
|
|
1274
|
+
return "Record<string, unknown>";
|
|
1275
|
+
}
|
|
1276
|
+
const typeMap = {
|
|
1277
|
+
string: "string",
|
|
1278
|
+
uuid: "string",
|
|
1279
|
+
email: "string",
|
|
1280
|
+
url: "string",
|
|
1281
|
+
number: "number",
|
|
1282
|
+
int: "number",
|
|
1283
|
+
float: "number",
|
|
1284
|
+
boolean: "boolean",
|
|
1285
|
+
date: "Date",
|
|
1286
|
+
ref: "string"
|
|
1287
|
+
};
|
|
1288
|
+
const baseType = typeMap[field.type] || "unknown";
|
|
1289
|
+
if (field.nullable) {
|
|
1290
|
+
return `${baseType} | null`;
|
|
1291
|
+
}
|
|
1292
|
+
return baseType;
|
|
1293
|
+
}
|
|
1294
|
+
function serializeMockResolver(resolver) {
|
|
1295
|
+
const source = resolver.toString();
|
|
1296
|
+
if (source.startsWith("async (") || source.startsWith("(") || source.startsWith("async(")) {
|
|
1297
|
+
return source;
|
|
1298
|
+
}
|
|
1299
|
+
if (source.startsWith("async function") || source.startsWith("function")) {
|
|
1300
|
+
const match = source.match(/^(async\s+)?function\s*\w*\s*\(([^)]*)\)\s*\{([\s\S]*)\}$/);
|
|
1301
|
+
if (match) {
|
|
1302
|
+
const [, asyncPrefix, params, body] = match;
|
|
1303
|
+
return `${asyncPrefix || ""}(${params}) => {${body}}`;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return source;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// src/cli/utils/code-builder.ts
|
|
1310
|
+
var CodeBuilder = class {
|
|
1311
|
+
_lines = [];
|
|
1312
|
+
indentLevel = 0;
|
|
1313
|
+
indentStr = " ";
|
|
1314
|
+
// 2 spaces
|
|
1315
|
+
/**
|
|
1316
|
+
* Increase indentation level
|
|
1317
|
+
*/
|
|
1318
|
+
indent() {
|
|
1319
|
+
this.indentLevel++;
|
|
1320
|
+
return this;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Decrease indentation level
|
|
1324
|
+
*/
|
|
1325
|
+
dedent() {
|
|
1326
|
+
this.indentLevel = Math.max(0, this.indentLevel - 1);
|
|
1327
|
+
return this;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Add a line of code
|
|
1331
|
+
*/
|
|
1332
|
+
line(content = "") {
|
|
1333
|
+
if (content === "") {
|
|
1334
|
+
this._lines.push("");
|
|
1335
|
+
} else {
|
|
1336
|
+
this._lines.push(this.indentStr.repeat(this.indentLevel) + content);
|
|
1337
|
+
}
|
|
1338
|
+
return this;
|
|
1339
|
+
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Add multiple lines of code
|
|
1342
|
+
*/
|
|
1343
|
+
addLines(content) {
|
|
1344
|
+
for (const line of content) {
|
|
1345
|
+
this.line(line);
|
|
1346
|
+
}
|
|
1347
|
+
return this;
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Add a single-line comment
|
|
1351
|
+
*/
|
|
1352
|
+
comment(text) {
|
|
1353
|
+
return this.line(`// ${text}`);
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Add a JSDoc comment
|
|
1357
|
+
*/
|
|
1358
|
+
docComment(text) {
|
|
1359
|
+
return this.line(`/** ${text} */`);
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Add a multi-line JSDoc comment
|
|
1363
|
+
*/
|
|
1364
|
+
multiDocComment(lines) {
|
|
1365
|
+
this.line("/**");
|
|
1366
|
+
for (const line of lines) {
|
|
1367
|
+
this.line(` * ${line}`);
|
|
1368
|
+
}
|
|
1369
|
+
this.line(" */");
|
|
1370
|
+
return this;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Add a code block with automatic indentation
|
|
1374
|
+
*/
|
|
1375
|
+
block(opener, fn, closer = "}") {
|
|
1376
|
+
this.line(opener);
|
|
1377
|
+
this.indent();
|
|
1378
|
+
fn();
|
|
1379
|
+
this.dedent();
|
|
1380
|
+
this.line(closer);
|
|
1381
|
+
return this;
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Add raw content without indentation
|
|
1385
|
+
*/
|
|
1386
|
+
raw(content) {
|
|
1387
|
+
this._lines.push(content);
|
|
1388
|
+
return this;
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Get the generated code as a string
|
|
1392
|
+
*/
|
|
1393
|
+
toString() {
|
|
1394
|
+
return this._lines.join("\n");
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Clear all content
|
|
1398
|
+
*/
|
|
1399
|
+
clear() {
|
|
1400
|
+
this._lines = [];
|
|
1401
|
+
this.indentLevel = 0;
|
|
1402
|
+
return this;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Get current indentation level
|
|
1406
|
+
*/
|
|
1407
|
+
getIndentLevel() {
|
|
1408
|
+
return this.indentLevel;
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
// src/cli/generators/types.ts
|
|
1413
|
+
function generateTypes(schemas) {
|
|
1414
|
+
const code = new CodeBuilder();
|
|
1415
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
1416
|
+
code.comment("Regenerate with: npx schemock generate");
|
|
1417
|
+
code.line();
|
|
1418
|
+
for (const schema of schemas) {
|
|
1419
|
+
if (schema.isJunctionTable) continue;
|
|
1420
|
+
generateEntityTypes(code, schema);
|
|
1421
|
+
}
|
|
1422
|
+
generateCommonTypes(code);
|
|
1423
|
+
return code.toString();
|
|
1424
|
+
}
|
|
1425
|
+
function generateEntityTypes(code, schema, allSchemas) {
|
|
1426
|
+
const { pascalName, fields, relations, computed, hasTimestamps } = schema;
|
|
1427
|
+
code.docComment(`${pascalName} entity`);
|
|
1428
|
+
code.block(`export interface ${pascalName} {`, () => {
|
|
1429
|
+
code.line("[key: string]: unknown;");
|
|
1430
|
+
for (const field of fields) {
|
|
1431
|
+
const opt = field.nullable ? "?" : "";
|
|
1432
|
+
code.line(`${field.name}${opt}: ${field.tsType};`);
|
|
1433
|
+
}
|
|
1434
|
+
for (const comp of computed) {
|
|
1435
|
+
code.line(`${comp.name}: ${comp.tsType};`);
|
|
1436
|
+
}
|
|
1437
|
+
for (const rel of relations) {
|
|
1438
|
+
const relType = rel.type === "hasMany" || rel.type === "manyToMany" ? `${rel.targetPascal}[]` : rel.targetPascal;
|
|
1439
|
+
code.line(`${rel.name}?: ${relType};`);
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
code.line();
|
|
1443
|
+
for (const rel of relations) {
|
|
1444
|
+
const relType = rel.type === "hasMany" || rel.type === "manyToMany" ? `${rel.targetPascal}[]` : rel.targetPascal;
|
|
1445
|
+
const typeName = `${pascalName}With${toPascalCase(rel.name)}`;
|
|
1446
|
+
code.docComment(`${pascalName} with ${rel.name} loaded`);
|
|
1447
|
+
code.block(`export interface ${typeName} extends Omit<${pascalName}, '${rel.name}'> {`, () => {
|
|
1448
|
+
code.line(`${rel.name}: ${relType};`);
|
|
1449
|
+
});
|
|
1450
|
+
code.line();
|
|
1451
|
+
}
|
|
1452
|
+
code.docComment(`Data for creating a ${pascalName}`);
|
|
1453
|
+
code.block(`export interface ${pascalName}Create {`, () => {
|
|
1454
|
+
for (const field of fields) {
|
|
1455
|
+
if (field.name === "id" || field.readOnly) continue;
|
|
1456
|
+
const opt = field.nullable || field.hasDefault ? "?" : "";
|
|
1457
|
+
code.line(`${field.name}${opt}: ${field.tsType};`);
|
|
1458
|
+
}
|
|
1459
|
+
for (const rel of relations) {
|
|
1460
|
+
if (rel.type === "hasMany") {
|
|
1461
|
+
code.line(`${rel.name}?: ${rel.targetPascal}Create[];`);
|
|
1462
|
+
} else if (rel.type === "hasOne") {
|
|
1463
|
+
code.line(`${rel.name}?: ${rel.targetPascal}Create;`);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
code.line();
|
|
1468
|
+
code.docComment(`Data for updating a ${pascalName}`);
|
|
1469
|
+
code.block(`export interface ${pascalName}Update {`, () => {
|
|
1470
|
+
for (const field of fields) {
|
|
1471
|
+
if (field.name === "id" || field.readOnly) continue;
|
|
1472
|
+
code.line(`${field.name}?: ${field.tsType};`);
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
code.line();
|
|
1476
|
+
code.docComment(`Filter options for querying ${pascalName}`);
|
|
1477
|
+
code.block(`export interface ${pascalName}Filter {`, () => {
|
|
1478
|
+
code.line("[key: string]: unknown;");
|
|
1479
|
+
for (const field of fields) {
|
|
1480
|
+
code.line(`${field.name}?: ${field.tsType} | FieldFilter<${field.tsType}>;`);
|
|
1481
|
+
}
|
|
1482
|
+
});
|
|
1483
|
+
code.line();
|
|
1484
|
+
if (relations.length > 0) {
|
|
1485
|
+
const relNames = relations.map((r) => `'${r.name}'`).join(" | ");
|
|
1486
|
+
code.docComment(`Relations that can be included when fetching ${pascalName}`);
|
|
1487
|
+
code.line(`export type ${pascalName}Include = ${relNames};`);
|
|
1488
|
+
code.line();
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
function generateCommonTypes(code) {
|
|
1492
|
+
code.docComment("Generic field filter for complex queries");
|
|
1493
|
+
code.block("export interface FieldFilter<T> {", () => {
|
|
1494
|
+
code.line("equals?: T;");
|
|
1495
|
+
code.line("not?: T;");
|
|
1496
|
+
code.line("in?: T[];");
|
|
1497
|
+
code.line("notIn?: T[];");
|
|
1498
|
+
code.line("lt?: T;");
|
|
1499
|
+
code.line("lte?: T;");
|
|
1500
|
+
code.line("gt?: T;");
|
|
1501
|
+
code.line("gte?: T;");
|
|
1502
|
+
code.line("contains?: string;");
|
|
1503
|
+
code.line("startsWith?: string;");
|
|
1504
|
+
code.line("endsWith?: string;");
|
|
1505
|
+
code.line("isNull?: boolean;");
|
|
1506
|
+
});
|
|
1507
|
+
code.line();
|
|
1508
|
+
code.docComment("Common query options");
|
|
1509
|
+
code.block("export interface QueryOptions<TFilter, TInclude extends string = never> {", () => {
|
|
1510
|
+
code.line("where?: TFilter;");
|
|
1511
|
+
code.line("include?: TInclude[];");
|
|
1512
|
+
code.line('orderBy?: Record<string, "asc" | "desc">;');
|
|
1513
|
+
code.line("limit?: number;");
|
|
1514
|
+
code.line("offset?: number;");
|
|
1515
|
+
code.line("cursor?: string;");
|
|
1516
|
+
});
|
|
1517
|
+
code.line();
|
|
1518
|
+
code.docComment("Paginated list response");
|
|
1519
|
+
code.block("export interface ListResponse<T> {", () => {
|
|
1520
|
+
code.line("data: T[];");
|
|
1521
|
+
code.block("meta: {", () => {
|
|
1522
|
+
code.line("total: number;");
|
|
1523
|
+
code.line("limit: number;");
|
|
1524
|
+
code.line("offset: number;");
|
|
1525
|
+
code.line("hasMore: boolean;");
|
|
1526
|
+
code.line("nextCursor?: string;");
|
|
1527
|
+
});
|
|
1528
|
+
});
|
|
1529
|
+
code.line();
|
|
1530
|
+
code.docComment("Single item response");
|
|
1531
|
+
code.block("export interface ItemResponse<T> {", () => {
|
|
1532
|
+
code.line("data: T;");
|
|
1533
|
+
});
|
|
1534
|
+
code.line();
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// src/cli/generators/mock/db.ts
|
|
1538
|
+
function generateMockDb(schemas, config) {
|
|
1539
|
+
const code = new CodeBuilder();
|
|
1540
|
+
const entityNames = schemas.map((s) => s.name);
|
|
1541
|
+
const persist = config.persist !== false;
|
|
1542
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
1543
|
+
code.line("import { factory, primaryKey, nullable } from '@mswjs/data';");
|
|
1544
|
+
code.line("import { faker } from '@faker-js/faker';");
|
|
1545
|
+
code.line();
|
|
1546
|
+
if (config.fakerSeed !== void 0) {
|
|
1547
|
+
code.line(`faker.seed(${config.fakerSeed});`);
|
|
1548
|
+
} else {
|
|
1549
|
+
code.line("faker.seed(Date.now());");
|
|
1550
|
+
}
|
|
1551
|
+
code.line();
|
|
1552
|
+
code.block("export const db = factory({", () => {
|
|
1553
|
+
for (const schema of schemas) {
|
|
1554
|
+
generateEntityFactory(code, schema);
|
|
1555
|
+
}
|
|
1556
|
+
}, "});");
|
|
1557
|
+
code.line();
|
|
1558
|
+
code.line("export type Database = typeof db;");
|
|
1559
|
+
if (persist) {
|
|
1560
|
+
code.line();
|
|
1561
|
+
generatePersistenceLayer(code, entityNames, config.storageKey);
|
|
1562
|
+
}
|
|
1563
|
+
return code.toString();
|
|
1564
|
+
}
|
|
1565
|
+
function generatePersistenceLayer(code, entityNames, storageKey) {
|
|
1566
|
+
const key = storageKey || "schemock";
|
|
1567
|
+
code.comment("=== localStorage Persistence ===");
|
|
1568
|
+
code.line();
|
|
1569
|
+
code.line(`const STORAGE_KEY = '${key}';`);
|
|
1570
|
+
code.line();
|
|
1571
|
+
code.block("function isLocalStorageAvailable(): boolean {", () => {
|
|
1572
|
+
code.block("try {", () => {
|
|
1573
|
+
code.line("if (typeof window === 'undefined' || !window.localStorage) return false;");
|
|
1574
|
+
code.line("const testKey = '__schemock_test__';");
|
|
1575
|
+
code.line("window.localStorage.setItem(testKey, 'test');");
|
|
1576
|
+
code.line("window.localStorage.removeItem(testKey);");
|
|
1577
|
+
code.line("return true;");
|
|
1578
|
+
}, "} catch {");
|
|
1579
|
+
code.indent();
|
|
1580
|
+
code.line("return false;");
|
|
1581
|
+
code.dedent();
|
|
1582
|
+
code.line("}");
|
|
1583
|
+
});
|
|
1584
|
+
code.line();
|
|
1585
|
+
code.block("function restoreDates(record: Record<string, unknown>): Record<string, unknown> {", () => {
|
|
1586
|
+
code.line("const result = { ...record };");
|
|
1587
|
+
code.block("for (const [key, value] of Object.entries(result)) {", () => {
|
|
1588
|
+
code.block("if (typeof value === 'string' && /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}/.test(value)) {", () => {
|
|
1589
|
+
code.line("const date = new Date(value);");
|
|
1590
|
+
code.line("if (!isNaN(date.getTime())) result[key] = date;");
|
|
1591
|
+
});
|
|
1592
|
+
});
|
|
1593
|
+
code.line("return result;");
|
|
1594
|
+
});
|
|
1595
|
+
code.line();
|
|
1596
|
+
code.block("function loadFromStorage(): void {", () => {
|
|
1597
|
+
code.line("if (!isLocalStorageAvailable()) return;");
|
|
1598
|
+
code.line();
|
|
1599
|
+
code.block("try {", () => {
|
|
1600
|
+
for (const entity of entityNames) {
|
|
1601
|
+
code.line(`const ${entity}Data = window.localStorage.getItem(\`\${STORAGE_KEY}:${entity}\`);`);
|
|
1602
|
+
code.block(`if (${entity}Data) {`, () => {
|
|
1603
|
+
code.line(`const records = JSON.parse(${entity}Data) as Record<string, unknown>[];`);
|
|
1604
|
+
code.block("for (const record of records) {", () => {
|
|
1605
|
+
code.line("const restored = restoreDates(record);");
|
|
1606
|
+
code.line(`db.${entity}.create(restored as Parameters<typeof db.${entity}.create>[0]);`);
|
|
1607
|
+
});
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
}, "} catch (error) {");
|
|
1611
|
+
code.indent();
|
|
1612
|
+
code.line("console.warn('[Schemock] Error loading from localStorage:', error);");
|
|
1613
|
+
code.dedent();
|
|
1614
|
+
code.line("}");
|
|
1615
|
+
});
|
|
1616
|
+
code.line();
|
|
1617
|
+
code.block("function saveToStorage(): void {", () => {
|
|
1618
|
+
code.line("if (!isLocalStorageAvailable()) return;");
|
|
1619
|
+
code.line();
|
|
1620
|
+
code.block("try {", () => {
|
|
1621
|
+
for (const entity of entityNames) {
|
|
1622
|
+
code.line(`window.localStorage.setItem(\`\${STORAGE_KEY}:${entity}\`, JSON.stringify(db.${entity}.getAll()));`);
|
|
1623
|
+
}
|
|
1624
|
+
}, "} catch (error) {");
|
|
1625
|
+
code.indent();
|
|
1626
|
+
code.line("console.warn('[Schemock] Error saving to localStorage:', error);");
|
|
1627
|
+
code.dedent();
|
|
1628
|
+
code.line("}");
|
|
1629
|
+
});
|
|
1630
|
+
code.line();
|
|
1631
|
+
code.block("function clearStorage(): void {", () => {
|
|
1632
|
+
code.line("if (!isLocalStorageAvailable()) return;");
|
|
1633
|
+
for (const entity of entityNames) {
|
|
1634
|
+
code.line(`window.localStorage.removeItem(\`\${STORAGE_KEY}:${entity}\`);`);
|
|
1635
|
+
}
|
|
1636
|
+
});
|
|
1637
|
+
code.line();
|
|
1638
|
+
code.line("let saveTimer: ReturnType<typeof setTimeout> | null = null;");
|
|
1639
|
+
code.line();
|
|
1640
|
+
code.block("function scheduleSave(): void {", () => {
|
|
1641
|
+
code.line("if (saveTimer) clearTimeout(saveTimer);");
|
|
1642
|
+
code.line("saveTimer = setTimeout(() => {");
|
|
1643
|
+
code.line(" saveToStorage();");
|
|
1644
|
+
code.line(" saveTimer = null;");
|
|
1645
|
+
code.line("}, 100);");
|
|
1646
|
+
});
|
|
1647
|
+
code.line();
|
|
1648
|
+
code.comment("Wrap database methods to persist on changes");
|
|
1649
|
+
code.block("function wrapDbMethods(): void {", () => {
|
|
1650
|
+
for (const entity of entityNames) {
|
|
1651
|
+
code.line(`const orig${entity}Create = db.${entity}.create.bind(db.${entity});`);
|
|
1652
|
+
code.line(`const orig${entity}Update = db.${entity}.update.bind(db.${entity});`);
|
|
1653
|
+
code.line(`const orig${entity}Delete = db.${entity}.delete.bind(db.${entity});`);
|
|
1654
|
+
code.line();
|
|
1655
|
+
code.line(`db.${entity}.create = (data) => {`);
|
|
1656
|
+
code.line(` const result = orig${entity}Create(data);`);
|
|
1657
|
+
code.line(" scheduleSave();");
|
|
1658
|
+
code.line(" return result;");
|
|
1659
|
+
code.line("};");
|
|
1660
|
+
code.line();
|
|
1661
|
+
code.line(`db.${entity}.update = (query) => {`);
|
|
1662
|
+
code.line(` const result = orig${entity}Update(query);`);
|
|
1663
|
+
code.line(" scheduleSave();");
|
|
1664
|
+
code.line(" return result;");
|
|
1665
|
+
code.line("};");
|
|
1666
|
+
code.line();
|
|
1667
|
+
code.line(`db.${entity}.delete = (query) => {`);
|
|
1668
|
+
code.line(` const result = orig${entity}Delete(query);`);
|
|
1669
|
+
code.line(" scheduleSave();");
|
|
1670
|
+
code.line(" return result;");
|
|
1671
|
+
code.line("};");
|
|
1672
|
+
code.line();
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
code.line();
|
|
1676
|
+
code.comment("Persistence utilities");
|
|
1677
|
+
code.block("export const persistence = {", () => {
|
|
1678
|
+
code.line("save: saveToStorage,");
|
|
1679
|
+
code.line("load: loadFromStorage,");
|
|
1680
|
+
code.line("clear: clearStorage,");
|
|
1681
|
+
code.line("/** Force immediate save (bypass debounce) */");
|
|
1682
|
+
code.block("flush: () => {", () => {
|
|
1683
|
+
code.line("if (saveTimer) {");
|
|
1684
|
+
code.line(" clearTimeout(saveTimer);");
|
|
1685
|
+
code.line(" saveTimer = null;");
|
|
1686
|
+
code.line("}");
|
|
1687
|
+
code.line("saveToStorage();");
|
|
1688
|
+
}, "},");
|
|
1689
|
+
}, "};");
|
|
1690
|
+
code.line();
|
|
1691
|
+
code.comment("Initialize: load data and wrap methods");
|
|
1692
|
+
code.line("loadFromStorage();");
|
|
1693
|
+
code.line("wrapDbMethods();");
|
|
1694
|
+
}
|
|
1695
|
+
function generateEntityFactory(code, schema) {
|
|
1696
|
+
code.block(`${schema.name}: {`, () => {
|
|
1697
|
+
for (const field of schema.fields) {
|
|
1698
|
+
if (field.name === "id") {
|
|
1699
|
+
code.line("id: primaryKey(faker.string.uuid),");
|
|
1700
|
+
} else if (field.isObject) {
|
|
1701
|
+
if (field.nullable) {
|
|
1702
|
+
code.line(`${field.name}: nullable(() => JSON.stringify(${field.fakerCall})),`);
|
|
1703
|
+
} else {
|
|
1704
|
+
code.line(`${field.name}: () => JSON.stringify(${field.fakerCall}),`);
|
|
1705
|
+
}
|
|
1706
|
+
} else if (field.nullable) {
|
|
1707
|
+
code.line(`${field.name}: nullable(() => ${field.fakerCall}),`);
|
|
1708
|
+
} else {
|
|
1709
|
+
code.line(`${field.name}: () => ${field.fakerCall},`);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}, "},");
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// src/cli/generators/shared/rls.ts
|
|
1716
|
+
function getRLSImports() {
|
|
1717
|
+
return "";
|
|
1718
|
+
}
|
|
1719
|
+
function generateRLSContextType(code) {
|
|
1720
|
+
code.comment("Row-Level Security Context (generic key-value)");
|
|
1721
|
+
code.block("export interface RLSContext {", () => {
|
|
1722
|
+
code.line("[key: string]: unknown;");
|
|
1723
|
+
}, "}");
|
|
1724
|
+
code.line();
|
|
1725
|
+
code.comment("=============================================================================");
|
|
1726
|
+
code.comment("Browser-Compatible RLS Context Storage");
|
|
1727
|
+
code.comment("");
|
|
1728
|
+
code.comment("Uses a simple global context that works in both browsers and Node.js.");
|
|
1729
|
+
code.comment("This is appropriate for development/mock scenarios where concurrent");
|
|
1730
|
+
code.comment("request isolation is not required.");
|
|
1731
|
+
code.comment("=============================================================================");
|
|
1732
|
+
code.line();
|
|
1733
|
+
code.comment("Global context storage - works in browsers and Node.js");
|
|
1734
|
+
code.line("let currentContext: RLSContext | null = null;");
|
|
1735
|
+
code.line();
|
|
1736
|
+
code.multiDocComment([
|
|
1737
|
+
"Set RLS context for the current execution.",
|
|
1738
|
+
"",
|
|
1739
|
+
"@param ctx - The RLS context to set, or null to clear",
|
|
1740
|
+
"",
|
|
1741
|
+
"@example",
|
|
1742
|
+
"```typescript",
|
|
1743
|
+
"// Set context for a request",
|
|
1744
|
+
"setContext({ userId: 'user-123', role: 'admin' });",
|
|
1745
|
+
"",
|
|
1746
|
+
"// Clear context",
|
|
1747
|
+
"setContext(null);",
|
|
1748
|
+
"```"
|
|
1749
|
+
]);
|
|
1750
|
+
code.block("export function setContext(ctx: RLSContext | null): void {", () => {
|
|
1751
|
+
code.line("currentContext = ctx;");
|
|
1752
|
+
});
|
|
1753
|
+
code.line();
|
|
1754
|
+
code.multiDocComment([
|
|
1755
|
+
"Get RLS context for the current execution.",
|
|
1756
|
+
"",
|
|
1757
|
+
"@returns The current RLS context, or null if not set"
|
|
1758
|
+
]);
|
|
1759
|
+
code.block("export function getContext(): RLSContext | null {", () => {
|
|
1760
|
+
code.line("return currentContext;");
|
|
1761
|
+
});
|
|
1762
|
+
code.line();
|
|
1763
|
+
code.multiDocComment([
|
|
1764
|
+
"Run a function with RLS context.",
|
|
1765
|
+
"Context is set before the function runs and restored after.",
|
|
1766
|
+
"",
|
|
1767
|
+
"@param ctx - The RLS context to use",
|
|
1768
|
+
"@param fn - The function to run with the context",
|
|
1769
|
+
"@returns The result of the function",
|
|
1770
|
+
"",
|
|
1771
|
+
"@example",
|
|
1772
|
+
"```typescript",
|
|
1773
|
+
"// Run with context",
|
|
1774
|
+
"const result = runWithContext({ userId: '123' }, () => {",
|
|
1775
|
+
" return api.posts.list();",
|
|
1776
|
+
"});",
|
|
1777
|
+
"```"
|
|
1778
|
+
]);
|
|
1779
|
+
code.block("export function runWithContext<T>(ctx: RLSContext | null, fn: () => T): T {", () => {
|
|
1780
|
+
code.line("const previousContext = currentContext;");
|
|
1781
|
+
code.line("currentContext = ctx;");
|
|
1782
|
+
code.block("try {", () => {
|
|
1783
|
+
code.line("return fn();");
|
|
1784
|
+
}, "} finally {");
|
|
1785
|
+
code.indent();
|
|
1786
|
+
code.line("currentContext = previousContext;");
|
|
1787
|
+
code.dedent();
|
|
1788
|
+
code.line("}");
|
|
1789
|
+
});
|
|
1790
|
+
code.line();
|
|
1791
|
+
code.multiDocComment([
|
|
1792
|
+
"Async version of runWithContext for async functions.",
|
|
1793
|
+
"",
|
|
1794
|
+
"@param ctx - The RLS context to use",
|
|
1795
|
+
"@param fn - The async function to run with the context",
|
|
1796
|
+
"@returns Promise resolving to the result of the function"
|
|
1797
|
+
]);
|
|
1798
|
+
code.block("export async function runWithContextAsync<T>(ctx: RLSContext | null, fn: () => Promise<T>): Promise<T> {", () => {
|
|
1799
|
+
code.line("const previousContext = currentContext;");
|
|
1800
|
+
code.line("currentContext = ctx;");
|
|
1801
|
+
code.block("try {", () => {
|
|
1802
|
+
code.line("return await fn();");
|
|
1803
|
+
}, "} finally {");
|
|
1804
|
+
code.indent();
|
|
1805
|
+
code.line("currentContext = previousContext;");
|
|
1806
|
+
code.dedent();
|
|
1807
|
+
code.line("}");
|
|
1808
|
+
});
|
|
1809
|
+
code.line();
|
|
1810
|
+
}
|
|
1811
|
+
function collectBypassConditions(schemas) {
|
|
1812
|
+
const bypassMap = /* @__PURE__ */ new Map();
|
|
1813
|
+
for (const schema of schemas) {
|
|
1814
|
+
for (const bypass of schema.rls.bypass) {
|
|
1815
|
+
if (!bypassMap.has(bypass.contextKey)) {
|
|
1816
|
+
bypassMap.set(bypass.contextKey, /* @__PURE__ */ new Set());
|
|
1817
|
+
}
|
|
1818
|
+
for (const val of bypass.values) {
|
|
1819
|
+
bypassMap.get(bypass.contextKey).add(val);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return Array.from(bypassMap.entries()).map(([contextKey, values]) => ({
|
|
1824
|
+
contextKey,
|
|
1825
|
+
values: Array.from(values)
|
|
1826
|
+
}));
|
|
1827
|
+
}
|
|
1828
|
+
function generateBypassCheck(code, bypassConditions) {
|
|
1829
|
+
if (bypassConditions.length === 0) {
|
|
1830
|
+
code.block("function checkBypass(_ctx: RLSContext | null): boolean {", () => {
|
|
1831
|
+
code.line("return false;");
|
|
1832
|
+
});
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
code.block("function checkBypass(ctx: RLSContext | null): boolean {", () => {
|
|
1836
|
+
code.line("if (!ctx) return false;");
|
|
1837
|
+
for (const bypass of bypassConditions) {
|
|
1838
|
+
const valuesStr = bypass.values.map((v) => `'${v}'`).join(", ");
|
|
1839
|
+
code.line(`if ([${valuesStr}].includes(ctx.${bypass.contextKey} as string)) return true;`);
|
|
1840
|
+
}
|
|
1841
|
+
code.line("return false;");
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
function hasAnyRLS(schemas) {
|
|
1845
|
+
return schemas.some((s) => s.rls.enabled);
|
|
1846
|
+
}
|
|
1847
|
+
function generateRLSError(code) {
|
|
1848
|
+
code.comment("RLS Error for unauthorized access");
|
|
1849
|
+
code.block("export class RLSError extends Error {", () => {
|
|
1850
|
+
code.line('readonly code = "RLS_DENIED";');
|
|
1851
|
+
code.block("constructor(operation: string, entity: string) {", () => {
|
|
1852
|
+
code.line("super(`Access denied: ${operation} on ${entity}`);");
|
|
1853
|
+
code.line('this.name = "RLSError";');
|
|
1854
|
+
});
|
|
1855
|
+
}, "}");
|
|
1856
|
+
code.line();
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// src/cli/generators/mock/handlers.ts
|
|
1860
|
+
function generateMockHandlers(schemas, apiPrefix = "/api") {
|
|
1861
|
+
const code = new CodeBuilder();
|
|
1862
|
+
const hasRLS = hasAnyRLS(schemas);
|
|
1863
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
1864
|
+
code.line("import { http, HttpResponse } from 'msw';");
|
|
1865
|
+
code.line("import { api } from './client';");
|
|
1866
|
+
code.line("import { routes } from './routes';");
|
|
1867
|
+
code.line("import type * as Types from './types';");
|
|
1868
|
+
code.line();
|
|
1869
|
+
if (hasRLS) {
|
|
1870
|
+
code.comment("RLS error class for access denied responses");
|
|
1871
|
+
code.block("class RLSError extends Error {", () => {
|
|
1872
|
+
code.line('readonly code = "RLS_DENIED";');
|
|
1873
|
+
code.block("constructor(operation: string, entity: string) {", () => {
|
|
1874
|
+
code.line("super(`Access denied: ${operation} on ${entity}`);");
|
|
1875
|
+
code.line('this.name = "RLSError";');
|
|
1876
|
+
});
|
|
1877
|
+
});
|
|
1878
|
+
code.line();
|
|
1879
|
+
code.comment("Handle errors and return appropriate HTTP responses");
|
|
1880
|
+
code.block("function handleError(error: unknown): Response {", () => {
|
|
1881
|
+
code.block("if (error instanceof RLSError) {", () => {
|
|
1882
|
+
code.line("return HttpResponse.json({ error: error.message }, { status: 403 });");
|
|
1883
|
+
});
|
|
1884
|
+
code.block('if (error instanceof Error && error.message.includes("not found")) {', () => {
|
|
1885
|
+
code.line("return HttpResponse.json({ error: error.message }, { status: 404 });");
|
|
1886
|
+
});
|
|
1887
|
+
code.line("console.error(error);");
|
|
1888
|
+
code.line("return HttpResponse.json({ error: 'Internal server error' }, { status: 500 });");
|
|
1889
|
+
});
|
|
1890
|
+
code.line();
|
|
1891
|
+
}
|
|
1892
|
+
code.block("export const handlers = [", () => {
|
|
1893
|
+
for (const schema of schemas) {
|
|
1894
|
+
if (schema.isJunctionTable) continue;
|
|
1895
|
+
generateEntityHandlers(code, schema, hasRLS);
|
|
1896
|
+
}
|
|
1897
|
+
}, "];");
|
|
1898
|
+
return code.toString();
|
|
1899
|
+
}
|
|
1900
|
+
function generateEntityHandlers(code, schema, hasRLS) {
|
|
1901
|
+
const { name, pascalName } = schema;
|
|
1902
|
+
const pluralName = pluralize(name);
|
|
1903
|
+
code.comment(`${pascalName} handlers`);
|
|
1904
|
+
code.block(`http.get(routes.${pluralName}.list.path, async ({ request }) => {`, () => {
|
|
1905
|
+
code.line("const url = new URL(request.url);");
|
|
1906
|
+
code.line("const limit = parseInt(url.searchParams.get('limit') || '20');");
|
|
1907
|
+
code.line("const offset = parseInt(url.searchParams.get('offset') || '0');");
|
|
1908
|
+
code.line();
|
|
1909
|
+
if (hasRLS) {
|
|
1910
|
+
code.block("try {", () => {
|
|
1911
|
+
code.line(`const response = await api.${name}.list({ limit, offset });`);
|
|
1912
|
+
code.line("return HttpResponse.json(response);");
|
|
1913
|
+
}, "} catch (error) {");
|
|
1914
|
+
code.indent();
|
|
1915
|
+
code.line("return handleError(error);");
|
|
1916
|
+
code.dedent();
|
|
1917
|
+
code.line("}");
|
|
1918
|
+
} else {
|
|
1919
|
+
code.line(`const response = await api.${name}.list({ limit, offset });`);
|
|
1920
|
+
code.line("return HttpResponse.json(response);");
|
|
1921
|
+
}
|
|
1922
|
+
}, "}),");
|
|
1923
|
+
code.line();
|
|
1924
|
+
code.block(`http.get(routes.${pluralName}.get.path, async ({ params }) => {`, () => {
|
|
1925
|
+
if (hasRLS) {
|
|
1926
|
+
code.block("try {", () => {
|
|
1927
|
+
code.line(`const response = await api.${name}.get(params.id as string);`);
|
|
1928
|
+
code.line("if (!response.data) {");
|
|
1929
|
+
code.line(` return HttpResponse.json({ error: '${pascalName} not found' }, { status: 404 });`);
|
|
1930
|
+
code.line("}");
|
|
1931
|
+
code.line("return HttpResponse.json(response);");
|
|
1932
|
+
}, "} catch (error) {");
|
|
1933
|
+
code.indent();
|
|
1934
|
+
code.line("return handleError(error);");
|
|
1935
|
+
code.dedent();
|
|
1936
|
+
code.line("}");
|
|
1937
|
+
} else {
|
|
1938
|
+
code.line(`const response = await api.${name}.get(params.id as string);`);
|
|
1939
|
+
code.line("if (!response.data) {");
|
|
1940
|
+
code.line(` return HttpResponse.json({ error: '${pascalName} not found' }, { status: 404 });`);
|
|
1941
|
+
code.line("}");
|
|
1942
|
+
code.line("return HttpResponse.json(response);");
|
|
1943
|
+
}
|
|
1944
|
+
}, "}),");
|
|
1945
|
+
code.line();
|
|
1946
|
+
code.block(`http.post(routes.${pluralName}.create.path, async ({ request }) => {`, () => {
|
|
1947
|
+
code.line(`const body = await request.json() as Types.${pascalName}Create;`);
|
|
1948
|
+
code.line();
|
|
1949
|
+
if (hasRLS) {
|
|
1950
|
+
code.block("try {", () => {
|
|
1951
|
+
code.line(`const response = await api.${name}.create(body);`);
|
|
1952
|
+
code.line("return HttpResponse.json(response, { status: 201 });");
|
|
1953
|
+
}, "} catch (error) {");
|
|
1954
|
+
code.indent();
|
|
1955
|
+
code.line("return handleError(error);");
|
|
1956
|
+
code.dedent();
|
|
1957
|
+
code.line("}");
|
|
1958
|
+
} else {
|
|
1959
|
+
code.line(`const response = await api.${name}.create(body);`);
|
|
1960
|
+
code.line("return HttpResponse.json(response, { status: 201 });");
|
|
1961
|
+
}
|
|
1962
|
+
}, "}),");
|
|
1963
|
+
code.line();
|
|
1964
|
+
code.block(`http.put(routes.${pluralName}.update.path, async ({ params, request }) => {`, () => {
|
|
1965
|
+
code.line(`const body = await request.json() as Types.${pascalName}Update;`);
|
|
1966
|
+
code.line();
|
|
1967
|
+
if (hasRLS) {
|
|
1968
|
+
code.block("try {", () => {
|
|
1969
|
+
code.line(`const response = await api.${name}.update(params.id as string, body);`);
|
|
1970
|
+
code.line("return HttpResponse.json(response);");
|
|
1971
|
+
}, "} catch (error) {");
|
|
1972
|
+
code.indent();
|
|
1973
|
+
code.line("return handleError(error);");
|
|
1974
|
+
code.dedent();
|
|
1975
|
+
code.line("}");
|
|
1976
|
+
} else {
|
|
1977
|
+
code.line(`const response = await api.${name}.update(params.id as string, body);`);
|
|
1978
|
+
code.line("return HttpResponse.json(response);");
|
|
1979
|
+
}
|
|
1980
|
+
}, "}),");
|
|
1981
|
+
code.line();
|
|
1982
|
+
code.block(`http.patch(routes.${pluralName}.patch.path, async ({ params, request }) => {`, () => {
|
|
1983
|
+
code.line(`const body = await request.json() as Types.${pascalName}Update;`);
|
|
1984
|
+
code.line();
|
|
1985
|
+
if (hasRLS) {
|
|
1986
|
+
code.block("try {", () => {
|
|
1987
|
+
code.line(`const response = await api.${name}.update(params.id as string, body);`);
|
|
1988
|
+
code.line("return HttpResponse.json(response);");
|
|
1989
|
+
}, "} catch (error) {");
|
|
1990
|
+
code.indent();
|
|
1991
|
+
code.line("return handleError(error);");
|
|
1992
|
+
code.dedent();
|
|
1993
|
+
code.line("}");
|
|
1994
|
+
} else {
|
|
1995
|
+
code.line(`const response = await api.${name}.update(params.id as string, body);`);
|
|
1996
|
+
code.line("return HttpResponse.json(response);");
|
|
1997
|
+
}
|
|
1998
|
+
}, "}),");
|
|
1999
|
+
code.line();
|
|
2000
|
+
code.block(`http.delete(routes.${pluralName}.delete.path, async ({ params }) => {`, () => {
|
|
2001
|
+
if (hasRLS) {
|
|
2002
|
+
code.block("try {", () => {
|
|
2003
|
+
code.line(`await api.${name}.delete(params.id as string);`);
|
|
2004
|
+
code.line("return new HttpResponse(null, { status: 204 });");
|
|
2005
|
+
}, "} catch (error) {");
|
|
2006
|
+
code.indent();
|
|
2007
|
+
code.line("return handleError(error);");
|
|
2008
|
+
code.dedent();
|
|
2009
|
+
code.line("}");
|
|
2010
|
+
} else {
|
|
2011
|
+
code.line(`await api.${name}.delete(params.id as string);`);
|
|
2012
|
+
code.line("return new HttpResponse(null, { status: 204 });");
|
|
2013
|
+
}
|
|
2014
|
+
}, "}),");
|
|
2015
|
+
code.line();
|
|
2016
|
+
}
|
|
2017
|
+
function generateAllHandlersExport(hasEndpoints) {
|
|
2018
|
+
const code = new CodeBuilder();
|
|
2019
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2020
|
+
code.comment("Combined handlers for MSW setup");
|
|
2021
|
+
code.line();
|
|
2022
|
+
code.line("import { handlers } from './handlers';");
|
|
2023
|
+
if (hasEndpoints) {
|
|
2024
|
+
code.line("import { endpointHandlers } from './endpoint-handlers';");
|
|
2025
|
+
code.line();
|
|
2026
|
+
code.comment("All handlers: entity CRUD + custom endpoints");
|
|
2027
|
+
code.line("export const allHandlers = [...handlers, ...endpointHandlers];");
|
|
2028
|
+
} else {
|
|
2029
|
+
code.line();
|
|
2030
|
+
code.comment("All handlers (no custom endpoints defined)");
|
|
2031
|
+
code.line("export const allHandlers = handlers;");
|
|
2032
|
+
}
|
|
2033
|
+
code.line();
|
|
2034
|
+
code.comment("Re-export for convenience");
|
|
2035
|
+
code.line("export { handlers };");
|
|
2036
|
+
if (hasEndpoints) {
|
|
2037
|
+
code.line("export { endpointHandlers };");
|
|
2038
|
+
}
|
|
2039
|
+
return code.toString();
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// src/cli/generators/mock/client.ts
|
|
2043
|
+
function generateMockClient(schemas) {
|
|
2044
|
+
const code = new CodeBuilder();
|
|
2045
|
+
const schemasWithRLS = hasAnyRLS(schemas);
|
|
2046
|
+
const allBypassConditions = collectBypassConditions(schemas);
|
|
2047
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2048
|
+
code.line("import { db } from './db';");
|
|
2049
|
+
code.line("import type * as Types from './types';");
|
|
2050
|
+
if (schemasWithRLS) {
|
|
2051
|
+
code.line(getRLSImports());
|
|
2052
|
+
}
|
|
2053
|
+
code.line();
|
|
2054
|
+
if (schemasWithRLS) {
|
|
2055
|
+
generateRLSContextType(code);
|
|
2056
|
+
generateRLSError(code);
|
|
2057
|
+
generateBypassCheck(code, allBypassConditions);
|
|
2058
|
+
code.line();
|
|
2059
|
+
const hasGlobalBypass = allBypassConditions.length > 0;
|
|
2060
|
+
for (const schema of schemas) {
|
|
2061
|
+
if (schema.isJunctionTable) continue;
|
|
2062
|
+
generateEntityRLSFilters(code, schema, hasGlobalBypass);
|
|
2063
|
+
}
|
|
2064
|
+
code.line();
|
|
2065
|
+
}
|
|
2066
|
+
generateFilterHelper(code);
|
|
2067
|
+
code.line();
|
|
2068
|
+
generateParseRowHelper(code);
|
|
2069
|
+
code.line();
|
|
2070
|
+
code.block("export const api = {", () => {
|
|
2071
|
+
for (const schema of schemas) {
|
|
2072
|
+
if (schema.isJunctionTable) continue;
|
|
2073
|
+
generateEntityApi(code, schema, schemas, schemasWithRLS);
|
|
2074
|
+
}
|
|
2075
|
+
}, "};");
|
|
2076
|
+
return code.toString();
|
|
2077
|
+
}
|
|
2078
|
+
function generateEntityRLSFilters(code, schema, hasGlobalBypass) {
|
|
2079
|
+
const { pascalName, rls } = schema;
|
|
2080
|
+
if (!rls.enabled) {
|
|
2081
|
+
code.comment(`RLS filters for ${pascalName} (disabled)`);
|
|
2082
|
+
code.line(`const rls${pascalName}Select = (_row: Record<string, unknown>, _ctx: RLSContext | null): boolean => true;`);
|
|
2083
|
+
code.line(`const rls${pascalName}Insert = (_row: Record<string, unknown>, _ctx: RLSContext | null): boolean => true;`);
|
|
2084
|
+
code.line(`const rls${pascalName}Update = (_row: Record<string, unknown>, _ctx: RLSContext | null): boolean => true;`);
|
|
2085
|
+
code.line(`const rls${pascalName}Delete = (_row: Record<string, unknown>, _ctx: RLSContext | null): boolean => true;`);
|
|
2086
|
+
code.line();
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
code.comment(`RLS filters for ${pascalName}`);
|
|
2090
|
+
const sourceFields = {
|
|
2091
|
+
Select: rls.selectSource,
|
|
2092
|
+
Insert: rls.insertSource,
|
|
2093
|
+
Update: rls.updateSource,
|
|
2094
|
+
Delete: rls.deleteSource
|
|
2095
|
+
};
|
|
2096
|
+
for (const op of ["Select", "Insert", "Update", "Delete"]) {
|
|
2097
|
+
const hasPolicy = rls[`has${op}`];
|
|
2098
|
+
const customSource = sourceFields[op];
|
|
2099
|
+
code.block(`const rls${pascalName}${op} = (row: Record<string, unknown>, ctx: RLSContext | null): boolean => {`, () => {
|
|
2100
|
+
if (hasGlobalBypass) {
|
|
2101
|
+
code.line("if (checkBypass(ctx)) return true;");
|
|
2102
|
+
}
|
|
2103
|
+
if (!hasPolicy) {
|
|
2104
|
+
code.line("return true;");
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
if (customSource) {
|
|
2108
|
+
for (const line of customSource.split("\n")) {
|
|
2109
|
+
code.line(line.trim());
|
|
2110
|
+
}
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
if (rls.scope.length > 0) {
|
|
2114
|
+
for (const mapping of rls.scope) {
|
|
2115
|
+
code.line(`// Scope: ${mapping.field} must match context.${mapping.contextKey}`);
|
|
2116
|
+
code.line(`if (!ctx || row.${mapping.field} !== ctx.${mapping.contextKey}) return false;`);
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
code.line("return true;");
|
|
2120
|
+
}, "};");
|
|
2121
|
+
}
|
|
2122
|
+
code.line();
|
|
2123
|
+
}
|
|
2124
|
+
function generateFilterHelper(code) {
|
|
2125
|
+
code.block("function applyFilter<T>(items: T[], filter: Record<string, unknown>): T[] {", () => {
|
|
2126
|
+
code.block("return items.filter(item => {", () => {
|
|
2127
|
+
code.block("for (const [key, value] of Object.entries(filter)) {", () => {
|
|
2128
|
+
code.line("const itemValue = (item as Record<string, unknown>)[key];");
|
|
2129
|
+
code.block('if (typeof value === "object" && value !== null) {', () => {
|
|
2130
|
+
code.line("const f = value as Record<string, unknown>;");
|
|
2131
|
+
code.line("if ('equals' in f && itemValue !== f.equals) return false;");
|
|
2132
|
+
code.line("if ('not' in f && itemValue === f.not) return false;");
|
|
2133
|
+
code.line("if ('in' in f && !(f.in as unknown[]).includes(itemValue)) return false;");
|
|
2134
|
+
code.line("if ('notIn' in f && (f.notIn as unknown[]).includes(itemValue)) return false;");
|
|
2135
|
+
code.line("if ('contains' in f && !String(itemValue).includes(f.contains as string)) return false;");
|
|
2136
|
+
code.line("if ('startsWith' in f && !String(itemValue).startsWith(f.startsWith as string)) return false;");
|
|
2137
|
+
code.line("if ('endsWith' in f && !String(itemValue).endsWith(f.endsWith as string)) return false;");
|
|
2138
|
+
code.line("if ('gt' in f && (itemValue as number) <= (f.gt as number)) return false;");
|
|
2139
|
+
code.line("if ('lt' in f && (itemValue as number) >= (f.lt as number)) return false;");
|
|
2140
|
+
code.line("if ('gte' in f && (itemValue as number) < (f.gte as number)) return false;");
|
|
2141
|
+
code.line("if ('lte' in f && (itemValue as number) > (f.lte as number)) return false;");
|
|
2142
|
+
code.line("if ('isNull' in f && f.isNull && itemValue !== null && itemValue !== undefined) return false;");
|
|
2143
|
+
code.line("if ('isNull' in f && !f.isNull && (itemValue === null || itemValue === undefined)) return false;");
|
|
2144
|
+
}, "} else {");
|
|
2145
|
+
code.indent();
|
|
2146
|
+
code.line("if (itemValue !== value) return false;");
|
|
2147
|
+
code.dedent();
|
|
2148
|
+
code.line("}");
|
|
2149
|
+
});
|
|
2150
|
+
code.line("return true;");
|
|
2151
|
+
}, "});");
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
function generateParseRowHelper(code) {
|
|
2155
|
+
code.comment("Parse JSONB fields stored as strings by @mswjs/data");
|
|
2156
|
+
code.block(
|
|
2157
|
+
"function parseRow<T>(row: Record<string, unknown>, jsonFields: string[]): T {",
|
|
2158
|
+
() => {
|
|
2159
|
+
code.line("const result = { ...row };");
|
|
2160
|
+
code.block("for (const field of jsonFields) {", () => {
|
|
2161
|
+
code.block('if (result[field] && typeof result[field] === "string") {', () => {
|
|
2162
|
+
code.block("try {", () => {
|
|
2163
|
+
code.line("result[field] = JSON.parse(result[field] as string);");
|
|
2164
|
+
}, "} catch { /* keep as string */ }");
|
|
2165
|
+
});
|
|
2166
|
+
});
|
|
2167
|
+
code.line("return result as T;");
|
|
2168
|
+
}
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
function generateEntityApi(code, schema, allSchemas, hasRLS) {
|
|
2172
|
+
const { name, pascalName, relations, fields, rls } = schema;
|
|
2173
|
+
const hasRelations = relations.length > 0;
|
|
2174
|
+
const includeType = hasRelations ? `Types.${pascalName}Include` : "never";
|
|
2175
|
+
const jsonFields = fields.filter((f) => f.isArray || f.isObject).map((f) => f.name);
|
|
2176
|
+
const hasJsonFields = jsonFields.length > 0;
|
|
2177
|
+
const jsonFieldsStr = jsonFields.map((f) => `'${f}'`).join(", ");
|
|
2178
|
+
code.block(`${name}: {`, () => {
|
|
2179
|
+
code.block(
|
|
2180
|
+
`list: async (options?: Types.QueryOptions<Types.${pascalName}Filter, ${includeType}>): Promise<Types.ListResponse<Types.${pascalName}>> => {`,
|
|
2181
|
+
() => {
|
|
2182
|
+
if (hasJsonFields) {
|
|
2183
|
+
code.line(`let rawItems = db.${name}.getAll() as unknown as Record<string, unknown>[];`);
|
|
2184
|
+
code.line(`let items = rawItems.map(row => parseRow<Types.${pascalName}>(row, [${jsonFieldsStr}]));`);
|
|
2185
|
+
} else {
|
|
2186
|
+
code.line(`let items = db.${name}.getAll() as unknown as Types.${pascalName}[];`);
|
|
2187
|
+
}
|
|
2188
|
+
code.line();
|
|
2189
|
+
if (hasRLS) {
|
|
2190
|
+
code.comment("Apply RLS filter");
|
|
2191
|
+
code.line(`const ctx = getContext();`);
|
|
2192
|
+
code.line(`items = items.filter(item => rls${pascalName}Select(item as unknown as Record<string, unknown>, ctx));`);
|
|
2193
|
+
code.line();
|
|
2194
|
+
}
|
|
2195
|
+
code.block("if (options?.where) {", () => {
|
|
2196
|
+
code.line("items = applyFilter(items, options.where);");
|
|
2197
|
+
});
|
|
2198
|
+
code.line();
|
|
2199
|
+
code.line("const total = items.length;");
|
|
2200
|
+
code.line();
|
|
2201
|
+
code.block("if (options?.orderBy) {", () => {
|
|
2202
|
+
code.line("const [field, dir] = Object.entries(options.orderBy)[0];");
|
|
2203
|
+
code.block("items = [...items].sort((a, b) => {", () => {
|
|
2204
|
+
code.line("const aVal = (a as Record<string, unknown>)[field] as string | number | Date;");
|
|
2205
|
+
code.line("const bVal = (b as Record<string, unknown>)[field] as string | number | Date;");
|
|
2206
|
+
code.line("if (aVal < bVal) return dir === 'asc' ? -1 : 1;");
|
|
2207
|
+
code.line("if (aVal > bVal) return dir === 'asc' ? 1 : -1;");
|
|
2208
|
+
code.line("return 0;");
|
|
2209
|
+
}, "});");
|
|
2210
|
+
});
|
|
2211
|
+
code.line();
|
|
2212
|
+
code.line("const limit = options?.limit ?? 20;");
|
|
2213
|
+
code.line("const offset = options?.offset ?? 0;");
|
|
2214
|
+
code.line("items = items.slice(offset, offset + limit);");
|
|
2215
|
+
code.line();
|
|
2216
|
+
if (hasRelations) {
|
|
2217
|
+
code.block("if (options?.include?.length) {", () => {
|
|
2218
|
+
code.block("items = items.map(item => {", () => {
|
|
2219
|
+
code.line("const result = { ...item } as Record<string, unknown>;");
|
|
2220
|
+
for (const rel of relations) {
|
|
2221
|
+
code.block(`if (options.include!.includes('${rel.name}')) {`, () => {
|
|
2222
|
+
generateRelationLoad(code, schema, rel);
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
code.line(`return result as Types.${pascalName};`);
|
|
2226
|
+
}, "});");
|
|
2227
|
+
});
|
|
2228
|
+
code.line();
|
|
2229
|
+
}
|
|
2230
|
+
code.line("return { data: items, meta: { total, limit, offset, hasMore: offset + limit < total } };");
|
|
2231
|
+
},
|
|
2232
|
+
"},"
|
|
2233
|
+
);
|
|
2234
|
+
code.line();
|
|
2235
|
+
code.block(
|
|
2236
|
+
`get: async (id: string, options?: { include?: ${includeType}[] }): Promise<Types.ItemResponse<Types.${pascalName}>> => {`,
|
|
2237
|
+
() => {
|
|
2238
|
+
code.line(`const rawItem = db.${name}.findFirst({ where: { id: { equals: id } } }) as unknown as Record<string, unknown> | null;`);
|
|
2239
|
+
code.line(`if (!rawItem) throw new Error('${pascalName} not found');`);
|
|
2240
|
+
if (hasJsonFields) {
|
|
2241
|
+
code.line(`const item = parseRow<Types.${pascalName}>(rawItem, [${jsonFieldsStr}]);`);
|
|
2242
|
+
} else {
|
|
2243
|
+
code.line(`const item = rawItem as Types.${pascalName};`);
|
|
2244
|
+
}
|
|
2245
|
+
code.line();
|
|
2246
|
+
if (hasRLS) {
|
|
2247
|
+
code.comment("Apply RLS check");
|
|
2248
|
+
code.line(`const ctx = getContext();`);
|
|
2249
|
+
code.block(`if (!rls${pascalName}Select(item as unknown as Record<string, unknown>, ctx)) {`, () => {
|
|
2250
|
+
code.line(`throw new RLSError('select', '${pascalName}');`);
|
|
2251
|
+
});
|
|
2252
|
+
code.line();
|
|
2253
|
+
}
|
|
2254
|
+
if (hasRelations) {
|
|
2255
|
+
code.line("const result = { ...item } as Record<string, unknown>;");
|
|
2256
|
+
code.line();
|
|
2257
|
+
code.block("if (options?.include?.length) {", () => {
|
|
2258
|
+
for (const rel of relations) {
|
|
2259
|
+
code.block(`if (options.include.includes('${rel.name}')) {`, () => {
|
|
2260
|
+
generateRelationLoad(code, schema, rel);
|
|
2261
|
+
});
|
|
2262
|
+
}
|
|
2263
|
+
});
|
|
2264
|
+
code.line();
|
|
2265
|
+
code.line(`return { data: result as Types.${pascalName} };`);
|
|
2266
|
+
} else {
|
|
2267
|
+
code.line("return { data: item };");
|
|
2268
|
+
}
|
|
2269
|
+
},
|
|
2270
|
+
"},"
|
|
2271
|
+
);
|
|
2272
|
+
code.line();
|
|
2273
|
+
generateCreateMethod(code, schema, hasJsonFields, jsonFieldsStr, hasRLS);
|
|
2274
|
+
code.line();
|
|
2275
|
+
code.block(`update: async (id: string, input: Types.${pascalName}Update): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
2276
|
+
if (hasRLS) {
|
|
2277
|
+
code.comment("Check RLS before update");
|
|
2278
|
+
code.line(`const existing = db.${name}.findFirst({ where: { id: { equals: id } } }) as unknown as Record<string, unknown> | null;`);
|
|
2279
|
+
code.line(`if (!existing) throw new Error('${pascalName} not found');`);
|
|
2280
|
+
code.line(`const ctx = getContext();`);
|
|
2281
|
+
code.block(`if (!rls${pascalName}Update(existing, ctx)) {`, () => {
|
|
2282
|
+
code.line(`throw new RLSError('update', '${pascalName}');`);
|
|
2283
|
+
});
|
|
2284
|
+
code.line();
|
|
2285
|
+
}
|
|
2286
|
+
code.line(`const rawItem = db.${name}.update({`);
|
|
2287
|
+
code.line(" where: { id: { equals: id } },");
|
|
2288
|
+
code.line(` // eslint-disable-next-line @typescript-eslint/no-explicit-any`);
|
|
2289
|
+
code.line(" data: { ...input, updatedAt: new Date() } as any,");
|
|
2290
|
+
code.line("}) as unknown as Record<string, unknown> | null;");
|
|
2291
|
+
if (!hasRLS) {
|
|
2292
|
+
code.line(`if (!rawItem) throw new Error('${pascalName} not found');`);
|
|
2293
|
+
}
|
|
2294
|
+
if (hasJsonFields) {
|
|
2295
|
+
code.line(`return { data: parseRow<Types.${pascalName}>(rawItem!, [${jsonFieldsStr}]) };`);
|
|
2296
|
+
} else {
|
|
2297
|
+
code.line(`return { data: rawItem as Types.${pascalName} };`);
|
|
2298
|
+
}
|
|
2299
|
+
}, "},");
|
|
2300
|
+
code.line();
|
|
2301
|
+
code.block("delete: async (id: string): Promise<void> => {", () => {
|
|
2302
|
+
if (hasRLS) {
|
|
2303
|
+
code.comment("Check RLS before delete");
|
|
2304
|
+
code.line(`const existing = db.${name}.findFirst({ where: { id: { equals: id } } }) as unknown as Record<string, unknown> | null;`);
|
|
2305
|
+
code.line(`if (!existing) throw new Error('${pascalName} not found');`);
|
|
2306
|
+
code.line(`const ctx = getContext();`);
|
|
2307
|
+
code.block(`if (!rls${pascalName}Delete(existing, ctx)) {`, () => {
|
|
2308
|
+
code.line(`throw new RLSError('delete', '${pascalName}');`);
|
|
2309
|
+
});
|
|
2310
|
+
code.line();
|
|
2311
|
+
}
|
|
2312
|
+
code.line(`const item = db.${name}.delete({ where: { id: { equals: id } } });`);
|
|
2313
|
+
if (!hasRLS) {
|
|
2314
|
+
code.line(`if (!item) throw new Error('${pascalName} not found');`);
|
|
2315
|
+
}
|
|
2316
|
+
}, "},");
|
|
2317
|
+
}, "},");
|
|
2318
|
+
code.line();
|
|
2319
|
+
}
|
|
2320
|
+
function generateRelationLoad(code, schema, rel) {
|
|
2321
|
+
if (rel.type === "hasMany") {
|
|
2322
|
+
code.line(`result.${rel.name} = db.${rel.target}.findMany({`);
|
|
2323
|
+
code.line(` where: { ${rel.foreignKey}: { equals: item.id } }`);
|
|
2324
|
+
code.line("});");
|
|
2325
|
+
} else if (rel.type === "hasOne") {
|
|
2326
|
+
code.line(`result.${rel.name} = db.${rel.target}.findFirst({`);
|
|
2327
|
+
code.line(` where: { ${rel.foreignKey}: { equals: item.id } }`);
|
|
2328
|
+
code.line("});");
|
|
2329
|
+
} else if (rel.type === "belongsTo") {
|
|
2330
|
+
code.line(`result.${rel.name} = db.${rel.target}.findFirst({`);
|
|
2331
|
+
code.line(` where: { id: { equals: (item as Record<string, unknown>).${rel.localField} as string } }`);
|
|
2332
|
+
code.line("});");
|
|
2333
|
+
} else if (rel.type === "manyToMany") {
|
|
2334
|
+
code.line(`const junctions = db.${rel.through}.findMany({`);
|
|
2335
|
+
code.line(` where: { ${rel.foreignKey}: { equals: item.id } }`);
|
|
2336
|
+
code.line("});");
|
|
2337
|
+
code.line(`result.${rel.name} = junctions`);
|
|
2338
|
+
code.line(` .map(j => db.${rel.target}.findFirst({`);
|
|
2339
|
+
code.line(` where: { id: { equals: (j as Record<string, unknown>).${rel.otherKey} as string } }`);
|
|
2340
|
+
code.line(" }))");
|
|
2341
|
+
code.line(" .filter(Boolean);");
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
function generateCreateMethod(code, schema, hasJsonFields, jsonFieldsStr, hasRLS) {
|
|
2345
|
+
const { name, pascalName, relations } = schema;
|
|
2346
|
+
const nestedRels = relations.filter((r) => r.type === "hasMany" || r.type === "hasOne");
|
|
2347
|
+
code.block(`create: async (input: Types.${pascalName}Create): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
2348
|
+
if (nestedRels.length > 0) {
|
|
2349
|
+
const relNames = nestedRels.map((r) => r.name).join(", ");
|
|
2350
|
+
code.line(`const { ${relNames}, ...data } = input;`);
|
|
2351
|
+
code.line();
|
|
2352
|
+
code.line(`// eslint-disable-next-line @typescript-eslint/no-explicit-any`);
|
|
2353
|
+
code.line(`const rawItem = db.${name}.create(data as any) as unknown as Record<string, unknown>;`);
|
|
2354
|
+
if (hasJsonFields) {
|
|
2355
|
+
code.line(`const item = parseRow<Types.${pascalName}>(rawItem, [${jsonFieldsStr}]);`);
|
|
2356
|
+
} else {
|
|
2357
|
+
code.line(`const item = rawItem as Types.${pascalName};`);
|
|
2358
|
+
}
|
|
2359
|
+
code.line();
|
|
2360
|
+
if (hasRLS) {
|
|
2361
|
+
code.comment("Check RLS on created item");
|
|
2362
|
+
code.line(`const ctx = getContext();`);
|
|
2363
|
+
code.block(`if (!rls${pascalName}Insert(item as unknown as Record<string, unknown>, ctx)) {`, () => {
|
|
2364
|
+
code.comment("Rollback by deleting");
|
|
2365
|
+
code.line(`db.${name}.delete({ where: { id: { equals: item.id } } });`);
|
|
2366
|
+
code.line(`throw new RLSError('insert', '${pascalName}');`);
|
|
2367
|
+
});
|
|
2368
|
+
code.line();
|
|
2369
|
+
}
|
|
2370
|
+
for (const rel of nestedRels) {
|
|
2371
|
+
code.block(`if (${rel.name}) {`, () => {
|
|
2372
|
+
if (rel.type === "hasMany") {
|
|
2373
|
+
code.block(`for (const nested of ${rel.name}) {`, () => {
|
|
2374
|
+
code.line(`// eslint-disable-next-line @typescript-eslint/no-explicit-any`);
|
|
2375
|
+
code.line(`db.${rel.target}.create({ ...nested, ${rel.foreignKey}: item.id } as any);`);
|
|
2376
|
+
});
|
|
2377
|
+
} else {
|
|
2378
|
+
code.line(`// eslint-disable-next-line @typescript-eslint/no-explicit-any`);
|
|
2379
|
+
code.line(`db.${rel.target}.create({ ...${rel.name}, ${rel.foreignKey}: item.id } as any);`);
|
|
2380
|
+
}
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
code.line();
|
|
2384
|
+
code.line("return { data: item };");
|
|
2385
|
+
} else {
|
|
2386
|
+
code.line(`// eslint-disable-next-line @typescript-eslint/no-explicit-any`);
|
|
2387
|
+
code.line(`const rawItem = db.${name}.create(input as any) as unknown as Record<string, unknown>;`);
|
|
2388
|
+
if (hasJsonFields) {
|
|
2389
|
+
code.line(`const item = parseRow<Types.${pascalName}>(rawItem, [${jsonFieldsStr}]);`);
|
|
2390
|
+
} else {
|
|
2391
|
+
code.line(`const item = rawItem as Types.${pascalName};`);
|
|
2392
|
+
}
|
|
2393
|
+
if (hasRLS) {
|
|
2394
|
+
code.line();
|
|
2395
|
+
code.comment("Check RLS on created item");
|
|
2396
|
+
code.line(`const ctx = getContext();`);
|
|
2397
|
+
code.block(`if (!rls${pascalName}Insert(item as unknown as Record<string, unknown>, ctx)) {`, () => {
|
|
2398
|
+
code.comment("Rollback by deleting");
|
|
2399
|
+
code.line(`db.${name}.delete({ where: { id: { equals: item.id } } });`);
|
|
2400
|
+
code.line(`throw new RLSError('insert', '${pascalName}');`);
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
code.line();
|
|
2404
|
+
code.line("return { data: item };");
|
|
2405
|
+
}
|
|
2406
|
+
}, "},");
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// src/cli/generators/mock/seed.ts
|
|
2410
|
+
function generateSeed(schemas, config) {
|
|
2411
|
+
const code = new CodeBuilder();
|
|
2412
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2413
|
+
code.line("import { db } from './db';");
|
|
2414
|
+
code.line();
|
|
2415
|
+
code.block("export interface SeedCounts {", () => {
|
|
2416
|
+
for (const schema of schemas) {
|
|
2417
|
+
if (schema.isJunctionTable) continue;
|
|
2418
|
+
code.line(`${schema.name}?: number;`);
|
|
2419
|
+
}
|
|
2420
|
+
});
|
|
2421
|
+
code.line();
|
|
2422
|
+
code.block("const defaultCounts: Required<SeedCounts> = {", () => {
|
|
2423
|
+
for (const schema of schemas) {
|
|
2424
|
+
if (schema.isJunctionTable) continue;
|
|
2425
|
+
const count = config.seed?.[schema.name] ?? 10;
|
|
2426
|
+
code.line(`${schema.name}: ${count},`);
|
|
2427
|
+
}
|
|
2428
|
+
}, "};");
|
|
2429
|
+
code.line();
|
|
2430
|
+
code.block("function pickRandom<T>(arr: T[]): T | undefined {", () => {
|
|
2431
|
+
code.line("if (arr.length === 0) return undefined;");
|
|
2432
|
+
code.line("return arr[Math.floor(Math.random() * arr.length)];");
|
|
2433
|
+
});
|
|
2434
|
+
code.line();
|
|
2435
|
+
code.block("export function seed(counts: SeedCounts = {}): void {", () => {
|
|
2436
|
+
code.line("const merged = { ...defaultCounts, ...counts };");
|
|
2437
|
+
code.line();
|
|
2438
|
+
code.comment("Track created entity IDs for foreign key references");
|
|
2439
|
+
code.line("const ids: Record<string, string[]> = {};");
|
|
2440
|
+
code.line();
|
|
2441
|
+
for (const schema of schemas) {
|
|
2442
|
+
if (schema.isJunctionTable) continue;
|
|
2443
|
+
const belongsToRels = schema.relations.filter((r) => r.type === "belongsTo");
|
|
2444
|
+
const fkFields = belongsToRels.map((r) => ({
|
|
2445
|
+
fieldName: r.localField || r.foreignKey,
|
|
2446
|
+
target: r.target,
|
|
2447
|
+
nullable: schema.fields.find((f) => f.name === (r.localField || r.foreignKey))?.nullable ?? false
|
|
2448
|
+
}));
|
|
2449
|
+
code.line(`ids.${schema.name} = [];`);
|
|
2450
|
+
code.block(`for (let i = 0; i < merged.${schema.name}; i++) {`, () => {
|
|
2451
|
+
if (fkFields.length > 0) {
|
|
2452
|
+
code.line(`const item = db.${schema.name}.create({`);
|
|
2453
|
+
code.indent();
|
|
2454
|
+
for (const fk of fkFields) {
|
|
2455
|
+
if (fk.nullable) {
|
|
2456
|
+
code.line(`${fk.fieldName}: Math.random() > 0.3 ? pickRandom(ids.${fk.target}) : undefined,`);
|
|
2457
|
+
} else {
|
|
2458
|
+
code.line(`${fk.fieldName}: pickRandom(ids.${fk.target}),`);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
code.dedent();
|
|
2462
|
+
code.line(`// eslint-disable-next-line @typescript-eslint/no-explicit-any`);
|
|
2463
|
+
code.line("} as any);");
|
|
2464
|
+
} else {
|
|
2465
|
+
code.line(`const item = db.${schema.name}.create({});`);
|
|
2466
|
+
}
|
|
2467
|
+
code.line(`ids.${schema.name}.push(item.id);`);
|
|
2468
|
+
});
|
|
2469
|
+
code.line();
|
|
2470
|
+
}
|
|
2471
|
+
});
|
|
2472
|
+
code.line();
|
|
2473
|
+
code.block("export function reset(): void {", () => {
|
|
2474
|
+
for (const schema of [...schemas].reverse()) {
|
|
2475
|
+
code.line(`db.${schema.name}.deleteMany({ where: {} });`);
|
|
2476
|
+
}
|
|
2477
|
+
});
|
|
2478
|
+
code.line();
|
|
2479
|
+
code.block("export function getAll(): Record<string, unknown[]> {", () => {
|
|
2480
|
+
code.block("return {", () => {
|
|
2481
|
+
for (const schema of schemas) {
|
|
2482
|
+
code.line(`${schema.name}: db.${schema.name}.getAll(),`);
|
|
2483
|
+
}
|
|
2484
|
+
}, "};");
|
|
2485
|
+
});
|
|
2486
|
+
return code.toString();
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// src/cli/generators/mock/routes.ts
|
|
2490
|
+
function generateRoutes(schemas) {
|
|
2491
|
+
const code = new CodeBuilder();
|
|
2492
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2493
|
+
code.line();
|
|
2494
|
+
code.comment("Auto-generated REST route definitions");
|
|
2495
|
+
code.comment("");
|
|
2496
|
+
code.comment("These routes are used by:");
|
|
2497
|
+
code.comment("- MSW handlers for intercepting requests");
|
|
2498
|
+
code.comment("- Backend reference for implementing custom servers");
|
|
2499
|
+
code.line();
|
|
2500
|
+
code.comment("Route definition type");
|
|
2501
|
+
code.block("export interface RouteDefinition {", () => {
|
|
2502
|
+
code.line("method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';");
|
|
2503
|
+
code.line("path: string;");
|
|
2504
|
+
}, "}");
|
|
2505
|
+
code.line();
|
|
2506
|
+
code.comment("Entity route definitions");
|
|
2507
|
+
code.block("export const routes = {", () => {
|
|
2508
|
+
for (const schema of schemas) {
|
|
2509
|
+
if (schema.isJunctionTable) continue;
|
|
2510
|
+
generateEntityRoutes(code, schema);
|
|
2511
|
+
}
|
|
2512
|
+
}, "} as const;");
|
|
2513
|
+
code.line();
|
|
2514
|
+
code.line("export type Routes = typeof routes;");
|
|
2515
|
+
code.line();
|
|
2516
|
+
generateRouteHelpers(code, schemas);
|
|
2517
|
+
return code.toString();
|
|
2518
|
+
}
|
|
2519
|
+
function generateEntityRoutes(code, schema) {
|
|
2520
|
+
const { name } = schema;
|
|
2521
|
+
const pluralName = pluralize(name);
|
|
2522
|
+
const basePath = `/api/${pluralName}`;
|
|
2523
|
+
code.block(`${pluralName}: {`, () => {
|
|
2524
|
+
code.line(`list: { method: 'GET', path: '${basePath}' },`);
|
|
2525
|
+
code.line(`get: { method: 'GET', path: '${basePath}/:id' },`);
|
|
2526
|
+
code.line(`create: { method: 'POST', path: '${basePath}' },`);
|
|
2527
|
+
code.line(`update: { method: 'PUT', path: '${basePath}/:id' },`);
|
|
2528
|
+
code.line(`patch: { method: 'PATCH', path: '${basePath}/:id' },`);
|
|
2529
|
+
code.line(`delete: { method: 'DELETE', path: '${basePath}/:id' },`);
|
|
2530
|
+
}, "},");
|
|
2531
|
+
}
|
|
2532
|
+
function generateRouteHelpers(code, schemas) {
|
|
2533
|
+
code.comment("Route path helpers - generate paths with parameters");
|
|
2534
|
+
code.line();
|
|
2535
|
+
for (const schema of schemas) {
|
|
2536
|
+
if (schema.isJunctionTable) continue;
|
|
2537
|
+
const { name, pascalName } = schema;
|
|
2538
|
+
const pluralName = pluralize(name);
|
|
2539
|
+
const basePath = `/api/${pluralName}`;
|
|
2540
|
+
code.block(`export const ${name}Routes = {`, () => {
|
|
2541
|
+
code.line(`list: () => '${basePath}',`);
|
|
2542
|
+
code.line(`get: (id: string) => \`${basePath}/\${id}\`,`);
|
|
2543
|
+
code.line(`create: () => '${basePath}',`);
|
|
2544
|
+
code.line(`update: (id: string) => \`${basePath}/\${id}\`,`);
|
|
2545
|
+
code.line(`patch: (id: string) => \`${basePath}/\${id}\`,`);
|
|
2546
|
+
code.line(`delete: (id: string) => \`${basePath}/\${id}\`,`);
|
|
2547
|
+
}, "};");
|
|
2548
|
+
code.line();
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// src/cli/generators/mock/endpoints.ts
|
|
2553
|
+
function generateEndpointTypes(endpoints) {
|
|
2554
|
+
const code = new CodeBuilder();
|
|
2555
|
+
code.line();
|
|
2556
|
+
code.comment("=============================================================================");
|
|
2557
|
+
code.comment("Custom Endpoint Types");
|
|
2558
|
+
code.comment("=============================================================================");
|
|
2559
|
+
code.line();
|
|
2560
|
+
for (const endpoint of endpoints) {
|
|
2561
|
+
generateEndpointTypeSet(code, endpoint);
|
|
2562
|
+
}
|
|
2563
|
+
return code.toString();
|
|
2564
|
+
}
|
|
2565
|
+
function generateEndpointTypeSet(code, endpoint) {
|
|
2566
|
+
const { pascalName, params, body, response } = endpoint;
|
|
2567
|
+
if (params.length > 0) {
|
|
2568
|
+
code.comment(`Parameters for ${endpoint.path}`);
|
|
2569
|
+
code.block(`export interface ${pascalName}Params {`, () => {
|
|
2570
|
+
for (const param of params) {
|
|
2571
|
+
const optional = !param.required ? "?" : "";
|
|
2572
|
+
code.line(`${param.name}${optional}: ${param.tsType};`);
|
|
2573
|
+
}
|
|
2574
|
+
});
|
|
2575
|
+
code.line();
|
|
2576
|
+
}
|
|
2577
|
+
if (body.length > 0) {
|
|
2578
|
+
code.comment(`Request body for ${endpoint.path}`);
|
|
2579
|
+
code.block(`export interface ${pascalName}Body {`, () => {
|
|
2580
|
+
for (const field of body) {
|
|
2581
|
+
const optional = !field.required ? "?" : "";
|
|
2582
|
+
code.line(`${field.name}${optional}: ${field.tsType};`);
|
|
2583
|
+
}
|
|
2584
|
+
});
|
|
2585
|
+
code.line();
|
|
2586
|
+
}
|
|
2587
|
+
code.comment(`Response from ${endpoint.path}`);
|
|
2588
|
+
code.block(`export interface ${pascalName}Response {`, () => {
|
|
2589
|
+
for (const field of response) {
|
|
2590
|
+
code.line(`${field.name}: ${field.tsType};`);
|
|
2591
|
+
}
|
|
2592
|
+
});
|
|
2593
|
+
code.line();
|
|
2594
|
+
}
|
|
2595
|
+
function generateEndpointClient(endpoints) {
|
|
2596
|
+
const code = new CodeBuilder();
|
|
2597
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2598
|
+
code.comment("Client methods for custom endpoints");
|
|
2599
|
+
code.line();
|
|
2600
|
+
code.line("import type * as Types from './types';");
|
|
2601
|
+
code.line();
|
|
2602
|
+
code.comment("API base URL - configure based on environment");
|
|
2603
|
+
code.line("const API_BASE = typeof window !== 'undefined' ? window.location.origin : '';");
|
|
2604
|
+
code.line();
|
|
2605
|
+
code.block("export const endpoints = {", () => {
|
|
2606
|
+
for (const endpoint of endpoints) {
|
|
2607
|
+
generateClientMethod(code, endpoint);
|
|
2608
|
+
}
|
|
2609
|
+
}, "};");
|
|
2610
|
+
return code.toString();
|
|
2611
|
+
}
|
|
2612
|
+
function generateClientMethod(code, endpoint) {
|
|
2613
|
+
const { name, pascalName, method, path, params, body, pathParams } = endpoint;
|
|
2614
|
+
const args = [];
|
|
2615
|
+
if (params.length > 0) {
|
|
2616
|
+
args.push(`params: Types.${pascalName}Params`);
|
|
2617
|
+
}
|
|
2618
|
+
if (body.length > 0) {
|
|
2619
|
+
args.push(`body: Types.${pascalName}Body`);
|
|
2620
|
+
}
|
|
2621
|
+
const returnType = `Promise<Types.${pascalName}Response>`;
|
|
2622
|
+
code.block(`${name}: async (${args.join(", ")}): ${returnType} => {`, () => {
|
|
2623
|
+
let urlExpr;
|
|
2624
|
+
if (pathParams.length > 0) {
|
|
2625
|
+
let pathTemplate = path;
|
|
2626
|
+
for (const param of pathParams) {
|
|
2627
|
+
pathTemplate = pathTemplate.replace(`:${param}`, `\${params.${param}}`);
|
|
2628
|
+
}
|
|
2629
|
+
urlExpr = `\`\${API_BASE}${pathTemplate}\``;
|
|
2630
|
+
} else {
|
|
2631
|
+
urlExpr = `\`\${API_BASE}${path}\``;
|
|
2632
|
+
}
|
|
2633
|
+
if (method === "GET" && params.length > 0) {
|
|
2634
|
+
code.line(`const url = new URL(${urlExpr});`);
|
|
2635
|
+
const queryParams = params.filter((p) => !pathParams.includes(p.name));
|
|
2636
|
+
for (const param of queryParams) {
|
|
2637
|
+
code.block(`if (params.${param.name} !== undefined) {`, () => {
|
|
2638
|
+
code.line(`url.searchParams.set('${param.name}', String(params.${param.name}));`);
|
|
2639
|
+
});
|
|
2640
|
+
}
|
|
2641
|
+
code.line("const response = await fetch(url.toString());");
|
|
2642
|
+
} else if (body.length > 0) {
|
|
2643
|
+
code.line(`const response = await fetch(${urlExpr}, {`);
|
|
2644
|
+
code.line(` method: '${method}',`);
|
|
2645
|
+
code.line(" headers: { 'Content-Type': 'application/json' },");
|
|
2646
|
+
code.line(" body: JSON.stringify(body),");
|
|
2647
|
+
code.line("});");
|
|
2648
|
+
} else {
|
|
2649
|
+
code.line(`const response = await fetch(${urlExpr}, { method: '${method}' });`);
|
|
2650
|
+
}
|
|
2651
|
+
code.line();
|
|
2652
|
+
code.block("if (!response.ok) {", () => {
|
|
2653
|
+
code.line("const error = await response.json().catch(() => ({}));");
|
|
2654
|
+
code.line("throw new Error(error.message || `HTTP ${response.status}`);");
|
|
2655
|
+
});
|
|
2656
|
+
code.line();
|
|
2657
|
+
code.line("return response.json();");
|
|
2658
|
+
}, "},");
|
|
2659
|
+
code.line();
|
|
2660
|
+
}
|
|
2661
|
+
function generateEndpointHandlers(endpoints) {
|
|
2662
|
+
const code = new CodeBuilder();
|
|
2663
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2664
|
+
code.comment("MSW handlers for custom endpoints");
|
|
2665
|
+
code.line();
|
|
2666
|
+
code.line("import { http, HttpResponse } from 'msw';");
|
|
2667
|
+
code.line("import { db } from './db';");
|
|
2668
|
+
code.line("import { endpointResolvers } from './endpoint-resolvers';");
|
|
2669
|
+
code.line();
|
|
2670
|
+
code.block("export const endpointHandlers = [", () => {
|
|
2671
|
+
for (const endpoint of endpoints) {
|
|
2672
|
+
generateHandler(code, endpoint);
|
|
2673
|
+
}
|
|
2674
|
+
}, "];");
|
|
2675
|
+
return code.toString();
|
|
2676
|
+
}
|
|
2677
|
+
function generateHandler(code, endpoint) {
|
|
2678
|
+
const { name, method, path, params, body, pathParams } = endpoint;
|
|
2679
|
+
const httpMethod = method.toLowerCase();
|
|
2680
|
+
code.comment(`${method} ${path}`);
|
|
2681
|
+
code.block(`http.${httpMethod}('${path}', async ({ request, params: pathParams }) => {`, () => {
|
|
2682
|
+
if (method === "GET" && params.length > 0) {
|
|
2683
|
+
code.line("const url = new URL(request.url);");
|
|
2684
|
+
code.block("const params = {", () => {
|
|
2685
|
+
for (const param of params) {
|
|
2686
|
+
if (pathParams.includes(param.name)) {
|
|
2687
|
+
code.line(`${param.name}: pathParams.${param.name} as string,`);
|
|
2688
|
+
} else {
|
|
2689
|
+
generateParamParsing(code, param);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}, "};");
|
|
2693
|
+
code.line();
|
|
2694
|
+
} else if (pathParams.length > 0) {
|
|
2695
|
+
code.block("const params = {", () => {
|
|
2696
|
+
for (const paramName of pathParams) {
|
|
2697
|
+
code.line(`${paramName}: pathParams.${paramName} as string,`);
|
|
2698
|
+
}
|
|
2699
|
+
}, "};");
|
|
2700
|
+
code.line();
|
|
2701
|
+
}
|
|
2702
|
+
if (body.length > 0) {
|
|
2703
|
+
code.line("const body = await request.json();");
|
|
2704
|
+
code.line();
|
|
2705
|
+
}
|
|
2706
|
+
const ctxParts = ["db"];
|
|
2707
|
+
if (params.length > 0 || pathParams.length > 0) ctxParts.push("params");
|
|
2708
|
+
if (body.length > 0) ctxParts.push("body");
|
|
2709
|
+
code.line("const headers: Record<string, string> = {};");
|
|
2710
|
+
code.line("request.headers.forEach((value, key) => { headers[key] = value; });");
|
|
2711
|
+
ctxParts.push("headers");
|
|
2712
|
+
code.line();
|
|
2713
|
+
code.block("try {", () => {
|
|
2714
|
+
code.line(`const result = await endpointResolvers.${name}({ ${ctxParts.join(", ")} });`);
|
|
2715
|
+
code.line("return HttpResponse.json(result);");
|
|
2716
|
+
}, "} catch (error) {");
|
|
2717
|
+
code.indent();
|
|
2718
|
+
code.line("console.error(`Error in ${name}:`, error);");
|
|
2719
|
+
code.line('const message = error instanceof Error ? error.message : "Internal server error";');
|
|
2720
|
+
code.line("return HttpResponse.json({ error: message }, { status: 500 });");
|
|
2721
|
+
code.dedent();
|
|
2722
|
+
code.line("}");
|
|
2723
|
+
}, "}),");
|
|
2724
|
+
code.line();
|
|
2725
|
+
}
|
|
2726
|
+
function generateParamParsing(code, param) {
|
|
2727
|
+
const { name, tsType, hasDefault } = param;
|
|
2728
|
+
const defaultVal = hasDefault ? JSON.stringify(param.default) : "undefined";
|
|
2729
|
+
if (tsType === "number" || tsType.includes("number")) {
|
|
2730
|
+
if (hasDefault) {
|
|
2731
|
+
code.line(`${name}: Number(url.searchParams.get('${name}') ?? ${defaultVal}),`);
|
|
2732
|
+
} else {
|
|
2733
|
+
code.line(`${name}: url.searchParams.has('${name}') ? Number(url.searchParams.get('${name}')) : undefined,`);
|
|
2734
|
+
}
|
|
2735
|
+
} else if (tsType === "boolean" || tsType.includes("boolean")) {
|
|
2736
|
+
if (hasDefault) {
|
|
2737
|
+
code.line(`${name}: url.searchParams.has('${name}') ? url.searchParams.get('${name}') === 'true' : ${defaultVal},`);
|
|
2738
|
+
} else {
|
|
2739
|
+
code.line(`${name}: url.searchParams.has('${name}') ? url.searchParams.get('${name}') === 'true' : undefined,`);
|
|
2740
|
+
}
|
|
2741
|
+
} else {
|
|
2742
|
+
if (hasDefault) {
|
|
2743
|
+
code.line(`${name}: url.searchParams.get('${name}') ?? ${defaultVal},`);
|
|
2744
|
+
} else {
|
|
2745
|
+
code.line(`${name}: url.searchParams.get('${name}') ?? undefined,`);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
function generateEndpointResolvers(endpoints) {
|
|
2750
|
+
const code = new CodeBuilder();
|
|
2751
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2752
|
+
code.comment("Mock resolvers for custom endpoints");
|
|
2753
|
+
code.comment("");
|
|
2754
|
+
code.comment("These resolvers are copied from your defineEndpoint() calls.");
|
|
2755
|
+
code.comment("They receive { params, body, db, headers } and return the response.");
|
|
2756
|
+
code.line();
|
|
2757
|
+
code.line("import type { MockResolverContext } from 'schemock/schema';");
|
|
2758
|
+
code.line();
|
|
2759
|
+
code.line("type ResolverFn = (ctx: MockResolverContext) => unknown | Promise<unknown>;");
|
|
2760
|
+
code.line();
|
|
2761
|
+
code.block("export const endpointResolvers: Record<string, ResolverFn> = {", () => {
|
|
2762
|
+
for (const endpoint of endpoints) {
|
|
2763
|
+
code.comment(`${endpoint.method} ${endpoint.path}`);
|
|
2764
|
+
if (endpoint.description) {
|
|
2765
|
+
code.comment(endpoint.description);
|
|
2766
|
+
}
|
|
2767
|
+
code.line(`${endpoint.name}: ${endpoint.mockResolverSource},`);
|
|
2768
|
+
code.line();
|
|
2769
|
+
}
|
|
2770
|
+
}, "};");
|
|
2771
|
+
return code.toString();
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
// src/cli/generators/supabase/client.ts
|
|
2775
|
+
function generateSupabaseClient(schemas, config) {
|
|
2776
|
+
const code = new CodeBuilder();
|
|
2777
|
+
const envPrefix = config.envPrefix ?? "NEXT_PUBLIC_SUPABASE";
|
|
2778
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2779
|
+
code.line("import { createClient } from '@supabase/supabase-js';");
|
|
2780
|
+
code.line("import type * as Types from './types';");
|
|
2781
|
+
code.line();
|
|
2782
|
+
code.line(`const supabaseUrl = process.env.${envPrefix}_URL!;`);
|
|
2783
|
+
code.line(`const supabaseKey = process.env.${envPrefix}_ANON_KEY!;`);
|
|
2784
|
+
code.line();
|
|
2785
|
+
code.line("export const supabase = createClient(supabaseUrl, supabaseKey);");
|
|
2786
|
+
code.line();
|
|
2787
|
+
code.block("function buildSelect(include?: string[]): string {", () => {
|
|
2788
|
+
code.line("if (!include?.length) return '*';");
|
|
2789
|
+
code.line("return `*, ${include.map(rel => `${rel}(*)`).join(', ')}`;");
|
|
2790
|
+
});
|
|
2791
|
+
code.line();
|
|
2792
|
+
const schemaMap = new Map(schemas.map((s) => [s.name, s]));
|
|
2793
|
+
code.block("export const api = {", () => {
|
|
2794
|
+
for (const schema of schemas) {
|
|
2795
|
+
if (schema.isJunctionTable) continue;
|
|
2796
|
+
generateSupabaseEntityApi(code, schema, config, schemaMap);
|
|
2797
|
+
}
|
|
2798
|
+
}, "};");
|
|
2799
|
+
return code.toString();
|
|
2800
|
+
}
|
|
2801
|
+
function generateSupabaseEntityApi(code, schema, config, schemaMap) {
|
|
2802
|
+
const { name, pascalName, tableName, relations } = schema;
|
|
2803
|
+
const hasRelations = relations.length > 0;
|
|
2804
|
+
const includeType = hasRelations ? `Types.${pascalName}Include` : "never";
|
|
2805
|
+
code.block(`${name}: {`, () => {
|
|
2806
|
+
code.block(
|
|
2807
|
+
`list: async (options?: Types.QueryOptions<Types.${pascalName}Filter, ${includeType}>): Promise<Types.ListResponse<Types.${pascalName}>> => {`,
|
|
2808
|
+
() => {
|
|
2809
|
+
code.line("const select = buildSelect(options?.include);");
|
|
2810
|
+
code.line(`let query = supabase.from('${tableName}').select(select, { count: 'exact' });`);
|
|
2811
|
+
code.line();
|
|
2812
|
+
code.block("if (options?.where) {", () => {
|
|
2813
|
+
code.block("for (const [key, value] of Object.entries(options.where)) {", () => {
|
|
2814
|
+
code.block('if (typeof value === "object" && value !== null) {', () => {
|
|
2815
|
+
code.line("const f = value as Record<string, unknown>;");
|
|
2816
|
+
code.line("if ('equals' in f) query = query.eq(key, f.equals);");
|
|
2817
|
+
code.line("if ('not' in f) query = query.neq(key, f.not);");
|
|
2818
|
+
code.line("if ('in' in f) query = query.in(key, f.in as unknown[]);");
|
|
2819
|
+
code.line("if ('notIn' in f) query = query.not(key, 'in', `(${(f.notIn as unknown[]).join(',')})`);");
|
|
2820
|
+
code.line("if ('contains' in f) query = query.ilike(key, `%${f.contains}%`);");
|
|
2821
|
+
code.line("if ('startsWith' in f) query = query.ilike(key, `${f.startsWith}%`);");
|
|
2822
|
+
code.line("if ('endsWith' in f) query = query.ilike(key, `%${f.endsWith}`);");
|
|
2823
|
+
code.line("if ('gt' in f) query = query.gt(key, f.gt);");
|
|
2824
|
+
code.line("if ('gte' in f) query = query.gte(key, f.gte);");
|
|
2825
|
+
code.line("if ('lt' in f) query = query.lt(key, f.lt);");
|
|
2826
|
+
code.line("if ('lte' in f) query = query.lte(key, f.lte);");
|
|
2827
|
+
code.line("if ('isNull' in f) f.isNull ? query = query.is(key, null) : query = query.not(key, 'is', null);");
|
|
2828
|
+
}, "} else {");
|
|
2829
|
+
code.indent();
|
|
2830
|
+
code.line("query = query.eq(key, value);");
|
|
2831
|
+
code.dedent();
|
|
2832
|
+
code.line("}");
|
|
2833
|
+
});
|
|
2834
|
+
});
|
|
2835
|
+
code.line();
|
|
2836
|
+
code.block("if (options?.orderBy) {", () => {
|
|
2837
|
+
code.block("for (const [field, dir] of Object.entries(options.orderBy)) {", () => {
|
|
2838
|
+
code.line("query = query.order(field, { ascending: dir === 'asc' });");
|
|
2839
|
+
});
|
|
2840
|
+
});
|
|
2841
|
+
code.line();
|
|
2842
|
+
code.line("const limit = options?.limit ?? 20;");
|
|
2843
|
+
code.line("const offset = options?.offset ?? 0;");
|
|
2844
|
+
code.line("query = query.range(offset, offset + limit - 1);");
|
|
2845
|
+
code.line();
|
|
2846
|
+
code.line("const { data, error, count } = await query;");
|
|
2847
|
+
code.line("if (error) throw error;");
|
|
2848
|
+
code.line();
|
|
2849
|
+
code.line("return {");
|
|
2850
|
+
code.line(` data: (data || []) as Types.${pascalName}[],`);
|
|
2851
|
+
code.line(" meta: { total: count || 0, limit, offset, hasMore: offset + limit < (count || 0) },");
|
|
2852
|
+
code.line("};");
|
|
2853
|
+
},
|
|
2854
|
+
"},"
|
|
2855
|
+
);
|
|
2856
|
+
code.line();
|
|
2857
|
+
code.block(`get: async (id: string, options?: { include?: ${includeType}[] }): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
2858
|
+
code.line("const select = buildSelect(options?.include);");
|
|
2859
|
+
code.line(`const { data, error } = await supabase.from('${tableName}').select(select).eq('id', id).single();`);
|
|
2860
|
+
code.line("if (error) throw error;");
|
|
2861
|
+
code.line(`return { data: data as Types.${pascalName} };`);
|
|
2862
|
+
}, "},");
|
|
2863
|
+
code.line();
|
|
2864
|
+
generateSupabaseCreateMethod(code, schema, config, schemaMap);
|
|
2865
|
+
code.line();
|
|
2866
|
+
code.block(`update: async (id: string, input: Types.${pascalName}Update): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
2867
|
+
code.line(`const { data, error } = await supabase.from('${tableName}').update(input).eq('id', id).select().single();`);
|
|
2868
|
+
code.line("if (error) throw error;");
|
|
2869
|
+
code.line(`return { data: data as Types.${pascalName} };`);
|
|
2870
|
+
}, "},");
|
|
2871
|
+
code.line();
|
|
2872
|
+
code.block("delete: async (id: string): Promise<void> => {", () => {
|
|
2873
|
+
code.line(`const { error } = await supabase.from('${tableName}').delete().eq('id', id);`);
|
|
2874
|
+
code.line("if (error) throw error;");
|
|
2875
|
+
}, "},");
|
|
2876
|
+
}, "},");
|
|
2877
|
+
code.line();
|
|
2878
|
+
}
|
|
2879
|
+
function generateSupabaseCreateMethod(code, schema, config, schemaMap) {
|
|
2880
|
+
const { pascalName, tableName, relations } = schema;
|
|
2881
|
+
const nestedRels = relations.filter((r) => r.type === "hasMany" || r.type === "hasOne");
|
|
2882
|
+
code.block(`create: async (input: Types.${pascalName}Create): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
2883
|
+
if (nestedRels.length > 0) {
|
|
2884
|
+
const relNames = nestedRels.map((r) => r.name).join(", ");
|
|
2885
|
+
code.line(`const { ${relNames}, ...data } = input;`);
|
|
2886
|
+
code.line();
|
|
2887
|
+
code.line(`const { data: item, error } = await supabase.from('${tableName}').insert(data).select().single();`);
|
|
2888
|
+
code.line("if (error) throw error;");
|
|
2889
|
+
code.line();
|
|
2890
|
+
for (const rel of nestedRels) {
|
|
2891
|
+
const targetSchema = schemaMap.get(rel.target);
|
|
2892
|
+
const targetTable = config.tableMap?.[rel.target] ?? targetSchema?.tableName ?? rel.target + "s";
|
|
2893
|
+
code.block(`if (${rel.name}) {`, () => {
|
|
2894
|
+
if (rel.type === "hasMany") {
|
|
2895
|
+
code.line(`const nestedData = ${rel.name}.map(nested => ({ ...nested, ${rel.foreignKey}: item.id }));`);
|
|
2896
|
+
code.line(`const { error: nestedError } = await supabase.from('${targetTable}').insert(nestedData);`);
|
|
2897
|
+
code.line("if (nestedError) throw nestedError;");
|
|
2898
|
+
} else {
|
|
2899
|
+
code.line(`const { error: nestedError } = await supabase.from('${targetTable}').insert({ ...${rel.name}, ${rel.foreignKey}: item.id });`);
|
|
2900
|
+
code.line("if (nestedError) throw nestedError;");
|
|
2901
|
+
}
|
|
2902
|
+
});
|
|
2903
|
+
}
|
|
2904
|
+
code.line();
|
|
2905
|
+
code.line(`return { data: item as Types.${pascalName} };`);
|
|
2906
|
+
} else {
|
|
2907
|
+
code.line(`const { data, error } = await supabase.from('${tableName}').insert(input).select().single();`);
|
|
2908
|
+
code.line("if (error) throw error;");
|
|
2909
|
+
code.line(`return { data: data as Types.${pascalName} };`);
|
|
2910
|
+
}
|
|
2911
|
+
}, "},");
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
// src/cli/generators/firebase/client.ts
|
|
2915
|
+
function generateFirebaseClient(schemas, config) {
|
|
2916
|
+
const code = new CodeBuilder();
|
|
2917
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
2918
|
+
code.line("import {");
|
|
2919
|
+
code.line(" collection,");
|
|
2920
|
+
code.line(" doc,");
|
|
2921
|
+
code.line(" getDoc,");
|
|
2922
|
+
code.line(" getDocs,");
|
|
2923
|
+
code.line(" addDoc,");
|
|
2924
|
+
code.line(" updateDoc,");
|
|
2925
|
+
code.line(" deleteDoc,");
|
|
2926
|
+
code.line(" query,");
|
|
2927
|
+
code.line(" where,");
|
|
2928
|
+
code.line(" orderBy,");
|
|
2929
|
+
code.line(" limit as fbLimit,");
|
|
2930
|
+
code.line("} from 'firebase/firestore';");
|
|
2931
|
+
code.line("import type * as Types from './types';");
|
|
2932
|
+
code.line();
|
|
2933
|
+
code.comment("Import your Firebase instance");
|
|
2934
|
+
code.line("import { db as firestore } from '../lib/firebase';");
|
|
2935
|
+
code.line();
|
|
2936
|
+
code.block("export const api = {", () => {
|
|
2937
|
+
for (const schema of schemas) {
|
|
2938
|
+
if (schema.isJunctionTable) continue;
|
|
2939
|
+
generateFirebaseEntityApi(code, schema, config, schemas);
|
|
2940
|
+
}
|
|
2941
|
+
}, "};");
|
|
2942
|
+
return code.toString();
|
|
2943
|
+
}
|
|
2944
|
+
function generateFirebaseEntityApi(code, schema, config, allSchemas) {
|
|
2945
|
+
const { name, pascalName, relations } = schema;
|
|
2946
|
+
const collectionName = config.collectionMap?.[name] ?? schema.pluralName;
|
|
2947
|
+
const hasRelations = relations.length > 0;
|
|
2948
|
+
const includeType = hasRelations ? `Types.${pascalName}Include` : "never";
|
|
2949
|
+
new Map(allSchemas.map((s) => [s.name, s]));
|
|
2950
|
+
code.block(`${name}: {`, () => {
|
|
2951
|
+
code.block(
|
|
2952
|
+
`list: async (options?: Types.QueryOptions<Types.${pascalName}Filter, ${includeType}>): Promise<Types.ListResponse<Types.${pascalName}>> => {`,
|
|
2953
|
+
() => {
|
|
2954
|
+
code.line(`let q = query(collection(firestore, '${collectionName}'));`);
|
|
2955
|
+
code.line();
|
|
2956
|
+
code.block("if (options?.where) {", () => {
|
|
2957
|
+
code.block("for (const [key, value] of Object.entries(options.where)) {", () => {
|
|
2958
|
+
code.block('if (typeof value === "object" && value !== null) {', () => {
|
|
2959
|
+
code.line("const f = value as Record<string, unknown>;");
|
|
2960
|
+
code.line("if ('equals' in f) q = query(q, where(key, '==', f.equals));");
|
|
2961
|
+
code.line("if ('not' in f) q = query(q, where(key, '!=', f.not));");
|
|
2962
|
+
code.line("if ('gt' in f) q = query(q, where(key, '>', f.gt));");
|
|
2963
|
+
code.line("if ('gte' in f) q = query(q, where(key, '>=', f.gte));");
|
|
2964
|
+
code.line("if ('lt' in f) q = query(q, where(key, '<', f.lt));");
|
|
2965
|
+
code.line("if ('lte' in f) q = query(q, where(key, '<=', f.lte));");
|
|
2966
|
+
code.line("if ('in' in f) q = query(q, where(key, 'in', f.in));");
|
|
2967
|
+
code.line("if ('notIn' in f) q = query(q, where(key, 'not-in', f.notIn));");
|
|
2968
|
+
code.comment("Note: contains/startsWith/endsWith require compound indexes in Firebase");
|
|
2969
|
+
code.line("if ('contains' in f) q = query(q, where(key, '>=', f.contains), where(key, '<=', f.contains + '\\uf8ff'));");
|
|
2970
|
+
code.line("if ('startsWith' in f) q = query(q, where(key, '>=', f.startsWith), where(key, '<=', f.startsWith + '\\uf8ff'));");
|
|
2971
|
+
}, "} else {");
|
|
2972
|
+
code.indent();
|
|
2973
|
+
code.line("q = query(q, where(key, '==', value));");
|
|
2974
|
+
code.dedent();
|
|
2975
|
+
code.line("}");
|
|
2976
|
+
});
|
|
2977
|
+
});
|
|
2978
|
+
code.line();
|
|
2979
|
+
code.block("if (options?.orderBy) {", () => {
|
|
2980
|
+
code.block("for (const [field, dir] of Object.entries(options.orderBy)) {", () => {
|
|
2981
|
+
code.line("q = query(q, orderBy(field, dir as 'asc' | 'desc'));");
|
|
2982
|
+
});
|
|
2983
|
+
});
|
|
2984
|
+
code.line();
|
|
2985
|
+
code.line("const queryLimit = options?.limit ?? 20;");
|
|
2986
|
+
code.line("const queryOffset = options?.offset ?? 0;");
|
|
2987
|
+
code.comment("Fetch limit + offset to enable client-side offset");
|
|
2988
|
+
code.line("q = query(q, fbLimit(queryLimit + queryOffset));");
|
|
2989
|
+
code.line();
|
|
2990
|
+
code.line("const snapshot = await getDocs(q);");
|
|
2991
|
+
code.line(`let allItems = snapshot.docs.map(d => ({ id: d.id, ...d.data() })) as Types.${pascalName}[];`);
|
|
2992
|
+
code.line("const total = allItems.length;");
|
|
2993
|
+
code.comment("Apply client-side offset");
|
|
2994
|
+
code.line("let items = allItems.slice(queryOffset, queryOffset + queryLimit);");
|
|
2995
|
+
code.line();
|
|
2996
|
+
if (hasRelations) {
|
|
2997
|
+
code.block("if (options?.include?.length) {", () => {
|
|
2998
|
+
code.block("items = await Promise.all(items.map(async (item) => {", () => {
|
|
2999
|
+
code.line("const result = { ...item } as Record<string, unknown>;");
|
|
3000
|
+
for (const rel of relations) {
|
|
3001
|
+
code.block(`if (options.include!.includes('${rel.name}')) {`, () => {
|
|
3002
|
+
generateFirebaseRelationLoad(code, schema, rel, config);
|
|
3003
|
+
});
|
|
3004
|
+
}
|
|
3005
|
+
code.line(`return result as Types.${pascalName};`);
|
|
3006
|
+
}, "}));");
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
code.line();
|
|
3010
|
+
code.line("return {");
|
|
3011
|
+
code.line(" data: items,");
|
|
3012
|
+
code.line(" meta: { total, limit: queryLimit, offset: queryOffset, hasMore: queryOffset + queryLimit < total },");
|
|
3013
|
+
code.line("};");
|
|
3014
|
+
},
|
|
3015
|
+
"},"
|
|
3016
|
+
);
|
|
3017
|
+
code.line();
|
|
3018
|
+
code.block(`get: async (id: string, options?: { include?: ${includeType}[] }): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
3019
|
+
code.line(`const docRef = doc(firestore, '${collectionName}', id);`);
|
|
3020
|
+
code.line("const snapshot = await getDoc(docRef);");
|
|
3021
|
+
code.line(`if (!snapshot.exists()) throw new Error('${pascalName} not found');`);
|
|
3022
|
+
code.line(`let item = { id: snapshot.id, ...snapshot.data() } as Types.${pascalName};`);
|
|
3023
|
+
if (hasRelations) {
|
|
3024
|
+
code.line();
|
|
3025
|
+
code.block("if (options?.include?.length) {", () => {
|
|
3026
|
+
code.line("const result = { ...item } as Record<string, unknown>;");
|
|
3027
|
+
for (const rel of relations) {
|
|
3028
|
+
code.block(`if (options.include.includes('${rel.name}')) {`, () => {
|
|
3029
|
+
generateFirebaseRelationLoad(code, schema, rel, config);
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
3032
|
+
code.line(`item = result as Types.${pascalName};`);
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
code.line();
|
|
3036
|
+
code.line("return { data: item };");
|
|
3037
|
+
}, "},");
|
|
3038
|
+
code.line();
|
|
3039
|
+
generateFirebaseCreateMethod(code, schema, collectionName, config, allSchemas);
|
|
3040
|
+
code.line();
|
|
3041
|
+
code.block(`update: async (id: string, input: Types.${pascalName}Update): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
3042
|
+
code.line(`const docRef = doc(firestore, '${collectionName}', id);`);
|
|
3043
|
+
code.line("await updateDoc(docRef, { ...input, updatedAt: new Date() });");
|
|
3044
|
+
code.line("const snapshot = await getDoc(docRef);");
|
|
3045
|
+
code.line(`return { data: { id: snapshot.id, ...snapshot.data() } as Types.${pascalName} };`);
|
|
3046
|
+
}, "},");
|
|
3047
|
+
code.line();
|
|
3048
|
+
code.block("delete: async (id: string): Promise<void> => {", () => {
|
|
3049
|
+
code.line(`await deleteDoc(doc(firestore, '${collectionName}', id));`);
|
|
3050
|
+
}, "},");
|
|
3051
|
+
}, "},");
|
|
3052
|
+
code.line();
|
|
3053
|
+
}
|
|
3054
|
+
function generateFirebaseCreateMethod(code, schema, collectionName, config, allSchemas) {
|
|
3055
|
+
const { pascalName, relations } = schema;
|
|
3056
|
+
const nestedRels = relations.filter((r) => r.type === "hasMany" || r.type === "hasOne");
|
|
3057
|
+
const schemaMap = new Map(allSchemas.map((s) => [s.name, s]));
|
|
3058
|
+
const getTargetCollection = (targetName) => {
|
|
3059
|
+
const targetSchema = schemaMap.get(targetName);
|
|
3060
|
+
return config.collectionMap?.[targetName] ?? targetSchema?.pluralName ?? targetName + "s";
|
|
3061
|
+
};
|
|
3062
|
+
code.block(`create: async (input: Types.${pascalName}Create): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
3063
|
+
if (nestedRels.length > 0) {
|
|
3064
|
+
const relNames = nestedRels.map((r) => r.name).join(", ");
|
|
3065
|
+
code.line(`const { ${relNames}, ...data } = input;`);
|
|
3066
|
+
code.line("const withTimestamps = { ...data, createdAt: new Date(), updatedAt: new Date() };");
|
|
3067
|
+
code.line(`const docRef = await addDoc(collection(firestore, '${collectionName}'), withTimestamps);`);
|
|
3068
|
+
code.line(`const item = { id: docRef.id, ...withTimestamps } as Types.${pascalName};`);
|
|
3069
|
+
code.line();
|
|
3070
|
+
for (const rel of nestedRels) {
|
|
3071
|
+
const targetCollection = getTargetCollection(rel.target);
|
|
3072
|
+
code.block(`if (${rel.name}) {`, () => {
|
|
3073
|
+
if (rel.type === "hasMany") {
|
|
3074
|
+
code.block(`for (const nested of ${rel.name}) {`, () => {
|
|
3075
|
+
code.line(`const nestedData = { ...nested, ${rel.foreignKey}: item.id, createdAt: new Date(), updatedAt: new Date() };`);
|
|
3076
|
+
code.line(`await addDoc(collection(firestore, '${targetCollection}'), nestedData);`);
|
|
3077
|
+
});
|
|
3078
|
+
} else {
|
|
3079
|
+
code.line(`const nestedData = { ...${rel.name}, ${rel.foreignKey}: item.id, createdAt: new Date(), updatedAt: new Date() };`);
|
|
3080
|
+
code.line(`await addDoc(collection(firestore, '${targetCollection}'), nestedData);`);
|
|
3081
|
+
}
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
code.line();
|
|
3085
|
+
code.line("return { data: item };");
|
|
3086
|
+
} else {
|
|
3087
|
+
code.line("const data = { ...input, createdAt: new Date(), updatedAt: new Date() };");
|
|
3088
|
+
code.line(`const docRef = await addDoc(collection(firestore, '${collectionName}'), data);`);
|
|
3089
|
+
code.line(`return { data: { id: docRef.id, ...data } as Types.${pascalName} };`);
|
|
3090
|
+
}
|
|
3091
|
+
}, "},");
|
|
3092
|
+
}
|
|
3093
|
+
function generateFirebaseRelationLoad(code, schema, rel, config, allSchemas) {
|
|
3094
|
+
const schemaMap = /* @__PURE__ */ new Map();
|
|
3095
|
+
const getCollection = (targetName) => {
|
|
3096
|
+
const targetSchema = schemaMap.get(targetName);
|
|
3097
|
+
return config.collectionMap?.[targetName] ?? targetSchema?.pluralName ?? targetName + "s";
|
|
3098
|
+
};
|
|
3099
|
+
const targetCollection = getCollection(rel.target);
|
|
3100
|
+
if (rel.type === "hasMany") {
|
|
3101
|
+
code.line(`const ${rel.name}Query = query(`);
|
|
3102
|
+
code.line(` collection(firestore, '${targetCollection}'),`);
|
|
3103
|
+
code.line(` where('${rel.foreignKey}', '==', item.id)`);
|
|
3104
|
+
code.line(");");
|
|
3105
|
+
code.line(`const ${rel.name}Snapshot = await getDocs(${rel.name}Query);`);
|
|
3106
|
+
code.line(`result.${rel.name} = ${rel.name}Snapshot.docs.map(d => ({ id: d.id, ...d.data() }));`);
|
|
3107
|
+
} else if (rel.type === "hasOne") {
|
|
3108
|
+
code.line(`const ${rel.name}Query = query(`);
|
|
3109
|
+
code.line(` collection(firestore, '${targetCollection}'),`);
|
|
3110
|
+
code.line(` where('${rel.foreignKey}', '==', item.id),`);
|
|
3111
|
+
code.line(" fbLimit(1)");
|
|
3112
|
+
code.line(");");
|
|
3113
|
+
code.line(`const ${rel.name}Snapshot = await getDocs(${rel.name}Query);`);
|
|
3114
|
+
code.line(`result.${rel.name} = ${rel.name}Snapshot.docs[0] ? { id: ${rel.name}Snapshot.docs[0].id, ...${rel.name}Snapshot.docs[0].data() } : null;`);
|
|
3115
|
+
} else if (rel.type === "belongsTo") {
|
|
3116
|
+
code.line(`const ${rel.name}Ref = doc(firestore, '${targetCollection}', (item as Record<string, unknown>).${rel.localField} as string);`);
|
|
3117
|
+
code.line(`const ${rel.name}Snapshot = await getDoc(${rel.name}Ref);`);
|
|
3118
|
+
code.line(`result.${rel.name} = ${rel.name}Snapshot.exists() ? { id: ${rel.name}Snapshot.id, ...${rel.name}Snapshot.data() } : null;`);
|
|
3119
|
+
} else if (rel.type === "manyToMany" && rel.through) {
|
|
3120
|
+
const throughCollection = getCollection(rel.through);
|
|
3121
|
+
code.comment("Load manyToMany via junction collection");
|
|
3122
|
+
code.line(`const junctionQuery = query(`);
|
|
3123
|
+
code.line(` collection(firestore, '${throughCollection}'),`);
|
|
3124
|
+
code.line(` where('${rel.foreignKey}', '==', item.id)`);
|
|
3125
|
+
code.line(");");
|
|
3126
|
+
code.line(`const junctionSnapshot = await getDocs(junctionQuery);`);
|
|
3127
|
+
code.line(`const relatedIds = junctionSnapshot.docs.map(d => (d.data() as Record<string, unknown>).${rel.otherKey}) as string[];`);
|
|
3128
|
+
code.line();
|
|
3129
|
+
code.block("if (relatedIds.length > 0) {", () => {
|
|
3130
|
+
code.comment("Firebase in query limited to 10 items, batch if needed");
|
|
3131
|
+
code.line(`const ${rel.name}Results: unknown[] = [];`);
|
|
3132
|
+
code.block("for (let i = 0; i < relatedIds.length; i += 10) {", () => {
|
|
3133
|
+
code.line("const batch = relatedIds.slice(i, i + 10);");
|
|
3134
|
+
code.line(`const batchQuery = query(`);
|
|
3135
|
+
code.line(` collection(firestore, '${targetCollection}'),`);
|
|
3136
|
+
code.line(" where('__name__', 'in', batch)");
|
|
3137
|
+
code.line(");");
|
|
3138
|
+
code.line("const batchSnapshot = await getDocs(batchQuery);");
|
|
3139
|
+
code.line(`${rel.name}Results.push(...batchSnapshot.docs.map(d => ({ id: d.id, ...d.data() })));`);
|
|
3140
|
+
});
|
|
3141
|
+
code.line(`result.${rel.name} = ${rel.name}Results;`);
|
|
3142
|
+
}, "} else {");
|
|
3143
|
+
code.indent();
|
|
3144
|
+
code.line(`result.${rel.name} = [];`);
|
|
3145
|
+
code.dedent();
|
|
3146
|
+
code.line("}");
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
// src/cli/generators/fetch/client.ts
|
|
3151
|
+
function generateFetchClient(schemas, config) {
|
|
3152
|
+
const code = new CodeBuilder();
|
|
3153
|
+
const baseUrl = config.baseUrl ?? "";
|
|
3154
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
3155
|
+
code.line("import type * as Types from './types';");
|
|
3156
|
+
code.line();
|
|
3157
|
+
code.line(`const BASE_URL = '${baseUrl}';`);
|
|
3158
|
+
code.line();
|
|
3159
|
+
code.block("async function request<T>(path: string, options?: RequestInit): Promise<T> {", () => {
|
|
3160
|
+
code.line("const response = await fetch(`${BASE_URL}${path}`, {");
|
|
3161
|
+
code.line(" headers: { 'Content-Type': 'application/json', ...options?.headers },");
|
|
3162
|
+
code.line(" ...options,");
|
|
3163
|
+
code.line("});");
|
|
3164
|
+
code.line("if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);");
|
|
3165
|
+
code.line("if (response.status === 204) return undefined as T;");
|
|
3166
|
+
code.line("return response.json();");
|
|
3167
|
+
});
|
|
3168
|
+
code.line();
|
|
3169
|
+
code.block("function buildQuery(options?: Record<string, unknown>): string {", () => {
|
|
3170
|
+
code.line("if (!options) return '';");
|
|
3171
|
+
code.line("const params = new URLSearchParams();");
|
|
3172
|
+
code.block("for (const [key, value] of Object.entries(options)) {", () => {
|
|
3173
|
+
code.line("if (value !== undefined) {");
|
|
3174
|
+
code.line(" params.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value));");
|
|
3175
|
+
code.line("}");
|
|
3176
|
+
});
|
|
3177
|
+
code.line("const str = params.toString();");
|
|
3178
|
+
code.line("return str ? `?${str}` : '';");
|
|
3179
|
+
});
|
|
3180
|
+
code.line();
|
|
3181
|
+
code.block("export const api = {", () => {
|
|
3182
|
+
for (const schema of schemas) {
|
|
3183
|
+
if (schema.isJunctionTable) continue;
|
|
3184
|
+
generateFetchEntityApi(code, schema);
|
|
3185
|
+
}
|
|
3186
|
+
}, "};");
|
|
3187
|
+
return code.toString();
|
|
3188
|
+
}
|
|
3189
|
+
function generateFetchEntityApi(code, schema) {
|
|
3190
|
+
const { name, pascalName, endpoint, relations } = schema;
|
|
3191
|
+
const hasRelations = relations.length > 0;
|
|
3192
|
+
const includeType = hasRelations ? `Types.${pascalName}Include` : "never";
|
|
3193
|
+
code.block(`${name}: {`, () => {
|
|
3194
|
+
code.block(
|
|
3195
|
+
`list: async (options?: Types.QueryOptions<Types.${pascalName}Filter, ${includeType}>): Promise<Types.ListResponse<Types.${pascalName}>> => {`,
|
|
3196
|
+
() => {
|
|
3197
|
+
code.line(`return request('${endpoint}' + buildQuery(options));`);
|
|
3198
|
+
},
|
|
3199
|
+
"},"
|
|
3200
|
+
);
|
|
3201
|
+
code.line();
|
|
3202
|
+
code.block(`get: async (id: string, options?: { include?: ${includeType}[] }): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
3203
|
+
code.line(`return request(\`${endpoint}/\${id}\` + buildQuery(options));`);
|
|
3204
|
+
}, "},");
|
|
3205
|
+
code.line();
|
|
3206
|
+
code.block(`create: async (input: Types.${pascalName}Create): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
3207
|
+
code.line(`return request('${endpoint}', {`);
|
|
3208
|
+
code.line(" method: 'POST',");
|
|
3209
|
+
code.line(" body: JSON.stringify(input),");
|
|
3210
|
+
code.line("});");
|
|
3211
|
+
}, "},");
|
|
3212
|
+
code.line();
|
|
3213
|
+
code.block(`update: async (id: string, input: Types.${pascalName}Update): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
3214
|
+
code.line(`return request(\`${endpoint}/\${id}\`, {`);
|
|
3215
|
+
code.line(" method: 'PUT',");
|
|
3216
|
+
code.line(" body: JSON.stringify(input),");
|
|
3217
|
+
code.line("});");
|
|
3218
|
+
}, "},");
|
|
3219
|
+
code.line();
|
|
3220
|
+
code.block(`patch: async (id: string, input: Types.${pascalName}Update): Promise<Types.ItemResponse<Types.${pascalName}>> => {`, () => {
|
|
3221
|
+
code.line(`return request(\`${endpoint}/\${id}\`, {`);
|
|
3222
|
+
code.line(" method: 'PATCH',");
|
|
3223
|
+
code.line(" body: JSON.stringify(input),");
|
|
3224
|
+
code.line("});");
|
|
3225
|
+
}, "},");
|
|
3226
|
+
code.line();
|
|
3227
|
+
code.block("delete: async (id: string): Promise<void> => {", () => {
|
|
3228
|
+
code.line(`await request(\`${endpoint}/\${id}\`, { method: 'DELETE' });`);
|
|
3229
|
+
}, "},");
|
|
3230
|
+
}, "},");
|
|
3231
|
+
code.line();
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
// src/cli/generators/pglite/db.ts
|
|
3235
|
+
function mapToPostgresType(field) {
|
|
3236
|
+
if (field.name === "id") return "UUID PRIMARY KEY DEFAULT gen_random_uuid()";
|
|
3237
|
+
if (field.isEnum && field.enumValues?.length) {
|
|
3238
|
+
const enumValues = field.enumValues.map((v) => `'${v}'`).join(", ");
|
|
3239
|
+
return `TEXT CHECK (${field.name} IN (${enumValues}))`;
|
|
3240
|
+
}
|
|
3241
|
+
if (field.isArray || field.isObject) {
|
|
3242
|
+
return field.nullable ? "JSONB" : "JSONB NOT NULL";
|
|
3243
|
+
}
|
|
3244
|
+
if (field.isRef) {
|
|
3245
|
+
return field.nullable ? "UUID" : "UUID NOT NULL";
|
|
3246
|
+
}
|
|
3247
|
+
let pgType;
|
|
3248
|
+
switch (field.type) {
|
|
3249
|
+
case "uuid":
|
|
3250
|
+
pgType = "UUID";
|
|
3251
|
+
break;
|
|
3252
|
+
case "string":
|
|
3253
|
+
case "text":
|
|
3254
|
+
pgType = "TEXT";
|
|
3255
|
+
break;
|
|
3256
|
+
case "email":
|
|
3257
|
+
pgType = "TEXT";
|
|
3258
|
+
break;
|
|
3259
|
+
case "url":
|
|
3260
|
+
pgType = "TEXT";
|
|
3261
|
+
break;
|
|
3262
|
+
case "number":
|
|
3263
|
+
case "float":
|
|
3264
|
+
pgType = "DOUBLE PRECISION";
|
|
3265
|
+
break;
|
|
3266
|
+
case "int":
|
|
3267
|
+
case "integer":
|
|
3268
|
+
pgType = "INTEGER";
|
|
3269
|
+
break;
|
|
3270
|
+
case "boolean":
|
|
3271
|
+
pgType = "BOOLEAN";
|
|
3272
|
+
break;
|
|
3273
|
+
case "date":
|
|
3274
|
+
case "datetime":
|
|
3275
|
+
pgType = "TIMESTAMPTZ";
|
|
3276
|
+
break;
|
|
3277
|
+
case "json":
|
|
3278
|
+
pgType = "JSONB";
|
|
3279
|
+
break;
|
|
3280
|
+
default:
|
|
3281
|
+
pgType = "TEXT";
|
|
3282
|
+
}
|
|
3283
|
+
const constraints = [];
|
|
3284
|
+
if (!field.nullable && field.name !== "id") {
|
|
3285
|
+
constraints.push("NOT NULL");
|
|
3286
|
+
}
|
|
3287
|
+
if (field.unique) {
|
|
3288
|
+
constraints.push("UNIQUE");
|
|
3289
|
+
}
|
|
3290
|
+
if (field.hasDefault && field.defaultValue !== void 0) {
|
|
3291
|
+
const defaultVal = formatDefaultValue(field);
|
|
3292
|
+
if (defaultVal) {
|
|
3293
|
+
constraints.push(`DEFAULT ${defaultVal}`);
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
return constraints.length > 0 ? `${pgType} ${constraints.join(" ")}` : pgType;
|
|
3297
|
+
}
|
|
3298
|
+
function formatDefaultValue(field) {
|
|
3299
|
+
const val = field.defaultValue;
|
|
3300
|
+
if (val === null) return "NULL";
|
|
3301
|
+
if (typeof val === "boolean") return val ? "TRUE" : "FALSE";
|
|
3302
|
+
if (typeof val === "number") return String(val);
|
|
3303
|
+
if (typeof val === "string") return `'${val.replace(/'/g, "''")}'`;
|
|
3304
|
+
if (val instanceof Date) return `'${val.toISOString()}'`;
|
|
3305
|
+
if (typeof val === "object") return `'${JSON.stringify(val).replace(/'/g, "''")}'::jsonb`;
|
|
3306
|
+
return null;
|
|
3307
|
+
}
|
|
3308
|
+
function generateCreateTable(schema) {
|
|
3309
|
+
const lines = [`CREATE TABLE IF NOT EXISTS "${schema.tableName}" (`];
|
|
3310
|
+
const columns = [];
|
|
3311
|
+
for (const field of schema.fields) {
|
|
3312
|
+
const pgType = mapToPostgresType(field);
|
|
3313
|
+
columns.push(` "${field.name}" ${pgType}`);
|
|
3314
|
+
}
|
|
3315
|
+
lines.push(columns.join(",\n"));
|
|
3316
|
+
lines.push(");");
|
|
3317
|
+
return lines.join("\n");
|
|
3318
|
+
}
|
|
3319
|
+
function generateForeignKeys(schema, allSchemas) {
|
|
3320
|
+
const fks = [];
|
|
3321
|
+
for (const field of schema.fields) {
|
|
3322
|
+
if (field.isRef && field.refTarget) {
|
|
3323
|
+
const targetSchema = allSchemas.find((s) => s.name === field.refTarget);
|
|
3324
|
+
if (targetSchema) {
|
|
3325
|
+
fks.push(
|
|
3326
|
+
`ALTER TABLE "${schema.tableName}" ADD CONSTRAINT "fk_${schema.tableName}_${field.name}" FOREIGN KEY ("${field.name}") REFERENCES "${targetSchema.tableName}"("id") ON DELETE CASCADE;`
|
|
3327
|
+
);
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
return fks;
|
|
3332
|
+
}
|
|
3333
|
+
function generateIndexes(schema) {
|
|
3334
|
+
const indexes = [];
|
|
3335
|
+
for (const field of schema.fields) {
|
|
3336
|
+
if (field.isRef) {
|
|
3337
|
+
indexes.push(
|
|
3338
|
+
`CREATE INDEX IF NOT EXISTS "idx_${schema.tableName}_${field.name}" ON "${schema.tableName}"("${field.name}");`
|
|
3339
|
+
);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
return indexes;
|
|
3343
|
+
}
|
|
3344
|
+
function generateRLSPolicies(schema) {
|
|
3345
|
+
const { tableName, rls } = schema;
|
|
3346
|
+
const policies = [];
|
|
3347
|
+
if (!rls.enabled) {
|
|
3348
|
+
return policies;
|
|
3349
|
+
}
|
|
3350
|
+
policies.push(`ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY;`);
|
|
3351
|
+
policies.push(`ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY;`);
|
|
3352
|
+
const operations = ["SELECT", "INSERT", "UPDATE", "DELETE"];
|
|
3353
|
+
const opHas = {
|
|
3354
|
+
SELECT: rls.hasSelect,
|
|
3355
|
+
INSERT: rls.hasInsert,
|
|
3356
|
+
UPDATE: rls.hasUpdate,
|
|
3357
|
+
DELETE: rls.hasDelete
|
|
3358
|
+
};
|
|
3359
|
+
for (const op of operations) {
|
|
3360
|
+
if (!opHas[op]) continue;
|
|
3361
|
+
const opLower = op.toLowerCase();
|
|
3362
|
+
const customSql = rls.sql?.[opLower];
|
|
3363
|
+
if (customSql) {
|
|
3364
|
+
policies.push(
|
|
3365
|
+
`CREATE POLICY "${tableName}_${opLower}_policy" ON "${tableName}" FOR ${op} USING (${customSql});`
|
|
3366
|
+
);
|
|
3367
|
+
} else if (rls.scope.length > 0) {
|
|
3368
|
+
const conditions = [];
|
|
3369
|
+
for (const bypass of rls.bypass) {
|
|
3370
|
+
const valuesStr = bypass.values.map((v) => `'${v}'`).join(", ");
|
|
3371
|
+
conditions.push(`current_setting('app.${bypass.contextKey}', true) IN (${valuesStr})`);
|
|
3372
|
+
}
|
|
3373
|
+
for (const mapping of rls.scope) {
|
|
3374
|
+
conditions.push(`"${mapping.field}" = current_setting('app.${mapping.contextKey}', true)::uuid`);
|
|
3375
|
+
}
|
|
3376
|
+
const condition = conditions.length > 1 ? conditions.map((c, i) => i === 0 && rls.bypass.length > 0 ? c : `(${c})`).join(" OR ") : conditions[0] || "true";
|
|
3377
|
+
policies.push(
|
|
3378
|
+
`CREATE POLICY "${tableName}_${opLower}_policy" ON "${tableName}" FOR ${op} USING (${condition});`
|
|
3379
|
+
);
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
return policies;
|
|
3383
|
+
}
|
|
3384
|
+
function generatePGliteDb(schemas, config) {
|
|
3385
|
+
const code = new CodeBuilder();
|
|
3386
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
3387
|
+
code.line("import { PGlite } from '@electric-sql/pglite';");
|
|
3388
|
+
code.line();
|
|
3389
|
+
const storage = config.persistence === "memory" ? void 0 : config.dataDir || "idb://schemock-db";
|
|
3390
|
+
code.comment("Database instance");
|
|
3391
|
+
if (storage) {
|
|
3392
|
+
code.line(`export const db = new PGlite('${storage}');`);
|
|
3393
|
+
} else {
|
|
3394
|
+
code.line("export const db = new PGlite();");
|
|
3395
|
+
}
|
|
3396
|
+
code.line();
|
|
3397
|
+
code.comment("SQL Schema");
|
|
3398
|
+
code.line("const schema = `");
|
|
3399
|
+
code.raw('CREATE EXTENSION IF NOT EXISTS "pgcrypto";');
|
|
3400
|
+
code.raw("");
|
|
3401
|
+
for (const schema of schemas) {
|
|
3402
|
+
code.raw(generateCreateTable(schema));
|
|
3403
|
+
code.raw("");
|
|
3404
|
+
}
|
|
3405
|
+
const allFKs = [];
|
|
3406
|
+
for (const schema of schemas) {
|
|
3407
|
+
allFKs.push(...generateForeignKeys(schema, schemas));
|
|
3408
|
+
}
|
|
3409
|
+
if (allFKs.length > 0) {
|
|
3410
|
+
code.raw("-- Foreign Keys");
|
|
3411
|
+
for (const fk of allFKs) {
|
|
3412
|
+
code.raw(fk);
|
|
3413
|
+
}
|
|
3414
|
+
code.raw("");
|
|
3415
|
+
}
|
|
3416
|
+
const allIndexes = [];
|
|
3417
|
+
for (const schema of schemas) {
|
|
3418
|
+
allIndexes.push(...generateIndexes(schema));
|
|
3419
|
+
}
|
|
3420
|
+
if (allIndexes.length > 0) {
|
|
3421
|
+
code.raw("-- Indexes");
|
|
3422
|
+
for (const idx of allIndexes) {
|
|
3423
|
+
code.raw(idx);
|
|
3424
|
+
}
|
|
3425
|
+
code.raw("");
|
|
3426
|
+
}
|
|
3427
|
+
const allPolicies = [];
|
|
3428
|
+
for (const schema of schemas) {
|
|
3429
|
+
allPolicies.push(...generateRLSPolicies(schema));
|
|
3430
|
+
}
|
|
3431
|
+
if (allPolicies.length > 0) {
|
|
3432
|
+
code.raw("-- Row-Level Security Policies");
|
|
3433
|
+
for (const policy of allPolicies) {
|
|
3434
|
+
code.raw(policy);
|
|
3435
|
+
}
|
|
3436
|
+
}
|
|
3437
|
+
code.line("`;");
|
|
3438
|
+
code.line();
|
|
3439
|
+
code.comment("Initialize database schema");
|
|
3440
|
+
code.line("let initialized = false;");
|
|
3441
|
+
code.line();
|
|
3442
|
+
code.block("export async function initDb(): Promise<void> {", () => {
|
|
3443
|
+
code.line("if (initialized) return;");
|
|
3444
|
+
code.line("await db.exec(schema);");
|
|
3445
|
+
code.line("initialized = true;");
|
|
3446
|
+
});
|
|
3447
|
+
code.line();
|
|
3448
|
+
code.comment("Reset database (drop and recreate all tables)");
|
|
3449
|
+
code.block("export async function resetDb(): Promise<void> {", () => {
|
|
3450
|
+
code.line("const dropSql = [");
|
|
3451
|
+
for (const schema of [...schemas].reverse()) {
|
|
3452
|
+
code.line(` 'DROP TABLE IF EXISTS "${schema.tableName}" CASCADE',`);
|
|
3453
|
+
}
|
|
3454
|
+
code.line('].join(";\\n");');
|
|
3455
|
+
code.line();
|
|
3456
|
+
code.line("await db.exec(dropSql);");
|
|
3457
|
+
code.line("initialized = false;");
|
|
3458
|
+
code.line("await initDb();");
|
|
3459
|
+
});
|
|
3460
|
+
code.line();
|
|
3461
|
+
code.comment("Table name mapping");
|
|
3462
|
+
code.block("export const tables = {", () => {
|
|
3463
|
+
for (const schema of schemas) {
|
|
3464
|
+
code.line(`${schema.name}: '${schema.tableName}',`);
|
|
3465
|
+
}
|
|
3466
|
+
}, "} as const;");
|
|
3467
|
+
code.line();
|
|
3468
|
+
code.line("export type TableName = keyof typeof tables;");
|
|
3469
|
+
code.line();
|
|
3470
|
+
const hasRLS = schemas.some((s) => s.rls.enabled);
|
|
3471
|
+
if (hasRLS) {
|
|
3472
|
+
generateRLSHelpers(code);
|
|
3473
|
+
}
|
|
3474
|
+
return code.toString();
|
|
3475
|
+
}
|
|
3476
|
+
function generateRLSHelpers(code) {
|
|
3477
|
+
code.comment("Row-Level Security Context (generic key-value)");
|
|
3478
|
+
code.block("export interface RLSContext {", () => {
|
|
3479
|
+
code.line("[key: string]: unknown;");
|
|
3480
|
+
}, "}");
|
|
3481
|
+
code.line();
|
|
3482
|
+
code.comment("Set context for RLS (sets PostgreSQL session variables)");
|
|
3483
|
+
code.block("export async function setContext(ctx: RLSContext | null): Promise<void> {", () => {
|
|
3484
|
+
code.block("if (ctx) {", () => {
|
|
3485
|
+
code.block("for (const [key, value] of Object.entries(ctx)) {", () => {
|
|
3486
|
+
code.line("if (value !== undefined && value !== null) {");
|
|
3487
|
+
code.line(" await db.exec(`SET LOCAL app.${key} = '${value}'`);");
|
|
3488
|
+
code.line("}");
|
|
3489
|
+
});
|
|
3490
|
+
}, "} else {");
|
|
3491
|
+
code.indent();
|
|
3492
|
+
code.comment("Reset all app.* settings would require knowing which keys were set");
|
|
3493
|
+
code.comment("For simplicity, start a new transaction instead");
|
|
3494
|
+
code.dedent();
|
|
3495
|
+
code.line("}");
|
|
3496
|
+
});
|
|
3497
|
+
code.line();
|
|
3498
|
+
code.comment("Execute a function with context (transaction-scoped)");
|
|
3499
|
+
code.block("export async function withContext<T>(ctx: RLSContext, fn: () => Promise<T>): Promise<T> {", () => {
|
|
3500
|
+
code.line("await db.exec('BEGIN');");
|
|
3501
|
+
code.block("try {", () => {
|
|
3502
|
+
code.line("await setContext(ctx);");
|
|
3503
|
+
code.line("const result = await fn();");
|
|
3504
|
+
code.line("await db.exec('COMMIT');");
|
|
3505
|
+
code.line("return result;");
|
|
3506
|
+
}, "} catch (e) {");
|
|
3507
|
+
code.indent();
|
|
3508
|
+
code.line("await db.exec('ROLLBACK');");
|
|
3509
|
+
code.line("throw e;");
|
|
3510
|
+
code.dedent();
|
|
3511
|
+
code.line("}");
|
|
3512
|
+
});
|
|
3513
|
+
code.line();
|
|
3514
|
+
code.comment("RLS Error class");
|
|
3515
|
+
code.block("export class RLSError extends Error {", () => {
|
|
3516
|
+
code.line("readonly code = 'RLS_DENIED';");
|
|
3517
|
+
code.block("constructor(message: string) {", () => {
|
|
3518
|
+
code.line("super(message);");
|
|
3519
|
+
code.line("this.name = 'RLSError';");
|
|
3520
|
+
});
|
|
3521
|
+
}, "}");
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
// src/cli/generators/pglite/client.ts
|
|
3525
|
+
function generatePGliteClient(schemas) {
|
|
3526
|
+
const code = new CodeBuilder();
|
|
3527
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
3528
|
+
code.line("import { db, initDb, tables } from './db';");
|
|
3529
|
+
code.line("import type * as Types from './types';");
|
|
3530
|
+
code.line();
|
|
3531
|
+
generateQueryHelpers(code);
|
|
3532
|
+
code.line();
|
|
3533
|
+
code.block("export const api = {", () => {
|
|
3534
|
+
for (const schema of schemas) {
|
|
3535
|
+
if (schema.isJunctionTable) continue;
|
|
3536
|
+
generateEntityApi2(code, schema, schemas);
|
|
3537
|
+
}
|
|
3538
|
+
}, "};");
|
|
3539
|
+
return code.toString();
|
|
3540
|
+
}
|
|
3541
|
+
function generateQueryHelpers(code) {
|
|
3542
|
+
code.comment("Build WHERE clause from filter object");
|
|
3543
|
+
code.block(
|
|
3544
|
+
"function buildWhere(filter: Record<string, unknown>, params: unknown[], startIndex = 1): { sql: string; nextIndex: number } {",
|
|
3545
|
+
() => {
|
|
3546
|
+
code.line("const conditions: string[] = [];");
|
|
3547
|
+
code.line("let paramIndex = startIndex;");
|
|
3548
|
+
code.line();
|
|
3549
|
+
code.block("for (const [key, value] of Object.entries(filter)) {", () => {
|
|
3550
|
+
code.block('if (typeof value === "object" && value !== null) {', () => {
|
|
3551
|
+
code.line("const f = value as Record<string, unknown>;");
|
|
3552
|
+
code.line();
|
|
3553
|
+
code.block("if ('equals' in f) {", () => {
|
|
3554
|
+
code.line('conditions.push(`"${key}" = $${paramIndex++}`);');
|
|
3555
|
+
code.line("params.push(f.equals);");
|
|
3556
|
+
});
|
|
3557
|
+
code.block("if ('not' in f) {", () => {
|
|
3558
|
+
code.line('conditions.push(`"${key}" != $${paramIndex++}`);');
|
|
3559
|
+
code.line("params.push(f.not);");
|
|
3560
|
+
});
|
|
3561
|
+
code.block("if ('in' in f) {", () => {
|
|
3562
|
+
code.line("const arr = f.in as unknown[];");
|
|
3563
|
+
code.line('const placeholders = arr.map(() => `$${paramIndex++}`).join(", ");');
|
|
3564
|
+
code.line('conditions.push(`"${key}" IN (${placeholders})`);');
|
|
3565
|
+
code.line("params.push(...arr);");
|
|
3566
|
+
});
|
|
3567
|
+
code.block("if ('notIn' in f) {", () => {
|
|
3568
|
+
code.line("const arr = f.notIn as unknown[];");
|
|
3569
|
+
code.line('const placeholders = arr.map(() => `$${paramIndex++}`).join(", ");');
|
|
3570
|
+
code.line('conditions.push(`"${key}" NOT IN (${placeholders})`);');
|
|
3571
|
+
code.line("params.push(...arr);");
|
|
3572
|
+
});
|
|
3573
|
+
code.block("if ('contains' in f) {", () => {
|
|
3574
|
+
code.line('conditions.push(`"${key}" ILIKE $${paramIndex++}`);');
|
|
3575
|
+
code.line("params.push(`%${f.contains}%`);");
|
|
3576
|
+
});
|
|
3577
|
+
code.block("if ('startsWith' in f) {", () => {
|
|
3578
|
+
code.line('conditions.push(`"${key}" ILIKE $${paramIndex++}`);');
|
|
3579
|
+
code.line("params.push(`${f.startsWith}%`);");
|
|
3580
|
+
});
|
|
3581
|
+
code.block("if ('endsWith' in f) {", () => {
|
|
3582
|
+
code.line('conditions.push(`"${key}" ILIKE $${paramIndex++}`);');
|
|
3583
|
+
code.line("params.push(`%${f.endsWith}`);");
|
|
3584
|
+
});
|
|
3585
|
+
code.block("if ('gt' in f) {", () => {
|
|
3586
|
+
code.line('conditions.push(`"${key}" > $${paramIndex++}`);');
|
|
3587
|
+
code.line("params.push(f.gt);");
|
|
3588
|
+
});
|
|
3589
|
+
code.block("if ('lt' in f) {", () => {
|
|
3590
|
+
code.line('conditions.push(`"${key}" < $${paramIndex++}`);');
|
|
3591
|
+
code.line("params.push(f.lt);");
|
|
3592
|
+
});
|
|
3593
|
+
code.block("if ('gte' in f) {", () => {
|
|
3594
|
+
code.line('conditions.push(`"${key}" >= $${paramIndex++}`);');
|
|
3595
|
+
code.line("params.push(f.gte);");
|
|
3596
|
+
});
|
|
3597
|
+
code.block("if ('lte' in f) {", () => {
|
|
3598
|
+
code.line('conditions.push(`"${key}" <= $${paramIndex++}`);');
|
|
3599
|
+
code.line("params.push(f.lte);");
|
|
3600
|
+
});
|
|
3601
|
+
code.block("if ('isNull' in f) {", () => {
|
|
3602
|
+
code.line('conditions.push(f.isNull ? `"${key}" IS NULL` : `"${key}" IS NOT NULL`);');
|
|
3603
|
+
});
|
|
3604
|
+
}, "} else {");
|
|
3605
|
+
code.indent();
|
|
3606
|
+
code.line('conditions.push(`"${key}" = $${paramIndex++}`);');
|
|
3607
|
+
code.line("params.push(value);");
|
|
3608
|
+
code.dedent();
|
|
3609
|
+
code.line("}");
|
|
3610
|
+
});
|
|
3611
|
+
code.line();
|
|
3612
|
+
code.line("return { sql: conditions.length ? conditions.join(' AND ') : '1=1', nextIndex: paramIndex };");
|
|
3613
|
+
}
|
|
3614
|
+
);
|
|
3615
|
+
code.line();
|
|
3616
|
+
code.comment("Build ORDER BY clause");
|
|
3617
|
+
code.block('function buildOrderBy(orderBy?: Record<string, "asc" | "desc">): string {', () => {
|
|
3618
|
+
code.line('if (!orderBy) return "";');
|
|
3619
|
+
code.line('const clauses = Object.entries(orderBy).map(([key, dir]) => `"${key}" ${dir.toUpperCase()}`);');
|
|
3620
|
+
code.line('return clauses.length ? `ORDER BY ${clauses.join(", ")}` : "";');
|
|
3621
|
+
});
|
|
3622
|
+
code.line();
|
|
3623
|
+
code.comment("Parse JSONB fields in result");
|
|
3624
|
+
code.block(
|
|
3625
|
+
"function parseRow<T>(row: Record<string, unknown>, jsonFields: string[]): T {",
|
|
3626
|
+
() => {
|
|
3627
|
+
code.line("const result = { ...row };");
|
|
3628
|
+
code.block("for (const field of jsonFields) {", () => {
|
|
3629
|
+
code.block('if (result[field] && typeof result[field] === "string") {', () => {
|
|
3630
|
+
code.block("try {", () => {
|
|
3631
|
+
code.line("result[field] = JSON.parse(result[field] as string);");
|
|
3632
|
+
}, "} catch { /* keep as string */ }");
|
|
3633
|
+
});
|
|
3634
|
+
});
|
|
3635
|
+
code.line("return result as T;");
|
|
3636
|
+
}
|
|
3637
|
+
);
|
|
3638
|
+
}
|
|
3639
|
+
function generateEntityApi2(code, schema, allSchemas) {
|
|
3640
|
+
const { name, pascalName, tableName, relations, fields } = schema;
|
|
3641
|
+
const hasRelations = relations.length > 0;
|
|
3642
|
+
const includeType = hasRelations ? `Types.${pascalName}Include` : "never";
|
|
3643
|
+
const jsonFields = fields.filter((f) => f.isArray || f.isObject).map((f) => f.name);
|
|
3644
|
+
code.block(`${name}: {`, () => {
|
|
3645
|
+
code.block(
|
|
3646
|
+
`list: async (options?: Types.QueryOptions<Types.${pascalName}Filter, ${includeType}>): Promise<Types.ListResponse<Types.${pascalName}>> => {`,
|
|
3647
|
+
() => {
|
|
3648
|
+
code.line("await initDb();");
|
|
3649
|
+
code.line("const params: unknown[] = [];");
|
|
3650
|
+
code.line("let paramIndex = 1;");
|
|
3651
|
+
code.line();
|
|
3652
|
+
code.line('let whereClause = "1=1";');
|
|
3653
|
+
code.block("if (options?.where) {", () => {
|
|
3654
|
+
code.line("const { sql, nextIndex } = buildWhere(options.where, params);");
|
|
3655
|
+
code.line("whereClause = sql;");
|
|
3656
|
+
code.line("paramIndex = nextIndex;");
|
|
3657
|
+
});
|
|
3658
|
+
code.line();
|
|
3659
|
+
code.line(`const countResult = await db.query<{ count: string }>(`);
|
|
3660
|
+
code.line(` \`SELECT COUNT(*) as count FROM "${tableName}" WHERE \${whereClause}\`,`);
|
|
3661
|
+
code.line(" params");
|
|
3662
|
+
code.line(");");
|
|
3663
|
+
code.line("const total = parseInt(countResult.rows[0].count, 10);");
|
|
3664
|
+
code.line();
|
|
3665
|
+
code.line("const orderBy = buildOrderBy(options?.orderBy);");
|
|
3666
|
+
code.line("const limit = options?.limit ?? 20;");
|
|
3667
|
+
code.line("const offset = options?.offset ?? 0;");
|
|
3668
|
+
code.line();
|
|
3669
|
+
code.line(`const result = await db.query<Types.${pascalName}>(`);
|
|
3670
|
+
code.line(` \`SELECT * FROM "${tableName}" WHERE \${whereClause} \${orderBy} LIMIT \${limit} OFFSET \${offset}\`,`);
|
|
3671
|
+
code.line(" params");
|
|
3672
|
+
code.line(");");
|
|
3673
|
+
code.line();
|
|
3674
|
+
if (jsonFields.length > 0) {
|
|
3675
|
+
const jsonFieldsStr = jsonFields.map((f) => `'${f}'`).join(", ");
|
|
3676
|
+
code.line(`let items = result.rows.map(row => parseRow<Types.${pascalName}>(row as Record<string, unknown>, [${jsonFieldsStr}]));`);
|
|
3677
|
+
} else {
|
|
3678
|
+
code.line("let items = result.rows;");
|
|
3679
|
+
}
|
|
3680
|
+
code.line();
|
|
3681
|
+
if (hasRelations) {
|
|
3682
|
+
code.block("if (options?.include?.length) {", () => {
|
|
3683
|
+
code.block("items = await Promise.all(items.map(async (item) => {", () => {
|
|
3684
|
+
code.line("const result = { ...item } as Record<string, unknown>;");
|
|
3685
|
+
for (const rel of relations) {
|
|
3686
|
+
code.block(`if (options.include!.includes('${rel.name}')) {`, () => {
|
|
3687
|
+
generateRelationLoad2(code, schema, rel, allSchemas);
|
|
3688
|
+
});
|
|
3689
|
+
}
|
|
3690
|
+
code.line(`return result as Types.${pascalName};`);
|
|
3691
|
+
}, "}));");
|
|
3692
|
+
});
|
|
3693
|
+
code.line();
|
|
3694
|
+
}
|
|
3695
|
+
code.line("return { data: items, meta: { total, limit, offset, hasMore: offset + limit < total } };");
|
|
3696
|
+
},
|
|
3697
|
+
"},"
|
|
3698
|
+
);
|
|
3699
|
+
code.line();
|
|
3700
|
+
code.block(
|
|
3701
|
+
`get: async (id: string, options?: { include?: ${includeType}[] }): Promise<Types.ItemResponse<Types.${pascalName}>> => {`,
|
|
3702
|
+
() => {
|
|
3703
|
+
code.line("await initDb();");
|
|
3704
|
+
code.line();
|
|
3705
|
+
code.line(`const result = await db.query<Types.${pascalName}>(`);
|
|
3706
|
+
code.line(` \`SELECT * FROM "${tableName}" WHERE "id" = $1\`,`);
|
|
3707
|
+
code.line(" [id]");
|
|
3708
|
+
code.line(");");
|
|
3709
|
+
code.line();
|
|
3710
|
+
code.line(`if (result.rows.length === 0) throw new Error('${pascalName} not found');`);
|
|
3711
|
+
code.line();
|
|
3712
|
+
if (jsonFields.length > 0) {
|
|
3713
|
+
const jsonFieldsStr = jsonFields.map((f) => `'${f}'`).join(", ");
|
|
3714
|
+
code.line(`let item = parseRow<Types.${pascalName}>(result.rows[0] as Record<string, unknown>, [${jsonFieldsStr}]);`);
|
|
3715
|
+
} else {
|
|
3716
|
+
code.line("let item = result.rows[0];");
|
|
3717
|
+
}
|
|
3718
|
+
code.line();
|
|
3719
|
+
if (hasRelations) {
|
|
3720
|
+
code.line("const resultObj = { ...item } as Record<string, unknown>;");
|
|
3721
|
+
code.line();
|
|
3722
|
+
code.block("if (options?.include?.length) {", () => {
|
|
3723
|
+
for (const rel of relations) {
|
|
3724
|
+
code.block(`if (options.include.includes('${rel.name}')) {`, () => {
|
|
3725
|
+
generateRelationLoad2(code, schema, rel, allSchemas, "resultObj", "item");
|
|
3726
|
+
});
|
|
3727
|
+
}
|
|
3728
|
+
});
|
|
3729
|
+
code.line();
|
|
3730
|
+
code.line(`return { data: resultObj as Types.${pascalName} };`);
|
|
3731
|
+
} else {
|
|
3732
|
+
code.line("return { data: item };");
|
|
3733
|
+
}
|
|
3734
|
+
},
|
|
3735
|
+
"},"
|
|
3736
|
+
);
|
|
3737
|
+
code.line();
|
|
3738
|
+
generateCreateMethod2(code, schema, allSchemas);
|
|
3739
|
+
code.line();
|
|
3740
|
+
code.block(
|
|
3741
|
+
`update: async (id: string, input: Types.${pascalName}Update): Promise<Types.ItemResponse<Types.${pascalName}>> => {`,
|
|
3742
|
+
() => {
|
|
3743
|
+
code.line("await initDb();");
|
|
3744
|
+
code.line();
|
|
3745
|
+
code.line("const data = { ...input, updatedAt: new Date() };");
|
|
3746
|
+
code.line("const fields = Object.keys(data);");
|
|
3747
|
+
code.line("const values = Object.values(data);");
|
|
3748
|
+
code.line();
|
|
3749
|
+
code.line('const setClauses = fields.map((f, i) => `"${f}" = $${i + 1}`).join(", ");');
|
|
3750
|
+
code.line();
|
|
3751
|
+
code.line(`const result = await db.query<Types.${pascalName}>(`);
|
|
3752
|
+
code.line(` \`UPDATE "${tableName}" SET \${setClauses} WHERE "id" = $\${fields.length + 1} RETURNING *\`,`);
|
|
3753
|
+
code.line(" [...values, id]");
|
|
3754
|
+
code.line(");");
|
|
3755
|
+
code.line();
|
|
3756
|
+
code.line(`if (result.rows.length === 0) throw new Error('${pascalName} not found');`);
|
|
3757
|
+
if (jsonFields.length > 0) {
|
|
3758
|
+
const jsonFieldsStr = jsonFields.map((f) => `'${f}'`).join(", ");
|
|
3759
|
+
code.line(`return { data: parseRow<Types.${pascalName}>(result.rows[0] as Record<string, unknown>, [${jsonFieldsStr}]) };`);
|
|
3760
|
+
} else {
|
|
3761
|
+
code.line("return { data: result.rows[0] };");
|
|
3762
|
+
}
|
|
3763
|
+
},
|
|
3764
|
+
"},"
|
|
3765
|
+
);
|
|
3766
|
+
code.line();
|
|
3767
|
+
code.block("delete: async (id: string): Promise<void> => {", () => {
|
|
3768
|
+
code.line("await initDb();");
|
|
3769
|
+
code.line();
|
|
3770
|
+
code.line(`const result = await db.query(`);
|
|
3771
|
+
code.line(` \`DELETE FROM "${tableName}" WHERE "id" = $1 RETURNING "id"\`,`);
|
|
3772
|
+
code.line(" [id]");
|
|
3773
|
+
code.line(");");
|
|
3774
|
+
code.line();
|
|
3775
|
+
code.line(`if (result.rows.length === 0) throw new Error('${pascalName} not found');`);
|
|
3776
|
+
}, "},");
|
|
3777
|
+
}, "},");
|
|
3778
|
+
code.line();
|
|
3779
|
+
}
|
|
3780
|
+
function generateRelationLoad2(code, schema, rel, allSchemas, resultVar = "result", itemVar = "item") {
|
|
3781
|
+
const targetSchema = allSchemas.find((s) => s.name === rel.target);
|
|
3782
|
+
if (!targetSchema) return;
|
|
3783
|
+
if (rel.type === "hasMany") {
|
|
3784
|
+
code.line(`const ${rel.name}Result = await db.query(`);
|
|
3785
|
+
code.line(` \`SELECT * FROM "${targetSchema.tableName}" WHERE "${rel.foreignKey}" = $1\`,`);
|
|
3786
|
+
code.line(` [${itemVar}.id]`);
|
|
3787
|
+
code.line(");");
|
|
3788
|
+
code.line(`${resultVar}.${rel.name} = ${rel.name}Result.rows;`);
|
|
3789
|
+
} else if (rel.type === "hasOne") {
|
|
3790
|
+
code.line(`const ${rel.name}Result = await db.query(`);
|
|
3791
|
+
code.line(` \`SELECT * FROM "${targetSchema.tableName}" WHERE "${rel.foreignKey}" = $1 LIMIT 1\`,`);
|
|
3792
|
+
code.line(` [${itemVar}.id]`);
|
|
3793
|
+
code.line(");");
|
|
3794
|
+
code.line(`${resultVar}.${rel.name} = ${rel.name}Result.rows[0] ?? null;`);
|
|
3795
|
+
} else if (rel.type === "belongsTo") {
|
|
3796
|
+
code.line(`const ${rel.name}Result = await db.query(`);
|
|
3797
|
+
code.line(` \`SELECT * FROM "${targetSchema.tableName}" WHERE "id" = $1 LIMIT 1\`,`);
|
|
3798
|
+
code.line(` [(${itemVar} as Record<string, unknown>).${rel.localField}]`);
|
|
3799
|
+
code.line(");");
|
|
3800
|
+
code.line(`${resultVar}.${rel.name} = ${rel.name}Result.rows[0] ?? null;`);
|
|
3801
|
+
} else if (rel.type === "manyToMany") {
|
|
3802
|
+
const throughSchema = allSchemas.find((s) => s.name === rel.through);
|
|
3803
|
+
if (throughSchema) {
|
|
3804
|
+
code.line(`const junctions = await db.query(`);
|
|
3805
|
+
code.line(` \`SELECT "${rel.otherKey}" FROM "${throughSchema.tableName}" WHERE "${rel.foreignKey}" = $1\`,`);
|
|
3806
|
+
code.line(` [${itemVar}.id]`);
|
|
3807
|
+
code.line(");");
|
|
3808
|
+
code.line(`const relatedIds = junctions.rows.map(j => (j as Record<string, unknown>).${rel.otherKey});`);
|
|
3809
|
+
code.block("if (relatedIds.length > 0) {", () => {
|
|
3810
|
+
code.line('const placeholders = relatedIds.map((_, i) => `$${i + 1}`).join(", ");');
|
|
3811
|
+
code.line(`const related = await db.query(`);
|
|
3812
|
+
code.line(` \`SELECT * FROM "${targetSchema.tableName}" WHERE "id" IN (\${placeholders})\`,`);
|
|
3813
|
+
code.line(" relatedIds");
|
|
3814
|
+
code.line(");");
|
|
3815
|
+
code.line(`${resultVar}.${rel.name} = related.rows;`);
|
|
3816
|
+
}, "} else {");
|
|
3817
|
+
code.indent();
|
|
3818
|
+
code.line(`${resultVar}.${rel.name} = [];`);
|
|
3819
|
+
code.dedent();
|
|
3820
|
+
code.line("}");
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
function generateCreateMethod2(code, schema, allSchemas) {
|
|
3825
|
+
const { name, pascalName, tableName, relations, fields } = schema;
|
|
3826
|
+
const nestedRels = relations.filter((r) => r.type === "hasMany" || r.type === "hasOne");
|
|
3827
|
+
const jsonFields = fields.filter((f) => f.isArray || f.isObject).map((f) => f.name);
|
|
3828
|
+
code.block(
|
|
3829
|
+
`create: async (input: Types.${pascalName}Create): Promise<Types.ItemResponse<Types.${pascalName}>> => {`,
|
|
3830
|
+
() => {
|
|
3831
|
+
code.line("await initDb();");
|
|
3832
|
+
code.line();
|
|
3833
|
+
if (nestedRels.length > 0) {
|
|
3834
|
+
const relNames = nestedRels.map((r) => r.name).join(", ");
|
|
3835
|
+
code.line(`const { ${relNames}, ...data } = input;`);
|
|
3836
|
+
} else {
|
|
3837
|
+
code.line("const data = input;");
|
|
3838
|
+
}
|
|
3839
|
+
code.line();
|
|
3840
|
+
if (jsonFields.length > 0) {
|
|
3841
|
+
code.line("const serialized = { ...data } as Record<string, unknown>;");
|
|
3842
|
+
for (const jf of jsonFields) {
|
|
3843
|
+
code.block(`if (serialized.${jf} !== undefined) {`, () => {
|
|
3844
|
+
code.line(`serialized.${jf} = JSON.stringify(serialized.${jf});`);
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
code.line();
|
|
3848
|
+
code.line("const fields = Object.keys(serialized);");
|
|
3849
|
+
code.line("const values = Object.values(serialized);");
|
|
3850
|
+
} else {
|
|
3851
|
+
code.line("const fields = Object.keys(data);");
|
|
3852
|
+
code.line("const values = Object.values(data);");
|
|
3853
|
+
}
|
|
3854
|
+
code.line();
|
|
3855
|
+
code.line('const placeholders = fields.map((_, i) => `$${i + 1}`).join(", ");');
|
|
3856
|
+
code.line('const columns = fields.map(f => `"${f}"`).join(", ");');
|
|
3857
|
+
code.line();
|
|
3858
|
+
code.line(`const result = await db.query<Types.${pascalName}>(`);
|
|
3859
|
+
code.line(` \`INSERT INTO "${tableName}" (\${columns}) VALUES (\${placeholders}) RETURNING *\`,`);
|
|
3860
|
+
code.line(" values");
|
|
3861
|
+
code.line(");");
|
|
3862
|
+
code.line();
|
|
3863
|
+
if (jsonFields.length > 0) {
|
|
3864
|
+
const jsonFieldsStr = jsonFields.map((f) => `'${f}'`).join(", ");
|
|
3865
|
+
code.line(`const item = parseRow<Types.${pascalName}>(result.rows[0] as Record<string, unknown>, [${jsonFieldsStr}]);`);
|
|
3866
|
+
} else {
|
|
3867
|
+
code.line("const item = result.rows[0];");
|
|
3868
|
+
}
|
|
3869
|
+
if (nestedRels.length > 0) {
|
|
3870
|
+
code.line();
|
|
3871
|
+
for (const rel of nestedRels) {
|
|
3872
|
+
const targetSchema = allSchemas.find((s) => s.name === rel.target);
|
|
3873
|
+
if (!targetSchema) continue;
|
|
3874
|
+
code.block(`if (${rel.name}) {`, () => {
|
|
3875
|
+
if (rel.type === "hasMany") {
|
|
3876
|
+
code.block(`for (const nested of ${rel.name}) {`, () => {
|
|
3877
|
+
code.line(`const nestedData = { ...nested, ${rel.foreignKey}: item.id };`);
|
|
3878
|
+
code.line("const nestedFields = Object.keys(nestedData);");
|
|
3879
|
+
code.line("const nestedValues = Object.values(nestedData);");
|
|
3880
|
+
code.line('const nestedPlaceholders = nestedFields.map((_, i) => `$${i + 1}`).join(", ");');
|
|
3881
|
+
code.line('const nestedColumns = nestedFields.map(f => `"${f}"`).join(", ");');
|
|
3882
|
+
code.line("await db.query(");
|
|
3883
|
+
code.line(` \`INSERT INTO "${targetSchema.tableName}" (\${nestedColumns}) VALUES (\${nestedPlaceholders})\`,`);
|
|
3884
|
+
code.line(" nestedValues");
|
|
3885
|
+
code.line(");");
|
|
3886
|
+
});
|
|
3887
|
+
} else {
|
|
3888
|
+
code.line(`const nestedData = { ...${rel.name}, ${rel.foreignKey}: item.id };`);
|
|
3889
|
+
code.line("const nestedFields = Object.keys(nestedData);");
|
|
3890
|
+
code.line("const nestedValues = Object.values(nestedData);");
|
|
3891
|
+
code.line('const nestedPlaceholders = nestedFields.map((_, i) => `$${i + 1}`).join(", ");');
|
|
3892
|
+
code.line('const nestedColumns = nestedFields.map(f => `"${f}"`).join(", ");');
|
|
3893
|
+
code.line("await db.query(");
|
|
3894
|
+
code.line(` \`INSERT INTO "${targetSchema.tableName}" (\${nestedColumns}) VALUES (\${nestedPlaceholders})\`,`);
|
|
3895
|
+
code.line(" nestedValues");
|
|
3896
|
+
code.line(");");
|
|
3897
|
+
}
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
}
|
|
3901
|
+
code.line();
|
|
3902
|
+
code.line("return { data: item };");
|
|
3903
|
+
},
|
|
3904
|
+
"},"
|
|
3905
|
+
);
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3908
|
+
// src/cli/generators/pglite/seed.ts
|
|
3909
|
+
function generatePGliteSeed(schemas, config) {
|
|
3910
|
+
const code = new CodeBuilder();
|
|
3911
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
3912
|
+
code.line("import { db, initDb, resetDb, tables } from './db';");
|
|
3913
|
+
code.line("import { faker } from '@faker-js/faker';");
|
|
3914
|
+
code.line();
|
|
3915
|
+
if (config.fakerSeed !== void 0) {
|
|
3916
|
+
code.line(`faker.seed(${config.fakerSeed});`);
|
|
3917
|
+
} else {
|
|
3918
|
+
code.line("faker.seed(Date.now());");
|
|
3919
|
+
}
|
|
3920
|
+
code.line();
|
|
3921
|
+
code.block("export interface SeedCounts {", () => {
|
|
3922
|
+
for (const schema of schemas) {
|
|
3923
|
+
if (schema.isJunctionTable) continue;
|
|
3924
|
+
code.line(`${schema.name}?: number;`);
|
|
3925
|
+
}
|
|
3926
|
+
});
|
|
3927
|
+
code.line();
|
|
3928
|
+
code.block("const defaultCounts: Required<SeedCounts> = {", () => {
|
|
3929
|
+
for (const schema of schemas) {
|
|
3930
|
+
if (schema.isJunctionTable) continue;
|
|
3931
|
+
const count = config.seed?.[schema.name] ?? 10;
|
|
3932
|
+
code.line(`${schema.name}: ${count},`);
|
|
3933
|
+
}
|
|
3934
|
+
}, "};");
|
|
3935
|
+
code.line();
|
|
3936
|
+
code.block("function pickRandom<T>(arr: T[]): T | undefined {", () => {
|
|
3937
|
+
code.line("if (arr.length === 0) return undefined;");
|
|
3938
|
+
code.line("return arr[Math.floor(Math.random() * arr.length)];");
|
|
3939
|
+
});
|
|
3940
|
+
code.line();
|
|
3941
|
+
for (const schema of schemas) {
|
|
3942
|
+
if (schema.isJunctionTable) continue;
|
|
3943
|
+
generateEntityGenerator(code, schema);
|
|
3944
|
+
code.line();
|
|
3945
|
+
}
|
|
3946
|
+
code.block("export async function seed(counts: SeedCounts = {}): Promise<void> {", () => {
|
|
3947
|
+
code.line("await initDb();");
|
|
3948
|
+
code.line("const merged = { ...defaultCounts, ...counts };");
|
|
3949
|
+
code.line();
|
|
3950
|
+
code.comment("Track created entity IDs for foreign key references");
|
|
3951
|
+
code.line("const ids: Record<string, string[]> = {};");
|
|
3952
|
+
code.line();
|
|
3953
|
+
for (const schema of schemas) {
|
|
3954
|
+
if (schema.isJunctionTable) continue;
|
|
3955
|
+
const belongsToRels = schema.relations.filter((r) => r.type === "belongsTo");
|
|
3956
|
+
code.line(`ids.${schema.name} = [];`);
|
|
3957
|
+
code.block(`for (let i = 0; i < merged.${schema.name}; i++) {`, () => {
|
|
3958
|
+
if (belongsToRels.length > 0) {
|
|
3959
|
+
code.line(`const data = generate${schema.pascalName}();`);
|
|
3960
|
+
for (const rel of belongsToRels) {
|
|
3961
|
+
const localField = rel.localField || rel.foreignKey;
|
|
3962
|
+
const field = schema.fields.find((f) => f.name === localField);
|
|
3963
|
+
if (field?.nullable) {
|
|
3964
|
+
code.line(`data.${localField} = Math.random() > 0.3 ? pickRandom(ids.${rel.target}) : null;`);
|
|
3965
|
+
} else {
|
|
3966
|
+
code.line(`data.${localField} = pickRandom(ids.${rel.target}) ?? null;`);
|
|
3967
|
+
}
|
|
3968
|
+
}
|
|
3969
|
+
} else {
|
|
3970
|
+
code.line(`const data = generate${schema.pascalName}();`);
|
|
3971
|
+
}
|
|
3972
|
+
code.line();
|
|
3973
|
+
code.line("const fields = Object.keys(data).filter(k => data[k as keyof typeof data] !== undefined);");
|
|
3974
|
+
code.line("const values = fields.map(k => data[k as keyof typeof data]);");
|
|
3975
|
+
code.line('const placeholders = fields.map((_, i) => `$${i + 1}`).join(", ");');
|
|
3976
|
+
code.line('const columns = fields.map(f => `"${f}"`).join(", ");');
|
|
3977
|
+
code.line();
|
|
3978
|
+
code.line(`const result = await db.query<{ id: string }>(`);
|
|
3979
|
+
code.line(` \`INSERT INTO "${schema.tableName}" (\${columns}) VALUES (\${placeholders}) RETURNING "id"\`,`);
|
|
3980
|
+
code.line(" values");
|
|
3981
|
+
code.line(");");
|
|
3982
|
+
code.line(`ids.${schema.name}.push(result.rows[0].id);`);
|
|
3983
|
+
});
|
|
3984
|
+
code.line();
|
|
3985
|
+
}
|
|
3986
|
+
code.line("console.log('\u2713 Database seeded');");
|
|
3987
|
+
});
|
|
3988
|
+
code.line();
|
|
3989
|
+
code.block("export async function reset(): Promise<void> {", () => {
|
|
3990
|
+
code.line("await resetDb();");
|
|
3991
|
+
code.line("console.log('\u2713 Database reset');");
|
|
3992
|
+
});
|
|
3993
|
+
code.line();
|
|
3994
|
+
code.block("export async function getAll(): Promise<Record<string, unknown[]>> {", () => {
|
|
3995
|
+
code.line("await initDb();");
|
|
3996
|
+
code.block("return {", () => {
|
|
3997
|
+
for (const schema of schemas) {
|
|
3998
|
+
code.line(`${schema.name}: (await db.query(\`SELECT * FROM "${schema.tableName}"\`)).rows,`);
|
|
3999
|
+
}
|
|
4000
|
+
}, "};");
|
|
4001
|
+
});
|
|
4002
|
+
code.line();
|
|
4003
|
+
code.block("export async function count(): Promise<Record<string, number>> {", () => {
|
|
4004
|
+
code.line("await initDb();");
|
|
4005
|
+
code.block("return {", () => {
|
|
4006
|
+
for (const schema of schemas) {
|
|
4007
|
+
code.line(
|
|
4008
|
+
`${schema.name}: parseInt((await db.query<{ count: string }>(\`SELECT COUNT(*) as count FROM "${schema.tableName}"\`)).rows[0].count, 10),`
|
|
4009
|
+
);
|
|
4010
|
+
}
|
|
4011
|
+
}, "};");
|
|
4012
|
+
});
|
|
4013
|
+
return code.toString();
|
|
4014
|
+
}
|
|
4015
|
+
function generateEntityGenerator(code, schema) {
|
|
4016
|
+
const fieldTypes = [];
|
|
4017
|
+
for (const field of schema.fields) {
|
|
4018
|
+
if (field.name === "id") continue;
|
|
4019
|
+
if (field.isRef) {
|
|
4020
|
+
fieldTypes.push(`${field.name}: string | null`);
|
|
4021
|
+
} else {
|
|
4022
|
+
fieldTypes.push(`${field.name}: ${field.tsType}${field.nullable ? " | null" : ""}`);
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
code.block(`function generate${schema.pascalName}(): { ${fieldTypes.join("; ")} } {`, () => {
|
|
4026
|
+
code.block("return {", () => {
|
|
4027
|
+
for (const field of schema.fields) {
|
|
4028
|
+
if (field.name === "id") continue;
|
|
4029
|
+
if (field.isRef) {
|
|
4030
|
+
code.line(`${field.name}: null,`);
|
|
4031
|
+
} else {
|
|
4032
|
+
generateFieldValue(code, field);
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
}, "};");
|
|
4036
|
+
});
|
|
4037
|
+
}
|
|
4038
|
+
function generateFieldValue(code, field) {
|
|
4039
|
+
if (field.nullable) {
|
|
4040
|
+
code.line(`${field.name}: Math.random() > 0.1 ? ${field.fakerCall} : null,`);
|
|
4041
|
+
} else {
|
|
4042
|
+
code.line(`${field.name}: ${field.fakerCall},`);
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
4045
|
+
|
|
4046
|
+
// src/cli/generators/hooks.ts
|
|
4047
|
+
function generateHooks(schemas) {
|
|
4048
|
+
const code = new CodeBuilder();
|
|
4049
|
+
code.comment("GENERATED BY SCHEMOCK - DO NOT EDIT");
|
|
4050
|
+
code.line("import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';");
|
|
4051
|
+
code.line("import { useMemo } from 'react';");
|
|
4052
|
+
code.line("import { api } from './client';");
|
|
4053
|
+
code.line("import type * as Types from './types';");
|
|
4054
|
+
code.line();
|
|
4055
|
+
generateStableKeyHelper(code);
|
|
4056
|
+
for (const schema of schemas) {
|
|
4057
|
+
if (schema.isJunctionTable) continue;
|
|
4058
|
+
generateEntityHooks(code, schema);
|
|
4059
|
+
}
|
|
4060
|
+
return code.toString();
|
|
4061
|
+
}
|
|
4062
|
+
function generateStableKeyHelper(code) {
|
|
4063
|
+
code.comment("=============================================================================");
|
|
4064
|
+
code.comment("Stable Query Key Helper");
|
|
4065
|
+
code.comment("");
|
|
4066
|
+
code.comment("Creates stable query keys from options objects to prevent unnecessary");
|
|
4067
|
+
code.comment("refetches when object references change but values are the same.");
|
|
4068
|
+
code.comment("=============================================================================");
|
|
4069
|
+
code.line();
|
|
4070
|
+
code.multiDocComment([
|
|
4071
|
+
"Create a stable serialized key from an options object.",
|
|
4072
|
+
"Uses JSON.stringify with sorted keys for consistent ordering.",
|
|
4073
|
+
"",
|
|
4074
|
+
"@param options - The options object to serialize",
|
|
4075
|
+
"@returns A stable string representation of the options"
|
|
4076
|
+
]);
|
|
4077
|
+
code.block("function stableKey(options: unknown): string {", () => {
|
|
4078
|
+
code.line('if (options === undefined || options === null) return "";');
|
|
4079
|
+
code.block("try {", () => {
|
|
4080
|
+
code.comment("Sort object keys for consistent serialization");
|
|
4081
|
+
code.line("return JSON.stringify(options, (_, value) => {");
|
|
4082
|
+
code.line(' if (value && typeof value === "object" && !Array.isArray(value)) {');
|
|
4083
|
+
code.line(" return Object.keys(value).sort().reduce((sorted, key) => {");
|
|
4084
|
+
code.line(" sorted[key] = value[key];");
|
|
4085
|
+
code.line(" return sorted;");
|
|
4086
|
+
code.line(" }, {} as Record<string, unknown>);");
|
|
4087
|
+
code.line(" }");
|
|
4088
|
+
code.line(" return value;");
|
|
4089
|
+
code.line("});");
|
|
4090
|
+
}, "} catch {");
|
|
4091
|
+
code.indent();
|
|
4092
|
+
code.comment("Fallback for non-serializable values");
|
|
4093
|
+
code.line("return String(options);");
|
|
4094
|
+
code.dedent();
|
|
4095
|
+
code.line("}");
|
|
4096
|
+
});
|
|
4097
|
+
code.line();
|
|
4098
|
+
code.multiDocComment([
|
|
4099
|
+
"React hook that memoizes a stable query key from options.",
|
|
4100
|
+
"Only recomputes when the serialized value changes.",
|
|
4101
|
+
"",
|
|
4102
|
+
'@param baseKey - The base query key (e.g., "users")',
|
|
4103
|
+
"@param options - Optional options to include in the key",
|
|
4104
|
+
"@returns A stable query key array"
|
|
4105
|
+
]);
|
|
4106
|
+
code.block("function useStableQueryKey(baseKey: string, ...parts: unknown[]): unknown[] {", () => {
|
|
4107
|
+
code.line('const serialized = parts.map(p => stableKey(p)).join("|");');
|
|
4108
|
+
code.line("return useMemo(() => [baseKey, ...parts], [baseKey, serialized]);");
|
|
4109
|
+
});
|
|
4110
|
+
code.line();
|
|
4111
|
+
}
|
|
4112
|
+
function generateEntityHooks(code, schema) {
|
|
4113
|
+
const { name, pascalName, pluralName, pascalPluralName, relations } = schema;
|
|
4114
|
+
const hasRelations = relations.length > 0;
|
|
4115
|
+
code.comment(`==================== ${pascalName} Hooks ====================`);
|
|
4116
|
+
code.line();
|
|
4117
|
+
code.docComment(`Fetch list of ${pluralName}`);
|
|
4118
|
+
code.block(`export function use${pascalPluralName}(options?: {`, () => {
|
|
4119
|
+
code.line(`where?: Types.${pascalName}Filter;`);
|
|
4120
|
+
if (hasRelations) {
|
|
4121
|
+
code.line(`include?: Types.${pascalName}Include[];`);
|
|
4122
|
+
}
|
|
4123
|
+
code.line("orderBy?: Record<string, 'asc' | 'desc'>;");
|
|
4124
|
+
code.line("limit?: number;");
|
|
4125
|
+
code.line("offset?: number;");
|
|
4126
|
+
code.line("enabled?: boolean;");
|
|
4127
|
+
}, "}) {");
|
|
4128
|
+
code.indent();
|
|
4129
|
+
code.comment("Use stable query key to prevent unnecessary refetches");
|
|
4130
|
+
code.line(`const queryKey = useStableQueryKey('${pluralName}', options);`);
|
|
4131
|
+
code.block("return useQuery({", () => {
|
|
4132
|
+
code.line("queryKey,");
|
|
4133
|
+
code.line(`queryFn: () => api.${name}.list(options),`);
|
|
4134
|
+
code.line("enabled: options?.enabled ?? true,");
|
|
4135
|
+
}, "});");
|
|
4136
|
+
code.dedent();
|
|
4137
|
+
code.line("}");
|
|
4138
|
+
code.line();
|
|
4139
|
+
code.docComment(`Fetch single ${pascalName} by ID`);
|
|
4140
|
+
code.block(`export function use${pascalName}(id: string | undefined, options?: {`, () => {
|
|
4141
|
+
if (hasRelations) {
|
|
4142
|
+
code.line(`include?: Types.${pascalName}Include[];`);
|
|
4143
|
+
}
|
|
4144
|
+
code.line("enabled?: boolean;");
|
|
4145
|
+
}, "}) {");
|
|
4146
|
+
code.indent();
|
|
4147
|
+
if (hasRelations) {
|
|
4148
|
+
code.comment("Use stable query key for includes array");
|
|
4149
|
+
code.line(`const queryKey = useStableQueryKey('${pluralName}', id, options?.include);`);
|
|
4150
|
+
}
|
|
4151
|
+
code.block("return useQuery({", () => {
|
|
4152
|
+
if (hasRelations) {
|
|
4153
|
+
code.line("queryKey,");
|
|
4154
|
+
code.line(`queryFn: () => api.${name}.get(id!, { include: options?.include }),`);
|
|
4155
|
+
} else {
|
|
4156
|
+
code.line(`queryKey: ['${pluralName}', id],`);
|
|
4157
|
+
code.line(`queryFn: () => api.${name}.get(id!),`);
|
|
4158
|
+
}
|
|
4159
|
+
code.line("enabled: (options?.enabled ?? true) && !!id,");
|
|
4160
|
+
}, "});");
|
|
4161
|
+
code.dedent();
|
|
4162
|
+
code.line("}");
|
|
4163
|
+
code.line();
|
|
4164
|
+
for (const rel of relations) {
|
|
4165
|
+
const hookName = `use${pascalName}With${toPascalCase(rel.name)}`;
|
|
4166
|
+
code.docComment(`Fetch ${pascalName} with ${rel.name} included`);
|
|
4167
|
+
code.block(`export function ${hookName}(id: string | undefined) {`, () => {
|
|
4168
|
+
code.line(`return use${pascalName}(id, { include: ['${rel.name}'] });`);
|
|
4169
|
+
});
|
|
4170
|
+
code.line();
|
|
4171
|
+
}
|
|
4172
|
+
code.docComment(`Create a new ${pascalName}`);
|
|
4173
|
+
code.block(`export function useCreate${pascalName}() {`, () => {
|
|
4174
|
+
code.line("const queryClient = useQueryClient();");
|
|
4175
|
+
code.block("return useMutation({", () => {
|
|
4176
|
+
code.line(`mutationFn: (data: Types.${pascalName}Create) => api.${name}.create(data),`);
|
|
4177
|
+
code.block("onSuccess: () => {", () => {
|
|
4178
|
+
code.line(`queryClient.invalidateQueries({ queryKey: ['${pluralName}'] });`);
|
|
4179
|
+
}, "},");
|
|
4180
|
+
}, "});");
|
|
4181
|
+
});
|
|
4182
|
+
code.line();
|
|
4183
|
+
code.docComment(`Update an existing ${pascalName}`);
|
|
4184
|
+
code.block(`export function useUpdate${pascalName}() {`, () => {
|
|
4185
|
+
code.line("const queryClient = useQueryClient();");
|
|
4186
|
+
code.block("return useMutation({", () => {
|
|
4187
|
+
code.line(`mutationFn: ({ id, data }: { id: string; data: Types.${pascalName}Update }) =>`);
|
|
4188
|
+
code.line(` api.${name}.update(id, data),`);
|
|
4189
|
+
code.block("onSuccess: (_, { id }) => {", () => {
|
|
4190
|
+
code.line(`queryClient.invalidateQueries({ queryKey: ['${pluralName}'] });`);
|
|
4191
|
+
code.line(`queryClient.invalidateQueries({ queryKey: ['${pluralName}', id] });`);
|
|
4192
|
+
}, "},");
|
|
4193
|
+
}, "});");
|
|
4194
|
+
});
|
|
4195
|
+
code.line();
|
|
4196
|
+
code.docComment(`Delete a ${pascalName}`);
|
|
4197
|
+
code.block(`export function useDelete${pascalName}() {`, () => {
|
|
4198
|
+
code.line("const queryClient = useQueryClient();");
|
|
4199
|
+
code.block("return useMutation({", () => {
|
|
4200
|
+
code.line(`mutationFn: (id: string) => api.${name}.delete(id),`);
|
|
4201
|
+
code.block("onSuccess: () => {", () => {
|
|
4202
|
+
code.line(`queryClient.invalidateQueries({ queryKey: ['${pluralName}'] });`);
|
|
4203
|
+
}, "},");
|
|
4204
|
+
}, "});");
|
|
4205
|
+
});
|
|
4206
|
+
code.line();
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
// src/cli/commands/generate.ts
|
|
4210
|
+
async function generate(options) {
|
|
4211
|
+
console.log("\n\u{1F50D} Schemock Generate\n");
|
|
4212
|
+
const config = await loadConfig(options.config);
|
|
4213
|
+
const adapter = options.adapter || config.adapter || "mock";
|
|
4214
|
+
const outputDir = options.output || config.output || "./src/generated";
|
|
4215
|
+
console.log(` Adapter: ${adapter}`);
|
|
4216
|
+
console.log(` Output: ${outputDir}
|
|
4217
|
+
`);
|
|
4218
|
+
console.log("\u{1F4E6} Discovering schemas...");
|
|
4219
|
+
const { schemas, endpoints, files } = await discoverSchemas(config.schemas);
|
|
4220
|
+
for (const file of files) {
|
|
4221
|
+
console.log(` Found: ${getRelativePath(file)}`);
|
|
4222
|
+
}
|
|
4223
|
+
console.log(` Total: ${schemas.length} schemas, ${endpoints.length} endpoints
|
|
4224
|
+
`);
|
|
4225
|
+
const analyzed = analyzeSchemas(schemas, { ...config, adapter });
|
|
4226
|
+
const analyzedEndpoints = analyzeEndpoints(endpoints);
|
|
4227
|
+
if (options.verbose) {
|
|
4228
|
+
console.log("\u{1F4CA} Analyzed schemas:");
|
|
4229
|
+
for (const schema of analyzed) {
|
|
4230
|
+
console.log(` ${schema.pascalName}: ${schema.fields.length} fields, ${schema.relations.length} relations`);
|
|
4231
|
+
if (schema.isJunctionTable) {
|
|
4232
|
+
console.log(` (junction table)`);
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
if (analyzedEndpoints.length > 0) {
|
|
4236
|
+
console.log("\n\u{1F4CA} Analyzed endpoints:");
|
|
4237
|
+
for (const endpoint of analyzedEndpoints) {
|
|
4238
|
+
console.log(` ${endpoint.method} ${endpoint.path} -> ${endpoint.name}`);
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
console.log();
|
|
4242
|
+
}
|
|
4243
|
+
if (!options.dryRun) {
|
|
4244
|
+
await mkdir(outputDir, { recursive: true });
|
|
4245
|
+
}
|
|
4246
|
+
console.log("\u{1F4DD} Generating types...");
|
|
4247
|
+
let typesCode = generateTypes(analyzed);
|
|
4248
|
+
if (analyzedEndpoints.length > 0) {
|
|
4249
|
+
typesCode += generateEndpointTypes(analyzedEndpoints);
|
|
4250
|
+
}
|
|
4251
|
+
await writeOutput(join(outputDir, "types.ts"), typesCode, options.dryRun);
|
|
4252
|
+
const entityCount = analyzed.filter((s) => !s.isJunctionTable).length;
|
|
4253
|
+
const endpointInfo = analyzedEndpoints.length > 0 ? ` + ${analyzedEndpoints.length} endpoint types` : "";
|
|
4254
|
+
console.log(` \u2713 types.ts (${entityCount} entities + Create/Update/Filter types${endpointInfo})
|
|
4255
|
+
`);
|
|
4256
|
+
console.log(`\u{1F50C} Generating ${adapter} adapter...`);
|
|
4257
|
+
switch (adapter) {
|
|
4258
|
+
case "mock":
|
|
4259
|
+
await generateMockAdapter(analyzed, analyzedEndpoints, outputDir, config, options);
|
|
4260
|
+
break;
|
|
4261
|
+
case "supabase":
|
|
4262
|
+
await generateSupabaseAdapter(analyzed, outputDir, config, options);
|
|
4263
|
+
break;
|
|
4264
|
+
case "firebase":
|
|
4265
|
+
await generateFirebaseAdapter(analyzed, outputDir, config, options);
|
|
4266
|
+
break;
|
|
4267
|
+
case "fetch":
|
|
4268
|
+
await generateFetchAdapter(analyzed, outputDir, config, options);
|
|
4269
|
+
break;
|
|
4270
|
+
case "graphql":
|
|
4271
|
+
console.log(" \u26A0\uFE0F GraphQL adapter not yet implemented");
|
|
4272
|
+
break;
|
|
4273
|
+
case "pglite":
|
|
4274
|
+
await generatePGliteAdapter(analyzed, outputDir, config, options);
|
|
4275
|
+
break;
|
|
4276
|
+
default:
|
|
4277
|
+
throw new Error(`Unknown adapter: ${adapter}`);
|
|
4278
|
+
}
|
|
4279
|
+
console.log("\n\u269B\uFE0F Generating React hooks...");
|
|
4280
|
+
const hooksCode = generateHooks(analyzed);
|
|
4281
|
+
await writeOutput(join(outputDir, "hooks.ts"), hooksCode, options.dryRun);
|
|
4282
|
+
const hookCount = analyzed.filter((s) => !s.isJunctionTable).length * 5;
|
|
4283
|
+
console.log(` \u2713 hooks.ts (${hookCount} hooks)`);
|
|
4284
|
+
console.log("\n\u{1F4E6} Generating barrel exports...");
|
|
4285
|
+
const indexCode = generateIndex(adapter, analyzedEndpoints.length > 0);
|
|
4286
|
+
await writeOutput(join(outputDir, "index.ts"), indexCode, options.dryRun);
|
|
4287
|
+
console.log(" \u2713 index.ts");
|
|
4288
|
+
console.log(`
|
|
4289
|
+
\u2705 Generated ${adapter} adapter in ${outputDir}
|
|
4290
|
+
`);
|
|
4291
|
+
const firstSchema = analyzed.find((s) => !s.isJunctionTable);
|
|
4292
|
+
if (firstSchema) {
|
|
4293
|
+
console.log("Usage:");
|
|
4294
|
+
console.log(` import { use${firstSchema.pascalPluralName}, useCreate${firstSchema.pascalName} } from '${outputDir.replace("./", "")}';`);
|
|
4295
|
+
console.log("");
|
|
4296
|
+
}
|
|
4297
|
+
}
|
|
4298
|
+
async function generateMockAdapter(schemas, endpoints, outputDir, config, options) {
|
|
4299
|
+
const mockConfig = config.adapters?.mock || {};
|
|
4300
|
+
const hasEndpoints = endpoints.length > 0;
|
|
4301
|
+
const dbCode = generateMockDb(schemas, mockConfig);
|
|
4302
|
+
await writeOutput(join(outputDir, "db.ts"), dbCode, options.dryRun);
|
|
4303
|
+
console.log(` \u2713 db.ts (@mswjs/data factory with ${schemas.length} entities)`);
|
|
4304
|
+
const routesCode = generateRoutes(schemas);
|
|
4305
|
+
await writeOutput(join(outputDir, "routes.ts"), routesCode, options.dryRun);
|
|
4306
|
+
const entityCount = schemas.filter((s) => !s.isJunctionTable).length;
|
|
4307
|
+
console.log(` \u2713 routes.ts (${entityCount} entity route definitions)`);
|
|
4308
|
+
const handlersCode = generateMockHandlers(schemas, config.apiPrefix || "/api");
|
|
4309
|
+
await writeOutput(join(outputDir, "handlers.ts"), handlersCode, options.dryRun);
|
|
4310
|
+
const handlerCount = schemas.filter((s) => !s.isJunctionTable).length * 6;
|
|
4311
|
+
console.log(` \u2713 handlers.ts (${handlerCount} MSW handlers)`);
|
|
4312
|
+
const seedCode = generateSeed(schemas, mockConfig);
|
|
4313
|
+
await writeOutput(join(outputDir, "seed.ts"), seedCode, options.dryRun);
|
|
4314
|
+
console.log(" \u2713 seed.ts (seed/reset utilities)");
|
|
4315
|
+
const clientCode = generateMockClient(schemas);
|
|
4316
|
+
await writeOutput(join(outputDir, "client.ts"), clientCode, options.dryRun);
|
|
4317
|
+
console.log(" \u2713 client.ts (API client with relations support)");
|
|
4318
|
+
if (hasEndpoints) {
|
|
4319
|
+
console.log("\n\u{1F3AF} Generating custom endpoints...");
|
|
4320
|
+
const endpointClientCode = generateEndpointClient(endpoints);
|
|
4321
|
+
await writeOutput(join(outputDir, "endpoints.ts"), endpointClientCode, options.dryRun);
|
|
4322
|
+
console.log(` \u2713 endpoints.ts (${endpoints.length} endpoint client methods)`);
|
|
4323
|
+
const endpointHandlersCode = generateEndpointHandlers(endpoints);
|
|
4324
|
+
await writeOutput(join(outputDir, "endpoint-handlers.ts"), endpointHandlersCode, options.dryRun);
|
|
4325
|
+
console.log(` \u2713 endpoint-handlers.ts (${endpoints.length} MSW handlers)`);
|
|
4326
|
+
const endpointResolversCode = generateEndpointResolvers(endpoints);
|
|
4327
|
+
await writeOutput(join(outputDir, "endpoint-resolvers.ts"), endpointResolversCode, options.dryRun);
|
|
4328
|
+
console.log(` \u2713 endpoint-resolvers.ts (mock resolvers)`);
|
|
4329
|
+
}
|
|
4330
|
+
const allHandlersCode = generateAllHandlersExport(hasEndpoints);
|
|
4331
|
+
await writeOutput(join(outputDir, "all-handlers.ts"), allHandlersCode, options.dryRun);
|
|
4332
|
+
console.log(" \u2713 all-handlers.ts (combined handlers export)");
|
|
4333
|
+
}
|
|
4334
|
+
async function generateSupabaseAdapter(schemas, outputDir, config, options) {
|
|
4335
|
+
const supabaseConfig = config.adapters?.supabase || {};
|
|
4336
|
+
const clientCode = generateSupabaseClient(schemas, supabaseConfig);
|
|
4337
|
+
await writeOutput(join(outputDir, "client.ts"), clientCode, options.dryRun);
|
|
4338
|
+
console.log(" \u2713 client.ts (Supabase client)");
|
|
4339
|
+
}
|
|
4340
|
+
async function generateFirebaseAdapter(schemas, outputDir, config, options) {
|
|
4341
|
+
const firebaseConfig = config.adapters?.firebase || {};
|
|
4342
|
+
const clientCode = generateFirebaseClient(schemas, firebaseConfig);
|
|
4343
|
+
await writeOutput(join(outputDir, "client.ts"), clientCode, options.dryRun);
|
|
4344
|
+
console.log(" \u2713 client.ts (Firebase client)");
|
|
4345
|
+
}
|
|
4346
|
+
async function generateFetchAdapter(schemas, outputDir, config, options) {
|
|
4347
|
+
const fetchConfig = config.adapters?.fetch || {};
|
|
4348
|
+
const clientCode = generateFetchClient(schemas, fetchConfig);
|
|
4349
|
+
await writeOutput(join(outputDir, "client.ts"), clientCode, options.dryRun);
|
|
4350
|
+
console.log(" \u2713 client.ts (Fetch client)");
|
|
4351
|
+
}
|
|
4352
|
+
async function generatePGliteAdapter(schemas, outputDir, config, options) {
|
|
4353
|
+
const pgliteConfig = config.adapters?.pglite || {};
|
|
4354
|
+
const dbCode = generatePGliteDb(schemas, pgliteConfig);
|
|
4355
|
+
await writeOutput(join(outputDir, "db.ts"), dbCode, options.dryRun);
|
|
4356
|
+
console.log(` \u2713 db.ts (PGlite schema with ${schemas.length} tables)`);
|
|
4357
|
+
const clientCode = generatePGliteClient(schemas);
|
|
4358
|
+
await writeOutput(join(outputDir, "client.ts"), clientCode, options.dryRun);
|
|
4359
|
+
console.log(" \u2713 client.ts (SQL-based CRUD operations)");
|
|
4360
|
+
const seedCode = generatePGliteSeed(schemas, pgliteConfig);
|
|
4361
|
+
await writeOutput(join(outputDir, "seed.ts"), seedCode, options.dryRun);
|
|
4362
|
+
console.log(" \u2713 seed.ts (seed/reset utilities)");
|
|
4363
|
+
}
|
|
4364
|
+
function generateIndex(adapter, hasEndpoints = false) {
|
|
4365
|
+
const lines = [
|
|
4366
|
+
"// GENERATED BY SCHEMOCK - DO NOT EDIT",
|
|
4367
|
+
"",
|
|
4368
|
+
"export * from './types';",
|
|
4369
|
+
"export * from './hooks';",
|
|
4370
|
+
"export { api } from './client';"
|
|
4371
|
+
];
|
|
4372
|
+
if (adapter === "mock") {
|
|
4373
|
+
lines.push("export { db } from './db';");
|
|
4374
|
+
lines.push("export { handlers } from './handlers';");
|
|
4375
|
+
lines.push("export { allHandlers } from './all-handlers';");
|
|
4376
|
+
lines.push("export { seed, reset, getAll } from './seed';");
|
|
4377
|
+
if (hasEndpoints) {
|
|
4378
|
+
lines.push("export { endpoints } from './endpoints';");
|
|
4379
|
+
lines.push("export { endpointHandlers } from './endpoint-handlers';");
|
|
4380
|
+
}
|
|
4381
|
+
}
|
|
4382
|
+
if (adapter === "pglite") {
|
|
4383
|
+
lines.push("export { db, initDb, resetDb, tables } from './db';");
|
|
4384
|
+
lines.push("export { seed, reset, getAll, count } from './seed';");
|
|
4385
|
+
}
|
|
4386
|
+
if (adapter === "supabase") {
|
|
4387
|
+
lines.push("export { supabase } from './client';");
|
|
4388
|
+
}
|
|
4389
|
+
return lines.join("\n");
|
|
4390
|
+
}
|
|
4391
|
+
async function writeOutput(path, content, dryRun) {
|
|
4392
|
+
if (dryRun) {
|
|
4393
|
+
console.log(` [DRY RUN] Would write: ${path}`);
|
|
4394
|
+
return;
|
|
4395
|
+
}
|
|
4396
|
+
await writeFile(path, content, "utf-8");
|
|
4397
|
+
}
|
|
4398
|
+
|
|
4399
|
+
export { CodeBuilder, analyzeSchemas, defineConfig, discoverSchemas, fieldToFakerCall, fieldToTsType, generate, generateFetchClient, generateFirebaseClient, generateHooks, generateMockClient, generateMockDb, generateMockHandlers, generateSeed, generateSupabaseClient, generateTypes, getDefaultConfig, getRelativePath, loadConfig, pluralize, primitiveToTs, toCamelCase, toPascalCase };
|
|
4400
|
+
//# sourceMappingURL=index.mjs.map
|
|
4401
|
+
//# sourceMappingURL=index.mjs.map
|