Skip to content

Commit 3c4e92d

Browse files
this version brings dynamic swagger generation for crud apis for better testing and dev experience
1 parent 9923e63 commit 3c4e92d

File tree

5 files changed

+175
-31
lines changed

5 files changed

+175
-31
lines changed

package.json

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@januscaler/tsed-helper",
3-
"version": "2.3.3",
3+
"version": "2.3.4",
44
"type": "module",
55
"publishConfig": {
66
"@januscaler:registry": "https://npm.pkg.github.com"
@@ -18,28 +18,30 @@
1818
"dist/**/*"
1919
],
2020
"scripts": {
21-
"build": "tsc"
21+
"build": "tsc",
22+
"prettier": "prettier --write 'src/**/*.ts' 'test/**/*.ts'"
2223
},
2324
"keywords": [],
2425
"author": "",
2526
"license": "ISC",
2627
"description": "",
2728
"devDependencies": {
28-
"@types/node": "24.5.0",
29-
"typescript": "5.9.2"
29+
"@types/node": "25.0.9",
30+
"typescript": "5.9.3"
3031
},
3132
"dependencies": {
32-
"@prisma/client": "6.16.2",
33-
"@prisma/internals": "6.16.2",
34-
"@tsed/common": "8.16.2",
35-
"@tsed/core": "8.16.2",
36-
"@tsed/di": "8.16.2",
37-
"@tsed/prisma": "8.16.2",
38-
"@tsed/schema": "8.16.2",
39-
"@types/lodash": "^4.17.20",
33+
"@prisma/client": "6.19.2",
34+
"@prisma/internals": "6.19.2",
35+
"@tsed/common": "8.23.0",
36+
"@tsed/core": "8.23.0",
37+
"@tsed/di": "8.23.0",
38+
"@tsed/prisma": "8.23.0",
39+
"@tsed/schema": "8.23.0",
40+
"@types/lodash": "^4.17.23",
4041
"aigle": "1.14.1",
41-
"glob": "11.0.3",
42+
"glob": "13.0.0",
4243
"lodash": "4.17.21",
44+
"prettier": "^3.8.0",
4345
"rxjs": "7.8.2"
4446
}
4547
}

src/baseCrud.ts

Lines changed: 144 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { useDecorators } from '@tsed/core';
2-
import { Delete, Get, inject, Post, Put, } from '@tsed/common';
3-
import { CollectionOf, Default, Example, Property, Required, Returns, Summary } from '@tsed/schema';
2+
import { Delete, Get, Post, Put, } from '@tsed/common';
3+
import { CollectionOf, Default, Enum, Example, Property, Required, Returns, Summary } from '@tsed/schema';
44
import _ from 'lodash';
55
import { SearchFilterRecord } from './types.js';
6+
import { PrismaMetaMapper } from './prismaMetaMapper.js';
7+
import aigle from 'aigle';
8+
const { Aigle } = aigle;
69

