Skip to content

Commit 440de41

Browse files
authored
fix(NODE-3174): Preserve sort key order for numeric string keys (#2788)
1 parent 88996d9 commit 440de41

File tree

5 files changed

+172
-70
lines changed

5 files changed

+172
-70
lines changed

src/sort.ts

+44-31
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type Sort =
1313
| string
1414
| string[]
1515
| { [key: string]: SortDirection }
16+
| Map<string, SortDirection>
1617
| [string, SortDirection][]
1718
| [string, SortDirection];
1819

@@ -22,7 +23,10 @@ export type Sort =
2223
type SortDirectionForCmd = 1 | -1 | { $meta: string };
2324

2425
/** @internal */
25-
type SortForCmd = { [key: string]: SortDirectionForCmd };
26+
type SortForCmd = Map<string, SortDirectionForCmd>;
27+
28+
/** @internal */
29+
type SortPairForCmd = [string, SortDirectionForCmd];
2630

2731
/** @internal */
2832
function prepareDirection(direction: any = 1): SortDirectionForCmd {
@@ -60,41 +64,47 @@ function isPair(t: Sort): t is [string, SortDirection] {
6064
return false;
6165
}
6266

67+
function isDeep(t: Sort): t is [string, SortDirection][] {
68+
return Array.isArray(t) && Array.isArray(t[0]);
69+
}
70+
71+
function isMap(t: Sort): t is Map<string, SortDirection> {
72+
return t instanceof Map && t.size > 0;
73+
}
74+
6375
/** @internal */
64-
function pairToObject(v: [string, SortDirection]): SortForCmd {
65-
return { [v[0]]: prepareDirection(v[1]) };
76+
function pairToMap(v: [string, SortDirection]): SortForCmd {
77+
return new Map([[`${v[0]}`, prepareDirection([v[1]])]]);
6678
}
6779

6880
/** @internal */
69-
function isDeep(t: Sort): t is [string, SortDirection][] {
70-
return Array.isArray(t) && Array.isArray(t[0]);
81+
function deepToMap(t: [string, SortDirection][]): SortForCmd {
82+
const sortEntries: SortPairForCmd[] = t.map(([k, v]) => [`${k}`, prepareDirection(v)]);
83+
return new Map(sortEntries);
7184
}
7285

7386
/** @internal */
74-
function deepToObject(t: [string, SortDirection][]): SortForCmd {
75-
const sortObject: SortForCmd = {};
76-
for (const [name, value] of t) {
77-
sortObject[name] = prepareDirection(value);
78-
}
79-
return sortObject;
87+
function stringsToMap(t: string[]): SortForCmd {
88+
const sortEntries: SortPairForCmd[] = t.map(key => [`${key}`, 1]);
89+
return new Map(sortEntries);
8090
}
8191

8292
/** @internal */
83-
function stringsToObject(t: string[]): SortForCmd {
84-
const sortObject: SortForCmd = {};
85-
for (const key of t) {
86-
sortObject[key] = 1;
87-
}
88-
return sortObject;
93+
function objectToMap(t: { [key: string]: SortDirection }): SortForCmd {
94+
const sortEntries: SortPairForCmd[] = Object.entries(t).map(([k, v]) => [
95+
`${k}`,
96+
prepareDirection(v)
97+
]);
98+
return new Map(sortEntries);
8999
}
90100

91101
/** @internal */
92-
function objectToObject(t: { [key: string]: SortDirection }): SortForCmd {
93-
const sortObject: SortForCmd = {};
94-
for (const key in t) {
95-
sortObject[key] = prepareDirection(t[key]);
96-
}
97-
return sortObject;
102+
function mapToMap(t: Map<string, SortDirection>): SortForCmd {
103+
const sortEntries: SortPairForCmd[] = Array.from(t).map(([k, v]) => [
104+
`${k}`,
105+
prepareDirection(v)
106+
]);
107+
return new Map(sortEntries);
98108
}
99109

100110
/** converts a Sort type into a type that is valid for the server (SortForCmd) */
@@ -103,12 +113,15 @@ export function formatSort(
103113
direction?: SortDirection
104114
): SortForCmd | undefined {
105115
if (sort == null) return undefined;
106-
if (Array.isArray(sort) && !sort.length) return undefined;
107-
if (typeof sort === 'object' && !Object.keys(sort).length) return undefined;
108-
if (typeof sort === 'string') return { [sort]: prepareDirection(direction) };
109-
if (isPair(sort)) return pairToObject(sort);
110-
if (isDeep(sort)) return deepToObject(sort);
111-
if (Array.isArray(sort)) return stringsToObject(sort);
112-
if (typeof sort === 'object') return objectToObject(sort);
113-
throw new Error(`Invalid sort format: ${JSON.stringify(sort)}`);
116+
if (typeof sort === 'string') return new Map([[sort, prepareDirection(direction)]]);
117+
if (typeof sort !== 'object') {
118+
throw new Error(`Invalid sort format: ${JSON.stringify(sort)}`);
119+
}
120+
if (!Array.isArray(sort)) {
121+
return isMap(sort) ? mapToMap(sort) : Object.keys(sort).length ? objectToMap(sort) : undefined;
122+
}
123+
if (!sort.length) return undefined;
124+
if (isDeep(sort)) return deepToMap(sort);
125+
if (isPair(sort)) return pairToMap(sort);
126+
return stringsToMap(sort);
114127
}

test/functional/apm.test.js

+14
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,20 @@ describe('APM', function () {
673673
Object.keys(expected).forEach(key => {
674674
expect(actual).to.include.key(key);
675675

676+
// TODO: This is a workaround that works because all sorts in the specs
677+
// are objects with one key; ideally we'd want to adjust the spec definitions
678+
// to indicate whether order matters for any given key and set general
679+
// expectations accordingly (see NODE-3235)
680+
if (key === 'sort') {
681+
expect(actual[key]).to.be.instanceOf(Map);
682+
expect(Object.keys(expected[key])).to.have.lengthOf(1);
683+
expect(actual[key].size).to.equal(1);
684+
expect(actual[key].get(Object.keys(expected[key])[0])).to.equal(
685+
Object.values(expected[key])[0]
686+
);
687+
return;
688+
}
689+
676690
if (Array.isArray(expected[key])) {
677691
expect(actual[key]).to.be.instanceOf(Array);
678692
expect(actual[key]).to.have.lengthOf(expected[key].length);

test/functional/cursor.test.js

+78-34
Original file line numberDiff line numberDiff line change
@@ -4175,7 +4175,8 @@ describe('Cursor', function () {
41754175
const cursor = collection.find({}, { sort: input });
41764176
cursor.next(err => {
41774177
expect(err).to.not.exist;
4178-
expect(events[0].command.sort).to.deep.equal(output);
4178+
expect(events[0].command.sort).to.be.instanceOf(Map);
4179+
expect(Array.from(events[0].command.sort)).to.deep.equal(Array.from(output));
41794180
cursor.close(done);
41804181
});
41814182
});
@@ -4189,54 +4190,97 @@ describe('Cursor', function () {
41894190
const cursor = collection.find({}).sort(input);
41904191
cursor.next(err => {
41914192
expect(err).to.not.exist;
4192-
expect(events[0].command.sort).to.deep.equal(output);
4193+
expect(events[0].command.sort).to.be.instanceOf(Map);
4194+
expect(Array.from(events[0].command.sort)).to.deep.equal(Array.from(output));
41934195
cursor.close(done);
41944196
});
41954197
});
41964198
});
41974199

4198-
it('should use find options object', findSort({ alpha: 1 }, { alpha: 1 }));
4199-
it('should use find options string', findSort('alpha', { alpha: 1 }));
4200-
it('should use find options shallow array', findSort(['alpha', 1], { alpha: 1 }));
4201-
it('should use find options deep array', findSort([['alpha', 1]], { alpha: 1 }));
4200+
it('should use find options object', findSort({ alpha: 1 }, new Map([['alpha', 1]])));
4201+
it('should use find options string', findSort('alpha', new Map([['alpha', 1]])));
4202+
it('should use find options shallow array', findSort(['alpha', 1], new Map([['alpha', 1]])));
4203+
it('should use find options deep array', findSort([['alpha', 1]], new Map([['alpha', 1]])));
42024204

4203-
it('should use cursor.sort object', cursorSort({ alpha: 1 }, { alpha: 1 }));
4204-
it('should use cursor.sort string', cursorSort('alpha', { alpha: 1 }));
4205-
it('should use cursor.sort shallow array', cursorSort(['alpha', 1], { alpha: 1 }));
4206-
it('should use cursor.sort deep array', cursorSort([['alpha', 1]], { alpha: 1 }));
4205+
it('should use cursor.sort object', cursorSort({ alpha: 1 }, new Map([['alpha', 1]])));
4206+
it('should use cursor.sort string', cursorSort('alpha', new Map([['alpha', 1]])));
4207+
it('should use cursor.sort shallow array', cursorSort(['alpha', 1], new Map([['alpha', 1]])));
4208+
it('should use cursor.sort deep array', cursorSort([['alpha', 1]], new Map([['alpha', 1]])));
42074209

42084210
it('formatSort - one key', () => {
4209-
expect(formatSort('alpha')).to.deep.equal({ alpha: 1 });
4210-
expect(formatSort(['alpha'])).to.deep.equal({ alpha: 1 });
4211-
expect(formatSort('alpha', 1)).to.deep.equal({ alpha: 1 });
4212-
expect(formatSort('alpha', 'asc')).to.deep.equal({ alpha: 1 });
4213-
expect(formatSort([['alpha', 'asc']])).to.deep.equal({ alpha: 1 });
4214-
expect(formatSort('alpha', 'ascending')).to.deep.equal({ alpha: 1 });
4215-
expect(formatSort({ alpha: 1 })).to.deep.equal({ alpha: 1 });
4216-
expect(formatSort('beta')).to.deep.equal({ beta: 1 });
4217-
expect(formatSort(['beta'])).to.deep.equal({ beta: 1 });
4218-
expect(formatSort('beta', -1)).to.deep.equal({ beta: -1 });
4219-
expect(formatSort('beta', 'desc')).to.deep.equal({ beta: -1 });
4220-
expect(formatSort('beta', 'descending')).to.deep.equal({ beta: -1 });
4221-
expect(formatSort({ beta: -1 })).to.deep.equal({ beta: -1 });
4222-
expect(formatSort({ alpha: { $meta: 'hi' } })).to.deep.equal({
4223-
alpha: { $meta: 'hi' }
4224-
});
4211+
// TODO (NODE-3236): These are unit tests for a standalone function and should be moved out of the cursor context file
4212+
expect(formatSort('alpha')).to.deep.equal(new Map([['alpha', 1]]));
4213+
expect(formatSort(['alpha'])).to.deep.equal(new Map([['alpha', 1]]));
4214+
expect(formatSort('alpha', 1)).to.deep.equal(new Map([['alpha', 1]]));
4215+
expect(formatSort('alpha', 'asc')).to.deep.equal(new Map([['alpha', 1]]));
4216+
expect(formatSort([['alpha', 'asc']])).to.deep.equal(new Map([['alpha', 1]]));
4217+
expect(formatSort('alpha', 'ascending')).to.deep.equal(new Map([['alpha', 1]]));
4218+
expect(formatSort({ alpha: 1 })).to.deep.equal(new Map([['alpha', 1]]));
4219+
expect(formatSort('beta')).to.deep.equal(new Map([['beta', 1]]));
4220+
expect(formatSort(['beta'])).to.deep.equal(new Map([['beta', 1]]));
4221+
expect(formatSort('beta', -1)).to.deep.equal(new Map([['beta', -1]]));
4222+
expect(formatSort('beta', 'desc')).to.deep.equal(new Map([['beta', -1]]));
4223+
expect(formatSort('beta', 'descending')).to.deep.equal(new Map([['beta', -1]]));
4224+
expect(formatSort({ beta: -1 })).to.deep.equal(new Map([['beta', -1]]));
4225+
expect(formatSort({ alpha: { $meta: 'hi' } })).to.deep.equal(
4226+
new Map([['alpha', { $meta: 'hi' }]])
4227+
);
42254228
});
42264229

42274230
it('formatSort - multi key', () => {
4228-
expect(formatSort(['alpha', 'beta'])).to.deep.equal({ alpha: 1, beta: 1 });
4229-
expect(formatSort({ alpha: 1, beta: 1 })).to.deep.equal({ alpha: 1, beta: 1 });
4231+
expect(formatSort(['alpha', 'beta'])).to.deep.equal(
4232+
new Map([
4233+
['alpha', 1],
4234+
['beta', 1]
4235+
])
4236+
);
4237+
expect(formatSort({ alpha: 1, beta: 1 })).to.deep.equal(
4238+
new Map([
4239+
['alpha', 1],
4240+
['beta', 1]
4241+
])
4242+
);
42304243
expect(
42314244
formatSort([
42324245
['alpha', 'asc'],
42334246
['beta', 'ascending']
42344247
])
4235-
).to.deep.equal({ alpha: 1, beta: 1 });
4236-
expect(formatSort({ alpha: { $meta: 'hi' }, beta: 'ascending' })).to.deep.equal({
4237-
alpha: { $meta: 'hi' },
4238-
beta: 1
4239-
});
4248+
).to.deep.equal(
4249+
new Map([
4250+
['alpha', 1],
4251+
['beta', 1]
4252+
])
4253+
);
4254+
expect(
4255+
formatSort(
4256+
new Map([
4257+
['alpha', 'asc'],
4258+
['beta', 'ascending']
4259+
])
4260+
)
4261+
).to.deep.equal(
4262+
new Map([
4263+
['alpha', 1],
4264+
['beta', 1]
4265+
])
4266+
);
4267+
expect(
4268+
formatSort([
4269+
['3', 'asc'],
4270+
['1', 'ascending']
4271+
])
4272+
).to.deep.equal(
4273+
new Map([
4274+
['3', 1],
4275+
['1', 1]
4276+
])
4277+
);
4278+
expect(formatSort({ alpha: { $meta: 'hi' }, beta: 'ascending' })).to.deep.equal(
4279+
new Map([
4280+
['alpha', { $meta: 'hi' }],
4281+
['beta', 1]
4282+
])
4283+
);
42404284
});
42414285

42424286
it('should use allowDiskUse option on sort', {
@@ -4249,7 +4293,7 @@ describe('Cursor', function () {
42494293
cursor.next(err => {
42504294
expect(err).to.not.exist;
42514295
const { command } = events.shift();
4252-
expect(command.sort).to.deep.equal({ alpha: 1 });
4296+
expect(command.sort).to.deep.equal(new Map([['alpha', 1]]));
42534297
expect(command.allowDiskUse).to.be.true;
42544298
cursor.close(done);
42554299
});

test/functional/spec-runner/index.js

+21-4
Original file line numberDiff line numberDiff line change
@@ -414,14 +414,26 @@ function validateExpectations(commandEvents, spec, savedSessionData) {
414414

415415
const actualCommand = actual.command;
416416
const expectedCommand = expected.command;
417+
if (expectedCommand.sort) {
418+
// TODO: This is a workaround that works because all sorts in the specs
419+
// are objects with one key; ideally we'd want to adjust the spec definitions
420+
// to indicate whether order matters for any given key and set general
421+
// expectations accordingly (see NODE-3235)
422+
expect(Object.keys(expectedCommand.sort)).to.have.lengthOf(1);
423+
expect(actualCommand.sort).to.be.instanceOf(Map);
424+
expect(actualCommand.sort.size).to.equal(1);
425+
const expectedKey = Object.keys(expectedCommand.sort)[0];
426+
expect(actualCommand.sort).to.have.all.keys(expectedKey);
427+
actualCommand.sort = { [expectedKey]: actualCommand.sort.get(expectedKey) };
428+
}
417429

418430
expect(actualCommand).withSessionData(savedSessionData).to.matchMongoSpec(expectedCommand);
419431
});
420432
}
421433

422434
function normalizeCommandShapes(commands) {
423-
return commands.map(def =>
424-
JSON.parse(
435+
return commands.map(def => {
436+
const output = JSON.parse(
425437
EJSON.stringify(
426438
{
427439
command: def.command,
@@ -430,8 +442,13 @@ function normalizeCommandShapes(commands) {
430442
},
431443
{ relaxed: true }
432444
)
433-
)
434-
);
445+
);
446+
// TODO: this is a workaround to preserve sort Map type until NODE-3235 is completed
447+
if (def.command.sort) {
448+
output.command.sort = def.command.sort;
449+
}
450+
return output;
451+
});
435452
}
436453

437454
function extractCrudResult(result, operation) {

test/functional/unified-spec-runner/match.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,21 @@ export function resultCheck(
138138
for (const [key, value] of expectedEntries) {
139139
path.push(Array.isArray(expected) ? `[${key}]` : `.${key}`); // record what key we're at
140140
depth += 1;
141-
resultCheck(actual[key], value, entities, path, depth);
141+
if (key === 'sort') {
142+
// TODO: This is a workaround that works because all sorts in the specs
143+
// are objects with one key; ideally we'd want to adjust the spec definitions
144+
// to indicate whether order matters for any given key and set general
145+
// expectations accordingly (see NODE-3235)
146+
expect(Object.keys(value)).to.have.lengthOf(1);
147+
expect(actual[key]).to.be.instanceOf(Map);
148+
expect(actual[key].size).to.equal(1);
149+
const expectedSortKey = Object.keys(value)[0];
150+
expect(actual[key]).to.have.all.keys(expectedSortKey);
151+
const objFromActual = { [expectedSortKey]: actual[key].get(expectedSortKey) };
152+
resultCheck(objFromActual, value, entities, path, depth);
153+
} else {
154+
resultCheck(actual[key], value, entities, path, depth);
155+
}
142156
depth -= 1;
143157
path.pop(); // if the recursion was successful we can drop the tested key
144158
}

0 commit comments

Comments
 (0)