Skip to content
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

refactor: Improve projection type safety #712

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
12 changes: 12 additions & 0 deletions src/__tests__/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,18 @@ describe('model', () => {
}
});

test('with invalid projection', async () => {
await simpleModel.find(
{},
{
projection: {
// @ts-expect-error `nested.baz` is not present on the schema
'nested.baz': 1,
},
Comment on lines +1082 to +1085
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you please combine some valid properties with this invalid one here?

}
);
});

test('with projection, re-assignable to Pick type', async () => {
let results = await simpleModel.find({}, { projection });

Expand Down
30 changes: 16 additions & 14 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,32 +78,32 @@ export interface Model<TSchema extends BaseSchema, TOptions extends SchemaOption
options?: Omit<FindOptions<TSchema>, 'limit' | 'projection' | 'skip' | 'sort'>
) => Promise<boolean>;

find: <TProjection extends Projection<TSchema> | undefined>(
find: <TProjection extends Projection<TProjection, TSchema> | undefined>(
Copy link
Collaborator

Choose a reason for hiding this comment

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

TIL that you can use the same generic identifier in both the left side and right side of the extends. How is that even possible? Isn't there some kind of recursion limit?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Circular generic constraints are really interesting and make quite a bit of sense, but they only currently work when they're non-immediate. Definitely pushes the boundaries of what TypeScript is able to deliver.

Here is a really good thread if you want to read a bit more about them.

And there is a recursion limit, but it's pretty sizable and we'd hit that when building the schema path options already if we were going to hit it, so there's no additional risk here.

filter: PaprFilter<TSchema>,
options?: Omit<FindOptions<TSchema>, 'projection'> & { projection?: TProjection }
) => Promise<ProjectionType<TSchema, TProjection>[]>;

findById: <TProjection extends Projection<TSchema> | undefined>(
findById: <TProjection extends Projection<TProjection, TSchema> | undefined>(
id: TSchema['_id'] | string,
options?: Omit<FindOptions<TSchema>, 'projection'> & { projection?: TProjection }
) => Promise<ProjectionType<TSchema, TProjection> | null>;

findCursor: <TProjection extends Projection<TSchema> | undefined>(
findCursor: <TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
options?: Omit<FindOptions<TSchema>, 'projection'> & { projection?: TProjection }
) => Promise<FindCursor<ProjectionType<TSchema, TProjection>>>;

findOne: <TProjection extends Projection<TSchema> | undefined>(
findOne: <TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
options?: Omit<FindOptions<TSchema>, 'projection'> & { projection?: TProjection }
) => Promise<ProjectionType<TSchema, TProjection> | null>;

findOneAndDelete: <TProjection extends Projection<TSchema> | undefined>(
findOneAndDelete: <TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
options?: Omit<FindOneAndDeleteOptions, 'projection'> & { projection?: TProjection }
) => Promise<ProjectionType<TSchema, TProjection> | null>;

findOneAndUpdate: <TProjection extends Projection<TSchema> | undefined>(
findOneAndUpdate: <TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
update: PaprUpdateFilter<TSchema>,
options?: Omit<FindOneAndUpdateOptions, 'projection'> & { projection?: TProjection }
Expand Down Expand Up @@ -131,7 +131,7 @@ export interface Model<TSchema extends BaseSchema, TOptions extends SchemaOption
options?: UpdateOptions
) => Promise<UpdateResult>;

upsert: <TProjection extends Projection<TSchema> | undefined>(
upsert: <TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
update: PaprUpdateFilter<TSchema>,
options?: Omit<FindOneAndUpdateOptions, 'projection' | 'upsert'> & { projection?: TProjection }
Expand Down Expand Up @@ -622,7 +622,7 @@ export function build<TSchema extends BaseSchema, TOptions extends SchemaOptions
// prettier-ignore
model.find = wrap(
model,
async function find<TProjection extends Projection<TSchema> | undefined>(
async function find<TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
options?: Omit<FindOptions<TSchema>, 'projection'> & { projection?: TProjection }
): Promise<ProjectionType<TSchema, TProjection>[]> {
Expand Down Expand Up @@ -664,7 +664,7 @@ export function build<TSchema extends BaseSchema, TOptions extends SchemaOptions
// prettier-ignore
model.findById = wrap(
model,
async function findById<TProjection extends Projection<TSchema> | undefined>(
async function findById<TProjection extends Projection<TProjection, TSchema> | undefined>(
id: TSchema['_id'] | string,
options?: Omit<FindOptions<TSchema>, 'projection'> & { projection?: TProjection }
): Promise<ProjectionType<TSchema, TProjection> | null> {
Expand Down Expand Up @@ -703,7 +703,7 @@ export function build<TSchema extends BaseSchema, TOptions extends SchemaOptions
* }
*/
model.findCursor = wrap(model, async function findCursor<
TProjection extends Projection<TSchema> | undefined,
TProjection extends Projection<TProjection, TSchema> | undefined,
>(filter: PaprFilter<TSchema>, options?: Omit<FindOptions<TSchema>, 'projection'> & { projection?: TProjection }): Promise<
FindCursor<ProjectionType<TSchema, TProjection>>
> {
Expand Down Expand Up @@ -742,7 +742,7 @@ export function build<TSchema extends BaseSchema, TOptions extends SchemaOptions
// prettier-ignore
model.findOne = wrap(
model,
async function findOne<TProjection extends Projection<TSchema> | undefined>(
async function findOne<TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
options?: Omit<FindOptions<TSchema>, 'projection'> & { projection?: TProjection }
): Promise<ProjectionType<TSchema, TProjection> | null> {
Expand All @@ -768,7 +768,7 @@ export function build<TSchema extends BaseSchema, TOptions extends SchemaOptions
* const user = await User.findOneAndDelete({ firstName: 'John' });
*/
// prettier-ignore
model.findOneAndDelete = wrap(model, async function findOneAndDelete<TProjection extends Projection<TSchema> | undefined>(
model.findOneAndDelete = wrap(model, async function findOneAndDelete<TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
options?: Omit<FindOneAndDeleteOptions, 'projection'> & { projection?: TProjection }
): Promise<ProjectionType<TSchema, TProjection> | null> {
Expand Down Expand Up @@ -812,7 +812,7 @@ export function build<TSchema extends BaseSchema, TOptions extends SchemaOptions
* userProjected.lastName; // valid
*/
// prettier-ignore
model.findOneAndUpdate = wrap(model, async function findOneAndUpdate<TProjection extends Projection<TSchema> | undefined>(
model.findOneAndUpdate = wrap(model, async function findOneAndUpdate<TProjection extends Projection<TProjection, TSchema> | undefined>(
filter: PaprFilter<TSchema>,
update: PaprUpdateFilter<TSchema>,
options?: Omit<FindOneAndUpdateOptions, 'projection'> & { projection?: TProjection }
Expand Down Expand Up @@ -1051,7 +1051,9 @@ export function build<TSchema extends BaseSchema, TOptions extends SchemaOptions
* userProjected.firstName; // TypeScript error
* userProjected.lastName; // valid
*/
model.upsert = async function upsert<TProjection extends Projection<TSchema> | undefined>(
model.upsert = async function upsert<
TProjection extends Projection<TProjection, TSchema> | undefined,
>(
filter: PaprFilter<TSchema>,
update: PaprUpdateFilter<TSchema>,
options?: Omit<FindOneAndUpdateOptions, 'projection' | 'upsert'> & { projection?: TProjection }
Expand Down
18 changes: 14 additions & 4 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ type FilterProperties<TObject, TValue> = Pick<TObject, KeysOfAType<TObject, TVal
export type ProjectionType<
TSchema extends BaseSchema,
Projection extends
| Partial<Record<Join<NestedPaths<WithId<TSchema>, []>, '.'>, number>>
| ExactPartial<Projection, Record<Join<NestedPaths<WithId<TSchema>, []>, '.'>, number>>
| undefined,
> = undefined extends Projection
? WithId<TSchema>
Expand All @@ -147,9 +147,19 @@ export type ProjectionType<
keyof FilterProperties<Projection, 0>
>;

export type Projection<TSchema> = Partial<
Record<Join<NestedPaths<WithId<TSchema>, []>, '.'>, number>
>;
type ExactPartial<Subset, BaseObject> = {
[K in keyof Subset]: K extends keyof BaseObject
? BaseObject[K] extends Record<number | string | symbol, unknown>
? ExactPartial<Subset[K], BaseObject[K]>
: BaseObject[K]
: never;
};

export type Projection<
TProjection,
TSchema,
TPaths = Record<Join<NestedPaths<WithId<TSchema>, []>, '.'>, number>,
> = ExactPartial<TProjection, TPaths>;

export type PropertyNestedType<
Type,
Expand Down