├── .jshintignore ├── .gitignore ├── .npmignore ├── spec ├── testing-tools │ ├── mocks │ │ ├── cat │ │ │ ├── 1.json │ │ │ └── all.json │ │ └── person │ │ │ ├── 1 │ │ │ └── pets │ │ │ │ ├── 1 │ │ │ │ └── flees │ │ │ │ │ └── all.json │ │ │ │ ├── 1.json │ │ │ │ └── all.json │ │ │ ├── 1.json │ │ │ └── 2.json │ ├── proxy-server.js │ └── mock-server.js ├── stores │ ├── datastore.spec.js │ ├── jsonapidatastore.spec.js │ └── memorystore.spec.js └── elide.spec.js ├── .editorconfig ├── .jshintrc ├── .jscsrc ├── lib ├── helpers │ ├── clone.js │ ├── checkpromise.js │ └── change-watcher.js ├── query.js ├── datastores │ ├── datastore.js │ └── memorydatastore.js └── elide.js ├── .travis.yml ├── mocks ├── index.html └── books_and_authors.yaml ├── package.json ├── gulpfile.js ├── README.md └── LICENSE /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | README.md 3 | gulpfile.js 4 | LICENSE 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | artifacts/ 4 | build/ 5 | *npm-debug.log 6 | .coverrun 7 | .coverdata 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | spec/ 2 | .editorconfig 3 | .jscsrc 4 | .jshintrc 5 | .validate.json 6 | gulpfile.js 7 | screwdriver.yaml 8 | -------------------------------------------------------------------------------- /spec/testing-tools/mocks/cat/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "cat", 4 | "id": "1", 5 | "attributes": { 6 | "color": "black", 7 | "age": 12 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "mocha": true, 4 | "esnext": true, 5 | 6 | "bitwise": true, 7 | "curly": true, 8 | "eqeqeq": true, 9 | "freeze": true, 10 | "futurehostile": true, 11 | "latedef": "nofunc", 12 | "maxcomplexity": 15, 13 | "noarg": true, 14 | "undef": true, 15 | "unused": "vars", 16 | "strict": true 17 | } 18 | -------------------------------------------------------------------------------- /spec/testing-tools/mocks/person/1/pets/1/flees/all.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "flee", 5 | "id": "1", 6 | "attributes": { 7 | "age": 12 8 | } 9 | }, 10 | { 11 | "type": "flee", 12 | "id": "2", 13 | "attributes": { 14 | "age": 2 15 | } 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.jscsrc: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "google", 3 | "esnext": true, 4 | "jsDoc": false, 5 | "maximumLineLength": { 6 | "value": 90, 7 | "tabSize": 2, 8 | "allowRegex": true, 9 | "allowComments": true, 10 | "allExcept": ["regex", "comments", "require", "functionSignature"] 11 | }, 12 | "excludeFiles": [ 13 | "build/**", 14 | "*.md" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /spec/testing-tools/mocks/person/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "person", 4 | "id": "1", 5 | "attributes": { 6 | "name": "John" 7 | }, 8 | "relationships": { 9 | "pets": { 10 | "data": [ 11 | {"type": "pet", "id": "1"}, 12 | {"type": "pet", "id": "2"} 13 | ] 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /spec/testing-tools/mocks/person/1/pets/1.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "pet", 4 | "id": "1", 5 | "attributes": { 6 | "type": "dog", 7 | "name": "spot", 8 | "age": 4 9 | }, 10 | "relationships": { 11 | "owner": { 12 | "data": { 13 | "type": "person", 14 | "id": "1" 15 | } 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /spec/testing-tools/mocks/person/2.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "type": "person", 4 | "id": "2", 5 | "attributes": { 6 | "name": "Cortana" 7 | }, 8 | "relationships": { 9 | "bike": { 10 | "data": {"type": "bicycle", "id": "1"} 11 | }, 12 | "pets": { 13 | "data": [ 14 | {"type": "pet", "id": "1"}, 15 | {"type": "pet", "id": "2"} 16 | ] 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /spec/testing-tools/proxy-server.js: -------------------------------------------------------------------------------- 1 | // proxy-server.js 2 | var express = require('express'); 3 | var url = require('url'); 4 | var proxy = require('proxy-middleware'); 5 | 6 | var app = express(); 7 | 8 | app.use('/', express.static('build/web/')); 9 | app.use('/', proxy(url.parse('http://localhost:4080/'))); 10 | 11 | var server = app.listen(8882, function () { 12 | var host = server.address().address; 13 | var port = server.address().port; 14 | 15 | console.log('Proxy server running on http://%s:%s/', host, port); 16 | }); 17 | -------------------------------------------------------------------------------- /spec/testing-tools/mocks/cat/all.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "cat", 5 | "id": "1", 6 | "attributes": { 7 | "color": "black", 8 | "age": 12 9 | } 10 | }, 11 | { 12 | "type": "cat", 13 | "id": "2", 14 | "attributes": { 15 | "color": "grey", 16 | "age": 2 17 | } 18 | }, 19 | { 20 | "type": "cat", 21 | "id": "3", 22 | "attributes": { 23 | "color": "white", 24 | "age": 5 25 | } 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /spec/testing-tools/mocks/person/1/pets/all.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "type": "pet", 5 | "id": "1", 6 | "attributes": { 7 | "type": "dog", 8 | "age": 4, 9 | "name": "spot" 10 | }, 11 | "relationships": { 12 | "owner": { 13 | "data": { 14 | "type": "person", 15 | "id": "1" 16 | } 17 | } 18 | } 19 | }, 20 | { 21 | "type": "pet", 22 | "id": "2", 23 | "attributes": { 24 | "type": "cat", 25 | "age": 2, 26 | "name": "blink" 27 | }, 28 | "relationships": { 29 | "owner": { 30 | "data": { 31 | "type": "person", 32 | "id": "1" 33 | } 34 | } 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /lib/helpers/clone.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | ********************************************************************************/ 6 | 'use strict'; 7 | 8 | export default function clone(object) { 9 | let copy; 10 | 11 | if (typeof object !== 'object' || object === null) { 12 | return object; 13 | } 14 | 15 | if (object instanceof Date) { 16 | copy = new Date(object.getTime()); 17 | 18 | } else if (object instanceof Array) { 19 | copy = object.map((el) => { return clone(el); }); 20 | 21 | } else { 22 | copy = Object.create(object.prototype || {}); 23 | Object.keys(object).forEach((key) => { copy[key] = clone(object[key]); }); 24 | } 25 | 26 | return copy; 27 | } 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: node_js 3 | node_js: 4 | - '4.1' 5 | - '4.0' 6 | - '0.12' 7 | - '0.10' 8 | 9 | # build steps 10 | before_deploy: 11 | - npm run build 12 | deploy: 13 | provider: npm 14 | email: clayreimann@gmail.com 15 | api_key: 16 | secure: rg2vb+7lXpUaL1Bsp2XXZhVNnc/l93vrzsXLPmsg/0+2WxQI7A2J2P/+ubJkpgiUsKfcT5G+42aQCQ1ThPfqd6df+h2mk0jYGkRLsiSaI1Pzn6Nscq/eSVr83AfjRq/uNplTim+XA7e4uw8HhVyas6vaMKcZ2g7ibzjeiSgEKmqLYhI6wIv0b342+ojmvXVW6z+y/2JU8ELoC5WEYk5ulA7OpnI2IU1lhh71CRaor0ZV1c3pOFafBuMhPDYP+9KdFzXazclG9i7rZNO2MO/APeohzNHUVY84uiTxFbEKBfEHQ6WrYcWiASFPey0YkNA37aHilf9KjxCSVRLgED4uXbBZCs7j4r5j77B7i7M3sMkwLJ76aJDJo9gvAtF7LZqlSvoVezqxuZN5zShM/LWrTmTykAsS8D/QKRYco6pHGlic4Wp0kBwD7s1ui9qCV+F7+pbwoikqjjMusxv6bfqUk2q/W0SlWQoDTT5Xt8De9RG05Gr+YAx4L3a5dG4hREoPoE0OWhpTfWSt4Ale8Ps333GjbURw/iSHLUTBI2A/QJWWuPY3QPoDbaCSVRnClS2IfoQ9ZfmiSbbphKUbESPPS7ys7z6LQijer3lsQe/SaIA+1EG/mFjelh2SOw/kc8TxPRJRWnROHuhFGWwWNKgDoZlLuTsZTt6fc6eClRn93eg= 17 | on: 18 | tags: true 19 | -------------------------------------------------------------------------------- /lib/helpers/checkpromise.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | ********************************************************************************/ 6 | 'use strict'; 7 | 8 | /** 9 | * try to figure out if something could be a Promise 10 | * @param {object} promiseLike - the thing in question 11 | * @return {boolean} - if it could be a promise 12 | */ 13 | export function objectLooksLikePromise(promiseLike) { 14 | if (!promiseLike) { 15 | return false; 16 | } 17 | 18 | // naïve check to see if a promise library is passed into options 19 | if (typeof promiseLike.all !== 'function' || 20 | typeof promiseLike.race !== 'function' || 21 | typeof promiseLike.resolve !== 'function' || 22 | typeof promiseLike.reject !== 'function') { 23 | 24 | return false; 25 | } 26 | 27 | return true; 28 | } 29 | -------------------------------------------------------------------------------- /mocks/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Elide-js Test Page 4 | 5 | 6 | 7 | 8 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /lib/helpers/change-watcher.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | ********************************************************************************/ 6 | 'use strict'; 7 | 8 | import jsonpatch from 'fast-json-patch'; 9 | 10 | class ChangeWatcher { 11 | constructor() { 12 | this.watchers = []; 13 | } 14 | 15 | watchModel(object, model) { 16 | if (!object) { 17 | return; 18 | } 19 | 20 | this.watchers.push({ 21 | observer: jsonpatch.observe(object), 22 | path: `/${model}/${object.id}` 23 | }); 24 | } 25 | 26 | getPatches() { 27 | let patches = []; 28 | 29 | for (let i = 0; i < this.watchers.length; i++) { 30 | let path = this.watchers[i].path; 31 | let observer = this.watchers[i].observer; 32 | 33 | let partials = jsonpatch.generate(observer); 34 | for (let j = 0; j < partials.length; j++) { 35 | partials[j].path = `${path}${partials[j].path}`; 36 | } 37 | 38 | patches = patches.concat(partials); 39 | } 40 | 41 | return patches; 42 | } 43 | } 44 | 45 | export default ChangeWatcher; 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elide-js", 3 | "description": "Talk to a JSON API compliant server", 4 | "license": "Apache-2.0", 5 | "authors": "Clay Reimann ", 6 | "contributors": [], 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/yahoo/elide-js.git" 10 | }, 11 | "version": "0.11.9", 12 | "main": "build/node/elide.js", 13 | "browser": "build/web/elide.js", 14 | "scripts": { 15 | "clean": "rm -rf node_modules build artifacts out", 16 | "lint": "node_modules/.bin/gulp lint", 17 | "test": "node_modules/.bin/gulp test", 18 | "build": "node_modules/.bin/gulp build" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/yahoo/elide-js/issues" 22 | }, 23 | "keywords": [ 24 | "elide", 25 | "json", 26 | "api" 27 | ], 28 | "dependencies": { 29 | "debug": "~2.2.0", 30 | "fast-json-patch": "~0.5.2", 31 | "superagent": "^1.8.0", 32 | "superagent-promise": "^1.0.0", 33 | "uuid": "~2.0.1" 34 | }, 35 | "devDependencies": { 36 | "babel": "^5.6.14", 37 | "babel-core": "^5.6.17", 38 | "babel-loader": "^5.3.1", 39 | "chai": "^3.0.0", 40 | "chai-as-promised": "^5.1.0", 41 | "es6-promise": "^2.3.0", 42 | "express": "^4.13.3", 43 | "gulp": "^3.9.0", 44 | "gulp-babel": "^5.1.0", 45 | "gulp-express": "^0.3.5", 46 | "gulp-istanbul": "^0.10.0", 47 | "gulp-jscs": "^1.6.0", 48 | "gulp-jscs-stylish": "~1.1.0", 49 | "gulp-jshint": "^1.11.2", 50 | "gulp-mocha": "^2.1.2", 51 | "gulp-stubby-server": "^0.1.5", 52 | "gulp-webpack": "^1.5.0", 53 | "inherits": "^2.0.1", 54 | "isparta": "^3.0.3", 55 | "jshint-stylish": "^2.0.1", 56 | "mocha": "^2.2.5", 57 | "mocha-junit-reporter": "^1.5.0", 58 | "open": "0.0.5", 59 | "proxy-middleware": "^0.15.0", 60 | "sinon": "~1.15.4", 61 | "url": "^0.11.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/query.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | ********************************************************************************/ 6 | 'use strict'; 7 | 8 | class Query { 9 | /** 10 | * create a new query object 11 | * 12 | * @param {object} storeInstance - the store this query is bound to 13 | * @param {string} model - the model we are searching in 14 | * @param {number} id - the id we are looking for (could be a uuid) 15 | * @param {number} options - additional arguments such as fields and includes 16 | */ 17 | constructor(storeInstance, model, id, options) { 18 | this._instance = storeInstance; 19 | this._params = []; 20 | this._params.push({ 21 | model: model, 22 | id: id 23 | }); 24 | 25 | options = options || {}; 26 | options.fields = options.fields || {}; 27 | options.filters = options.filters || {}; 28 | options.include = options.include || []; 29 | this._opts = options; 30 | } 31 | 32 | /** 33 | * add a new level of specificity to the query 34 | * 35 | * @param {string} field - the linked field to continue searching down into 36 | * @param {number} id - the id we are looking 37 | * @param {object} options - some options for the query 38 | */ 39 | find(field, id, options) { 40 | if (id instanceof Object) { 41 | options = id; 42 | id = undefined; 43 | } 44 | 45 | this._params.push({ 46 | field: field, 47 | id: id 48 | }); 49 | this._mergeOptions(options || {}); 50 | 51 | return this; 52 | } 53 | 54 | /** 55 | * merge the options object with this._opts 56 | * 57 | * @param {object} options - the additional options provided 58 | */ 59 | _mergeOptions(options) { 60 | if (options.fields) { 61 | Object.keys(options.fields).forEach((model) => { 62 | this._opts.fields[model] = 63 | (this._opts.fields[model] || []).concat(options.fields[model]); 64 | }); 65 | } 66 | 67 | if (options.filters) { 68 | Object.keys(options.filters).forEach((model) => { 69 | this._opts.filters[model] = 70 | (this._opts.filters[model] || []).concat(options.filters[model]); 71 | }); 72 | } 73 | 74 | if (options.include) { 75 | this._opts.include.push.apply(this._opts.include, options.include); 76 | } 77 | } 78 | 79 | /** 80 | * execute the search and return the results 81 | * 82 | * @return {Promise} the promise that will receive the results 83 | */ 84 | then(success, failure) { 85 | return this._instance.find(this).then(success, failure); 86 | } 87 | } 88 | 89 | export default Query; 90 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var gulp = require('gulp'); 3 | 4 | // code style 5 | var jscs = require('gulp-jscs'); 6 | var jshint = require('gulp-jshint'); 7 | 8 | // build 9 | var babel = require('gulp-babel'); 10 | var mocha = require('gulp-mocha'); 11 | var webpack = require('gulp-webpack'); 12 | 13 | // testing 14 | var isparta = require('isparta'); 15 | var istanbul = require('gulp-istanbul'); 16 | var testServer = require('./spec/testing-tools/mock-server'); 17 | var stubby = require('gulp-stubby-server'); 18 | var proxyServer = require('gulp-express'); 19 | 20 | var mockServer; 21 | var stubbyServer; 22 | 23 | // random 24 | var open = require('open'); 25 | 26 | gulp.task('lint', function() { 27 | return gulp.src(['lib/**/*.js', 'spec/**/*.js']) 28 | .pipe(jshint()) 29 | .pipe(jshint.reporter('jshint-stylish')) 30 | .pipe(jscs()); 31 | }); 32 | 33 | gulp.task('mock', function(cb) { 34 | mockServer = testServer.listen(1337); 35 | 36 | var options = { files: ['mocks/*.{json,yaml,js}'] }; 37 | stubbyServer = stubby(options, cb); 38 | }); 39 | 40 | gulp.task('test:tdd', function() { 41 | // swizzle require because it's simpler 42 | require('babel/register'); 43 | return gulp.src(['spec/**/*.spec.js']) 44 | .pipe(mocha({ 45 | reporter: 'min' 46 | })); 47 | }); 48 | 49 | gulp.task('tdd', ['mock'], function() { 50 | gulp.start('test:tdd'); 51 | gulp.watch(['lib/**/*.js', 'spec/**/*.spec.js'], ['test:tdd']); 52 | }); 53 | 54 | gulp.task('test', ['mock'], function(done) { 55 | var testFile = process.env.TEST_RESULTS_DIR === undefined ? 56 | 'artifacts/test/test-results.xml' : 57 | process.env.TEST_RESULTS_DIR + '/test-results.xml'; 58 | var coverageDir = process.env.COVERAGE_DIR || 'artifacts/coverage'; 59 | 60 | gulp.src(['lib/**/*.js']) 61 | .pipe(istanbul({ 62 | instrumenter: isparta.Instrumenter 63 | })) 64 | .pipe(istanbul.hookRequire()) 65 | .on('finish', function() { 66 | gulp.src(['spec/**/*.spec.js']) 67 | .pipe(mocha({ 68 | reporter: 'spec' 69 | })) 70 | .pipe(istanbul.writeReports({ 71 | dir: coverageDir, 72 | reporters: ['lcov', 'json', 'html', 'text'] 73 | })) 74 | .on('end', function() { 75 | mockServer.close(done); 76 | stubbyServer.stop(); 77 | if (process.env.OPEN) { 78 | open(__dirname + '/artifacts/coverage/index.html'); 79 | } 80 | }); 81 | }); 82 | }); 83 | 84 | gulp.task('build', ['build:web_min', 'build:server']); 85 | 86 | var webpackConfig = { 87 | entry: './lib/elide.js', 88 | resolve: { 89 | extensions: ['', '.js'] 90 | }, 91 | output: { 92 | filename: 'web/elide.js', 93 | library: 'Elide', 94 | libraryTarget: 'umd' 95 | }, 96 | module: { 97 | loaders: [ 98 | {test: /\.js$/, exclude: [/node_modules/], loader: 'babel-loader'} 99 | ], 100 | }, 101 | plugins: [], 102 | stats: { 103 | colors: true 104 | }, 105 | devtool: 'source-map' 106 | }; 107 | 108 | gulp.task('build:web_min', ['build:web_debug'], function() { 109 | var minConfig = Object.create(webpackConfig); 110 | minConfig.output.filename = 'web/elide.min.js'; 111 | minConfig.plugins = [ 112 | new webpack.webpack.optimize.UglifyJsPlugin() 113 | ]; 114 | return gulp.src('./lib/elide.js') 115 | .pipe(webpack(minConfig)) 116 | .pipe(gulp.dest('./build')); 117 | }); 118 | 119 | gulp.task('build:web_debug', function() { 120 | return gulp.src('./lib/elide.js') 121 | .pipe(webpack(webpackConfig)) 122 | .pipe(gulp.dest('./build')); 123 | }); 124 | 125 | gulp.task('build:server', function() { 126 | return gulp.src('./lib/**/*.js') 127 | .pipe(babel()) 128 | .pipe(gulp.dest('./build/node')); 129 | }); 130 | 131 | gulp.task('proxy-server', ['build:web_debug'], function() { 132 | gulp.src('mocks/index.html') 133 | .pipe(gulp.dest('./build/web')); 134 | proxyServer.run(['spec/testing-tools/proxy-server.js']); 135 | }); 136 | -------------------------------------------------------------------------------- /spec/stores/datastore.spec.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations 14 | * under the License. 15 | *********************************************************************************/ 16 | /* jshint expr: true */ 17 | // jscs:disable maximumLineLength 18 | 'use strict'; 19 | 20 | var chai = require('chai'); 21 | chai.use(require('chai-as-promised')); 22 | var expect = chai.expect; 23 | var DATASTORE = require('../../lib/datastores/datastore'); 24 | var Datastore = DATASTORE.default; 25 | var inherits = require('inherits'); 26 | var ES6Promise = require('es6-promise').Promise; 27 | var Query = require('../../lib/query'); 28 | 29 | var FooStore = function FooStore(Promise, ttl, baseURL, models) { 30 | FooStore.super_.call(this, Promise, ttl, baseURL, models); 31 | }; 32 | inherits(FooStore, Datastore); 33 | ['find', 'create', 'update', 'delete', 'commit'].forEach(function(method) { 34 | FooStore.prototype['_' + method] = function(model, state) { 35 | return new this._promise(function(resolve, reject) {}); 36 | }; 37 | }); 38 | 39 | describe('Datastore', function() { 40 | 41 | describe('initialization', function() { 42 | it('should require a promise library', function() { 43 | expect(function() { 44 | new Datastore(); 45 | }).to.throw(DATASTORE.ERROR_NO_PROMISE); 46 | 47 | expect(function() { 48 | new Datastore('will fail'); 49 | }).to.throw(DATASTORE.ERROR_NO_PROMISE); 50 | }); 51 | 52 | it('should require models', function() { 53 | expect(function() { 54 | new Datastore(ES6Promise); 55 | }).to.throw(DATASTORE.ERROR_NO_MODELS); 56 | }); 57 | 58 | it('should use the specified promise library', function() { 59 | var ds = new Datastore(ES6Promise, undefined, undefined, {}); 60 | 61 | expect(ds._promise).to.be.equal(ES6Promise); 62 | }); 63 | 64 | it('should set ttl correctly', function() { 65 | var ds = new Datastore(ES6Promise, 1000, undefined, {}); 66 | 67 | expect(ds._ttl).to.equal(1000); 68 | }); 69 | 70 | it('should reject invalid ttls', function() { 71 | ['', {}, [], null].map(function(badTTL) { 72 | expect(function() { 73 | new Datastore(ES6Promise, badTTL); 74 | }).to.throw(Datastore.ERROR_BAD_TTL); 75 | }); 76 | 77 | expect(function() { 78 | new Datastore(ES6Promise, -40, undefined, {}); 79 | }).to.throw(Datastore.ERROR_NEG_TTL); 80 | }); 81 | 82 | it('should set baseURL correctly', function() { 83 | var ds = new Datastore(ES6Promise, undefined, 'http://foo.bar.com', {}); 84 | 85 | expect(ds._baseURL).to.equal('http://foo.bar.com'); 86 | }); 87 | 88 | it('should reject invalid baseURLs', function() { 89 | [/foo/, {}, [], null].map(function(badURL) { 90 | expect(function() { 91 | new Datastore(ES6Promise, undefined, badURL); 92 | }).to.throw(Datastore.ERROR_BAD_BASEURL); 93 | }); 94 | }); 95 | 96 | it('should set models correctly', function() { 97 | var models = {}; 98 | var ds = new Datastore(ES6Promise, undefined, undefined, models); 99 | expect(ds._models).to.equal(models); 100 | }); 101 | }); 102 | 103 | ['create', 'update', 'delete'].map(function(method) { 104 | describe('#' + method, function() { 105 | it('should return a promise', function() { 106 | var foo = new FooStore(ES6Promise, undefined, undefined, {}); 107 | var promise = foo[method]('model', {}); 108 | expect(promise).to.be.an.instanceof(ES6Promise); 109 | }); 110 | 111 | it('should throw an error if no ' + method + ' function exists', function() { 112 | var store = new Datastore(ES6Promise, undefined, undefined, {}); 113 | expect(store[method]('model', {})).to.eventually.be.rejectedWith('Not implemented'); 114 | }); 115 | }); 116 | 117 | }); 118 | 119 | describe('#find', function() { 120 | it('should return a promise', function() { 121 | var foo = new FooStore(ES6Promise, undefined, undefined, {}); 122 | var q = new Query(foo, 'model', 1); 123 | var promise = foo.find(q); 124 | expect(promise).to.be.an.instanceof(ES6Promise); 125 | }); 126 | 127 | it('should require a Query', function() { 128 | var foo = new FooStore(ES6Promise, undefined, undefined, {}); 129 | var q = new Query(foo, 'model', 1); 130 | expect(function() { 131 | foo.find(q); 132 | }).not.to.throw(); 133 | 134 | expect(function() { 135 | foo.find('model1', 1); 136 | }).to.throw(DATASTORE.ERROR_MUST_FIND_QUERY); 137 | }); 138 | 139 | it('should throw an error if no find function exists', function() { 140 | var ds = new Datastore(ES6Promise, undefined, undefined, {}); 141 | var q = new Query(ds, 'model', 1); 142 | expect(ds.find(q)).to.eventually.be.rejectedWith('Not implemented'); 143 | }); 144 | }); 145 | 146 | describe('#commit', function() { 147 | it('should return a promise', function() { 148 | var foo = new FooStore(ES6Promise, undefined, undefined, {}); 149 | var promise = foo.commit(); 150 | expect(promise).to.be.an.instanceof(ES6Promise); 151 | }); 152 | 153 | it('should throw an error if no _commit function exists', function() { 154 | var ds = new Datastore(ES6Promise, undefined, undefined, {}); 155 | expect(ds.commit()).to.eventually.be.rejectedWith('Not implemented'); 156 | }); 157 | }); 158 | 159 | describe('#setUpstream', function() { 160 | it('should let you put a datastore upstream', function() { 161 | var store1 = new Datastore(ES6Promise, undefined, undefined, {}); 162 | var store2 = new Datastore(ES6Promise, undefined, undefined, {}); 163 | store1.setUpstream(store2); 164 | 165 | expect(store1._upstream).to.be.equal(store2); 166 | }); 167 | it('should ONLY let you put a datastore upstream', function() { 168 | var store = new Datastore(ES6Promise, undefined, undefined, {}); 169 | expect(function() { store.setUpstream('wont work'); }).to.throw(); 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /lib/datastores/datastore.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | ********************************************************************************/ 6 | 'use strict'; 7 | 8 | import debug from 'debug'; 9 | import Query from '../query'; 10 | import { objectLooksLikePromise } from '../helpers/checkpromise'; 11 | 12 | // jscs:disable maximumLineLength 13 | export let ERROR_NO_PROMISE = 'Datastore must be initalized with a Promise.'; 14 | export let ERROR_BAD_TTL = 'Datastore ttl must be a Number or undefined.'; 15 | export let ERROR_NEG_TTL = 'Datastore ttl must be non-negative.'; 16 | export let ERROR_BAD_BASEURL = 'Datastore baseURL must be a String or undefined.'; 17 | export let ERROR_NO_MODELS = 'You must provide datastore with models.'; 18 | export let ERROR_NO_FIND = 'Datastore must implement _find in subclass.'; 19 | export let ERROR_NO_CREATE = 'Datastore must implement _create in subclass.'; 20 | export let ERROR_NO_UPDATE = 'Datastore must implement _update in subclass.'; 21 | export let ERROR_NO_DELETE = 'Datastore must implement _delete in subclass.'; 22 | export let ERROR_NO_COMMIT = 'Datastore must implement _commit in sublcass.'; 23 | export let ERROR_BAD_UPSTREAM = 'Only a Datastore can be upstream.'; 24 | export let ERROR_MUST_FIND_QUERY = 'Datastore#find did not receive a Query'; 25 | // jscs:enable maximumLineLength 26 | 27 | let info = debug('elide:info'); 28 | 29 | /** 30 | * Base datastore class. 31 | */ 32 | class Datastore { 33 | /** 34 | * Datastore constructor 35 | * 36 | * @param {Promise} Promise - a reference to the Promise function to be used by the instance 37 | * @param {Number} ttl - the Number of milliseconds that we will cache data 38 | * @param {String} baseURL - the base URL to use when constructing queries to the API 39 | * @param {Object} models - the description of the models 40 | */ 41 | constructor(Promise, ttl, baseURL, models) { 42 | if (!objectLooksLikePromise(Promise)) { 43 | throw new Error(ERROR_NO_PROMISE); 44 | } 45 | 46 | if (ttl !== undefined && typeof ttl !== 'number') { 47 | throw new Error(ERROR_BAD_TTL); 48 | } 49 | if (ttl !== undefined && ttl < 0) { 50 | throw new Error(ERROR_NEG_TTL); 51 | } 52 | 53 | if (baseURL !== undefined && typeof baseURL !== 'string') { 54 | throw new Error(ERROR_BAD_BASEURL); 55 | } 56 | 57 | if (typeof models !== 'object') { 58 | throw new Error(ERROR_NO_MODELS); 59 | } 60 | 61 | this._promise = Promise; 62 | this._ttl = ttl; 63 | this._baseURL = baseURL; 64 | this._models = models; 65 | } 66 | 67 | /** 68 | * Find an object or set of objects. If they cannot be found in this store 69 | * and this store has an `upstream` store set then the search will continue 70 | * in the upstream store. 71 | * 72 | * @param {Query} query - a query for us to resolve 73 | * @return {Promise} - a promise that will eventually receieve the results 74 | */ 75 | find(query) { 76 | if (!(query instanceof Query)) { 77 | throw new Error(ERROR_MUST_FIND_QUERY); 78 | } 79 | 80 | return this._promise.reject('Not implemented'); 81 | } 82 | 83 | /** 84 | * Create a new object and return it. The object can optionally be initalized 85 | * with state. The object will recieve a uuid for its `id` if the store is not 86 | * an interface for an upstream API. 87 | * 88 | * @param {String} model - the model that we'll be trying to find 89 | * @param {Object} state - the initial state of the object we're creating 90 | * @return {Promise} - a promise that will eventually receieve the results 91 | */ 92 | create(model, state) { 93 | return this._promise.reject('Not implemented'); 94 | } 95 | 96 | /** 97 | * Update an existing object. 98 | * 99 | * @param {String} model - the model that we'll be trying to find 100 | * @param {Object} state - the state of the object we're updating 101 | * @param {Number} state.id - you must specify the `id` of the model to be updated 102 | * @return {Promise} - a promise that will eventually receieve the results 103 | */ 104 | update(model, state) { 105 | return this._promise.reject('Not implemented'); 106 | } 107 | 108 | /** 109 | * Delete an object from the datastore. 110 | * 111 | * @param {String} model - the model that we'll be trying to find 112 | * @param {Object} state - the state of the object we're deleting 113 | * @param {Number} state.id - you must specify the `id` of the model to be deleted 114 | * @return {Promise} - a promise that will eventually receieve the results 115 | */ 116 | delete(model, state) { 117 | return this._promise.reject('Not implemented'); 118 | } 119 | 120 | /** 121 | * Push all pending operations to the upstream store. 122 | * 123 | * @param {Array} patches - the list of patches to apply to the store 124 | * @return {Promise} - a promise that receives the results of the operation 125 | */ 126 | commit(patches) { 127 | return this._promise.reject('Not implemented'); 128 | } 129 | 130 | /** 131 | * Specify a datastore that is upstream of this one. This is where data goes 132 | * when we call {@link Datastore#commit} 133 | * 134 | * @param {Datastore} store - the store that is upstream of this store 135 | */ 136 | setUpstream(store) { 137 | if (!(store instanceof Datastore)) { 138 | throw new Error(ERROR_BAD_UPSTREAM); 139 | } 140 | this._upstream = store; 141 | } 142 | 143 | /** 144 | * Add a query parameter to the store 145 | * 146 | * @param {String} key - the name of the query parameter 147 | * @param {String} value - the value fo the query parameter 148 | */ 149 | addQueryParameter(key, value) {} 150 | 151 | /** 152 | * Add a header to requests sent from this store 153 | * 154 | * @param {String} key - the name of the header 155 | * @param {String} value - the value of the header 156 | */ 157 | addRequestHeader(key, value) {} 158 | 159 | /** 160 | * clears the stored authentication data 161 | */ 162 | clearAuthData() {} 163 | 164 | /** 165 | * seralizes any internal state for the store so that the store can 166 | * be reconstitued at a later time. (should be able to be stringify'ed) 167 | * 168 | * @return {Object} a seralized representation of the store 169 | */ 170 | dehydrate() { 171 | if (typeof this._dehydrate === 'function') { 172 | return this._dehydrate(); 173 | } 174 | } 175 | 176 | /** 177 | * deseralized the internal state of the store from the object produced by `dehydrate` 178 | * 179 | * @param {Object} state - the representation of the store's state 180 | */ 181 | rehydrate(state) { 182 | if (typeof this._rehydrate === 'function') { 183 | this._rehydrate(state); 184 | } 185 | } 186 | } 187 | 188 | export default Datastore; 189 | -------------------------------------------------------------------------------- /spec/testing-tools/mock-server.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations 14 | * under the License. 15 | *********************************************************************************/ 16 | 'use strict'; 17 | 18 | var http = require('http'); 19 | var fs = require('fs'); 20 | var path = require('path'); 21 | var debug = require('debug'); 22 | 23 | var debugFind = debug('mock-server:find'); 24 | var debugCreate = debug('mock-server:create'); 25 | var debugUpdate = debug('mock-server:update'); 26 | var debugDelete = debug('mock-server:delete'); 27 | 28 | var BASE = path.join(process.cwd(), 'spec/testing-tools/mocks'); 29 | function fileForUrl(url, collections) { 30 | var extension; 31 | 32 | if (url.indexOf('?') !== -1) { 33 | url = url.slice(0, url.indexOf('?')); 34 | } 35 | url = url.slice('/api/'.length); 36 | extension = url.charAt(url.length - 1) === '/' ? '' : '.json'; 37 | if (collections && url.charAt(url.length - 1) === '/') { 38 | extension = 'all.json'; 39 | } 40 | 41 | return path.join(BASE, url + extension); 42 | } 43 | 44 | function respondWith(response, status, body) { 45 | response.writeHead(status, { 46 | 'Content-Length': body.length, 47 | 'Content-Type': 'application/vnd.api+json' 48 | }); 49 | response.end(body); 50 | } 51 | 52 | function findObject(request, response) { 53 | var filePath; var text; var status; 54 | 55 | if (request.url === '/api/cat' || 56 | request.url === '/api/person/1/pets' || 57 | request.url === '/api/person/1/pets/1/flees') { 58 | request.url += '/'; 59 | } 60 | 61 | filePath = fileForUrl(request.url, true); 62 | debugFind('filePath', filePath); 63 | try { 64 | text = fs.readFileSync(filePath, {encoding: 'utf8'}); 65 | } catch (e) { 66 | text = ''; 67 | } 68 | 69 | status = text.length !== 0 ? 200 : 404; 70 | respondWith(response, status, text); 71 | } 72 | 73 | function createObject(request, response) { 74 | var filePath; var data = ''; 75 | 76 | if (request.url === '/api/person' || 77 | request.url === '/api/person/1/pets') { 78 | request.url += '/'; 79 | } 80 | 81 | filePath = fileForUrl(request.url, false); 82 | debugCreate('filePath', filePath); 83 | try { 84 | var stat = fs.statSync(filePath); 85 | if (!stat.isDirectory()) { 86 | respondWith(response, 403, JSON.stringify({error: 'Invalid request'})); 87 | } 88 | } catch (e) { 89 | respondWith(response, 403, JSON.stringify({error: 'Invalid request'})); 90 | return; 91 | } 92 | 93 | request.on('data', function(chunk) { 94 | data += chunk; 95 | }); 96 | 97 | request.on('end', function() { 98 | var json = JSON.parse(data); 99 | var resp = JSON.parse(data); 100 | var oldId = json.data.id; 101 | var status = 200; 102 | debugCreate('received:', data); 103 | 104 | resp.data.id = 1; 105 | resp.data.meta = resp.data.meta || {}; 106 | resp.data.meta.clientId = oldId; 107 | 108 | if (resp.data.attributes.name === 'FAIL') { 109 | status = 403; 110 | resp = {error: 'Invalid request'}; 111 | } 112 | 113 | debugCreate('responding with:', resp); 114 | respondWith(response, status, JSON.stringify(resp)); 115 | }); 116 | } 117 | 118 | function updateObject(request, response) { 119 | var filePath; var data = ''; 120 | 121 | filePath = fileForUrl(request.url, false); 122 | try { 123 | fs.statSync(filePath); 124 | } catch (e) { 125 | respondWith(response, 403, JSON.stringify({error: 'Invalid request'})); 126 | return; 127 | } 128 | 129 | request.on('data', function(chunk) { 130 | data += chunk; 131 | }); 132 | 133 | request.on('end', function() { 134 | var json = JSON.parse(data); 135 | var status = 200; 136 | debugUpdate('received:', json); 137 | 138 | // remove empty relationship keys 139 | Object.keys(json.data.relationships).forEach(function(rel) { 140 | if (json.data.relationships[rel].data === null) { 141 | delete json.data.relationships[rel]; 142 | } else if (json.data.relationships[rel].data instanceof Array && 143 | json.data.relationships[rel].data.length === 0) { 144 | delete json.data.relationships[rel]; 145 | } 146 | }); 147 | 148 | debugUpdate('responding with:', json); 149 | respondWith(response, status, JSON.stringify(json)); 150 | }); 151 | } 152 | 153 | function patchExtension(request, response) { 154 | var data = ''; 155 | 156 | request.on('data', function(chunk) { 157 | data += chunk; 158 | }); 159 | 160 | request.on('end', function() { 161 | var patches = JSON.parse(data); 162 | debugUpdate('patches:', patches); 163 | if (patches.length === 1) { 164 | var path = fileForUrl('/api/person/1'); 165 | respondWith(response, 200, '[' + 166 | fs.readFileSync(path, {encoding: 'utf8'}) + 167 | ']'); 168 | } else { 169 | respondWith(response, 204, ''); 170 | } 171 | }); 172 | } 173 | 174 | function deleteObject(request, response) { 175 | var filePath; 176 | 177 | filePath = fileForUrl(request.url, false); 178 | try { 179 | fs.statSync(filePath); 180 | } catch (e) { 181 | debugDelete('file for url', request.url, 'does not exist'); 182 | respondWith(response, 403, JSON.stringify({error: 'Invalid request'})); 183 | return; 184 | } 185 | 186 | respondWith(response, 204, ''); 187 | } 188 | 189 | module.exports = http.createServer(function(request, response) { 190 | if (!request.headers['content-type'] || 191 | !request.headers['content-type'].search('application/vnd.api+json')) { 192 | respondWith(response, 415, '{"error": "Invalid Content-Type"}'); 193 | return; 194 | } 195 | 196 | switch (request.method) { 197 | case 'GET': 198 | debugFind('GETing data from:', request.url); 199 | findObject(request, response); 200 | break; 201 | 202 | case 'POST': 203 | debugCreate('POSTing data to:', request.url); 204 | createObject(request, response); 205 | break; 206 | 207 | case 'PATCH': 208 | debugUpdate('PATCHing data to:', request.url); 209 | if (request.headers['content-type'] === 210 | 'application/vnd.api+json; ext=jsonpatch') { 211 | patchExtension(request, response); 212 | } else { 213 | updateObject(request, response); 214 | } 215 | break; 216 | 217 | case 'DELETE': 218 | debugDelete('DELETEing data to:', request.url); 219 | deleteObject(request, response); 220 | break; 221 | 222 | default: 223 | respondWith(response, 500, '{"error": "Unknown method `' + 224 | request.method + '`"}'); 225 | break; 226 | } 227 | 228 | }); 229 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Please Note:** This repository has known security vulnerabilities. Use at your own risk! 2 | 3 | [![Stories in Ready](https://badge.waffle.io/yahoo/elide-js.png?label=ready&title=Ready)](https://waffle.io/yahoo/elide-js) 4 | [![Build Status](https://travis-ci.org/yahoo/elide-js.svg?branch=master)](https://travis-ci.org/yahoo/elide-js) [![npm version](https://badge.fury.io/js/elide-js.svg)](https://badge.fury.io/js/elide-js) [![Code Climate](https://codeclimate.com/github/yahoo/elide-js/badges/gpa.svg)](https://codeclimate.com/github/yahoo/elide-js) 5 | 6 | Elide 7 | =============== 8 | Elide is a library that makes it easy to talk to a [JSON API](http://jsonapi.org/format) compliant backend. 9 | While it was specifically designed to talk to the [Elide server](https://github.com/yahoo/elide) it should 10 | work with any server that is JSON API compliant. 11 | 12 | While the examples make use of ES6, it is by no means required to write your code in ES6. Elide is developed 13 | in ES6, but the distributed code is ES5 so you can run it without any other support libraries. 14 | 15 | ## Getting Started 16 | ``` 17 | npm install elide-js 18 | ``` 19 | 20 | Elide is tested on: 21 | * node 4.1.x 22 | * node 4.0.x 23 | * node 0.12.x 24 | * node 0.10.x 25 | 26 | ## Usage 27 | 28 | * [Schema](#Schema) 29 | * [API](#API) 30 | - [*constructor*](#constructor) 31 | - [find](#find) 32 | - [create](#create) 33 | - [update](#update) 34 | - [delete](#delete) 35 | - [commit](#commit) 36 | 37 | ### Schema 38 | The schema is a javascript object composed of two sections: `stores` and `models`. 39 | 40 | Example schema 41 | ```javascript 42 | var SCHEMA = { 43 | stores: { 44 | memory: { 45 | type: 'memory', // memory stores cache data in the browser 46 | upstream: 'jsonapi', // upstream stores are where queries fall back to 47 | ttl: 60000 48 | }, 49 | jsonapi: { 50 | type: 'jsonapi', // jsonapi stores talk to remote servers to fetch data 51 | baseURL: 'https://stg6-large.flurry.gq1.yahoo.com/pulse/v1' 52 | } 53 | }, 54 | models: { 55 | // all models have an implicit id property 56 | // only the properties that are listed directly in the object or in the links object 57 | // are parsed out of the response from server or sent to the server 58 | company: { // the property names in models are the name of the model (JSON API type) 59 | meta: { 60 | store: 'jsonapi', // where we should start looking for instances of this model 61 | isRootObject: true // if we can query for this model directly (defaults to false) 62 | }, 63 | name: 'string', 64 | publisherLevel: 'string|number', // the values of the properties are meant as documentation, 65 | // in reality they could be any valid javascript value 66 | publisherDiscount: 'number', 67 | links: { 68 | project: { // this key will be the property name in the parsed object 69 | model: 'project', // what model type the property links to 70 | type: 'hasMany', // hasOne|hasMany 71 | inverse: 'company' // what property on the other object will hold the inverse relationship 72 | } 73 | } 74 | }, 75 | project: { 76 | meta: { 77 | store: 'memory' 78 | } 79 | name: 'string' 80 | } 81 | } 82 | }; 83 | 84 | export default SCHEMA; // or module.exports = SCHEMA; if you prefer 85 | ``` 86 | 87 | ### API 88 | #### *constructor*(`schema`, `options`) 89 | `schema` - an object as described in [schema](#Schema) 90 | 91 | `options` - an object 92 | `options.promise` - Your favorite promise implementation 93 | ```javascript 94 | var schema = require('./schema'); // import schema from './schema'; 95 | var Promise = require('es6-promise').Promise // import {Promise} from 'es6-promise'; 96 | 97 | var options = { 98 | promise: Promise 99 | }; 100 | 101 | var elide = new Elide(schema, options); 102 | ``` 103 | #### find(`model`, `id`, `opts`) → Promise(`result`) 104 | `model` - the name of the model (or property for nested queries) to search 105 | 106 | `id` - (optional) the id to find (leave blank for collections) 107 | 108 | `opts` - (optional) additional options for querying sparse fields, filtering and includes (see below) 109 | 110 | `result` - the object returned by the query. Will have the format: 111 | ``` 112 | { 113 | data: object|array, 114 | included: { 115 | model: [], 116 | model2: [] 117 | } 118 | }``` 119 | 120 | ```javascript 121 | elide.find('company', 1) 122 | .then((results) => { 123 | // do something with company 1 124 | }).catch((error) => { 125 | // inspect error to see what went wrong 126 | }); 127 | 128 | elide.find('company', 1) 129 | .find('projects') 130 | .then(function(results) { 131 | // do something with company 1's projects 132 | }).catch(function(error) { 133 | // inspect error to see what went wrong 134 | }); 135 | 136 | elide.find('company', 1, {fields: {projects: ['name', 'companyid']}}) 137 | .find('projects') 138 | .then(function(results) { 139 | // do something with company 1's projects's name and company id 140 | }).catch(function(error) { 141 | // inspect error to see what went wrong 142 | }); 143 | 144 | elide.find('company', 1, {filters: {project: [ {attribute: 'name', operator: "in", value: "myapp" }]}}) 145 | .find('projects') 146 | .then(function(results) { 147 | // do something with company 1's only myapp projects 148 | }).catch(function(error) { 149 | // inspect error to see what went wrong 150 | }); 151 | ``` 152 | 153 | ##### Options 154 | 155 | `include` - an array of additional resource objects to include in the results that are 156 | related to the primary data. The contents of the array are the property names of the 157 | relationships. For example `['authors', 'authors.spouse', 'publisher.bankAccounts']` 158 | would include the authors for the requested books, the spouses for the included authors, 159 | and the bank accounts for the publisher of the requested books. 160 | 161 | For instance, you might query for books and include the related author resources as follows: 162 | 163 | ```javascript 164 | elide.find('book', {include: ['authors']}) 165 | .then((results) => { 166 | console.log(results.data); // the books 167 | console.log(results.included); // the included resources (authors) 168 | }); 169 | ``` 170 | 171 | `fields` - an object that specifies which set of fields to return for each model. 172 | By default, all attributes and relationships described in the model will be fetched. 173 | 174 | For instance, query for the title and authors of books as follows: 175 | 176 | ```javascript 177 | elide.find('book', {fields: {book: ['title', 'authors']}}) 178 | .then((results) => { 179 | console.log(results.data); // only book information will be available 180 | }); 181 | ``` 182 | 183 | **Note:** If you specify a fields option, it overrides the fields for **all models**. 184 | What this means is that if you query for books, include authors and ask for title and 185 | authors of books, you will not get any fields back for authors. In addition to 186 | title and authors of books, you will also have to include name of authors, as follows: 187 | 188 | ```javascript 189 | elide.find('book', {include: ['authors'], fields: {book: ['title', 'authors'], author: ['name']}}) 190 | .then((results) => { 191 | console.log(results.data); // only book.title and book.authors will be available 192 | console.log(results.included); // only author.name will be available 193 | }); 194 | ``` 195 | 196 | `filters` - an object that specifies criteria that result types must match. 197 | 198 | For instance, query for all books that start with Harry Potter as follows: 199 | 200 | ```javascript 201 | elide.find('book', {filters: {book: [ {attribute: 'title', operator: "prefix", value: "Harry Potter"} ]}) 202 | .then((results) => { 203 | console.log(results.data); // returns books with title Harry Potter 204 | }); 205 | ``` 206 | 207 | #### create(`model`, `state`) → Promise(`result`) 208 | `model` - The name of the model to be created 209 | 210 | `state` - The initial state (without `id`) of the new object 211 | 212 | `result` - The object created by `create` 213 | ```javascript 214 | let company = { 215 | name: 'Flurry', 216 | publisherLevel: 'Advanced', 217 | publisherDiscount: 0.5 218 | }; 219 | elide.create('company', company) 220 | .then((createdCompany) => { 221 | // company now has an id 222 | }).catch((error) => { 223 | // inspect error to see what went wrong 224 | }); 225 | ``` 226 | 227 | #### update(`model`, `newState`) → Promise(`result`) 228 | `model` - The name of the model to be updated 229 | 230 | `state` - The new state of the object 231 | 232 | `result` - The object updated by `update` 233 | ```javascript 234 | let company = { 235 | id: 1, 236 | name: 'Flurry by Yahoo!' 237 | }; 238 | 239 | elide.update('company', company) 240 | .then(function(updatedCompany) { 241 | // company.name now == 'Flurry by Yahoo!' 242 | }).catch(function(error) { 243 | // inspect error to see what went wrong 244 | }); 245 | ``` 246 | 247 | #### delete(`model`, `state`) → Promise() 248 | `model` - The name of the model to be deleted 249 | 250 | `state` - An object that contains at least `id` with the id of the object to be deleted 251 | 252 | Delete receives no value, but returns a Promise so errors can be caught. 253 | ```javascript 254 | elide.delete('company', {id: 1}) 255 | .then(function() { 256 | // there is no value received 257 | }).catch((error) => { 258 | // inspect error to see what went wrong 259 | }); 260 | ``` 261 | 262 | #### commit() → Promise() 263 | Commit receives no value but returns a Promise so errors can be caught 264 | ```javascript 265 | elide.commit() 266 | .then(() => { 267 | // there is no value received 268 | }).catch((error) => { 269 | // inspect error to see what went wrong 270 | }); 271 | ``` 272 | -------------------------------------------------------------------------------- /lib/elide.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | ********************************************************************************/ 6 | 'use strict'; 7 | 8 | import Query from './query'; 9 | import MemoryDatastore from './datastores/memorydatastore'; 10 | import JsonApiDatastore from './datastores/jsonapidatastore'; 11 | 12 | import clone from './helpers/clone'; 13 | import { objectLooksLikePromise } from './helpers/checkpromise'; 14 | 15 | // jscs:disable maximumLineLength 16 | export let ERROR_INVALID_SCHEMA = 'Invalid Elide schema format.'; 17 | export let ERROR_INVALID_OPTIONS = 'Invalid Elide options specified.'; 18 | export let ERROR_INVALID_PROMISE = 'Invalid Promise/A+ library specified.'; 19 | export let ERROR_INVALID_STORES = 'Invalid stores section found in schema.'; 20 | export let ERROR_NUM_STORES = 'Elide schema.stores must describe at least one store.'; 21 | export let ERROR_INVALID_MODELS = 'Invalid models section found in schema.'; 22 | export let ERROR_NUM_MODELS = 'Elide schema.models must describe at least one model.'; 23 | export let ERROR_BAD_STORE = 'Elide model "${modelName}" must specify a valid store.'; 24 | export let ERROR_BAD_STORE_TYPE = 'Unknown store type "${storeType}".'; 25 | export let ERROR_NO_LINK_TYPE = 'Elide model "${modelName}" specifies a link without a type.'; 26 | export let ERROR_BAD_LINK_TYPE = 'Invalid link type "${linkType}".'; 27 | export let ERROR_NO_LINK_MODEL = 'Elide model "${modelName}" specifies a link to an unknown model.'; 28 | export let ERROR_UNKNOWN_MODEL = 'Elide model "${modelName}" does not exist.'; 29 | export let ERROR_BAD_LINK_MODEL = 'Elide model "${modelName}" specifies a link to an unknown model.'; 30 | export let ERROR_DANGLING_MODEL = 'Elide model "${modelName}" cannot be rooted.'; 31 | export let ERROR_UNKNOWN_UPSTREAM_STORE = 'Cannot set upstream store to "${storeName}", "${storeName}" not defined.'; 32 | // jscs:enable maximumLineLength 33 | 34 | class Elide { 35 | constructor(schema, options) { 36 | if (typeof schema !== 'object') { 37 | throw new Error(ERROR_INVALID_SCHEMA); 38 | } 39 | 40 | if (typeof options !== 'object') { 41 | throw new Error(ERROR_INVALID_OPTIONS); 42 | } 43 | if (!objectLooksLikePromise(options.promise)) { 44 | throw new Error(ERROR_INVALID_PROMISE); 45 | } 46 | 47 | if (typeof schema.stores !== 'object') { 48 | throw new Error(ERROR_INVALID_STORES); 49 | } 50 | if (Object.keys(schema.stores).length === 0) { 51 | throw new Error(ERROR_NUM_STORES); 52 | } 53 | 54 | if (typeof schema.models !== 'object') { 55 | throw new Error(ERROR_INVALID_MODELS); 56 | } 57 | if (Object.keys(schema.models).length === 0) { 58 | throw new Error(ERROR_NUM_MODELS); 59 | } 60 | 61 | this._promise = options.promise; 62 | this._stores = {}; 63 | this._modelToStoreMap = {}; 64 | this._storesThatCommit = []; 65 | this._configureModels(schema.models, Object.keys(schema.stores)); 66 | this._configureStores(schema.stores); 67 | 68 | this.auth = { 69 | addQueryParameter: this._addQueryParameter.bind(this), 70 | addRequestHeader: this._addRequestHeader.bind(this), 71 | reset: this._resetAuthData.bind(this) 72 | }; 73 | } 74 | 75 | _configureModels(models, storeNames) { 76 | let rootable = {}; 77 | let canRoot = {}; 78 | 79 | // verify metadata and links 80 | Object.keys(models).map((modelName) => { 81 | let model = models[modelName]; 82 | 83 | if (!model.meta || !model.meta.store) { 84 | throw new Error(ERROR_BAD_STORE 85 | .replace('${modelName}', modelName)); 86 | } 87 | if (storeNames.indexOf(model.meta.store) === -1) { 88 | throw new Error(ERROR_BAD_STORE 89 | .replace('${modelName}', modelName)); 90 | } 91 | this._modelToStoreMap[modelName] = model.meta.store; 92 | 93 | if (model.meta.isRootObject === true) { 94 | rootable[modelName] = true; 95 | } 96 | 97 | // this is the list of models that can be rooted via the current model 98 | canRoot[modelName] = []; 99 | model.links = model.links || {}; 100 | Object.keys(model.links).map(function(linkName) { 101 | let link = model.links[linkName]; 102 | 103 | let linkType = link.type; 104 | if (!linkType) { 105 | throw new Error(ERROR_NO_LINK_TYPE 106 | .replace('${modelName}', modelName)); 107 | } 108 | if (linkType !== 'hasOne' && linkType !== 'hasMany') { 109 | throw new Error(ERROR_BAD_LINK_TYPE 110 | .replace('${linkType}', linkType)); 111 | } 112 | 113 | let modelType = link.model; 114 | if (!modelType) { 115 | throw new Error(ERROR_NO_LINK_MODEL 116 | .replace('${modelName}', modelName)); 117 | } 118 | if (!models[modelType]) { 119 | throw new Error(ERROR_BAD_LINK_MODEL 120 | .replace('${modelName}', modelName)); 121 | } 122 | 123 | canRoot[modelName].push(modelType); 124 | }); 125 | }); 126 | 127 | // verify rootability by recursively checking children 128 | var rootChildren = function rootChildren(modelName) { 129 | rootable[modelName] = true; 130 | 131 | let children = canRoot[modelName]; 132 | children.map(function(childName) { 133 | rootChildren(childName); 134 | }); 135 | }; 136 | Object.keys(rootable).map(function(rootableModel) { 137 | rootChildren(rootableModel); 138 | }); 139 | 140 | Object.keys(models).map(function(model) { 141 | if (rootable[model] !== true) { 142 | throw new Error(ERROR_DANGLING_MODEL 143 | .replace('${modelName}', model)); 144 | } 145 | }); 146 | 147 | this._models = clone(models); 148 | } 149 | 150 | _configureStores(stores) { 151 | Object.keys(stores).map((storeName) => { 152 | let storeDef = stores[storeName]; 153 | let storeType = storeDef.type; 154 | 155 | let promise = this._promise; 156 | let ttl = storeDef.ttl; 157 | let baseURL = storeDef.baseURL; 158 | let models = clone(this._models); 159 | let store; 160 | 161 | switch (storeType) { 162 | case 'memory': 163 | store = new MemoryDatastore(promise, ttl, baseURL, models); 164 | this._storesThatCommit.push(storeName); 165 | break; 166 | 167 | case 'jsonapi': 168 | store = new JsonApiDatastore(promise, ttl, baseURL, models); 169 | break; 170 | 171 | default: 172 | throw new Error(ERROR_BAD_STORE_TYPE 173 | .replace('${storeType}', storeType)); 174 | } 175 | 176 | this._stores[storeName] = store; 177 | }); 178 | 179 | Object.keys(stores).map((storeName) => { 180 | let upstreamName = stores[storeName].upstream; 181 | if (!upstreamName) { 182 | return; 183 | } 184 | 185 | let store = this._stores[storeName]; 186 | let upstream = this._stores[upstreamName]; 187 | 188 | if (upstream === undefined) { 189 | throw new Error(ERROR_UNKNOWN_UPSTREAM_STORE 190 | .replace(/\$\{storeName\}/g, upstreamName)); 191 | } 192 | 193 | store.setUpstream(upstream); 194 | }); 195 | } 196 | 197 | _getStoreForModel(model) { 198 | if (!this._modelToStoreMap.hasOwnProperty(model)) { 199 | throw new Error(ERROR_UNKNOWN_MODEL 200 | .replace('${modelName}', model)); 201 | } 202 | return this._stores[this._modelToStoreMap[model]]; 203 | } 204 | 205 | /** 206 | * Add a query parameter to those stores that compare 207 | * 208 | * @param {String} key - the name of the qurey parameter 209 | * @param {String} value - the value of the query parameter 210 | */ 211 | _addQueryParameter(key, value) { 212 | Object.keys(this._stores).forEach((store) => { 213 | this._stores[store].addQueryParameter(key, value); 214 | }); 215 | } 216 | 217 | /** 218 | * Add a request header to those stores that care 219 | * 220 | * @param {String} key - the name of the header 221 | * @param {String} value - the value of the header 222 | */ 223 | _addRequestHeader(key, value) { 224 | Object.keys(this._stores).forEach((store) => { 225 | this._stores[store].addRequestHeader(key, value); 226 | }); 227 | } 228 | 229 | /** 230 | * clear auth data from stores that track it 231 | * 232 | */ 233 | _resetAuthData() { 234 | Object.keys(this._stores).forEach((store) => { 235 | store.clearAuthData(); 236 | }); 237 | } 238 | 239 | /* 240 | * PUBLIC INTERFACE 241 | */ 242 | 243 | find(model, id, opts) { 244 | if (id instanceof Object) { 245 | opts = id 246 | id = undefined 247 | } 248 | 249 | opts = opts || {}; 250 | let store = this._getStoreForModel(model); 251 | return new Query(store, model, id, opts); 252 | } 253 | 254 | 255 | create(model, state) { 256 | let store = this._getStoreForModel(model); 257 | return store.create(model, state); 258 | } 259 | 260 | update(model, state) { 261 | let store = this._getStoreForModel(model); 262 | return store.update(model, state); 263 | } 264 | 265 | delete(model, state) { 266 | let store = this._getStoreForModel(model); 267 | return store.delete(model, state); 268 | } 269 | 270 | commit() { 271 | return this._promise.all(this._storesThatCommit.map((storeName) => { 272 | return this._stores[storeName].commit(); 273 | })); 274 | } 275 | 276 | dehydrate() { 277 | let state = {}; 278 | 279 | state.storeMap = JSON.stringify(this._modelToStoreMap); 280 | state.stores = {}; 281 | Object.keys(this._stores).forEach((store) => { 282 | state.stores[store] = this._stores[store].dehydrate(); 283 | }); 284 | 285 | return state; 286 | } 287 | 288 | rehydrate(state) { 289 | let storeMap = JSON.stringify(this._modelToStoreMap); 290 | if (state.storeMap !== storeMap) { 291 | // debug('Caution: restoring state from a different instance has undefined behavior'); 292 | } 293 | 294 | Object.keys(state.stores).forEach((store) => { 295 | this._stores[store].rehydrate(state.stores[store]); 296 | }); 297 | } 298 | } 299 | 300 | export default Elide; 301 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /spec/elide.spec.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | ********************************************************************************/ 6 | /* jshint expr: true */ 7 | 'use strict'; 8 | 9 | var chai = require('chai'); 10 | var expect = chai.expect; 11 | 12 | var ES6Promise = require('es6-promise').Promise; 13 | var ELIDE = require('../lib/elide'); 14 | var Elide = ELIDE.default; 15 | var Query = require('../lib/query'); 16 | 17 | describe('Elide', function() { 18 | var schema = { 19 | stores: { 20 | store1: { 21 | type: 'memory', 22 | ttl: 60000 23 | } 24 | }, 25 | models: { 26 | model1: { 27 | meta: { 28 | store: 'store1', 29 | isRootObject: true 30 | }, 31 | name: 'string' 32 | } 33 | } 34 | }; 35 | 36 | var options = { 37 | promise: ES6Promise 38 | }; 39 | 40 | it('should NOT throw any errors when configured correctly', function() { 41 | expect(function() { 42 | new Elide(schema, options); 43 | }).not.to.throw(); 44 | }); 45 | 46 | describe('configuration', function() { 47 | it('should throw an error with no configuration', function() { 48 | expect(function() { 49 | new Elide(schema); 50 | }).to.throw(ELIDE.ERROR_INVALID_OPTIONS); 51 | }); 52 | 53 | it('should throw an error without a promise option', function() { 54 | expect(function() { 55 | new Elide(schema, { 56 | dummyOption: 'dummyOption' 57 | }); 58 | }).to.throw(ELIDE.ERROR_INVALID_PROMISE); 59 | }); 60 | 61 | it('should throw an error without a null promise option', function() { 62 | expect(function() { 63 | new Elide(schema, { 64 | promise: null 65 | }); 66 | }).to.throw(ELIDE.ERROR_INVALID_PROMISE); 67 | }); 68 | }); 69 | 70 | describe('methods', function() { 71 | var elide; 72 | beforeEach(function() { 73 | elide = new Elide(schema, options); 74 | }); 75 | 76 | it('should have #find', function() { 77 | expect(elide.find).to.be.a('function'); 78 | expect(elide.find('model1', 1)).to.be.an.instanceof(Query); 79 | }); 80 | 81 | it('should have #create', function(done) { 82 | expect(elide.create).to.be.a('function'); 83 | elide.create('model1', {name: 'foo'}).then(function(model) { 84 | expect(model).to.have.a.property('name', 'foo'); 85 | expect(model).to.have.a.property('id'); 86 | done(); 87 | }, done); 88 | }); 89 | 90 | it('should have #update', function(done) { 91 | expect(elide.update).to.be.a('function'); 92 | var model1 = {name: 'foo'}; 93 | elide.create('model1', model1).then(function(model) { 94 | model1 = model; 95 | return elide.update('model1', {id: model1.id, name: 'bar'}); 96 | 97 | }).then(function(model) { 98 | expect(model).to.have.a.property('name', 'bar'); 99 | expect(model).to.have.a.property('id', model1.id); 100 | 101 | done(); 102 | }).catch(done); 103 | }); 104 | 105 | it('should have #delete', function(done) { 106 | expect(elide.delete).to.be.a('function'); 107 | 108 | var model1 = {name: 'foo'}; 109 | elide.create('model1', model1).then(function(model) { 110 | model1 = model; 111 | return elide.delete('model1', {id: model1.id}); 112 | 113 | }).then(done).catch(done); 114 | }); 115 | 116 | it('should have #commit', function() { 117 | expect(elide.commit).to.be.a('function'); 118 | }); 119 | }); 120 | 121 | describe('models', function() { 122 | var stores = { 123 | store1: { 124 | type: 'memory' 125 | } 126 | }; 127 | var options = { 128 | promise: ES6Promise 129 | }; 130 | 131 | it('should require the schema to have a models section', function() { 132 | expect(function() { 133 | new Elide({ 134 | stores: stores 135 | }, options); 136 | }).to.throw(ELIDE.ERROR_INVALID_MODELS); 137 | }); 138 | 139 | it('should require the models section to not be empty', function() { 140 | expect(function() { 141 | new Elide({ 142 | stores: stores, 143 | models: {} 144 | }, options); 145 | }).to.throw(ELIDE.ERROR_NUM_MODELS); 146 | }); 147 | 148 | describe('meta', function() { 149 | it('should require all models have a store', function() { 150 | // has store 151 | expect(function() { 152 | new Elide({ 153 | stores: stores, 154 | models: { 155 | model1: {} 156 | } 157 | }, options); 158 | }).to.throw(ELIDE.ERROR_BAD_STORE.replace('${modelName}', 'model1')); 159 | 160 | // has invalid store 161 | expect(function() { 162 | new Elide({ 163 | stores: stores, 164 | models: { 165 | model1: { 166 | meta: { 167 | store: 'store2' 168 | } 169 | } 170 | } 171 | }, options); 172 | }).to.throw(ELIDE.ERROR_BAD_STORE.replace('${modelName}', 'model1')); 173 | }); 174 | }); 175 | 176 | describe('relationships', function() { 177 | var meta = { 178 | store: 'store1', 179 | isRootObject: true 180 | }; 181 | var model2 = { 182 | meta: meta 183 | }; 184 | 185 | it('should require all links to specify a valid type', function() { 186 | var schema = { 187 | stores: stores, 188 | models: { 189 | model1: { 190 | meta: meta, 191 | links: { 192 | link1: { 193 | model: 'model2' 194 | } 195 | } 196 | }, 197 | model2: model2 198 | } 199 | }; 200 | 201 | expect(function() { 202 | new Elide(schema, options); 203 | }).to.throw(ELIDE.ERROR_NO_LINK_TYPE.replace('${modelName}', 'model1')); 204 | 205 | schema.models.model1.links.link1.type = 'oneToOne'; 206 | expect(function() { 207 | new Elide(schema, options); 208 | }).to.throw(ELIDE.ERROR_BAD_LINK_TYPE.replace('${linkType}', 'oneToOne')); 209 | }); 210 | 211 | it('should require all links to specify a valid model', function() { 212 | var schema = { 213 | stores: stores, 214 | models: { 215 | model1: { 216 | meta: meta, 217 | links: { 218 | link1: { 219 | type: 'hasOne' 220 | } 221 | } 222 | }, 223 | model2: model2 224 | } 225 | }; 226 | 227 | expect(function() { 228 | new Elide(schema, options); 229 | }).to.throw(ELIDE.ERROR_NO_LINK_MODEL.replace('${modelName}', 'model1')); 230 | 231 | schema.models.model1.links.link1.model = 'model3'; 232 | expect(function() { 233 | new Elide(schema, options); 234 | }).to.throw(ELIDE.ERROR_BAD_LINK_MODEL.replace('${modelName}', 'model1')); 235 | 236 | schema.models.model1.links.link1.model = 'model2'; 237 | expect(function() { 238 | new Elide(schema, options); 239 | }).not.to.throw(); 240 | }); 241 | 242 | it('should require no link overwrites a property on the model'); 243 | 244 | it('should require all models be rootable', function() { 245 | var schema = { 246 | stores: stores, 247 | models: { 248 | model1: { 249 | meta: { 250 | store: 'store1', 251 | isRootObject: true 252 | }, 253 | links: { 254 | child: { 255 | model: 'model2', 256 | type: 'hasOne' 257 | } 258 | } 259 | }, 260 | model2: { 261 | meta: { 262 | store: 'store1' 263 | } 264 | } 265 | } 266 | }; 267 | 268 | expect(function() { 269 | new Elide(schema, options); 270 | }).not.to.throw(); 271 | 272 | schema.models.model1.meta.isRootObject = false; 273 | expect(function() { 274 | new Elide(schema, options); 275 | }).to.throw(ELIDE.ERROR_DANGLING_MODEL.replace('${modelName}', 'model1')); 276 | 277 | schema.models.model1.meta.isRootObject = true; 278 | schema.models.model3 = { 279 | meta: { 280 | store: 'store1' 281 | } 282 | }; 283 | expect(function() { 284 | new Elide(schema, options); 285 | }).to.throw(ELIDE.ERROR_DANGLING_MODEL.replace('${modelName}', 'model3')); 286 | }); 287 | }); 288 | 289 | }); 290 | 291 | describe('stores', function() { 292 | var minModels = { 293 | model1: { 294 | meta: { 295 | store: 'store1', 296 | isRootObject: true 297 | }, 298 | links: {} 299 | } 300 | }; 301 | var schema = { 302 | stores: { 303 | store1: { 304 | type: 'memory', 305 | upstream: 'store2', 306 | ttl: 60000 307 | }, 308 | store2: { 309 | type: 'jsonapi', 310 | upstream: undefined, 311 | baseURL: 'http://foo.bar.com' 312 | } 313 | }, 314 | models: minModels 315 | }; 316 | 317 | var options = { 318 | promise: ES6Promise 319 | }; 320 | 321 | it('should require schema to have a stores section', function() { 322 | expect(function() { 323 | new Elide({}, options); 324 | }).to.throw(Elide.ERROR_NUM_STORES); 325 | 326 | ['stores', [], 2, null, undefined].map(function(invalidStores) { 327 | expect(function() { 328 | new Elide({ 329 | stores: invalidStores 330 | }, options); 331 | }).to.throw(Elide.ERROR_INVALID_STORES); 332 | }); 333 | }); 334 | 335 | it('should require stores section not to be empty', function() { 336 | expect(function() { 337 | new Elide({ 338 | stores: {}, 339 | models: minModels 340 | }, options); 341 | }).to.throw(ELIDE.ERROR_NUM_STORES); 342 | }); 343 | 344 | it('should reject stores of invalid type', function() { 345 | expect(function() { 346 | new Elide({ 347 | stores: { 348 | store1: { 349 | type: 'foo', 350 | ttl: 99, 351 | baseURL: 'ssh://foo@stuf.bar' 352 | } 353 | }, 354 | models: minModels 355 | }, options); 356 | // jscs:disable maximumLineLength 357 | }).to.throw(ELIDE.ERROR_BAD_STORE_TYPE.replace('${storeType}', 'foo')); 358 | // jscs:enable maximumLineLength 359 | }); 360 | 361 | it('should reject stores with invalid upstream store', function() { 362 | expect(function() { 363 | new Elide({ 364 | stores: { 365 | store1: { 366 | type: 'memory', 367 | upstream: 'store2' 368 | }, 369 | }, 370 | models: minModels 371 | }, options); 372 | // jscs:disable maximumLineLength 373 | }).to.throw(ELIDE.ERROR_UNKNOWN_UPSTREAM_STORE.replace(/\$\{storeName\}/g, 'store2')); 374 | // jscs:enable maximumLineLength 375 | }); 376 | 377 | it('should set ttl correctly', function() { 378 | var elide = new Elide(schema, options); 379 | 380 | expect(elide._stores.store1._ttl).to.be.equal(60000); 381 | expect(elide._stores.store2._ttl).to.be.undefined; 382 | }); 383 | 384 | it('should set baseURL correctly', function() { 385 | var elide = new Elide(schema, options); 386 | 387 | expect(elide._stores.store1._baseURL).to.be.undefined; 388 | expect(elide._stores.store2._baseURL).to.be.equal('http://foo.bar.com'); 389 | }); 390 | 391 | it('should set upstream stores correctly', function() { 392 | var elide = new Elide(schema, options); 393 | expect(elide._stores.store1._upstream).to.equal(elide._stores.store2); 394 | }); 395 | 396 | it.skip('should set models correctly', function() { 397 | var elide = new Elide(schema, options); 398 | expect(elide._stores.store1._models).to.deep.equal(elide._models); 399 | }); 400 | }); 401 | 402 | }); 403 | -------------------------------------------------------------------------------- /mocks/books_and_authors.yaml: -------------------------------------------------------------------------------- 1 | - request: 2 | url: ^/$ 3 | method: GET 4 | response: 5 | - status: 200 6 | file: mocks/index.html 7 | 8 | - request: 9 | url: ^/elide.js$ 10 | method: GET 11 | response: 12 | - status: 200 13 | file: build/web/elide.js 14 | 15 | - request: 16 | url: ^/book/?$ 17 | query: 18 | fields[book]: title,language,genre,author 19 | filter[book.genre][in]: Literary Fiction 20 | method: GET 21 | response: 22 | - status: 200 23 | headers: 24 | content-type: application/vnd.api+json 25 | body: > 26 | { 27 | "data": [ 28 | { 29 | "type": "book", 30 | "id": "1", 31 | "attributes": { 32 | "genre": "Literary Fiction", 33 | "title": "The Old Man and the Sea" 34 | } 35 | }, 36 | { 37 | "type": "book", 38 | "id": "2", 39 | "attributes": { 40 | "genre": "Literary Fiction", 41 | "title": "For Whom the Bell Tolls" 42 | } 43 | } 44 | ] 45 | } 46 | 47 | - request: 48 | url: ^/book/?$ 49 | query: 50 | fields[book]: title,language,genre,author 51 | filter[book.title][in]: Enders Game 52 | method: GET 53 | response: 54 | - status: 200 55 | headers: 56 | content-type: application/vnd.api+json 57 | body: > 58 | { 59 | "data": [ 60 | { 61 | "type": "book", 62 | "id": "3", 63 | "attributes": { 64 | "genre": "Science Fiction", 65 | "title": "Enders Game" 66 | } 67 | } 68 | ] 69 | } 70 | 71 | - request: 72 | url: ^/book/?$ 73 | query: 74 | include: authors 75 | method: GET 76 | response: 77 | - status: 200 78 | headers: 79 | content-type: application/vnd.api+json 80 | body: > 81 | { 82 | "data": [ 83 | { 84 | "type": "book", 85 | "id": "1", 86 | "attributes": { 87 | "genre": "Literary Fiction", 88 | "language": "English", 89 | "title": "The Old Man and the Sea" 90 | }, 91 | "relationships": { 92 | "authors": { 93 | "data": [ 94 | { 95 | "type": "author", 96 | "id": "1" 97 | } 98 | ] 99 | } 100 | } 101 | }, 102 | { 103 | "type": "book", 104 | "id": "2", 105 | "attributes": { 106 | "genre": "Literary Fiction", 107 | "language": "English", 108 | "title": "For Whom the Bell Tolls" 109 | }, 110 | "relationships": { 111 | "authors": { 112 | "data": [ 113 | { 114 | "type": "author", 115 | "id": "1" 116 | } 117 | ] 118 | } 119 | } 120 | }, 121 | { 122 | "type": "book", 123 | "id": "3", 124 | "attributes": { 125 | "genre": "Science Fiction", 126 | "language": "English", 127 | "title": "Enders Game" 128 | }, 129 | "relationships": { 130 | "authors": { 131 | "data": [ 132 | { 133 | "type": "author", 134 | "id": "2" 135 | } 136 | ] 137 | } 138 | } 139 | } 140 | ], 141 | "included": [ 142 | { 143 | "type": "author", 144 | "id": "1", 145 | "attributes": { 146 | "name": "Ernest Hemingway" 147 | }, 148 | "relationships": { 149 | "books": { 150 | "data": [ 151 | { 152 | "type": "book", 153 | "id": "1" 154 | }, 155 | { 156 | "type": "book", 157 | "id": "2" 158 | } 159 | ] 160 | } 161 | } 162 | }, 163 | { 164 | "type": "author", 165 | "id": "2", 166 | "attributes": { 167 | "name": "Orson Scott Card" 168 | }, 169 | "relationships": { 170 | "books": { 171 | "data": [ 172 | { 173 | "type": "book", 174 | "id": "3" 175 | } 176 | ] 177 | } 178 | } 179 | } 180 | ] 181 | } 182 | 183 | - request: 184 | url: ^/book/?$ 185 | query: 186 | include: authors 187 | fields[book]: title,genre 188 | fields[author]: name 189 | method: GET 190 | response: 191 | - status: 200 192 | headers: 193 | content-type: application/vnd.api+json 194 | body: > 195 | { 196 | "data": [ 197 | { 198 | "type": "book", 199 | "id": "1", 200 | "attributes": { 201 | "genre": "Literary Fiction", 202 | "title": "The Old Man and the Sea" 203 | } 204 | }, 205 | { 206 | "type": "book", 207 | "id": "2", 208 | "attributes": { 209 | "genre": "Literary Fiction", 210 | "title": "For Whom the Bell Tolls" 211 | } 212 | }, 213 | { 214 | "type": "book", 215 | "id": "3", 216 | "attributes": { 217 | "genre": "Science Fiction", 218 | "title": "Enders Game" 219 | } 220 | } 221 | ], 222 | "included": [ 223 | { 224 | "type": "author", 225 | "id": "1", 226 | "attributes": { 227 | "name": "Ernest Hemingway" 228 | } 229 | }, 230 | { 231 | "type": "author", 232 | "id": "2", 233 | "attributes": { 234 | "name": "Orson Scott Card" 235 | } 236 | } 237 | ] 238 | } 239 | 240 | - request: 241 | url: ^/book/?$ 242 | query: 243 | fields[book]: title,genre 244 | method: GET 245 | response: 246 | - status: 200 247 | headers: 248 | content-type: application/vnd.api+json 249 | body: > 250 | { 251 | "data": [ 252 | { 253 | "type": "book", 254 | "id": "1", 255 | "attributes": { 256 | "genre": "Literary Fiction", 257 | "title": "The Old Man and the Sea" 258 | } 259 | }, 260 | { 261 | "type": "book", 262 | "id": "2", 263 | "attributes": { 264 | "genre": "Literary Fiction", 265 | "title": "For Whom the Bell Tolls" 266 | } 267 | }, 268 | { 269 | "type": "book", 270 | "id": "3", 271 | "attributes": { 272 | "genre": "Science Fiction", 273 | "title": "Enders Game" 274 | } 275 | } 276 | ] 277 | } 278 | 279 | - request: 280 | url: ^/book/?$ 281 | query: 282 | include: authors 283 | fields[book]: title,genre 284 | method: GET 285 | response: 286 | - status: 200 287 | headers: 288 | content-type: application/vnd.api+json 289 | body: > 290 | { 291 | "data": [ 292 | { 293 | "type": "book", 294 | "id": "1", 295 | "attributes": { 296 | "genre": "Literary Fiction", 297 | "title": "The Old Man and the Sea" 298 | } 299 | }, 300 | { 301 | "type": "book", 302 | "id": "2", 303 | "attributes": { 304 | "genre": "Literary Fiction", 305 | "title": "For Whom the Bell Tolls" 306 | } 307 | }, 308 | { 309 | "type": "book", 310 | "id": "3", 311 | "attributes": { 312 | "genre": "Science Fiction", 313 | "title": "Enders Game" 314 | } 315 | } 316 | ], 317 | "included": [ 318 | { 319 | "type": "author", 320 | "id": "1" 321 | }, 322 | { 323 | "type": "author", 324 | "id": "2" 325 | } 326 | ] 327 | } 328 | 329 | - request: 330 | url: ^/book/1/?$ 331 | query: 332 | fields[book]: title,genre 333 | method: GET 334 | response: 335 | - status: 200 336 | headers: 337 | content-type: application/vnd.api+json 338 | body: > 339 | { 340 | "data": { 341 | "type": "book", 342 | "id": "1", 343 | "attributes": { 344 | "genre": "Literary Fiction", 345 | "title": "The Old Man and the Sea" 346 | } 347 | } 348 | } 349 | 350 | - request: 351 | url: ^/book/1/?$ 352 | method: GET 353 | response: 354 | - status: 200 355 | headers: 356 | content-type: application/vnd.api+json 357 | body: > 358 | { 359 | "data": { 360 | "type": "book", 361 | "id": "1", 362 | "attributes": { 363 | "genre": "Literary Fiction", 364 | "language": "English", 365 | "title": "The Old Man and the Sea" 366 | }, 367 | "relationships": { 368 | "authors": { 369 | "data": [ 370 | { 371 | "type": "author", 372 | "id": "1" 373 | } 374 | ] 375 | } 376 | } 377 | } 378 | } 379 | 380 | - request: 381 | url: ^/book/2/?$ 382 | method: GET 383 | response: 384 | - status: 200 385 | headers: 386 | content-type: application/vnd.api+json 387 | body: > 388 | { 389 | "data": { 390 | "type": "book", 391 | "id": "2", 392 | "attributes": { 393 | "genre": "Literary Fiction", 394 | "language": "English", 395 | "title": "For Whom the Bell Tolls" 396 | }, 397 | "relationships": { 398 | "authors": { 399 | "data": [ 400 | { 401 | "type": "author", 402 | "id": "1" 403 | } 404 | ] 405 | } 406 | } 407 | } 408 | } 409 | 410 | - request: 411 | url: ^/book/3/?$ 412 | method: GET 413 | response: 414 | - status: 200 415 | headers: 416 | content-type: application/vnd.api+json 417 | body: > 418 | { 419 | "data": { 420 | "type": "book", 421 | "id": "3", 422 | "attributes": { 423 | "genre": "Science Fiction", 424 | "language": "English", 425 | "title": "Enders Game" 426 | }, 427 | "relationships": { 428 | "authors": { 429 | "data": [ 430 | { 431 | "type": "author", 432 | "id": "2" 433 | } 434 | ] 435 | } 436 | } 437 | } 438 | } 439 | 440 | - request: 441 | url: ^/book/?$ 442 | method: GET 443 | response: 444 | - status: 200 445 | headers: 446 | content-type: application/vnd.api+json 447 | body: > 448 | { 449 | "data": [ 450 | { 451 | "type": "book", 452 | "id": "1", 453 | "attributes": { 454 | "genre": "Literary Fiction", 455 | "language": "English", 456 | "title": "The Old Man and the Sea" 457 | }, 458 | "relationships": { 459 | "authors": { 460 | "data": [ 461 | { 462 | "type": "author", 463 | "id": "1" 464 | } 465 | ] 466 | } 467 | } 468 | }, 469 | { 470 | "type": "book", 471 | "id": "2", 472 | "attributes": { 473 | "genre": "Literary Fiction", 474 | "language": "English", 475 | "title": "For Whom the Bell Tolls" 476 | }, 477 | "relationships": { 478 | "authors": { 479 | "data": [ 480 | { 481 | "type": "author", 482 | "id": "1" 483 | } 484 | ] 485 | } 486 | } 487 | }, 488 | { 489 | "type": "book", 490 | "id": "3", 491 | "attributes": { 492 | "genre": "Science Fiction", 493 | "language": "English", 494 | "title": "Enders Game" 495 | }, 496 | "relationships": { 497 | "authors": { 498 | "data": [ 499 | { 500 | "type": "author", 501 | "id": "2" 502 | } 503 | ] 504 | } 505 | } 506 | } 507 | ] 508 | } 509 | 510 | - request: 511 | url: ^/author/1/?$ 512 | method: GET 513 | response: 514 | - status: 200 515 | headers: 516 | content-type: application/vnd.api+json 517 | body: > 518 | { 519 | "data": { 520 | "type": "author", 521 | "id": "1", 522 | "attributes": { 523 | "name": "Ernest Hemingway" 524 | }, 525 | "relationships": { 526 | "books": { 527 | "data": [ 528 | { 529 | "type": "book", 530 | "id": "1" 531 | }, 532 | { 533 | "type": "book", 534 | "id": "2" 535 | } 536 | ] 537 | } 538 | } 539 | } 540 | } 541 | 542 | - request: 543 | url: ^/author/2/?$ 544 | method: GET 545 | response: 546 | - status: 200 547 | headers: 548 | content-type: application/vnd.api+json 549 | body: > 550 | { 551 | "data": { 552 | "type": "author", 553 | "id": "2", 554 | "attributes": { 555 | "name": "Orson Scott Card" 556 | }, 557 | "relationships": { 558 | "books": { 559 | "data": [ 560 | { 561 | "type": "book", 562 | "id": "3" 563 | } 564 | ] 565 | } 566 | } 567 | } 568 | } 569 | 570 | - request: 571 | url: ^/author/?$ 572 | method: GET 573 | response: 574 | - status: 200 575 | headers: 576 | content-type: application/vnd.api+json 577 | body: > 578 | { 579 | "data": [ 580 | { 581 | "type": "author", 582 | "id": "1", 583 | "attributes": { 584 | "name": "Ernest Hemingway" 585 | }, 586 | "relationships": { 587 | "books": { 588 | "data": [ 589 | { 590 | "type": "book", 591 | "id": "1" 592 | }, 593 | { 594 | "type": "book", 595 | "id": "2" 596 | } 597 | ] 598 | } 599 | } 600 | }, 601 | { 602 | "type": "author", 603 | "id": "2", 604 | "attributes": { 605 | "name": "Orson Scott Card" 606 | }, 607 | "relationships": { 608 | "books": { 609 | "data": [ 610 | { 611 | "type": "book", 612 | "id": "3" 613 | } 614 | ] 615 | } 616 | } 617 | } 618 | ] 619 | } 620 | -------------------------------------------------------------------------------- /spec/stores/jsonapidatastore.spec.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations 14 | * under the License. 15 | *********************************************************************************/ 16 | /* jshint expr: true */ 17 | // jscs:disable maximumLineLength 18 | 'use strict'; 19 | 20 | var chai = require('chai'); 21 | chai.use(require('chai-as-promised')); 22 | var expect = chai.expect; 23 | var patch = require('fast-json-patch'); 24 | var uuid = require('uuid'); 25 | 26 | var JSONAPIDATASTORE = require('../../lib/datastores/jsonapidatastore'); 27 | var JsonApiDatastore = JSONAPIDATASTORE.default; 28 | var ES6Promise = require('es6-promise').Promise; 29 | var Query = require('../../lib/query'); 30 | 31 | describe('JsonApiDatastore', function() { 32 | var baseURL = 'http://localhost:1337/api'; 33 | var simpleModels = { 34 | cat: { 35 | color: 'string', 36 | age: 'number', 37 | meta: { 38 | isRootObject: true 39 | }, 40 | links: {} 41 | }, 42 | dog: { 43 | breed: 'string', 44 | age: 'number', 45 | meta: { 46 | isRootObject: true 47 | }, 48 | links: {} 49 | } 50 | }; 51 | var modelsWithLinks = { 52 | person: { 53 | name: 'string', 54 | links: { 55 | pets: { 56 | model: 'pet', 57 | type: 'hasMany', 58 | inverse: 'owner' 59 | }, 60 | bike: { 61 | model: 'bicycle', 62 | type: 'hasOne', 63 | inverse: 'owner' 64 | } 65 | }, 66 | meta: { 67 | isRootObject: true 68 | } 69 | }, 70 | bicycle: { 71 | maker: 'string', 72 | model: 'string', 73 | meta: { 74 | isRootObject: false 75 | }, 76 | links: {} 77 | }, 78 | pet: { 79 | type: 'string', 80 | name: 'string', 81 | age: 'number', 82 | links: { 83 | flees: { 84 | model: 'flee', 85 | type: 'hasMany' 86 | } 87 | }, 88 | meta: {} 89 | }, 90 | flee: { 91 | age: 'number', 92 | meta: {}, 93 | links: {} 94 | } 95 | }; 96 | 97 | var booksAndAuthorsURL = 'http://localhost:8882'; 98 | var booksAndAuthorsModels = { 99 | book: { 100 | meta: { 101 | store: 'jsonapi', 102 | isRootObject: true 103 | }, 104 | 105 | title: 'string', 106 | language: 'string', 107 | genre: 'string', 108 | 109 | links: { 110 | author: { 111 | model: 'author', 112 | type: 'hasMany', 113 | inverse: 'book' 114 | } 115 | } 116 | }, 117 | author: { 118 | meta: { 119 | store: 'jsonapi', 120 | isRootObject: true 121 | }, 122 | 123 | name: 'string', 124 | 125 | links: {} 126 | } 127 | }; 128 | 129 | var booksAndAuthorsModelsNoGenre = { 130 | book: { 131 | meta: { 132 | store: 'jsonapi', 133 | isRootObject: true 134 | }, 135 | 136 | title: 'string', 137 | language: 'string', 138 | 139 | links: { 140 | author: { 141 | model: 'author', 142 | type: 'hasMany', 143 | inverse: 'book' 144 | } 145 | } 146 | }, 147 | author: { 148 | meta: { 149 | store: 'jsonapi', 150 | isRootObject: true 151 | }, 152 | 153 | name: 'string', 154 | 155 | links: {} 156 | } 157 | }; 158 | 159 | describe('initalize', function() { 160 | it('should initalize cleanly', function() { 161 | expect(function() { 162 | new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 163 | }).not.to.throw(); 164 | }); 165 | }); 166 | 167 | describe('#find', function() { 168 | var cat1 = {id: '1', color: 'black', age: 12}; 169 | var cat2 = {id: '2', color: 'grey', age: 2}; 170 | var cat3 = {id: '3', color: 'white', age: 5}; 171 | var person1 = {id: '1', name: 'John', bike: null, pets: ['1', '2']}; 172 | var pet1 = {id: '1', type: 'dog', name: 'spot', age: 4, owner: '1', flees: []}; 173 | var pet2 = {id: '2', type: 'cat', name: 'blink', age: 2, owner: '1', flees: []}; 174 | var flee1 = {id: '1', age: 12}; 175 | var flee2 = {id: '2', age: 2}; 176 | var book1 = {id: '1', genre: 'Literary Fiction', title: 'The Old Man and the Sea', author: []}; 177 | var book2 = {id: '2', genre: 'Literary Fiction', title: 'For Whom the Bell Tolls', author: []}; 178 | var book3 = {id: '3', genre: 'Science Fiction', title: 'Enders Game', author: []}; 179 | 180 | it('should reject unknown models', function() { 181 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, simpleModels); 182 | var q = new Query(store, 'catdog', 1); 183 | return expect(store.find(q)) 184 | .to.eventually.be.rejectedWith(JSONAPIDATASTORE.ERROR_UNKNOWN_MODEL.replace('${model}', 'catdog')); 185 | }); 186 | 187 | it('should fail if the resource does not exist', function() { 188 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, simpleModels); 189 | 190 | var q = new Query(store, 'cat', 99); 191 | return expect(store.find(q)) 192 | .to.eventually.be.rejected; 193 | }); 194 | 195 | it('should be able to fetch a single root level model', function(done) { 196 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 197 | 198 | var q = new Query(store, 'person', 1); 199 | store.find(q).then(function(person) { 200 | expect(person.data).to.deep.equal(person1); 201 | done(); 202 | }).catch(done); 203 | }); 204 | 205 | it('should be able to fetch a single, fully rooted, nested model', function(done) { 206 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 207 | 208 | var q = new Query(store, 'person', 1).find('pets', 1); 209 | store.find(q).then(function(pet) { 210 | expect(pet.data).to.deep.equal(pet1); 211 | done(); 212 | }).catch(done); 213 | }); 214 | 215 | it('should be able to fetch a single nested model that it can root', function(done) { 216 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 217 | 218 | var q = new Query(store, 'person', 1); 219 | store.find(q).then(function(foundPerson) { 220 | expect(foundPerson.data).to.deep.equal(person1); 221 | 222 | var q = new Query(store, 'pet', 1); 223 | return store.find(q); 224 | 225 | }).then(function(foundPet) { 226 | expect(foundPet.data).to.deep.equal(pet1); 227 | 228 | done(); 229 | }).catch(done); 230 | }); 231 | 232 | it('should be able to fetch a collection of top level models', function(done) { 233 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, simpleModels); 234 | 235 | var q = new Query(store, 'cat'); 236 | store.find(q).then(function(pets) { 237 | expect(pets.data).to.contain.deep.members([cat1, cat2, cat3]); 238 | done(); 239 | }).catch(done); 240 | }); 241 | 242 | it('should be able to fetch a collection of fully rooted nested models', function(done) { 243 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 244 | 245 | var q = new Query(store, 'person', 1).find('pets'); 246 | store.find(q).then(function(pets) { 247 | expect(pets.data).to.contain.deep.members([pet1, pet2]); 248 | done(); 249 | }).catch(done); 250 | }); 251 | 252 | it('should be able to fetch a collection of nested models on a model it can root', function(done) { 253 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 254 | 255 | var q = new Query(store, 'person', 1); 256 | store.find(q).then(function(foundPerson) { 257 | var q = new Query(store, 'pet', 1).find('flees'); 258 | return store.find(q); 259 | 260 | }).then(function(foundFlees) { 261 | expect(foundFlees.data).to.have.deep.members([flee1, flee2]); 262 | done(); 263 | 264 | }).catch(done); 265 | }); 266 | 267 | it('should reject a fetch for a collection of nested models it cannot root', function() { 268 | var store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 269 | 270 | var q = new Query(store, 'pet', 1).find('flees'); 271 | return expect(store.find(q)) 272 | .to.eventually.be.rejectedWith(JSONAPIDATASTORE.ERROR_CANNOT_ROOT_QUERY 273 | .replace('${model}', 'pet') 274 | .replace('${nextModel}', 'person') 275 | .replace('${id}', 1)); 276 | }); 277 | 278 | it('should only fetch title and genre and not language', function(done) { 279 | var store = new JsonApiDatastore(ES6Promise, undefined, booksAndAuthorsURL, booksAndAuthorsModels); 280 | 281 | var q = new Query(store, 'book', 1, {fields: {book: ['title', 'genre']}}); 282 | store.find(q).then(function(result) { 283 | expect(result.data).to.not.have.property('language'); 284 | expect(result.data).to.have.property('title'); 285 | expect(result.data).to.have.property('genre'); 286 | done(); 287 | }).catch(done); 288 | }); 289 | 290 | it('should only fetch title and language and not genre', function(done) { 291 | var store = new JsonApiDatastore(ES6Promise, undefined, booksAndAuthorsURL, booksAndAuthorsModelsNoGenre); 292 | 293 | var q = new Query(store, 'book', 1); 294 | store.find(q).then(function(result) { 295 | expect(result.data).to.not.have.property('genre'); 296 | expect(result.data).to.have.property('title'); 297 | expect(result.data).to.have.property('language'); 298 | done(); 299 | }).catch(done); 300 | }); 301 | 302 | it('should only fetch literary fiction books', function(done) { 303 | var store = new JsonApiDatastore(ES6Promise, undefined, booksAndAuthorsURL, booksAndAuthorsModels); 304 | 305 | var q = new Query(store, 'book', undefined, {filters: {book: [ {attribute: 'genre', operator: "in", value: "Literary Fiction" }]}}); 306 | store.find(q).then(function(result) { 307 | expect(result.data).to.deep.equal([book1, book2]) 308 | done(); 309 | }).catch(done); 310 | }); 311 | 312 | it('should only fetch title and genre from all books', function(done) { 313 | var store = new JsonApiDatastore(ES6Promise, undefined, booksAndAuthorsURL, booksAndAuthorsModels); 314 | 315 | var q = new Query(store, 'book', undefined, {fields: {book: ['title', 'genre']}}); 316 | store.find(q).then(function(books) { 317 | books.data.forEach(function(book) { 318 | expect(book).to.not.have.property('language'); 319 | expect(book).to.have.property('genre'); 320 | expect(book).to.have.property('title'); 321 | }); 322 | done(); 323 | }).catch(done); 324 | }); 325 | 326 | it('should only fetch enders game book', function(done) { 327 | var store = new JsonApiDatastore(ES6Promise, undefined, booksAndAuthorsURL, booksAndAuthorsModels); 328 | 329 | var q = new Query(store, 'book', undefined, {filters: {book: [ {attribute: 'title', operator: "in", value: "Enders Game"} ]}}); 330 | store.find(q).then(function(result) { 331 | expect(result.data).to.deep.equal([book3]); 332 | done(); 333 | }).catch(done); 334 | }); 335 | 336 | it('should fetch books and authors', function(done) { 337 | var store = new JsonApiDatastore(ES6Promise, undefined, booksAndAuthorsURL, booksAndAuthorsModels); 338 | 339 | var q = new Query(store, 'book', undefined, {include: ['authors']}); 340 | store.find(q).then(function(results) { 341 | var books = results['data']; 342 | var authorIds = []; 343 | var i; 344 | 345 | expect(books).to.be.not.empty; 346 | for (i = 0; i < books.length; i++) { 347 | authorIds.push(books[i]['id']); 348 | expect(books[i]).to.have.property('title'); 349 | } 350 | 351 | var authors = results['included']; 352 | expect(authors).to.be.not.empty; 353 | for (i = 0; i < authors.length; i++) { 354 | expect(authorIds).to.have.property(authors[i].name) 355 | } 356 | done(); 357 | }).catch(done); 358 | }); 359 | }); 360 | 361 | describe('#create', function() { 362 | var store; 363 | beforeEach(function() { 364 | store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 365 | }); 366 | 367 | it('should be able to create root level objects', function(done) { 368 | var person = {id: 1, name: 'John', bike: null, pets: []}; 369 | store.create('person', {id: 'some-uuid', name: 'John'}).then(function(result) { 370 | expect(result.data).to.deep.equal(person); 371 | done(); 372 | }).catch(done); 373 | }); 374 | 375 | it('should be able to create nested objects', function(done) { 376 | var pet = {id: 1, name: 'spot', type: 'dog', age: null, owner: 1, flees: []}; 377 | var q = new Query(store, 'person', 1); 378 | store.find(q).then(function(foundPerson) { 379 | return store.create('pet', {id: 'some-uuid', name: 'spot', type: 'dog', owner: 1}); 380 | 381 | }).then(function(createdPet) { 382 | expect(createdPet.data).to.deep.equal(pet); 383 | done(); 384 | 385 | }).catch(done); 386 | }); 387 | 388 | it('should fail if it cannot root the query', function() { 389 | var flee = {id: 'some-uuid', age: 4}; 390 | var apiObject = store._transformRequest('flee', flee); 391 | return expect(store.create('flee', flee)) 392 | .to.eventually.be.rejectedWith(JSONAPIDATASTORE.ERROR_CANNOT_ROOT_OBJECT 393 | .replace('${model}', 'flee') 394 | .replace('${modelState}', JSON.stringify(apiObject.data)) 395 | .replace('${parentModel}', 'pet')); 396 | }); 397 | 398 | it('should fail if the server responds with a 40x', function() { 399 | var failPerson = {id: 'some-uuid', name: 'FAIL'}; 400 | return expect(store.create('person', failPerson)) 401 | .to.eventually.be.rejectedWith(JSONAPIDATASTORE.ERROR_CANNOT_CREATE_OBJECT 402 | .replace('${model}', 'person') 403 | .replace('${modelState}', JSON.stringify(failPerson))); 404 | }); 405 | 406 | }); 407 | 408 | describe('#update', function() { 409 | var store; 410 | beforeEach(function() { 411 | store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 412 | var q = new Query(store, 'person', 1); 413 | return store.find(q); 414 | }); 415 | 416 | it('should be able to update root objects', function(done) { 417 | var person = {id: 1, name: 'Cortana', bike: null, pets: [1, 2]}; 418 | store.update('person', person).then(function(result) { 419 | expect(result.data).to.deep.equal(person); 420 | done(); 421 | }).catch(done); 422 | }); 423 | 424 | it('should be able to update nested objects', function(done) { 425 | var pet = {id: 1, name: 'rex', type: 'dog', owner: 1, age: null, flees: []}; 426 | store.update('pet', pet).then(function(result) { 427 | expect(result.data).to.deep.equal(pet); 428 | done(); 429 | }).catch(done); 430 | }); 431 | 432 | it('should fail if it cannot root the object', function() { 433 | var flee = {id: 1, age: 4}; 434 | return expect(store.update('flee', flee)) 435 | .to.be.rejectedWith(JSONAPIDATASTORE.ERROR_CANNOT_ROOT_OBJECT 436 | .replace('${model}', 'flee') 437 | .replace('${modelState}', JSON.stringify(flee)) 438 | .replace('${parentModel}', 'pet')); 439 | }); 440 | 441 | it('should fail if the server rejects the update', function() { 442 | var person = {id: 4}; 443 | return expect(store.update('person', person)) 444 | .to.be.rejectedWith(JSONAPIDATASTORE.ERROR_CANNOT_UPDATE_OBJECT 445 | .replace('${model}', 'person') 446 | .replace('${modelState}', JSON.stringify(person))); 447 | }); 448 | 449 | }); 450 | 451 | describe('#delete', function() { 452 | var store; 453 | beforeEach(function() { 454 | store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 455 | var q = new Query(store, 'person', 1); 456 | return store.find(q); 457 | }); 458 | 459 | it('should be able to delete root objects', function() { 460 | var person = {id: 1}; 461 | return expect(store.delete('person', person)) 462 | .to.be.fulfilled; 463 | }); 464 | 465 | it('should be able to delete nested objects', function() { 466 | var pet = {id: 1, owner: 1}; 467 | return expect(store.delete('pet', pet)) 468 | .to.be.fulfilled; 469 | }); 470 | 471 | it('should fail if it cannot root an object', function() { 472 | var pet = {id: 4}; 473 | return expect(store.delete('pet', pet)) 474 | .to.be.rejectedWith(JSONAPIDATASTORE.ERROR_CANNOT_ROOT_OBJECT 475 | .replace('${model}', 'pet') 476 | .replace('${modelState}', JSON.stringify(pet)) 477 | .replace('${parentModel}', 'person')); 478 | }); 479 | 480 | it('should fail if the server responds with a 40x', function() { 481 | var person = {id: 4}; 482 | return expect(store.delete('person', person)) 483 | .to.be.rejectedWith(JSONAPIDATASTORE.ERROR_CANNOT_DELETE_OBJECT 484 | .replace('${model}', 'person') 485 | .replace('${modelState}', JSON.stringify(person))); 486 | }); 487 | 488 | }); 489 | 490 | describe('#commit', function() { 491 | var store; 492 | var objects; 493 | var models; 494 | beforeEach(function() { 495 | store = new JsonApiDatastore(ES6Promise, undefined, baseURL, modelsWithLinks); 496 | models = { 497 | person: {}, 498 | pet: {}, 499 | bicycle: {}, 500 | flee: {} 501 | }; 502 | objects = { 503 | person: {}, 504 | pet: {}, 505 | bicycle: {}, 506 | flee: {} 507 | }; 508 | }); 509 | 510 | it('should have no return value when the server sends 204', function(done) { 511 | var id = uuid.v4(); 512 | var johnId = id; 513 | objects.person[id] = { 514 | id: id, 515 | name: 'John', 516 | age: 37, 517 | bike: null, 518 | pets: [] 519 | }; 520 | id = uuid.v4(); 521 | objects.person[id] = { 522 | id: id, 523 | name: 'Cortana', 524 | age: 4, 525 | bike: null, 526 | pets: [] 527 | }; 528 | 529 | id = uuid.v4(); 530 | objects.bicycle[id] = { 531 | id: id, 532 | make: 'Trek', 533 | model: 'Mountain', 534 | owner: johnId 535 | }; 536 | objects.person[johnId].bike = id; 537 | 538 | id = uuid.v4(); 539 | objects.bicycle[id] = { 540 | id: id, 541 | make: 'Felt', 542 | model: 'SR-93', 543 | owner: 1 544 | }; 545 | 546 | id = uuid.v4(); 547 | var petId = id; 548 | objects.pet[id] = { 549 | id: id, 550 | type: 'dog', 551 | owner: johnId, 552 | flees: [] 553 | }; 554 | objects.person[johnId].pets.push(id); 555 | 556 | id = uuid.v4(); 557 | objects.flee[id] = { 558 | id: id, 559 | age: 2 560 | }; 561 | objects.pet[petId].flees.push(id); 562 | id = uuid.v4(); 563 | objects.flee[id] = { 564 | id: id, 565 | age: 0 566 | }; 567 | objects.pet[petId].flees.push(id); 568 | 569 | var q = new Query(store, 'person', 1); 570 | store.find(q).then(function(person) { 571 | return store.commit(patch.compare(models, objects)); 572 | }).then(done).catch(done); 573 | }); 574 | 575 | it('should return a model object when the server sends 200', function(done) { 576 | var id = uuid.v4(); 577 | objects.person[id] = { 578 | id: id, 579 | name: 'John', 580 | age: 37, 581 | bike: null, 582 | pets: [] 583 | }; 584 | 585 | store.commit(patch.compare(models, objects)).then(function(objects) { 586 | expect(objects).to.deep.equal([ 587 | { 588 | type: 'person', 589 | oldId: id, 590 | data: { 591 | id: '1', 592 | name: 'John', 593 | pets: ['1', '2'], 594 | bike: null 595 | } 596 | } 597 | ]); 598 | done(); 599 | }).catch(done); 600 | }); 601 | }); 602 | 603 | }); 604 | -------------------------------------------------------------------------------- /lib/datastores/memorydatastore.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * Licensed under the Apache License, Version 2.0 (the "License"); 4 | * you may not use this file except in compliance with the License. 5 | ********************************************************************************/ 6 | 'use strict'; 7 | 8 | import uuid from 'uuid'; 9 | import jsonpatch from 'fast-json-patch'; 10 | 11 | import Datastore from './datastore'; 12 | import ChangeWatcher from '../helpers/change-watcher'; 13 | import clone from '../helpers/clone'; 14 | import debug from 'debug'; 15 | 16 | // jscs:disable maximumLineLength 17 | export let ERROR_UNKNOWN_MODEL = 'Unknown model "${model}" passed to #${method}.'; 18 | export let ERROR_NO_UPSTREAM = 'No upstream store to commit to.'; 19 | export let ERROR_NO_STATE_GIVEN = 'No state passed to #${method}'; 20 | export let ERROR_CANNOT_FIND_ON_PROP = '"${property}" not a linked property on "${lastModel}".'; 21 | export let ERROR_CANNOT_CREATE_WITH_ID = 'Newly created records must not specify an id.'; 22 | export let ERROR_CANNOT_UPDATE_WITHOUT_ID = 'You must specify an id in order to modify a record.'; 23 | export let ERROR_CANNOT_UPDATE_MISSING_RECORD = 'The "${model}" passed to #${method} does not exist.'; 24 | export let ERROR_CANNOT_LINK_TO_MISSING_RECORD = 'The "${model}":${id} passed to #${method} does not exist.'; 25 | // jscs:enable maximumLineLength 26 | 27 | let OWNS = '>'; 28 | let OWNED_BY = '<'; 29 | let HAS_ONE = 'hasOne'; 30 | let HAS_MANY = 'hasMany'; 31 | 32 | const log = debug('elide:memorystore'); 33 | 34 | class MemoryDatastore extends Datastore { 35 | constructor(Promise, ttl, baseURL, models) { 36 | super(Promise, ttl, baseURL, models); 37 | 38 | this._data = {}; 39 | this._meta = {}; 40 | this._ttlCache = {}; 41 | 42 | let annotatedModels = clone(this._models); 43 | Object.keys(annotatedModels).forEach((modelName) => { 44 | let model = annotatedModels[modelName]; 45 | this._data[modelName] = {}; 46 | this._meta[modelName] = {}; 47 | this._ttlCache[modelName] = {}; 48 | 49 | Object.keys(model.links).forEach(function(link) { 50 | let linkage = model.links[link]; 51 | 52 | annotatedModels[modelName][link] = linkage.type + OWNS + linkage.model; 53 | if (linkage.inverse) { 54 | annotatedModels[modelName][link] += '.' + linkage.inverse; 55 | annotatedModels[linkage.model][linkage.inverse] = 56 | linkage.type + OWNED_BY + modelName + '.' + link; 57 | } 58 | }); 59 | 60 | delete annotatedModels[modelName].links; 61 | }); 62 | 63 | this._models = annotatedModels; 64 | this._snapshot = clone(this._data); 65 | } 66 | 67 | /** 68 | * checks to see if the model that we're looking for exists in our schema 69 | * throws an error if the model isn't present 70 | * @param {string} model - the name of the model we're looking for 71 | * @param {string} method - the name of the method we're calling from 72 | */ 73 | _checkModel(model, method) { 74 | if (!this._models.hasOwnProperty(model)) { 75 | throw new Error(ERROR_UNKNOWN_MODEL 76 | .replace('${model}', model) 77 | .replace('${method}', method)); 78 | } 79 | } 80 | 81 | /** 82 | * get a model template 83 | * @param {string} model - the name of the model 84 | * @return {object} a copy of the model definition 85 | */ 86 | _getModelTemplate(model) { 87 | return clone(this._models[model]); 88 | } 89 | 90 | /** 91 | * get data from the store 92 | * @param {string} model - the name of the model 93 | * @param {number} id - the model's id (could be string in case of uuid) 94 | * @return {object} - a copy of the model 95 | */ 96 | _getData(model, id) { 97 | let ttlExpiry = (new Date()).getTime() - this._ttl; 98 | id = this._meta[model][id] || id; 99 | if (this._ttlCache[model][id] && this._ttlCache[model][id] < ttlExpiry) { 100 | return undefined; 101 | } 102 | return clone(this._data[model][id]); 103 | } 104 | 105 | /** 106 | * set data into the store 107 | * 108 | * @param {string} model - the name of the model 109 | * @param {number} instance - the instance data to set 110 | */ 111 | _setData(model, instance) { 112 | this._data[model][instance.id] = clone(instance); 113 | } 114 | 115 | /** 116 | * copy data returned from upstream into this store so we won't refetch 117 | * it if it gets queried again 118 | * 119 | * @param {Query} query - the query executed upstream 120 | * @param {Array} results - the results returned from the upstream datastore 121 | */ 122 | _setDataFromUpstreamQuery(query, results) { 123 | let model; 124 | 125 | if (results === undefined || results.length === 0) { 126 | return; 127 | } 128 | 129 | query._params.forEach((param) => { 130 | if (param.model) { 131 | model = param.model; 132 | 133 | } else { 134 | let modelTemplate = this._getModelTemplate(model); 135 | let linkDef = modelTemplate[param.field]; 136 | [ , , model, ] = this._getLinkAttrs(linkDef); 137 | } 138 | }); 139 | 140 | if (results instanceof Array) { 141 | for (let i = 0; i < results.length; i++) { 142 | let result = results[i]; 143 | this._data[model][result.id] = result; 144 | this._ttlCache[model][result.id] = (new Date()).getTime(); 145 | this._snapshot[model][result.id] = result; 146 | } 147 | 148 | } else { 149 | this._data[model][results.id] = results; 150 | this._ttlCache[model][results.id] = (new Date()).getTime(); 151 | this._snapshot[model][results.id] = results; 152 | } 153 | } 154 | 155 | /** 156 | * delete data from the store 157 | * @param {string} model - the name of the model 158 | * @param {number} id - the model's id (could be string in case of uuid) 159 | */ 160 | _deleteData(model, id) { 161 | id = this._meta[model][id] || id; 162 | delete this._data[model][id]; 163 | } 164 | 165 | /** 166 | * determine if the specified property is a link 167 | * @param {string} model - the name of the model 168 | * @param {string} prop - the property to check 169 | * @return {boolean} if the property is linked 170 | */ 171 | _isLinkedProp(model, prop) { 172 | if (prop === 'meta') { return; } 173 | return this._models[model][prop].search(/hasOne|hasMany/) !== -1; 174 | } 175 | 176 | /** 177 | * get various attribues of a linkage between two models 178 | * @return [type, direction, toModel, toProp] 179 | * type - what kind of link (hasOne|hasMany) 180 | * direction - OWNS or OWNED_BY 181 | * toModel - the name of a model 182 | * toProp - the name of the property on that model (may be `undefined`) 183 | */ 184 | _getLinkAttrs(linkDef) { 185 | let matches = linkDef.match(/(hasOne|hasMany)(<|>)(\w+)(\.\w+)?/); 186 | let [ , type, direction, otherModel, otherProp] = matches; 187 | if (otherProp) { 188 | otherProp = otherProp.substr(1); 189 | } 190 | 191 | return [type, direction, otherModel, otherProp]; 192 | } 193 | 194 | /** 195 | * check to ensure that the referenced object(s) exist 196 | * @param {string} model - the model we are verifying 197 | * @param {number|Array} value - an id or an array of ids 198 | * @param {string} method - the method checking these ids 199 | * @return [willReject, withReason] 200 | * willReject - false if all of the specified ids exist 201 | * withReason - the error if `willReject == true` 202 | */ 203 | _ensureReferencesExist(model, value, method) { 204 | let willReject = false; 205 | let withReason; 206 | 207 | if (!value) { 208 | return [willReject, withReason]; 209 | } 210 | 211 | if (value instanceof Array) { 212 | for (let i = 0; i < value.length; i++) { 213 | if (!this._getData(model, value[i])) { 214 | willReject = true; 215 | withReason = ERROR_CANNOT_LINK_TO_MISSING_RECORD 216 | .replace('${model}', model) 217 | .replace('${id}', value[i]) 218 | .replace('${method}', method); 219 | break; 220 | } 221 | } 222 | 223 | } else if (!this._getData(model, value)) { 224 | willReject = true; 225 | withReason = ERROR_CANNOT_LINK_TO_MISSING_RECORD 226 | .replace('${model}', model) 227 | .replace('${id}', value) 228 | .replace('${method}', method); 229 | } 230 | 231 | return [willReject, withReason]; 232 | } 233 | 234 | /** 235 | * copy the atomic properties from `state` to `instance` 236 | * @param {string} model - the type of `instance` 237 | * @param {object} instance - the object to modify 238 | * @param {object} state - the new values for atomic properties of `instance` 239 | */ 240 | _updateSimpleProps(model, instance, state) { 241 | let template = this._getModelTemplate(model); 242 | 243 | Object.keys(template).forEach((prop) => { 244 | if (this._isLinkedProp(model, prop) || prop === 'meta') { 245 | return; 246 | } 247 | if (instance.hasOwnProperty(prop) && !state.hasOwnProperty(prop)) { 248 | return; 249 | } 250 | 251 | instance[prop] = state[prop]; 252 | }); 253 | } 254 | 255 | /** 256 | * create the keys on `instance` with empty values so they can be iterated 257 | * @param {object} instance - the object to receive the property 258 | * @param {string} prop - the property 259 | * @param {string} type - the type of the property 260 | * @param {string} direction - if the object is the root or leaf of the relationship 261 | */ 262 | _setEmptyLinkedProperty(instance, prop, type, direction) { 263 | if (type === 'hasMany' && direction === OWNS) { 264 | instance[prop] = []; 265 | } else { 266 | instance[prop] = undefined; 267 | } 268 | } 269 | 270 | /** 271 | * add a leaf model to the root model. if the leaf is currently linked to a root we 272 | * will unlink it from it's current root 273 | * @param {string} rootModel - the name of the root model 274 | * @param {string} rootProp - the name of the property on the root 275 | * @param {Object} rootInstance - the instance of the root model to link 276 | * @param {string} leafModel - the name of the leaf model 277 | * @param {string} leafProp - the name of the property on the leaf model 278 | * @param {Object} leafInstance - the instance of the leaf model to link 279 | * @param {ChangeWatcher} watcher - the watcher which will gather changes to the involved objects 280 | */ 281 | // jscs:disable maximumLineLength 282 | _addSingleLeaf(rootModel, rootProp, rootInstance, leafModel, leafProp, leafInstance, watcher) { 283 | // jscs:enable maximumLineLength 284 | if (rootInstance[rootProp] !== undefined) { 285 | // we need to unlink the current value 286 | let curLeaf = this._getData(leafModel, rootInstance[rootProp]); 287 | watcher.watchModel(curLeaf, leafModel); 288 | 289 | this._removeSingleLeaf(rootModel, rootProp, rootInstance, 290 | leafModel, leafProp, curLeaf); 291 | } 292 | 293 | if (leafInstance === undefined) { 294 | rootInstance[rootProp] = undefined; 295 | return; 296 | } 297 | 298 | rootInstance[rootProp] = leafInstance.id; 299 | 300 | if (leafProp !== undefined) { 301 | // link the inverse 302 | if (leafInstance[leafProp]) { 303 | let curRoot = this._getData(rootModel, leafInstance[leafProp]); 304 | watcher.watchModel(curRoot, rootModel); 305 | 306 | this._removeSingleRoot(rootModel, rootProp, curRoot, 307 | leafModel, leafProp, leafInstance); 308 | } 309 | 310 | leafInstance[leafProp] = rootInstance.id; 311 | } 312 | } 313 | 314 | /** 315 | * add a root model to the leaf model. if the root is currently linked to a leaf we 316 | * will unlink it from it's current leaf 317 | * @param {string} rootModel - the name of the root model 318 | * @param {string} rootProp - the name of the property on the root 319 | * @param {Object} rootInstance - the instance of the root model to link 320 | * @param {string} leafModel - the name of the leaf model 321 | * @param {string} leafProp - the name of the property on the leaf model 322 | * @param {Object} leafInstance - the instance of the leaf model to link 323 | * @param {ChangeWatcher} watcher - the watcher which will gather changes to the involved objects 324 | */ 325 | // jscs:disable maximumLineLength 326 | _addSingleRoot(rootModel, rootProp, rootInstance, leafModel, leafProp, leafInstance, watcher) { 327 | // jscs:enable maximumLineLength 328 | if (leafInstance[leafProp] !== undefined) { 329 | let curRoot = this._getData(rootModel, leafInstance[leafProp]); 330 | watcher.watchModel(curRoot, rootModel); 331 | 332 | this._removeSingleRoot(rootModel, rootProp, curRoot, 333 | leafModel, leafProp, leafInstance); 334 | } 335 | 336 | if (rootInstance === undefined) { 337 | leafInstance[leafProp] = undefined; 338 | return; 339 | } 340 | 341 | leafInstance[leafProp] = rootInstance.id; 342 | 343 | if (rootInstance[rootProp] !== undefined) { 344 | // we need to unlink the current value 345 | let curLeaf = this._getData(leafModel, rootInstance[rootProp]); 346 | watcher.watchModel(curLeaf, leafModel); 347 | 348 | this._removeSingleLeaf(rootModel, rootProp, rootInstance, 349 | leafModel, leafProp, curLeaf); 350 | } 351 | 352 | rootInstance[rootProp] = leafInstance.id; 353 | } 354 | 355 | /** 356 | * remove a leaf from its current root 357 | * @param {string} rootModel - the name of the root model 358 | * @param {string} rootProp - the name of the property on the root 359 | * @param {Object} rootInstance - the instance of the root model to link 360 | * @param {string} leafModel - the name of the leaf model 361 | * @param {string} leafProp - the name of the property on the leaf model 362 | * @param {Object} leafInstance - the instance of the leaf model to link 363 | * @param {ChangeWatcher} watcher - the watcher which will gather changes to the involved objects 364 | */ 365 | // jscs:disable maximumLineLength 366 | _removeSingleLeaf(rootModel, rootProp, rootInstance, leafModel, leafProp, leafInstance, watcher) { 367 | // jscs:enable maximumLineLength 368 | if (leafProp !== undefined) { 369 | leafInstance[leafProp] = undefined; 370 | } 371 | 372 | rootInstance[rootProp] = undefined; 373 | } 374 | 375 | /** 376 | * remove a root from its current leaf 377 | * @param {string} rootModel - the name of the root model 378 | * @param {string} rootProp - the name of the property on the root 379 | * @param {Object} rootInstance - the instance of the root model to link 380 | * @param {string} leafModel - the name of the leaf model 381 | * @param {string} leafProp - the name of the property on the leaf model 382 | * @param {Object} leafInstance - the instance of the leaf model to link 383 | * @param {ChangeWatcher} watcher - the watcher which will gather changes to the involved objects 384 | */ 385 | // jscs:disable maximumLineLength 386 | _removeSingleRoot(rootModel, rootProp, rootInstance, leafModel, leafProp, leafInstance, watcher) { 387 | // jscs:enable maximumLineLength 388 | rootInstance[rootProp] = undefined; 389 | leafInstance[leafProp] = undefined; 390 | } 391 | 392 | /** 393 | * add a leaf to a root in a one-to-many property 394 | * @param {string} rootModel - the name of the root model 395 | * @param {string} rootProp - the name of the property on the root 396 | * @param {Object} rootInstance - the instance of the root model to link 397 | * @param {string} leafModel - the name of the leaf model 398 | * @param {string} leafProp - the name of the property on the leaf model 399 | * @param {Object} leafInstance - the instance of the leaf model to link 400 | * @param {ChangeWatcher} watcher - the watcher which will gather changes to the involved objects 401 | */ 402 | // jscs:disable maximumLineLength 403 | _addMultiLeaf(rootModel, rootProp, rootInstance, leafModel, leafProp, leafInstance, watcher) { 404 | // jscs:enable maximumLineLength 405 | if (rootInstance[rootProp].indexOf(leafInstance.id) === -1) { 406 | rootInstance[rootProp].push(leafInstance.id); 407 | } 408 | 409 | if (leafProp !== undefined) { 410 | if (leafInstance[leafProp] !== rootInstance.id) { 411 | let curRoot = this._getData(rootModel, leafInstance[leafProp]); 412 | watcher.watchModel(curRoot, rootModel); 413 | 414 | this._removeMultiRoot(rootModel, rootProp, curRoot, 415 | leafModel, leafProp, leafInstance); 416 | } 417 | 418 | leafInstance[leafProp] = rootInstance.id; 419 | } 420 | } 421 | 422 | /** 423 | * add a root to a leaf in a one-to-many property 424 | * @param {string} rootModel - the name of the root model 425 | * @param {string} rootProp - the name of the property on the root 426 | * @param {Object} rootInstance - the instance of the root model to link 427 | * @param {string} leafModel - the name of the leaf model 428 | * @param {string} leafProp - the name of the property on the leaf model 429 | * @param {Object} leafInstance - the instance of the leaf model to link 430 | * @param {ChangeWatcher} watcher - the watcher which will gather changes to the involved objects 431 | */ 432 | // jscs:disable maximumLineLength 433 | _addMultiRoot(rootModel, rootProp, rootInstance, leafModel, leafProp, leafInstance, watcher) { 434 | // jscs:enable maximumLineLength 435 | if (rootInstance === undefined) { 436 | leafInstance[leafProp] = undefined; 437 | return; 438 | } 439 | 440 | if (rootInstance[rootProp].indexOf(leafInstance.id) === -1) { 441 | rootInstance[rootProp].push(leafInstance.id); 442 | } 443 | 444 | if (leafInstance[leafProp] !== rootInstance.id) { 445 | let curRoot = this._getData(rootModel, leafInstance[leafProp]); 446 | watcher.watchModel(curRoot, rootModel); 447 | 448 | this._removeMultiLeaf(rootModel, rootProp, curRoot, 449 | leafModel, leafProp, leafInstance); 450 | } 451 | 452 | leafInstance[leafProp] = rootInstance.id; 453 | } 454 | 455 | /** 456 | * remove a leaf in a one-to-many property 457 | * @param {string} rootModel - the name of the root model 458 | * @param {string} rootProp - the name of the property on the root 459 | * @param {Object} rootInstance - the instance of the root model to link 460 | * @param {string} leafModel - the name of the leaf model 461 | * @param {string} leafProp - the name of the property on the leaf model 462 | * @param {Object} leafInstance - the instance of the leaf model to link 463 | * @param {ChangeWatcher} watcher - the watcher which will gather changes to the involved objects 464 | */ 465 | // jscs:disable maximumLineLength 466 | _removeMultiLeaf(rootModel, rootProp, rootInstance, leafModel, leafProp, leafInstance, watcher) { 467 | // jscs:enable maximumLineLength 468 | if (rootInstance === undefined) { 469 | return; 470 | } 471 | 472 | if (rootInstance[rootProp].indexOf(leafInstance.id) !== -1) { 473 | rootInstance[rootProp] = rootInstance[rootProp].filter((ele) => { 474 | return ele !== leafInstance.id; 475 | }); 476 | } 477 | 478 | if (leafProp !== undefined) { 479 | leafInstance[leafProp] = undefined; 480 | } 481 | } 482 | 483 | /** 484 | * unlink a leaf from a root in a one-to-many property 485 | * @param {string} rootModel - the name of the root model 486 | * @param {string} rootProp - the name of the property on the root 487 | * @param {Object} rootInstance - the instance of the root model to link 488 | * @param {string} leafModel - the name of the leaf model 489 | * @param {string} leafProp - the name of the property on the leaf model 490 | * @param {Object} leafInstance - the instance of the leaf model to link 491 | * @param {ChangeWatcher} watcher - the watcher which will gather changes to the involved objects 492 | */ 493 | // jscs:disable maximumLineLength 494 | _removeMultiRoot(rootModel, rootProp, rootInstance, leafModel, leafProp, leafInstance, watcher) { 495 | // jscs:enable maximumLineLength 496 | if (rootInstance !== undefined) { 497 | rootInstance[rootProp] = rootInstance[rootProp].filter((ele) => { 498 | return ele !== leafInstance.id; 499 | }); 500 | } 501 | 502 | if (leafProp !== undefined) { 503 | leafInstance[leafProp] = undefined; 504 | } 505 | } 506 | 507 | /** 508 | * updates the properties on an object that are links to other models 509 | * @param {string} model - the name of the model we are modifying 510 | * @param {Object} instance - the instance of `model` we are updating 511 | * @param {Object} state - the new state for `instance` 512 | * @param {boolean} createProps - a flag that will create empty instances of the properties on `instance` 513 | * @param {string} method - where this is being called from (for error tracking) 514 | */ 515 | _updateLinkProperties(model, instance, state, createProps, method) { 516 | let willReject = false; 517 | let withReason; 518 | 519 | let watcher = new ChangeWatcher(); 520 | let template = this._getModelTemplate(model); 521 | 522 | watcher.watchModel(instance, model); 523 | 524 | // relational properties in the second pass 525 | Object.keys(template).some((prop) => { 526 | if (!this._isLinkedProp(model, prop)) { 527 | return false; 528 | } 529 | 530 | let value = instance[prop]; 531 | if (state.hasOwnProperty(prop)) { 532 | value = state[prop]; 533 | } 534 | // properties which link to other models 535 | let [ 536 | type, 537 | direction, 538 | otherModel, 539 | otherProp 540 | ] = this._getLinkAttrs(template[prop]); 541 | 542 | if (createProps) { 543 | this._setEmptyLinkedProperty(instance, prop, type, direction); 544 | } 545 | 546 | if (!value && type === HAS_MANY && direction === OWNS) { 547 | value = []; 548 | } 549 | 550 | [ 551 | willReject, 552 | withReason 553 | ] = this._ensureReferencesExist(otherModel, value, method); 554 | if (willReject) { 555 | return true; 556 | } 557 | 558 | if (type === HAS_ONE) { 559 | if (direction === OWNS) { 560 | let leaf = this._getData(otherModel, value); 561 | watcher.watchModel(leaf, otherModel); 562 | this._addSingleLeaf(model, prop, instance, 563 | otherModel, otherProp, leaf, watcher); 564 | 565 | } else { 566 | let root = this._getData(otherModel, value); 567 | watcher.watchModel(root, otherModel); 568 | this._addSingleRoot(otherModel, otherProp, root, 569 | model, prop, instance, watcher); 570 | 571 | } 572 | 573 | } else if (type === HAS_MANY) { 574 | if (direction === OWNS) { 575 | let toRemove = instance[prop].filter((element) => { 576 | return value.indexOf(element) === -1; 577 | }); 578 | for (let i = 0; i < toRemove.length; i++) { 579 | let leaf = this._getData(otherModel, toRemove[i]); 580 | watcher.watchModel(leaf, otherModel); 581 | 582 | this._removeMultiLeaf(model, prop, instance, 583 | otherModel, otherProp, leaf, watcher); 584 | } 585 | 586 | for (let i = 0; i < value.length; i++) { 587 | let leaf = this._getData(otherModel, value[i]); 588 | watcher.watchModel(leaf, otherModel); 589 | 590 | this._addMultiLeaf(model, prop, instance, 591 | otherModel, otherProp, leaf, watcher); 592 | } 593 | 594 | } else { 595 | let root = this._getData(otherModel, value); 596 | watcher.watchModel(root, otherModel); 597 | 598 | this._addMultiRoot(otherModel, otherProp, root, 599 | model, prop, instance, watcher); 600 | } 601 | } 602 | }); 603 | 604 | let patches = watcher.getPatches(); 605 | 606 | return [patches, willReject, withReason]; 607 | } 608 | 609 | // 610 | // Datastore implementation 611 | // 612 | find(query) { 613 | let willReject = false; 614 | let withReason; 615 | let foundObject; 616 | let foundObjects = []; 617 | let wantsCollection = false; 618 | 619 | let lastModel; 620 | let modelTemplate; 621 | 622 | // call for error handling, ignore return value 623 | super.find(query); 624 | 625 | query._params.some((param) => { 626 | let field = param.field; 627 | if (param.model) { 628 | lastModel = param.model; 629 | modelTemplate = this._getModelTemplate(lastModel); 630 | 631 | } else if (!this._isLinkedProp(lastModel, field)) { 632 | willReject = true; 633 | withReason = ERROR_CANNOT_FIND_ON_PROP 634 | .replace('${model}', lastModel) 635 | .replace('${property}', field); 636 | return true; 637 | 638 | } else { 639 | let linkDef = modelTemplate[param.field]; 640 | [ , , lastModel, ] = this._getLinkAttrs(linkDef); 641 | modelTemplate = this._getModelTemplate(lastModel); 642 | } 643 | this._checkModel(lastModel, 'find'); 644 | }); 645 | 646 | if (willReject) { 647 | return this._promise.reject(new Error(withReason)); 648 | } 649 | 650 | let getIdsFromField = function(obj, field) { 651 | if (obj[field] instanceof Array) { 652 | return obj[field]; 653 | } else { 654 | return [obj[field]]; 655 | } 656 | }; 657 | for (let i = 0; i < query._params.length; i++) { 658 | let param = query._params[i]; 659 | let field = param.field; 660 | wantsCollection = param.id === undefined; 661 | 662 | if (i === 0) { 663 | lastModel = param.model; 664 | 665 | } else { 666 | let linkDef = this._getModelTemplate(lastModel)[field]; 667 | [ , , lastModel, ] = this._getLinkAttrs(linkDef); 668 | } 669 | 670 | let allIds = []; 671 | if (foundObject) { 672 | allIds = getIdsFromField(foundObject, field); 673 | 674 | } else if (foundObjects.length > 0) { 675 | for (let i = 0; i < foundObjects.length; i++) { 676 | let obj = foundObjects[i]; 677 | allIds = allIds.concat(getIdsFromField(obj, field)); 678 | } 679 | 680 | } else if (wantsCollection) { 681 | allIds = Object.keys(this._data[lastModel]); 682 | 683 | } else { 684 | allIds = [param.id]; 685 | } 686 | 687 | if (wantsCollection) { 688 | foundObjects = []; 689 | foundObject = undefined; 690 | for (let i = 0; i < allIds.length; i++) { 691 | let id = allIds[i]; 692 | let obj = this._getData(lastModel, id); 693 | if (obj) { 694 | foundObjects.push(obj); 695 | } 696 | } 697 | 698 | } else { 699 | let foundId; 700 | if (allIds.indexOf(param.id) !== -1) { 701 | foundId = param.id; 702 | } 703 | 704 | foundObject = this._getData(lastModel, foundId); 705 | foundObjects = []; 706 | } 707 | 708 | if (!wantsCollection && foundObject === undefined || 709 | wantsCollection && foundObjects.length === 0) { 710 | break; 711 | } 712 | } 713 | 714 | if (this._upstream !== undefined && 715 | foundObject === undefined && 716 | foundObjects.length === 0) { 717 | log('Query not fulfilled locally, going upstream'); 718 | return this._upstream.find(query).then((upstreamResults) => { 719 | this._setDataFromUpstreamQuery(query, upstreamResults); 720 | return upstreamResults; 721 | 722 | }, (upstreamErr) => { 723 | throw upstreamErr; 724 | }); 725 | } 726 | 727 | let result = wantsCollection ? foundObjects : foundObject; 728 | return this._promise.resolve(result); 729 | } 730 | 731 | create(model, state) { 732 | this._checkModel(model, 'create'); 733 | let willReject = false; 734 | let withReason; 735 | 736 | let toReturn; 737 | let toCreate = {}; 738 | 739 | if (!state) { 740 | let err = new Error(ERROR_NO_STATE_GIVEN.replace('${method}', 'create')); 741 | return this._promise.reject(err); 742 | 743 | } else if (state.id) { 744 | let err = new Error(ERROR_CANNOT_CREATE_WITH_ID); 745 | return this._promise.reject(err); 746 | 747 | } else { 748 | let id = uuid.v4(); 749 | toCreate.id = id; 750 | 751 | let relationPatches = []; 752 | 753 | this._updateSimpleProps(model, toCreate, state); 754 | 755 | // we link objects by applying patches after we determine that the operation 756 | // will succeed, so we need to create a copy so that we can insert the unlinked 757 | // version into our store and return the linked version to the client 758 | toReturn = clone(toCreate); 759 | 760 | [ 761 | relationPatches, 762 | willReject, 763 | withReason 764 | ] = this._updateLinkProperties(model, toReturn, state, true, 'create'); 765 | 766 | if (!willReject) { 767 | this._setData(model, toCreate); 768 | jsonpatch.apply(this._data, relationPatches); 769 | } else { 770 | return this._promise.reject(new Error(withReason)); 771 | } 772 | } 773 | 774 | return this._promise.resolve(toReturn); 775 | } 776 | 777 | update(model, state) { 778 | this._checkModel(model, 'update'); 779 | let willReject = false; 780 | let withReason; 781 | 782 | let toUpdate; 783 | 784 | if (!state) { 785 | let err = new Error(ERROR_NO_STATE_GIVEN 786 | .replace('${method}', 'update')); 787 | return this._promise.reject(err); 788 | 789 | } else if (!state.id) { 790 | let err = new Error(ERROR_CANNOT_UPDATE_WITHOUT_ID); 791 | return this._promise.reject(err); 792 | 793 | } else if (!this._getData(model, state.id)) { 794 | let err = new Error(ERROR_CANNOT_UPDATE_MISSING_RECORD 795 | .replace('${model}', model) 796 | .replace('${method}', 'update')); 797 | return this._promise.reject(err); 798 | 799 | } else { 800 | let relationPatches = []; 801 | 802 | toUpdate = this._getData(model, state.id); 803 | 804 | this._updateSimpleProps(model, toUpdate, state); 805 | [ 806 | relationPatches, 807 | willReject, 808 | withReason 809 | ] = this._updateLinkProperties(model, toUpdate, state, false, 'update'); 810 | 811 | if (!willReject) { 812 | jsonpatch.apply(this._data, relationPatches); 813 | this._setData(model, toUpdate); 814 | } else { 815 | return this._promise.reject(new Error(withReason)); 816 | } 817 | } 818 | 819 | return this._promise.resolve(toUpdate); 820 | } 821 | 822 | delete(model, state) { 823 | this._checkModel(model, 'delete'); 824 | let willReject = false; 825 | let withReason; 826 | 827 | if (!state) { 828 | let err = new Error(ERROR_NO_STATE_GIVEN.replace('${method}', 'delete')); 829 | return this._promise.reject(err); 830 | 831 | } else if (!state.id) { 832 | let err = new Error(ERROR_CANNOT_UPDATE_WITHOUT_ID); 833 | return this._promise.reject(err); 834 | 835 | } else if (!this._getData(model, state.id)) { 836 | let err = new Error(ERROR_CANNOT_UPDATE_MISSING_RECORD 837 | .replace('${model}', model) 838 | .replace('${method}', 'delete')); 839 | return this._promise.reject(err); 840 | 841 | } else { 842 | this._deleteData(model, state.id); 843 | } 844 | 845 | return this._promise.resolve(); 846 | } 847 | 848 | /** 849 | * commit pushes all pending operations upstream 850 | * 851 | * @param {none} _ - we ignore this value in the memorydatastore 852 | * @return {Promise} - the promise resolves if the commit succeedes and rejects if the 853 | * commit fails, in either case there is no return value. If the promise 854 | * local copys of objects be refetched after the commit if the promise 855 | * rejects or if the promise succeedes and is called with `true` 856 | */ 857 | commit(_) { 858 | let willReject = false; 859 | 860 | if (!this._upstream) { 861 | return this._promise.reject(new Error(ERROR_NO_UPSTREAM)); 862 | } 863 | 864 | let patches = jsonpatch.compare(this._snapshot, this._data); 865 | 866 | return this._upstream.commit(patches).then((upstreamResults) => { 867 | if (upstreamResults) { 868 | for (let i = 0; i < upstreamResults.length; i++) { 869 | let result = upstreamResults[i]; 870 | 871 | let type = result.type; 872 | let data = result.data; 873 | let curId = data.id; 874 | let lastId = result.oldId; 875 | 876 | if (lastId && lastId !== curId) { 877 | this._meta[type][lastId] = curId; 878 | delete this._data[type][lastId]; 879 | } 880 | 881 | this._data[type][curId] = data; 882 | } 883 | } 884 | 885 | this._snapshot = clone(this._data); 886 | return this._promise.resolve(); 887 | 888 | }, (upstreamError) => { 889 | this._data = clone(this._snapshot); 890 | throw upstreamError; 891 | }); 892 | } 893 | 894 | dehydrate() { 895 | let dehydratedData = JSON.stringify(this._data); 896 | let dehydratedSnapshot = JSON.stringify(this._snapshot); 897 | 898 | if (dehydratedData !== dehydratedSnapshot) { 899 | throw new Error('Cannot dehydrate MemoryStore with uncommitted data'); 900 | } 901 | 902 | let dehydratedMeta = JSON.stringify(this._meta); 903 | 904 | return { 905 | dehydratedMeta, 906 | dehydratedData 907 | }; 908 | } 909 | 910 | rehydrate(state) { 911 | let { 912 | dehydratedMeta, 913 | dehydratedData 914 | } = state; 915 | 916 | this._data = JSON.parse(dehydratedData); 917 | this._meta = JSON.parse(dehydratedMeta); 918 | this._snapshot = clone(this._data); 919 | } 920 | } 921 | 922 | export default MemoryDatastore; 923 | -------------------------------------------------------------------------------- /spec/stores/memorystore.spec.js: -------------------------------------------------------------------------------- 1 | /********************************************************************************* 2 | * Copyright 2015 Yahoo Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and limitations 14 | * under the License. 15 | *********************************************************************************/ 16 | /* jshint expr: true */ 17 | // jscs:disable maximumLineLength 18 | 'use strict'; 19 | 20 | var chai = require('chai'); 21 | chai.use(require('chai-as-promised')); 22 | var expect = chai.expect; 23 | var sinon = require('sinon'); 24 | 25 | var MEMORYDATASTORE = require('../../lib/datastores/memorydatastore'); 26 | var MemoryDatastore = MEMORYDATASTORE.default; 27 | var ES6Promise = require('es6-promise').Promise; 28 | var Query = require('../../lib/query'); 29 | 30 | var Datastore = require('../../lib/datastores/datastore').default; 31 | var inherits = require('inherits'); 32 | 33 | var NoopStore = function NoopStore(promise, ttl, url, models) { 34 | NoopStore.super_.call(this, promise, ttl, url, models); 35 | }; 36 | inherits(NoopStore, Datastore); 37 | NoopStore.prototype.commit = function(ops) { 38 | return new this._promise(function(resolve, reject) { 39 | resolve(); 40 | }); 41 | }; 42 | 43 | var FakeStore = function FakeStore(promise, ttl, url, models) { 44 | FakeStore.super_.call(this, promise, ttl, url, models); 45 | }; 46 | inherits(FakeStore, Datastore); 47 | FakeStore.prototype.commit = function(ops) { 48 | return new this._promise(function(resolve, reject) { 49 | var cat = ops[0].value; 50 | var jsonDSReturn = { 51 | type: 'cat', 52 | oldId: cat.id, 53 | data: cat 54 | }; 55 | jsonDSReturn.data.id = 1; 56 | resolve([ 57 | jsonDSReturn 58 | ]); 59 | }); 60 | }; 61 | 62 | describe('MemoryDatastore', function() { 63 | var simpleModels = { 64 | cat: { 65 | color: 'string', 66 | age: 'number', 67 | links: {} 68 | }, 69 | dog: { 70 | breed: 'string', 71 | age: 'number', 72 | links: {} 73 | } 74 | }; 75 | var modelsWithLinks = { 76 | person: { 77 | name: 'string', 78 | links: { 79 | pets: { 80 | model: 'pet', 81 | type: 'hasMany', 82 | inverse: 'owner' 83 | }, 84 | bike: { 85 | model: 'bicycle', 86 | type: 'hasOne', 87 | inverse: 'owner' 88 | } 89 | } 90 | }, 91 | bicycle: { 92 | maker: 'string', 93 | model: 'string', 94 | links: {} 95 | }, 96 | pet: { 97 | type: 'string', 98 | name: 'string', 99 | age: 'number', 100 | links: { 101 | flees: { 102 | model: 'flee', 103 | type: 'hasMany' 104 | } 105 | } 106 | }, 107 | flee: { 108 | age: 'number', 109 | links: {} 110 | } 111 | }; 112 | var personKeys = ['id', 'name', 'pets', 'bike']; 113 | var noopStore = new NoopStore(ES6Promise, undefined, undefined, simpleModels); 114 | var fakeStore = new FakeStore(ES6Promise, undefined, undefined, simpleModels); 115 | 116 | describe('initialization', function() { 117 | 118 | it('should initialize cleanly', function() { 119 | expect(function() { 120 | new MemoryDatastore(ES6Promise, 10, undefined, simpleModels); 121 | }).not.to.throw(); 122 | }); 123 | 124 | it('should use the correct promise lib', function() { 125 | var ms = new MemoryDatastore(ES6Promise, 10, undefined, simpleModels); 126 | var q = new Query(ms, 'cat', 1); 127 | expect(ms.find(q)).to.be.an.instanceof(ES6Promise); 128 | }); 129 | 130 | it('should use the correct ttl', function() { 131 | var ms = new MemoryDatastore(ES6Promise, 10, undefined, simpleModels); 132 | expect(ms._ttl).to.equal(10); 133 | }); 134 | }); 135 | 136 | describe('subclassing', function() { 137 | var ms; 138 | var catId; 139 | 140 | before(function() { 141 | ms = new MemoryDatastore(ES6Promise, 10, undefined, simpleModels); 142 | }); 143 | 144 | it('should implement find', function() { 145 | expect(ms.find(new Query(ms, 'cat', 1))).to.eventually.resolve; 146 | }); 147 | 148 | it('should return a promise from find', function() { 149 | expect(ms.find(new Query(ms, 'cat', 1))).to.be.an.instanceof(ES6Promise); 150 | }); 151 | 152 | it('should implement create', function(done) { 153 | ms.create('cat', {color: 'black'}) 154 | .then(function(cat) { 155 | catId = cat.id; 156 | done(); 157 | }, done); 158 | }); 159 | 160 | it('should return a promise from create', function() { 161 | expect(ms.create('cat', {color: 'black'})).to.be.an.instanceof(ES6Promise); 162 | }); 163 | 164 | it('should implement update', function() { 165 | expect(ms.update('cat', {id: catId, color: 'green'})).to.eventually.resolve; 166 | }); 167 | 168 | it('should return a promise from update', function() { 169 | expect(ms.update('cat', {id: catId, color: 'green'})).to.be.an.instanceof(ES6Promise); 170 | }); 171 | 172 | it('should implement delete', function() { 173 | expect(ms.delete('cat', {id: catId, color: 'green'})).to.eventually.resolve; 174 | }); 175 | 176 | it('should return a promise from delete', function() { 177 | expect(ms.delete('cat', {id: catId, color: 'green'})).to.be.an.instanceof(ES6Promise); 178 | }); 179 | 180 | it('should implement commit', function() { 181 | ms.setUpstream(fakeStore); 182 | expect(ms.commit()).to.eventually.resolve; 183 | }); 184 | 185 | it('should return a promise from commit', function() { 186 | ms.setUpstream(fakeStore); 187 | expect(ms.commit()).to.be.an.instanceof(ES6Promise); 188 | }); 189 | }); 190 | 191 | describe('writing data', function() { 192 | var ms; 193 | 194 | before(function() { 195 | ms = new MemoryDatastore(ES6Promise, undefined, undefined, modelsWithLinks); 196 | }); 197 | 198 | it('should be able to create new objects', function() { 199 | var person = {name: 'John'}; 200 | return expect(ms.create('person', person)).to.eventually.have.all.keys(personKeys); 201 | }); 202 | 203 | it('should ignore properties which are not part of the model on create', function() { 204 | var person = {name: 'John', occupation: 'Sierra 117'}; 205 | return expect(ms.create('person', person)).to.eventually.have.all.keys(personKeys); 206 | }); 207 | 208 | it('should reject creates with no data', function() { 209 | return expect(ms.create('person', null)).to.eventually.be.rejectedWith(MEMORYDATASTORE.ERROR_NO_STATE_GIVEN.replace('${method}', 'create')); 210 | }); 211 | 212 | it('should not be able to create objects that specify an id', function() { 213 | var person = {name: 'John', id: 1}; 214 | return expect(ms.create('person', person)).to.eventually.be.rejectedWith(MEMORYDATASTORE.ERROR_CANNOT_CREATE_WITH_ID); 215 | }); 216 | 217 | it('should be able to modify objects', function() { 218 | var person = {name: 'John'}; 219 | var promise = ms.create('person', person).then(function(createdPerson) { 220 | createdPerson.name = 'Master Chief'; 221 | return ms.update('person', createdPerson); 222 | }); 223 | return expect(promise).to.eventually.have.property('name', 'Master Chief'); 224 | }); 225 | 226 | it('should ignore properties which are not part of the model on modify', function() { 227 | var person = {name: 'John'}; 228 | var promise = ms.create('person', person).then(function(createdPerson) { 229 | createdPerson.occupation = 'Master Chief'; 230 | return ms.update('person', createdPerson); 231 | }); 232 | return expect(promise).to.eventually.have.all.keys(personKeys); 233 | }); 234 | 235 | it('should reject updates with no data', function() { 236 | var person = {name: 'John'}; 237 | var promise = ms.create('person', person).then(function(createdPerson) { 238 | return ms.update('person', null); 239 | }); 240 | return expect(promise).to.eventually.be.rejectedWith(MEMORYDATASTORE.ERROR_NO_STATE_GIVEN.replace('${method}', 'update')); 241 | }); 242 | 243 | it('should reject updates that do not specify an id', function() { 244 | var person = {name: 'John'}; 245 | var promise = ms.create('person', person).then(function(createdPerson) { 246 | createdPerson.id = undefined; 247 | return ms.update('person', createdPerson); 248 | }); 249 | return expect(promise).to.eventually.be.rejectedWith(MEMORYDATASTORE.ERROR_CANNOT_UPDATE_WITHOUT_ID); 250 | }); 251 | 252 | it('should not be able to modify objects which do not exist', function() { 253 | var person = {name: 'John'}; 254 | var promise = ms.create('person', person).then(function(createdPerson) { 255 | createdPerson.id = 4; 256 | return ms.update('person', createdPerson); 257 | }); 258 | return expect(promise).to.eventually.be.rejectedWith(MEMORYDATASTORE.ERROR_CANNOT_UPDATE_MISSING_RECORD.replace('${model}', 'person').replace('${method}', 'update')); 259 | }); 260 | 261 | it('should be able to delete objects', function() { 262 | var person = {name: 'John'}; 263 | var promise = ms.create('person', person).then(function(createdPerson) { 264 | return ms.delete('person', createdPerson); 265 | }); 266 | return expect(promise).to.eventually.be.fulfilled; 267 | }); 268 | 269 | it('should reject deletes with no data', function() { 270 | var person = {name: 'John'}; 271 | var promise = ms.create('person', person).then(function(createdPerson) { 272 | return ms.delete('person', null); 273 | }); 274 | return expect(promise).to.eventually.be.rejectedWith(MEMORYDATASTORE.ERROR_NO_STATE_GIVEN.replace('${method}', 'delete')); 275 | }); 276 | 277 | it('should reject deletes that do not specify an id', function() { 278 | var person = {name: 'John'}; 279 | var promise = ms.create('person', person).then(function(createdPerson) { 280 | createdPerson.id = undefined; 281 | return ms.delete('person', createdPerson); 282 | }); 283 | return expect(promise).to.eventually.be.rejectedWith(MEMORYDATASTORE.ERROR_CANNOT_UPDATE_WITHOUT_ID); 284 | }); 285 | 286 | it('should not be able to delete objects which do not exist', function() { 287 | var person = {name: 'John'}; 288 | var promise = ms.create('person', person).then(function(createdPerson) { 289 | createdPerson.id = 4; 290 | return ms.delete('person', createdPerson); 291 | }); 292 | return expect(promise).to.eventually.be.rejectedWith(MEMORYDATASTORE.ERROR_CANNOT_UPDATE_MISSING_RECORD.replace('${model}', 'person').replace('${method}', 'delete')); 293 | }); 294 | }); 295 | 296 | describe('maintaining relationships', function() { 297 | var ms; 298 | 299 | beforeEach(function() { 300 | ms = new MemoryDatastore(ES6Promise, undefined, undefined, modelsWithLinks); 301 | }); 302 | 303 | it('should update to-one relationships when creating models', function(done) { 304 | var person = {name: 'John'}; 305 | var bike1 = {maker: 'Felt', model: 'SR-73'}; 306 | var bike2 = {maker: 'Felt', model: 'SR-73'}; 307 | 308 | /** 309 | * - create bike1 310 | * - create person, person.bike = bike1.id 311 | * - find bike1, verify bike1.owner = person.id 312 | * - create bike2, bike2.owner = person.id 313 | * - find bike1, verify bike1.owner = undefined 314 | * - find person, verify person.bike = bike2.id 315 | */ 316 | ms.create('bicycle', bike1).then(function(createdBike) { 317 | bike1 = createdBike; 318 | person.bike = createdBike.id; 319 | return ms.create('person', person); 320 | 321 | }).then(function(createdPerson) { 322 | person = createdPerson; 323 | 324 | var q = new Query(ms, 'bicycle', bike1.id); 325 | return ms.find(q); 326 | 327 | }).then(function(foundBike1) { 328 | expect(foundBike1.owner).to.equal(person.id); 329 | 330 | bike2.owner = person.id; 331 | return ms.create('bicycle', bike2); 332 | 333 | }).then(function(createdBike) { 334 | bike2 = createdBike; 335 | 336 | var q = new Query(ms, 'bicycle', bike1.id); 337 | return ms.find(q); 338 | 339 | }).then(function(foundBike1) { 340 | expect(foundBike1.owner).to.be.undefined; 341 | 342 | var q = new Query(ms, 'person', person.id); 343 | return ms.find(q); 344 | 345 | }).then(function(foundPerson) { 346 | expect(foundPerson.bike).to.equal(bike2.id); 347 | done(); 348 | 349 | }).catch(done); 350 | }); 351 | 352 | it('should not re-add relationships when updating models', function(done) { 353 | var john = {name: 'John'}; 354 | var spot = {name: 'Spot', type: 'dog'}; 355 | var blink = {name: 'Blink', type: 'cat'}; 356 | 357 | /** 358 | * - create spot 359 | * - create john, with john.pets = [ spot.id ] 360 | * - find spot, expect spot.owner = john.id 361 | * - create blink, with blink.owner = john.id 362 | * - find john, expect john.pets = [ spot.id, blink.id ] 363 | */ 364 | ms.create('pet', spot).then(function(pet) { 365 | spot = pet; 366 | john.pets = [spot.id]; 367 | return ms.create('person', john); 368 | }).then(function(person1) { 369 | john = person1; 370 | blink.owner = john.id; 371 | return ms.create('pet', blink); 372 | }).then(function(pet) { 373 | blink = pet; 374 | john.name = 'Master Chief'; 375 | return ms.update('person', { 376 | id: john.id, 377 | name: 'Master Chief' 378 | }); 379 | }).then(function(person2) { 380 | john = person2; 381 | return ms.update('pet', { 382 | id: spot.id, 383 | name: 'Red' 384 | }); 385 | }).then(function(pet) { 386 | return ms.find(new Query(ms, 'person', john.id)); 387 | }).then(function(person3) { 388 | expect(person3, JSON.stringify(person3, null, 2) + ' != ' + JSON.stringify(john, null, 2)).to.deep.equal(john); 389 | done(); 390 | }).catch(done); 391 | }); 392 | 393 | it('should update to-many relationships when creating models', function(done) { 394 | var person = {name: 'John'}; 395 | var spot = {name: 'Spot', type: 'dog'}; 396 | var blink = {name: 'Blink', type: 'cat'}; 397 | 398 | /** 399 | * - create spot 400 | * - create john, with john.pets = [ spot.id ] 401 | * - find spot, expect spot.owner = john.id 402 | * - create blink, with blink.owner = john.id 403 | * - find john, expect john.pets = [ spot.id, blink.id ] 404 | */ 405 | ms.create('pet', spot).then(function(createdPet) { 406 | spot = createdPet; 407 | 408 | person.pets = [spot.id]; 409 | return ms.create('person', person); 410 | 411 | }).then(function(createdPerson) { 412 | person = createdPerson; 413 | 414 | var q = new Query(ms, 'pet', spot.id); 415 | return ms.find(q); 416 | 417 | }).then(function(foundSpot) { 418 | expect(foundSpot.owner).to.equal(person.id); 419 | 420 | blink.owner = person.id; 421 | return ms.create('pet', blink); 422 | 423 | }).then(function(createdPet) { 424 | blink = createdPet; 425 | 426 | var q = new Query(ms, 'person', person.id); 427 | return ms.find(q); 428 | 429 | }).then(function(foundPerson) { 430 | expect(foundPerson.pets).to.have.members([spot.id, blink.id]) 431 | .and.to.have.length(2); 432 | done(); 433 | 434 | }).catch(done); 435 | }); 436 | 437 | it('should reject creates that link to non-existant objects', function(done) { 438 | var person = {name: 'John', bike: 1}; 439 | ms.create('person', person).then(function() { 440 | done('should not create person'); 441 | 442 | }, function(error) { 443 | expect(error.message).to.be.equal(MEMORYDATASTORE.ERROR_CANNOT_LINK_TO_MISSING_RECORD 444 | .replace('${model}', 'bicycle') 445 | .replace('${id}', '1') 446 | .replace('${method}', 'create')); 447 | 448 | person.bike = undefined; 449 | person.pets = [1]; 450 | ms.create('person', person).then(function() { 451 | done('still should not create person'); 452 | 453 | }, function(error) { 454 | expect(error.message).to.be.equal(MEMORYDATASTORE.ERROR_CANNOT_LINK_TO_MISSING_RECORD 455 | .replace('${model}', 'pet') 456 | .replace('${id}', '1') 457 | .replace('${method}', 'create')); 458 | done(); 459 | }).catch(done); 460 | }).catch(done); 461 | }); 462 | 463 | it('should retain correct relationships when creates fail', function(done) { 464 | // this test is fragile, since it is dependent on the order in which 465 | // the processes the model's properties 466 | 467 | var person = {name: 'John', bike: 1}; 468 | var spot = {name: 'Spot', type: 'dog'}; 469 | var blink = {name: 'Blink', type: 'cat'}; 470 | 471 | /** 472 | * - create spot 473 | * - create blink 474 | * - try to create john (which should fail) 475 | * - fetch spot, verify that spot.owner is not set 476 | * - fetch blink, verify that blink.owner is not set 477 | */ 478 | ms.create('pet', spot).then(function(createdPet) { 479 | spot = createdPet; 480 | 481 | return ms.create('pet', blink); 482 | }).then(function(createdPet) { 483 | blink = createdPet; 484 | person.pets = [spot.id, blink.id]; 485 | 486 | return ms.create('person', person); 487 | 488 | }).then(function(createdPerson) { 489 | done('We should not have been able to create a person!'); 490 | 491 | }, function(error) { 492 | var q = new Query(ms, 'pet', spot.id); 493 | return ms.find(q); 494 | 495 | }).then(function(foundPet) { 496 | expect(foundPet.owner).to.be.undefined; 497 | 498 | var q = new Query(ms, 'pet', blink.id); 499 | return ms.find(q); 500 | 501 | }).then(function(foundPet) { 502 | expect(foundPet.owner).to.be.undefined; 503 | done(); 504 | 505 | }).catch(done); 506 | 507 | }); 508 | 509 | it('should update to-one relationships when updating objects', function(done) { 510 | var person = {name: 'John'}; 511 | var bike1 = {maker: 'Felt', model: 'SR-73'}; 512 | var bike2 = {maker: 'Felt', model: 'SR-73'}; 513 | 514 | /** 515 | * - create bike1 516 | * - create bike2 517 | * - create person 518 | * - update person.bike = bike1.id 519 | * - verify person.bike = bike1.id, find bike1 520 | * - verify bike1.owner = person.id, update bike2.owner = person.id 521 | * - verify bike2.owner = person.id, find person 522 | * - verify person.bike = bike2.id, find bike1 523 | * - verify bike1.owner = undefined 524 | */ 525 | ms.create('bicycle', bike1).then(function(createdBike) { 526 | bike1 = createdBike; 527 | 528 | return ms.create('bicycle', bike2); 529 | 530 | }).then(function(createdBike) { 531 | bike2 = createdBike; 532 | 533 | return ms.create('person', person); 534 | 535 | }).then(function(createdPerson) { 536 | person = createdPerson; 537 | 538 | person.bike = bike1.id; 539 | return ms.update('person', person); 540 | 541 | }).then(function(updatedPerson) { 542 | expect(updatedPerson.bike).to.equal(bike1.id); 543 | 544 | var q = new Query(ms, 'bicycle', bike1.id); 545 | return ms.find(q); 546 | 547 | }).then(function(foundBike) { 548 | expect(foundBike.owner).to.equal(person.id); 549 | 550 | bike2.owner = person.id; 551 | return ms.update('bicycle', bike2); 552 | 553 | }).then(function(updatedBike) { 554 | expect(updatedBike.owner).to.equal(person.id); 555 | 556 | var q = new Query(ms, 'person', person.id); 557 | return ms.find(q); 558 | 559 | }).then(function(foundPerson) { 560 | expect(foundPerson.bike).to.equal(bike2.id); 561 | 562 | var q = new Query(ms, 'bicycle', bike1.id); 563 | return ms.find(q); 564 | 565 | }).then(function(foundBike) { 566 | expect(foundBike.owner).to.be.undefined; 567 | done(); 568 | 569 | }).catch(done); 570 | 571 | }); 572 | 573 | it('should update to-many relationships when updating objects', function(done) { 574 | var person = {name: 'John'}; 575 | var spot = {name: 'Spot', type: 'dog'}; 576 | var blink = {name: 'Blink', type: 'cat'}; 577 | 578 | /** 579 | * - create spot 580 | * - create blink 581 | * - create person, set person.pets = [blink.id] 582 | * - verify person.pets = [blink.id], find blink 583 | * - verify blink.owner = perons.id, set spot.owner = person.id 584 | * - verify spot.owner = perons.id, find person 585 | * - verify person.pets = [blink.id, spot.id] 586 | */ 587 | ms.create('pet', spot).then(function(createdPet) { 588 | spot = createdPet; 589 | return ms.create('pet', blink); 590 | 591 | }).then(function(createdPet) { 592 | blink = createdPet; 593 | return ms.create('person', person); 594 | 595 | }).then(function(createdPerson) { 596 | person = createdPerson; 597 | person.pets = [blink.id]; 598 | return ms.update('person', person); 599 | 600 | }).then(function(updatedPerson) { 601 | expect(updatedPerson.pets).to.have.members([blink.id]) 602 | .and.to.have.length(1); 603 | 604 | var q = new Query(ms, 'pet', blink.id); 605 | return ms.find(q); 606 | 607 | }).then(function(foundPet) { 608 | expect(foundPet.owner).to.equal(person.id); 609 | 610 | spot.owner = person.id; 611 | return ms.update('pet', spot); 612 | 613 | }).then(function(foundPet) { 614 | expect(foundPet.owner).to.equal(person.id); 615 | 616 | var q = new Query(ms, 'person', person.id); 617 | return ms.find(q); 618 | 619 | }).then(function(foundPerson) { 620 | expect(foundPerson.pets).to.have.members([blink.id, spot.id]) 621 | .and.to.have.length(2); 622 | done(); 623 | 624 | }).catch(done); 625 | }); 626 | 627 | it('should reject updates that link to non-existant objects', function(done) { 628 | var person = {name: 'John'}; 629 | var spot = {name: 'Spot', type: 'dog'}; 630 | var blink = {name: 'Blink', type: 'cat'}; 631 | 632 | /** 633 | * - create spot 634 | * - create blink 635 | * - create person, set person.pets = [blink.id, 1] 636 | * - expect rejection 637 | * - update bike.owner = 1 638 | */ 639 | ms.create('pet', spot).then(function(createdPet) { 640 | spot = createdPet; 641 | return ms.create('pet', blink); 642 | 643 | }).then(function(createdPet) { 644 | blink = createdPet; 645 | person.pets = [blink.id]; 646 | return ms.create('person', person); 647 | 648 | }).then(function(createdPerson) { 649 | person = createdPerson; 650 | expect(createdPerson.pets).to.have.members([blink.id]) 651 | .and.to.have.length(1); 652 | 653 | person.pets = [spot.id, 1]; 654 | return ms.update('person', person); 655 | 656 | }).then(function(updatedPerson) { 657 | done('should not have updated person'); 658 | 659 | }, function(error) { 660 | expect(error.message).to.equal(MEMORYDATASTORE.ERROR_CANNOT_LINK_TO_MISSING_RECORD 661 | .replace('${model}', 'pet') 662 | .replace('${id}', '1') 663 | .replace('${method}', 'update')); 664 | 665 | }).then(function() { 666 | person.pets = [spot.id]; 667 | person.bike = 1; 668 | return ms.update('person', person); 669 | 670 | }).then(function() { 671 | done('should not have updated person'); 672 | 673 | }, function(error) { 674 | expect(error.message).to.equal(MEMORYDATASTORE.ERROR_CANNOT_LINK_TO_MISSING_RECORD 675 | .replace('${model}', 'bicycle') 676 | .replace('${id}', '1') 677 | .replace('${method}', 'update')); 678 | done(); 679 | }).catch(done); 680 | }); 681 | 682 | it('should retain correct relationships when updates fail', function(done) { 683 | var person = {name: 'John'}; 684 | var bike = {maker: 'Felt', model: 'SR-73'}; 685 | var spot = {name: 'Spot', type: 'dog'}; 686 | var blink = {name: 'Blink', type: 'cat'}; 687 | 688 | /** 689 | * - create spot 690 | * - create blink 691 | * - create bike 692 | * - create person, person.pets = [blink.id] 693 | * - update person.pets = [spot.id, 1], person.bike = bike.id 694 | * - expect rejection 695 | * - find person 696 | * - expect person.pets == [blink.id] and person.bike == undefined, 697 | * update person.pets = [spot.id] person.bike = 1 698 | * - expect rejection 699 | * - find person 700 | * - expect person.pets == [blink.id] berson.bike == undefined 701 | */ 702 | ms.create('pet', spot).then(function(createdPet) { 703 | spot = createdPet; 704 | return ms.create('pet', blink); 705 | 706 | }).then(function(createdPet) { 707 | blink = createdPet; 708 | return ms.create('person', person); 709 | 710 | }).then(function(createdPerson) { 711 | person = createdPerson; 712 | person.pets = [blink.id]; 713 | return ms.update('person', person); 714 | 715 | }).then(function(updatedPerson) { 716 | expect(updatedPerson.pets).to.have.members([blink.id]) 717 | .and.to.have.length(1); 718 | 719 | person.bike = bike.id; 720 | person.pets = [spot.id, 1]; 721 | return ms.update('person', person); 722 | 723 | }).then(function(updatedPerson) { 724 | done('should not have updated person'); 725 | 726 | }, function(error) { 727 | expect(error.message).to.equal(MEMORYDATASTORE.ERROR_CANNOT_LINK_TO_MISSING_RECORD 728 | .replace('${model}', 'pet') 729 | .replace('${id}', '1') 730 | .replace('${method}', 'update')); 731 | 732 | }).then(function() { 733 | var q = new Query(ms, 'person', person.id); 734 | return ms.find(q); 735 | 736 | }).then(function(foundPerson) { 737 | expect(foundPerson.bike).to.be.undefined; 738 | expect(foundPerson.pets).to.have.members([blink.id]) 739 | .and.to.have.length(1); 740 | 741 | person.pets = [spot.id]; 742 | person.bike = 1; 743 | return ms.update('person', person); 744 | 745 | }).then(function() { 746 | done('should not have updated person'); 747 | 748 | }, function(error) { 749 | expect(error.message).to.equal(MEMORYDATASTORE.ERROR_CANNOT_LINK_TO_MISSING_RECORD 750 | .replace('${model}', 'bicycle') 751 | .replace('${id}', '1') 752 | .replace('${method}', 'update')); 753 | }).then(function() { 754 | var q = new Query(ms, 'person', person.id); 755 | return ms.find(q); 756 | 757 | }).then(function(foundPerson) { 758 | expect(foundPerson.pets).to.have.members([blink.id]) 759 | .and.to.have.length(1); 760 | 761 | done(); 762 | }).catch(done); 763 | }); 764 | }); 765 | 766 | describe('reading data', function() { 767 | var ms; 768 | 769 | beforeEach(function() { 770 | ms = new MemoryDatastore(ES6Promise, undefined, undefined, modelsWithLinks); 771 | }); 772 | 773 | it('should return top level results', function(done) { 774 | var person = {name: 'John'}; 775 | 776 | ms.create('person', person).then(function(createdPerson) { 777 | person = createdPerson; 778 | var q = new Query(ms, 'person', createdPerson.id); 779 | return ms.find(q); 780 | 781 | }).then(function(foundPerson) { 782 | expect(foundPerson).to.deep.equal(person); 783 | done(); 784 | 785 | }).catch(done); 786 | }); 787 | 788 | it('should traverse relationships and return linked results', function(done) { 789 | var person = {name: 'John'}; 790 | var spot = {name: 'Spot', type: 'dog', age: 2}; 791 | 792 | ms.create('person', person).then(function(createdPerson) { 793 | person = createdPerson; 794 | spot.owner = person.id; 795 | return ms.create('pet', spot); 796 | 797 | }).then(function(createdPet) { 798 | spot = createdPet; 799 | var q = new Query(ms, 'person', person.id).find('pets', spot.id); 800 | return ms.find(q); 801 | 802 | }).then(function(foundSpot) { 803 | expect(foundSpot).to.deep.equal(spot); 804 | done(); 805 | 806 | }).catch(done); 807 | }); 808 | 809 | it('should return top level collections', function(done) { 810 | var bike1 = {maker: 'Felt', model: 'SR-73'}; 811 | var bike2 = {maker: 'Felt', model: 'SR-73'}; 812 | 813 | /** 814 | * - create bike1 815 | * - create bike2 816 | * - find all bikes 817 | */ 818 | ms.create('bicycle', bike1).then(function(createdBike) { 819 | bike1 = createdBike; 820 | 821 | return ms.create('bicycle', bike2); 822 | 823 | }).then(function(createdBike) { 824 | bike2 = createdBike; 825 | 826 | var q = new Query(ms, 'bicycle'); 827 | return ms.find(q); 828 | 829 | }).then(function(foundBikes) { 830 | expect(foundBikes).to.have.length(2) 831 | .and.deep.members([bike1, bike2]); 832 | 833 | done(); 834 | }).catch(done); 835 | }); 836 | 837 | it('should return nested collections', function(done) { 838 | var person = {name: 'John'}; 839 | var spot = {name: 'Spot', type: 'dog', age: 2}; 840 | var blink = {name: 'Blink', type: 'cat', age: 1}; 841 | var dory = {name: 'Dory', type: 'fish', age: 3.5}; 842 | 843 | /** 844 | * - create person 845 | * - create spot 846 | * - create blink 847 | * - find all pets for person 848 | */ 849 | ms.create('person', person).then(function(createdPerson) { 850 | person = createdPerson; 851 | 852 | spot.owner = person.id; 853 | return ms.create('pet', spot); 854 | 855 | }).then(function(createdPet) { 856 | spot = createdPet; 857 | 858 | blink.owner = person.id; 859 | return ms.create('pet', blink); 860 | 861 | }).then(function(createdPet) { 862 | blink = createdPet; 863 | 864 | return ms.create('pet', dory); 865 | 866 | }).then(function(createdPet) { 867 | var q = new Query(ms, 'person', person.id).find('pets'); 868 | return ms.find(q); 869 | 870 | }).then(function(foundPets) { 871 | expect(foundPets).to.have.length(2) 872 | .and.to.have.deep.members([spot, blink]); 873 | 874 | done(); 875 | }).catch(done); 876 | }); 877 | 878 | it('should return collections nested in collections', function(done) { 879 | var john = {name: 'John'}; 880 | var cortana = {name: 'Cortana'}; 881 | 882 | var spot = {name: 'Spot', type: 'dog', age: 2}; 883 | var blink = {name: 'Blink', type: 'cat', age: 1}; 884 | var dory = {name: 'Dory', type: 'fish', age: 3.5}; 885 | 886 | /** 887 | * - create john 888 | * - create spot 889 | * - create blink 890 | * - create cortana 891 | * - create dory 892 | * - create betsy (who shoudn't be returned in the next query) 893 | * - find all pets for all people 894 | */ 895 | ms.create('person', john).then(function(createdPerson) { 896 | john = createdPerson; 897 | 898 | spot.owner = john.id; 899 | return ms.create('pet', spot); 900 | 901 | }).then(function(createdPet) { 902 | spot = createdPet; 903 | 904 | blink.owner = john.id; 905 | return ms.create('pet', blink); 906 | 907 | }).then(function(createdPet) { 908 | blink = createdPet; 909 | 910 | return ms.create('person', cortana); 911 | 912 | }).then(function(createdPerson) { 913 | cortana = createdPerson; 914 | 915 | dory.owner = cortana.id; 916 | return ms.create('pet', dory); 917 | 918 | }).then(function(createdPet) { 919 | dory = createdPet; 920 | 921 | return ms.create('pet', {name: 'Betsy', type: 'cow', age: 9}); 922 | 923 | }).then(function(createdPet) { 924 | 925 | var q = new Query(ms, 'person').find('pets'); 926 | return ms.find(q); 927 | 928 | }).then(function(foundPets) { 929 | expect(foundPets).to.have.length(3) 930 | .and.to.have.deep.members([spot, blink, dory]); 931 | 932 | done(); 933 | }).catch(done); 934 | }); 935 | 936 | it('should not return results which exist but are not linked', function(done) { 937 | var person = {name: 'John'}; 938 | var spot = {name: 'Spot', type: 'dog', age: 2}; 939 | 940 | /** 941 | * - create a person 942 | * - create spot, ask for spot linked to the person 943 | * - verify he can't be found, ask for all the person's pets 944 | * - verify that there aren't any, ask for all pets of all people 945 | * - verify that there aren't any, ask for spot as linked from anyone 946 | */ 947 | ms.create('person', person).then(function(createdPerson) { 948 | person = createdPerson; 949 | 950 | return ms.create('pet', spot); 951 | 952 | }).then(function(createdPet) { 953 | spot = createdPet; 954 | 955 | var q = (new Query(ms, 'person', person.id)).find('pets', spot.id); 956 | return ms.find(q); 957 | 958 | }).then(function(foundPet) { 959 | expect(foundPet).to.be.undefined; 960 | 961 | var q = (new Query(ms, 'person', person.id)).find('pets'); 962 | return ms.find(q); 963 | 964 | }).then(function(foundPets) { 965 | expect(foundPets).to.have.length(0); 966 | 967 | var q = (new Query(ms, 'person')).find('pets'); 968 | return ms.find(q); 969 | 970 | }).then(function(foundPets) { 971 | expect(foundPets).to.have.length(0); 972 | 973 | var q = (new Query(ms, 'person')).find('pets', spot.id); 974 | return ms.find(q); 975 | 976 | }).then(function(foundPet) { 977 | expect(foundPet).to.be.undefined; 978 | 979 | done(); 980 | }).catch(done); 981 | }); 982 | 983 | it('should not try to traverse properties which are not links', function(done) { 984 | var person = {name: 'John'}; 985 | 986 | ms.create('person', person).then(function(createdPerson) { 987 | person = createdPerson; 988 | 989 | var q = new Query(ms, 'person', createdPerson.id).find('name', 12); 990 | return ms.find(q); 991 | 992 | }).then(function() { 993 | done('we should not be able to find via `person.name`.'); 994 | 995 | }, function(error) { 996 | expect(error.message).to.be.equal(MEMORYDATASTORE.ERROR_CANNOT_FIND_ON_PROP 997 | .replace('${model}', 'person') 998 | .replace('${property}', 'name')); 999 | done(); 1000 | 1001 | }).catch(done); 1002 | }); 1003 | }); 1004 | 1005 | describe('models', function() { 1006 | var ms; 1007 | 1008 | beforeEach(function() { 1009 | ms = new MemoryDatastore(ES6Promise, undefined, undefined, modelsWithLinks); 1010 | }); 1011 | 1012 | it('should allow known models', function() { 1013 | var q1 = new Query(ms, 'pet', 1); 1014 | var q2 = new Query(ms, 'person', 1); 1015 | 1016 | expect(function() { 1017 | ms.find(q1); 1018 | ms.find(q2); 1019 | }).not.to.throw(); 1020 | 1021 | ['create', 'update', 'delete'].forEach(function(method) { 1022 | 1023 | expect(function() { 1024 | ms[method]('pet', {}); 1025 | ms[method]('person', {}); 1026 | }).not.to.throw(); 1027 | }); 1028 | }); 1029 | 1030 | it('should NOT allow unknown models', function() { 1031 | var q = new Query(ms, 'catdog', 1); 1032 | expect(function() { 1033 | ms.find(q); 1034 | }).to.throw(MEMORYDATASTORE.ERROR_UNKNOWN_MODEL.replace('${model}', 'catdog').replace('${method}', 'find')); 1035 | 1036 | ['create', 'update', 'delete'].forEach(function(method) { 1037 | expect(function() { 1038 | ms[method]('catdog', {id: 1}); 1039 | }).to.throw(MEMORYDATASTORE.ERROR_UNKNOWN_MODEL.replace('${model}', 'catdog').replace('${method}', method)); 1040 | }); 1041 | }); 1042 | }); 1043 | 1044 | describe('upstream', function() { 1045 | 1046 | it('should read from upstream after ttl', function(done) { 1047 | var ms1 = new MemoryDatastore(ES6Promise, 10, undefined, simpleModels); 1048 | var ms2 = new MemoryDatastore(ES6Promise, undefined, undefined, simpleModels); 1049 | ms1.setUpstream(ms2); 1050 | sinon.spy(ms2, 'find'); 1051 | 1052 | var cat = {color: 'black', age: 2}; 1053 | ms2.create('cat', cat).then(function(createdCat) { 1054 | cat = createdCat; 1055 | 1056 | }).then(function() { 1057 | var q = new Query(ms1, 'cat', cat.id); 1058 | return ms1.find(q); 1059 | 1060 | }).then(function(foundCat) { 1061 | expect(foundCat).to.deep.equal(cat); 1062 | 1063 | var q = new Query(ms1, 'cat', cat.id); 1064 | return new ES6Promise(function(resolve, reject) { 1065 | setTimeout(function() { 1066 | ms1.find(q).then(resolve); 1067 | }, 250); 1068 | }); 1069 | 1070 | }).then(function(foundCat) { 1071 | expect(foundCat).to.deep.equal(cat); 1072 | expect(ms2.find.callCount).to.equal(2); 1073 | 1074 | done(); 1075 | }).catch(done); 1076 | }); 1077 | 1078 | it('should read from upstream if it does not have data locally', function(done) { 1079 | var ms1 = new MemoryDatastore(ES6Promise, undefined, undefined, simpleModels); 1080 | var ms2 = new MemoryDatastore(ES6Promise, undefined, undefined, simpleModels); 1081 | ms1.setUpstream(ms2); 1082 | sinon.spy(ms2, 'find'); 1083 | 1084 | var cat = {color: 'black', age: 2}; 1085 | ms2.create('cat', cat).then(function(createdCat) { 1086 | cat = createdCat; 1087 | 1088 | }).then(function() { 1089 | var q = new Query(ms1, 'cat', cat.id); 1090 | return ms1.find(q); 1091 | 1092 | }).then(function(foundCat) { 1093 | expect(foundCat).to.deep.equal(cat); 1094 | 1095 | var q = new Query(ms1, 'cat', cat.id); 1096 | return ms1.find(q); 1097 | 1098 | }).then(function(foundCat) { 1099 | expect(foundCat).to.deep.equal(cat); 1100 | expect(ms2.find.callCount).to.equal(1); 1101 | 1102 | done(); 1103 | }).catch(done); 1104 | }); 1105 | 1106 | it('should send diffs upstream', function(done) { 1107 | var ms1 = new MemoryDatastore(ES6Promise, undefined, undefined, simpleModels); 1108 | ms1.setUpstream(noopStore); 1109 | sinon.spy(noopStore, 'commit'); 1110 | 1111 | var cat = {color: 'black', age: 2}; 1112 | ms1.create('cat', cat).then(function(createdCat) { 1113 | cat = createdCat; 1114 | return ms1.commit(); 1115 | 1116 | }).then(function() { 1117 | var op = { 1118 | op: 'add', 1119 | path: '/cat/' + cat.id, 1120 | value: cat 1121 | }; 1122 | expect(noopStore.commit.lastCall.args[0], 'create') 1123 | .to.have.deep.members([op]) 1124 | .and.to.have.length(1); 1125 | 1126 | cat.color = 'grey'; 1127 | return ms1.update('cat', cat); 1128 | 1129 | }).then(function(updatedCat) { 1130 | cat = updatedCat; 1131 | return ms1.commit(); 1132 | 1133 | }).then(function() { 1134 | var op = { 1135 | op: 'replace', 1136 | path: '/cat/' + cat.id + '/color', 1137 | value: 'grey' 1138 | }; 1139 | expect(noopStore.commit.lastCall.args[0], 'update') 1140 | .to.have.deep.members([op]) 1141 | .and.to.have.length(1); 1142 | 1143 | }).then(function() { 1144 | return ms1.delete('cat', cat); 1145 | 1146 | }).then(function() { 1147 | return ms1.commit(); 1148 | 1149 | }).then(function() { 1150 | var op = { 1151 | op: 'remove', 1152 | path: '/cat/' + cat.id 1153 | }; 1154 | expect(noopStore.commit.lastCall.args[0], 'delete (' + JSON.stringify(noopStore.commit.lastCall.args[0]) + ')') 1155 | .to.have.deep.members([op]) 1156 | .and.to.have.length(1); 1157 | 1158 | done(); 1159 | }).catch(done); 1160 | }); 1161 | 1162 | it('should update uuids to actual ids', function(done) { 1163 | var ms1 = new MemoryDatastore(ES6Promise, undefined, undefined, simpleModels); 1164 | ms1.setUpstream(fakeStore); 1165 | 1166 | var cat = {color: 'blue', age: 3}; 1167 | ms1.create('cat', cat).then(function(createdCat) { 1168 | cat = createdCat; 1169 | return ms1.commit(); 1170 | 1171 | }).then(function() { 1172 | var q = new Query(ms1, 'cat', cat.id); 1173 | return ms1.find(q); 1174 | 1175 | }).then(function(foundCat) { 1176 | cat.id = 1; 1177 | expect(foundCat).to.deep.equal(cat); 1178 | var q = new Query(ms1, 'cat', cat.id); 1179 | return ms1.find(q); 1180 | 1181 | }).then(function(foundCat) { 1182 | expect(foundCat).to.deep.equal(cat); 1183 | 1184 | done(); 1185 | }).catch(done); 1186 | }); 1187 | 1188 | it('should fail if it receives a bad response from upstream'); 1189 | }); 1190 | }); 1191 | --------------------------------------------------------------------------------