From 76fff97606d5e8b33ff8453400ccaefd18a54a1a Mon Sep 17 00:00:00 2001 From: Valentin Agachi Date: Sat, 18 Dec 2021 00:04:40 +0100 Subject: [PATCH] feat(NODE-3589): support dot-notation attributes in Filter (#2972) --- .evergreen/run-checks.sh | 16 +- README.md | 2 +- src/index.ts | 3 + src/mongo_types.ts | 62 +++++++- .../collection/filterQuery.test-d.ts | 137 ++++++++++++++++-- 5 files changed, 199 insertions(+), 21 deletions(-) diff --git a/.evergreen/run-checks.sh b/.evergreen/run-checks.sh index 318023e3ab..944b4b36e7 100644 --- a/.evergreen/run-checks.sh +++ b/.evergreen/run-checks.sh @@ -20,12 +20,16 @@ npm run check:lint npm run check:unit -echo "Typescript $(npx tsc -v)" +export TSC="./node_modules/typescript/bin/tsc" + +echo "Typescript $($TSC -v)" # check resolution uses the default latest types -echo "import * as mdb from '.'" > file.ts && npx tsc --noEmit --traceResolution file.ts | grep 'mongodb.d.ts' && rm file.ts +echo "import * as mdb from '.'" > file.ts && $TSC --noEmit --traceResolution file.ts | grep 'mongodb.d.ts' && rm file.ts -npm i --no-save typescript@4.0.2 # there is no 4.0.0 -echo "Typescript $(npx tsc -v)" -npx tsc --noEmit mongodb.ts34.d.ts +npm i --no-save typescript@4.1.6 +echo "Typescript $($TSC -v)" +$TSC --noEmit mongodb.ts34.d.ts # check that resolution uses the downleveled types -echo "import * as mdb from '.'" > file.ts && npx tsc --noEmit --traceResolution file.ts | grep 'mongodb.ts34.d.ts' && rm file.ts +echo "import * as mdb from '.'" > file.ts && $TSC --noEmit --traceResolution file.ts | grep 'mongodb.ts34.d.ts' && rm file.ts + +rm -f file.ts diff --git a/README.md b/README.md index 6832698678..560f628f63 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ For version compatibility matrices, please refer to the following links: #### Typescript Version -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`. +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`. Since typescript [does not restrict breaking changes to major versions](https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes) we consider this support best effort. If you run into any unexpected compiler failures please let us know and we will do our best to correct it. diff --git a/src/index.ts b/src/index.ts index e3d71891d0..e3a53fdbc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -283,9 +283,11 @@ export type { InferIdType, IntegerType, IsAny, + Join, KeysOfAType, KeysOfOtherType, MatchKeysAndValues, + NestedPaths, NonObjectIdLikeDocument, NotAcceptedFields, NumericType, @@ -295,6 +297,7 @@ export type { OptionalUnlessRequiredId, Projection, ProjectionOperators, + PropertyType, PullAllOperator, PullOperator, PushOperator, diff --git a/src/mongo_types.ts b/src/mongo_types.ts index 5705908861..60564a87cf 100644 --- a/src/mongo_types.ts +++ b/src/mongo_types.ts @@ -66,7 +66,9 @@ export type WithoutId = Omit; /** A MongoDB filter can be some portion of the schema or a set of operators @public */ export type Filter = { - [P in keyof WithId]?: Condition[P]>; + [Property in Join>, '.'>]?: Condition< + PropertyType, Property> + >; } & RootFilterOperators>; /** @public */ @@ -440,3 +442,61 @@ export class TypedEventEmitter extends EventEm /** @public */ export class CancellationToken extends TypedEventEmitter<{ cancel(): void }> {} + +/** + * Helper types for dot-notation filter attributes + */ + +/** @public */ +export type Join = T extends [] + ? '' + : T extends [string | number] + ? `${T[0]}` + : T extends [string | number, ...infer R] + ? `${T[0]}${D}${Join}` + : string; + +/** @public */ +export type PropertyType = string extends Property + ? unknown + : Property extends keyof Type + ? Type[Property] + : Property extends `${number}` + ? Type extends ReadonlyArray + ? ArrayType + : unknown + : Property extends `${infer Key}.${infer Rest}` + ? Key extends `${number}` + ? Type extends ReadonlyArray + ? PropertyType + : unknown + : Key extends keyof Type + ? Type[Key] extends Map + ? MapType + : PropertyType + : unknown + : unknown; + +// We dont't support nested circular references +/** @public */ +export type NestedPaths = Type extends + | string + | number + | boolean + | Date + | RegExp + | Buffer + | Uint8Array + | ((...args: any[]) => any) + | { _bsontype: string } + ? [] + : Type extends ReadonlyArray + ? [number, ...NestedPaths] + : Type extends Map + ? [string] + : // eslint-disable-next-line @typescript-eslint/ban-types + Type extends object + ? { + [Key in Extract]: [Key, ...NestedPaths]; + }[Extract] + : []; diff --git a/test/types/community/collection/filterQuery.test-d.ts b/test/types/community/collection/filterQuery.test-d.ts index b3c8ebe723..53c83f586f 100644 --- a/test/types/community/collection/filterQuery.test-d.ts +++ b/test/types/community/collection/filterQuery.test-d.ts @@ -1,4 +1,15 @@ -import { BSONRegExp, Decimal128, ObjectId } from 'bson'; +import { + Binary, + BSONRegExp, + BSONSymbol, + Code, + DBRef, + Decimal128, + Long, + MaxKey, + MinKey, + ObjectId +} from 'bson'; import { expectAssignable, expectError, expectNotType, expectType } from 'tsd'; import { Collection, Filter, MongoClient, WithId } from '../../../../src'; @@ -16,6 +27,11 @@ const db = client.db('test'); * Test the generic Filter using collection.find() method */ +interface HumanModel { + _id: ObjectId; + name: string; +} + // a collection model for all possible MongoDB BSON types and TypeScript types interface PetModel { _id: ObjectId; // ObjectId field @@ -24,14 +40,42 @@ interface PetModel { age: number; // number field type: 'dog' | 'cat' | 'fish'; // union field isCute: boolean; // boolean field - bestFriend?: PetModel; // object field (Embedded/Nested Documents) + bestFriend?: HumanModel; // object field (Embedded/Nested Documents) createdAt: Date; // date field + numOfPats: Long; // long field treats: string[]; // array of string playTimePercent: Decimal128; // bson Decimal128 type - readonly friends?: ReadonlyArray; // readonly array of objects - playmates?: PetModel[]; // writable array of objects + readonly friends?: ReadonlyArray; // readonly array of objects + playmates?: HumanModel[]; // writable array of objects + laps?: Map; // map field + // Object with multiple nested levels + meta?: { + updatedAt?: Date; + deep?: { + nestedArray: number[]; + nested?: { + level?: number; + }; + }; + }; + + binary: Binary; + code: Code; + minKey: MinKey; + maxKey: MaxKey; + dBRef: DBRef; + bSONSymbol: BSONSymbol; + + regex: RegExp; + + fn: (...args: any[]) => any; } +const john = { + _id: new ObjectId('577fa2d90c4cc47e31cf4b6a'), + name: 'John' +}; + const spot = { _id: new ObjectId('577fa2d90c4cc47e31cf4b6f'), name: 'Spot', @@ -40,8 +84,22 @@ const spot = { type: 'dog' as const, isCute: true, createdAt: new Date(), + numOfPats: Long.fromBigInt(100000000n), treats: ['kibble', 'bone'], - playTimePercent: new Decimal128('0.999999') + playTimePercent: new Decimal128('0.999999'), + + binary: new Binary('', 2), + code: new Code(() => true), + minKey: new MinKey(), + maxKey: new MaxKey(), + dBRef: new DBRef('collection', new ObjectId()), + bSONSymbol: new BSONSymbol('hi'), + + regex: /a/, + + fn() { + return 'hi'; + } }; expectAssignable(spot); @@ -49,7 +107,7 @@ expectAssignable(spot); const collectionT = db.collection('test.filterQuery'); // Assert that collection.find uses the Filter helper like so: -const filter: Filter = {}; +const filter: Filter = {} as Filter; collectionT.find(filter); collectionT.find(spot); // a whole model definition is also a valid filter // Now tests below can directly test the Filter helper, and are implicitly checking collection.find @@ -73,6 +131,10 @@ expectNotType>({ name: 23 }); expectNotType>({ name: { suffix: 'Jr' } }); expectNotType>({ name: ['Spot'] }); +// it should not accept wrong types for function fields +expectNotType>({ fn: 3 }); +expectAssignable[]>(await collectionT.find({ fn: () => true }).toArray()); + /// it should query __number__ fields await collectionT.find({ age: 12 }).toArray(); /// it should not accept wrong types for number fields @@ -83,14 +145,67 @@ expectNotType>({ age: [23, 43] }); /// it should query __nested document__ fields only by exact match // TODO: we currently cannot enforce field order but field order is important for mongo -await collectionT.find({ bestFriend: spot }).toArray(); +await collectionT.find({ bestFriend: john }).toArray(); /// nested documents query should contain all required fields -expectNotType>({ bestFriend: { family: 'Andersons' } }); +expectNotType>({ bestFriend: { name: 'Andersons' } }); /// it should not accept wrong types for nested document fields expectNotType>({ bestFriend: 21 }); expectNotType>({ bestFriend: 'Andersons' }); expectNotType>({ bestFriend: [spot] }); -expectNotType>({ bestFriend: [{ family: 'Andersons' }] }); +expectNotType>({ bestFriend: [{ name: 'Andersons' }] }); + +// it should permit all our BSON types as query values +expectAssignable>({ binary: new Binary('', 2) }); +expectAssignable>({ code: new Code(() => true) }); +expectAssignable>({ minKey: new MinKey() }); +expectAssignable>({ maxKey: new MaxKey() }); +expectAssignable>({ dBRef: new DBRef('collection', new ObjectId()) }); +expectAssignable>({ bSONSymbol: new BSONSymbol('hi') }); + +// None of the bson types should be broken up into their nested keys +expectNotType>({ 'binary.sub_type': 2 }); +expectNotType>({ 'code.code': 'string' }); +expectNotType>({ 'minKey._bsontype': 'MinKey' }); +expectNotType>({ 'maxKey._bsontype': 'MaxKey' }); +expectNotType>({ 'dBRef.collection': 'collection' }); +expectNotType>({ 'bSONSymbol.value': 'hi' }); +expectNotType>({ 'numOfPats.__isLong__': true }); +expectNotType>({ 'playTimePercent.bytes.BYTES_PER_ELEMENT': 1 }); +expectNotType>({ 'binary.sub_type': 'blah' }); +expectNotType>({ 'regex.dotAll': true }); + +/// it should query __nested document__ fields using dot-notation +collectionT.find({ 'meta.updatedAt': new Date() }); +collectionT.find({ 'meta.deep.nested.level': 123 }); +collectionT.find({ meta: { deep: { nested: { level: 123 } } } }); // no impact on actual nesting +collectionT.find({ 'friends.0.name': 'John' }); +collectionT.find({ 'playmates.0.name': 'John' }); +// supports arrays with primitive types +collectionT.find({ 'treats.0': 'bone' }); +collectionT.find({ 'laps.foo': 123 }); + +// Handle special BSON types +collectionT.find({ numOfPats: Long.fromBigInt(2n) }); +collectionT.find({ playTimePercent: new Decimal128('123.2') }); + +// works with some extreme indexes +collectionT.find({ 'friends.4294967295.name': 'John' }); +collectionT.find({ 'friends.999999999999999999999999999999999999.name': 'John' }); + +/// it should not accept wrong types for nested document fields +expectNotType>({ 'meta.updatedAt': 123 }); +expectNotType>({ 'meta.updatedAt': true }); +expectNotType>({ 'meta.updatedAt': 'now' }); +expectNotType>({ 'meta.deep.nested.level': 'string' }); +expectNotType>({ 'meta.deep.nested.level': true }); +expectNotType>({ 'meta.deep.nested.level': new Date() }); +expectNotType>({ 'friends.0.name': 123 }); +expectNotType>({ 'playmates.0.name': 123 }); +expectNotType>({ 'laps.foo': 'string' }); +expectNotType>({ 'treats.0': 123 }); + +// Nested arrays aren't checked +expectNotType>({ 'meta.deep.nestedArray.0': 'not a number' }); /// it should query __array__ fields by exact match await collectionT.find({ treats: ['kibble', 'bone'] }).toArray(); @@ -233,10 +348,6 @@ expectNotType>({ name: { $all: ['world', 'world'] } }); expectNotType>({ age: { $elemMatch: [1, 2] } }); expectNotType>({ type: { $size: 2 } }); -// dot key case that shows it is assignable even when the referenced key is the wrong type -expectAssignable>({ 'bestFriend.name': 23 }); // using dot notation permits any type for the key -expectNotType>({ bestFriend: { name: 23 } }); - // ObjectId are not allowed to be used as a query predicate (issue described here: NODE-3758) // this only applies to schemas where the _id is not of type ObjectId. declare const nonObjectIdCollection: Collection<{ _id: number; otherField: string }>;