710
function nameWithoutModel(model: any): string {
811
return _.replace(model.name, 'Model', '');
@@ -98,3 +101,142 @@ export function getItems(options: GetItems): Function {
98101
.Description(`Return a list of ${nameWithoutModel(model)}`)
99102
);
100103
}
104+
105+
106+
107+
108+
export type FilterMode =
109+
| "EM"
110+
| "EQ"
111+
| "EX"
112+
| "LT"
113+
| "LTE"
114+
| "GT"
115+
| "GTE"
116+
| "NEM"
117+
| "RG";
118+
119+
export const FilterModeEnum: FilterMode[] = [
120+
"EM", "EQ", "EX", "LT", "LTE", "GT", "GTE", "NEM", "RG"
121+
];
122+
123+
// Typed filter record
124+
export type SearchFilter<TFields extends string> = {
125+
[K in TFields]: {
126+
mode: FilterMode;
127+
value: any;
128+
isRelation?: boolean;
129+
};
130+
};
131+
132+
export class BaseSearchParams {
133+
@Default(10)
134+
limit?: number;
135+
136+
@Default(0)
137+
offset?: number;
138+
}
139+
140+
export class FilterItemModel {
141+
@Enum(FilterModeEnum)
142+
mode!: FilterMode;
143+
@Property()
144+
value!: any;
145+
@Property()
146+
isRelation?: boolean;
147+
}
148+
149+
export async function makeSearchParamsForPrismaModel<TField extends string>(model: string) {
150+
const entityFieldMapping = await PrismaMetaMapper.getEntityFieldMapping(PrismaMetaMapper.normalizeEntityName(model));
151+
const scalarExamples = _.transform(entityFieldMapping, (result, value, key) => {
152+
if (!value.isList && !value.relationName) {
153+
result.push(key as TField);
154+
}
155+
}, [] as TField[]);
156+
const relationExamples = await Aigle.transform(entityFieldMapping, async (result, value, key) => {
157+
if (value.isList || value.relationName) {
158+
const relationFieldMapping = await PrismaMetaMapper.getEntityFieldMapping(PrismaMetaMapper.normalizeEntityName(value.type));
159+
for (const [relationFieldName, relationField] of Object.entries(relationFieldMapping)) {
160+
if (!relationField.isList && !relationField.relationName) {
161+
result.push(`${key}.${relationFieldName}` as TField);
162+
}
163+
}
164+
}
165+
}, [] as TField[]);
166+
return makeSearchParamsFor<TField>([...scalarExamples, ...relationExamples], entityFieldMapping);
167+
}
168+
169+
function getPrismaExample(fieldInfo: { type: string; isArray?: boolean }) {
170+
let example: any;
171+
172+
switch (fieldInfo.type) {
173+
case "String":
174+
example = "example";
175+
break;
176+
case "Int":
177+
example = 123;
178+
break;
179+
case "BigInt":
180+
example = 123n;
181+
break;
182+
case "Float":
183+
example = 12.34;
184+
break;
185+
case "Decimal":
186+
example = "12.34"; // Prisma Decimal is a string
187+
break;
188+
case "Boolean":
189+
example = true;
190+
break;
191+
case "DateTime":
192+
example = new Date().toISOString();
193+
break;
194+
case "Json":
195+
example = { key: "value" };
196+
break;
197+
case "Bytes":
198+
example = "AA=="; // base64 string
199+
break;
200+
default:
201+
example = "example";
202+
}
203+
204+
if (fieldInfo.isArray) {
205+
return [example];
206+
}
207+
208+
return example;
209+
}
210+
211+
export function makeSearchParamsFor<TField extends string>(examples: TField[], entityFieldMapping?: Record<string, any>) {
212+
const filterExample = [
213+
examples.reduce((acc, f) => {
214+
if (!f.includes('.')) {
215+
const fieldInfo = entityFieldMapping ? entityFieldMapping[f] : null;
216+
acc[f] = { mode: "EQ", value: getPrismaExample(fieldInfo) };
217+
}
218+
return acc;
219+
}, {} as Record<TField, any>)
220+
]
221+
const orderByExample = examples.reduce((acc, f) => {
222+
if (!f.includes('.')) {
223+
acc[f] = "asc";
224+
}
225+
return acc;
226+
}, {} as Record<TField, "asc" | "desc">);
227+
class DynamicSearchParams extends BaseSearchParams {
228+
@Example(examples)
229+
@Required(true)
230+
@CollectionOf(String)
231+
fields!: TField[];
232+
@Example(orderByExample)
233+
@CollectionOf(Object)
234+
orderBy: Record<TField, "asc" | "desc">;
235+
@Property()
236+
@CollectionOf(Object)
237+
@Example(filterExample)
238+
filters: SearchFilter<TField>[];
239+
}
240+
241+
return DynamicSearchParams;
242+
}

