Skip to content

Commit 33a22b5

Browse files
authored
Merge pull request #15418 from Automattic/vkarpov15/gh-15327
types: stricter projection typing with 1-level deep nesting
2 parents b4d34f4 + 8699cd6 commit 33a22b5

File tree

6 files changed

+107
-29
lines changed

6 files changed

+107
-29
lines changed

test/types/models.test.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,6 @@ function find() {
225225
Project.find({}, { name: 1 });
226226
Project.find({}, { name: 0 });
227227

228-
// filter + callback
229-
Project.find({}, (error: CallbackError, result: IProject[]) => console.log(error, result));
230-
Project.find({ name: 'Hello' }, (error: CallbackError, result: IProject[]) => console.log(error, result));
231-
232228
// filter + projection + options
233229
Project.find({}, undefined, { limit: 5 });
234230
Project.find({}, null, { limit: 5 });

test/types/queries.test.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ 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';
2223
import { autoTypedModel } from './models.test';
23-
import { AutoTypedSchemaType } from './schema.test';
2424

2525
interface QueryHelpers {
2626
_byName(this: QueryWithHelpers<any, ITest, QueryHelpers>, name: string): QueryWithHelpers<Array<ITest>, ITest, QueryHelpers>;
@@ -54,6 +54,9 @@ interface ISubdoc {
5454
myId?: Types.ObjectId;
5555
id?: number;
5656
tags?: string[];
57+
profiles: {
58+
name?: string
59+
}
5760
}
5861

5962
interface ITest {
@@ -93,16 +96,6 @@ Test.find({ name: ['Test1', 'Test2'] }).exec();
9396
// Implicit `$in` for regex string
9497
Test.find({ name: [/Test1/, /Test2/] });
9598

96-
Test.find({ name: 'test' }, (err: Error | null, docs: ITest[]) => {
97-
console.log(!!err, docs[0].age);
98-
});
99-
100-
Test.findOne({ name: 'test' }, (err: Error | null, doc: ITest | null) => {
101-
if (doc != null) {
102-
console.log(!!err, doc.age);
103-
}
104-
});
105-
10699
Test.find({ name: { $gte: 'Test' } }, null, { collation: { locale: 'en-us' } }).exec().
107100
then((res: Array<ITest>) => console.log(res[0].name));
108101

@@ -160,6 +153,43 @@ const p1: Record<string, number> = Test.find().projection('age docs.id');
160153
const p2: Record<string, number> | null = Test.find().projection();
161154
const p3: null = Test.find().projection(null);
162155

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

types/index.d.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -657,9 +657,37 @@ 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+
export type InclusionProjection<T> = IsItRecordAndNotAny<T> extends true ? Projector<WithLevel1NestedPaths<T>, true | 1> & _IDType : AnyObject;
688+
export type ExclusionProjection<T> = IsItRecordAndNotAny<T> extends true ? Projector<WithLevel1NestedPaths<T>, false | 0> & _IDType : AnyObject;
689+
690+
export type ProjectionType<T> = (InclusionProjection<T> & AnyObject) | (ExclusionProjection<T> & AnyObject) | string;
663691
export type SortValues = SortOrder;
664692

665693
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: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,44 @@ 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+
// Handle top-level paths
8+
// First, drill into documents so we don't end up surfacing `$assertPopulated`, etc.
9+
? Extract<NonNullable<T[P]>, Document> extends never
10+
// If not a document, then return the type. Otherwise, get the DocType.
11+
? NonNullable<T[P]>
12+
: Extract<NonNullable<T[P]>, Document> extends Document<any, any, infer DocType, any>
13+
? DocType
14+
: never
15+
// Handle nested paths
816
: P extends `${infer Key}.${infer Rest}`
917
? Key extends keyof T
10-
? Rest extends keyof NonNullable<T[Key]>
11-
? NonNullable<T[Key]>[Rest]
12-
: never
18+
? T[Key] extends (infer U)[]
19+
? Rest extends keyof NonNullable<U>
20+
? NonNullable<U>[Rest]
21+
: never
22+
: Rest extends keyof NonNullable<T[Key]>
23+
? NonNullable<T[Key]>[Rest]
24+
: never
1325
: never
1426
: never;
1527
};
1628

1729
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
30+
? T[K] extends TreatAsPrimitives
31+
? never
32+
: Extract<NonNullable<T[K]>, Document> extends never
33+
? T[K] extends Array<infer U>
34+
? U extends Record<string, any>
35+
? `${K}.${keyof NonNullable<U> & string}`
36+
: never
37+
: T[K] extends Record<string, any> | null | undefined
38+
? `${K}.${keyof NonNullable<T[K]> & string}`
39+
: never
40+
: Extract<NonNullable<T[K]>, Document> extends Document<any, any, infer DocType, any>
41+
? DocType extends Record<string, any>
42+
? `${K}.${keyof NonNullable<DocType> & string}`
43+
: never
44+
: never
2145
: never;
2246

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

0 commit comments

Comments
 (0)