Skip to content

Commit 6a88762

Browse files
authored
fix: Strict Projection Object Typing (#13993)
* Projection Type Should handle all exclusion and inclusions * fix tests * remove comment * remove test * fields which are not in schema should be allowed * support $slice and $elemMatch * $slice and $elemMatch should be allowed with inclusion and exclusion queries * feat: ✨ support dot notation and a combination of object and dot notation projection * should not project methods inside Date type * use replacer type
1 parent 089175d commit 6a88762

File tree

4 files changed

+128
-6
lines changed

4 files changed

+128
-6
lines changed

test/types/queries.test.ts

Lines changed: 36 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 { 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 {
@@ -154,6 +158,37 @@ const p1: Record<string, number> = Test.find().projection('age docs.id');
154158
const p2: Record<string, number> | null = Test.find().projection();
155159
const p3: null = Test.find().projection(null);
156160

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

test/types/schema.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -760,7 +760,7 @@ function pluginOptions() {
760760
}
761761

762762
const schema = new Schema({});
763-
expectType<Schema<any>>(schema.plugin(pluginFunction)); // test that chaining would be possible
763+
expectType<typeof schema>(schema.plugin(pluginFunction)); // test that chaining would be possible
764764

765765
// could not add strict tests that the parameters are inferred correctly, because i dont know how this would be done in tsd
766766

types/index.d.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -589,9 +589,96 @@ declare module 'mongoose' {
589589

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

592-
export type ProjectionElementType = number | string;
593-
export type ProjectionType<T> = { [P in keyof T]?: ProjectionElementType } | AnyObject | string;
592+
type ArrayOperators = { $slice: number | [number, number]; $elemMatch?: undefined } | { $elemMatch: object; $slice?: undefined };
593+
type Projector<T, Element> = T extends Array<infer U>
594+
? Projector<U, Element> | ArrayOperators
595+
: T extends object
596+
? {
597+
[K in keyof T]?: T[K] extends object ? Projector<T[K], Element> | Element : Element;
598+
}
599+
: Element;
600+
type _IDType = { _id?: boolean | 1 | 0 };
601+
type InclusionProjection<T> = NestedPartial<Projector<NestedRequired<DotKeys<T>>, true | 1> & _IDType>;
602+
type ExclusionProjection<T> = NestedPartial<Projector<NestedRequired<DotKeys<T>>, false | 0> & _IDType>;
603+
type ProjectionUnion<T> = InclusionProjection<T> | ExclusionProjection<T>;
604+
605+
type NestedRequired<T> = T extends Array<infer U>
606+
? Array<NestedRequired<U>>
607+
: T extends object
608+
? {
609+
[K in keyof T]-?: NestedRequired<T[K]>;
610+
}
611+
: T;
612+
type NestedPartial<T> = T extends Array<infer U>
613+
? Array<NestedPartial<U>>
614+
: T extends object
615+
? {
616+
[K in keyof T]?: NestedPartial<T[K]>;
617+
}
618+
: T;
619+
620+
/** https://stackoverflow.com/questions/58434389/typescript-deep-keyof-of-a-nested-object/58436959#58436959 for dot nested implementation it is used and then modified */
621+
type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;
622+
623+
/** https://stackoverflow.com/questions/75419012/how-do-i-limit-the-level-of-nesting-on-a-recursive-type-in-typescript */
624+
type Length<T extends unknown[]> = T extends { length: infer L } ? L : never;
625+
type BuildTuple<L extends number, T extends unknown[] = []> = T extends { length: L } ? T : BuildTuple<L, [...T, unknown]>;
626+
type MinusOne<N extends number> = BuildTuple<N> extends [...infer U, unknown] ? Length<U> : never;
594627

628+
/**
629+
* Generates a union from dot path in object
630+
* We have to give it the Depth to prevent infinite recursion and also prevent slow compilation when the object is too much nested
631+
*/
632+
type DotNestedKeys<T, Depth extends number> = (
633+
Depth extends 0
634+
? never
635+
: T extends object
636+
? { [K in Exclude<keyof T, symbol>]: `${K}` | `${K}${DotPrefix<DotNestedKeys<T[K], MinusOne<Depth>>>}` }[Exclude<keyof T, symbol>]
637+
: ''
638+
) extends infer D
639+
? Extract<D, string>
640+
: never;
641+
type FindDottedPathType<T, Path extends string> = Path extends `${infer K}.${infer R}`
642+
? K extends keyof T
643+
? FindDottedPathType<T[K] extends Array<infer U> ? U : T[K], R>
644+
: never
645+
: Path extends keyof T
646+
? T[Path]
647+
: never;
648+
type ExtractNestedArrayElement<T> = T extends (infer U)[]
649+
? ExtractNestedArrayElement<U>
650+
: T extends object
651+
? { [K in keyof T]: ExtractNestedArrayElement<T[K]> }
652+
: T;
653+
type DotnotationMaximumDepth = 4;
654+
/**
655+
* Create dot path for nested objects
656+
* It creates dot notation for arrays similar to mongodb. For example { a: { c: { b: number}[] }[] } => 'a.c.b': number, 'a.c': { b: number }[]
657+
*/
658+
type DotKeys<DocType> = {
659+
[key in DotNestedKeys<ExtractNestedArrayElement<DocType>, DotnotationMaximumDepth>]?: FindDottedPathType<NestedRequired<DocType>, key>;
660+
};
661+
662+
/**
663+
* This types are equivalent to primary types
664+
*/
665+
type SpecialTypes = DateSchemaDefinition | Date | globalThis.Date | DateConstructor | Types.Buffer | Types.Decimal128 | Types.Buffer | BooleanSchemaDefinition | NumberSchemaDefinition;
666+
type Replacer<T> = T extends SpecialTypes ? string : T;
667+
/**
668+
* Date type is like a Primitiv type for us and we do not want to project something inside it.
669+
* ObjectId is also similar.
670+
*/
671+
type ReplaceSpecialTypes<T> = T extends SpecialTypes
672+
? Replacer<T>
673+
: T extends Array<infer U>
674+
? Array<ReplaceSpecialTypes<U>>
675+
: T extends object
676+
? {
677+
[K in keyof T]?: ReplaceSpecialTypes<T[K]>;
678+
}
679+
: Replacer<T>;
680+
681+
export type ProjectionType<T> = (ProjectionUnion<ReplaceSpecialTypes<T>> & AnyObject) | string | ((...agrs: any) => any);
595682
export type SortValues = SortOrder;
596683

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

types/query.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ declare module 'mongoose' {
9595
updatedAt?: boolean;
9696
}
9797

98-
interface QueryOptions<DocType = unknown> extends
98+
interface QueryOptions<DocType = any> extends
9999
PopulateOption,
100100
SessionOption {
101101
arrayFilters?: { [key: string]: any }[];
@@ -122,7 +122,7 @@ declare module 'mongoose' {
122122
new?: boolean;
123123

124124
overwriteDiscriminatorKey?: boolean;
125-
projection?: ProjectionType<DocType>;
125+
projection?: ProjectionType<any>;
126126
/**
127127
* if true, returns the full ModifyResult rather than just the document
128128
*/

0 commit comments

Comments
 (0)