src/baseService.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,8 @@ export class BaseService<T, M> implements OnInit, IBaseService<M> {
9898

9999

100100
async $onInit(): Promise<any> {
101-
const prismaMapper = new PrismaMetaMapper(this.prismaFilePath)
102-
this.tablesInfo = await prismaMapper.getTablesInfo()
101+
PrismaMetaMapper.relativePrismaFilePath = this.prismaFilePath
102+
this.tablesInfo = await PrismaMetaMapper.getTablesInfo()
103103
}
104104

105105
/**
@@ -379,7 +379,7 @@ export class BaseService<T, M> implements OnInit, IBaseService<M> {
379379
const properties = {
380380
skip: offset,
381381
take: limit,
382-
orderBy: orderBy,
382+
orderBy: _.map(orderBy, (value, key) => ({ [key]: value })),
383383
where: prismaWhere,
384384
select: selectFields
385385
};

src/prismaMetaMapper.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
import _ from 'lodash';
22
import pi from '@prisma/internals';
3-
import { join, dirname } from 'path'
43
import { DMMF, ReadonlyDeep } from '@prisma/client/runtime/library';
5-
import { fileURLToPath } from 'url';
64
import { readFile } from 'fs/promises';
7-
8-
const __filename = fileURLToPath(import.meta.url);
9-
const __dirname = dirname(__filename);
105
const { getDMMF } = pi
116

127
export interface PrismaMapperEntity {
@@ -46,32 +41,36 @@ export interface PrismaMapperEntityField {
4641

4742
export class PrismaMetaMapper {
4843

49-
constructor(protected relativePrismaFilePath = "./prisma/schema.prisma") { }
44+
static relativePrismaFilePath: string = "./prisma/schema.prisma"
45+
46+
static normalizeEntityName(entityName: string) {
47+
return _.upperFirst(_.camelCase(_.split(entityName, 'Model')[0])) as string
48+
}
5049

51-
async getDMMF(): Promise<ReadonlyDeep<{
50+
static async getDMMF(overrideRelativePrismaFilePath?: string): Promise<ReadonlyDeep<{
5251
datamodel: DMMF.Datamodel;
5352
schema: DMMF.Schema;
5453
mappings: DMMF.Mappings;
5554
}>> {
5655
const dmmf = await getDMMF({
57-
datamodel: await readFile(this.relativePrismaFilePath, 'utf-8')
56+
datamodel: await readFile(overrideRelativePrismaFilePath ?? this.relativePrismaFilePath, 'utf-8')
5857
})
5958
return dmmf
6059
}
6160

62-
async getEntity(entityName: string) {
61+
static async getEntity(entityName: string) {
6362
const tablesInfo = await this.getTablesInfo()
6463
return tablesInfo[entityName]
6564
}
6665

67-
async getEntityFieldMapping(entityName: string) {
66+
static async getEntityFieldMapping(entityName: string) {
6867
const { fields } = await this.getEntity(entityName)
6968
return _.transform(fields, (result, field) => {
7069
result[field.name] = field;
7170
}, {}) as Promise<Record<string, PrismaMapperEntityField>>
7271
}
7372

74-
async getTablesInfo() {
73+
static async getTablesInfo() {
7574
const dmmf = await this.getDMMF()
7675
return _.transform(dmmf.datamodel.models, (finalInfoMap, value) => {
7776
finalInfoMap[value.name] = value

src/seederHelper.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import aigle from 'aigle'
33
import { sync } from 'glob'
44
import { join, basename, dirname } from 'path'
55
import { fileURLToPath } from 'url';
6+
import { PrismaMetaMapper } from './prismaMetaMapper.js';
67
const __filename = fileURLToPath(import.meta.url);
78
const __dirname = dirname(__filename);
89
const { Aigle } = aigle
@@ -21,8 +22,8 @@ export class SeederHelper {
2122
}, {})
2223
}
2324

24-
async generatePrismaCreateUpdatePayload({ rowData, metaMapper, entity, prismaService }) {
25-
const entityFieldMapping = await metaMapper.getEntityFieldMapping(entity);
25+
async generatePrismaCreateUpdatePayload({ rowData, entity, prismaService }) {
26+
const entityFieldMapping = await PrismaMetaMapper.getEntityFieldMapping(entity);
2627
return Aigle.transform(rowData, async (createPayload, value, column: string) => {
2728
if (entityFieldMapping[column]?.relationName) {
2829
const targetColumnEntityName = _.lowerFirst(entityFieldMapping[column].type)

0 commit comments

Comments
 (0)