├── .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 | [](https://waffle.io/yahoo/elide-js)
4 | [](https://travis-ci.org/yahoo/elide-js) [](https://badge.fury.io/js/elide-js) [](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 |
--------------------------------------------------------------------------------