Skip to content

Commit fccfa59

Browse files
Merge pull request #4030 from kyungseopk1m/fix/deep-partial-type-plugin-metadata
fix(type-helpers): apply deep partial substitution to plugin metadata
2 parents 91e5917 + a70d5c4 commit fccfa59

2 files changed

Lines changed: 273 additions & 19 deletions

File tree

lib/type-helpers/deep-partial-type.helper.ts

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,24 @@ function isDtoClass(typeRef: unknown): typeRef is Type<unknown> {
3737
) {
3838
return false;
3939
}
40+
// A lazy factory that throws is handed back as-is by the caller, and an arrow
41+
// function has no prototype. Bail out before `getModelProperties` dereferences
42+
// it, otherwise resolving the type would throw instead of leaving it alone.
43+
if (!(typeRef as Type<unknown>).prototype) {
44+
return false;
45+
}
4046
const fields = modelPropertiesAccessor.getModelProperties(
4147
(typeRef as Type<unknown>).prototype
4248
);
43-
return fields.length > 0;
49+
if (fields.length > 0) {
50+
return true;
51+
}
52+
// A DTO written with the CLI plugin carries no `@ApiProperty()` metadata of
53+
// its own; its properties only exist in the generated metadata factory, which
54+
// is not applied to the prototype until the schema is explored.
55+
return (
56+
typeof (typeRef as Type<unknown>)[METADATA_FACTORY_NAME] === 'function'
57+
);
4458
}
4559

