Skip to content

Commit 3e7b894

Browse files
fix(NODE-3813): unexpected type conversion of read preference tags (#3138)
1 parent 9242de5 commit 3e7b894

File tree

5 files changed

+88
-50
lines changed

5 files changed

+88
-50
lines changed

.eslintrc.json

+11
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@
5959
"global"
6060
],
6161
"@typescript-eslint/no-explicit-any": "off",
62+
"no-restricted-imports": [
63+
"error",
64+
{
65+
"paths": [
66+
{
67+
"name": ".",
68+
"message": "Please import directly from the relevant file instead."
69+
}
70+
]
71+
}
72+
],
6273
"no-restricted-syntax": [
6374
"error",
6475
{

src/connection_string.ts

+56-41
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ServerApi,
2020
ServerApiVersion
2121
} from './mongo_client';
22+
import type { OneOrMore } from './mongo_types';
2223
import { PromiseProvider } from './promise_provider';
2324
import { ReadConcern, ReadConcernLevel } from './read_concern';
2425
import { ReadPreference, ReadPreferenceMode } from './read_preference';
@@ -28,6 +29,7 @@ import {
2829
Callback,
2930
DEFAULT_PK_FACTORY,
3031
emitWarning,
32+
emitWarningOnce,
3133
HostAddress,
3234
isRecord,
3335
makeClientMetadata,
@@ -184,8 +186,22 @@ const FALSEHOODS = new Set(['false', 'f', '0', 'n', 'no', '-1']);
184186
function getBoolean(name: string, value: unknown): boolean {
185187
if (typeof value === 'boolean') return value;
186188
const valueString = String(value).toLowerCase();
187-
if (TRUTHS.has(valueString)) return true;
188-
if (FALSEHOODS.has(valueString)) return false;
189+
if (TRUTHS.has(valueString)) {
190+
if (valueString !== 'true') {
191+
emitWarningOnce(
192+
`deprecated value for ${name} : ${valueString} - please update to ${name} : true instead`
193+
);
194+
}
195+
return true;
196+
}
197+
if (FALSEHOODS.has(valueString)) {
198+
if (valueString !== 'false') {
199+
emitWarningOnce(
200+
`deprecated value for ${name} : ${valueString} - please update to ${name} : false instead`
201+
);
202+
}
203+
return false;
204+
}
189205
throw new MongoParseError(`Expected ${name} to be stringified boolean value, got: ${value}`);
190206
}
191207

@@ -204,31 +220,24 @@ function getUint(name: string, value: unknown): number {
204220
return parsedValue;
205221
}
206222

207-
function toRecord(value: string): Record<string, any> {
208-
const record = Object.create(null);
223+
/** Wrap a single value in an array if the value is not an array */
224+
function toArray<T>(value: OneOrMore<T>): T[] {
225+
return Array.isArray(value) ? value : [value];
226+
}
227+
228+
function* entriesFromString(value: string) {
209229
const keyValuePairs = value.split(',');
210230
for (const keyValue of keyValuePairs) {
211231
const [key, value] = keyValue.split(':');
212232
if (value == null) {
213233
throw new MongoParseError('Cannot have undefined values in key value pairs');
214234
}
215-
try {
216-
// try to get a boolean
217-
record[key] = getBoolean('', value);
218-
} catch {
219-
try {
220-
// try to get a number
221-
record[key] = getInt('', value);
222-
} catch {
223-
// keep value as a string
224-
record[key] = value;
225-
}
226-
}
235+
236+
yield [key, value];
227237
}
228-
return record;
229238
}
230239

231-
class CaseInsensitiveMap extends Map<string, any> {
240+
class CaseInsensitiveMap<Value = any> extends Map<string, Value> {
232241
constructor(entries: Array<[string, any]> = []) {
233242
super(entries.map(([k, v]) => [k.toLowerCase(), v]));
234243
}
@@ -262,7 +271,7 @@ export function parseOptions(
262271
const mongoOptions = Object.create(null);
263272
mongoOptions.hosts = isSRV ? [] : hosts.map(HostAddress.fromString);
264273

265-
const urlOptions = new CaseInsensitiveMap();
274+
const urlOptions = new CaseInsensitiveMap<any[]>();
266275

267276
if (url.pathname !== '/' && url.pathname !== '') {
268277
const dbName = decodeURIComponent(
@@ -324,16 +333,11 @@ export function parseOptions(
324333
]);
325334

326335
for (const key of allKeys) {
327-
const values = [];
328-
if (objectOptions.has(key)) {
329-
values.push(objectOptions.get(key));
330-
}
331-
if (urlOptions.has(key)) {
332-
values.push(...urlOptions.get(key));
333-
}
334-
if (DEFAULT_OPTIONS.has(key)) {
335-
values.push(DEFAULT_OPTIONS.get(key));
336-
}
336+
const values = [objectOptions, urlOptions, DEFAULT_OPTIONS].flatMap(optionsObject => {
337+
const options = optionsObject.get(key) ?? [];
338+
return toArray(options);
339+
});
340+
337341
allOptions.set(key, values);
338342
}
339343

@@ -478,12 +482,11 @@ export function parseOptions(
478482
throw new MongoParseError('Can only specify both of proxy username/password or neither');
479483
}
480484

481-
if (
482-
urlOptions.get('proxyHost')?.length > 1 ||
483-
urlOptions.get('proxyPort')?.length > 1 ||
484-
urlOptions.get('proxyUsername')?.length > 1 ||
485-
urlOptions.get('proxyPassword')?.length > 1
486-
) {
485+
const proxyOptions = ['proxyHost', 'proxyPort', 'proxyUsername', 'proxyPassword'].map(
486+
key => urlOptions.get(key) ?? []
487+
);
488+
489+
if (proxyOptions.some(options => options.length > 1)) {
487490
throw new MongoParseError(
488491
'Proxy options cannot be specified multiple times in the connection string'
489492
);
@@ -629,14 +632,26 @@ export const OPTIONS = {
629632
},
630633
authMechanismProperties: {
631634
target: 'credentials',
632-
transform({ options, values: [value] }): MongoCredentials {
633-
if (typeof value === 'string') {
634-
value = toRecord(value);
635+
transform({ options, values: [optionValue] }): MongoCredentials {
636+
if (typeof optionValue === 'string') {
637+
const mechanismProperties = Object.create(null);
638+
639+
for (const [key, value] of entriesFromString(optionValue)) {
640+
try {
641+
mechanismProperties[key] = getBoolean(key, value);
642+
} catch {
643+
mechanismProperties[key] = value;
644+
}
645+
}
646+
647+
return MongoCredentials.merge(options.credentials, {
648+
mechanismProperties
649+
});
635650
}
636-
if (!isRecord(value)) {
651+
if (!isRecord(optionValue)) {
637652
throw new MongoParseError('AuthMechanismProperties must be an object');
638653
}
639-
return MongoCredentials.merge(options.credentials, { mechanismProperties: value });
654+
return MongoCredentials.merge(options.credentials, { mechanismProperties: optionValue });
640655
}
641656
},
642657
authSource: {
@@ -965,7 +980,7 @@ export const OPTIONS = {
965980
for (const tag of values) {
966981
const readPreferenceTag: TagSet = Object.create(null);
967982
if (typeof tag === 'string') {
968-
for (const [k, v] of Object.entries(toRecord(tag))) {
983+
for (const [k, v] of entriesFromString(tag)) {
969984
readPreferenceTag[k] = v;
970985
}
971986
}

test/tools/uri_spec_runner.ts

+6
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,12 @@ export function executeUriValidationTest(
221221
.to.have.nested.property(expectedProp)
222222
.deep.equal(optionValue);
223223
break;
224+
case 'maxStalenessSeconds':
225+
expectedProp = 'readPreference.maxStalenessSeconds';
226+
expect(options, `${errorMessage} ${optionKey} -> ${expectedProp}`)
227+
.to.have.nested.property(expectedProp)
228+
.deep.equal(optionValue);
229+
break;
224230

225231
//** WRITE CONCERN OPTIONS **/
226232
case 'w':

test/unit/assorted/uri_options.spec.test.ts

+1-4
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ describe('URI option spec tests', function () {
2828
'tlsDisableCertificateRevocationCheck can be set to true',
2929
'tlsDisableCertificateRevocationCheck can be set to false',
3030
'tlsDisableOCSPEndpointCheck can be set to true',
31-
'tlsDisableOCSPEndpointCheck can be set to false',
32-
33-
// TODO(NODE-3813): read preference tag issue: parsing rack:1 as rack:true
34-
'Valid read preference options are parsed correctly'
31+
'tlsDisableOCSPEndpointCheck can be set to false'
3532
];
3633

3734
const testsThatDoNotThrowOnWarn = [

test/unit/connection_string.test.ts

+14-5
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,20 @@ describe('Connection String', function () {
4646
expect(options.hosts[0].port).to.equal(27017);
4747
});
4848

49-
it('should parse multiple readPreferenceTags', function () {
50-
const options = parseOptions(
51-
'mongodb://hostname?readPreferenceTags=bar:foo&readPreferenceTags=baz:bar'
52-
);
53-
expect(options.readPreference.tags).to.deep.equal([{ bar: 'foo' }, { baz: 'bar' }]);
49+
context('readPreferenceTags', function () {
50+
it('should parse multiple readPreferenceTags when passed in the uri', () => {
51+
const options = parseOptions(
52+
'mongodb://hostname?readPreferenceTags=bar:foo&readPreferenceTags=baz:bar'
53+
);
54+
expect(options.readPreference.tags).to.deep.equal([{ bar: 'foo' }, { baz: 'bar' }]);
55+
});
56+
57+
it('should parse multiple readPreferenceTags when passed in options object', () => {
58+
const options = parseOptions('mongodb://hostname?', {
59+
readPreferenceTags: [{ bar: 'foo' }, { baz: 'bar' }]
60+
});
61+
expect(options.readPreference.tags).to.deep.equal([{ bar: 'foo' }, { baz: 'bar' }]);
62+
});
5463
});
5564

5665
it('should parse boolean values', function () {

0 commit comments

Comments
 (0)