From 70da949c02bbf804caf44216cae19d2805b2cc09 Mon Sep 17 00:00:00 2001 From: marshall007 Date: Thu, 12 May 2016 16:21:14 -0500 Subject: [PATCH] Smarter `filter` and `orderBy` index optimizations --- lib/model.js | 104 +++++++++++++++++++++++++++++++------------------- lib/util.js | 38 ++++++++++++++++++ test/query.js | 9 +++++ 3 files changed, 112 insertions(+), 39 deletions(-) diff --git a/lib/model.js b/lib/model.js index 4e1a3161..076e750f 100644 --- a/lib/model.js +++ b/lib/model.js @@ -307,11 +307,6 @@ Model.prototype.getTableName = function() { Model.prototype.ensureIndex = function(name, fn, opts) { var self = this; - if ((opts === undefined) && (util.isPlainObject(fn))) { - opts = fn; - fn = undefined; - } - return self._createIndex(name, fn, opts) .catch(function(error) { self._getModel()._setError(error); @@ -319,26 +314,44 @@ Model.prototype.ensureIndex = function(name, fn, opts) { }); } +Model.prototype.ensureCompoundIndex = function(name, fields) { + var self = this; + + fields.forEach(function (f) { + if (typeof f !== 'string') + throw new Error('Second argument to `ensureCompoundIndex` must be an array of strings.'); + }); + + return self.ensureIndex(name, { compound: fields }); +} + Model.prototype._createIndex = function(name, fn, opts) { var model = this._getModel(); - var tableName = this.getTableName(); var r = model._thinky.r; + var table = r.table(this.getTableName()); + var fields = [ name ]; if (opts === undefined && util.isPlainObject(fn)) { opts = fn; fn = undefined; } + if (opts && Array.isArray(opts.compound)) { + fields = opts.compound; + fn = opts.compound.map(function (f) { return r.row(f); }); + delete opts.compound; + } + var promise = this.tableReady().then(function() { return new Promise(function(resolve, reject) { return r.branch( - r.table(tableName).indexList().contains(name), - r.table(tableName).indexWait(name), + table.indexList().contains(name), + table.indexWait(name), r.branch( - r.table(tableName).info()('primary_key').eq(name), - r.table(tableName).indexWait(name), - r.table(tableName).indexCreate(name, fn, opts).do(function() { - return r.table(tableName).indexWait(name); + table.info()('primary_key').eq(name), + table.indexWait(name), + table.indexCreate(name, fn, opts).do(function() { + return table.indexWait(name); }) ) ) @@ -356,7 +369,7 @@ Model.prototype._createIndex = function(name, fn, opts) { }); }) .then(function() { - model._indexes[name] = true; + model._indexes[name] = fields; }); this._waitFor(promise); @@ -622,8 +635,8 @@ Model.prototype.hasAndBelongsToMany = function(joinedModel, fieldDoc, leftKey, r var linkPromise = linkModel.ready().then(function() { return query.run() .then(function() { - self._getModel()._indexes[leftKey] = true; - joinedModel._getModel()._indexes[rightKey] = true; + self._getModel()._indexes[leftKey] = [leftKey]; + joinedModel._getModel()._indexes[rightKey] = [rightKey]; }) .error(function(error) { if (error.message.match(/^Index `/)) { @@ -670,43 +683,56 @@ Model.prototype.hasAndBelongsToMany = function(joinedModel, fieldDoc, leftKey, r case 'orderBy': Model.prototype[key] = function() { var query = new Query(this); - if ((arguments.length === 1) - && (typeof arguments[0] === 'string') - && (this._getModel()._indexes[arguments[0]] === true)) { + var args = Array.prototype.slice.call(arguments); - query = query[key]({index: arguments[0]}); - return query; - } - else { - query = query[key].apply(query, arguments); - return query; - } + // We find the first index matching all fields + util.loopKeys(this._getModel()._indexes, function(indexes, key) { + if (util.deepEqual(indexes[key], args)) { + args = {index: key}; + return false; + } + }); + + return query[key].apply(query, [].concat(args)); } break; case 'filter': Model.prototype[key] = function() { var query = new Query(this); + + // Optimize a filter with an object if ((arguments.length === 1) && (util.isPlainObject(arguments[0]))) { - // Optimize a filter with an object - // We replace the first key that match an index name - var filter = arguments[0]; - - var keys = Object.keys(filter).sort(); // Lexicographical order - for(var i=0 ; i indexes[optimal].length)) ? name : optimal; + }, null); + + if (optimal !== null) { + var args = indexes[optimal].map(function (f) { + var val = filter[f]; + delete filter[f]; + return val; + }); + + if (args.length === 1) args = args[0]; + query = query.getAll(args, {index: optimal}); + + // Prevent appending a no-op empty filter + if (Object.keys(filter).length === 0) return query; } } - query = query[key].apply(query, arguments); - return query; + return query[key].apply(query, arguments); } break; case 'get': diff --git a/lib/util.js b/lib/util.js index f89be908..452e2d48 100644 --- a/lib/util.js +++ b/lib/util.js @@ -52,6 +52,44 @@ function deepCopy(value) { util.deepCopy = deepCopy; +/** + * Compare to objects for strict, deep equality. + */ +function deepEqual(a, b) { + var aArray = Array.isArray(a); + var bArray = Array.isArray(b); + + if (aArray !== bArray) return false; + if (aArray) { + return a.reduce(function (acc, val, i) { + return acc && deepEqual(val, b[i]); + }, a.length === b.length); + } + + if (isPlainObject(a)) { + var equal = isPlainObject(b); + loopKeys(a, function(_, key) { + return equal = equal && deepEqual(a[key], b[key]); + }); + return equal; + } + + return a === b; +} +util.deepEqual = deepEqual; + + +/** + * Compute the number of overlapping values between two arrays. + */ +function arrayOverlap(a, b) { + return a.reduce(function (sum, val) { + return ~b.indexOf(val) ? sum + 1 : sum; + }, 0) +} +util.arrayOverlap = arrayOverlap; + + /** * Wrap try/catch for v8 */ diff --git a/test/query.js b/test/query.js index 3904b086..8b4d3ded 100644 --- a/test/query.js +++ b/test/query.js @@ -1704,6 +1704,7 @@ describe('optimizer', function() { Model.hasAndBelongsToMany(Model, 'manyToMany', 'hasAndBelongsToMany1', 'hasAndBelongsToMany2') Model.ensureIndex('name1'); + Model.ensureCompoundIndex('fullName', [ 'name1', 'name2' ]); Model.once('ready', function() { done(); }) @@ -1738,6 +1739,10 @@ describe('optimizer', function() { var query = Model.orderBy('name1').toString(); assert(query.match(/index: "name1"/)); }) + it('orderBy should be able to use an index - multiple fields - compound index', function() { + var query = Model.orderBy('name1', 'name2').toString(); + assert(query.match(/index: "fullName"/)); + }) it('filter should be able to use an index - single field', function() { var query = Model.filter({name1: "Michel"}).toString(); assert(query.match(/index: "name1"/)); @@ -1746,6 +1751,10 @@ describe('optimizer', function() { var query = Model.filter({name1: "Michel", foo: "bar"}).toString(); assert.equal(query.replace(/\s/g, ''), 'r.table("'+Model.getTableName()+'").getAll("Michel",{index:"name1"}).filter({foo:"bar"})') }) + it('filter should be able to use an index - multiple fields - compound index', function() { + var query = Model.filter({name1: "Michel", name2: "Bar"}).toString(); + assert.equal(query.replace(/\s/g, ''), 'r.table("'+Model.getTableName()+'").getAll(["Michel","Bar"],{index:"fullName"})') + }) it('filter should not optimize a field without index', function() { var query = Model.filter({name2: "Michel"}).toString(); assert.equal(query.match(/index: "name2"/), null);