Skip to content

Commit 42e1a8a

Browse files
authored
fix(inputs): prevent enumerable undefined properties in instances (#1789)
1 parent 292f239 commit 42e1a8a

File tree

2 files changed

+180
-1
lines changed

2 files changed

+180
-1
lines changed

src/helpers/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,18 @@ export function convertToType(Target: any, data?: object): object | undefined {
106106
return data.map(item => convertToType(Target, item));
107107
}
108108

109-
return Object.assign(new Target(), data);
109+
// Create instance by calling constructor to initialize instance fields
110+
const instance = new (Target as any)();
111+
112+
// Remove undefined properties that weren't provided in the input data
113+
// This prevents optional @Field() decorated properties from being enumerable
114+
for (const key of Object.keys(instance)) {
115+
if (instance[key] === undefined && !(key in data)) {
116+
delete instance[key];
117+
}
118+
}
119+
120+
return Object.assign(instance, data);
110121
}
111122

112123
export function getEnumValuesMap<T extends object>(enumObject: T) {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import "reflect-metadata";
2+
import { type GraphQLSchema, graphql } from "graphql";
3+
import { Arg, Field, InputType, Query, Resolver, buildSchema } from "type-graphql";
4+
import { getMetadataStorage } from "@/metadata/getMetadataStorage";
5+
6+
describe("InputType enumerable properties", () => {
7+
let schema: GraphQLSchema;
8+
9+
beforeAll(async () => {
10+
getMetadataStorage().clear();
11+
12+
@InputType()
13+
class SampleInput {
14+
@Field()
15+
requiredField!: string;
16+
17+
@Field({ nullable: true })
18+
optionalField?: string;
19+
20+
@Field({ nullable: true })
21+
anotherOptional?: number;
22+
}
23+
24+
@InputType()
25+
class NestedInput {
26+
@Field({ nullable: true })
27+
optionalNested?: string;
28+
}
29+
30+
@InputType()
31+
class ParentInput {
32+
@Field()
33+
required!: string;
34+
35+
@Field(() => NestedInput, { nullable: true })
36+
nested?: NestedInput;
37+
}
38+
39+
@Resolver()
40+
class SampleResolver {
41+
@Query(() => String)
42+
testSimpleInput(@Arg("input") input: SampleInput): string {
43+
return JSON.stringify({
44+
keys: Object.keys(input),
45+
hasOptional: "optionalField" in input,
46+
hasAnother: "anotherOptional" in input,
47+
optionalValue: input.optionalField,
48+
});
49+
}
50+
51+
@Query(() => String)
52+
testNestedInput(@Arg("input") input: ParentInput): string {
53+
return JSON.stringify({
54+
keys: Object.keys(input),
55+
hasNested: "nested" in input,
56+
});
57+
}
58+
}
59+
60+
schema = await buildSchema({
61+
resolvers: [SampleResolver],
62+
validate: false,
63+
});
64+
});
65+
66+
describe("optional fields not provided", () => {
67+
it("should not create enumerable properties for undefined optional fields", async () => {
68+
const query = `
69+
query {
70+
testSimpleInput(input: { requiredField: "test" })
71+
}
72+
`;
73+
74+
const result = await graphql({ schema, source: query });
75+
76+
expect(result.errors).toBeUndefined();
77+
expect(result.data).toBeDefined();
78+
79+
const data = JSON.parse(result.data!.testSimpleInput as string);
80+
81+
// Only requiredField should be in Object.keys()
82+
expect(data.keys).toEqual(["requiredField"]);
83+
84+
// Optional fields should not be enumerable
85+
expect(data.hasOptional).toBe(false);
86+
expect(data.hasAnother).toBe(false);
87+
88+
// But should still be accessible (undefined)
89+
expect(data.optionalValue).toBeUndefined();
90+
});
91+
92+
it("should handle nested InputTypes correctly", async () => {
93+
const query = `
94+
query {
95+
testNestedInput(input: { required: "value" })
96+
}
97+
`;
98+
99+
const result = await graphql({ schema, source: query });
100+
101+
expect(result.errors).toBeUndefined();
102+
expect(result.data).toBeDefined();
103+
104+
const data = JSON.parse(result.data!.testNestedInput as string);
105+
106+
// Only required field should be enumerable
107+
expect(data.keys).toEqual(["required"]);
108+
109+
// Nested optional field should not be enumerable
110+
expect(data.hasNested).toBe(false);
111+
});
112+
});
113+
114+
describe("optional fields provided", () => {
115+
it("should include provided optional fields in Object.keys()", async () => {
116+
const query = `
117+
query {
118+
testSimpleInput(input: { requiredField: "test", optionalField: "provided" })
119+
}
120+
`;
121+
122+
const result = await graphql({ schema, source: query });
123+
124+
expect(result.errors).toBeUndefined();
125+
expect(result.data).toBeDefined();
126+
127+
const data = JSON.parse(result.data!.testSimpleInput as string);
128+
129+
// Both provided fields should be in Object.keys()
130+
expect(data.keys).toContain("requiredField");
131+
expect(data.keys).toContain("optionalField");
132+
133+
// Provided field should be enumerable
134+
expect(data.hasOptional).toBe(true);
135+
136+
// Non-provided field should not be enumerable
137+
expect(data.hasAnother).toBe(false);
138+
139+
// Value should be set
140+
expect(data.optionalValue).toBe("provided");
141+
});
142+
143+
it("should handle explicitly null values correctly", async () => {
144+
const query = `
145+
query {
146+
testSimpleInput(input: { requiredField: "test", optionalField: null })
147+
}
148+
`;
149+
150+
const result = await graphql({ schema, source: query });
151+
152+
expect(result.errors).toBeUndefined();
153+
expect(result.data).toBeDefined();
154+
155+
const data = JSON.parse(result.data!.testSimpleInput as string);
156+
157+
// Explicitly null field should be in Object.keys()
158+
expect(data.keys).toContain("requiredField");
159+
expect(data.keys).toContain("optionalField");
160+
161+
// Should be enumerable
162+
expect(data.hasOptional).toBe(true);
163+
164+
// Value should be null (not undefined)
165+
expect(data.optionalValue).toBeNull();
166+
});
167+
});
168+
});

0 commit comments

Comments
 (0)