4660
/**
@@ -105,23 +119,14 @@ export function DeepPartialType<T>(
105119
mapValues(metadata, (item) => ({ ...item, required: false }))
106120
);
107121

108-
if (DeepPartialTypeClass[METADATA_FACTORY_NAME]) {
109-
const pluginFields = Object.keys(
110-
DeepPartialTypeClass[METADATA_FACTORY_NAME]()
111-
);
112-
pluginFields.forEach((key) =>
113-
applyPartialDecoratorFn(DeepPartialTypeClass, key)
114-
);
115-
}
116-
117-
fields.forEach((key) => {
118-
const metadata =
119-
Reflect.getMetadata(
120-
DECORATORS.API_MODEL_PROPERTIES,
121-
classRef.prototype,
122-
key
123-
) || {};
124-
122+
// Applies the recursive type substitution for a single property. Shared by
123+
// the explicitly decorated properties and by the ones that only exist in
124+
// the plugin-generated metadata factory, so both routes end up with the
125+
// nested DTO wrapped in `DeepPartialType`.
126+
function applyDeepPartialProperty(
127+
metadata: Record<string, any>,
128+
key: string
129+
) {
125130
// Resolve the effective type, supporting lazy factory functions
126131
let resolvedType = metadata.type;
127132
if (typeof resolvedType === 'function' && resolvedType.length === 0) {
@@ -132,7 +137,7 @@ export function DeepPartialType<T>(
132137
}
133138
}
134139

135-
// Unwrap array type: [SomeDto] SomeDto. An array can be expressed
140+
// Unwrap array type: [SomeDto] -> SomeDto. An array can be expressed
136141
// either through the `isArray` flag or by wrapping the type in a
137142
// single-element tuple (commonly returned from a lazy factory, e.g.
138143
// `type: () => [SomeDto]`). Capture the array-ness so it can be
@@ -158,8 +163,30 @@ export function DeepPartialType<T>(
158163
required: false
159164
});
160165
decoratorFactory(DeepPartialTypeClass.prototype, key);
166+
}
167+
168+
fields.forEach((key) => {
169+
const metadata =
170+
Reflect.getMetadata(
171+
DECORATORS.API_MODEL_PROPERTIES,
172+
classRef.prototype,
173+
key
174+
) || {};
175+
176+
applyDeepPartialProperty(metadata, key);
161177
applyPartialDecoratorFn(DeepPartialTypeClass, key);
162178
});
179+
180+
if (DeepPartialTypeClass[METADATA_FACTORY_NAME]) {
181+
const pluginMetadata = DeepPartialTypeClass[METADATA_FACTORY_NAME]();
182+
const pluginFields = Object.keys(pluginMetadata);
183+
pluginFields.forEach((key) => {
184+
if (!fields.includes(key)) {
185+
applyDeepPartialProperty(pluginMetadata[key], key);
186+
}
187+
applyPartialDecoratorFn(DeepPartialTypeClass, key);
188+
});
189+
}
163190
}
164191
applyFields(fields);
165192

test/type-helpers/deep-partial-type.helper.spec.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { DECORATORS } from '../../lib/constants';
22
import { ApiProperty } from '../../lib/decorators';
3+
import { METADATA_FACTORY_NAME } from '../../lib/plugin/plugin-constants';
34
import { ModelPropertiesAccessor } from '../../lib/services/model-properties-accessor';
45
import { DeepPartialType } from '../../lib/type-helpers';
56

@@ -179,6 +180,232 @@ describe('DeepPartialType', () => {
179180
});
180181
});
181182

183+
describe('plugin-generated metadata', () => {
184+
class PluginAddressDto {
185+
street: string;
186+
city: string;
187+
188+
static [METADATA_FACTORY_NAME]() {
189+
return {
190+
street: { required: true, type: () => String },
191+
city: { required: true, type: () => String }
192+
};
193+
}
194+
}
195+
196+
class PluginProfileDto {
197+
bio: string;
198+
address: PluginAddressDto;
199+
200+
static [METADATA_FACTORY_NAME]() {
201+
return {
202+
bio: { required: true, type: () => String },
203+
address: { required: true, type: () => PluginAddressDto }
204+
};
205+
}
206+
}
207+
208+
class PluginUserDto {
209+
name: string;
210+
profile: PluginProfileDto;
211+
212+
static [METADATA_FACTORY_NAME]() {
213+
return {
214+
name: { required: true, type: () => String },
215+
profile: { required: true, type: () => PluginProfileDto }
216+
};
217+
}
218+
}
219+
220+
class UpdatePluginUserDto extends DeepPartialType(PluginUserDto) {}
221+
222+
beforeAll(() => {
223+
modelPropertiesAccessor.applyMetadataFactory(
224+
UpdatePluginUserDto.prototype
225+
);
226+
});
227+
228+
it('should mark plugin-declared top-level properties as optional', () => {
229+
expect(getMetadata(UpdatePluginUserDto, 'name').required).toBe(false);
230+
expect(getMetadata(UpdatePluginUserDto, 'profile').required).toBe(false);
231+
});
232+
233+
it('should leave plugin-declared primitive types unchanged', () => {
234+
const nameMeta = getMetadata(UpdatePluginUserDto, 'name');
235+
expect(typeof nameMeta.type).toBe('function');
236+
expect(nameMeta.type()).toBe(String);
237+
});
238+
239+
it('should wrap nested DTOs declared only in the metadata factory', () => {
240+
const NestedProfileType = getMetadata(
241+
UpdatePluginUserDto,
242+
'profile'
243+
).type;
244+
245+
expect(NestedProfileType).not.toBe(PluginProfileDto);
246+
247+
const bioMeta = Reflect.getMetadata(
248+
DECORATORS.API_MODEL_PROPERTIES,
249+
NestedProfileType.prototype,
250+
'bio'
251+
);
252+
expect(bioMeta.required).toBe(false);
253+
});
254+
255+
it('should recursively wrap deeply nested plugin DTOs', () => {
256+
const NestedProfileType = getMetadata(
257+
UpdatePluginUserDto,
258+
'profile'
259+
).type;
260+
const NestedAddressType = Reflect.getMetadata(
261+
DECORATORS.API_MODEL_PROPERTIES,
262+
NestedProfileType.prototype,
263+
'address'
264+
).type;
265+
266+
expect(NestedAddressType).not.toBe(PluginAddressDto);
267+
268+
const streetMeta = Reflect.getMetadata(
269+
DECORATORS.API_MODEL_PROPERTIES,
270+
NestedAddressType.prototype,
271+
'street'
272+
);
273+
expect(streetMeta.required).toBe(false);
274+
});
275+
});
276+
277+
describe('plugin-generated arrays', () => {
278+
class PluginTagDto {
279+
label: string;
280+
281+
static [METADATA_FACTORY_NAME]() {
282+
return { label: { required: true, type: () => String } };
283+
}
284+
}
285+
286+
class PluginArticleDto {
287+
tags: PluginTagDto[];
288+
289+
static [METADATA_FACTORY_NAME]() {
290+
return { tags: { required: true, type: () => [PluginTagDto] } };
291+
}
292+
}
293+
294+
it('should preserve array-ness for a plugin-declared nested DTO array', () => {
295+
class UpdatePluginArticleDto extends DeepPartialType(PluginArticleDto) {}
296+
const meta = getMetadata(UpdatePluginArticleDto, 'tags');
297+
298+
expect(meta.isArray).toBe(true);
299+
expect(meta.required).toBe(false);
300+
expect(meta.type).not.toBe(PluginTagDto);
301+
expect(
302+
Reflect.getMetadata(
303+
DECORATORS.API_MODEL_PROPERTIES,
304+
meta.type.prototype,
305+
'label'
306+
).required
307+
).toBe(false);
308+
});
309+
});
310+
311+
describe('lazy factory that throws', () => {
312+
it('should leave the property type alone instead of propagating the error', () => {
313+
class ThrowingPluginDto {
314+
broken: unknown;
315+
316+
static [METADATA_FACTORY_NAME]() {
317+
return {
318+
broken: {
319+
required: true,
320+
type: () => {
321+
throw new Error('circular import not resolved yet');
322+
}
323+
}
324+
};
325+
}
326+
}
327+
328+
expect(() => DeepPartialType(ThrowingPluginDto)).not.toThrow();
329+
});
330+
331+
it('should leave an explicitly decorated throwing factory alone', () => {
332+
class ThrowingExplicitDto {
333+
@ApiProperty({
334+
type: () => {
335+
throw new Error('circular import not resolved yet');
336+
}
337+
})
338+
broken: unknown;
339+
}
340+
341+
expect(() => DeepPartialType(ThrowingExplicitDto)).not.toThrow();
342+
});
343+
});
344+
345+
describe('mixed explicit and plugin-generated metadata', () => {
346+
class MixedTagDto {
347+
@ApiProperty({ type: String, required: true })
348+
label: string;
349+
}
350+
351+
class MixedSettingsDto {
352+
theme: string;
353+
354+
static [METADATA_FACTORY_NAME]() {
355+
return { theme: { required: true, type: () => String } };
356+
}
357+
}
358+
359+
class MixedAccountDto {
360+
@ApiProperty({ type: () => MixedTagDto, required: true })
361+
tag: MixedTagDto;
362+
363+
settings: MixedSettingsDto;
364+
365+
static [METADATA_FACTORY_NAME]() {
366+
return {
367+
settings: { required: true, type: () => MixedSettingsDto }
368+
};
369+
}
370+
}
371+
372+
class UpdateMixedAccountDto extends DeepPartialType(MixedAccountDto) {}
373+
374+
beforeAll(() => {
375+
modelPropertiesAccessor.applyMetadataFactory(
376+
UpdateMixedAccountDto.prototype
377+
);
378+
});
379+
380+
it('should wrap the explicitly decorated nested DTO', () => {
381+
const meta = getMetadata(UpdateMixedAccountDto, 'tag');
382+
383+
expect(meta.required).toBe(false);
384+
expect(meta.type).not.toBe(MixedTagDto);
385+
expect(
386+
Reflect.getMetadata(
387+
DECORATORS.API_MODEL_PROPERTIES,
388+
meta.type.prototype,
389+
'label'
390+
).required
391+
).toBe(false);
392+
});
393+
394+
it('should wrap the plugin-declared nested DTO on the same class', () => {
395+
const meta = getMetadata(UpdateMixedAccountDto, 'settings');
396+
397+
expect(meta.required).toBe(false);
398+
expect(meta.type).not.toBe(MixedSettingsDto);
399+
expect(
400+
Reflect.getMetadata(
401+
DECORATORS.API_MODEL_PROPERTIES,
402+
meta.type.prototype,
403+
'theme'
404+
).required
405+
).toBe(false);
406+
});
407+
});
408+
182409
describe('class caching', () => {
183410
it('should return the same class for the same input to avoid infinite recursion', () => {
184411
const A = DeepPartialType(UserDto);

0 commit comments

Comments
 (0)