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

feat(NODE-3875): support recursive schema types #3433

Merged
merged 12 commits into from Oct 13, 2022
36 changes: 19 additions & 17 deletions src/mongo_types.ts
Expand Up @@ -68,7 +68,7 @@ export type WithoutId<TSchema> = Omit<TSchema, '_id'>;
export type Filter<TSchema> =
| Partial<TSchema>
| ({
[Property in Join<NestedPaths<WithId<TSchema>>, '.'>]?: Condition<
[Property in Join<NestedPaths<WithId<TSchema>, []>, '.'>]?: Condition<
PropertyType<WithId<TSchema>, Property>
>;
} & RootFilterOperators<WithId<TSchema>>);
Expand Down Expand Up @@ -263,7 +263,7 @@ export type OnlyFieldsOfType<TSchema, FieldType = any, AssignableType = FieldTyp
/** @public */
export type MatchKeysAndValues<TSchema> = Readonly<
{
[Property in Join<NestedPaths<TSchema>, '.'>]?: PropertyType<TSchema, Property>;
[Property in Join<NestedPaths<TSchema, []>, '.'>]?: PropertyType<TSchema, Property>;
} & {
[Property in `${NestedPathsOfType<TSchema, any[]>}.$${`[${string}]` | ''}`]?: ArrayElement<
PropertyType<TSchema, Property extends `${infer Key}.$${string}` ? Key : never>
Expand All @@ -272,7 +272,7 @@ export type MatchKeysAndValues<TSchema> = Readonly<
[Property in `${NestedPathsOfType<TSchema, Record<string, any>[]>}.$${
| `[${string}]`
| ''}.${string}`]?: any; // Could be further narrowed
}
} & Document
>;

/** @public */
Expand Down Expand Up @@ -499,19 +499,21 @@ export type PropertyType<Type, Property extends string> = string extends Propert
* returns tuple of strings (keys to be joined on '.') that represent every path into a schema
* https://docs.mongodb.com/manual/tutorial/query-embedded-documents/
*/
export type NestedPaths<Type> = Type extends
| string
| number
| boolean
| Date
| RegExp
| Buffer
| Uint8Array
| ((...args: any[]) => any)
| { _bsontype: string }
export type NestedPaths<Type, Depth extends number[]> = Depth['length'] extends 10
dariakp marked this conversation as resolved.
Show resolved Hide resolved
? []
: Type extends
| string
| number
| boolean
| Date
| RegExp
| Buffer
| Uint8Array
| ((...args: any[]) => any)
| { _bsontype: string }
? []
: Type extends ReadonlyArray<infer ArrayType>
? [] | [number, ...NestedPaths<ArrayType>]
? [] | [number, ...NestedPaths<ArrayType, [...Depth, 1]>]
: Type extends Map<string, any>
? [string]
: Type extends object
Expand All @@ -529,9 +531,9 @@ export type NestedPaths<Type> = Type extends
ArrayType extends Type
? [Key] // we have a recursive array union
: // child is an array, but it's not a recursive array
[Key, ...NestedPaths<Type[Key]>]
[Key, ...NestedPaths<Type[Key], [...Depth, 1]>]
: // child is not structured the same as the parent
[Key, ...NestedPaths<Type[Key]>] | [Key];
[Key, ...NestedPaths<Type[Key], [...Depth, 1]>] | [Key];
}[Extract<keyof Type, string>]
: [];

Expand All @@ -542,7 +544,7 @@ export type NestedPaths<Type> = Type extends
*/
export type NestedPathsOfType<TSchema, Type> = KeysOfAType<
{
[Property in Join<NestedPaths<TSchema>, '.'>]: PropertyType<TSchema, Property>;
[Property in Join<NestedPaths<TSchema, []>, '.'>]: PropertyType<TSchema, Property>;
},
Type
>;
4 changes: 2 additions & 2 deletions test/types/basic_schema.test-d.ts
@@ -1,6 +1,6 @@
import { expectAssignable, expectNotAssignable, expectNotType, expectType } from 'tsd';

import { ObjectId } from '../../src/bson';
import { Document, ObjectId } from '../../src/bson';
import { Collection } from '../../src/collection';
import { Db } from '../../src/db';
import { MongoClient } from '../../src/mongo_client';
Expand All @@ -20,7 +20,7 @@ expectType<Collection<ACounterWithId>>(new Collection<ACounterWithId>(db, ''));
////////////////////////////////////////////////////////////////////////////////////////////////////
// Simple Schema that does not define an _id
// With _id
type InsertOneArgOf<S> = Parameters<Collection<S>['insertOne']>[0];
type InsertOneArgOf<S extends Document> = Parameters<Collection<S>['insertOne']>[0];
dariakp marked this conversation as resolved.
Show resolved Hide resolved
expectAssignable<InsertOneArgOf<ACounter>>({ _id: new ObjectId(), a: 3 });
// Without _id
expectAssignable<InsertOneArgOf<ACounter>>({ a: 3 });
Expand Down
@@ -1,23 +1,70 @@
import { expectError } from 'tsd';
import { expectAssignable, expectError, expectNotAssignable, expectNotType } from 'tsd';

import type { Collection } from '../../../../src';
import type { Collection, Filter, UpdateFilter } from '../../../../src';

