├── .eslintignore ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── arango-datasource.js ├── arango-datasource.test.js ├── arango-document-datasource.js ├── arango-document-datasource.test.js ├── document-manager.js ├── document-manager.test.js ├── edge-manager.js ├── edge-manager.test.js ├── index.js ├── package-lock.json ├── package.json └── scripts ├── release.sh └── utils.inc /.eslintignore: -------------------------------------------------------------------------------- 1 | /build 2 | /node_modules 3 | /node_modules_prod 4 | /release -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "prettier"], 3 | "plugins": [ 4 | "prettier" 5 | ], 6 | "env": { 7 | "node": true, 8 | "es6": true, 9 | "jest": true 10 | }, 11 | "parserOptions": { 12 | "ecmaVersion": 2018 13 | }, 14 | "rules": { 15 | "prettier/prettier": ["error", {"singleQuote": true, "parser": "flow"}], 16 | "no-console": 1, 17 | "indent": [ 18 | "error", 19 | 2 20 | ], 21 | "camelcase": [ 22 | "error", 23 | { 24 | "properties": "never" 25 | } 26 | ], 27 | "block-spacing": "error", 28 | "max-lines": [ 29 | "warn", 30 | 150 31 | ], 32 | "max-depth": 1, 33 | "no-nested-ternary": "error", 34 | "no-unneeded-ternary": "error", 35 | "constructor-super": "error", 36 | "no-this-before-super": "error", 37 | "no-var": "warn", 38 | "prefer-template": "warn", 39 | "require-yield": 0 40 | } 41 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 10 9 | reviewers: 10 | - danwkennedy 11 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package 2 | 3 | on: [release] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | node-version: [10.x, 12.x] 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - name: npm install, build, and test 18 | run: | 19 | npm install 20 | npm test 21 | 22 | publish-npm: 23 | needs: test 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v1 27 | - uses: actions/setup-node@v1 28 | with: 29 | node-version: 12 30 | registry-url: https://registry.npmjs.org/ 31 | - run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | jobs: 5 | test: 6 | 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [10.x, 12.x, 14.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - name: npm install, build, and test 20 | run: | 21 | npm install 22 | npm test 23 | env: 24 | CI: true 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslint* 2 | *.test.js 3 | scripts 4 | coverage 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [0.8.0](https://github.com/danwkennedy/arango-datasouce/compare/0.7.0...0.8.0) (2020-09-17) 2 | 3 | 4 | ### Features 5 | 6 | * **arangojs:** upgrade to arangojs 7.0.1 ([ae3c8ff](https://github.com/danwkennedy/arango-datasouce/commit/ae3c8ff01c7b5fd10fcd5221d08049d6bb4b3e8f)) 7 | * **readme:** document how to use the package ([70fccf0](https://github.com/danwkennedy/arango-datasouce/commit/70fccf0bcb2538afaa13078532c5d40c5c4fde2a)) 8 | 9 | 10 | ### BREAKING CHANGES 11 | 12 | * **arangojs:** arangojs 7+ has quite a few breaking changes. We've addressed the issues that look like they need to be addressed but more testing is likely necessary to make sure. 13 | 14 | 15 | 16 | # [0.7.0](https://github.com/danwkennedy/arango-datasouce/compare/0.6.4...0.7.0) (2020-05-17) 17 | 18 | 19 | ### Features 20 | 21 | * **dataloader:** bump dataloader to 2.0.0 ([5f24845](https://github.com/danwkennedy/arango-datasouce/commit/5f248451ec39958ba57306d02050d955f4519217)) 22 | 23 | 24 | ### BREAKING CHANGES 25 | 26 | * **dataloader:** since Dataloader's implementation of LoadMany() has changed, the same behavior changes can happen here. 27 | 28 | 29 | 30 | ## [0.6.4](https://github.com/danwkennedy/arango-datasouce/compare/0.6.3...0.6.4) (2019-08-28) 31 | 32 | 33 | 34 | ## [0.6.3](https://github.com/danwkennedy/arango-datasouce/compare/0.6.2...0.6.3) (2019-08-28) 35 | 36 | 37 | 38 | ## [0.6.2](https://github.com/danwkennedy/arango-datasouce/compare/0.6.1...0.6.2) (2019-07-19) 39 | 40 | 41 | 42 | ## [0.6.1](https://github.com/danwkennedy/arango-datasouce/compare/0.6.0...0.6.1) (2019-07-02) 43 | 44 | 45 | ### Bug Fixes 46 | 47 | * **document datasource:** handle the correct return type for the AQL query ([69204fb](https://github.com/danwkennedy/arango-datasouce/commit/69204fb)) 48 | 49 | 50 | 51 | # [0.6.0](https://github.com/danwkennedy/arango-datasouce/compare/0.5.0...0.6.0) (2019-07-02) 52 | 53 | 54 | ### Features 55 | 56 | * **document datasource:** add exists and manyExists calls ([9eda7d4](https://github.com/danwkennedy/arango-datasouce/commit/9eda7d4)) 57 | 58 | 59 | 60 | # [0.5.0](https://github.com/danwkennedy/arango-datasouce/compare/0.4.0...0.5.0) (2019-06-14) 61 | 62 | 63 | ### Features 64 | 65 | * **edge manager:** add bulk operations ([b698949](https://github.com/danwkennedy/arango-datasouce/commit/b698949)) 66 | 67 | 68 | 69 | # [0.4.0](https://github.com/danwkennedy/arango-datasouce/compare/0.3.0...0.4.0) (2019-06-02) 70 | 71 | 72 | 73 | # [0.3.0](https://github.com/danwkennedy/arango-datasouce/compare/0.2.1...0.3.0) (2019-05-29) 74 | 75 | 76 | ### Features 77 | 78 | * **DocumentManager:** add a document manager ([d8d106b](https://github.com/danwkennedy/arango-datasouce/commit/d8d106b)) 79 | * **EdgeManager:** add an edge manager ([8b8078a](https://github.com/danwkennedy/arango-datasouce/commit/8b8078a)) 80 | 81 | 82 | 83 | ## [0.2.1](https://github.com/danwkennedy/arango-datasouce/compare/0.2.0...0.2.1) (2019-05-27) 84 | 85 | 86 | ### Bug Fixes 87 | 88 | * **document datasource:** unroll the array returned by the cursor. ([b495423](https://github.com/danwkennedy/arango-datasouce/commit/b495423)) 89 | 90 | 91 | 92 | # [0.2.0](https://github.com/danwkennedy/arango-datasouce/compare/0.1.0...0.2.0) (2019-05-14) 93 | 94 | 95 | ### Features 96 | 97 | * **npm:** add an npmignore file ([e5c68a3](https://github.com/danwkennedy/arango-datasouce/commit/e5c68a3)) 98 | 99 | 100 | 101 | # 0.1.0 (2019-05-14) 102 | 103 | 104 | ### Features 105 | 106 | * **datasource:** add datasource ([ca204df](https://github.com/danwkennedy/arango-datasouce/commit/ca204df)) 107 | * **document datasource:** add document datasource ([274a1d8](https://github.com/danwkennedy/arango-datasouce/commit/274a1d8)) 108 | * **eslint:** setup eslint ([f0e8b65](https://github.com/danwkennedy/arango-datasouce/commit/f0e8b65)) 109 | * **release:** automate releasing new versions ([49ec15f](https://github.com/danwkennedy/arango-datasouce/commit/49ec15f)) 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Daniel Kennedy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # arango-datasouce 2 | An implementation of Apollo's datasource for ArangoDb 3 | 4 | ## Installation 5 | 6 | ``` 7 | $ npm install arango-datasource 8 | ``` 9 | 10 | ## DataSources 11 | 12 | ### ArangoDataSource 13 | 14 | A "general purpose" datasource that's mainly suitable for querying the database using AQL. 15 | 16 | Requires passing the target database instance from `arango-js` 17 | 18 | ```js 19 | // index.js 20 | 21 | const { ArangoDataSource } = require('@danwkennedy/arango-datasource'); 22 | const { Database } = require('arango-js'); 23 | 24 | // initialize the db 25 | const database = new Database('http://my.database.url'); 26 | 27 | // initialize the server 28 | const server = new ApolloServer({ 29 | typeDefs, 30 | resolvers, 31 | cache, 32 | context, 33 | dataSources: () => ({ 34 | arangoDataSource: new ArangoDataSource(database) 35 | }) 36 | }); 37 | ``` 38 | 39 | Extend this class to create more targetted DataSources according to your needs: 40 | 41 | ```js 42 | // UserDataSource.js 43 | 44 | const { ArangoDataSource } = require('@danwkennedy/arango-datasource'); 45 | 46 | module.exports = class UserDataSource extends ArangoDataSource { 47 | 48 | // Pass the user collection to the DataSource 49 | constructor(db, collection) { 50 | super(db); 51 | this.collection = collection; 52 | } 53 | 54 | // Build the query and call super.query 55 | async getUsers() { 56 | const query = aql` 57 | FOR user in ${this.collection} 58 | return user 59 | `; 60 | 61 | return await this.query(query); 62 | } 63 | } 64 | ``` 65 | 66 | Basic query caching is available. Cache keys for queries are simply the query object's hash value using `object-hash`. This type of caching is mainly useful when using a persisted cache across machines (i.e. Redis instead of the default in memory cache) and works best for fetching common data that doesn't change very often. 67 | 68 | ### ArangoDocumentDataSource 69 | 70 | Uses the [DataLoader]() class to add batching and caching to fetching Arango Documents by their Id. This is especially useful as a `NodeDataSource` as ArangoDb's default Id structure prepends the collection name to the `_key` making it so you don't need to pass the target collection the document datasource. 71 | 72 | ```js 73 | // index.js 74 | 75 | const { ArangoDocumentDataSource } = require('@danwkennedy/arango-datasource'); 76 | const { Database } = require('arango-js'); 77 | 78 | // initialize the db 79 | const database = new Database('http://my.database.url'); 80 | 81 | // initialize the server 82 | const server = new ApolloServer({ 83 | typeDefs, 84 | resolvers, 85 | cache, 86 | context, 87 | dataSources: () => ({ 88 | NodeDataSource: new ArangoDocumentDataSource(database) 89 | }) 90 | }); 91 | 92 | // node/resolver.js 93 | 94 | module.exports = { 95 | Query: { 96 | node: async (_, { id }, { dataSources }) => { 97 | return dataSources.NodeDataSource.load(id); 98 | }, 99 | }, 100 | } 101 | 102 | ``` 103 | 104 | ## Managers 105 | 106 | DataSources fetch records from the database. Managers create/update/delete records from the database. 107 | 108 | ### DocumentManager 109 | 110 | Manages the lifecylc of documents in a document collection. 111 | 112 | ```js 113 | // index.js 114 | 115 | const { DocumentManager } = require('@danwkennedy/arango-datasource'); 116 | const { Database } = require('arango-js'); 117 | 118 | // initialize the db 119 | const database = new Database('http://my.database.url'); 120 | const userCollection = database.collection('users'); 121 | 122 | // initialize the server 123 | const server = new ApolloServer({ 124 | typeDefs, 125 | resolvers, 126 | cache, 127 | context, 128 | dataSources: () => ({ 129 | userDocumentManager: new DocumentManager(userCollection) 130 | }) 131 | }); 132 | ``` 133 | 134 | ### EdgeManager 135 | 136 | Manages the lifecycle of edges in a graph. 137 | 138 | ```js 139 | // index.js 140 | 141 | const { EdgeManager } = require('@danwkennedy/arango-datasource'); 142 | const { Database } = require('arango-js'); 143 | 144 | // initialize the db 145 | const database = new Database('http://my.database.url'); 146 | const userFavoriteFoodCollection = database.edgeCollection('user_favorite_food'); 147 | 148 | // initialize the server 149 | const server = new ApolloServer({ 150 | typeDefs, 151 | resolvers, 152 | cache, 153 | context, 154 | dataSources: () => ({ 155 | userDocumentManager: new EdgeManager(userFavoriteFoodCollection) 156 | }) 157 | }); 158 | ``` 159 | 160 | The main difference between the `EdgeManager` and the `DocumentManager` is the `EdgeManager` requires the `_to` and `_from` ids be passed. It also smooths over some difficulties with removing edges where we might not know the edge's id. In that case, we can pass the `_from` and `_to` ids and the manager will do the query to find the correct edge. 161 | -------------------------------------------------------------------------------- /arango-datasource.js: -------------------------------------------------------------------------------- 1 | const { DataSource } = require('apollo-datasource'); 2 | const hash = require('object-hash'); 3 | 4 | /** 5 | * An ArangoDb implementation of the Apollo DataSource. 6 | * 7 | * @class ArangoDataSource 8 | * @extends {DataSource} 9 | */ 10 | class ArangoDataSource extends DataSource { 11 | /** 12 | * Creates an instance of ArangoDataSource. 13 | * @param {Database} db 14 | * @memberof ArangoDataSource 15 | */ 16 | constructor(db) { 17 | super(); 18 | this.db = db; 19 | } 20 | 21 | /** 22 | * Initializes the DataSource. 23 | * 24 | * Called at the beginning of each request. 25 | * 26 | * @param {*} config A configuration object that provides access to the shared cache and request context. 27 | * @memberof ArangoDataSource 28 | */ 29 | initialize(config) { 30 | this.cache = config.cache; 31 | } 32 | 33 | /** 34 | * Query the database. 35 | * 36 | * Options: 37 | * useCache: check the cache first and update 38 | * 39 | * @param {*} query The query to use 40 | * @returns {*} A list of results that match the query 41 | * @memberof ArangoDataSource 42 | */ 43 | async query(query, { useCache } = { useCache: true }) { 44 | if (useCache) { 45 | const cachedValue = await this.queryCached(query); 46 | 47 | if (cachedValue) { 48 | return cachedValue; 49 | } 50 | } 51 | 52 | const cursor = await this.db.query(query); 53 | const result = await cursor.all(); 54 | 55 | if (useCache && result) { 56 | await this.addToCache(query, result); 57 | } 58 | 59 | return result; 60 | } 61 | 62 | /** 63 | * Compute the key for a given query 64 | * 65 | * @private 66 | * @param {*} query The query to compute the key for 67 | * @returns {string} A string key for the query 68 | * @memberof ArangoDataSource 69 | */ 70 | getCacheKeyForQuery(query) { 71 | return hash(query); 72 | } 73 | 74 | /** 75 | * Check the cache for previously saved results for the given query 76 | * 77 | * @private 78 | * @param {*} query The query to check for 79 | * @returns {*} The results saved to the cache 80 | * @memberof ArangoDataSource 81 | */ 82 | async queryCached(query) { 83 | const key = this.getCacheKeyForQuery(query); 84 | return this.cache.get(key); 85 | } 86 | 87 | /** 88 | * Add a result set to the cache using the query as a key 89 | * 90 | * @param {*} query The query to key by 91 | * @param {*} result The results to add to the cache 92 | * @memberof ArangoDataSource 93 | */ 94 | async addToCache(query, result) { 95 | const key = this.getCacheKeyForQuery(query); 96 | await this.cache.set(key, result); 97 | } 98 | } 99 | 100 | module.exports = { 101 | ArangoDataSource: ArangoDataSource, 102 | }; 103 | -------------------------------------------------------------------------------- /arango-datasource.test.js: -------------------------------------------------------------------------------- 1 | const { ArangoDataSource } = require('.'); 2 | const { aql } = require('arangojs'); 3 | const hash = require('object-hash'); 4 | 5 | describe('ArangDataSource', () => { 6 | test('it queries the db (cache miss)', async () => { 7 | const result = { results: 'output' }; 8 | const db = createDb(result); 9 | const config = createConfig(); 10 | const query = aql`DOCUMENT()`; 11 | 12 | const datasource = new ArangoDataSource(db); 13 | 14 | datasource.initialize(config); 15 | 16 | const output = await datasource.query(query); 17 | expect(output).toEqual(result); 18 | expect(db.query).toHaveBeenCalledTimes(1); 19 | expect(config.cache.set).toHaveBeenCalledTimes(1); 20 | expect(config.cache.set).toHaveBeenCalledWith(hash(query), result); 21 | }); 22 | 23 | test('it queries the db (cache hit)', async () => { 24 | const result = { results: 'output' }; 25 | const query = aql`DOCUMENT()`; 26 | const key = hash(query); 27 | const db = createDb(result); 28 | const config = createConfig({ 29 | [key]: result, 30 | }); 31 | 32 | const datasource = new ArangoDataSource(db); 33 | 34 | datasource.initialize(config); 35 | 36 | const output = await datasource.query(query); 37 | expect(output).toEqual(result); 38 | expect(db.query).toHaveBeenCalledTimes(0); 39 | expect(config.cache.set).toHaveBeenCalledTimes(0); 40 | expect(config.cache.get).toHaveBeenCalledTimes(1); 41 | expect(config.cache.get).toHaveReturnedWith(Promise.resolve(result)); 42 | }); 43 | 44 | test('it caches a successful query', async () => { 45 | const result = { results: 'output' }; 46 | const db = createDb(result); 47 | const config = createConfig(); 48 | const query = aql`DOCUMENT()`; 49 | 50 | const datasource = new ArangoDataSource(db); 51 | 52 | datasource.initialize(config); 53 | 54 | for (let i = 1; i <= 3; i++) { 55 | const output = await datasource.query(query); 56 | expect(output).toEqual(result); 57 | expect(db.query).toHaveBeenCalledTimes(1); 58 | 59 | expect(config.cache.get).toHaveBeenCalledTimes(i); 60 | expect(config.cache.get).toHaveReturnedWith(Promise.resolve(result)); 61 | 62 | expect(config.cache.set).toHaveBeenCalledTimes(1); 63 | expect(config.cache.set).toHaveBeenCalledWith(hash(query), result); 64 | } 65 | }); 66 | 67 | test('it ignores the cache', async () => { 68 | const result = { results: 'output' }; 69 | const db = createDb(result); 70 | const config = createConfig(); 71 | const query = aql`DOCUMENT()`; 72 | 73 | const datasource = new ArangoDataSource(db); 74 | 75 | datasource.initialize(config); 76 | 77 | const output = await datasource.query(query, { useCache: false }); 78 | expect(output).toEqual(result); 79 | expect(db.query).toHaveBeenCalledTimes(1); 80 | expect(config.cache.set).toHaveBeenCalledTimes(0); 81 | expect(config.cache.get).toHaveBeenCalledTimes(0); 82 | }); 83 | }); 84 | 85 | function createDb(queryResults) { 86 | return { 87 | query: jest.fn().mockResolvedValue({ 88 | all: () => queryResults, 89 | }), 90 | }; 91 | } 92 | 93 | function createConfig(cache = {}) { 94 | return { 95 | cache: { 96 | get: jest.fn().mockImplementation(async (key) => cache[key]), 97 | set: jest 98 | .fn() 99 | .mockImplementation(async (key, result) => (cache[key] = result)), 100 | }, 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /arango-document-datasource.js: -------------------------------------------------------------------------------- 1 | const { DataSource } = require('apollo-datasource'); 2 | const DataLoader = require('dataloader'); 3 | const { aql } = require('arangojs'); 4 | 5 | /** 6 | * An ArangoDb implementation of the Apollo DataSource. 7 | * 8 | * This DataSource is solely for resolving Arando Documents by their Id. 9 | * Requests are batched and cached per request. 10 | * 11 | * @class ArangoDocumentDataSource 12 | * @extends {DataSource} 13 | */ 14 | class ArangoDocumentDataSource extends DataSource { 15 | /** 16 | * Creates an instance of ArangoDocumentDataSource. 17 | * @param {Database} db 18 | * @memberof ArangoDocumentDataSource 19 | */ 20 | constructor(db) { 21 | super(); 22 | this.db = db; 23 | } 24 | 25 | /** 26 | * Initializes the DataSource. Called at the beginning of each request. 27 | * 28 | * @memberof ArangoDocumentDataSource 29 | */ 30 | initialize() { 31 | this.dataloader = new DataLoader((keys) => this.loadKeys(keys)); 32 | this.existsDataloader = new DataLoader((keys) => 33 | this.checkForExistence(keys) 34 | ); 35 | } 36 | 37 | /** 38 | * Gets a Document by its _id 39 | * 40 | * @param {string} id The _id to query for 41 | * @returns {any} The corresponding Document 42 | * @memberof ArangoDocumentDataSource 43 | */ 44 | async get(id) { 45 | return this.dataloader.load(id); 46 | } 47 | 48 | /** 49 | * Gets several Documents at once by their _id 50 | * Note: if a key does not exist, null will be returned 51 | * 52 | * @param {string[]} ids The _ids to query for 53 | * @returns {object|Error[]} The corresponding Documents. In the order their Ids were specified in the ids array. 54 | * @memberof ArangoDocumentDataSource 55 | */ 56 | async getMany(ids) { 57 | return this.dataloader.loadMany(ids); 58 | } 59 | 60 | /** 61 | * Returns whether a given document exists 62 | * 63 | * @param {String} id The id of the document 64 | * @returns {Boolean} Whether or not the document exists 65 | * @memberof ArangoDocumentDataSource 66 | */ 67 | async exists(id) { 68 | return this.existsDataloader.load(id); 69 | } 70 | 71 | /** 72 | * Returns whether a given list of documents exist 73 | * 74 | * @param {String[]} ids A list of document ids to check 75 | * @returns {Boolean[]} A corresponding list of booleans. The order matches the ids list. 76 | * @memberof ArangoDocumentDataSource 77 | */ 78 | async manyExist(ids) { 79 | return this.existsDataloader.loadMany(ids); 80 | } 81 | 82 | /** 83 | * Queries the database for the given keys. 84 | * 85 | * @param {string[]} keys The keys to query for 86 | * @private 87 | * @returns {*[]} The corresponding Documents. In the order their Ids were specified in the ids array 88 | * @memberof ArangoDocumentDataSource 89 | */ 90 | async loadKeys(keys) { 91 | const cursor = await this.db.query(aql`RETURN DOCUMENT(${keys})`); 92 | const [nodes] = await cursor.all(); 93 | 94 | return keys.map((key) => { 95 | const node = nodes.find((node) => node._id === key); 96 | 97 | if (node) { 98 | node.id = node._id; 99 | } 100 | 101 | return node; 102 | }); 103 | } 104 | 105 | /** 106 | * Checks whether the given keys exist 107 | * 108 | * @param {String[]} keys A list of keys to check for 109 | * @returns {Boolean[]} The corresponding list of booleans. The order matches the keys list. 110 | * @memberof ArangoDocumentDataSource 111 | */ 112 | async checkForExistence(keys) { 113 | const cursor = await this.db.query(aql` 114 | FOR key in ${keys} 115 | RETURN (DOCUMENT(key))._id 116 | `); 117 | const nodes = await cursor.all(); 118 | 119 | const output = []; 120 | for (const [index, id] of keys.entries()) { 121 | output[index] = nodes[index] === id; 122 | } 123 | 124 | return output; 125 | } 126 | } 127 | 128 | module.exports = { 129 | ArangoDocumentDataSource: ArangoDocumentDataSource, 130 | }; 131 | -------------------------------------------------------------------------------- /arango-document-datasource.test.js: -------------------------------------------------------------------------------- 1 | const { ArangoDocumentDataSource } = require('.'); 2 | 3 | describe('ArangDocumentDataSource', () => { 4 | test('it batches key loads', async () => { 5 | const results = [{ _id: '123' }, { _id: '456' }]; 6 | const db = createDb(results); 7 | const datasource = new ArangoDocumentDataSource(db); 8 | datasource.initialize(); 9 | 10 | const docs = await Promise.all([ 11 | datasource.get('123'), 12 | datasource.get('456'), 13 | ]); 14 | 15 | expect(docs).toEqual(results); 16 | expect(db.query).toHaveBeenCalledTimes(1); 17 | }); 18 | 19 | test('it caches key loads', async () => { 20 | const results = [{ _id: '123' }]; 21 | const db = createDb(results); 22 | const datasource = new ArangoDocumentDataSource(db); 23 | datasource.initialize(); 24 | 25 | for (let i = 0; i < 3; i++) { 26 | const doc = await datasource.get('123'); 27 | expect(db.query).toHaveBeenCalledTimes(1); 28 | expect(doc).toEqual(results[0]); 29 | } 30 | }); 31 | 32 | test('it returns docs for several keys', async () => { 33 | const results = [{ _id: '123' }, { _id: '456' }]; 34 | const db = createDb(results); 35 | const datasource = new ArangoDocumentDataSource(db); 36 | datasource.initialize(); 37 | 38 | const docs = await datasource.getMany(['456', '123']); 39 | 40 | expect(docs).toEqual(results.reverse()); 41 | expect(db.query).toHaveBeenCalledTimes(1); 42 | }); 43 | 44 | describe(`exists`, () => { 45 | test(`it returns true if a document exists`, async () => { 46 | const results = ['123']; 47 | const db = createExistsDb(results); 48 | const datasource = new ArangoDocumentDataSource(db); 49 | datasource.initialize(); 50 | 51 | const exists = await datasource.exists('123'); 52 | 53 | expect(exists).toBe(true); 54 | }); 55 | 56 | test(`it caches the results`, async () => { 57 | const results = ['123']; 58 | const db = createExistsDb(results); 59 | const datasource = new ArangoDocumentDataSource(db); 60 | datasource.initialize(); 61 | 62 | await datasource.exists('123'); 63 | await datasource.exists('123'); 64 | 65 | expect(db.query).toHaveBeenCalledTimes(1); 66 | }); 67 | 68 | test(`it batches the calls`, async () => { 69 | const results = ['123']; 70 | const db = createExistsDb(results); 71 | const datasource = new ArangoDocumentDataSource(db); 72 | datasource.initialize(); 73 | 74 | await Promise.all([datasource.exists('123'), datasource.exists('123')]); 75 | 76 | expect(db.query).toHaveBeenCalledTimes(1); 77 | }); 78 | }); 79 | 80 | describe(`manyExist`, () => { 81 | test(`it returns a list of document existence`, async () => { 82 | const results = ['123']; 83 | const db = createExistsDb(results); 84 | const datasource = new ArangoDocumentDataSource(db); 85 | datasource.initialize(); 86 | 87 | const exists = await datasource.manyExist(['123', '456']); 88 | 89 | expect(exists[0]).toBe(true); 90 | expect(exists[1]).toBe(false); 91 | }); 92 | 93 | test(`it caches the results`, async () => { 94 | const results = ['123']; 95 | const db = createExistsDb(results); 96 | const datasource = new ArangoDocumentDataSource(db); 97 | datasource.initialize(); 98 | 99 | await datasource.manyExist(['123']); 100 | await datasource.manyExist(['123']); 101 | 102 | expect(db.query).toHaveBeenCalledTimes(1); 103 | }); 104 | 105 | test(`it batches the calls`, async () => { 106 | const results = ['123']; 107 | const db = createExistsDb(results); 108 | const datasource = new ArangoDocumentDataSource(db); 109 | datasource.initialize(); 110 | 111 | await Promise.all([ 112 | datasource.manyExist(['123']), 113 | datasource.manyExist(['123']), 114 | ]); 115 | 116 | expect(db.query).toHaveBeenCalledTimes(1); 117 | }); 118 | }); 119 | }); 120 | 121 | function createDb(queryResults) { 122 | return { 123 | query: jest.fn().mockResolvedValue({ 124 | all: () => [queryResults], 125 | }), 126 | }; 127 | } 128 | 129 | function createExistsDb(queryResults) { 130 | return { 131 | query: jest.fn().mockResolvedValue({ 132 | all: () => queryResults, 133 | }), 134 | }; 135 | } 136 | -------------------------------------------------------------------------------- /document-manager.js: -------------------------------------------------------------------------------- 1 | const { DataSource } = require('apollo-datasource'); 2 | 3 | /** 4 | * Manages ArangoDb documents 5 | * 6 | * @class DocumentManager 7 | * @extends {DataSource} 8 | */ 9 | class DocumentManager extends DataSource { 10 | /** 11 | * Creates an instance of DocumentManager. 12 | * @param {Collection} collection The ArangoDb document collection to manage 13 | * @memberof DocumentManager 14 | */ 15 | constructor(collection) { 16 | super(); 17 | this.collection = collection; 18 | } 19 | 20 | /** 21 | * Create a new document and add it to the collection 22 | * 23 | * @param {*} input 24 | * @returns {*} An object with the created document as the new field 25 | * @memberof DocumentManager 26 | */ 27 | async create(input) { 28 | const { new: document } = await this.collection.save(input, { 29 | returnNew: true, 30 | }); 31 | 32 | return { new: document }; 33 | } 34 | 35 | /** 36 | * Replace all of a document's values 37 | * 38 | * @param {String} id The id of the document to update 39 | * @param {*} input An object with the values to replace 40 | * @returns {*} An object with the old and new versions of the document 41 | * @memberof DocumentManager 42 | */ 43 | async replace(id, input) { 44 | const results = await this.collection.replace(id, input, { 45 | returnNew: true, 46 | returnOld: true, 47 | }); 48 | 49 | return { 50 | new: results.new, 51 | old: results.old, 52 | }; 53 | } 54 | 55 | /** 56 | * Updates a subset of a document's values. Values not passed as input are left unchanged. 57 | * 58 | * @param {String} id The id of the document to update 59 | * @param {*} input An object with the values to update 60 | * @param {*} [{ keepNull = false, mergeObjects = false }={}] A set of options: 61 | * keepNull: if true null values are left as null. If false, the field is removed. 62 | * @returns An object with the old and new versions of the document 63 | * @memberof DocumentManager 64 | */ 65 | async update(id, input, { keepNull = false, mergeObjects = false } = {}) { 66 | const results = await this.collection.update(id, input, { 67 | keepNull, 68 | mergeObjects, 69 | returnNew: true, 70 | returnOld: true, 71 | }); 72 | 73 | return { 74 | new: results.new, 75 | old: results.old, 76 | }; 77 | } 78 | 79 | /** 80 | * Removes a document from the collection. 81 | * 82 | * @param {String} id The id of the document to remove 83 | * @returns An object with the old version of the document 84 | * @memberof DocumentManager 85 | */ 86 | async remove(id) { 87 | const { old: document } = await this.collection.remove(id, { 88 | returnOld: true, 89 | }); 90 | return { old: document }; 91 | } 92 | } 93 | 94 | module.exports = { 95 | DocumentManager: DocumentManager, 96 | }; 97 | -------------------------------------------------------------------------------- /document-manager.test.js: -------------------------------------------------------------------------------- 1 | const { DocumentManager } = require(`.`); 2 | 3 | class CollectionMock { 4 | constructor() { 5 | this.save = jest.fn(); 6 | this.replace = jest.fn(); 7 | this.update = jest.fn(); 8 | this.remove = jest.fn(); 9 | } 10 | 11 | reset() { 12 | this.save.mockReset(); 13 | this.replace.mockReset(); 14 | this.update.mockReset(); 15 | this.remove.mockReset(); 16 | } 17 | } 18 | 19 | describe(`DocumentManager`, () => { 20 | const collectionMock = new CollectionMock(); 21 | const manager = new DocumentManager(collectionMock); 22 | 23 | afterEach(() => { 24 | collectionMock.reset(); 25 | }); 26 | 27 | test(`it calls collection.save()`, async () => { 28 | const doc = { name: 'abc' }; 29 | collectionMock.save.mockResolvedValue({ new: doc }); 30 | 31 | const output = await manager.create(doc); 32 | expect(output).toEqual({ new: doc }); 33 | expect(collectionMock.save).toHaveBeenCalledTimes(1); 34 | expect(collectionMock.save).toHaveBeenCalledWith(doc, { 35 | returnNew: true, 36 | }); 37 | }); 38 | 39 | test(`it calls collection.replace()`, async () => { 40 | const oldDoc = { name: 'abc' }; 41 | const newDoc = { name: 'xyz' }; 42 | collectionMock.replace.mockResolvedValue({ new: newDoc, old: oldDoc }); 43 | 44 | const output = await manager.replace('123', newDoc); 45 | expect(output).toEqual({ new: newDoc, old: oldDoc }); 46 | expect(collectionMock.replace).toHaveBeenCalledTimes(1); 47 | expect(collectionMock.replace).toHaveBeenCalledWith('123', newDoc, { 48 | returnNew: true, 49 | returnOld: true, 50 | }); 51 | }); 52 | 53 | test(`it calls collection.update()`, async () => { 54 | const oldDoc = { name: 'abc' }; 55 | const newDoc = { name: 'xyz' }; 56 | collectionMock.update.mockResolvedValue({ new: newDoc, old: oldDoc }); 57 | 58 | const output = await manager.update('123', newDoc); 59 | expect(output).toEqual({ new: newDoc, old: oldDoc }); 60 | expect(collectionMock.update).toHaveBeenCalledTimes(1); 61 | expect(collectionMock.update).toHaveBeenCalledWith('123', newDoc, { 62 | returnNew: true, 63 | returnOld: true, 64 | mergeObjects: false, 65 | keepNull: false, 66 | }); 67 | }); 68 | 69 | test(`it calls collection.remove()`, async () => { 70 | const doc = { name: 'abc' }; 71 | collectionMock.remove.mockResolvedValue({ old: doc }); 72 | 73 | const output = await manager.remove('123'); 74 | expect(output).toEqual({ old: doc }); 75 | expect(collectionMock.remove).toHaveBeenCalledTimes(1); 76 | expect(collectionMock.remove).toHaveBeenCalledWith('123', { 77 | returnOld: true, 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /edge-manager.js: -------------------------------------------------------------------------------- 1 | const { DataSource } = require('apollo-datasource'); 2 | const { aql } = require('arangojs'); 3 | 4 | /** 5 | * Manages ArangoDb edges 6 | * 7 | * @class EdgeManager 8 | * @extends {DataSource} 9 | */ 10 | class EdgeManager extends DataSource { 11 | /** 12 | * Creates an instance of EdgeManager. 13 | * @param {Database} db An instance of an ArangoDb Database 14 | * @param {Collection} collection An instance of an ArangoDb Collection 15 | * @memberof EdgeManager 16 | */ 17 | constructor(db, collection) { 18 | super(); 19 | this.db = db; 20 | this.collection = collection; 21 | } 22 | 23 | /** 24 | * Creates a new edge between two documents 25 | * 26 | * @param {String} from The id of the origin document 27 | * @param {String} to The id of the destination document 28 | * @param {Object} [properties={}] An object with key values describing the relationship 29 | * @returns An object with the new version of the edge 30 | * @memberof EdgeManager 31 | */ 32 | async create(from, to, properties = {}) { 33 | const { new: edge } = await this.collection.save( 34 | { _from: from, _to: to, ...properties }, 35 | { 36 | returnNew: true, 37 | } 38 | ); 39 | return { new: edge }; 40 | } 41 | 42 | /** 43 | * Create a one to many to one relationship. This is useful when bulk operations are possible. 44 | * 45 | * @param {String} fromId The id of the single document 46 | * @param {String[]} toIds An array of ids to the many documents 47 | * @param {Function} [getProperties=() => ({})] A function that resolves the extra properties of each edge 48 | * @param {Object} [opts={}] Options to pass to the create call 49 | * @returns {Object[]} An array of the created edges 50 | * @memberof EdgeManager 51 | */ 52 | async createOneToMany(fromId, toIds, getProperties = () => ({}), opts = {}) { 53 | const edges = toIds.map((toId) => ({ 54 | _from: fromId, 55 | _to: toId, 56 | ...getProperties(fromId, toId), 57 | })); 58 | 59 | return this.createMany(edges, opts); 60 | } 61 | 62 | /** 63 | * Create a many to one relationship. This is useful when bulk operations are possible. 64 | * 65 | * @param {String[]} fromIds The ids of the many documents 66 | * @param {String} toId The id of the single document 67 | * @param {Function} [getProperties=() => ({})] A function that resolves the extra properties of each edge 68 | * @param {Object} [opts={}] Options to pass to the create call 69 | * @returns {Object[]} An array of the created edges 70 | * @memberof EdgeManager 71 | */ 72 | async createManyToOne(fromIds, toId, getProperties = () => ({}), opts = {}) { 73 | const edges = fromIds.map((fromId) => ({ 74 | _from: fromId, 75 | _to: toId, 76 | ...getProperties(fromId, toId), 77 | })); 78 | return this.createMany(edges, opts); 79 | } 80 | 81 | /** 82 | * Create many edges in a single database call. This is useful when bulk operations are possible. 83 | * 84 | * @param {Object[]} edges The edges to create 85 | * @returns {Object[]} The created edges 86 | * @memberof EdgeManager 87 | */ 88 | async createMany(edges) { 89 | const query = aql` 90 | FOR edge in ${edges} 91 | UPSERT { _from: edge._from, _to: edge._to } 92 | INSERT edge 93 | UPDATE {} 94 | IN ${aql.literal(this.collection.name)} 95 | RETURN NEW 96 | `; 97 | 98 | return this.db.query(query); 99 | } 100 | 101 | /** 102 | * Removes an edge from between two documents 103 | * 104 | * @param {String} from The id of the origin document 105 | * @param {String} to The id of the destination document 106 | * @returns An object with the old version of the edge 107 | * @memberof EdgeManager 108 | */ 109 | async remove(from, to) { 110 | const edge = await this.db.query(aql` 111 | FOR edge in ${this.collection} 112 | FILTER edge._from == ${from} 113 | FILTER edge._to == ${to} 114 | REMOVE edge._key IN ${this.collection} 115 | RETURN OLD 116 | `); 117 | 118 | if (edge === null) { 119 | throw new Error(`Couldn't find edge`); 120 | } 121 | 122 | return { old: edge }; 123 | } 124 | 125 | /** 126 | * Removes multiple edges by filtering on their _from and _to handles. This is useful when bulk operations are possible. 127 | * 128 | * @param {*} edges 129 | * @returns 130 | * @memberof EdgeManager 131 | */ 132 | async removeMany(edges) { 133 | const query = aql` 134 | FOR edge IN ${edges} 135 | FOR collectionEdge IN ${this.collection} 136 | FILTER collectionEdge._from == edge._from 137 | FILTER collectionEdge._to == edge._to 138 | REMOVE collectionEdge IN ${aql.literal(this.collection.name)} 139 | RETURN OLD 140 | `; 141 | 142 | return this.db.query(query); 143 | } 144 | } 145 | 146 | module.exports = { 147 | EdgeManager: EdgeManager, 148 | }; 149 | -------------------------------------------------------------------------------- /edge-manager.test.js: -------------------------------------------------------------------------------- 1 | const { EdgeManager } = require(`.`); 2 | 3 | class EdgeCollectionMock { 4 | constructor() { 5 | this.save = jest.fn(); 6 | } 7 | 8 | reset() { 9 | this.save.mockReset(); 10 | } 11 | } 12 | 13 | class DbMock { 14 | constructor() { 15 | this.query = jest.fn(); 16 | } 17 | 18 | reset() { 19 | this.query.mockReset(); 20 | } 21 | } 22 | 23 | describe(`EdgeManager`, () => { 24 | const collectionMock = new EdgeCollectionMock(); 25 | const dbMock = new DbMock(); 26 | const manager = new EdgeManager(dbMock, collectionMock); 27 | 28 | afterEach(() => { 29 | collectionMock.reset(); 30 | dbMock.reset(); 31 | }); 32 | 33 | describe(`EdgeManager.create`, () => { 34 | test(`it calls collection.save()`, async () => { 35 | const from = 'abc'; 36 | const to = 'xyz'; 37 | const properties = { test: true }; 38 | const doc = { _from: from, _to: to, ...properties }; 39 | collectionMock.save.mockResolvedValue({ new: doc }); 40 | 41 | const output = await manager.create(from, to, properties); 42 | expect(output).toEqual({ new: doc }); 43 | expect(collectionMock.save).toHaveBeenCalledTimes(1); 44 | expect(collectionMock.save).toHaveBeenCalledWith(doc, { 45 | returnNew: true, 46 | }); 47 | }); 48 | }); 49 | 50 | describe(`EdgeManager.remove`, () => { 51 | test(`it calls db.query()`, async () => { 52 | const edge = { name: 'abc' }; 53 | dbMock.query.mockResolvedValue(edge); 54 | 55 | const output = await manager.remove('123'); 56 | expect(output).toEqual({ old: edge }); 57 | expect(dbMock.query).toHaveBeenCalledTimes(1); 58 | }); 59 | 60 | test(`it throws when the edge doesn't exist`, async () => { 61 | dbMock.query.mockResolvedValue(null); 62 | await expect(manager.remove('123')).rejects.toThrow(); 63 | }); 64 | }); 65 | 66 | describe(`EdgeManager.createMany`, () => { 67 | test(`it calls db.query`, async () => { 68 | await manager.createMany([{ _from: '123', _to: '123', test: true }]); 69 | expect(dbMock.query).toHaveBeenCalledTimes(1); 70 | }); 71 | }); 72 | 73 | describe(`EdgeManager.createOneToMany`, () => { 74 | test(`it calls getProperties() for every edge`, async () => { 75 | const getProperties = jest.fn(); 76 | await manager.createOneToMany('123', ['456', '789'], getProperties); 77 | 78 | expect(getProperties).toHaveBeenCalledTimes(2); 79 | expect(getProperties).toHaveBeenNthCalledWith(1, '123', '456'); 80 | expect(getProperties).toHaveBeenNthCalledWith(2, '123', '789'); 81 | }); 82 | 83 | test(`it calls db.query`, async () => { 84 | await manager.createOneToMany('123', ['456', '789']); 85 | expect(dbMock.query).toHaveBeenCalledTimes(1); 86 | }); 87 | }); 88 | 89 | describe(`EdgeManager.createManyToOne`, () => { 90 | test(`it calls getProperties() for every edge`, async () => { 91 | const getProperties = jest.fn(); 92 | await manager.createManyToOne(['456', '789'], '123', getProperties); 93 | 94 | expect(getProperties).toHaveBeenCalledTimes(2); 95 | expect(getProperties).toHaveBeenNthCalledWith(1, '456', '123'); 96 | expect(getProperties).toHaveBeenNthCalledWith(2, '789', '123'); 97 | }); 98 | 99 | test(`it calls db.query`, async () => { 100 | await manager.createManyToOne(['456', '789'], '123'); 101 | expect(dbMock.query).toHaveBeenCalledTimes(1); 102 | }); 103 | }); 104 | 105 | describe(`EdgeManager.removeMany`, () => { 106 | test(`it calls db.query`, async () => { 107 | await manager.removeMany([{ _from: '123', _to: '123', test: true }]); 108 | expect(dbMock.query).toHaveBeenCalledTimes(1); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./arango-datasource'), 3 | ...require('./arango-document-datasource'), 4 | ...require('./document-manager'), 5 | ...require('./edge-manager'), 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@danwkennedy/arango-datasource", 3 | "version": "0.8.0", 4 | "description": "An implentation of Apollo's datasources for ArangoDb", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "jest", 8 | "coverage": "jest --coverage", 9 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/danwkennedy/arango-datasouce.git" 14 | }, 15 | "keywords": [ 16 | "arangodb", 17 | "apollo", 18 | "graphql", 19 | "datasource" 20 | ], 21 | "author": "Daniel Kennedy", 22 | "license": "MIT", 23 | "bugs": { 24 | "url": "https://github.com/danwkennedy/arango-datasouce/issues" 25 | }, 26 | "homepage": "https://github.com/danwkennedy/arango-datasouce#readme", 27 | "dependencies": { 28 | "apollo-datasource": "^0.9.0", 29 | "arangojs": "^7.0.1", 30 | "dataloader": "^2.0.0", 31 | "object-hash": "^2.0.3" 32 | }, 33 | "devDependencies": { 34 | "conventional-changelog-cli": "^2.0.34", 35 | "eslint": "^7.0.0", 36 | "eslint-config-prettier": "^8.3.0", 37 | "eslint-plugin-prettier": "^4.0.0", 38 | "git-hooks": "^1.1.10", 39 | "jest": "^27.0.1", 40 | "prettier": "^2.0.5" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ARG_DEFS=( 4 | "--version=(.*)" 5 | ) 6 | 7 | function run { 8 | cd ../ 9 | 10 | echo "-- Running tests" 11 | 12 | npm test 13 | 14 | echo "-- Updating version" 15 | 16 | replaceJsonProp "package.json" "version" "$VERSION" 17 | 18 | echo "-- Generating changelog" 19 | 20 | npm run changelog 21 | 22 | echo "-- Committing, tagging and pushing package.json and CHANGELOG.md" 23 | git commit package.json CHANGELOG.md -m "release: version $VERSION" 24 | git tag -f $VERSION 25 | git push -q origin master 26 | git push -q origin $VERSION 27 | 28 | echo "-- Version $VERSION pushed successfully" 29 | } 30 | 31 | source $(dirname $0)/utils.inc -------------------------------------------------------------------------------- /scripts/utils.inc: -------------------------------------------------------------------------------- 1 | # bash utils from angularjs 2 | 3 | # This file provides: 4 | # - a default control flow 5 | # * initializes the environment 6 | # * able to mock "git push" in your script and in all sub scripts 7 | # * call a function in your script based on the arguments 8 | # - named argument parsing and automatic generation of the "usage" for your script 9 | # - intercepting "git push" in your script and all sub scripts 10 | # - utility functions 11 | # 12 | # Usage: 13 | # - define the variable ARGS_DEF (see below) with the arguments for your script 14 | # - include this file using `source utils.inc` at the end of your script. 15 | # 16 | # Default control flow: 17 | # 0. Set the current directory to the directory of the script. By this 18 | # the script can be called from anywhere. 19 | # 1. Parse the named arguments 20 | # 2. If the parameter "git_push_dryrun" is set, all calls the `git push` in this script 21 | # or in child scripts will be intercepted so that the `--dry-run` and `--porcelain` is added 22 | # to show what the push would do but not actually do it. 23 | # 3. If the parameter "verbose" is set, the `-x` flag will be set in bash. 24 | # 4. The function "init" will be called if it exists 25 | # 5. If the parameter "action" is set, it will call the function with the name of that parameter. 26 | # Otherwise the function "run" will be called. 27 | # 28 | # Named Argument Parsing: 29 | # - The variable ARGS_DEF defines the valid command arguments 30 | # * Required args syntax: --paramName=paramRegex 31 | # * Optional args syntax: [--paramName=paramRegex] 32 | # * e.g. ARG_DEFS=("--required_param=(.+)" "[--optional_param=(.+)]") 33 | # - Checks that: 34 | # * all arguments match to an entry in ARGS_DEF 35 | # * all required arguments are present 36 | # * all arguments match their regex 37 | # - Afterwards, every paramter value will be stored in a variable 38 | # with the name of the parameter in upper case (with dash converted to underscore). 39 | # 40 | # Special arguments that are always available: 41 | # - "--action=.*": This parameter will be used to dispatch to a function with that name when the 42 | # script is started 43 | # - "--git_push_dryrun=true": This will intercept all calls to `git push` in this script 44 | # or in child scripts so that the `--dry-run` and `--porcelain` is added 45 | # to show what the push would do but not actually do it. 46 | # - "--verbose=true": This will set the `-x` flag in bash so that all calls will be logged 47 | # 48 | # Utility functions: 49 | # - readJsonProp 50 | # - replaceJsonProp 51 | # - resolveDir 52 | # - getVar 53 | # - serVar 54 | # - isFunction 55 | 56 | # always stop on errors 57 | set -e 58 | 59 | function usage { 60 | echo "Usage: ${0} ${ARG_DEFS[@]}" 61 | exit 1 62 | } 63 | 64 | 65 | function parseArgs { 66 | local REQUIRED_ARG_NAMES=() 67 | 68 | # -- helper functions 69 | function varName { 70 | # everything to upper case and dash to underscore 71 | echo ${1//-/_} | tr '[:lower:]' '[:upper:]' 72 | } 73 | 74 | function readArgDefs { 75 | local ARG_DEF 76 | local AD_OPTIONAL 77 | local AD_NAME 78 | local AD_RE 79 | 80 | # -- helper functions 81 | function parseArgDef { 82 | local ARG_DEF_REGEX="(\[?)--([^=]+)=(.*)" 83 | if [[ ! $1 =~ $ARG_DEF_REGEX ]]; then 84 | echo "Internal error: arg def has wrong format: $ARG_DEF" 85 | exit 1 86 | fi 87 | AD_OPTIONAL="${BASH_REMATCH[1]}" 88 | AD_NAME="${BASH_REMATCH[2]}" 89 | AD_RE="${BASH_REMATCH[3]}" 90 | if [[ $AD_OPTIONAL ]]; then 91 | # Remove last bracket for optional args. 92 | # Can't put this into the ARG_DEF_REGEX somehow... 93 | AD_RE=${AD_RE%?} 94 | fi 95 | } 96 | 97 | # -- run 98 | for ARG_DEF in "${ARG_DEFS[@]}" 99 | do 100 | parseArgDef $ARG_DEF 101 | 102 | local AD_NAME_UPPER=$(varName $AD_NAME) 103 | setVar "${AD_NAME_UPPER}_OPTIONAL" "$AD_OPTIONAL" 104 | setVar "${AD_NAME_UPPER}_RE" "$AD_RE" 105 | if [[ ! $AD_OPTIONAL ]]; then 106 | REQUIRED_ARG_NAMES+=($AD_NAME) 107 | fi 108 | done 109 | } 110 | 111 | function readAndValidateArgs { 112 | local ARG_NAME 113 | local ARG_VALUE 114 | local ARG_NAME_UPPER 115 | 116 | # -- helper functions 117 | function parseArg { 118 | local ARG_REGEX="--([^=]+)=?(.*)" 119 | 120 | if [[ ! $1 =~ $ARG_REGEX ]]; then 121 | echo "Can't parse argument $i" 122 | usage 123 | fi 124 | 125 | ARG_NAME="${BASH_REMATCH[1]}" 126 | ARG_VALUE="${BASH_REMATCH[2]}" 127 | ARG_NAME_UPPER=$(varName $ARG_NAME) 128 | } 129 | 130 | function validateArg { 131 | local AD_RE=$(getVar ${ARG_NAME_UPPER}_RE) 132 | 133 | if [[ ! $AD_RE ]]; then 134 | echo "Unknown option: $ARG_NAME" 135 | usage 136 | fi 137 | 138 | if [[ ! $ARG_VALUE =~ ^${AD_RE}$ ]]; then 139 | echo "Wrong format: $ARG_NAME" 140 | usage; 141 | fi 142 | 143 | # validate that the "action" option points to a valid function 144 | if [[ $ARG_NAME == "action" ]] && ! isFunction $ARG_VALUE; then 145 | echo "No action $ARG_VALUE defined in this script" 146 | usage; 147 | fi 148 | } 149 | 150 | # -- run 151 | for i in "$@" 152 | do 153 | parseArg $i 154 | validateArg 155 | setVar "${ARG_NAME_UPPER}" "$ARG_VALUE" 156 | done 157 | } 158 | 159 | function checkMissingArgs { 160 | local ARG_NAME 161 | for ARG_NAME in "${REQUIRED_ARG_NAMES[@]}" 162 | do 163 | ARG_VALUE=$(getVar $(varName $ARG_NAME)) 164 | 165 | if [[ ! $ARG_VALUE ]]; then 166 | echo "Missing: $ARG_NAME" 167 | usage; 168 | fi 169 | done 170 | } 171 | 172 | # -- run 173 | readArgDefs 174 | readAndValidateArgs "$@" 175 | checkMissingArgs 176 | 177 | } 178 | 179 | # getVar(varName) 180 | function getVar { 181 | echo ${!1} 182 | } 183 | 184 | # setVar(varName, varValue) 185 | function setVar { 186 | eval "$1=\"$2\"" 187 | } 188 | 189 | # isFunction(name) 190 | # - to be used in an if, so return 0 if successful and 1 if not! 191 | function isFunction { 192 | if [[ $(type -t $1) == "function" ]]; then 193 | return 0 194 | else 195 | return 1 196 | fi 197 | } 198 | 199 | # readJsonProp(jsonFile, property) 200 | # - restriction: property needs to be on an own line! 201 | function readJsonProp { 202 | echo $(sed -En 's/.*"'$2'"[ ]*:[ ]*"(.*)".*/\1/p' $1) 203 | } 204 | 205 | # replaceJsonProp(jsonFile, property, newValue) 206 | # - note: propertyRegex will be automatically placed into a 207 | # capturing group! -> all other groups start at index 2! 208 | function replaceJsonProp { 209 | replaceInFile $1 "\"$2\": \".*?\"" "\"$2\": \"$3\"" 210 | } 211 | 212 | # replaceInFile(file, findPattern, replacePattern) 213 | function replaceInFile { 214 | perl -pi -e "s/$2/$3/g;" $1 215 | } 216 | 217 | # resolveDir(relativeDir) 218 | # - resolves a directory relative to the current script 219 | function resolveDir { 220 | echo $(cd $SCRIPT_DIR; cd $1; pwd) 221 | } 222 | 223 | function git_push_dryrun_proxy { 224 | echo "## git push dryrun proxy enabled!" 225 | export ORIGIN_GIT=$(which git) 226 | 227 | function git { 228 | local ARGS=("$@") 229 | local RC 230 | if [[ $1 == "push" ]]; then 231 | ARGS+=("--dry-run" "--porcelain") 232 | echo "####### START GIT PUSH DRYRUN #######" 233 | echo "${ARGS[@]}" 234 | fi 235 | if [[ $1 == "commit" ]]; then 236 | echo "${ARGS[@]}" 237 | fi 238 | $ORIGIN_GIT "${ARGS[@]}" 239 | RC=$? 240 | if [[ $1 == "push" ]]; then 241 | echo "####### END GIT PUSH DRYRUN #######" 242 | fi 243 | return $RC 244 | } 245 | 246 | export -f git 247 | } 248 | 249 | function main { 250 | # normalize the working dir to the directory of the script 251 | cd $(dirname $0);SCRIPT_DIR=$(pwd) 252 | 253 | ARG_DEFS+=("[--git-push-dryrun=(true|false)]" "[--verbose=(true|false)]") 254 | parseArgs "$@" 255 | 256 | # --git_push_dryrun argument 257 | if [[ $GIT_PUSH_DRYRUN == "true" ]]; then 258 | git_push_dryrun_proxy 259 | fi 260 | 261 | # --verbose argument 262 | if [[ $VERBOSE == "true" ]]; then 263 | set -x 264 | fi 265 | 266 | if isFunction init; then 267 | init "$@" 268 | fi 269 | 270 | # jump to the function denoted by the --action argument, 271 | # otherwise call the "run" function 272 | if [[ $ACTION ]]; then 273 | $ACTION "$@" 274 | else 275 | run "$@" 276 | fi 277 | } 278 | 279 | 280 | main "$@" --------------------------------------------------------------------------------