Skip to content

Commit 76fff97

Browse files
authored
feat(NODE-3589): support dot-notation attributes in Filter (#2972)
1 parent 91a67e0 commit 76fff97

File tree

5 files changed

+199
-21
lines changed

5 files changed

+199
-21
lines changed

.evergreen/run-checks.sh

+10-6
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ npm run check:lint
2020

2121
npm run check:unit
2222

23-
echo "Typescript $(npx tsc -v)"
23+
export TSC="./node_modules/typescript/bin/tsc"
24+
25+
echo "Typescript $($TSC -v)"
2426
# check resolution uses the default latest types
25-
echo "import * as mdb from '.'" > file.ts && npx tsc --noEmit --traceResolution file.ts | grep 'mongodb.d.ts' && rm file.ts
27+
echo "import * as mdb from '.'" > file.ts && $TSC --noEmit --traceResolution file.ts | grep 'mongodb.d.ts' && rm file.ts
2628

27-
npm i --no-save typescript@4.0.2 # there is no 4.0.0
28-
echo "Typescript $(npx tsc -v)"
29-
npx tsc --noEmit mongodb.ts34.d.ts
29+
npm i --no-save typescript@4.1.6
30+
echo "Typescript $($TSC -v)"
31+
$TSC --noEmit mongodb.ts34.d.ts
3032
# check that resolution uses the downleveled types
31-
echo "import * as mdb from '.'" > file.ts && npx tsc --noEmit --traceResolution file.ts | grep 'mongodb.ts34.d.ts' && rm file.ts
33+
echo "import * as mdb from '.'" > file.ts && $TSC --noEmit --traceResolution file.ts | grep 'mongodb.ts34.d.ts' && rm file.ts
34+
35+
rm -f file.ts

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ For version compatibility matrices, please refer to the following links:
4646

4747
#### Typescript Version
4848

49-
We recommend using the latest version of typescript, however we do provide a [downleveled](https://github.com./sandersn/downlevel-dts#readme) version of the type definitions that we test compiling against `typescript@4.0.2`.
49+
We recommend using the latest version of typescript, however we do provide a [downleveled](https://github.com./sandersn/downlevel-dts#readme) version of the type definitions that we test compiling against `typescript@4.1.6`.
5050
Since typescript [does not restrict breaking changes to major versions](https://github.com./Microsoft/TypeScript/wiki/Breaking-Changes) we consider this support best effort.
5151
If you run into any unexpected compiler failures please let us know and we will do our best to correct it.
5252

src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,11 @@ export type {
283283
InferIdType,
284284
IntegerType,
285285
IsAny,
286+
Join,
286287
KeysOfAType,
287288
KeysOfOtherType,
288289
MatchKeysAndValues,
290+
NestedPaths,
289291
NonObjectIdLikeDocument,
290292
NotAcceptedFields,
291293
NumericType,
@@ -295,6 +297,7 @@ export type {
295297
OptionalUnlessRequiredId,
296298
Projection,
297299
ProjectionOperators,
300+
PropertyType,
298301
PullAllOperator,
299302
PullOperator,
300303
PushOperator,

src/mongo_types.ts

+61-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ export type WithoutId<TSchema> = Omit<TSchema, '_id'>;
6666

6767
/** A MongoDB filter can be some portion of the schema or a set of operators @public */
6868
export type Filter<TSchema> = {
69-
[P in keyof WithId<TSchema>]?: Condition<WithId<TSchema>[P]>;
69+
[Property in Join<NestedPaths<WithId<TSchema>>, '.'>]?: Condition<
70+
PropertyType<WithId<TSchema>, Property>
71+
>;
7072
} & RootFilterOperators<WithId<TSchema>>;
7173

7274
/** @public */
@@ -440,3 +442,61 @@ export class TypedEventEmitter<Events extends EventsDescription> extends EventEm
440442

441443
/** @public */
442444
export class CancellationToken extends TypedEventEmitter<{ cancel(): void }> {}
445+
446+
/**
447+
* Helper types for dot-notation filter attributes
448+
*/
449+
450+
/** @public */
451+
export type Join<T extends unknown[], D extends string> = T extends []
452+
? ''
453+
: T extends [string | number]
454+
? `${T[0]}`
455+
: T extends [string | number, ...infer R]
456+
? `${T[0]}${D}${Join<R, D>}`
457+
: string;
458+
459+
/** @public */
460+
export type PropertyType<Type, Property extends string> = string extends Property
461+
? unknown
462+
: Property extends keyof Type
463+
? Type[Property]
464+
: Property extends `${number}`
465+
? Type extends ReadonlyArray<infer ArrayType>
466+
? ArrayType
467+
: unknown
468+
: Property extends `${infer Key}.${infer Rest}`
469+
? Key extends `${number}`
470+
? Type extends ReadonlyArray<infer ArrayType>
471+
? PropertyType<ArrayType, Rest>
472+
: unknown
473+
: Key extends keyof Type
474+
? Type[Key] extends Map<string, infer MapType>
475+
? MapType
476+
: PropertyType<Type[Key], Rest>
477+
: unknown
478+
: unknown;
479+
480+
// We dont't support nested circular references
481+
/** @public */
482+
export type NestedPaths<Type> = Type extends
483+
| string
484+
| number
485+
| boolean
486+
| Date
487+
| RegExp
488+
| Buffer
489+
| Uint8Array
490+
| ((...args: any[]) => any)
491+
| { _bsontype: string }
492+
? []
493+
: Type extends ReadonlyArray<infer ArrayType>
494+
? [number, ...NestedPaths<ArrayType>]
495+
: Type extends Map<string, any>
496+
? [string]
497+
: // eslint-disable-next-line @typescript-eslint/ban-types
498+
Type extends object
499+
? {
500+
[Key in Extract<keyof Type, string>]: [Key, ...NestedPaths<Type[Key]>];
501+
}[Extract<keyof Type, string>]
502+
: [];

test/types/community/collection/filterQuery.test-d.ts

+124-13
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import { BSONRegExp, Decimal128, ObjectId } from 'bson';
1+
import {
2+
Binary,
3+
BSONRegExp,
4+
BSONSymbol,
5+
Code,
6+
DBRef,
7+
Decimal128,
8+
Long,
9+
MaxKey,
10+
MinKey,
11+
ObjectId
12+
} from 'bson';
213
import { expectAssignable, expectError, expectNotType, expectType } from 'tsd';
314

415
import { Collection, Filter, MongoClient, WithId } from '../../../../src';
@@ -16,6 +27,11 @@ const db = client.db('test');
1627
* Test the generic Filter using collection.find<T>() method
1728
*/
1829

30+
interface HumanModel {
31+
_id: ObjectId;
32+
name: string;
33+
}
34+
1935
// a collection model for all possible MongoDB BSON types and TypeScript types
2036
interface PetModel {
2137
_id: ObjectId; // ObjectId field
@@ -24,14 +40,42 @@ interface PetModel {
2440
age: number; // number field
2541
type: 'dog' | 'cat' | 'fish'; // union field
2642
isCute: boolean; // boolean field
27-
bestFriend?: PetModel; // object field (Embedded/Nested Documents)
43+
bestFriend?: HumanModel; // object field (Embedded/Nested Documents)
2844
createdAt: Date; // date field
45+
numOfPats: Long; // long field
2946
treats: string[]; // array of string
3047
playTimePercent: Decimal128; // bson Decimal128 type
31-
readonly friends?: ReadonlyArray<PetModel>; // readonly array of objects
32-
playmates?: PetModel[]; // writable array of objects
48+
readonly friends?: ReadonlyArray<HumanModel>; // readonly array of objects
49+
playmates?: HumanModel[]; // writable array of objects
50+
laps?: Map<string, number>; // map field
51+
// Object with multiple nested levels
52+
meta?: {
53+
updatedAt?: Date;
54+
deep?: {
55+
nestedArray: number[];
56+
nested?: {
57+
level?: number;
58+
};
59+
};
60+
};
61+
62+
binary: Binary;
63+
code: Code;
64+
minKey: MinKey;
65+
maxKey: MaxKey;
66+
dBRef: DBRef;
67+
bSONSymbol: BSONSymbol;
68+
69+
regex: RegExp;
70+
71+
fn: (...args: any[]) => any;
3372
}
3473

74+
const john = {
75+
_id: new ObjectId('577fa2d90c4cc47e31cf4b6a'),
76+
name: 'John'
77+
};
78+
3579
const spot = {
3680
_id: new ObjectId('577fa2d90c4cc47e31cf4b6f'),
3781
name: 'Spot',
@@ -40,16 +84,30 @@ const spot = {
4084
type: 'dog' as const,
4185
isCute: true,
4286
createdAt: new Date(),
87+
numOfPats: Long.fromBigInt(100000000n),
4388
treats: ['kibble', 'bone'],
44-
playTimePercent: new Decimal128('0.999999')
89+
playTimePercent: new Decimal128('0.999999'),
90+
91+
binary: new Binary('', 2),
92+
code: new Code(() => true),
93+
minKey: new MinKey(),
94+
maxKey: new MaxKey(),
95+
dBRef: new DBRef('collection', new ObjectId()),
96+
bSONSymbol: new BSONSymbol('hi'),
97+
98+
regex: /a/,
99+
100+
fn() {
101+
return 'hi';
102+
}
45103
};
46104

47105
expectAssignable<PetModel>(spot);
48106

49107
const collectionT = db.collection<PetModel>('test.filterQuery');
50108

51109
// Assert that collection.find uses the Filter helper like so:
52-
const filter: Filter<PetModel> = {};
110+
const filter: Filter<PetModel> = {} as Filter<PetModel>;
53111
collectionT.find(filter);
54112
collectionT.find(spot); // a whole model definition is also a valid filter
55113
// Now tests below can directly test the Filter helper, and are implicitly checking collection.find
@@ -73,6 +131,10 @@ expectNotType<Filter<PetModel>>({ name: 23 });
73131
expectNotType<Filter<PetModel>>({ name: { suffix: 'Jr' } });
74132
expectNotType<Filter<PetModel>>({ name: ['Spot'] });
75133

134+
// it should not accept wrong types for function fields
135+
expectNotType<Filter<PetModel>>({ fn: 3 });
136+
expectAssignable<WithId<PetModel>[]>(await collectionT.find({ fn: () => true }).toArray());
137+
76138
/// it should query __number__ fields
77139
await collectionT.find({ age: 12 }).toArray();
78140
/// it should not accept wrong types for number fields
@@ -83,14 +145,67 @@ expectNotType<Filter<PetModel>>({ age: [23, 43] });
83145

84146
/// it should query __nested document__ fields only by exact match
85147
// TODO: we currently cannot enforce field order but field order is important for mongo
86-
await collectionT.find({ bestFriend: spot }).toArray();
148+
await collectionT.find({ bestFriend: john }).toArray();
87149
/// nested documents query should contain all required fields
88-
expectNotType<Filter<PetModel>>({ bestFriend: { family: 'Andersons' } });
150+
expectNotType<Filter<PetModel>>({ bestFriend: { name: 'Andersons' } });
89151
/// it should not accept wrong types for nested document fields
90152
expectNotType<Filter<PetModel>>({ bestFriend: 21 });
91153
expectNotType<Filter<PetModel>>({ bestFriend: 'Andersons' });
92154
expectNotType<Filter<PetModel>>({ bestFriend: [spot] });
93-
expectNotType<Filter<PetModel>>({ bestFriend: [{ family: 'Andersons' }] });
155+
expectNotType<Filter<PetModel>>({ bestFriend: [{ name: 'Andersons' }] });
156+
157+
// it should permit all our BSON types as query values
158+
expectAssignable<Filter<PetModel>>({ binary: new Binary('', 2) });
159+
expectAssignable<Filter<PetModel>>({ code: new Code(() => true) });
160+
expectAssignable<Filter<PetModel>>({ minKey: new MinKey() });
161+
expectAssignable<Filter<PetModel>>({ maxKey: new MaxKey() });
162+
expectAssignable<Filter<PetModel>>({ dBRef: new DBRef('collection', new ObjectId()) });
163+
expectAssignable<Filter<PetModel>>({ bSONSymbol: new BSONSymbol('hi') });
164+
165+
// None of the bson types should be broken up into their nested keys
166+
expectNotType<Filter<PetModel>>({ 'binary.sub_type': 2 });
167+
expectNotType<Filter<PetModel>>({ 'code.code': 'string' });
168+
expectNotType<Filter<PetModel>>({ 'minKey._bsontype': 'MinKey' });
169+
expectNotType<Filter<PetModel>>({ 'maxKey._bsontype': 'MaxKey' });
170+
expectNotType<Filter<PetModel>>({ 'dBRef.collection': 'collection' });
171+
expectNotType<Filter<PetModel>>({ 'bSONSymbol.value': 'hi' });
172+
expectNotType<Filter<PetModel>>({ 'numOfPats.__isLong__': true });
173+
expectNotType<Filter<PetModel>>({ 'playTimePercent.bytes.BYTES_PER_ELEMENT': 1 });
174+
expectNotType<Filter<PetModel>>({ 'binary.sub_type': 'blah' });
175+
expectNotType<Filter<PetModel>>({ 'regex.dotAll': true });
176+
177+
/// it should query __nested document__ fields using dot-notation
178+
collectionT.find({ 'meta.updatedAt': new Date() });
179+
collectionT.find({ 'meta.deep.nested.level': 123 });
180+
collectionT.find({ meta: { deep: { nested: { level: 123 } } } }); // no impact on actual nesting
181+
collectionT.find({ 'friends.0.name': 'John' });
182+
collectionT.find({ 'playmates.0.name': 'John' });
183+
// supports arrays with primitive types
184+
collectionT.find({ 'treats.0': 'bone' });
185+
collectionT.find({ 'laps.foo': 123 });
186+
187+
// Handle special BSON types
188+
collectionT.find({ numOfPats: Long.fromBigInt(2n) });
189+
collectionT.find({ playTimePercent: new Decimal128('123.2') });
190+
191+
// works with some extreme indexes
192+
collectionT.find({ 'friends.4294967295.name': 'John' });
193+
collectionT.find({ 'friends.999999999999999999999999999999999999.name': 'John' });
194+
195+
/// it should not accept wrong types for nested document fields
196+
expectNotType<Filter<PetModel>>({ 'meta.updatedAt': 123 });
197+
expectNotType<Filter<PetModel>>({ 'meta.updatedAt': true });
198+
expectNotType<Filter<PetModel>>({ 'meta.updatedAt': 'now' });
199+
expectNotType<Filter<PetModel>>({ 'meta.deep.nested.level': 'string' });
200+
expectNotType<Filter<PetModel>>({ 'meta.deep.nested.level': true });
201+
expectNotType<Filter<PetModel>>({ 'meta.deep.nested.level': new Date() });
202+
expectNotType<Filter<PetModel>>({ 'friends.0.name': 123 });
203+
expectNotType<Filter<PetModel>>({ 'playmates.0.name': 123 });
204+
expectNotType<Filter<PetModel>>({ 'laps.foo': 'string' });
205+
expectNotType<Filter<PetModel>>({ 'treats.0': 123 });
206+
207+
// Nested arrays aren't checked
208+
expectNotType<Filter<PetModel>>({ 'meta.deep.nestedArray.0': 'not a number' });
94209

95210
/// it should query __array__ fields by exact match
96211
await collectionT.find({ treats: ['kibble', 'bone'] }).toArray();
@@ -233,10 +348,6 @@ expectNotType<Filter<PetModel>>({ name: { $all: ['world', 'world'] } });
233348
expectNotType<Filter<PetModel>>({ age: { $elemMatch: [1, 2] } });
234349
expectNotType<Filter<PetModel>>({ type: { $size: 2 } });
235350

236-
// dot key case that shows it is assignable even when the referenced key is the wrong type
237-
expectAssignable<Filter<PetModel>>({ 'bestFriend.name': 23 }); // using dot notation permits any type for the key
238-
expectNotType<Filter<PetModel>>({ bestFriend: { name: 23 } });
239-
240351
// ObjectId are not allowed to be used as a query predicate (issue described here: NODE-3758)
241352
// this only applies to schemas where the _id is not of type ObjectId.
242353
declare const nonObjectIdCollection: Collection<{ _id: number; otherField: string }>;

0 commit comments

Comments
 (0)