diff --git a/src/cmap/wire_protocol/responses.ts b/src/cmap/wire_protocol/responses.ts index d5549aea54..114c37be9c 100644 --- a/src/cmap/wire_protocol/responses.ts +++ b/src/cmap/wire_protocol/responses.ts @@ -391,3 +391,9 @@ export class ClientBulkWriteCursorResponse extends CursorResponse { return this.get('writeConcernError', BSONType.object, false); } } + +export class ExplainResponse extends MongoDBResponse { + get queryPlanner() { + return this.get('queryPlanner', BSONType.object, false); + } +} diff --git a/src/collection.ts b/src/collection.ts index 1bdc89b262..bfe544e8a5 100644 --- a/src/collection.ts +++ b/src/collection.ts @@ -39,7 +39,7 @@ import { type EstimatedDocumentCountOptions } from './operations/estimated_document_count'; import { executeOperation } from './operations/execute_operation'; -import type { FindOptions } from './operations/find'; +import { type FindOptions } from './operations/find'; import { FindOneAndDeleteOperation, type FindOneAndDeleteOptions, @@ -48,6 +48,7 @@ import { FindOneAndUpdateOperation, type FindOneAndUpdateOptions } from './operations/find_and_modify'; +import { FindOneOperation, type FindOneOptions } from './operations/find_one'; import { CreateIndexesOperation, type CreateIndexesOptions, @@ -507,7 +508,7 @@ export class Collection { async findOne(filter: Filter): Promise | null>; async findOne( filter: Filter, - options: Omit & Abortable + options: Omit & Abortable ): Promise | null>; // allow an override of the schema. @@ -515,17 +516,25 @@ export class Collection { async findOne(filter: Filter): Promise; async findOne( filter: Filter, - options?: Omit & Abortable + options?: Omit & Abortable ): Promise; async findOne( filter: Filter = {}, - options: FindOptions & Abortable = {} + options: Omit & Abortable = {} ): Promise | null> { - const cursor = this.find(filter, options).limit(-1).batchSize(1); - const res = await cursor.next(); - await cursor.close(); - return res; + //const cursor = this.find(filter, options).limit(-1).batchSize(1); + //const res = await cursor.next(); + //await cursor.close(); + return await executeOperation( + this.client, + new FindOneOperation( + this.s.db, + this.collectionName, + filter, + resolveOptions(this as TODO_NODE_3286, options) + ) + ); } /** diff --git a/src/index.ts b/src/index.ts index b87a86042a..2540477a11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -529,6 +529,7 @@ export type { FindOneAndReplaceOptions, FindOneAndUpdateOptions } from './operations/find_and_modify'; +export type { FindOneOptions } from './operations/find_one'; export type { IndexInformationOptions } from './operations/indexes'; export type { CreateIndexesOptions, diff --git a/src/operations/find.ts b/src/operations/find.ts index 1775ea6e07..9dc967dda7 100644 --- a/src/operations/find.ts +++ b/src/operations/find.ts @@ -142,7 +142,11 @@ export class FindOperation extends CommandOperation { } } -function makeFindCommand(ns: MongoDBNamespace, filter: Document, options: FindOptions): Document { +export function makeFindCommand( + ns: MongoDBNamespace, + filter: Document, + options: FindOptions +): Document { const findCommand: Document = { find: ns.collection, filter @@ -195,7 +199,13 @@ function makeFindCommand(ns: MongoDBNamespace, filter: Document, options: FindOp findCommand.singleBatch = true; } else { - findCommand.batchSize = options.batchSize; + if (options.batchSize === options.limit) { + // Spec dictates that if these are equal the batchSize should be one more than the + // limit to avoid leaving the cursor open. + findCommand.batchSize = options.batchSize + 1; + } else { + findCommand.batchSize = options.batchSize; + } } } diff --git a/src/operations/find_one.ts b/src/operations/find_one.ts new file mode 100644 index 0000000000..6ca55266b5 --- /dev/null +++ b/src/operations/find_one.ts @@ -0,0 +1,91 @@ +import { type Db } from '..'; +import { type Document, pluckBSONSerializeOptions } from '../bson'; +import { type OnDemandDocumentDeserializeOptions } from '../cmap/wire_protocol/on_demand/document'; +import { CursorResponse, ExplainResponse } from '../cmap/wire_protocol/responses'; +import type { Server } from '../sdam/server'; +import type { ClientSession } from '../sessions'; +import { type TimeoutContext } from '../timeout'; +import { MongoDBNamespace } from '../utils'; +import { CommandOperation } from './command'; +import { type FindOptions, makeFindCommand } from './find'; +import { Aspect, defineAspects } from './operation'; + +/** @public */ +export interface FindOneOptions extends FindOptions { + /** @deprecated Will be removed in the next major version. User provided value will be ignored. */ + batchSize?: number; + /** @deprecated Will be removed in the next major version. User provided value will be ignored. */ + limit?: number; + /** @deprecated Will be removed in the next major version. User provided value will be ignored. */ + noCursorTimeout?: boolean; +} + +/** @internal */ +export class FindOneOperation extends CommandOperation { + override options: FindOneOptions; + /** @internal */ + private namespace: MongoDBNamespace; + /** @internal */ + private filter: Document; + /** @internal */ + protected deserializationOptions: OnDemandDocumentDeserializeOptions; + + constructor(db: Db, collectionName: string, filter: Document, options: FindOneOptions = {}) { + super(db, options); + this.namespace = new MongoDBNamespace(db.databaseName, collectionName); + // special case passing in an ObjectId as a filter + this.filter = filter != null && filter._bsontype === 'ObjectId' ? { _id: filter } : filter; + this.options = { ...options }; + this.deserializationOptions = { + ...pluckBSONSerializeOptions(options), + validation: { + utf8: options?.enableUtf8Validation === false ? false : true + } + }; + } + + override get commandName() { + return 'find' as const; + } + + override async execute( + server: Server, + session: ClientSession | undefined, + timeoutContext: TimeoutContext + ): Promise { + const command: Document = makeFindCommand(this.namespace, this.filter, this.options); + // Explicitly set the limit to 1 and singleBatch to true for all commands, per the spec. + // noCursorTimeout must be unset as well as batchSize. + // See: https://github.com/mongodb/specifications/blob/master/source/crud/crud.md#findone-api-details + command.limit = 1; + command.singleBatch = true; + if (command.noCursorTimeout != null) { + delete command.noCursorTimeout; + } + if (command.batchSize != null) { + delete command.batchSize; + } + + if (this.explain) { + const response = await super.executeCommand( + server, + session, + command, + timeoutContext, + ExplainResponse + ); + return response.toObject() as TSchema; + } else { + const response = await super.executeCommand( + server, + session, + command, + timeoutContext, + CursorResponse + ); + return response.shift(this.deserializationOptions); + } + } +} + +defineAspects(FindOneOperation, [Aspect.READ_OPERATION, Aspect.RETRYABLE, Aspect.EXPLAINABLE]); diff --git a/test/integration/crud/crud_api.test.ts b/test/integration/crud/crud_api.test.ts index eceb1ce60f..6b8bcfbadc 100644 --- a/test/integration/crud/crud_api.test.ts +++ b/test/integration/crud/crud_api.test.ts @@ -98,50 +98,6 @@ describe('CRUD API', function () { await collection.drop().catch(() => null); await client.close(); }); - - describe('when the operation succeeds', () => { - it('the cursor for findOne is closed', async function () { - const spy = sinon.spy(Collection.prototype, 'find'); - const result = await collection.findOne({}); - expect(result).to.deep.equal({ _id: 1 }); - expect(events.at(0)).to.be.instanceOf(CommandSucceededEvent); - expect(spy.returnValues.at(0)).to.have.property('closed', true); - expect(spy.returnValues.at(0)).to.have.nested.property('session.hasEnded', true); - }); - }); - - describe('when the find operation fails', () => { - beforeEach(async function () { - const failPoint: FailPoint = { - configureFailPoint: 'failCommand', - mode: 'alwaysOn', - data: { - failCommands: ['find'], - // 1 == InternalError, but this value not important to the test - errorCode: 1 - } - }; - await client.db().admin().command(failPoint); - }); - - afterEach(async function () { - const failPoint: FailPoint = { - configureFailPoint: 'failCommand', - mode: 'off', - data: { failCommands: ['find'] } - }; - await client.db().admin().command(failPoint); - }); - - it('the cursor for findOne is closed', async function () { - const spy = sinon.spy(Collection.prototype, 'find'); - const error = await collection.findOne({}).catch(error => error); - expect(error).to.be.instanceOf(MongoServerError); - expect(events.at(0)).to.be.instanceOf(CommandFailedEvent); - expect(spy.returnValues.at(0)).to.have.property('closed', true); - expect(spy.returnValues.at(0)).to.have.nested.property('session.hasEnded', true); - }); - }); }); describe('countDocuments()', () => { diff --git a/test/spec/crud/unified/find.json b/test/spec/crud/unified/find.json index 6bf1e4e445..325cd96c21 100644 --- a/test/spec/crud/unified/find.json +++ b/test/spec/crud/unified/find.json @@ -237,6 +237,68 @@ ] } ] + }, + { + "description": "Find with batchSize equal to limit", + "operations": [ + { + "object": "collection0", + "name": "find", + "arguments": { + "filter": { + "_id": { + "$gt": 1 + } + }, + "sort": { + "_id": 1 + }, + "limit": 4, + "batchSize": 4 + }, + "expectResult": [ + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + } + ] + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll0", + "filter": { + "_id": { + "$gt": 1 + } + }, + "limit": 4, + "batchSize": 5 + }, + "commandName": "find", + "databaseName": "find-tests" + } + } + ] + } + ] } ] } diff --git a/test/spec/crud/unified/find.yml b/test/spec/crud/unified/find.yml index 76676900fa..3a09c4d830 100644 --- a/test/spec/crud/unified/find.yml +++ b/test/spec/crud/unified/find.yml @@ -105,3 +105,31 @@ tests: - { _id: 2, x: 22 } - { _id: 3, x: 33 } - { _id: 4, x: 44 } + - + description: 'Find with batchSize equal to limit' + operations: + - + object: *collection0 + name: find + arguments: + filter: { _id: { $gt: 1 } } + sort: { _id: 1 } + limit: 4 + batchSize: 4 + expectResult: + - { _id: 2, x: 22 } + - { _id: 3, x: 33 } + - { _id: 4, x: 44 } + - { _id: 5, x: 55 } + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + find: *collection0Name + filter: { _id: { $gt: 1 } } + limit: 4 + # Drivers use limit + 1 for batchSize to ensure the server closes the cursor + batchSize: 5 + commandName: find + databaseName: *database0Name diff --git a/test/spec/crud/unified/findOne.json b/test/spec/crud/unified/findOne.json new file mode 100644 index 0000000000..826c0f5dfd --- /dev/null +++ b/test/spec/crud/unified/findOne.json @@ -0,0 +1,158 @@ +{ + "description": "findOne", + "schemaVersion": "1.0", + "createEntities": [ + { + "client": { + "id": "client0", + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "find-tests" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "coll0" + } + } + ], + "initialData": [ + { + "collectionName": "coll0", + "databaseName": "find-tests", + "documents": [ + { + "_id": 1, + "x": 11 + }, + { + "_id": 2, + "x": 22 + }, + { + "_id": 3, + "x": 33 + }, + { + "_id": 4, + "x": 44 + }, + { + "_id": 5, + "x": 55 + }, + { + "_id": 6, + "x": 66 + } + ] + } + ], + "tests": [ + { + "description": "FindOne with filter", + "operations": [ + { + "object": "collection0", + "name": "findOne", + "arguments": { + "filter": { + "_id": 1 + } + }, + "expectResult": { + "_id": 1, + "x": 11 + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll0", + "filter": { + "_id": 1 + }, + "batchSize": { + "$$exists": false + }, + "limit": 1, + "singleBatch": true + }, + "commandName": "find", + "databaseName": "find-tests" + } + } + ] + } + ] + }, + { + "description": "FindOne with filter, sort, and skip", + "operations": [ + { + "object": "collection0", + "name": "findOne", + "arguments": { + "filter": { + "_id": { + "$gt": 2 + } + }, + "sort": { + "_id": 1 + }, + "skip": 2 + }, + "expectResult": { + "_id": 5, + "x": 55 + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "find": "coll0", + "filter": { + "_id": { + "$gt": 2 + } + }, + "sort": { + "_id": 1 + }, + "skip": 2, + "batchSize": { + "$$exists": false + }, + "limit": 1, + "singleBatch": true + }, + "commandName": "find", + "databaseName": "find-tests" + } + } + ] + } + ] + } + ] +} diff --git a/test/spec/crud/unified/findOne.yml b/test/spec/crud/unified/findOne.yml new file mode 100644 index 0000000000..ed74124bf3 --- /dev/null +++ b/test/spec/crud/unified/findOne.yml @@ -0,0 +1,75 @@ +description: "findOne" + +schemaVersion: "1.0" + +createEntities: + - client: + id: &client0 client0 + observeEvents: [ commandStartedEvent ] + - database: + id: &database0 database0 + client: *client0 + databaseName: &database0Name find-tests + - collection: + id: &collection0 collection0 + database: *database0 + collectionName: &collection0Name coll0 + +initialData: + - collectionName: *collection0Name + databaseName: *database0Name + documents: + - { _id: 1, x: 11 } + - { _id: 2, x: 22 } + - { _id: 3, x: 33 } + - { _id: 4, x: 44 } + - { _id: 5, x: 55 } + - { _id: 6, x: 66 } + +tests: + - + description: 'FindOne with filter' + operations: + - + object: *collection0 + name: findOne + arguments: + filter: { _id: 1 } + expectResult: { _id: 1, x: 11 } + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + find: *collection0Name + filter: { _id: 1 } + batchSize: { $$exists: false } + limit: 1 + singleBatch: true + commandName: find + databaseName: *database0Name + - + description: 'FindOne with filter, sort, and skip' + operations: + - + object: *collection0 + name: findOne + arguments: + filter: { _id: { $gt: 2 } } + sort: { _id: 1 } + skip: 2 + expectResult: { _id: 5, x: 55 } + expectEvents: + - client: *client0 + events: + - commandStartedEvent: + command: + find: *collection0Name + filter: { _id: { $gt: 2 } } + sort: { _id: 1 } + skip: 2 + batchSize: { $$exists: false } + limit: 1 + singleBatch: true + commandName: find + databaseName: *database0Name