@tertium/prisma-codegen 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +236 -0
- package/client/client.test.ts +275 -0
- package/client/client.ts +328 -0
- package/client/client.types.ts +33 -0
- package/dmmf/dmmf.types.ts +53 -0
- package/dmmf/dmmf.utils.ts +125 -0
- package/package.json +35 -0
- package/scripts/generate-client.ts +131 -0
- package/scripts/generate-server.ts +110 -0
- package/server/server.test.ts +352 -0
- package/server/server.ts +766 -0
- package/server/server.types.ts +72 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend code generation script — copy this into your project's scripts/ folder
|
|
3
|
+
* and fill in the Config section below.
|
|
4
|
+
*
|
|
5
|
+
* Run: bun scripts/generate-client.ts --api http://localhost:8080
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** Where to write generated entity files */
|
|
11
|
+
const ENTITIES_DIR = 'src/entities';
|
|
12
|
+
|
|
13
|
+
/** Path from entity files back to the entities root (used in cross-entity imports) */
|
|
14
|
+
const ENTITY_IMPORT_BASE = '../../entities';
|
|
15
|
+
|
|
16
|
+
/** Import path for the GraphQL request function */
|
|
17
|
+
const GRAPHQL_REQUEST_IMPORT = '../../core/graphql/graphql.client';
|
|
18
|
+
|
|
19
|
+
/** Import path for the types barrel (ApiList, PaginationInput, etc.) */
|
|
20
|
+
const API_TYPES_IMPORT = '../../core/graphql/graphql.types.auto';
|
|
21
|
+
|
|
22
|
+
/** Import path for the TableSchema type */
|
|
23
|
+
const TABLE_SCHEMA_IMPORT = '../../core/rest/rest.types';
|
|
24
|
+
|
|
25
|
+
/** Import path for the entity options loader service */
|
|
26
|
+
const OPTIONS_SERVICE_IMPORT = '../../core/graphql/graphql.service';
|
|
27
|
+
|
|
28
|
+
/** Where to write the GraphQL client barrel */
|
|
29
|
+
const GQL_CLIENT_BARREL_OUT = 'src/core/graphql/graphql.client.auto.ts';
|
|
30
|
+
|
|
31
|
+
/** Where to write the types barrel */
|
|
32
|
+
const GQL_TYPES_BARREL_OUT = 'src/core/graphql/graphql.types.auto.ts';
|
|
33
|
+
|
|
34
|
+
/** Where to write the enums file */
|
|
35
|
+
const GQL_ENUMS_OUT = 'src/core/graphql/graphql.enums.auto.ts';
|
|
36
|
+
|
|
37
|
+
/** Where to write the schemas barrel */
|
|
38
|
+
const SCHEMAS_BARREL_OUT = 'src/core/rest/rest.schemas.auto.ts';
|
|
39
|
+
|
|
40
|
+
/** Enum import path used inside the types barrel */
|
|
41
|
+
const ENUMS_IMPORT = './graphql.enums.auto';
|
|
42
|
+
|
|
43
|
+
/** Fields excluded from generated forms */
|
|
44
|
+
const SKIP_FIELDS = ['id', 'createdAt', 'updatedAt'];
|
|
45
|
+
|
|
46
|
+
/** Fields rendered as textarea in forms */
|
|
47
|
+
const LARGE_TEXT_FIELDS: string[] = [];
|
|
48
|
+
|
|
49
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs';
|
|
52
|
+
import { join } from 'path';
|
|
53
|
+
import type { EntityMeta, EnumMeta } from '@tertium/prisma-codegen/dmmf';
|
|
54
|
+
import {
|
|
55
|
+
generateClientTypesContent,
|
|
56
|
+
generateClientSchemaContent,
|
|
57
|
+
generateGraphQLClientContent,
|
|
58
|
+
generateClientBarrelContent,
|
|
59
|
+
generateTypesBarrelContent,
|
|
60
|
+
generateSchemasBarrelContent,
|
|
61
|
+
generateEnumsContent,
|
|
62
|
+
} from '@tertium/prisma-codegen/client';
|
|
63
|
+
|
|
64
|
+
const apiUrl = (() => {
|
|
65
|
+
const idx = process.argv.indexOf('--api');
|
|
66
|
+
if (idx === -1 || !process.argv[idx + 1]) {
|
|
67
|
+
throw new Error('--api <url> is required');
|
|
68
|
+
}
|
|
69
|
+
return process.argv[idx + 1];
|
|
70
|
+
})();
|
|
71
|
+
|
|
72
|
+
console.log(`\nFetching entity metadata from ${apiUrl}/entities ...\n`);
|
|
73
|
+
|
|
74
|
+
const data = (await fetch(`${apiUrl}/entities`).then((r) => r.json())) as unknown;
|
|
75
|
+
const entities: EntityMeta[] = Array.isArray(data) ? data : (data as any).entities;
|
|
76
|
+
const enums: EnumMeta[] = Array.isArray(data) ? [] : ((data as any).enums ?? []);
|
|
77
|
+
|
|
78
|
+
console.log(`Found ${entities.length} entities and ${enums.length} enums\n`);
|
|
79
|
+
|
|
80
|
+
function write(path: string, content: string): void {
|
|
81
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
82
|
+
writeFileSync(path, content, 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Remove entity dirs that no longer exist in the API
|
|
86
|
+
if (existsSync(ENTITIES_DIR)) {
|
|
87
|
+
const activeKebabs = new Set(entities.map((e) => e.kebab));
|
|
88
|
+
for (const entry of readdirSync(ENTITIES_DIR, { withFileTypes: true })) {
|
|
89
|
+
if (entry.isDirectory() && !activeKebabs.has(entry.name)) {
|
|
90
|
+
rmSync(join(ENTITIES_DIR, entry.name), { recursive: true, force: true });
|
|
91
|
+
console.log(` ✗ removed entities/${entry.name}/`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const entity of entities) {
|
|
97
|
+
const dir = join(ENTITIES_DIR, entity.kebab);
|
|
98
|
+
|
|
99
|
+
write(join(dir, `${entity.kebab}.types.auto.ts`),
|
|
100
|
+
generateClientTypesContent(entity, entities, enums, {
|
|
101
|
+
entityImportBase: ENTITY_IMPORT_BASE,
|
|
102
|
+
enumsImport: ENUMS_IMPORT,
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
write(join(dir, `${entity.kebab}.schema.auto.ts`),
|
|
106
|
+
generateClientSchemaContent(entity, {
|
|
107
|
+
tableSchemaImport: TABLE_SCHEMA_IMPORT,
|
|
108
|
+
optionsServiceImport: OPTIONS_SERVICE_IMPORT,
|
|
109
|
+
skipFields: SKIP_FIELDS,
|
|
110
|
+
largeTextFields: LARGE_TEXT_FIELDS,
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
write(join(dir, `${entity.kebab}.client.auto.ts`),
|
|
114
|
+
generateGraphQLClientContent(entity, {
|
|
115
|
+
graphqlRequestImport: GRAPHQL_REQUEST_IMPORT,
|
|
116
|
+
apiTypesImport: API_TYPES_IMPORT,
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
console.log(` ✓ entities/${entity.kebab}/`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
write(GQL_CLIENT_BARREL_OUT, generateClientBarrelContent(entities, { entityImportBase: ENTITY_IMPORT_BASE }));
|
|
123
|
+
write(GQL_TYPES_BARREL_OUT, generateTypesBarrelContent(entities, enums, { entityImportBase: ENTITY_IMPORT_BASE, enumsImport: ENUMS_IMPORT }));
|
|
124
|
+
write(SCHEMAS_BARREL_OUT, generateSchemasBarrelContent(entities, { entityImportBase: ENTITY_IMPORT_BASE }));
|
|
125
|
+
write(GQL_ENUMS_OUT, generateEnumsContent(enums));
|
|
126
|
+
|
|
127
|
+
console.log(`\n ✓ ${GQL_CLIENT_BARREL_OUT}`);
|
|
128
|
+
console.log(` ✓ ${GQL_TYPES_BARREL_OUT}`);
|
|
129
|
+
console.log(` ✓ ${SCHEMAS_BARREL_OUT}`);
|
|
130
|
+
console.log(` ✓ ${GQL_ENUMS_OUT}`);
|
|
131
|
+
console.log(`\n✅ Done — ${entities.length} entities generated.\n`);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend code generation script — copy this into your project's scripts/ folder
|
|
3
|
+
* and fill in the Config section below.
|
|
4
|
+
*
|
|
5
|
+
* Run: bun scripts/generate-server.ts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** Import path to your generated PrismaClient */
|
|
11
|
+
const PRISMA_CLIENT_IMPORT = './generated/prisma/client';
|
|
12
|
+
|
|
13
|
+
/** Import path to your Prisma singleton (used inside generated handlers) */
|
|
14
|
+
const PRISMA_SINGLETON_PATH = '../db/prisma';
|
|
15
|
+
|
|
16
|
+
/** Import path to your base GraphQL context interface */
|
|
17
|
+
const GRAPHQL_CONTEXT_PATH = './graphql.context';
|
|
18
|
+
|
|
19
|
+
/** Where to write entity files */
|
|
20
|
+
const ENTITIES_DIR = 'src/entities';
|
|
21
|
+
|
|
22
|
+
/** Where to write the combined REST router */
|
|
23
|
+
const REST_ROUTER_OUT = 'src/core/rest.router.auto.ts';
|
|
24
|
+
|
|
25
|
+
/** Where to write the combined GraphQL resolvers */
|
|
26
|
+
const GRAPHQL_RESOLVERS_OUT = 'src/core/graphql.resolvers.auto.ts';
|
|
27
|
+
|
|
28
|
+
/** String fields whose names match these patterns become full-text searchable */
|
|
29
|
+
const SEARCHABLE_PATTERNS: RegExp[] = [/name/i, /title/i];
|
|
30
|
+
|
|
31
|
+
/** Int fields whose names match these patterns are treated as enum-like (filterable with 'equals') */
|
|
32
|
+
const ENUM_INT_PATTERNS: RegExp[] = [];
|
|
33
|
+
|
|
34
|
+
/** Field names excluded from filterable inference */
|
|
35
|
+
const SKIP_FILTERABLE: string[] = [];
|
|
36
|
+
|
|
37
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'fs';
|
|
40
|
+
import { join } from 'path';
|
|
41
|
+
import type { DMMFModel } from '@tertium/prisma-codegen/dmmf';
|
|
42
|
+
import {
|
|
43
|
+
parsePrismaModels,
|
|
44
|
+
toKebabCase,
|
|
45
|
+
inferEntityMetadata,
|
|
46
|
+
generateEntityTypesContent,
|
|
47
|
+
generateRestHandlerContent,
|
|
48
|
+
generateRestRouterContent,
|
|
49
|
+
generateGraphQLResolversContent,
|
|
50
|
+
} from '@tertium/prisma-codegen/server';
|
|
51
|
+
|
|
52
|
+
function getDMMFModels(): DMMFModel[] {
|
|
53
|
+
const { PrismaClient } = require(join(process.cwd(), PRISMA_CLIENT_IMPORT.replace(/^\.\//, '')));
|
|
54
|
+
const pc = new PrismaClient();
|
|
55
|
+
const runtime = (pc as any)._runtimeDataModel;
|
|
56
|
+
return Object.entries(runtime.models as Record<string, { fields: any[]; dbName?: string | null }>)
|
|
57
|
+
.map(([name, m]) => ({ name, dbName: m.dbName, fields: m.fields }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dmmfModels = getDMMFModels();
|
|
61
|
+
const models = parsePrismaModels(dmmfModels);
|
|
62
|
+
const metadata = inferEntityMetadata(dmmfModels, {
|
|
63
|
+
searchableFieldPatterns: SEARCHABLE_PATTERNS,
|
|
64
|
+
enumLikeIntPatterns: ENUM_INT_PATTERNS,
|
|
65
|
+
skipFilterableFields: SKIP_FILTERABLE,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
console.log(`\n🔄 Generating server code for ${models.length} models...\n`);
|
|
69
|
+
|
|
70
|
+
// Remove entity dirs that no longer exist in schema
|
|
71
|
+
if (existsSync(ENTITIES_DIR)) {
|
|
72
|
+
const activeKebabs = new Set(models.map((m) => toKebabCase(m.name)));
|
|
73
|
+
for (const entry of readdirSync(ENTITIES_DIR, { withFileTypes: true })) {
|
|
74
|
+
if (entry.isDirectory() && !activeKebabs.has(entry.name)) {
|
|
75
|
+
rmSync(join(ENTITIES_DIR, entry.name), { recursive: true, force: true });
|
|
76
|
+
console.log(` ✗ removed ${entry.name}/`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const model of models) {
|
|
82
|
+
const kebab = toKebabCase(model.name);
|
|
83
|
+
const dir = join(ENTITIES_DIR, kebab);
|
|
84
|
+
mkdirSync(dir, { recursive: true });
|
|
85
|
+
|
|
86
|
+
writeFileSync(join(dir, `${kebab}.types.auto.ts`), generateEntityTypesContent(model));
|
|
87
|
+
writeFileSync(
|
|
88
|
+
join(dir, `${kebab}.rest.auto.ts`),
|
|
89
|
+
generateRestHandlerContent(model.name, metadata[model.name] ?? {}, {
|
|
90
|
+
prismaClientPath: PRISMA_SINGLETON_PATH,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
console.log(` ✓ entities/${kebab}/`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
mkdirSync(REST_ROUTER_OUT.replace(/\/[^/]+$/, ''), { recursive: true });
|
|
98
|
+
writeFileSync(REST_ROUTER_OUT, generateRestRouterContent(models, {
|
|
99
|
+
entityImportBase: `../${ENTITIES_DIR.split('/').pop()}`,
|
|
100
|
+
}));
|
|
101
|
+
console.log(`\n ✓ ${REST_ROUTER_OUT}`);
|
|
102
|
+
|
|
103
|
+
mkdirSync(GRAPHQL_RESOLVERS_OUT.replace(/\/[^/]+$/, ''), { recursive: true });
|
|
104
|
+
writeFileSync(GRAPHQL_RESOLVERS_OUT, generateGraphQLResolversContent(metadata, dmmfModels, {
|
|
105
|
+
prismaClientPath: PRISMA_CLIENT_IMPORT,
|
|
106
|
+
contextTypePath: GRAPHQL_CONTEXT_PATH,
|
|
107
|
+
}));
|
|
108
|
+
console.log(` ✓ ${GRAPHQL_RESOLVERS_OUT}`);
|
|
109
|
+
|
|
110
|
+
console.log(`\n✅ Done — ${models.length} entities generated.\n`);
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
generateGraphQLResolversContent,
|
|
4
|
+
generateRestHandlerContent,
|
|
5
|
+
generateRestRouterContent,
|
|
6
|
+
parsePrismaModels,
|
|
7
|
+
} from './server';
|
|
8
|
+
import type { DMMFModel } from '../dmmf/dmmf.types';
|
|
9
|
+
|
|
10
|
+
// ── Shared fixtures ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
const PRISMA_PATH = '../../../generated/prisma/client';
|
|
13
|
+
const CONTEXT_PATH = './api-graphql.types.auto';
|
|
14
|
+
|
|
15
|
+
const dmmfModels: DMMFModel[] = [
|
|
16
|
+
{
|
|
17
|
+
name: 'Author',
|
|
18
|
+
dbName: null,
|
|
19
|
+
fields: [
|
|
20
|
+
{ name: 'id', kind: 'scalar', type: 'String', isRequired: true, isList: false, isId: true },
|
|
21
|
+
{ name: 'name', kind: 'scalar', type: 'String', isRequired: true, isList: false, isId: false },
|
|
22
|
+
{ name: 'bio', kind: 'scalar', type: 'String', isRequired: false, isList: false, isId: false },
|
|
23
|
+
{ name: 'categoryId', kind: 'scalar', type: 'String', isRequired: false, isList: false, isId: false },
|
|
24
|
+
{
|
|
25
|
+
name: 'Category',
|
|
26
|
+
kind: 'object',
|
|
27
|
+
type: 'Category',
|
|
28
|
+
isRequired: false,
|
|
29
|
+
isList: false,
|
|
30
|
+
isId: false,
|
|
31
|
+
relationName: 'AuthorToCategory',
|
|
32
|
+
relationFromFields: ['categoryId'],
|
|
33
|
+
relationToFields: ['id'],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'Category',
|
|
39
|
+
dbName: null,
|
|
40
|
+
fields: [
|
|
41
|
+
{ name: 'id', kind: 'scalar', type: 'String', isRequired: true, isList: false, isId: true },
|
|
42
|
+
{ name: 'name', kind: 'scalar', type: 'String', isRequired: true, isList: false, isId: false },
|
|
43
|
+
{
|
|
44
|
+
name: 'Author',
|
|
45
|
+
kind: 'object',
|
|
46
|
+
type: 'Author',
|
|
47
|
+
isRequired: false,
|
|
48
|
+
isList: true,
|
|
49
|
+
isId: false,
|
|
50
|
+
relationName: 'AuthorToCategory',
|
|
51
|
+
relationFromFields: [],
|
|
52
|
+
relationToFields: [],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const graphqlMetadata = {
|
|
59
|
+
Author: {
|
|
60
|
+
filterable: { name: 'contains' as const, categoryId: 'equals' as const },
|
|
61
|
+
searchableFields: ['name'],
|
|
62
|
+
includeRelations: ['Category'],
|
|
63
|
+
},
|
|
64
|
+
Category: {
|
|
65
|
+
filterable: { name: 'contains' as const },
|
|
66
|
+
searchableFields: ['name'],
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ── generateGraphQLResolversContent ──────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe('generateGraphQLResolversContent', () => {
|
|
73
|
+
describe('without localization', () => {
|
|
74
|
+
const output = generateGraphQLResolversContent(graphqlMetadata, dmmfModels, {
|
|
75
|
+
prismaClientPath: PRISMA_PATH,
|
|
76
|
+
contextTypePath: CONTEXT_PATH,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('imports PrismaClient', () => {
|
|
80
|
+
expect(output).toContain(`import { PrismaClient } from '${PRISMA_PATH}'`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('does not import any localization function', () => {
|
|
84
|
+
expect(output).not.toContain('localizeEntity');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('resolvers do not have a lang parameter', () => {
|
|
88
|
+
const authorBlock = output.slice(output.indexOf('author:'), output.indexOf('authorList:'));
|
|
89
|
+
expect(authorBlock).not.toContain('lang');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('generates Query and Mutation resolvers for each model', () => {
|
|
93
|
+
expect(output).toContain('author:');
|
|
94
|
+
expect(output).toContain('authorList:');
|
|
95
|
+
expect(output).toContain('createAuthor:');
|
|
96
|
+
expect(output).toContain('updateAuthor:');
|
|
97
|
+
expect(output).toContain('deleteAuthor:');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('with localization', () => {
|
|
102
|
+
const output = generateGraphQLResolversContent(graphqlMetadata, dmmfModels, {
|
|
103
|
+
prismaClientPath: PRISMA_PATH,
|
|
104
|
+
contextTypePath: CONTEXT_PATH,
|
|
105
|
+
localization: { localizeImport: '../localization/localization.entity' },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('imports the consumer localizeEntity function', () => {
|
|
109
|
+
expect(output).toContain("import { localizeEntity } from '../localization/localization.entity'");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('calls localizeEntity with 3 args in single resolver', () => {
|
|
113
|
+
expect(output).toContain("await localizeEntity(data, 'Author', lang)");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('calls localizeEntity with 3 args in list resolver', () => {
|
|
117
|
+
expect(output).toContain("localizeEntity(item, 'Author', lang)");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('adds lang parameter to single resolver args', () => {
|
|
121
|
+
expect(output).toContain("{ id, lang }: { id: string; lang?: string }");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('custom localizeExport name', () => {
|
|
126
|
+
const output = generateGraphQLResolversContent(graphqlMetadata, dmmfModels, {
|
|
127
|
+
prismaClientPath: PRISMA_PATH,
|
|
128
|
+
contextTypePath: CONTEXT_PATH,
|
|
129
|
+
localization: { localizeImport: './translate', localizeExport: 'translateEntity' },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('uses the custom export name in import', () => {
|
|
133
|
+
expect(output).toContain("import { translateEntity } from './translate'");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('calls the custom function by its export name', () => {
|
|
137
|
+
expect(output).toContain("await translateEntity(data, 'Author', lang)");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('FK to relation transform', () => {
|
|
142
|
+
const output = generateGraphQLResolversContent(graphqlMetadata, dmmfModels, {
|
|
143
|
+
prismaClientPath: PRISMA_PATH,
|
|
144
|
+
contextTypePath: CONTEXT_PATH,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('generates a transform function for models with FK fields', () => {
|
|
148
|
+
expect(output).toContain('function transformAuthorInputToPrisma');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('transform connects FK field to relation', () => {
|
|
152
|
+
expect(output).toContain("result.Category = { connect: { id: value } }");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('custom context type', () => {
|
|
157
|
+
const output = generateGraphQLResolversContent(graphqlMetadata, dmmfModels, {
|
|
158
|
+
prismaClientPath: PRISMA_PATH,
|
|
159
|
+
contextTypePath: CONTEXT_PATH,
|
|
160
|
+
contextTypeExport: 'MyBaseContext',
|
|
161
|
+
prismaClientExport: 'MyPrismaClient',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('uses custom PrismaClient export name', () => {
|
|
165
|
+
expect(output).toContain('import { MyPrismaClient }');
|
|
166
|
+
expect(output).toContain('prisma: MyPrismaClient;');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('uses custom context type export name', () => {
|
|
170
|
+
expect(output).toContain('import type { MyBaseContext }');
|
|
171
|
+
expect(output).toContain('extends MyBaseContext');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ── generateRestHandlerContent ────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
const REST_PRISMA_PATH = '../../db/prisma.client';
|
|
179
|
+
const handlerMetadata = { filterable: { name: 'contains' as const }, searchableFields: ['name', 'title'] };
|
|
180
|
+
|
|
181
|
+
describe('generateRestHandlerContent', () => {
|
|
182
|
+
describe('without localization', () => {
|
|
183
|
+
const output = generateRestHandlerContent('Author', handlerMetadata, { prismaClientPath: REST_PRISMA_PATH });
|
|
184
|
+
|
|
185
|
+
it('imports the Prisma client', () => {
|
|
186
|
+
expect(output).toContain(`import prisma from '${REST_PRISMA_PATH}'`);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('does not import any localization function', () => {
|
|
190
|
+
expect(output).not.toContain('localizeEntity');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('list signature has no lang parameter', () => {
|
|
194
|
+
expect(output).toContain('listAuthors(req: Request): Promise<Response>');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('generates the five CRUD functions', () => {
|
|
198
|
+
expect(output).toContain('export async function listAuthors(');
|
|
199
|
+
expect(output).toContain('export async function getAuthor(');
|
|
200
|
+
expect(output).toContain('export async function createAuthor(');
|
|
201
|
+
expect(output).toContain('export async function updateAuthor(');
|
|
202
|
+
expect(output).toContain('export async function deleteAuthor(');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('with localization', () => {
|
|
207
|
+
const output = generateRestHandlerContent('Author', handlerMetadata, {
|
|
208
|
+
prismaClientPath: REST_PRISMA_PATH,
|
|
209
|
+
localization: { localizeImport: '../../core/localization/localization.entity' },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('imports only the consumer localizeEntity function', () => {
|
|
213
|
+
expect(output).toContain("import { localizeEntity } from '../../core/localization/localization.entity'");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('list signature accepts lang as an explicit parameter', () => {
|
|
217
|
+
expect(output).toContain('listAuthors(req: Request, lang?: string): Promise<Response>');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('calls localizeEntity with 3 args in list', () => {
|
|
221
|
+
expect(output).toContain("localizeEntity(item, 'Author', lang)");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('calls localizeEntity with 3 args in get', () => {
|
|
225
|
+
expect(output).toContain("localizeEntity(data, 'Author', lang)");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('custom localizeExport name', () => {
|
|
230
|
+
const output = generateRestHandlerContent('Post', {}, {
|
|
231
|
+
prismaClientPath: REST_PRISMA_PATH,
|
|
232
|
+
localization: { localizeImport: './my-localize', localizeExport: 'myLocalizer' },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('uses the custom export name in import', () => {
|
|
236
|
+
expect(output).toContain("import { myLocalizer } from './my-localize'");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('calls the custom function by its export name', () => {
|
|
240
|
+
expect(output).toContain("myLocalizer(item, 'Post', lang)");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('modelName propagation', () => {
|
|
245
|
+
it('embeds the correct model name in localize calls', () => {
|
|
246
|
+
const output = generateRestHandlerContent('UserProfile', {}, {
|
|
247
|
+
prismaClientPath: REST_PRISMA_PATH,
|
|
248
|
+
localization: { localizeImport: './localize' },
|
|
249
|
+
});
|
|
250
|
+
expect(output).toContain("localizeEntity(item, 'UserProfile', lang)");
|
|
251
|
+
expect(output).toContain("localizeEntity(data, 'UserProfile', lang)");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ── generateRestRouterContent ─────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
const routerModels = parsePrismaModels([
|
|
259
|
+
{ name: 'Author', dbName: null, fields: [
|
|
260
|
+
{ name: 'id', kind: 'scalar', type: 'String', isRequired: true, isList: false, isId: true },
|
|
261
|
+
{ name: 'name', kind: 'scalar', type: 'String', isRequired: true, isList: false, isId: false },
|
|
262
|
+
]},
|
|
263
|
+
{ name: 'Post', dbName: null, fields: [
|
|
264
|
+
{ name: 'id', kind: 'scalar', type: 'String', isRequired: true, isList: false, isId: true },
|
|
265
|
+
{ name: 'name', kind: 'scalar', type: 'String', isRequired: true, isList: false, isId: false },
|
|
266
|
+
]},
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
describe('generateRestRouterContent', () => {
|
|
270
|
+
describe('without localization', () => {
|
|
271
|
+
const output = generateRestRouterContent(routerModels, { entityImportBase: '../entities' });
|
|
272
|
+
|
|
273
|
+
it('does not import a getLang function', () => {
|
|
274
|
+
expect(output).not.toContain('getLanguageFromRequest');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('calls list handler with only req', () => {
|
|
278
|
+
expect(output).toContain('listAuthors(req)');
|
|
279
|
+
expect(output).not.toContain('listAuthors(req, lang)');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('dispatches all models', () => {
|
|
283
|
+
expect(output).toContain("entity === 'authors'");
|
|
284
|
+
expect(output).toContain("entity === 'posts'");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('with localization', () => {
|
|
289
|
+
const output = generateRestRouterContent(routerModels, {
|
|
290
|
+
entityImportBase: '../entities',
|
|
291
|
+
localization: { getLangImport: '../localization/localization.utils', getLangExport: 'getLanguageFromRequest' },
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('imports the getLang function', () => {
|
|
295
|
+
expect(output).toContain("import { getLanguageFromRequest } from '../localization/localization.utils'");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('extracts lang once before the dispatch block', () => {
|
|
299
|
+
expect(output).toContain('const lang = getLanguageFromRequest(req)');
|
|
300
|
+
const count = (output.match(/getLanguageFromRequest\(req\)/g) ?? []).length;
|
|
301
|
+
expect(count).toBe(1);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('passes lang to list and get handlers', () => {
|
|
305
|
+
expect(output).toContain('listAuthors(req, lang)');
|
|
306
|
+
expect(output).toContain('getAuthor(id, lang)');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('does not pass lang to create/update/delete', () => {
|
|
310
|
+
expect(output).toContain('createAuthor(req)');
|
|
311
|
+
expect(output).not.toContain('createAuthor(req, lang)');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('lang is declared outside the try block', () => {
|
|
315
|
+
const langPos = output.indexOf('const lang =');
|
|
316
|
+
const tryPos = output.indexOf('try {');
|
|
317
|
+
expect(langPos).toBeGreaterThan(0);
|
|
318
|
+
expect(langPos).toBeLessThan(tryPos);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe('custom getLangExport name', () => {
|
|
323
|
+
const output = generateRestRouterContent(routerModels, {
|
|
324
|
+
entityImportBase: '../entities',
|
|
325
|
+
localization: { getLangImport: './lang', getLangExport: 'extractLang' },
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('uses the custom export name', () => {
|
|
329
|
+
expect(output).toContain("import { extractLang } from './lang'");
|
|
330
|
+
expect(output).toContain('const lang = extractLang(req)');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe('extra routes and imports', () => {
|
|
335
|
+
const output = generateRestRouterContent(routerModels, {
|
|
336
|
+
entityImportBase: '../entities',
|
|
337
|
+
extraImports: `import { handleAuth } from './auth';`,
|
|
338
|
+
extraRoutes: ` if (pathname === '/api/auth') return handleAuth(req);`,
|
|
339
|
+
localization: { getLangImport: './lang' },
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('includes extra imports', () => {
|
|
343
|
+
expect(output).toContain("import { handleAuth } from './auth'");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('includes extra routes before entity dispatch', () => {
|
|
347
|
+
const extraPos = output.indexOf('/api/auth');
|
|
348
|
+
const entityPos = output.indexOf("entity === 'authors'");
|
|
349
|
+
expect(extraPos).toBeLessThan(entityPos);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
});
|