diff --git a/README.md b/README.md index 7f0cb9a..546dd7d 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ console.log(cat.undefinedProperty); // undefined |key|default| | | |---|---|---|---| |relations|undefined|Relations to be instantiated when instantiating this model as well. Should be an array of strings.| `['location', 'owner.parents']` +|linkRelations|'tree'|There are 2 ways of linking relations, 'tree' and 'graph'. When using tree every model is guaranteed to be a contained tree itself, so all its relations are unique instances. With a graph instances that occur in a similar path will be reused. For example if you have 2 animals with the same owner the owner would be a different model with tree linking, but the same model with graph linking.|`animalStore = new AnimalStore({ linkRelations: 'tree' });`| ### Properties @@ -402,6 +403,7 @@ A Store (Collection in Backbone) is holds multiple instances of models and have |limit|25|Page size per fetch, also able to set using `setLimit()`. By default a limit is always set, but there are occations where you want to fetch everything. In this case, set limit to false. | `animalStore = new AnimalStore({ limit: false })` |comparator|undefined| The models in the store will be sorted by comparator. When it's a string, the models will be sorted by that property name. If it's a function, the models will be sorted using the [default array sort](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort). | `animalStore = new AnimalStore({ comparator: 'name' })` |params|undefined| All params will be converted to GET params. This is used for quering the server to fill the store with models. | `animalStore = new AnimalStore({ params: { 'search': 'Gizmo' } })` +|linkRelations|'tree'|There are 2 ways of linking relations, 'tree' and 'graph'. When using tree every model is guaranteed to be a contained tree itself, so all its relations are unique instances. With a graph instances that occur in a similar path will be reused. For example if you have 2 animals with the same owner the owner would be a different model with tree linking, but the same model with graph linking.|`animalStore = new AnimalStore({ linkRelations: 'tree' });`| ### Adding models diff --git a/dist/mobx-spine.cjs.js b/dist/mobx-spine.cjs.js index 3c60d4c..6b45121 100644 --- a/dist/mobx-spine.cjs.js +++ b/dist/mobx-spine.cjs.js @@ -234,11 +234,82 @@ function _applyDecoratedDescriptor(target, property, decorators, descriptor, con return desc; } -var AVAILABLE_CONST_OPTIONS = ['relations', 'limit', 'comparator', 'params', 'repository']; +var AVAILABLE_CONST_OPTIONS = ['relations', 'limit', 'comparator', 'params', 'repository', 'linkRelations']; + +function getRelsFromCache(record, rels, repos, relMapping, relPath, relCache) { + var relsFromCache = {}; + + var _iteratorNormalCompletion = true; + var _didIteratorError = false; + var _iteratorError = undefined; + + try { + var _loop = function _loop() { + var _step$value = slicedToArray(_step.value, 2), + rel = _step$value[0], + subRels = _step$value[1]; + + // First we try to get the related id + var backendRel = camelToSnake(rel); + var relId = record[backendRel]; + // We only care about numbers since that means that it + // is filled and is a direct relation. + if (typeof relId !== 'number') { + return 'continue'; + } + + // Then we see if we can find the model in the cache. + var subRelPath = relPath + '.' + rel; + var cachedModel = relCache[subRelPath + ':' + relId]; + if (cachedModel) { + relsFromCache[rel] = { model: cachedModel }; + // If we found it we don't have to dive deeper. + return 'continue'; + } + + // Then we search for the related data in the repos + var subRecord = repos[relMapping[camelToSnake(subRelPath.slice('root.'.length))]].find(function (_ref) { + var id = _ref.id; + return id === relId; + }); + if (!subRecord) { + return 'continue'; + } + + // Now we recurse to see if we can find relations in the cache for this model + relsFromCache[rel] = { rels: getRelsFromCache(subRecord, subRels, repos, relMapping, subRelPath, relCache) }; + }; + + for (var _iterator = Object.entries(rels)[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { + var _ret = _loop(); + + if (_ret === 'continue') continue; + } + } catch (err) { + _didIteratorError = true; + _iteratorError = err; + } finally { + try { + if (!_iteratorNormalCompletion && _iterator.return) { + _iterator.return(); + } + } finally { + if (_didIteratorError) { + throw _iteratorError; + } + } + } + + return relsFromCache; +} var Store = (_class = (_temp = _class2 = function () { createClass(Store, [{ key: 'url', + + // The set of models has changed + + // Holds the fetch parameters value: function url() { // Try to auto-generate the URL. var bname = this.constructor.backendResourceName; @@ -247,10 +318,6 @@ var Store = (_class = (_temp = _class2 = function () { } return null; } - // The set of models has changed - - // Holds the fetch parameters - }, { key: 'initialize', @@ -297,6 +364,8 @@ var Store = (_class = (_temp = _class2 = function () { invariant(AVAILABLE_CONST_OPTIONS.includes(option), 'Unknown option passed to store: ' + option); }); this.__repository = options.repository; + this.__linkRelations = options.linkRelations || 'tree'; + invariant(['tree', 'graph'].includes(this.__linkRelations), 'Unknown relation linking method: ' + this.__linkRelations); if (options.relations) { this.__parseRelations(options.relations); } @@ -326,26 +395,86 @@ var Store = (_class = (_temp = _class2 = function () { } }, { key: 'fromBackend', - value: function fromBackend(_ref) { + value: function fromBackend(_ref2) { var _this = this; - var data = _ref.data, - repos = _ref.repos, - relMapping = _ref.relMapping, - reverseRelMapping = _ref.reverseRelMapping; + var data = _ref2.data, + repos = _ref2.repos, + relMapping = _ref2.relMapping, + reverseRelMapping = _ref2.reverseRelMapping, + _ref2$relCache = _ref2.relCache, + relCache = _ref2$relCache === undefined ? {} : _ref2$relCache, + _ref2$relPath = _ref2.relPath, + relPath = _ref2$relPath === undefined ? 'root' : _ref2$relPath; invariant(data, 'Backend error. Data is not set. HINT: DID YOU FORGET THE M2M again?'); this.models.replace(data.map(function (record) { + var options = {}; + + if (_this.__linkRelations === 'graph') { + // Return from cache if available + var _model = relCache[relPath + ':' + record.id]; + if (_model !== undefined) { + return _model; + } + + options.relsFromCache = getRelsFromCache(record, relationsToNestedKeys(_this.__activeRelations), repos, relMapping, relPath, relCache); + } + // TODO: I'm not happy at all about how this looks. // We'll need to finetune some things, but hey, for now it works. - var model = _this._newModel(); + + var model = _this._newModel(null, options); model.fromBackend({ data: record, repos: repos, relMapping: relMapping, - reverseRelMapping: reverseRelMapping + reverseRelMapping: reverseRelMapping, + relCache: relCache, + relPath: relPath }); + + if (_this.__linkRelations === 'graph') { + var toAdd = [[relPath, model]]; + while (toAdd.length > 0) { + var _toAdd$pop = toAdd.pop(), + _toAdd$pop2 = slicedToArray(_toAdd$pop, 2), + _relPath = _toAdd$pop2[0], + _model2 = _toAdd$pop2[1]; + + relCache[_relPath + ':' + _model2.id] = _model2; + + var _iteratorNormalCompletion2 = true; + var _didIteratorError2 = false; + var _iteratorError2 = undefined; + + try { + for (var _iterator2 = _model2.__activeCurrentRelations[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { + var _rel = _step2.value; + + var relModel = _model2[_rel]; + if (relModel instanceof Model && !relModel.isNew) { + toAdd.push([_relPath + '.' + _rel, relModel]); + } + } + } catch (err) { + _didIteratorError2 = true; + _iteratorError2 = err; + } finally { + try { + if (!_iteratorNormalCompletion2 && _iterator2.return) { + _iterator2.return(); + } + } finally { + if (_didIteratorError2) { + throw _iteratorError2; + } + } + } + } + } + return model; })); this.sort(); @@ -354,11 +483,13 @@ var Store = (_class = (_temp = _class2 = function () { key: '_newModel', value: function _newModel() { var model = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - return new this.Model(model, { + return new this.Model(model, _extends({}, options, { store: this, - relations: this.__activeRelations - }); + relations: this.__activeRelations, + linkRelations: this.__linkRelations + })); } }, { key: 'sort', @@ -379,10 +510,14 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'parse', value: function parse(models) { + var _this2 = this; + invariant(lodash.isArray(models), 'Parameter supplied to `parse()` is not an array, got: ' + JSON.stringify(models)); // Parse does not mutate __setChanged, as it is used in // fromBackend in the model... - this.models.replace(models.map(this._newModel.bind(this))); + this.models.replace(models.map(function (data) { + return _this2._newModel(data); + })); this.sort(); return this; @@ -404,18 +539,20 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'add', value: function add(models) { - var _this2 = this; + var _this3 = this; var singular = !lodash.isArray(models); models = singular ? [models] : models.slice(); - var modelInstances = models.map(this._newModel.bind(this)); + var modelInstances = models.map(function (data) { + return _this3._newModel(data); + }); modelInstances.forEach(function (modelInstance) { - var primaryValue = modelInstance[_this2.Model.primaryKey]; - invariant(!primaryValue || !_this2.get(primaryValue), 'A model with the same primary key value "' + primaryValue + '" already exists in this store.'); - _this2.__setChanged = true; - _this2.models.push(modelInstance); + var primaryValue = modelInstance[_this3.Model.primaryKey]; + invariant(!primaryValue || !_this3.get(primaryValue), 'A model with the same primary key value "' + primaryValue + '" already exists in this store.'); + _this3.__setChanged = true; + _this3.models.push(modelInstance); }); this.sort(); @@ -424,13 +561,13 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'remove', value: function remove(models) { - var _this3 = this; + var _this4 = this; var singular = !lodash.isArray(models); models = singular ? [models] : models.slice(); models.forEach(function (model) { - return _this3.models.remove(model); + return _this4.models.remove(model); }); if (models.length > 0) { this.__setChanged = true; @@ -440,20 +577,20 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'removeById', value: function removeById(ids) { - var _this4 = this; + var _this5 = this; var singular = !lodash.isArray(ids); ids = singular ? [ids] : ids.slice(); invariant(!ids.some(isNaN), 'Cannot remove a model by id that is not a number: ' + JSON.stringify(ids)); var models = ids.map(function (id) { - return _this4.get(id); + return _this5.get(id); }); models.forEach(function (model) { if (model) { - _this4.models.remove(model); - _this4.__setChanged = true; + _this5.models.remove(model); + _this5.__setChanged = true; } }); @@ -477,7 +614,7 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'fetch', value: function fetch() { - var _this5 = this; + var _this6 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -488,8 +625,8 @@ var Store = (_class = (_temp = _class2 = function () { data: data, requestOptions: lodash.omit(options, 'data') }).then(mobx.action(function (res) { - _this5.__state.totalRecords = res.totalRecords; - _this5.fromBackend(res); + _this6.__state.totalRecords = res.totalRecords; + _this6.fromBackend(res); return res.response; }))); @@ -562,7 +699,7 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'toBackendAll', value: function toBackendAll() { - var _this6 = this; + var _this7 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -581,7 +718,7 @@ var Store = (_class = (_temp = _class2 = function () { lodash.forIn(model.relations, function (relModel, key) { relations[key] = relations[key] ? relations[key].concat(relModel) : relModel; // TODO: this primaryKey is not the primaryKey of the relation we're de-duplicating... - relations[key] = lodash.uniqBy(relations[key], _this6.Model.primaryKey); + relations[key] = lodash.uniqBy(relations[key], _this7.Model.primaryKey); }); }); @@ -593,11 +730,11 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'virtualStore', - value: function virtualStore(_ref2) { - var _this7 = this; + value: function virtualStore(_ref3) { + var _this8 = this; - var filter = _ref2.filter, - comparator = _ref2.comparator; + var filter = _ref3.filter, + comparator = _ref3.comparator; var store = new this.constructor({ relations: this.__activeRelations, @@ -606,13 +743,13 @@ var Store = (_class = (_temp = _class2 = function () { // Oh gawd MobX is so awesome. var events = mobx.autorun(function () { - var models = _this7.filter(filter); + var models = _this8.filter(filter); store.models.replace(models); store.sort(); // When the parent store is busy, make sure the virtual store is // also busy. - store.__pendingRequestCount = _this7.__pendingRequestCount; + store.__pendingRequestCount = _this8.__pendingRequestCount; }); store.unsubscribeVirtualStore = events; @@ -687,15 +824,15 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'wrapPendingRequestCount', value: function wrapPendingRequestCount(promise) { - var _this8 = this; + var _this9 = this; this.__pendingRequestCount++; return promise.then(function (res) { - _this8.__pendingRequestCount--; + _this9.__pendingRequestCount--; return res; }).catch(function (err) { - _this8.__pendingRequestCount--; + _this9.__pendingRequestCount--; throw err; }); } @@ -705,27 +842,27 @@ var Store = (_class = (_temp = _class2 = function () { var relations = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var promises = []; - var _iteratorNormalCompletion = true; - var _didIteratorError = false; - var _iteratorError = undefined; + var _iteratorNormalCompletion3 = true; + var _didIteratorError3 = false; + var _iteratorError3 = undefined; try { - for (var _iterator = this.models[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { - var model = _step.value; + for (var _iterator3 = this.models[Symbol.iterator](), _step3; !(_iteratorNormalCompletion3 = (_step3 = _iterator3.next()).done); _iteratorNormalCompletion3 = true) { + var model = _step3.value; promises.push(model.saveAllFiles(relations)); } } catch (err) { - _didIteratorError = true; - _iteratorError = err; + _didIteratorError3 = true; + _iteratorError3 = err; } finally { try { - if (!_iteratorNormalCompletion && _iterator.return) { - _iterator.return(); + if (!_iteratorNormalCompletion3 && _iterator3.return) { + _iterator3.return(); } } finally { - if (_didIteratorError) { - throw _iteratorError; + if (_didIteratorError3) { + throw _iteratorError3; } } } @@ -997,6 +1134,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { this.__store = options.store; this.__repository = options.repository; + this.__linkRelations = options.linkRelations || 'tree'; + invariant(['tree', 'graph'].includes(this.__linkRelations), 'Unknown relation linking method: ' + this.__linkRelations); // Find all attributes. Not all observables are an attribute. lodash.forIn(this, function (value, key) { if (!key.startsWith('__') && mobx.isObservableProp(_this2, key)) { @@ -1014,10 +1153,10 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { } }); if (options.relations) { - this.__parseRelations(options.relations); + this.__parseRelations(options.relations, options.relsFromCache); } if (data) { - this.parse(data); + this.parse(data, options.relsFromCache); } this.initialize(); @@ -1029,6 +1168,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function __parseRelations(activeRelations) { var _this3 = this; + var relsFromCache = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + this.__activeRelations = activeRelations; // TODO: No idea why getting the relations only works when it's a Function. var relations = this.relations && this.relations(); @@ -1057,7 +1198,20 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { mobx.extendObservable(this, lodash.mapValues(relModels, function (otherRelNames, relName) { var RelModel = relations[relName]; invariant(RelModel, 'Specified relation "' + relName + '" does not exist on model.'); - var options = { relations: otherRelNames }; + + var cacheData = relsFromCache[relName]; + if (cacheData && cacheData.model) { + return cacheData.model; + } + + var options = { + relations: otherRelNames, + linkRelations: _this3.__linkRelations + }; + if (cacheData && cacheData.rels) { + options.relsFromCache = cacheData.rels; + } + if (RelModel.prototype instanceof Store) { return new RelModel(options); } @@ -1245,12 +1399,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: '__parseRepositoryToData', value: function __parseRepositoryToData(key, repository) { if (lodash.isArray(key)) { - var models = key.map(function (k) { - return lodash.find(repository, { id: k }); + var idIndexes = Object.fromEntries(key.map(function (id, index) { + return [id, index]; + })); + var models = repository.filter(function (_ref2) { + var id = _ref2.id; + return idIndexes[id] !== undefined; }); - return lodash.filter(models, function (m) { - return m; + models.sort(function (l, r) { + return idIndexes[l.id] - idIndexes[r.id]; }); + return models; } return lodash.find(repository, { id: key }); } @@ -1276,14 +1435,14 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__scopeBackendResponse', - value: function __scopeBackendResponse(_ref2) { + value: function __scopeBackendResponse(_ref3) { var _this7 = this; - var data = _ref2.data, - targetRelName = _ref2.targetRelName, - repos = _ref2.repos, - mapping = _ref2.mapping, - reverseMapping = _ref2.reverseMapping; + var data = _ref3.data, + targetRelName = _ref3.targetRelName, + repos = _ref3.repos, + mapping = _ref3.mapping, + reverseMapping = _ref3.reverseMapping; var scopedData = null; var relevant = false; @@ -1349,13 +1508,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fromBackend', - value: function fromBackend(_ref3) { + value: function fromBackend(_ref4) { var _this8 = this; - var data = _ref3.data, - repos = _ref3.repos, - relMapping = _ref3.relMapping, - reverseRelMapping = _ref3.reverseRelMapping; + var data = _ref4.data, + repos = _ref4.repos, + relMapping = _ref4.relMapping, + reverseRelMapping = _ref4.reverseRelMapping, + _ref4$relCache = _ref4.relCache, + relCache = _ref4$relCache === undefined ? {} : _ref4$relCache, + _ref4$relPath = _ref4.relPath, + relPath = _ref4$relPath === undefined ? 'root' : _ref4$relPath; // We handle the fromBackend recursively. On each relation of the source model // fromBackend gets called as well, but with data scoped for itself @@ -1386,7 +1549,9 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { data: scopedData, repos: scopedRepos, relMapping: scopedRelMapping, - reverseRelMapping: scopedReverseRelMapping + reverseRelMapping: scopedReverseRelMapping, + relCache: relCache, + relPath: relPath + '.' + relName }); }); @@ -1408,6 +1573,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function parse(data) { var _this9 = this; + var relsFromCache = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + invariant(lodash.isPlainObject(data), 'Parameter supplied to `parse()` is not an object, got: ' + JSON.stringify(data)); lodash.forIn(data, function (value, key) { @@ -1415,10 +1582,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { if (_this9.__attributes.includes(attr)) { _this9[attr] = _this9.__parseAttr(attr, value); } else if (_this9.__activeCurrentRelations.includes(attr)) { + var cacheData = relsFromCache[attr]; + + // Model came from cache so we do not have to parse it again + if (cacheData && cacheData.model) { + return; + } + // In Binder, a relation property is an `int` or `[int]`, referring to its ID. // However, it can also be an object if there are nested relations (non flattened). if (lodash.isPlainObject(value) || Array.isArray(value) && value.every(lodash.isPlainObject)) { - _this9[attr].parse(value); + _this9[attr].parse(value, cacheData && cacheData.rels); } else if (value === null) { // The relation is cleared. _this9[attr].clear(); diff --git a/dist/mobx-spine.es.js b/dist/mobx-spine.es.js index 39d4226..78ce782 100644 --- a/dist/mobx-spine.es.js +++ b/dist/mobx-spine.es.js @@ -228,11 +228,82 @@ function _applyDecoratedDescriptor(target, property, decorators, descriptor, con return desc; } -var AVAILABLE_CONST_OPTIONS = ['relations', 'limit', 'comparator', 'params', 'repository']; +var AVAILABLE_CONST_OPTIONS = ['relations', 'limit', 'comparator', 'params', 'repository', 'linkRelations']; + +function getRelsFromCache(record, rels, repos, relMapping, relPath, relCache) { + var relsFromCache = {}; + + var _iteratorNormalCompletion = true; + var _didIteratorError = false; + var _iteratorError = undefined; + + try { + var _loop = function _loop() { + var _step$value = slicedToArray(_step.value, 2), + rel = _step$value[0], + subRels = _step$value[1]; + + // First we try to get the related id + var backendRel = camelToSnake(rel); + var relId = record[backendRel]; + // We only care about numbers since that means that it + // is filled and is a direct relation. + if (typeof relId !== 'number') { + return 'continue'; + } + + // Then we see if we can find the model in the cache. + var subRelPath = relPath + '.' + rel; + var cachedModel = relCache[subRelPath + ':' + relId]; + if (cachedModel) { + relsFromCache[rel] = { model: cachedModel }; + // If we found it we don't have to dive deeper. + return 'continue'; + } + + // Then we search for the related data in the repos + var subRecord = repos[relMapping[camelToSnake(subRelPath.slice('root.'.length))]].find(function (_ref) { + var id = _ref.id; + return id === relId; + }); + if (!subRecord) { + return 'continue'; + } + + // Now we recurse to see if we can find relations in the cache for this model + relsFromCache[rel] = { rels: getRelsFromCache(subRecord, subRels, repos, relMapping, subRelPath, relCache) }; + }; + + for (var _iterator = Object.entries(rels)[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { + var _ret = _loop(); + + if (_ret === 'continue') continue; + } + } catch (err) { + _didIteratorError = true; + _iteratorError = err; + } finally { + try { + if (!_iteratorNormalCompletion && _iterator.return) { + _iterator.return(); + } + } finally { + if (_didIteratorError) { + throw _iteratorError; + } + } + } + + return relsFromCache; +} var Store = (_class = (_temp = _class2 = function () { createClass(Store, [{ key: 'url', + + // The set of models has changed + + // Holds the fetch parameters value: function url() { // Try to auto-generate the URL. var bname = this.constructor.backendResourceName; @@ -241,10 +312,6 @@ var Store = (_class = (_temp = _class2 = function () { } return null; } - // The set of models has changed - - // Holds the fetch parameters - }, { key: 'initialize', @@ -291,6 +358,8 @@ var Store = (_class = (_temp = _class2 = function () { invariant(AVAILABLE_CONST_OPTIONS.includes(option), 'Unknown option passed to store: ' + option); }); this.__repository = options.repository; + this.__linkRelations = options.linkRelations || 'tree'; + invariant(['tree', 'graph'].includes(this.__linkRelations), 'Unknown relation linking method: ' + this.__linkRelations); if (options.relations) { this.__parseRelations(options.relations); } @@ -320,26 +389,86 @@ var Store = (_class = (_temp = _class2 = function () { } }, { key: 'fromBackend', - value: function fromBackend(_ref) { + value: function fromBackend(_ref2) { var _this = this; - var data = _ref.data, - repos = _ref.repos, - relMapping = _ref.relMapping, - reverseRelMapping = _ref.reverseRelMapping; + var data = _ref2.data, + repos = _ref2.repos, + relMapping = _ref2.relMapping, + reverseRelMapping = _ref2.reverseRelMapping, + _ref2$relCache = _ref2.relCache, + relCache = _ref2$relCache === undefined ? {} : _ref2$relCache, + _ref2$relPath = _ref2.relPath, + relPath = _ref2$relPath === undefined ? 'root' : _ref2$relPath; invariant(data, 'Backend error. Data is not set. HINT: DID YOU FORGET THE M2M again?'); this.models.replace(data.map(function (record) { + var options = {}; + + if (_this.__linkRelations === 'graph') { + // Return from cache if available + var _model = relCache[relPath + ':' + record.id]; + if (_model !== undefined) { + return _model; + } + + options.relsFromCache = getRelsFromCache(record, relationsToNestedKeys(_this.__activeRelations), repos, relMapping, relPath, relCache); + } + // TODO: I'm not happy at all about how this looks. // We'll need to finetune some things, but hey, for now it works. - var model = _this._newModel(); + + var model = _this._newModel(null, options); model.fromBackend({ data: record, repos: repos, relMapping: relMapping, - reverseRelMapping: reverseRelMapping + reverseRelMapping: reverseRelMapping, + relCache: relCache, + relPath: relPath }); + + if (_this.__linkRelations === 'graph') { + var toAdd = [[relPath, model]]; + while (toAdd.length > 0) { + var _toAdd$pop = toAdd.pop(), + _toAdd$pop2 = slicedToArray(_toAdd$pop, 2), + _relPath = _toAdd$pop2[0], + _model2 = _toAdd$pop2[1]; + + relCache[_relPath + ':' + _model2.id] = _model2; + + var _iteratorNormalCompletion2 = true; + var _didIteratorError2 = false; + var _iteratorError2 = undefined; + + try { + for (var _iterator2 = _model2.__activeCurrentRelations[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { + var _rel = _step2.value; + + var relModel = _model2[_rel]; + if (relModel instanceof Model && !relModel.isNew) { + toAdd.push([_relPath + '.' + _rel, relModel]); + } + } + } catch (err) { + _didIteratorError2 = true; + _iteratorError2 = err; + } finally { + try { + if (!_iteratorNormalCompletion2 && _iterator2.return) { + _iterator2.return(); + } + } finally { + if (_didIteratorError2) { + throw _iteratorError2; + } + } + } + } + } + return model; })); this.sort(); @@ -348,11 +477,13 @@ var Store = (_class = (_temp = _class2 = function () { key: '_newModel', value: function _newModel() { var model = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - return new this.Model(model, { + return new this.Model(model, _extends({}, options, { store: this, - relations: this.__activeRelations - }); + relations: this.__activeRelations, + linkRelations: this.__linkRelations + })); } }, { key: 'sort', @@ -373,10 +504,14 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'parse', value: function parse(models) { + var _this2 = this; + invariant(isArray(models), 'Parameter supplied to `parse()` is not an array, got: ' + JSON.stringify(models)); // Parse does not mutate __setChanged, as it is used in // fromBackend in the model... - this.models.replace(models.map(this._newModel.bind(this))); + this.models.replace(models.map(function (data) { + return _this2._newModel(data); + })); this.sort(); return this; @@ -398,18 +533,20 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'add', value: function add(models) { - var _this2 = this; + var _this3 = this; var singular = !isArray(models); models = singular ? [models] : models.slice(); - var modelInstances = models.map(this._newModel.bind(this)); + var modelInstances = models.map(function (data) { + return _this3._newModel(data); + }); modelInstances.forEach(function (modelInstance) { - var primaryValue = modelInstance[_this2.Model.primaryKey]; - invariant(!primaryValue || !_this2.get(primaryValue), 'A model with the same primary key value "' + primaryValue + '" already exists in this store.'); - _this2.__setChanged = true; - _this2.models.push(modelInstance); + var primaryValue = modelInstance[_this3.Model.primaryKey]; + invariant(!primaryValue || !_this3.get(primaryValue), 'A model with the same primary key value "' + primaryValue + '" already exists in this store.'); + _this3.__setChanged = true; + _this3.models.push(modelInstance); }); this.sort(); @@ -418,13 +555,13 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'remove', value: function remove(models) { - var _this3 = this; + var _this4 = this; var singular = !isArray(models); models = singular ? [models] : models.slice(); models.forEach(function (model) { - return _this3.models.remove(model); + return _this4.models.remove(model); }); if (models.length > 0) { this.__setChanged = true; @@ -434,20 +571,20 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'removeById', value: function removeById(ids) { - var _this4 = this; + var _this5 = this; var singular = !isArray(ids); ids = singular ? [ids] : ids.slice(); invariant(!ids.some(isNaN), 'Cannot remove a model by id that is not a number: ' + JSON.stringify(ids)); var models = ids.map(function (id) { - return _this4.get(id); + return _this5.get(id); }); models.forEach(function (model) { if (model) { - _this4.models.remove(model); - _this4.__setChanged = true; + _this5.models.remove(model); + _this5.__setChanged = true; } }); @@ -471,7 +608,7 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'fetch', value: function fetch() { - var _this5 = this; + var _this6 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -482,8 +619,8 @@ var Store = (_class = (_temp = _class2 = function () { data: data, requestOptions: omit(options, 'data') }).then(action(function (res) { - _this5.__state.totalRecords = res.totalRecords; - _this5.fromBackend(res); + _this6.__state.totalRecords = res.totalRecords; + _this6.fromBackend(res); return res.response; }))); @@ -556,7 +693,7 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'toBackendAll', value: function toBackendAll() { - var _this6 = this; + var _this7 = this; var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; @@ -575,7 +712,7 @@ var Store = (_class = (_temp = _class2 = function () { forIn(model.relations, function (relModel, key) { relations[key] = relations[key] ? relations[key].concat(relModel) : relModel; // TODO: this primaryKey is not the primaryKey of the relation we're de-duplicating... - relations[key] = uniqBy(relations[key], _this6.Model.primaryKey); + relations[key] = uniqBy(relations[key], _this7.Model.primaryKey); }); }); @@ -587,11 +724,11 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'virtualStore', - value: function virtualStore(_ref2) { - var _this7 = this; + value: function virtualStore(_ref3) { + var _this8 = this; - var filter$$1 = _ref2.filter, - comparator = _ref2.comparator; + var filter$$1 = _ref3.filter, + comparator = _ref3.comparator; var store = new this.constructor({ relations: this.__activeRelations, @@ -600,13 +737,13 @@ var Store = (_class = (_temp = _class2 = function () { // Oh gawd MobX is so awesome. var events = autorun(function () { - var models = _this7.filter(filter$$1); + var models = _this8.filter(filter$$1); store.models.replace(models); store.sort(); // When the parent store is busy, make sure the virtual store is // also busy. - store.__pendingRequestCount = _this7.__pendingRequestCount; + store.__pendingRequestCount = _this8.__pendingRequestCount; }); store.unsubscribeVirtualStore = events; @@ -681,15 +818,15 @@ var Store = (_class = (_temp = _class2 = function () { }, { key: 'wrapPendingRequestCount', value: function wrapPendingRequestCount(promise) { - var _this8 = this; + var _this9 = this; this.__pendingRequestCount++; return promise.then(function (res) { - _this8.__pendingRequestCount--; + _this9.__pendingRequestCount--; return res; }).catch(function (err) { - _this8.__pendingRequestCount--; + _this9.__pendingRequestCount--; throw err; }); } @@ -699,27 +836,27 @@ var Store = (_class = (_temp = _class2 = function () { var relations = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var promises = []; - var _iteratorNormalCompletion = true; - var _didIteratorError = false; - var _iteratorError = undefined; + var _iteratorNormalCompletion3 = true; + var _didIteratorError3 = false; + var _iteratorError3 = undefined; try { - for (var _iterator = this.models[Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { - var model = _step.value; + for (var _iterator3 = this.models[Symbol.iterator](), _step3; !(_iteratorNormalCompletion3 = (_step3 = _iterator3.next()).done); _iteratorNormalCompletion3 = true) { + var model = _step3.value; promises.push(model.saveAllFiles(relations)); } } catch (err) { - _didIteratorError = true; - _iteratorError = err; + _didIteratorError3 = true; + _iteratorError3 = err; } finally { try { - if (!_iteratorNormalCompletion && _iterator.return) { - _iterator.return(); + if (!_iteratorNormalCompletion3 && _iterator3.return) { + _iterator3.return(); } } finally { - if (_didIteratorError) { - throw _iteratorError; + if (_didIteratorError3) { + throw _iteratorError3; } } } @@ -991,6 +1128,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { this.__store = options.store; this.__repository = options.repository; + this.__linkRelations = options.linkRelations || 'tree'; + invariant(['tree', 'graph'].includes(this.__linkRelations), 'Unknown relation linking method: ' + this.__linkRelations); // Find all attributes. Not all observables are an attribute. forIn(this, function (value, key) { if (!key.startsWith('__') && isObservableProp(_this2, key)) { @@ -1008,10 +1147,10 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { } }); if (options.relations) { - this.__parseRelations(options.relations); + this.__parseRelations(options.relations, options.relsFromCache); } if (data) { - this.parse(data); + this.parse(data, options.relsFromCache); } this.initialize(); @@ -1023,6 +1162,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function __parseRelations(activeRelations) { var _this3 = this; + var relsFromCache = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + this.__activeRelations = activeRelations; // TODO: No idea why getting the relations only works when it's a Function. var relations = this.relations && this.relations(); @@ -1051,7 +1192,20 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { extendObservable(this, mapValues(relModels, function (otherRelNames, relName) { var RelModel = relations[relName]; invariant(RelModel, 'Specified relation "' + relName + '" does not exist on model.'); - var options = { relations: otherRelNames }; + + var cacheData = relsFromCache[relName]; + if (cacheData && cacheData.model) { + return cacheData.model; + } + + var options = { + relations: otherRelNames, + linkRelations: _this3.__linkRelations + }; + if (cacheData && cacheData.rels) { + options.relsFromCache = cacheData.rels; + } + if (RelModel.prototype instanceof Store) { return new RelModel(options); } @@ -1239,12 +1393,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { key: '__parseRepositoryToData', value: function __parseRepositoryToData(key, repository) { if (isArray(key)) { - var models = key.map(function (k) { - return find(repository, { id: k }); + var idIndexes = Object.fromEntries(key.map(function (id, index) { + return [id, index]; + })); + var models = repository.filter(function (_ref2) { + var id = _ref2.id; + return idIndexes[id] !== undefined; }); - return filter(models, function (m) { - return m; + models.sort(function (l, r) { + return idIndexes[l.id] - idIndexes[r.id]; }); + return models; } return find(repository, { id: key }); } @@ -1270,14 +1429,14 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: '__scopeBackendResponse', - value: function __scopeBackendResponse(_ref2) { + value: function __scopeBackendResponse(_ref3) { var _this7 = this; - var data = _ref2.data, - targetRelName = _ref2.targetRelName, - repos = _ref2.repos, - mapping = _ref2.mapping, - reverseMapping = _ref2.reverseMapping; + var data = _ref3.data, + targetRelName = _ref3.targetRelName, + repos = _ref3.repos, + mapping = _ref3.mapping, + reverseMapping = _ref3.reverseMapping; var scopedData = null; var relevant = false; @@ -1343,13 +1502,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { }, { key: 'fromBackend', - value: function fromBackend(_ref3) { + value: function fromBackend(_ref4) { var _this8 = this; - var data = _ref3.data, - repos = _ref3.repos, - relMapping = _ref3.relMapping, - reverseRelMapping = _ref3.reverseRelMapping; + var data = _ref4.data, + repos = _ref4.repos, + relMapping = _ref4.relMapping, + reverseRelMapping = _ref4.reverseRelMapping, + _ref4$relCache = _ref4.relCache, + relCache = _ref4$relCache === undefined ? {} : _ref4$relCache, + _ref4$relPath = _ref4.relPath, + relPath = _ref4$relPath === undefined ? 'root' : _ref4$relPath; // We handle the fromBackend recursively. On each relation of the source model // fromBackend gets called as well, but with data scoped for itself @@ -1380,7 +1543,9 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { data: scopedData, repos: scopedRepos, relMapping: scopedRelMapping, - reverseRelMapping: scopedReverseRelMapping + reverseRelMapping: scopedReverseRelMapping, + relCache: relCache, + relPath: relPath + '.' + relName }); }); @@ -1402,6 +1567,8 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { value: function parse(data) { var _this9 = this; + var relsFromCache = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + invariant(isPlainObject(data), 'Parameter supplied to `parse()` is not an object, got: ' + JSON.stringify(data)); forIn(data, function (value, key) { @@ -1409,10 +1576,17 @@ var Model = (_class$1 = (_temp$1 = _class2$1 = function () { if (_this9.__attributes.includes(attr)) { _this9[attr] = _this9.__parseAttr(attr, value); } else if (_this9.__activeCurrentRelations.includes(attr)) { + var cacheData = relsFromCache[attr]; + + // Model came from cache so we do not have to parse it again + if (cacheData && cacheData.model) { + return; + } + // In Binder, a relation property is an `int` or `[int]`, referring to its ID. // However, it can also be an object if there are nested relations (non flattened). if (isPlainObject(value) || Array.isArray(value) && value.every(isPlainObject)) { - _this9[attr].parse(value); + _this9[attr].parse(value, cacheData && cacheData.rels); } else if (value === null) { // The relation is cleared. _this9[attr].clear(); diff --git a/src/Model.js b/src/Model.js index 081195b..568f9b3 100644 --- a/src/Model.js +++ b/src/Model.js @@ -73,6 +73,7 @@ export default class Model { __activeCurrentRelations = []; __repository; __store; + __linkRelations; api = null; // A `cid` can be used to identify the model locally. cid = `m${uniqueId()}`; @@ -166,6 +167,11 @@ export default class Model { constructor(data, options = {}) { this.__store = options.store; this.__repository = options.repository; + this.__linkRelations = options.linkRelations || 'tree'; + invariant( + ['tree', 'graph'].includes(this.__linkRelations), + `Unknown relation linking method: ${this.__linkRelations}`, + ); // Find all attributes. Not all observables are an attribute. forIn(this, (value, key) => { if (!key.startsWith('__') && isObservableProp(this, key)) { @@ -186,10 +192,10 @@ export default class Model { } }); if (options.relations) { - this.__parseRelations(options.relations); + this.__parseRelations(options.relations, options.relsFromCache); } if (data) { - this.parse(data); + this.parse(data, options.relsFromCache); } this.initialize(); @@ -197,7 +203,7 @@ export default class Model { } @action - __parseRelations(activeRelations) { + __parseRelations(activeRelations, relsFromCache = {}) { this.__activeRelations = activeRelations; // TODO: No idea why getting the relations only works when it's a Function. const relations = this.relations && this.relations(); @@ -236,7 +242,20 @@ export default class Model { RelModel, `Specified relation "${relName}" does not exist on model.` ); - const options = { relations: otherRelNames }; + + const cacheData = relsFromCache[relName]; + if (cacheData && cacheData.model) { + return cacheData.model; + } + + const options = { + relations: otherRelNames, + linkRelations: this.__linkRelations, + }; + if (cacheData && cacheData.rels) { + options.relsFromCache = cacheData.rels; + } + if (RelModel.prototype instanceof Store) { return new RelModel(options); } @@ -528,7 +547,7 @@ export default class Model { // e.g. "animal_kind", while the relation name would be "kind". // `relMapping` maps relation names to repositories. @action - fromBackend({ data, repos, relMapping, reverseRelMapping, }) { + fromBackend({ data, repos, relMapping, reverseRelMapping, relCache = {}, relPath = 'root' }) { // We handle the fromBackend recursively. On each relation of the source model // fromBackend gets called as well, but with data scoped for itself // @@ -555,6 +574,8 @@ export default class Model { repos: scopedRepos, relMapping: scopedRelMapping, reverseRelMapping: scopedReverseRelMapping, + relCache, + relPath: `${relPath}.${relName}`, }); }); @@ -578,7 +599,7 @@ export default class Model { } @action - parse(data) { + parse(data, relsFromCache = {}) { invariant( isPlainObject(data), `Parameter supplied to \`parse()\` is not an object, got: ${JSON.stringify( @@ -591,10 +612,17 @@ export default class Model { if (this.__attributes.includes(attr)) { this[attr] = this.__parseAttr(attr, value); } else if (this.__activeCurrentRelations.includes(attr)) { + const cacheData = relsFromCache[attr]; + + // Model came from cache so we do not have to parse it again + if (cacheData && cacheData.model) { + return + } + // In Binder, a relation property is an `int` or `[int]`, referring to its ID. // However, it can also be an object if there are nested relations (non flattened). if (isPlainObject(value) || (Array.isArray(value) && value.every(isPlainObject))) { - this[attr].parse(value); + this[attr].parse(value, cacheData && cacheData.rels); } else if (value === null) { // The relation is cleared. this[attr].clear(); diff --git a/src/Store.js b/src/Store.js index 971e964..4e7ff5e 100644 --- a/src/Store.js +++ b/src/Store.js @@ -11,15 +11,56 @@ import { result, uniqBy, } from 'lodash'; -import { invariant } from './utils'; +import { invariant, relationsToNestedKeys, camelToSnake } from './utils'; +import Model from './Model'; const AVAILABLE_CONST_OPTIONS = [ 'relations', 'limit', 'comparator', 'params', 'repository', + 'linkRelations', ]; +function getRelsFromCache(record, rels, repos, relMapping, relPath, relCache) { + const relsFromCache = {}; + + for (const [rel, subRels] of Object.entries(rels)) { + // First we try to get the related id + const backendRel = camelToSnake(rel); + const relId = record[backendRel]; + // We only care about numbers since that means that it + // is filled and is a direct relation. + if (typeof relId !== 'number') { + continue; + } + + // Then we see if we can find the model in the cache. + const subRelPath = `${relPath}.${rel}`; + const cachedModel = relCache[`${subRelPath}:${relId}`]; + if (cachedModel) { + relsFromCache[rel] = { model: cachedModel }; + // If we found it we don't have to dive deeper. + continue; + } + + // Then we search for the related data in the repos + const subRecord = repos[relMapping[camelToSnake(subRelPath.slice('root.'.length))]].find(({ id }) => id === relId); + if (!subRecord) { + continue; + } + + // Now we recurse to see if we can find relations in the cache for this model + relsFromCache[rel] = { rels: getRelsFromCache( + subRecord, subRels, + repos, relMapping, + subRelPath, relCache, + ) }; + } + + return relsFromCache; +} + export default class Store { // Holds all models @observable models = []; @@ -38,6 +79,7 @@ export default class Store { Model = null; api = null; __repository; + __linkRelations; static backendResourceName = ''; url() { @@ -81,6 +123,11 @@ export default class Store { ); }); this.__repository = options.repository; + this.__linkRelations = options.linkRelations || 'tree'; + invariant( + ['tree', 'graph'].includes(this.__linkRelations), + `Unknown relation linking method: ${this.__linkRelations}`, + ); if (options.relations) { this.__parseRelations(options.relations); } @@ -113,7 +160,7 @@ export default class Store { } @action - fromBackend({ data, repos, relMapping, reverseRelMapping }) { + fromBackend({ data, repos, relMapping, reverseRelMapping, relCache = {}, relPath = 'root' }) { invariant( data, 'Backend error. Data is not set. HINT: DID YOU FORGET THE M2M again?' @@ -121,25 +168,62 @@ export default class Store { this.models.replace( data.map(record => { + const options = {}; + + if (this.__linkRelations === 'graph') { + // Return from cache if available + const model = relCache[`${relPath}:${record.id}`]; + if (model !== undefined) { + return model; + } + + options.relsFromCache = getRelsFromCache( + record, relationsToNestedKeys(this.__activeRelations), + repos, relMapping, + relPath, relCache, + ); + } + // TODO: I'm not happy at all about how this looks. // We'll need to finetune some things, but hey, for now it works. - const model = this._newModel(); + + const model = this._newModel(null, options); model.fromBackend({ data: record, repos, relMapping, reverseRelMapping, + relCache, + relPath, }); + + if (this.__linkRelations === 'graph') { + const toAdd = [[relPath, model]]; + while (toAdd.length > 0) { + const [relPath, model] = toAdd.pop(); + relCache[`${relPath}:${model.id}`] = model; + + for (const rel of model.__activeCurrentRelations) { + const relModel = model[rel]; + if (relModel instanceof Model && !relModel.isNew) { + toAdd.push([`${relPath}.${rel}`, relModel]); + } + } + } + } + return model; }) ); this.sort(); } - _newModel(model = null) { + _newModel(model = null, options = {}) { return new this.Model(model, { + ...options, store: this, relations: this.__activeRelations, + linkRelations: this.__linkRelations, }); } @@ -170,7 +254,7 @@ export default class Store { ); // Parse does not mutate __setChanged, as it is used in // fromBackend in the model... - this.models.replace(models.map(this._newModel.bind(this))); + this.models.replace(models.map((data) => this._newModel(data))); this.sort(); return this; @@ -193,7 +277,7 @@ export default class Store { const singular = !isArray(models); models = singular ? [models] : models.slice(); - const modelInstances = models.map(this._newModel.bind(this)); + const modelInstances = models.map((data) => this._newModel(data)); modelInstances.forEach(modelInstance => { const primaryValue = modelInstance[this.Model.primaryKey]; diff --git a/src/__tests__/Store.js b/src/__tests__/Store.js index 397a8a8..4999f32 100644 --- a/src/__tests__/Store.js +++ b/src/__tests__/Store.js @@ -24,12 +24,14 @@ import customersWithTownRestaurantsUnbalanced from './fixtures/customers-with-to import townsWithRestaurantsAndCustomersNoIdList from './fixtures/towns-with-restaurants-and-customers-no-id-list.json'; import customersWithOldTowns from './fixtures/customers-with-old-towns.json'; import animalsData from './fixtures/animals.json'; +import animalsGraphData from './fixtures/animals-graph.json'; import pagination0Data from './fixtures/pagination/0.json'; import pagination1Data from './fixtures/pagination/1.json'; import pagination2Data from './fixtures/pagination/2.json'; import pagination3Data from './fixtures/pagination/3.json'; import pagination4Data from './fixtures/pagination/4.json'; + const simpleData = [ { id: 2, @@ -1010,3 +1012,62 @@ describe('Pagination', () => { }); }); }); + +describe('Linking relations', () => { + let mock; + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { + if (mock) { + mock.restore(); + mock = null; + } + }); + + test('instantiate', () => { + new AnimalStore(); + new AnimalStore({ linkRelations: 'tree' }); + new AnimalStore({ linkRelations: 'graph' }); + expect(() => new AnimalStore({ linkRelations: 'foo' })).toThrow(new Error('[mobx-spine] Unknown relation linking method: foo')); + }) + + test('fetch animals with owner & town as tree', () => { + mock.onAny().replyOnce(() => [200, animalsGraphData]); + + const animalStore = new AnimalStore({ + linkRelations: 'tree', + relations: ['owner.town'], + }); + + return animalStore.fetch().then(() => { + expect(animalStore.map('id')).toEqual([1, 2, 3]); + + expect(animalStore.at(0).owner).not.toBe(animalStore.at(1).owner); + expect(animalStore.at(0).owner).not.toBe(animalStore.at(2).owner); + expect(animalStore.at(1).owner).not.toBe(animalStore.at(2).owner); + + expect(animalStore.at(0).owner.town).not.toBe(animalStore.at(1).owner.town); + expect(animalStore.at(0).owner.town).not.toBe(animalStore.at(2).owner.town); + expect(animalStore.at(1).owner.town).not.toBe(animalStore.at(2).owner.town); + }); + }); + + test('fetch animals with owner & town as graph', () => { + mock.onAny().replyOnce(() => [200, animalsGraphData]); + + const animalStore = new AnimalStore({ + linkRelations: 'graph', + relations: ['owner.town'], + }); + + return animalStore.fetch().then(() => { + expect(animalStore.map('id')).toEqual([1, 2, 3]); + + expect(animalStore.at(0).owner).toBe(animalStore.at(1).owner); + expect(animalStore.at(0).owner).not.toBe(animalStore.at(2).owner); + + expect(animalStore.at(0).owner.town).toBe(animalStore.at(2).owner.town); + }); + }); +}); diff --git a/src/__tests__/fixtures/animals-graph.json b/src/__tests__/fixtures/animals-graph.json new file mode 100644 index 0000000..1403446 --- /dev/null +++ b/src/__tests__/fixtures/animals-graph.json @@ -0,0 +1,46 @@ +{ + "data": [ + { + "id": 1, + "name": "Foo", + "owner": 1 + }, + { + "id": 2, + "name": "Bar", + "owner": 1 + }, + { + "id": 3, + "name": "Baz", + "owner": 2 + } + ], + "with": { + "person": [ + { + "id": 1, + "name": "FOO", + "town": 1 + }, + { + "id": 2, + "name": "BAR", + "town": 1 + } + ], + "town": [ + { + "id": 1, + "name": "Eindhoven" + } + ] + }, + "with_mapping": { + "owner": "person", + "owner.town": "town" + }, + "meta": { + "total_records": 3 + } +}