/**
* mutually recursive types are not supported and will not get type safety
*/
interface A {
b: B;
interface Author {
name: string;
favoritePublication: Book;
}

interface B {
a: A;
interface Book {
title: string;
author: Author;
}

declare const mutuallyRecursive: Collection<A>;
//@ts-expect-error
mutuallyRecursive.find({});
mutuallyRecursive.find({
b: {}
expectAssignable<Filter<Author>>({
favoritePublication: {
title: 'book title',
author: {
name: 'author name'
}
}
});
expectNotType<UpdateFilter<Author>>({
dariakp marked this conversation as resolved.
Show resolved Hide resolved
$set: {
favoritePublication: {
title: 'a title',
published: new Date(),
author: {
name: 23
}
}
}
});

// Extremely deep type checking for recursive schemas
expectNotAssignable<Filter<Author>>({
'favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.title': 23
});
expectAssignable<Filter<Author>>({
'favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.title':
'good soup'
});
expectNotAssignable<Filter<Author>>({
'favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.author.name': 23
});

// Beyond the depth of 10, `extends Document` permits anything (number for name is permitted)
dariakp marked this conversation as resolved.
Show resolved Hide resolved
expectAssignable<Filter<Author>>({
'favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.author.name': 23
});

// Update filter has similar depth limit
expectAssignable<UpdateFilter<Author>>({
$set: {
'favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.author.name':
dariakp marked this conversation as resolved.
Show resolved Hide resolved
'joe'
}
});

// Depth below 9 is type checked
expectNotAssignable<UpdateFilter<Author>>({
$set: {
'favoritePublication.author.favoritePublication.author.favoritePublication.author.favoritePublication.author.name': 3
}
});

/**
Expand Down Expand Up @@ -173,3 +220,45 @@ recursiveSchemaWithArray.findOne({
name: 3
}
});

// Modeling A -> B -> C -> D -> A recursive type
type A = {
name: string;
b: B;
};

type B = {
name: string;
c: C;
};

type C = {
name: string;
d: D;
};

type D = {
name: string;
a: A;
};

expectAssignable<Filter<A>>({
'b.c.d.a.b.c.d.a.b.name': 'a'
});

// Beyond the depth supported, there is no type checking
expectAssignable<Filter<A>>({
baileympearson marked this conversation as resolved.
Show resolved Hide resolved
'b.c.d.a.b.c.d.a.b.c.name': 3
});

expectAssignable<Filter<A>>({
'b.c.d.a.b.c.d.a.b.c.name': 2
});

expectAssignable<UpdateFilter<A>>({
$set: { 'b.c.d.a.b.c.d.a.b.name': 'a' }
});

expectAssignable<UpdateFilter<A>>({
$set: { 'b.c.d.a.b.c.d.a.b.c.name': 'a' }
});
10 changes: 8 additions & 2 deletions test/types/community/collection/updateX.test-d.ts
Expand Up @@ -221,7 +221,10 @@ expectError<UpdateFilter<TestModel>>({
$set: { 'subInterfaceField.nestedObject': { a: 1, b: '2' } }
});
expectError(buildUpdateFilter({ $set: { 'subInterfaceField.field2': 2 } }));
expectError(buildUpdateFilter({ $set: { 'unknown.field': null } }));

// NODE-3875 introduced intersection with Document to the MatchKeysAndValues so this no longer errors
// expectError(buildUpdateFilter({ $set: { 'unknown.field': null } }));
baileympearson marked this conversation as resolved.
Show resolved Hide resolved

expectAssignable<UpdateFilter<TestModel>>({ $set: { 'numberArray.$': 40 } });
expectAssignable<UpdateFilter<TestModel>>({ $set: { 'numberArray.$[bla]': 40 } });
expectAssignable<UpdateFilter<TestModel>>({ $set: { 'numberArray.$[]': 1000.2 } });
Expand All @@ -241,7 +244,10 @@ expectAssignable<UpdateFilter<TestModel>>({ $setOnInsert: { stringField: 'a' } }
expectError(buildUpdateFilter({ $setOnInsert: { stringField: 123 } }));
expectAssignable<UpdateFilter<TestModel>>({ $setOnInsert: { 'subInterfaceField.field1': '2' } });
expectError(buildUpdateFilter({ $setOnInsert: { 'subInterfaceField.field2': 2 } }));
expectError(buildUpdateFilter({ $setOnInsert: { 'unknown.field': null } }));

// NODE-3875 introduced intersection with Document to the MatchKeysAndValues so this no longer errors
// expectError(buildUpdateFilter({ $setOnInsert: { 'unknown.field': null } }));
baileympearson marked this conversation as resolved.
Show resolved Hide resolved

expectAssignable<UpdateFilter<TestModel>>({ $setOnInsert: { 'numberArray.$': 40 } });
expectAssignable<UpdateFilter<TestModel>>({ $setOnInsert: { 'numberArray.$[bla]': 40 } });
expectAssignable<UpdateFilter<TestModel>>({ $setOnInsert: { 'numberArray.$[]': 1000.2 } });
Expand Down
4 changes: 2 additions & 2 deletions test/types/union_schema.test-d.ts
@@ -1,10 +1,10 @@
import { expectAssignable, expectError, expectNotAssignable, expectNotType, expectType } from 'tsd';

import { ObjectId } from '../../src/bson';
import { Document, ObjectId } from '../../src/bson';
import type { Collection } from '../../src/collection';
import type { WithId } from '../../src/mongo_types';

type InsertOneFirstParam<Schema> = Parameters<Collection<Schema>['insertOne']>[0];
type InsertOneFirstParam<Schema extends Document> = Parameters<Collection<Schema>['insertOne']>[0];
baileympearson marked this conversation as resolved.
Show resolved Hide resolved

interface Circle {
_id: ObjectId;
Expand Down