Skip to content

Commit 28857b7

Browse files
authored
feat(NODE-6290): add sort support to updateOne and replaceOne (#4515)
1 parent 746af47 commit 28857b7

21 files changed

+2086
-27
lines changed

src/bulk/common.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { makeUpdateStatement, UpdateOperation, type UpdateStatement } from '../o
1919
import type { Server } from '../sdam/server';
2020
import type { Topology } from '../sdam/topology';
2121
import type { ClientSession } from '../sessions';
22+
import { type Sort } from '../sort';
2223
import { type TimeoutContext } from '../timeout';
2324
import {
2425
applyRetryableWrites,
@@ -68,7 +69,7 @@ export interface DeleteManyModel<TSchema extends Document = Document> {
6869

6970
/** @public */
7071
export interface ReplaceOneModel<TSchema extends Document = Document> {
71-
/** The filter to limit the replaced document. */
72+
/** The filter that specifies which document to replace. In the case of multiple matches, the first document matched is replaced. */
7273
filter: Filter<TSchema>;
7374
/** The document with which to replace the matched document. */
7475
replacement: WithoutId<TSchema>;
@@ -78,11 +79,13 @@ export interface ReplaceOneModel<TSchema extends Document = Document> {
7879
hint?: Hint;
7980
/** When true, creates a new document if no document matches the query. */
8081
upsert?: boolean;
82+
/** Specifies the sort order for the documents matched by the filter. */
83+
sort?: Sort;
8184
}
8285

8386
/** @public */
8487
export interface UpdateOneModel<TSchema extends Document = Document> {
85-
/** The filter to limit the updated documents. */
88+
/** The filter that specifies which document to update. In the case of multiple matches, the first document matched is updated. */
8689
filter: Filter<TSchema>;
8790
/**
8891
* The modifications to apply. The value can be either:
@@ -98,6 +101,8 @@ export interface UpdateOneModel<TSchema extends Document = Document> {
98101
hint?: Hint;
99102
/** When true, creates a new document if no document matches the query. */
100103
upsert?: boolean;
104+
/** Specifies the sort order for the documents matched by the filter. */
105+
sort?: Sort;
101106
}
102107

103108
/** @public */

src/collection.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ import {
8787
} from './operations/update';
8888
import { ReadConcern, type ReadConcernLike } from './read_concern';
8989
import { ReadPreference, type ReadPreferenceLike } from './read_preference';
90+
import { type Sort } from './sort';
9091
import {
9192
DEFAULT_PK_FACTORY,
9293
MongoDBCollectionNamespace,
@@ -365,7 +366,7 @@ export class Collection<TSchema extends Document = Document> {
365366
async updateOne(
366367
filter: Filter<TSchema>,
367368
update: UpdateFilter<TSchema> | Document[],
368-
options?: UpdateOptions
369+
options?: UpdateOptions & { sort?: Sort }
369370
): Promise<UpdateResult<TSchema>> {
370371
return await executeOperation(
371372
this.client,

src/operations/client_bulk_write/command_builder.ts

+9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DocumentSequence } from '../../cmap/commands';
33
import { MongoAPIError, MongoInvalidArgumentError } from '../../error';
44
import { type PkFactory } from '../../mongo_client';
55
import type { Filter, OptionalId, UpdateFilter, WithoutId } from '../../mongo_types';
6+
import { formatSort, type SortForCmd } from '../../sort';
67
import { DEFAULT_PK_FACTORY, hasAtomicOperators } from '../../utils';
78
import { type CollationOptions } from '../command';
89
import { type Hint } from '../operation';
@@ -327,6 +328,7 @@ export interface ClientUpdateOperation {
327328
upsert?: boolean;
328329
arrayFilters?: Document[];
329330
collation?: CollationOptions;
331+
sort?: SortForCmd;
330332
}
331333

332334
/**
@@ -398,6 +400,9 @@ function createUpdateOperation(
398400
if (model.collation) {
399401
document.collation = model.collation;
400402
}
403+
if (!multi && 'sort' in model && model.sort != null) {
404+
document.sort = formatSort(model.sort);
405+
}
401406
return document;
402407
}
403408

@@ -410,6 +415,7 @@ export interface ClientReplaceOneOperation {
410415
hint?: Hint;
411416
upsert?: boolean;
412417
collation?: CollationOptions;
418+
sort?: SortForCmd;
413419
}
414420

415421
/**
@@ -443,6 +449,9 @@ export const buildReplaceOneOperation = (
443449
if (model.collation) {
444450
document.collation = model.collation;
445451
}
452+
if (model.sort != null) {
453+
document.sort = formatSort(model.sort);
454+
}
446455
return document;
447456
};
448457

src/operations/client_bulk_write/common.ts

+5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type Document } from '../../bson';
22
import type { Filter, OptionalId, UpdateFilter, WithoutId } from '../../mongo_types';
33
import type { CollationOptions, CommandOperationOptions } from '../../operations/command';
44
import type { Hint } from '../../operations/operation';
5+
import { type Sort } from '../../sort';
56

67
/** @public */
78
export interface ClientBulkWriteOptions extends CommandOperationOptions {
@@ -89,6 +90,8 @@ export interface ClientReplaceOneModel<TSchema> extends ClientWriteModel {
8990
hint?: Hint;
9091
/** When true, creates a new document if no document matches the query. */
9192
upsert?: boolean;
93+
/** Specifies the sort order for the documents matched by the filter. */
94+
sort?: Sort;
9295
}
9396

9497
/** @public */
@@ -113,6 +116,8 @@ export interface ClientUpdateOneModel<TSchema> extends ClientWriteModel {
113116
hint?: Hint;
114117
/** When true, creates a new document if no document matches the query. */
115118
upsert?: boolean;
119+
/** Specifies the sort order for the documents matched by the filter. */
120+
sort?: Sort;
116121
}
117122

118123
/** @public */

src/operations/update.ts

+10-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { MongoCompatibilityError, MongoInvalidArgumentError, MongoServerError }
44
import type { InferIdType, TODO_NODE_3286 } from '../mongo_types';
55
import type { Server } from '../sdam/server';
66
import type { ClientSession } from '../sessions';
7+
import { formatSort, type Sort, type SortForCmd } from '../sort';
78
import { type TimeoutContext } from '../timeout';
89
import { hasAtomicOperators, type MongoDBNamespace } from '../utils';
910
import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command';
@@ -58,6 +59,8 @@ export interface UpdateStatement {
5859
arrayFilters?: Document[];
5960
/** A document or string that specifies the index to use to support the query predicate. */
6061
hint?: Hint;
62+
/** Specifies the sort order for the documents matched by the filter. */
63+
sort?: SortForCmd;
6164
}
6265

6366
/**
@@ -214,6 +217,8 @@ export interface ReplaceOptions extends CommandOperationOptions {
214217
upsert?: boolean;
215218
/** Map of parameter names and values that can be accessed using $$var (requires MongoDB 5.0). */
216219
let?: Document;
220+
/** Specifies the sort order for the documents matched by the filter. */
221+
sort?: Sort;
217222
}
218223

219224
/** @internal */
@@ -259,7 +264,7 @@ export class ReplaceOneOperation extends UpdateOperation {
259264
export function makeUpdateStatement(
260265
filter: Document,
261266
update: Document | Document[],
262-
options: UpdateOptions & { multi?: boolean }
267+
options: UpdateOptions & { multi?: boolean } & { sort?: Sort }
263268
): UpdateStatement {
264269
if (filter == null || typeof filter !== 'object') {
265270
throw new MongoInvalidArgumentError('Selector must be a valid JavaScript object');
@@ -290,6 +295,10 @@ export function makeUpdateStatement(
290295
op.collation = options.collation;
291296
}
292297

298+
if (!options.multi && options.sort != null) {
299+
op.sort = formatSort(options.sort);
300+
}
301+
293302
return op;
294303
}
295304

src/sort.ts

+32-23
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,24 @@ export type SortDirection =
88
| 'desc'
99
| 'ascending'
1010
| 'descending'
11-
| { $meta: string };
11+
| { readonly $meta: string };
1212

1313
/** @public */
1414
export type Sort =
1515
| string
16-
| Exclude<SortDirection, { $meta: string }>
17-
| string[]
18-
| { [key: string]: SortDirection }
19-
| Map<string, SortDirection>
20-
| [string, SortDirection][]
21-
| [string, SortDirection];
16+
| Exclude<SortDirection, { readonly $meta: string }>
17+
| ReadonlyArray<string>
18+
| { readonly [key: string]: SortDirection }
19+
| ReadonlyMap<string, SortDirection>
20+
| ReadonlyArray<readonly [string, SortDirection]>
21+
| readonly [string, SortDirection];
2222

2323
/** Below stricter types were created for sort that correspond with type that the cmd takes */
2424

25-
/** @internal */
25+
/** @public */
2626
export type SortDirectionForCmd = 1 | -1 | { $meta: string };
2727

28-
/** @internal */
28+
/** @public */
2929
export type SortForCmd = Map<string, SortDirectionForCmd>;
3030

3131
/** @internal */
@@ -55,7 +55,7 @@ function isMeta(t: SortDirection): t is { $meta: string } {
5555
}
5656

5757
/** @internal */
58-
function isPair(t: Sort): t is [string, SortDirection] {
58+
function isPair(t: Sort): t is readonly [string, SortDirection] {
5959
if (Array.isArray(t) && t.length === 2) {
6060
try {
6161
prepareDirection(t[1]);
@@ -67,33 +67,37 @@ function isPair(t: Sort): t is [string, SortDirection] {
6767
return false;
6868
}
6969

70-
function isDeep(t: Sort): t is [string, SortDirection][] {
70+
function isDeep(t: Sort): t is ReadonlyArray<readonly [string, SortDirection]> {
7171
return Array.isArray(t) && Array.isArray(t[0]);
7272
}
7373

74-
function isMap(t: Sort): t is Map<string, SortDirection> {
74+
function isMap(t: Sort): t is ReadonlyMap<string, SortDirection> {
7575
return t instanceof Map && t.size > 0;
7676
}
7777

78+
function isReadonlyArray<T>(value: any): value is readonly T[] {
79+
return Array.isArray(value);
80+
}
81+
7882
/** @internal */
79-
function pairToMap(v: [string, SortDirection]): SortForCmd {
83+
function pairToMap(v: readonly [string, SortDirection]): SortForCmd {
8084
return new Map([[`${v[0]}`, prepareDirection([v[1]])]]);
8185
}
8286

8387
/** @internal */
84-
function deepToMap(t: [string, SortDirection][]): SortForCmd {
88+
function deepToMap(t: ReadonlyArray<readonly [string, SortDirection]>): SortForCmd {
8589
const sortEntries: SortPairForCmd[] = t.map(([k, v]) => [`${k}`, prepareDirection(v)]);
8690
return new Map(sortEntries);
8791
}
8892

8993
/** @internal */
90-
function stringsToMap(t: string[]): SortForCmd {
94+
function stringsToMap(t: ReadonlyArray<string>): SortForCmd {
9195
const sortEntries: SortPairForCmd[] = t.map(key => [`${key}`, 1]);
9296
return new Map(sortEntries);
9397
}
9498

9599
/** @internal */
96-
function objectToMap(t: { [key: string]: SortDirection }): SortForCmd {
100+
function objectToMap(t: { readonly [key: string]: SortDirection }): SortForCmd {
97101
const sortEntries: SortPairForCmd[] = Object.entries(t).map(([k, v]) => [
98102
`${k}`,
99103
prepareDirection(v)
@@ -102,7 +106,7 @@ function objectToMap(t: { [key: string]: SortDirection }): SortForCmd {
102106
}
103107

104108
/** @internal */
105-
function mapToMap(t: Map<string, SortDirection>): SortForCmd {
109+
function mapToMap(t: ReadonlyMap<string, SortDirection>): SortForCmd {
106110
const sortEntries: SortPairForCmd[] = Array.from(t).map(([k, v]) => [
107111
`${k}`,
108112
prepareDirection(v)
@@ -116,17 +120,22 @@ export function formatSort(
116120
direction?: SortDirection
117121
): SortForCmd | undefined {
118122
if (sort == null) return undefined;
119-
if (typeof sort === 'string') return new Map([[sort, prepareDirection(direction)]]);
123+
124+
if (typeof sort === 'string') return new Map([[sort, prepareDirection(direction)]]); // 'fieldName'
125+
120126
if (typeof sort !== 'object') {
121127
throw new MongoInvalidArgumentError(
122128
`Invalid sort format: ${JSON.stringify(sort)} Sort must be a valid object`
123129
);
124130
}
125-
if (!Array.isArray(sort)) {
126-
return isMap(sort) ? mapToMap(sort) : Object.keys(sort).length ? objectToMap(sort) : undefined;
131+
132+
if (!isReadonlyArray(sort)) {
133+
if (isMap(sort)) return mapToMap(sort); // Map<fieldName, SortDirection>
134+
if (Object.keys(sort).length) return objectToMap(sort); // { [fieldName: string]: SortDirection }
135+
return undefined;
127136
}
128137
if (!sort.length) return undefined;
129-
if (isDeep(sort)) return deepToMap(sort);
130-
if (isPair(sort)) return pairToMap(sort);
131-
return stringsToMap(sort);
138+
if (isDeep(sort)) return deepToMap(sort); // [ [fieldName, sortDir], [fieldName, sortDir] ... ]
139+
if (isPair(sort)) return pairToMap(sort); // [ fieldName, sortDir ]
140+
return stringsToMap(sort); // [ fieldName, fieldName ]
132141
}

test/integration/crud/client_bulk_write.test.ts

+35
Original file line numberDiff line numberDiff line change
@@ -396,4 +396,39 @@ describe('Client Bulk Write', function () {
396396
});
397397
});
398398
});
399+
400+
describe('sort support', () => {
401+
describe(
402+
'updateMany does not support sort option',
403+
{ requires: { mongodb: '>=8.0' } },
404+
function () {
405+
const commands: CommandStartedEvent[] = [];
406+
407+
beforeEach(async function () {
408+
client = this.configuration.newClient({}, { monitorCommands: true });
409+
410+
client.on('commandStarted', filterForCommands('bulkWrite', commands));
411+
await client.connect();
412+
});
413+
414+
it('should not include sort field in the command', async function () {
415+
await client.bulkWrite([
416+
{
417+
name: 'updateMany',
418+
namespace: 'foo.bar',
419+
filter: { age: { $lte: 5 } },
420+
update: { $set: { puppy: true } },
421+
// @ts-expect-error: sort is not supported in updateMany
422+
sort: { age: 1 } // This sort option should be ignored
423+
}
424+
]);
425+
426+
expect(commands).to.have.lengthOf(1);
427+
const [updateCommand] = commands;
428+
expect(updateCommand.commandName).to.equal('bulkWrite');
429+
expect(updateCommand.command.ops[0]).to.not.have.property('sort');
430+
});
431+
}
432+
);
433+
});
399434
});

0 commit comments

Comments
 (0)