mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Add collation support to Minimongo and mongo driver
This commit is contained in:
@@ -211,6 +211,12 @@ On the client, there will be a period of time between when the page loads and
|
||||
when the published data arrives from the server during which your client-side
|
||||
collections will be empty.
|
||||
|
||||
The `collation` option enables locale-aware string comparison for selectors and
|
||||
sorting, matching [MongoDB's collation feature](https://docs.mongodb.com/manual/reference/collation/).
|
||||
Collation is supported on both client (Minimongo, via `Intl.Collator`) and server,
|
||||
and is compatible with oplog-tailing. For example, `{collation: {locale: 'en', strength: 2}}`
|
||||
enables case-insensitive matching.
|
||||
|
||||
{% apibox "Mongo.Collection#findOne" %}
|
||||
|
||||
Equivalent to [`find`](#find)`(selector, options).`[`fetch`](#fetch)`()[0]` with
|
||||
|
||||
@@ -23,6 +23,7 @@ Minimongo implements the following features, mirroring the MongoDB features:
|
||||
- Querying with `sort` and `limit`
|
||||
- ObjectID generation
|
||||
- Geo-positional operator `$near` with GeoJSON parsing
|
||||
- Collation support for locale-aware string comparison (via `Intl.Collator`)
|
||||
|
||||
Internally, all documents are mapped in a single JS object from `_id` to the
|
||||
document. Besides this mapping, Minimongo doesn't implement any types of
|
||||
|
||||
@@ -37,11 +37,12 @@ export const ELEMENT_OPERATORS = {
|
||||
},
|
||||
},
|
||||
$in: {
|
||||
compileElementSelector(operand) {
|
||||
compileElementSelector(operand, valueSelector, matcher) {
|
||||
if (!Array.isArray(operand)) {
|
||||
throw new MiniMongoQueryError('$in needs an array');
|
||||
}
|
||||
|
||||
const collator = matcher && matcher._collation;
|
||||
const elementMatchers = operand.map(option => {
|
||||
if (option instanceof RegExp) {
|
||||
return regexpElementMatcher(option);
|
||||
@@ -51,7 +52,7 @@ export const ELEMENT_OPERATORS = {
|
||||
throw new MiniMongoQueryError('cannot nest $ under $in');
|
||||
}
|
||||
|
||||
return equalityElementMatcher(option);
|
||||
return equalityElementMatcher(option, collator);
|
||||
});
|
||||
|
||||
return value => {
|
||||
@@ -325,23 +326,25 @@ const LOGICAL_OPERATORS = {
|
||||
// "match each branched value independently and combine with
|
||||
// convertElementMatcherToBranchedMatcher".
|
||||
const VALUE_OPERATORS = {
|
||||
$eq(operand) {
|
||||
$eq(operand, valueSelector, matcher) {
|
||||
return convertElementMatcherToBranchedMatcher(
|
||||
equalityElementMatcher(operand)
|
||||
equalityElementMatcher(operand, matcher && matcher._collation)
|
||||
);
|
||||
},
|
||||
$not(operand, valueSelector, matcher) {
|
||||
return invertBranchedMatcher(compileValueSelector(operand, matcher));
|
||||
},
|
||||
$ne(operand) {
|
||||
return invertBranchedMatcher(
|
||||
convertElementMatcherToBranchedMatcher(equalityElementMatcher(operand))
|
||||
);
|
||||
},
|
||||
$nin(operand) {
|
||||
$ne(operand, valueSelector, matcher) {
|
||||
return invertBranchedMatcher(
|
||||
convertElementMatcherToBranchedMatcher(
|
||||
ELEMENT_OPERATORS.$in.compileElementSelector(operand)
|
||||
equalityElementMatcher(operand, matcher && matcher._collation)
|
||||
)
|
||||
);
|
||||
},
|
||||
$nin(operand, valueSelector, matcher) {
|
||||
return invertBranchedMatcher(
|
||||
convertElementMatcherToBranchedMatcher(
|
||||
ELEMENT_OPERATORS.$in.compileElementSelector(operand, valueSelector, matcher)
|
||||
)
|
||||
);
|
||||
},
|
||||
@@ -628,7 +631,7 @@ function compileValueSelector(valueSelector, matcher, isRoot) {
|
||||
}
|
||||
|
||||
return convertElementMatcherToBranchedMatcher(
|
||||
equalityElementMatcher(valueSelector)
|
||||
equalityElementMatcher(valueSelector, matcher && matcher._collation)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -680,8 +683,9 @@ function distanceCoordinatePairs(a, b) {
|
||||
}
|
||||
|
||||
// Takes something that is not an operator object and returns an element matcher
|
||||
// for equality with that thing.
|
||||
export function equalityElementMatcher(elementSelector) {
|
||||
// for equality with that thing. When a collator (Intl.Collator) is provided,
|
||||
// string equality uses locale-aware comparison.
|
||||
export function equalityElementMatcher(elementSelector, collator) {
|
||||
if (isOperatorObject(elementSelector)) {
|
||||
throw new MiniMongoQueryError('Can\'t create equalityValueSelector for operator object');
|
||||
}
|
||||
@@ -694,7 +698,7 @@ export function equalityElementMatcher(elementSelector) {
|
||||
return value => value == null;
|
||||
}
|
||||
|
||||
return value => LocalCollection._f._equal(elementSelector, value);
|
||||
return value => LocalCollection._f._equal(elementSelector, value, collator);
|
||||
}
|
||||
|
||||
function everythingMatcher(docOrBranchedValues) {
|
||||
@@ -878,7 +882,7 @@ export function isOperatorObject(valueSelector, inconsistentOK) {
|
||||
// Helper for $lt/$gt/$lte/$gte.
|
||||
function makeInequality(cmpValueComparator) {
|
||||
return {
|
||||
compileElementSelector(operand) {
|
||||
compileElementSelector(operand, valueSelector, matcher) {
|
||||
// Arrays never compare false with non-arrays for any inequality.
|
||||
// XXX This was behavior we observed in pre-release MongoDB 2.5, but
|
||||
// it seems to have been reverted.
|
||||
@@ -894,6 +898,7 @@ function makeInequality(cmpValueComparator) {
|
||||
}
|
||||
|
||||
const operandType = LocalCollection._f._type(operand);
|
||||
const collator = matcher && matcher._collation;
|
||||
|
||||
return value => {
|
||||
if (value === undefined) {
|
||||
@@ -906,7 +911,7 @@ function makeInequality(cmpValueComparator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return cmpValueComparator(LocalCollection._f._cmp(value, operand));
|
||||
return cmpValueComparator(LocalCollection._f._cmp(value, operand, collator));
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,16 +9,17 @@ export default class Cursor {
|
||||
constructor(collection, selector, options = {}) {
|
||||
this.collection = collection;
|
||||
this.sorter = null;
|
||||
this.matcher = new Minimongo.Matcher(selector);
|
||||
this.matcher = new Minimongo.Matcher(selector, undefined, options.collation);
|
||||
|
||||
if (LocalCollection._selectorIsIdPerhapsAsObject(selector)) {
|
||||
if (LocalCollection._selectorIsIdPerhapsAsObject(selector) &&
|
||||
!options.collation) {
|
||||
// stash for fast _id and { _id }
|
||||
this._selectorId = hasOwn.call(selector, '_id') ? selector._id : selector;
|
||||
} else {
|
||||
this._selectorId = undefined;
|
||||
|
||||
if (this.matcher.hasGeoQuery() || options.sort) {
|
||||
this.sorter = new Minimongo.Sorter(options.sort || []);
|
||||
this.sorter = new Minimongo.Sorter(options.sort || [], options.collation);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const Decimal = Package['mongo-decimal']?.Decimal || class DecimalStub {}
|
||||
// var matcher = new Minimongo.Matcher({a: {$gt: 5}});
|
||||
// if (matcher.documentMatches({a: 7})) ...
|
||||
export default class Matcher {
|
||||
constructor(selector, isUpdate) {
|
||||
constructor(selector, isUpdate, collation) {
|
||||
// A set (object mapping string -> *) of all of the document paths looked
|
||||
// at by the selector. Also includes the empty string if it may look at any
|
||||
// path (eg, $where).
|
||||
@@ -49,6 +49,12 @@ export default class Matcher {
|
||||
// translated into {_id: ID} first. Used by canBecomeTrueByModifier and
|
||||
// Sorter._useWithMatcher.
|
||||
this._selector = null;
|
||||
// An optional Intl.Collator for locale-aware string comparison (mirrors
|
||||
// MongoDB's collation option). Stored as the collator instance so that
|
||||
// _cmp / _equal can use it without re-creating it on every call.
|
||||
this._collation = collation
|
||||
? LocalCollection._createCollator(collation)
|
||||
: null;
|
||||
this._docMatcher = this._compileSelector(selector);
|
||||
// Set to true if selection is done for an update operation
|
||||
// Default is false
|
||||
@@ -93,6 +99,12 @@ export default class Matcher {
|
||||
this._selector = {_id: selector};
|
||||
this._recordPathUsed('_id');
|
||||
|
||||
if (this._collation) {
|
||||
// When collation is active, compile {_id: selector} as a regular
|
||||
// document selector so string comparison uses the collator.
|
||||
return compileDocumentSelector(this._selector, this, {isRoot: true});
|
||||
}
|
||||
|
||||
return doc => ({result: EJSON.equals(doc._id, selector)});
|
||||
}
|
||||
|
||||
@@ -189,7 +201,12 @@ LocalCollection._f = {
|
||||
},
|
||||
|
||||
// deep equality test: use for literal document and array matches
|
||||
_equal(a, b) {
|
||||
// When a collator (Intl.Collator) is provided, string equality uses
|
||||
// locale-aware comparison instead of strict ===.
|
||||
_equal(a, b, collator) {
|
||||
if (collator && typeof a === 'string' && typeof b === 'string') {
|
||||
return collator.compare(a, b) === 0;
|
||||
}
|
||||
return EJSON.equals(a, b, {keyOrderSensitive: true});
|
||||
},
|
||||
|
||||
@@ -227,7 +244,9 @@ LocalCollection._f = {
|
||||
// semantics. (as an extension, consider 'undefined' to be less than
|
||||
// any other value.) return negative if a is less, positive if b is
|
||||
// less, or 0 if equal
|
||||
_cmp(a, b) {
|
||||
// When a collator (Intl.Collator) is provided, string comparison uses
|
||||
// locale-aware ordering instead of lexicographic <.
|
||||
_cmp(a, b, collator) {
|
||||
if (a === undefined) {
|
||||
return b === undefined ? 0 : -1;
|
||||
}
|
||||
@@ -274,8 +293,12 @@ LocalCollection._f = {
|
||||
}
|
||||
}
|
||||
|
||||
if (tb === 2) // string
|
||||
if (tb === 2) { // string
|
||||
if (collator) {
|
||||
return collator.compare(a, b);
|
||||
}
|
||||
return a < b ? -1 : a === b ? 0 : 1;
|
||||
}
|
||||
|
||||
if (ta === 3) { // Object
|
||||
// this could be much more efficient in the expected case ...
|
||||
@@ -289,7 +312,7 @@ LocalCollection._f = {
|
||||
return result;
|
||||
};
|
||||
|
||||
return LocalCollection._f._cmp(toArray(a), toArray(b));
|
||||
return LocalCollection._f._cmp(toArray(a), toArray(b), collator);
|
||||
}
|
||||
|
||||
if (ta === 4) { // Array
|
||||
@@ -302,7 +325,7 @@ LocalCollection._f = {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const s = LocalCollection._f._cmp(a[i], b[i]);
|
||||
const s = LocalCollection._f._cmp(a[i], b[i], collator);
|
||||
if (s !== 0) {
|
||||
return s;
|
||||
}
|
||||
@@ -357,3 +380,29 @@ LocalCollection._f = {
|
||||
throw Error('Unknown type to sort');
|
||||
},
|
||||
};
|
||||
|
||||
// Creates an Intl.Collator from a MongoDB-style collation spec.
|
||||
// MongoDB collation options map to Intl.Collator options as follows:
|
||||
// strength 1 (primary) → sensitivity 'base' (a = A = á = Á)
|
||||
// strength 2 (secondary) → sensitivity 'accent' (a = A, á ≠ a)
|
||||
// strength 3 (tertiary) → sensitivity 'variant' (a ≠ A, á ≠ a)
|
||||
// caseLevel true at strength 1 → sensitivity 'case' (a ≠ A, á = a)
|
||||
// numericOrdering → numeric
|
||||
// caseFirst → caseFirst ('upper'|'lower'|'false')
|
||||
LocalCollection._createCollator = function (collation) {
|
||||
const options = {};
|
||||
if (collation.strength != null) {
|
||||
if (collation.strength === 1 && collation.caseLevel) {
|
||||
options.sensitivity = 'case';
|
||||
} else {
|
||||
options.sensitivity = { 1: 'base', 2: 'accent' }[collation.strength] || 'variant';
|
||||
}
|
||||
}
|
||||
if (collation.numericOrdering != null) {
|
||||
options.numeric = collation.numericOrdering;
|
||||
}
|
||||
if (collation.caseFirst != null && collation.caseFirst !== 'off') {
|
||||
options.caseFirst = collation.caseFirst;
|
||||
}
|
||||
return new Intl.Collator(collation.locale, options);
|
||||
};
|
||||
|
||||
@@ -4061,4 +4061,233 @@ Tinytest.addAsync('minimongo - operation result fields (async)', async test => {
|
||||
// Test remove
|
||||
const removeResult = await c.removeAsync({name: 'doc1'});
|
||||
test.equal(removeResult, 1, 'remove should return removed count');
|
||||
});
|
||||
|
||||
// ---- Collation support ----
|
||||
|
||||
Tinytest.add('minimongo - collation - _createCollator maps MongoDB options to Intl.Collator', test => {
|
||||
// strength 2 → case-insensitive (sensitivity 'accent')
|
||||
const collator = LocalCollection._createCollator({locale: 'en', strength: 2});
|
||||
test.equal(collator.compare('abc', 'ABC'), 0);
|
||||
test.equal(collator.compare('abc', 'abc'), 0);
|
||||
test.notEqual(collator.compare('abc', 'ábc'), 0); // accents differ at strength 2
|
||||
|
||||
// strength 1 → base only (case + accents ignored)
|
||||
const base = LocalCollection._createCollator({locale: 'en', strength: 1});
|
||||
test.equal(base.compare('abc', 'ABC'), 0);
|
||||
test.equal(base.compare('abc', 'ábc'), 0); // accents ignored at strength 1
|
||||
|
||||
// strength 3 (default) → case-sensitive
|
||||
const strict = LocalCollection._createCollator({locale: 'en', strength: 3});
|
||||
test.notEqual(strict.compare('abc', 'ABC'), 0);
|
||||
|
||||
// numericOrdering
|
||||
const numeric = LocalCollection._createCollator({locale: 'en', numericOrdering: true});
|
||||
test.isTrue(numeric.compare('2', '10') < 0); // numeric: 2 < 10
|
||||
const lexical = LocalCollection._createCollator({locale: 'en'});
|
||||
test.isTrue(lexical.compare('2', '10') > 0); // lexical: '2' > '10'
|
||||
|
||||
// caseFirst: 'upper' → uppercase sorts before lowercase
|
||||
const upperFirst = LocalCollection._createCollator({locale: 'en', caseFirst: 'upper', strength: 3});
|
||||
test.isTrue(upperFirst.compare('A', 'a') < 0);
|
||||
|
||||
// caseLevel true at strength 1
|
||||
const caseLevel = LocalCollection._createCollator({locale: 'en', strength: 1, caseLevel: true});
|
||||
test.notEqual(caseLevel.compare('a', 'A'), 0); // case matters
|
||||
test.equal(caseLevel.compare('a', 'á'), 0); // accents still ignored
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - collation - Matcher equality (case-insensitive)', test => {
|
||||
const collation = {locale: 'en', strength: 2};
|
||||
|
||||
const matchCI = (selector, doc) => {
|
||||
return new Minimongo.Matcher(selector, undefined, collation).documentMatches(doc).result;
|
||||
};
|
||||
|
||||
// Plain value selector: {field: value}
|
||||
test.isTrue(matchCI({name: 'john'}, {name: 'John'}));
|
||||
test.isTrue(matchCI({name: 'john'}, {name: 'JOHN'}));
|
||||
test.isTrue(matchCI({name: 'John'}, {name: 'john'}));
|
||||
test.isTrue(matchCI({name: 'café'}, {name: 'café'}));
|
||||
test.isFalse(matchCI({name: 'cafe'}, {name: 'café'})); // accents differ at strength 2
|
||||
test.isFalse(matchCI({name: 'john'}, {name: 'jane'}));
|
||||
|
||||
// $eq operator
|
||||
test.isTrue(matchCI({name: {$eq: 'john'}}, {name: 'JOHN'}));
|
||||
test.isFalse(matchCI({name: {$eq: 'john'}}, {name: 'jane'}));
|
||||
|
||||
// $ne operator
|
||||
test.isFalse(matchCI({name: {$ne: 'john'}}, {name: 'JOHN'}));
|
||||
test.isTrue(matchCI({name: {$ne: 'john'}}, {name: 'jane'}));
|
||||
|
||||
// $in operator
|
||||
test.isTrue(matchCI({name: {$in: ['john', 'jane']}}, {name: 'JOHN'}));
|
||||
test.isTrue(matchCI({name: {$in: ['john', 'jane']}}, {name: 'Jane'}));
|
||||
test.isFalse(matchCI({name: {$in: ['john', 'jane']}}, {name: 'bob'}));
|
||||
|
||||
// $nin operator
|
||||
test.isFalse(matchCI({name: {$nin: ['john', 'jane']}}, {name: 'JOHN'}));
|
||||
test.isTrue(matchCI({name: {$nin: ['john', 'jane']}}, {name: 'bob'}));
|
||||
|
||||
// Without collation, same selectors should be case-sensitive
|
||||
const matchCS = (selector, doc) => {
|
||||
return new Minimongo.Matcher(selector).documentMatches(doc).result;
|
||||
};
|
||||
test.isFalse(matchCS({name: 'john'}, {name: 'John'}));
|
||||
test.isFalse(matchCS({name: {$in: ['john']}}, {name: 'JOHN'}));
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - collation - Matcher inequality operators', test => {
|
||||
const collation = {locale: 'en', strength: 2};
|
||||
|
||||
const matchCI = (selector, doc) => {
|
||||
return new Minimongo.Matcher(selector, undefined, collation).documentMatches(doc).result;
|
||||
};
|
||||
|
||||
// $lt / $gt with collation — 'b' > 'a' regardless of case
|
||||
test.isTrue(matchCI({name: {$gt: 'a'}}, {name: 'B'}));
|
||||
test.isTrue(matchCI({name: {$gt: 'a'}}, {name: 'b'}));
|
||||
test.isFalse(matchCI({name: {$gt: 'b'}}, {name: 'A'}));
|
||||
|
||||
test.isTrue(matchCI({name: {$lt: 'b'}}, {name: 'A'}));
|
||||
test.isTrue(matchCI({name: {$lt: 'b'}}, {name: 'a'}));
|
||||
test.isFalse(matchCI({name: {$lt: 'a'}}, {name: 'B'}));
|
||||
|
||||
// $gte / $lte — equality is case-insensitive
|
||||
test.isTrue(matchCI({name: {$gte: 'john'}}, {name: 'JOHN'}));
|
||||
test.isTrue(matchCI({name: {$lte: 'john'}}, {name: 'JOHN'}));
|
||||
test.isTrue(matchCI({name: {$gte: 'john'}}, {name: 'zoe'}));
|
||||
test.isFalse(matchCI({name: {$gte: 'john'}}, {name: 'alice'}));
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - collation - Matcher with non-string values is unaffected', test => {
|
||||
const collation = {locale: 'en', strength: 2};
|
||||
|
||||
const matchCI = (selector, doc) => {
|
||||
return new Minimongo.Matcher(selector, undefined, collation).documentMatches(doc).result;
|
||||
};
|
||||
|
||||
// Numbers, booleans, null — collation should not affect these
|
||||
test.isTrue(matchCI({age: 25}, {age: 25}));
|
||||
test.isFalse(matchCI({age: 25}, {age: 30}));
|
||||
test.isTrue(matchCI({active: true}, {active: true}));
|
||||
test.isFalse(matchCI({active: true}, {active: false}));
|
||||
test.isTrue(matchCI({val: null}, {val: null}));
|
||||
test.isTrue(matchCI({age: {$gt: 10}}, {age: 20}));
|
||||
test.isFalse(matchCI({age: {$gt: 30}}, {age: 20}));
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - collation - Sorter', test => {
|
||||
const collation = {locale: 'en', strength: 2};
|
||||
const sorter = new Minimongo.Sorter({name: 1}, collation);
|
||||
const cmp = sorter.getComparator();
|
||||
|
||||
// Case-insensitive: 'alice' and 'Alice' should be equal
|
||||
test.equal(cmp({name: 'alice'}, {name: 'Alice'}), 0);
|
||||
test.equal(cmp({name: 'BOB'}, {name: 'bob'}), 0);
|
||||
|
||||
// Order: alice < bob regardless of case
|
||||
test.isTrue(cmp({name: 'alice'}, {name: 'Bob'}) < 0);
|
||||
test.isTrue(cmp({name: 'ALICE'}, {name: 'bob'}) < 0);
|
||||
test.isTrue(cmp({name: 'Bob'}, {name: 'alice'}) > 0);
|
||||
|
||||
// Descending sort
|
||||
const descSorter = new Minimongo.Sorter({name: -1}, collation);
|
||||
const descCmp = descSorter.getComparator();
|
||||
test.isTrue(descCmp({name: 'alice'}, {name: 'Bob'}) > 0);
|
||||
test.isTrue(descCmp({name: 'Bob'}, {name: 'alice'}) < 0);
|
||||
|
||||
// Without collation — case-sensitive (uppercase < lowercase in ASCII)
|
||||
const csSorter = new Minimongo.Sorter({name: 1});
|
||||
const csCmp = csSorter.getComparator();
|
||||
test.notEqual(csCmp({name: 'alice'}, {name: 'Alice'}), 0);
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - collation - Sorter with numericOrdering', test => {
|
||||
const collation = {locale: 'en', numericOrdering: true};
|
||||
const sorter = new Minimongo.Sorter({val: 1}, collation);
|
||||
const cmp = sorter.getComparator();
|
||||
|
||||
// Numeric ordering: '2' < '10' < '20'
|
||||
test.isTrue(cmp({val: '2'}, {val: '10'}) < 0);
|
||||
test.isTrue(cmp({val: '10'}, {val: '20'}) < 0);
|
||||
test.isTrue(cmp({val: '2'}, {val: '20'}) < 0);
|
||||
|
||||
// Without numericOrdering, lexical: '10' < '2' < '20'
|
||||
const lexSorter = new Minimongo.Sorter({val: 1});
|
||||
const lexCmp = lexSorter.getComparator();
|
||||
test.isTrue(lexCmp({val: '10'}, {val: '2'}) < 0); // '1' < '2' lexically
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - collation - LocalCollection.find with collation', test => {
|
||||
const c = new LocalCollection();
|
||||
c.insert({_id: '1', name: 'Alice'});
|
||||
c.insert({_id: '2', name: 'bob'});
|
||||
c.insert({_id: '3', name: 'CHARLIE'});
|
||||
c.insert({_id: '4', name: 'alice'});
|
||||
|
||||
const collation = {locale: 'en', strength: 2};
|
||||
|
||||
// find with equality — should match case-insensitively
|
||||
const results = c.find({name: 'alice'}, {collation}).fetch();
|
||||
test.length(results, 2);
|
||||
const ids = results.map(d => d._id).sort();
|
||||
test.equal(ids, ['1', '4']);
|
||||
|
||||
// find with $in
|
||||
const inResults = c.find({name: {$in: ['bob', 'charlie']}}, {collation}).fetch();
|
||||
test.length(inResults, 2);
|
||||
const inIds = inResults.map(d => d._id).sort();
|
||||
test.equal(inIds, ['2', '3']);
|
||||
|
||||
// find with sort — case-insensitive ordering
|
||||
const sorted = c.find({}, {collation, sort: {name: 1}}).fetch();
|
||||
// alice, Alice, bob, CHARLIE (alice/Alice equal under collation, order stable)
|
||||
test.equal(sorted[0].name.toLowerCase(), 'alice');
|
||||
test.equal(sorted[1].name.toLowerCase(), 'alice');
|
||||
test.equal(sorted[2].name, 'bob');
|
||||
test.equal(sorted[3].name, 'CHARLIE');
|
||||
|
||||
// findOne with collation
|
||||
const one = c.findOne({name: 'BOB'}, {collation});
|
||||
test.equal(one._id, '2');
|
||||
|
||||
// Without collation — case-sensitive, no match
|
||||
const noMatch = c.find({name: 'alice'}).fetch();
|
||||
test.length(noMatch, 1);
|
||||
test.equal(noMatch[0]._id, '4');
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - collation - strength 1 ignores accents and case', test => {
|
||||
const collation = {locale: 'en', strength: 1};
|
||||
|
||||
const matchBase = (selector, doc) => {
|
||||
return new Minimongo.Matcher(selector, undefined, collation).documentMatches(doc).result;
|
||||
};
|
||||
|
||||
test.isTrue(matchBase({name: 'cafe'}, {name: 'café'}));
|
||||
test.isTrue(matchBase({name: 'cafe'}, {name: 'CAFÉ'}));
|
||||
test.isTrue(matchBase({name: 'resume'}, {name: 'résumé'}));
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - collation - _id matching with collation', test => {
|
||||
const collation = {locale: 'en', strength: 2};
|
||||
const c = new LocalCollection();
|
||||
c.insert({_id: 'ABC', val: 1});
|
||||
|
||||
// Scalar _id selector uses collation when specified
|
||||
const result = c.findOne('abc', {collation});
|
||||
test.isNotUndefined(result);
|
||||
test.equal(result._id, 'ABC');
|
||||
|
||||
// Document selector {_id: 'abc'} also uses collation
|
||||
const result2 = c.findOne({_id: 'abc'}, {collation});
|
||||
test.isNotUndefined(result2);
|
||||
test.equal(result2._id, 'ABC');
|
||||
|
||||
// Without collation, case-sensitive — no match
|
||||
const result3 = c.findOne('abc');
|
||||
test.isUndefined(result3);
|
||||
const result4 = c.findOne({_id: 'abc'});
|
||||
test.isUndefined(result4);
|
||||
});
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
// first, or 0 if neither object comes before the other.
|
||||
|
||||
export default class Sorter {
|
||||
constructor(spec) {
|
||||
constructor(spec, collation) {
|
||||
this._sortSpecParts = [];
|
||||
this._sortFunction = null;
|
||||
this._collation = collation
|
||||
? LocalCollection._createCollator(collation)
|
||||
: null;
|
||||
|
||||
const addSpecPart = (path, ascending) => {
|
||||
if (!path) {
|
||||
@@ -291,9 +294,10 @@ export default class Sorter {
|
||||
// on field 'i'.
|
||||
_keyFieldComparator(i) {
|
||||
const invert = !this._sortSpecParts[i].ascending;
|
||||
const collator = this._collation;
|
||||
|
||||
return (key1, key2) => {
|
||||
const compare = LocalCollection._f._cmp(key1[i], key2[i]);
|
||||
const compare = LocalCollection._f._cmp(key1[i], key2[i], collator);
|
||||
return invert ? -compare : compare;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export const AsyncMethods = {
|
||||
* @param {Boolean} options.reactive (Client only) Default true; pass false to disable reactivity
|
||||
* @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections) for this cursor. Pass `null` to disable transformation.
|
||||
* @param {String} options.readPreference (Server only) Specifies a custom MongoDB [`readPreference`](https://docs.mongodb.com/manual/core/read-preference) for fetching the document. Possible values are `primary`, `primaryPreferred`, `secondary`, `secondaryPreferred` and `nearest`.
|
||||
* @param {Object} options.collation Specifies a [collation](https://docs.mongodb.com/manual/reference/collation/) for string comparison. See [`find`](#find) for details.
|
||||
* @returns {Object}
|
||||
*/
|
||||
findOneAsync(...args) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export const SyncMethods = {
|
||||
* @param {Number} options.maxTimeMs (Server only) If set, instructs MongoDB to set a time limit for this cursor's operations. If the operation reaches the specified time limit (in milliseconds) without the having been completed, an exception will be thrown. Useful to prevent an (accidental or malicious) unoptimized query from causing a full collection scan that would disrupt other database users, at the expense of needing to handle the resulting error.
|
||||
* @param {String|Object} options.hint (Server only) Overrides MongoDB's default index selection and query optimization process. Specify an index to force its use, either by its name or index specification. You can also specify `{ $natural : 1 }` to force a forwards collection scan, or `{ $natural : -1 }` for a reverse collection scan. Setting this is only recommended for advanced users.
|
||||
* @param {String} options.readPreference (Server only) Specifies a custom MongoDB [`readPreference`](https://docs.mongodb.com/manual/core/read-preference) for this particular cursor. Possible values are `primary`, `primaryPreferred`, `secondary`, `secondaryPreferred` and `nearest`.
|
||||
* @param {Object} options.collation Specifies a [collation](https://docs.mongodb.com/manual/reference/collation/) for string comparison. Supported on both client (Minimongo) and server. Common options: `locale` (required, e.g. `'en'`), `strength` (`1` for base, `2` for case-insensitive, `3` for default), `numericOrdering` (`true` to sort `'2'` before `'10'`), `caseFirst` (`'upper'` or `'lower'`). Compatible with oplog-tailing.
|
||||
* @returns {Mongo.Cursor}
|
||||
*/
|
||||
find(...args) {
|
||||
@@ -45,6 +46,7 @@ export const SyncMethods = {
|
||||
* @param {Boolean} options.reactive (Client only) Default true; pass false to disable reactivity
|
||||
* @param {Function} options.transform Overrides `transform` on the [`Collection`](#collections) for this cursor. Pass `null` to disable transformation.
|
||||
* @param {String} options.readPreference (Server only) Specifies a custom MongoDB [`readPreference`](https://docs.mongodb.com/manual/core/read-preference) for fetching the document. Possible values are `primary`, `primaryPreferred`, `secondary`, `secondaryPreferred` and `nearest`.
|
||||
* @param {Object} options.collation Specifies a [collation](https://docs.mongodb.com/manual/reference/collation/) for string comparison. See [`find`](#find) for details.
|
||||
* @returns {Object}
|
||||
*/
|
||||
findOne(...args) {
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
interface CollationOptions {
|
||||
locale: string;
|
||||
caseLevel?: boolean;
|
||||
caseFirst?: 'upper' | 'lower' | 'off';
|
||||
strength?: 1 | 2 | 3 | 4 | 5;
|
||||
numericOrdering?: boolean;
|
||||
alternate?: 'non-ignorable' | 'shifted';
|
||||
maxVariable?: 'punct' | 'space';
|
||||
backwards?: boolean;
|
||||
}
|
||||
|
||||
interface CursorOptions {
|
||||
limit?: number;
|
||||
skip?: number;
|
||||
sort?: Record<string, 1 | -1>;
|
||||
fields?: Record<string, 1 | 0>;
|
||||
projection?: Record<string, 1 | 0>;
|
||||
collation?: CollationOptions;
|
||||
disableOplog?: boolean;
|
||||
_disableOplog?: boolean;
|
||||
tailable?: boolean;
|
||||
|
||||
@@ -691,6 +691,7 @@ MongoConnection.prototype._createAsynchronousCursor = function(
|
||||
skip: cursorOptions.skip,
|
||||
projection: cursorOptions.fields || cursorOptions.projection,
|
||||
readPreference: cursorOptions.readPreference,
|
||||
collation: cursorOptions.collation,
|
||||
};
|
||||
|
||||
// Do we want a tailable cursor (which only works on capped collections)?
|
||||
@@ -883,7 +884,11 @@ Object.assign(MongoConnection.prototype, {
|
||||
// We need to be able to compile the selector. Fall back to polling for
|
||||
// some newfangled $selector that minimongo doesn't support yet.
|
||||
try {
|
||||
matcher = new Minimongo.Matcher(cursorDescription.selector);
|
||||
matcher = new Minimongo.Matcher(
|
||||
cursorDescription.selector,
|
||||
undefined,
|
||||
cursorDescription.options.collation
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// XXX make all compilation errors MinimongoError or something
|
||||
@@ -904,7 +909,10 @@ Object.assign(MongoConnection.prototype, {
|
||||
if (!cursorDescription.options.sort)
|
||||
return true;
|
||||
try {
|
||||
sorter = new Minimongo.Sorter(cursorDescription.options.sort);
|
||||
sorter = new Minimongo.Sorter(
|
||||
cursorDescription.options.sort,
|
||||
cursorDescription.options.collation
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
// XXX make all compilation errors MinimongoError or something
|
||||
|
||||
@@ -136,4 +136,5 @@ Package.onTest(function (api) {
|
||||
api.addFiles("tests/oplog_tests.js", "server");
|
||||
api.addFiles("tests/oplog_v2_converter_tests.js", "server");
|
||||
api.addFiles("tests/doc_fetcher_tests.js", "server");
|
||||
api.addFiles("tests/collation_tests.js", "server");
|
||||
});
|
||||
|
||||
314
packages/mongo/tests/collation_tests.js
Normal file
314
packages/mongo/tests/collation_tests.js
Normal file
@@ -0,0 +1,314 @@
|
||||
// Tests for MongoDB collation support in the mongo package.
|
||||
// These test that collation options flow through to the MongoDB driver
|
||||
// and that observeChanges works correctly with collation-aware Minimongo.
|
||||
|
||||
var makeCollection = function () {
|
||||
return new Mongo.Collection('collation_' + Random.id());
|
||||
};
|
||||
|
||||
if (Meteor.isServer) {
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - find with case-insensitive collation',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', strength: 2 };
|
||||
|
||||
await c.insertAsync({ _id: 'a', name: 'Alice' });
|
||||
await c.insertAsync({ _id: 'b', name: 'bob' });
|
||||
await c.insertAsync({ _id: 'c', name: 'Charlie' });
|
||||
|
||||
// Case-insensitive equality
|
||||
var docs = await c.find(
|
||||
{ name: 'alice' },
|
||||
{ collation: collation }
|
||||
).fetchAsync();
|
||||
test.equal(docs.length, 1);
|
||||
test.equal(docs[0].name, 'Alice');
|
||||
|
||||
// Case-insensitive findOne
|
||||
var doc = await c.findOneAsync(
|
||||
{ name: 'BOB' },
|
||||
{ collation: collation }
|
||||
);
|
||||
test.isTrue(doc);
|
||||
test.equal(doc.name, 'bob');
|
||||
|
||||
// Without collation, no match
|
||||
var noMatch = await c.findOneAsync({ name: 'alice' });
|
||||
test.isFalse(noMatch);
|
||||
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - find with $in operator',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', strength: 2 };
|
||||
|
||||
await c.insertAsync({ _id: 'a', name: 'Alice' });
|
||||
await c.insertAsync({ _id: 'b', name: 'Bob' });
|
||||
await c.insertAsync({ _id: 'c', name: 'Charlie' });
|
||||
|
||||
var docs = await c.find(
|
||||
{ name: { $in: ['alice', 'charlie'] } },
|
||||
{ collation: collation, sort: { _id: 1 } }
|
||||
).fetchAsync();
|
||||
test.equal(docs.length, 2);
|
||||
test.equal(docs[0].name, 'Alice');
|
||||
test.equal(docs[1].name, 'Charlie');
|
||||
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - sort with collation',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', strength: 2 };
|
||||
|
||||
await c.insertAsync({ _id: 'a', name: 'banana' });
|
||||
await c.insertAsync({ _id: 'b', name: 'Apple' });
|
||||
await c.insertAsync({ _id: 'c', name: 'cherry' });
|
||||
|
||||
// With collation, sort is case-insensitive
|
||||
var docs = await c.find(
|
||||
{},
|
||||
{ collation: collation, sort: { name: 1 } }
|
||||
).fetchAsync();
|
||||
test.equal(docs.length, 3);
|
||||
test.equal(docs[0].name, 'Apple');
|
||||
test.equal(docs[1].name, 'banana');
|
||||
test.equal(docs[2].name, 'cherry');
|
||||
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - sort with numericOrdering',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', numericOrdering: true };
|
||||
|
||||
await c.insertAsync({ _id: 'a', val: '2' });
|
||||
await c.insertAsync({ _id: 'b', val: '10' });
|
||||
await c.insertAsync({ _id: 'c', val: '1' });
|
||||
|
||||
var docs = await c.find(
|
||||
{},
|
||||
{ collation: collation, sort: { val: 1 } }
|
||||
).fetchAsync();
|
||||
test.equal(docs.length, 3);
|
||||
// Numeric ordering: '1' < '2' < '10'
|
||||
test.equal(docs[0].val, '1');
|
||||
test.equal(docs[1].val, '2');
|
||||
test.equal(docs[2].val, '10');
|
||||
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - inequality operators with collation',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', strength: 2 };
|
||||
|
||||
await c.insertAsync({ _id: 'a', name: 'Alice' });
|
||||
await c.insertAsync({ _id: 'b', name: 'Bob' });
|
||||
await c.insertAsync({ _id: 'c', name: 'Charlie' });
|
||||
|
||||
// $gt with collation (case-insensitive ordering)
|
||||
var docs = await c.find(
|
||||
{ name: { $gt: 'bob' } },
|
||||
{ collation: collation, sort: { name: 1 } }
|
||||
).fetchAsync();
|
||||
test.equal(docs.length, 1);
|
||||
test.equal(docs[0].name, 'Charlie');
|
||||
|
||||
// $lte with collation
|
||||
docs = await c.find(
|
||||
{ name: { $lte: 'bob' } },
|
||||
{ collation: collation, sort: { name: 1 } }
|
||||
).fetchAsync();
|
||||
test.equal(docs.length, 2);
|
||||
test.equal(docs[0].name, 'Alice');
|
||||
test.equal(docs[1].name, 'Bob');
|
||||
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - createIndex with collation',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', strength: 2 };
|
||||
|
||||
// Create an index with collation
|
||||
await c.createIndexAsync(
|
||||
{ name: 1 },
|
||||
{ collation: collation }
|
||||
);
|
||||
|
||||
// Insert data and verify the index is used
|
||||
await c.insertAsync({ _id: 'a', name: 'Alice' });
|
||||
await c.insertAsync({ _id: 'b', name: 'Bob' });
|
||||
|
||||
// Queries using the same collation should work
|
||||
var doc = await c.findOneAsync(
|
||||
{ name: 'alice' },
|
||||
{ collation: collation }
|
||||
);
|
||||
test.isTrue(doc);
|
||||
test.equal(doc.name, 'Alice');
|
||||
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - strength 1 ignores accents and case',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', strength: 1 };
|
||||
|
||||
await c.insertAsync({ _id: 'a', name: 'café' });
|
||||
await c.insertAsync({ _id: 'b', name: 'resume' });
|
||||
|
||||
// Strength 1: base characters only (ignore case + accents)
|
||||
var doc = await c.findOneAsync(
|
||||
{ name: 'CAFE' },
|
||||
{ collation: collation }
|
||||
);
|
||||
test.isTrue(doc);
|
||||
test.equal(doc.name, 'café');
|
||||
|
||||
var doc2 = await c.findOneAsync(
|
||||
{ name: 'résumé' },
|
||||
{ collation: collation }
|
||||
);
|
||||
test.isTrue(doc2);
|
||||
test.equal(doc2.name, 'resume');
|
||||
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - observeChanges with collation',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', strength: 2 };
|
||||
var addedDocs = [];
|
||||
var changedDocs = [];
|
||||
var removedIds = [];
|
||||
|
||||
// Insert a document that matches via collation
|
||||
await c.insertAsync({ _id: 'x', name: 'Alice' });
|
||||
// Insert a document that does NOT match
|
||||
await c.insertAsync({ _id: 'y', name: 'Bob' });
|
||||
|
||||
var handle = await c.find(
|
||||
{ name: 'alice' },
|
||||
{ collation: collation }
|
||||
).observeChangesAsync({
|
||||
added: function (id, fields) {
|
||||
addedDocs.push({ id: id, fields: fields });
|
||||
},
|
||||
changed: function (id, fields) {
|
||||
changedDocs.push({ id: id, fields: fields });
|
||||
},
|
||||
removed: function (id) {
|
||||
removedIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Initial result should include Alice (matched case-insensitively)
|
||||
test.equal(addedDocs.length, 1);
|
||||
test.equal(addedDocs[0].id, 'x');
|
||||
test.equal(addedDocs[0].fields.name, 'Alice');
|
||||
|
||||
// Update Alice's other field — should trigger changed
|
||||
await c.updateAsync('x', { $set: { age: 30 } });
|
||||
// Give the observer a moment to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
test.equal(changedDocs.length, 1);
|
||||
test.equal(changedDocs[0].id, 'x');
|
||||
test.equal(changedDocs[0].fields.age, 30);
|
||||
|
||||
// Insert another doc that matches via collation
|
||||
await c.insertAsync({ _id: 'z', name: 'ALICE' });
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
test.equal(addedDocs.length, 2);
|
||||
test.equal(addedDocs[1].id, 'z');
|
||||
test.equal(addedDocs[1].fields.name, 'ALICE');
|
||||
|
||||
// Remove original Alice — should trigger removed
|
||||
await c.removeAsync('x');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
test.equal(removedIds.length, 1);
|
||||
test.equal(removedIds[0], 'x');
|
||||
|
||||
handle.stop();
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - oplog cursorSupported with collation',
|
||||
async function (test) {
|
||||
var oplogEnabled = !!MongoInternals.defaultRemoteCollectionDriver().mongo
|
||||
._oplogHandle;
|
||||
|
||||
var c = new Mongo.Collection('collation_oplog_' + Random.id());
|
||||
var collation = { locale: 'en', strength: 2 };
|
||||
|
||||
// Collation queries should be supported by oplog (since Minimongo
|
||||
// now supports collation natively)
|
||||
var handle = await c.find(
|
||||
{ name: 'test' },
|
||||
{ collation: collation }
|
||||
).observeChanges({ added: function () {} });
|
||||
|
||||
if (oplogEnabled) {
|
||||
test.isTrue(
|
||||
handle._multiplexer._observeDriver._usesOplog,
|
||||
'Collation queries should use oplog'
|
||||
);
|
||||
}
|
||||
|
||||
handle.stop();
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'mongo collation - countDocuments with collation',
|
||||
async function (test) {
|
||||
var c = makeCollection();
|
||||
var collation = { locale: 'en', strength: 2 };
|
||||
|
||||
await c.insertAsync({ _id: 'a', name: 'Alice' });
|
||||
await c.insertAsync({ _id: 'b', name: 'alice' });
|
||||
await c.insertAsync({ _id: 'c', name: 'Bob' });
|
||||
|
||||
// countDocuments respects collation
|
||||
var count = await c.countDocuments(
|
||||
{ name: 'ALICE' },
|
||||
{ collation: collation }
|
||||
);
|
||||
test.equal(count, 2);
|
||||
|
||||
// Without collation, no match
|
||||
var noCount = await c.countDocuments({ name: 'ALICE' });
|
||||
test.equal(noCount, 0);
|
||||
|
||||
await c.dropCollectionAsync();
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user