Skip to content

Commit 8157b25

Browse files
committed
types: stricter projection typing with 1-level deep nesting
Re: #15327
1 parent 9702ac2 commit 8157b25

File tree

5 files changed

+93
-14
lines changed

5 files changed

+93
-14
lines changed

test/types/queries.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ import {
1515
QuerySelector,
1616
InferSchemaType,
1717
ProjectionFields,
18-
QueryOptions
18+
QueryOptions,
19+
ProjectionType
1920
} from 'mongoose';
2021
import { ModifyResult, ObjectId } from 'mongodb';
2122
import { expectAssignable, expectError, expectNotAssignable, expectType } from 'tsd';
@@ -54,6 +55,9 @@ interface ISubdoc {
5455
myId?: Types.ObjectId;
5556
id?: number;
5657
tags?: string[];
58+
profiles: {
59+
name?: string
60+
}
5761
}
5862

5963
interface ITest {
@@ -160,6 +164,42 @@ const p1: Record<string, number> = Test.find().projection('age docs.id');
160164
const p2: Record<string, number> | null = Test.find().projection();
161165
const p3: null = Test.find().projection(null);
162166

167+
expectError(Test.find({ }, { name: 'ss' })); // Only 0 and 1 are allowed
168+
expectError(Test.find({ }, { name: 3 })); // Only 0 and 1 are allowed
169+
expectError(Test.find({ }, { name: true, age: false, endDate: true, tags: 1 })); // Exclusion in a inclusion projection is not allowed
170+
expectError(Test.find({ }, { name: true, age: false, endDate: true })); // Inclusion in a exclusion projection is not allowed
171+
expectError(Test.find({ }, { name: false, age: false, tags: false, child: { name: false }, docs: { myId: false, id: true } })); // Inclusion in a exclusion projection is not allowed in nested objects and arrays
172+
expectError(Test.find({ }, { tags: { something: 1 } })); // array of strings or numbers should only be allowed to be a boolean or 1 and 0
173+
Test.find({}, { name: true, age: true, endDate: true, tags: 1, child: { name: true }, docs: { myId: true, id: true } }); // This should be allowed
174+
Test.find({}, { name: 1, age: 1, endDate: 1, tags: 1, child: { name: 1 }, docs: { myId: 1, id: 1 } }); // This should be allowed
175+
Test.find({}, { _id: 0, name: 1, age: 1, endDate: 1, tags: 1, child: 1, docs: 1 }); // _id is an exception and should be allowed to be excluded
176+
Test.find({}, { name: 0, age: 0, endDate: 0, tags: 0, child: 0, docs: 0 }); // This should be allowed
177+
Test.find({}, { name: 0, age: 0, endDate: 0, tags: 0, child: { name: 0 }, docs: { myId: 0, id: 0 } }); // This should be allowed
178+
Test.find({}, { name: 1, age: 1, _id: 0 }); // This should be allowed since _id is an exception
179+
Test.find({}, { someOtherField: 1 }); // This should be allowed since it's not a field in the schema
180+
expectError(Test.find({}, { name: { $slice: 1 } })); // $slice should only be allowed on arrays
181+
Test.find({}, { tags: { $slice: 1 } }); // $slice should be allowed on arrays
182+
Test.find({}, { tags: { $slice: [1, 2] } }); // $slice with the format of [ <number to skip>, <number to return> ] should also be allowed on arrays
183+
expectError(Test.find({}, { age: { $elemMatch: {} } })); // $elemMatch should not be allowed on non arrays
184+
Test.find({}, { docs: { $elemMatch: { id: 'aa' } } }); // $elemMatch should be allowed on arrays
185+
expectError(Test.find({}, { tags: { $slice: 1, $elemMatch: {} } })); // $elemMatch and $slice should not be allowed together
186+
Test.find({}, { age: 1, tags: { $slice: 5 } }); // $slice should be allowed in inclusion projection
187+
Test.find({}, { age: 0, tags: { $slice: 5 } }); // $slice should be allowed in exclusion projection
188+
Test.find({}, { age: 1, tags: { $elemMatch: {} } }); // $elemMatch should be allowed in inclusion projection
189+
Test.find({}, { age: 0, tags: { $elemMatch: {} } }); // $elemMatch should be allowed in exclusion projection
190+
expectError(Test.find({}, { 'docs.id': 11 })); // Dot notation should be allowed and does not accept any
191+
expectError(Test.find({}, { docs: { id: '1' } })); // Dot notation should be able to use a combination with objects
192+
Test.find({}, { docs: { id: false } }); // Dot notation should be allowed with valid values - should correctly handle arrays
193+
Test.find({}, { docs: { id: true } }); // Dot notation should be allowed with valid values - should correctly handle arrays
194+
Test.find({ docs: { $elemMatch: { id: 1 } } }, { 'docs.$': 1 }); // $ projection should be allowed
195+
Test.find({}, { child: 1 }); // Dot notation should be able to use a combination with objects
196+
// Test.find({}, { 'docs.profiles': { name: 1 } }); // 3 levels deep not supported
197+
expectError(Test.find({}, { 'docs.profiles': { name: 'aa' } })); // should support a combination of dot notation and objects
198+
expectError(Test.find({}, { endDate: { toString: 1 } }));
199+
expectError(Test.find({}, { tags: { trim: 1 } }));
200+
201+
// Manual Casting using ProjectionType
202+
Test.find({}, { docs: { unknownParams: 1 } } as ProjectionType<ITest>);
163203
// Sorting
164204
Test.find().sort();
165205
Test.find().sort('-name');

types/index.d.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -657,9 +657,38 @@ declare module 'mongoose' {
657657

658658
export type ReturnsNewDoc = { new: true } | { returnOriginal: false } | { returnDocument: 'after' };
659659

660-
export type ProjectionElementType = number | string;
661-
export type ProjectionType<T> = { [P in keyof T]?: ProjectionElementType } | AnyObject | string;
662-
660+
type ArrayOperators = { $slice: number | [number, number]; $elemMatch?: never } | { $elemMatch: Record<string, any>; $slice?: never };
661+
/**
662+
* This Type Assigns `Element | undefined` recursively to the `T` type.
663+
* if it is an array it will do this to the element of the array, if it is an object it will do this for the properties of the object.
664+
* `Element` is the truthy or falsy values that are going to be used as the value of the projection.(1 | true or 0 | false)
665+
* For the elements of the array we will use: `Element | `undefined` | `ArrayOperators`
666+
* @example
667+
* type CalculatedType = Projector<{ a: string, b: number, c: { d: string }, d: string[] }, true>
668+
* type CalculatedType = {
669+
a?: true | undefined;
670+
b?: true | undefined;
671+
c?: true | {
672+
d?: true | undefined;
673+
} | undefined;
674+
d?: true | ArrayOperators | undefined;
675+
}
676+
*/
677+
type Projector<T, Element> = T extends Array<infer U>
678+
? Projector<U, Element> | ArrayOperators
679+
: T extends TreatAsPrimitives
680+
? Element
681+
: T extends Record<string, any>
682+
? {
683+
[K in keyof T]?: T[K] extends Record<string, any> ? Projector<T[K], Element> | Element : Element;
684+
}
685+
: Element;
686+
type _IDType = { _id?: boolean | 1 | 0 };
687+
type InclusionProjection<T> = IsItRecordAndNotAny<T> extends true ? Projector<WithLevel1NestedPaths<T>, true | 1> & _IDType : AnyObject;
688+
type ExclusionProjection<T> = IsItRecordAndNotAny<T> extends true ? Projector<WithLevel1NestedPaths<T>, false | 0> & _IDType : AnyObject;
689+
type ProjectionUnion<T> = InclusionProjection<T> | ExclusionProjection<T>;
690+
691+
export type ProjectionType<T> = (ProjectionUnion<T> & AnyObject) | string | ((...agrs: any) => any);
663692
export type SortValues = SortOrder;
664693

665694
export type SortOrder = -1 | 1 | 'asc' | 'ascending' | 'desc' | 'descending';

types/query.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ declare module 'mongoose' {
160160
* Set `overwriteImmutable` to `true` to allow updating immutable properties using other update operators.
161161
*/
162162
overwriteImmutable?: boolean;
163-
projection?: ProjectionType<DocType>;
163+
projection?: { [P in keyof DocType]?: number | string } | AnyObject | string;
164164
/**
165165
* if true, returns the full ModifyResult rather than just the document
166166
*/

types/types.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ declare module 'mongoose' {
6060

6161
class Decimal128 extends mongodb.Decimal128 { }
6262

63-
class DocumentArray<T, THydratedDocumentType extends Types.Subdocument<any> = Types.Subdocument<InferId<T>, any, T> & T> extends Types.Array<THydratedDocumentType> {
63+
class DocumentArray<T, THydratedDocumentType extends Types.Subdocument<any, any, T> = Types.Subdocument<InferId<T>, any, T> & T> extends Types.Array<THydratedDocumentType> {
6464
/** DocumentArray constructor */
6565
constructor(values: AnyObject[]);
6666

@@ -85,7 +85,7 @@ declare module 'mongoose' {
8585
class ObjectId extends mongodb.ObjectId {
8686
}
8787

88-
class Subdocument<IdType = unknown, TQueryHelpers = any, DocType = any> extends Document<IdType, TQueryHelpers, DocType> {
88+
class Subdocument<IdType = any, TQueryHelpers = any, DocType = any> extends Document<IdType, TQueryHelpers, DocType> {
8989
$isSingleNested: true;
9090

9191
/** Returns the top level document of this sub-document. */

types/utility.d.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,30 @@ declare module 'mongoose' {
44

55
type WithLevel1NestedPaths<T, K extends keyof T = keyof T> = {
66
[P in K | NestedPaths<Required<T>, K>]: P extends K
7-
? T[P]
7+
? NonNullable<T[P]>
88
: P extends `${infer Key}.${infer Rest}`
99
? Key extends keyof T
10-
? Rest extends keyof NonNullable<T[Key]>
11-
? NonNullable<T[Key]>[Rest]
12-
: never
10+
? T[Key] extends (infer U)[]
11+
? Rest extends keyof NonNullable<U>
12+
? NonNullable<U>[Rest]
13+
: never
14+
: Rest extends keyof NonNullable<T[Key]>
15+
? NonNullable<T[Key]>[Rest]
16+
: never
1317
: never
1418
: never;
1519
};
1620

1721
type NestedPaths<T, K extends keyof T> = K extends string
18-
? T[K] extends Record<string, any> | null | undefined
19-
? `${K}.${keyof NonNullable<T[K]> & string}`
20-
: never
22+
? T[K] extends TreatAsPrimitives
23+
? never
24+
: T[K] extends Array<infer U>
25+
? U extends Record<string, any>
26+
? `${K}.${keyof NonNullable<U> & string}`
27+
: never
28+
: T[K] extends Record<string, any> | null | undefined
29+
? `${K}.${keyof NonNullable<T[K]> & string}`
30+
: never
2131
: never;
2232

2333
type WithoutUndefined<T> = T extends undefined ? never : T;

0 commit comments

Comments
 (0)