Skip to content

fix: Strict Projection Object Typing #15327

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion test/types/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import {
QuerySelector,
InferSchemaType,
ProjectionFields,
QueryOptions
QueryOptions,
ProjectionType
} from 'mongoose';
import { ModifyResult, ObjectId } from 'mongodb';
import { expectAssignable, expectError, expectNotAssignable, expectType } from 'tsd';
Expand Down Expand Up @@ -54,6 +55,9 @@ interface ISubdoc {
myId?: Types.ObjectId;
id?: number;
tags?: string[];
profiles: {
name?: string
}
}

interface ITest {
Expand Down Expand Up @@ -160,6 +164,39 @@ const p1: Record<string, number> = Test.find().projection('age docs.id');
const p2: Record<string, number> | null = Test.find().projection();
const p3: null = Test.find().projection(null);

expectError(Test.find({ }, { name: 'ss' })); // Only 0 and 1 are allowed
expectError(Test.find({ }, { name: 3 })); // Only 0 and 1 are allowed
expectError(Test.find({ }, { name: true, age: false, endDate: true, tags: 1 })); // Exclusion in a inclusion projection is not allowed
expectError(Test.find({ }, { name: true, age: false, endDate: true })); // Inclusion in a exclusion projection is not allowed
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
expectError(Test.find({ }, { tags: { something: 1 } })); // array of strings or numbers should only be allowed to be a boolean or 1 and 0
Test.find({}, { name: true, age: true, endDate: true, tags: 1, child: { name: true }, docs: { myId: true, id: true } }); // This should be allowed
Test.find({}, { name: 1, age: 1, endDate: 1, tags: 1, child: { name: 1 }, docs: { myId: 1, id: 1 } }); // This should be allowed
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
Test.find({}, { name: 0, age: 0, endDate: 0, tags: 0, child: 0, docs: 0 }); // This should be allowed
Test.find({}, { name: 0, age: 0, endDate: 0, tags: 0, child: { name: 0 }, docs: { myId: 0, id: 0 } }); // This should be allowed
Test.find({}, { name: 1, age: 1, _id: 0 }); // This should be allowed since _id is an exception
Test.find({}, { someOtherField: 1 }); // This should be allowed since it's not a field in the schema
expectError(Test.find({}, { name: { $slice: 1 } })); // $slice should only be allowed on arrays
Test.find({}, { tags: { $slice: 1 } }); // $slice should be allowed on arrays
Test.find({}, { tags: { $slice: [1, 2] } }); // $slice with the format of [ <number to skip>, <number to return> ] should also be allowed on arrays
expectError(Test.find({}, { age: { $elemMatch: {} } })); // $elemMatch should not be allowed on non arrays
Test.find({}, { tags: { $elemMatch: {} } }); // $elemMatch should be allowed on arrays
expectError(Test.find({}, { tags: { $slice: 1, $elemMatch: {} } })); // $elemMatch and $slice should not be allowed together
Test.find({}, { age: 1, tags: { $slice: 5 } }); // $slice should be allowed in inclusion projection
Test.find({}, { age: 0, tags: { $slice: 5 } }); // $slice should be allowed in exclusion projection
Test.find({}, { age: 1, tags: { $elemMatch: {} } }); // $elemMatch should be allowed in inclusion projection
Test.find({}, { age: 0, tags: { $elemMatch: {} } }); // $elemMatch should be allowed in exclusion projection
expectError(Test.find({}, { 'docs.id': 11 })); // Dot notation should be allowed and does not accept any
expectError(Test.find({}, { docs: { id: '1' } })); // Dot notation should be able to use a combination with objects
Test.find({}, { docs: { id: false } }); // Dot notation should be allowed with valid values - should correctly handle arrays
Test.find({}, { docs: { id: true } }); // Dot notation should be allowed with valid values - should correctly handle arrays
Test.find({}, { child: 1 }); // Dot notation should be able to use a combination with objects
Test.find({}, { 'docs.profiles': { name: 1 } }); // should support a combination of dot notation and objects
expectError(Test.find({}, { 'docs.profiles': { name: 'aa' } })); // should support a combination of dot notation and objects
expectError(Test.find({}, { endDate: { toString: 1 } }));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to see a test of using as ProjectionType<> to explicitly cast in case the type checking is incorrect for some reason (or if user is dealing with 5-level deep path)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vkarpov15
something like this you mean?

Test.find({}, { docs: { unknownParams: 1 }} as ProjectionType<ITest>);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// Manual Casting using ProjectionType
Test.find({}, { docs: { unknownParams: 1 } } as ProjectionType<ITest>);
// Sorting
Test.find().sort();
Test.find().sort('-name');
Expand Down
107 changes: 105 additions & 2 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,9 +657,112 @@ declare module 'mongoose' {

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

export type ProjectionElementType = number | string;
export type ProjectionType<T> = { [P in keyof T]?: ProjectionElementType } | AnyObject | string;
type ArrayOperators = { $slice: number | [number, number]; $elemMatch?: undefined } | { $elemMatch: object; $slice?: undefined };
/**
* This Type Assigns `Element | undefined` recursively to the `T` type.
* 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.
* `Element` is the truthy or falsy values that are going to be used as the value of the projection.(1 | true or 0 | false)
* For the elements of the array we will use: `Element | `undefined` | `ArrayOperators`
* @example
* type CalculatedType = Projector<{ a: string, b: number, c: { d: string }, d: string[] }, true>
* type CalculatedType = {
a?: true | undefined;
b?: true | undefined;
c?: true | {
d?: true | undefined;
} | undefined;
d?: true | ArrayOperators | undefined;
}
*/
type Projector<T, Element> = T extends Array<infer U>
? Projector<U, Element> | ArrayOperators
: T extends object
? {
[K in keyof T]?: T[K] extends object ? Projector<T[K], Element> | Element : Element;
}
: Element;
type _IDType = { _id?: boolean | 1 | 0 };
type InclusionProjection<T> = NestedPartial<Projector<NestedRequired<DotKeys<T>>, true | 1> & _IDType>;
type ExclusionProjection<T> = NestedPartial<Projector<NestedRequired<DotKeys<T>>, false | 0> & _IDType>;
type ProjectionUnion<T> = InclusionProjection<T> | ExclusionProjection<T>;

type NestedRequired<T> = T extends Array<infer U>
? Array<NestedRequired<U>>
: T extends object
? {
[K in keyof T]-?: NestedRequired<T[K]>;
}
: T;
type NestedPartial<T> = T extends Array<infer U>
? Array<NestedPartial<U>>
: T extends object
? {
[K in keyof T]?: NestedPartial<T[K]>;
}
: T;

/** https://stackoverflow.com/questions/58434389/typescript-deep-keyof-of-a-nested-object/58436959#58436959 for dot nested implementation it is used and then modified */
type DotPrefix<T extends string> = T extends '' ? '' : `.${T}`;

/** https://stackoverflow.com/questions/75419012/how-do-i-limit-the-level-of-nesting-on-a-recursive-type-in-typescript */
type Length<T extends unknown[]> = T extends { length: infer L } ? L : never;
type BuildTuple<L extends number, T extends unknown[] = []> = T extends { length: L } ? T : BuildTuple<L, [...T, unknown]>;
type MinusOne<N extends number> = BuildTuple<N> extends [...infer U, unknown] ? Length<U> : never;

/**
* Generates a union from dot path in object
* We have to give it the Depth to prevent infinite recursion and also prevent slow compilation when the object is too much nested
*/
type DotNestedKeys<T, Depth extends number> = (
Depth extends 0
? never
: T extends object
? { [K in Exclude<keyof T, symbol>]: `${K}` | `${K}${DotPrefix<DotNestedKeys<T[K], MinusOne<Depth>>>}` }[Exclude<keyof T, symbol>]
: ''
) extends infer D
? Extract<D, string>
: never;
type FindDottedPathType<T, Path extends string> = Path extends `${infer K}.${infer R}`
? K extends keyof T
? FindDottedPathType<T[K] extends Array<infer U> ? U : T[K], R>
: never
: Path extends keyof T
? T[Path]
: never;
type ExtractNestedArrayElement<T> = T extends (infer U)[]
? ExtractNestedArrayElement<U>
: T extends object
? { [K in keyof T]: ExtractNestedArrayElement<T[K]> }
: T;
type DotnotationMaximumDepth = 3;
/**
* Create dot path for nested objects
* It creates dot notation for arrays similar to mongodb. For example { a: { c: { b: number}[] }[] } => 'a.c.b': number, 'a.c': { b: number }[]
*/
type DotKeys<DocType> = {
[key in DotNestedKeys<ExtractNestedArrayElement<DocType>, DotnotationMaximumDepth>]?: FindDottedPathType<NestedRequired<DocType>, key>;
};

/**
* This types are equivalent to primary types
*/
type SpecialTypes = DateSchemaDefinition | Date | globalThis.Date | DateConstructor | Types.Buffer | Types.Decimal128 | Types.Buffer | BooleanSchemaDefinition | NumberSchemaDefinition;
type Replacer<T> = T extends SpecialTypes ? string : T;
/**
* Date type is like a Primitive type for us and we do not want to project something inside it.
* ObjectId is also similar.
*/
type ReplaceSpecialTypes<T> = T extends SpecialTypes
? Replacer<T>
: T extends Array<infer U>
? Array<ReplaceSpecialTypes<U>>
: T extends object
? {
[K in keyof T]?: ReplaceSpecialTypes<T[K]>;
}
: Replacer<T>;

export type ProjectionType<T> = (ProjectionUnion<ReplaceSpecialTypes<T>> & AnyObject) | string | ((...agrs: any) => any);
export type SortValues = SortOrder;

export type SortOrder = -1 | 1 | 'asc' | 'ascending' | 'desc' | 'descending';
Expand Down
2 changes: 1 addition & 1 deletion types/query.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ declare module 'mongoose' {
* Set `overwriteImmutable` to `true` to allow updating immutable properties using other update operators.
*/
overwriteImmutable?: boolean;
projection?: ProjectionType<DocType>;
projection?: { [P in keyof DocType]?: number | string } | AnyObject | string;
/**
* if true, returns the full ModifyResult rather than just the document
*/
Expand Down
4 changes: 2 additions & 2 deletions types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ declare module 'mongoose' {

class Decimal128 extends mongodb.Decimal128 { }

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

Expand All @@ -85,7 +85,7 @@ declare module 'mongoose' {
class ObjectId extends mongodb.ObjectId {
}

class Subdocument<IdType = unknown, TQueryHelpers = any, DocType = any> extends Document<IdType, TQueryHelpers, DocType> {
class Subdocument<IdType = any, TQueryHelpers = any, DocType = any> extends Document<IdType, TQueryHelpers, DocType> {
$isSingleNested: true;

/** Returns the top level document of this sub-document. */
Expand Down
Loading