Skip to content

feat(NODE-6290): add sort support to updateOne and replaceOne #4515

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

Merged
merged 2 commits into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/bulk/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { makeUpdateStatement, UpdateOperation, type UpdateStatement } from '../o
import type { Server } from '../sdam/server';
import type { Topology } from '../sdam/topology';
import type { ClientSession } from '../sessions';
import { type Sort } from '../sort';
import { type TimeoutContext } from '../timeout';
import {
applyRetryableWrites,
Expand Down Expand Up @@ -68,7 +69,7 @@ export interface DeleteManyModel<TSchema extends Document = Document> {

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

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

/** @public */
Expand Down
3 changes: 2 additions & 1 deletion src/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
} from './operations/update';
import { ReadConcern, type ReadConcernLike } from './read_concern';
import { ReadPreference, type ReadPreferenceLike } from './read_preference';
import { type Sort } from './sort';
import {
DEFAULT_PK_FACTORY,
MongoDBCollectionNamespace,
Expand Down Expand Up @@ -365,7 +366,7 @@ export class Collection<TSchema extends Document = Document> {
async updateOne(
filter: Filter<TSchema>,
update: UpdateFilter<TSchema> | Document[],
options?: UpdateOptions
options?: UpdateOptions & { sort?: Sort }
): Promise<UpdateResult<TSchema>> {
return await executeOperation(
this.client,
Expand Down
9 changes: 9 additions & 0 deletions src/operations/client_bulk_write/command_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DocumentSequence } from '../../cmap/commands';
import { MongoAPIError, MongoInvalidArgumentError } from '../../error';
import { type PkFactory } from '../../mongo_client';
import type { Filter, OptionalId, UpdateFilter, WithoutId } from '../../mongo_types';
import { formatSort, type SortForCmd } from '../../sort';
import { DEFAULT_PK_FACTORY, hasAtomicOperators } from '../../utils';
import { type CollationOptions } from '../command';
import { type Hint } from '../operation';
Expand Down Expand Up @@ -327,6 +328,7 @@ export interface ClientUpdateOperation {
upsert?: boolean;
arrayFilters?: Document[];
collation?: CollationOptions;
sort?: SortForCmd;
}

/**
Expand Down Expand Up @@ -398,6 +400,9 @@ function createUpdateOperation(
if (model.collation) {
document.collation = model.collation;
}
if (!multi && 'sort' in model && model.sort != null) {
document.sort = formatSort(model.sort);
}
return document;
}

Expand All @@ -410,6 +415,7 @@ export interface ClientReplaceOneOperation {
hint?: Hint;
upsert?: boolean;
collation?: CollationOptions;
sort?: SortForCmd;
}

/**
Expand Down Expand Up @@ -443,6 +449,9 @@ export const buildReplaceOneOperation = (
if (model.collation) {
document.collation = model.collation;
}
if (model.sort != null) {
document.sort = formatSort(model.sort);
}
return document;
};

Expand Down
5 changes: 5 additions & 0 deletions src/operations/client_bulk_write/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type Document } from '../../bson';
import type { Filter, OptionalId, UpdateFilter, WithoutId } from '../../mongo_types';
import type { CollationOptions, CommandOperationOptions } from '../../operations/command';
import type { Hint } from '../../operations/operation';
import { type Sort } from '../../sort';

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

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

/** @public */
Expand Down
11 changes: 10 additions & 1 deletion src/operations/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MongoCompatibilityError, MongoInvalidArgumentError, MongoServerError }
import type { InferIdType, TODO_NODE_3286 } from '../mongo_types';
import type { Server } from '../sdam/server';
import type { ClientSession } from '../sessions';
import { formatSort, type Sort, type SortForCmd } from '../sort';
import { type TimeoutContext } from '../timeout';
import { hasAtomicOperators, type MongoDBNamespace } from '../utils';
import { type CollationOptions, CommandOperation, type CommandOperationOptions } from './command';
Expand Down Expand Up @@ -58,6 +59,8 @@ export interface UpdateStatement {
arrayFilters?: Document[];
/** A document or string that specifies the index to use to support the query predicate. */
hint?: Hint;
/** Specifies the sort order for the documents matched by the filter. */
sort?: SortForCmd;
}

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

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

if (!options.multi && options.sort != null) {
op.sort = formatSort(options.sort);
}

return op;
}

Expand Down
55 changes: 32 additions & 23 deletions src/sort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,24 @@ export type SortDirection =
| 'desc'
| 'ascending'
| 'descending'
| { $meta: string };
| { readonly $meta: string };

/** @public */
export type Sort =
| string
| Exclude<SortDirection, { $meta: string }>
| string[]
| { [key: string]: SortDirection }
| Map<string, SortDirection>
| [string, SortDirection][]
| [string, SortDirection];
| Exclude<SortDirection, { readonly $meta: string }>
| ReadonlyArray<string>
| { readonly [key: string]: SortDirection }
| ReadonlyMap<string, SortDirection>
| ReadonlyArray<readonly [string, SortDirection]>
| readonly [string, SortDirection];

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

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

/** @internal */
/** @public */
export type SortForCmd = Map<string, SortDirectionForCmd>;

/** @internal */
Expand Down Expand Up @@ -55,7 +55,7 @@ function isMeta(t: SortDirection): t is { $meta: string } {
}

/** @internal */
function isPair(t: Sort): t is [string, SortDirection] {
function isPair(t: Sort): t is readonly [string, SortDirection] {
if (Array.isArray(t) && t.length === 2) {
try {
prepareDirection(t[1]);
Expand All @@ -67,33 +67,37 @@ function isPair(t: Sort): t is [string, SortDirection] {
return false;
}

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

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

function isReadonlyArray<T>(value: any): value is readonly T[] {
return Array.isArray(value);
}

/** @internal */
function pairToMap(v: [string, SortDirection]): SortForCmd {
function pairToMap(v: readonly [string, SortDirection]): SortForCmd {
return new Map([[`${v[0]}`, prepareDirection([v[1]])]]);
}

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

/** @internal */
function stringsToMap(t: string[]): SortForCmd {
function stringsToMap(t: ReadonlyArray<string>): SortForCmd {
const sortEntries: SortPairForCmd[] = t.map(key => [`${key}`, 1]);
return new Map(sortEntries);
}

/** @internal */
function objectToMap(t: { [key: string]: SortDirection }): SortForCmd {
function objectToMap(t: { readonly [key: string]: SortDirection }): SortForCmd {
const sortEntries: SortPairForCmd[] = Object.entries(t).map(([k, v]) => [
`${k}`,
prepareDirection(v)
Expand All @@ -102,7 +106,7 @@ function objectToMap(t: { [key: string]: SortDirection }): SortForCmd {
}

/** @internal */
function mapToMap(t: Map<string, SortDirection>): SortForCmd {
function mapToMap(t: ReadonlyMap<string, SortDirection>): SortForCmd {
const sortEntries: SortPairForCmd[] = Array.from(t).map(([k, v]) => [
`${k}`,
prepareDirection(v)
Expand All @@ -116,17 +120,22 @@ export function formatSort(
direction?: SortDirection
): SortForCmd | undefined {
if (sort == null) return undefined;
if (typeof sort === 'string') return new Map([[sort, prepareDirection(direction)]]);

if (typeof sort === 'string') return new Map([[sort, prepareDirection(direction)]]); // 'fieldName'

if (typeof sort !== 'object') {
throw new MongoInvalidArgumentError(
`Invalid sort format: ${JSON.stringify(sort)} Sort must be a valid object`
);
}
if (!Array.isArray(sort)) {
return isMap(sort) ? mapToMap(sort) : Object.keys(sort).length ? objectToMap(sort) : undefined;

if (!isReadonlyArray(sort)) {
if (isMap(sort)) return mapToMap(sort); // Map<fieldName, SortDirection>
if (Object.keys(sort).length) return objectToMap(sort); // { [fieldName: string]: SortDirection }
return undefined;
}
if (!sort.length) return undefined;
if (isDeep(sort)) return deepToMap(sort);
if (isPair(sort)) return pairToMap(sort);
return stringsToMap(sort);
if (isDeep(sort)) return deepToMap(sort); // [ [fieldName, sortDir], [fieldName, sortDir] ... ]
if (isPair(sort)) return pairToMap(sort); // [ fieldName, sortDir ]
return stringsToMap(sort); // [ fieldName, fieldName ]
}
35 changes: 35 additions & 0 deletions test/integration/crud/client_bulk_write.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,4 +396,39 @@ describe('Client Bulk Write', function () {
});
});
});

describe('sort support', () => {
describe(
'updateMany does not support sort option',
{ requires: { mongodb: '>=8.0' } },
function () {
const commands: CommandStartedEvent[] = [];

beforeEach(async function () {
client = this.configuration.newClient({}, { monitorCommands: true });

client.on('commandStarted', filterForCommands('bulkWrite', commands));
await client.connect();
});

it('should not include sort field in the command', async function () {
await client.bulkWrite([
{
name: 'updateMany',
namespace: 'foo.bar',
filter: { age: { $lte: 5 } },
update: { $set: { puppy: true } },
// @ts-expect-error: sort is not supported in updateMany
sort: { age: 1 } // This sort option should be ignored
}
]);

expect(commands).to.have.lengthOf(1);
const [updateCommand] = commands;
expect(updateCommand.commandName).to.equal('bulkWrite');
expect(updateCommand.command.ops[0]).to.not.have.property('sort');
});
}
);
});
});
Loading